mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
5 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0ec49c90 | ||
|
|
077d160343 | ||
|
|
b5ee77d8b8 | ||
|
|
a589b0dfc4 | ||
|
|
908c2f50d7 |
@@ -5,6 +5,215 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.22.1] - 2025-12-02 - Display Configuration
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **DisplayConfigSchema (0.22.1)**
|
||||||
|
- New configuration schema for display settings in `src/shared/constants/config.ts`
|
||||||
|
- `showStats: boolean` (default: true) - toggle statistics display in chat
|
||||||
|
- `showToolCalls: boolean` (default: true) - toggle tool calls display in chat
|
||||||
|
- `theme: "dark" | "light"` (default: "dark") - color theme for TUI
|
||||||
|
- `bellOnComplete: boolean` (default: false) - ring terminal bell on completion
|
||||||
|
- `progressBar: boolean` (default: true) - toggle progress bar display
|
||||||
|
- Integrated into main ConfigSchema with `.default({})`
|
||||||
|
- Exported `DisplayConfig` type from config module
|
||||||
|
|
||||||
|
- **Theme Utilities (0.22.1)**
|
||||||
|
- New `theme.ts` utility in `src/tui/utils/theme.ts`
|
||||||
|
- `Theme` type: "dark" | "light"
|
||||||
|
- `ColorScheme` interface with semantic colors (primary, secondary, success, warning, error, info, muted)
|
||||||
|
- Dark theme colors: cyan primary, blue secondary, black background, white foreground
|
||||||
|
- Light theme colors: blue primary, cyan secondary, white background, black foreground
|
||||||
|
- `getColorScheme()` - get color scheme for theme
|
||||||
|
- `getStatusColor()` - dynamic colors for status (ready, thinking, error, tool_call, awaiting_confirmation)
|
||||||
|
- `getRoleColor()` - dynamic colors for message roles (user, assistant, system, tool)
|
||||||
|
- `getContextColor()` - dynamic colors for context usage (green <60%, yellow 60-79%, red ≥80%)
|
||||||
|
|
||||||
|
- **Bell Notification (0.22.1)**
|
||||||
|
- New `bell.ts` utility in `src/tui/utils/bell.ts`
|
||||||
|
- `ringBell()` function for terminal bell notification
|
||||||
|
- Uses ASCII bell character (\u0007) via stdout
|
||||||
|
- Triggered when status changes to "ready" if `bellOnComplete` enabled
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **StatusBar Component**
|
||||||
|
- Added `theme?: Theme` prop (default: "dark")
|
||||||
|
- Uses `getStatusColor()` for dynamic status indicator colors
|
||||||
|
- Uses `getContextColor()` for dynamic context usage colors
|
||||||
|
- Theme-aware color scheme throughout component
|
||||||
|
|
||||||
|
- **Chat Component**
|
||||||
|
- Added `theme?: Theme` prop (default: "dark")
|
||||||
|
- Added `showStats?: boolean` prop (default: true)
|
||||||
|
- Added `showToolCalls?: boolean` prop (default: true)
|
||||||
|
- Created `MessageComponentProps` interface for consistent prop passing
|
||||||
|
- All message subcomponents (UserMessage, AssistantMessage, ToolMessage, SystemMessage) now theme-aware
|
||||||
|
- Uses `getRoleColor()` for dynamic message role colors
|
||||||
|
- Stats conditionally displayed based on `showStats`
|
||||||
|
- Tool calls conditionally displayed based on `showToolCalls`
|
||||||
|
- ThinkingIndicator now theme-aware
|
||||||
|
|
||||||
|
- **App Component**
|
||||||
|
- Added `theme?: "dark" | "light"` prop (default: "dark")
|
||||||
|
- Added `showStats?: boolean` prop (default: true)
|
||||||
|
- Added `showToolCalls?: boolean` prop (default: true)
|
||||||
|
- Added `bellOnComplete?: boolean` prop (default: false)
|
||||||
|
- Extended `ExtendedAppProps` interface with display config props
|
||||||
|
- Passes display config to StatusBar and Chat components
|
||||||
|
- Added useEffect hook for bell notification on status change to "ready"
|
||||||
|
- Imports `ringBell` utility
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Total tests: 1571 (was 1525, +46 new tests)
|
||||||
|
- New test files:
|
||||||
|
- `display-config.test.ts` with 20 tests (schema validation)
|
||||||
|
- `theme.test.ts` with 24 tests (color scheme, status/role/context colors)
|
||||||
|
- `bell.test.ts` with 2 tests (stdout write verification)
|
||||||
|
- Coverage: 97.68% lines, 91.38% branches, 98.97% functions, 97.68% statements
|
||||||
|
- 0 ESLint errors, 0 warnings
|
||||||
|
- Build successful with no TypeScript errors
|
||||||
|
- 3 new utility files created, 4 components updated
|
||||||
|
- All display options configurable via DisplayConfigSchema
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
This release completes the first item (0.22.1) of the v0.22.0 Extended Configuration milestone. Remaining items for v0.22.0:
|
||||||
|
- 0.22.2 - Session Configuration
|
||||||
|
- 0.22.3 - Context Configuration
|
||||||
|
- 0.22.4 - Autocomplete Configuration
|
||||||
|
- 0.22.5 - Commands Configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.21.4] - 2025-12-02 - Syntax Highlighting in DiffView
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Syntax Highlighter Utility (0.21.4)**
|
||||||
|
- New syntax-highlighter utility in `src/tui/utils/syntax-highlighter.ts`
|
||||||
|
- Simple regex-based syntax highlighting for terminal UI
|
||||||
|
- Language detection from file extension: `ts`, `tsx`, `js`, `jsx`, `json`, `yaml`, `yml`
|
||||||
|
- Token types: keywords, strings, comments, numbers, operators, whitespace
|
||||||
|
- Color mapping: keywords (magenta), strings (green), comments (gray), numbers (cyan), operators (yellow)
|
||||||
|
- Support for single-line comments (`//`), multi-line comments (`/* */`)
|
||||||
|
- String literals: double quotes, single quotes, template literals
|
||||||
|
- Keywords: TypeScript/JavaScript keywords (const, let, function, async, etc.)
|
||||||
|
- Exports: `detectLanguage()`, `highlightLine()`, `Language` type, `HighlightedToken` interface
|
||||||
|
|
||||||
|
- **EditConfigSchema Enhancement**
|
||||||
|
- Added `syntaxHighlight` option to EditConfigSchema (default: `true`)
|
||||||
|
- Enables/disables syntax highlighting in diff views globally
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **DiffView Component Enhanced**
|
||||||
|
- Added `language?: Language` prop for explicit language override
|
||||||
|
- Added `syntaxHighlight?: boolean` prop (default: `false`)
|
||||||
|
- Automatic language detection from `filePath` using `detectLanguage()`
|
||||||
|
- Highlights only added lines (`type === "add"`) when syntax highlighting enabled
|
||||||
|
- Renders tokens with individual colors when highlighting is active
|
||||||
|
- Falls back to plain colored text when highlighting is disabled
|
||||||
|
|
||||||
|
- **ConfirmDialog Component**
|
||||||
|
- Added `syntaxHighlight?: boolean` prop (default: `false`)
|
||||||
|
- Passes `syntaxHighlight` to DiffView component
|
||||||
|
- Enables syntax highlighting in confirmation dialogs when configured
|
||||||
|
|
||||||
|
- **App Component**
|
||||||
|
- Added `syntaxHighlight?: boolean` prop to ExtendedAppProps (default: `true`)
|
||||||
|
- Passes `syntaxHighlight` to ConfirmDialog
|
||||||
|
- Integrates with global configuration for syntax highlighting
|
||||||
|
|
||||||
|
- **DiffLine Subcomponent**
|
||||||
|
- Enhanced to support syntax highlighting mode
|
||||||
|
- Conditional rendering: highlighted tokens vs plain colored text
|
||||||
|
- Token-based rendering when syntax highlighting is active
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Total tests: 1525 passed (was 1501, +24 new tests)
|
||||||
|
- New test file: `syntax-highlighter.test.ts` with 24 tests
|
||||||
|
- Language detection (9 tests)
|
||||||
|
- Token highlighting for keywords, strings, comments, numbers, operators (15 tests)
|
||||||
|
- Coverage: 97.63% lines, 91.25% branches, 98.97% functions, 97.63% statements
|
||||||
|
- 0 ESLint errors, 0 warnings
|
||||||
|
- Build successful with no TypeScript errors
|
||||||
|
- Regex-based approach using `RegExp#exec()` for performance
|
||||||
|
- No external dependencies added (native JavaScript)
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
This release completes the v0.21.0 TUI Enhancements milestone. All items for v0.21.0 are now complete:
|
||||||
|
- ✅ 0.21.1 - useAutocomplete Hook
|
||||||
|
- ✅ 0.21.2 - Edit Mode in ConfirmDialog
|
||||||
|
- ✅ 0.21.3 - Multiline Input support
|
||||||
|
- ✅ 0.21.4 - Syntax Highlighting in DiffView
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.21.3] - 2025-12-02 - Multiline Input Support
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **InputConfigSchema (0.21.3)**
|
||||||
|
- New configuration schema for input settings
|
||||||
|
- `multiline` option: boolean | "auto" (default: false)
|
||||||
|
- Supports three modes: `false` (disabled), `true` (always on), `"auto"` (activates when multiple lines present)
|
||||||
|
- Added `InputConfig` type export
|
||||||
|
|
||||||
|
- **Multiline Input Component (0.21.3)**
|
||||||
|
- Multiline text input support in Input component
|
||||||
|
- Shift+Enter: add new line in multiline mode
|
||||||
|
- Enter: submit all lines (in multiline mode) or submit text (in single-line mode)
|
||||||
|
- Auto-height adjustment: dynamically shows all input lines
|
||||||
|
- Line-by-line editing with visual indicator (">") for current line
|
||||||
|
- Arrow key navigation (↑/↓) between lines in multiline mode
|
||||||
|
- Instructions displayed: "Shift+Enter: new line | Enter: submit"
|
||||||
|
- Seamless switch between single-line and multiline modes based on configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Input Component Enhanced**
|
||||||
|
- Added `multiline?: boolean | "auto"` prop
|
||||||
|
- State management for multiple lines (`lines`, `currentLineIndex`)
|
||||||
|
- Conditional rendering: single-line TextInput vs multiline Box with multiple lines
|
||||||
|
- Arrow key handlers now support both history navigation (single-line) and line navigation (multiline)
|
||||||
|
- Submit handler resets lines state in addition to value
|
||||||
|
- Line change handlers: `handleLineChange`, `handleAddLine`, `handleMultilineSubmit`
|
||||||
|
|
||||||
|
- **App Component**
|
||||||
|
- Added `multiline?: boolean | "auto"` prop to ExtendedAppProps
|
||||||
|
- Passes multiline config to Input component
|
||||||
|
- Default value: false (single-line mode)
|
||||||
|
|
||||||
|
- **Config Schema**
|
||||||
|
- Added `input` section to ConfigSchema
|
||||||
|
- InputConfigSchema included in full configuration
|
||||||
|
- Config type updated to include InputConfig
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Total tests: 1501 passed (was 1484, +17 new tests)
|
||||||
|
- New test suite: "multiline support" with 21 tests
|
||||||
|
- InputProps with multiline options
|
||||||
|
- Multiline activation logic (true, false, "auto")
|
||||||
|
- Line management (update, add, join)
|
||||||
|
- Line navigation (up/down with boundaries)
|
||||||
|
- Multiline submit (trim, empty check, reset)
|
||||||
|
- Coverage: 97.67% lines, 91.37% branches, 98.97% functions, 97.67% statements
|
||||||
|
- 0 ESLint errors, 0 warnings
|
||||||
|
- Build successful with no type errors
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
This release completes the third item of the v0.21.0 TUI Enhancements milestone. Remaining item for v0.21.0:
|
||||||
|
- 0.21.4 - Syntax Highlighting in DiffView
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.21.1] - 2025-12-01 - TUI Enhancements (Part 2)
|
## [0.21.1] - 2025-12-01 - TUI Enhancements (Part 2)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1648,9 +1648,9 @@ interface DiffViewProps {
|
|||||||
## Version 0.22.0 - Extended Configuration ⚙️
|
## Version 0.22.0 - Extended Configuration ⚙️
|
||||||
|
|
||||||
**Priority:** MEDIUM
|
**Priority:** MEDIUM
|
||||||
**Status:** Pending
|
**Status:** In Progress (1/5 complete)
|
||||||
|
|
||||||
### 0.22.1 - Display Configuration
|
### 0.22.1 - Display Configuration ✅
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/shared/constants/config.ts additions
|
// src/shared/constants/config.ts additions
|
||||||
@@ -1664,11 +1664,11 @@ export const DisplayConfigSchema = z.object({
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [ ] DisplayConfigSchema in config.ts
|
- [x] DisplayConfigSchema in config.ts
|
||||||
- [ ] Bell notification on response complete
|
- [x] Bell notification on response complete
|
||||||
- [ ] Theme support (dark/light color schemes)
|
- [x] Theme support (dark/light color schemes)
|
||||||
- [ ] Configurable stats display
|
- [x] Configurable stats display
|
||||||
- [ ] Unit tests
|
- [x] Unit tests (46 new tests: 20 schema, 24 theme, 2 bell)
|
||||||
|
|
||||||
### 0.22.2 - Session Configuration
|
### 0.22.2 - Session Configuration
|
||||||
|
|
||||||
@@ -1880,6 +1880,6 @@ sessions:list # List<session_id>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-12-01
|
**Last Updated:** 2025-12-02
|
||||||
**Target Version:** 1.0.0
|
**Target Version:** 1.0.0
|
||||||
**Current Version:** 0.18.0
|
**Current Version:** 0.22.1
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/ipuaro",
|
"name": "@samiyev/ipuaro",
|
||||||
"version": "0.21.0",
|
"version": "0.22.1",
|
||||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -76,6 +76,25 @@ export const UndoConfigSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const EditConfigSchema = z.object({
|
export const EditConfigSchema = z.object({
|
||||||
autoApply: z.boolean().default(false),
|
autoApply: z.boolean().default(false),
|
||||||
|
syntaxHighlight: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input configuration schema.
|
||||||
|
*/
|
||||||
|
export const InputConfigSchema = z.object({
|
||||||
|
multiline: z.union([z.boolean(), z.literal("auto")]).default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display configuration schema.
|
||||||
|
*/
|
||||||
|
export const DisplayConfigSchema = z.object({
|
||||||
|
showStats: z.boolean().default(true),
|
||||||
|
showToolCalls: z.boolean().default(true),
|
||||||
|
theme: z.enum(["dark", "light"]).default("dark"),
|
||||||
|
bellOnComplete: z.boolean().default(false),
|
||||||
|
progressBar: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +107,8 @@ export const ConfigSchema = z.object({
|
|||||||
watchdog: WatchdogConfigSchema.default({}),
|
watchdog: WatchdogConfigSchema.default({}),
|
||||||
undo: UndoConfigSchema.default({}),
|
undo: UndoConfigSchema.default({}),
|
||||||
edit: EditConfigSchema.default({}),
|
edit: EditConfigSchema.default({}),
|
||||||
|
input: InputConfigSchema.default({}),
|
||||||
|
display: DisplayConfigSchema.default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +121,8 @@ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
|
|||||||
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
|
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
|
||||||
export type UndoConfig = z.infer<typeof UndoConfigSchema>
|
export type UndoConfig = z.infer<typeof UndoConfigSchema>
|
||||||
export type EditConfig = z.infer<typeof EditConfigSchema>
|
export type EditConfig = z.infer<typeof EditConfigSchema>
|
||||||
|
export type InputConfig = z.infer<typeof InputConfigSchema>
|
||||||
|
export type DisplayConfig = z.infer<typeof DisplayConfigSchema>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default configuration.
|
* Default configuration.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Chat, ConfirmDialog, Input, StatusBar } from "./components/index.js"
|
|||||||
import { type CommandResult, useCommands, useHotkeys, useSession } from "./hooks/index.js"
|
import { type CommandResult, useCommands, useHotkeys, useSession } from "./hooks/index.js"
|
||||||
import type { AppProps, BranchInfo } from "./types.js"
|
import type { AppProps, BranchInfo } from "./types.js"
|
||||||
import type { ConfirmChoice } from "../shared/types/index.js"
|
import type { ConfirmChoice } from "../shared/types/index.js"
|
||||||
|
import { ringBell } from "./utils/bell.js"
|
||||||
|
|
||||||
export interface AppDependencies {
|
export interface AppDependencies {
|
||||||
storage: IStorage
|
storage: IStorage
|
||||||
@@ -29,6 +30,12 @@ export interface AppDependencies {
|
|||||||
export interface ExtendedAppProps extends AppProps {
|
export interface ExtendedAppProps extends AppProps {
|
||||||
deps: AppDependencies
|
deps: AppDependencies
|
||||||
onExit?: () => void
|
onExit?: () => void
|
||||||
|
multiline?: boolean | "auto"
|
||||||
|
syntaxHighlight?: boolean
|
||||||
|
theme?: "dark" | "light"
|
||||||
|
showStats?: boolean
|
||||||
|
showToolCalls?: boolean
|
||||||
|
bellOnComplete?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingScreen(): React.JSX.Element {
|
function LoadingScreen(): React.JSX.Element {
|
||||||
@@ -65,6 +72,12 @@ export function App({
|
|||||||
autoApply: initialAutoApply = false,
|
autoApply: initialAutoApply = false,
|
||||||
deps,
|
deps,
|
||||||
onExit,
|
onExit,
|
||||||
|
multiline = false,
|
||||||
|
syntaxHighlight = true,
|
||||||
|
theme = "dark",
|
||||||
|
showStats = true,
|
||||||
|
showToolCalls = true,
|
||||||
|
bellOnComplete = false,
|
||||||
}: ExtendedAppProps): React.JSX.Element {
|
}: ExtendedAppProps): React.JSX.Element {
|
||||||
const { exit } = useApp()
|
const { exit } = useApp()
|
||||||
|
|
||||||
@@ -189,6 +202,12 @@ export function App({
|
|||||||
}
|
}
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bellOnComplete && status === "ready") {
|
||||||
|
ringBell()
|
||||||
|
}
|
||||||
|
}, [bellOnComplete, status])
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(text: string): void => {
|
(text: string): void => {
|
||||||
if (isCommand(text)) {
|
if (isCommand(text)) {
|
||||||
@@ -224,8 +243,15 @@ export function App({
|
|||||||
branch={branch}
|
branch={branch}
|
||||||
sessionTime={sessionTime}
|
sessionTime={sessionTime}
|
||||||
status={status}
|
status={status}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
<Chat
|
||||||
|
messages={messages}
|
||||||
|
isThinking={status === "thinking"}
|
||||||
|
theme={theme}
|
||||||
|
showStats={showStats}
|
||||||
|
showToolCalls={showToolCalls}
|
||||||
/>
|
/>
|
||||||
<Chat messages={messages} isThinking={status === "thinking"} />
|
|
||||||
{commandResult && (
|
{commandResult && (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
@@ -253,6 +279,7 @@ export function App({
|
|||||||
}
|
}
|
||||||
onSelect={handleConfirmSelect}
|
onSelect={handleConfirmSelect}
|
||||||
editableContent={pendingConfirmation.diff?.newLines}
|
editableContent={pendingConfirmation.diff?.newLines}
|
||||||
|
syntaxHighlight={syntaxHighlight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
@@ -263,6 +290,7 @@ export function App({
|
|||||||
storage={deps.storage}
|
storage={deps.storage}
|
||||||
projectRoot={projectPath}
|
projectRoot={projectPath}
|
||||||
autocompleteEnabled={true}
|
autocompleteEnabled={true}
|
||||||
|
multiline={multiline}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { Box, Text } from "ink"
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
|
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
|
||||||
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
|
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
|
||||||
|
import { getRoleColor, type Theme } from "../utils/theme.js"
|
||||||
|
|
||||||
export interface ChatProps {
|
export interface ChatProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
isThinking: boolean
|
isThinking: boolean
|
||||||
|
theme?: Theme
|
||||||
|
showStats?: boolean
|
||||||
|
showToolCalls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number): string {
|
function formatTimestamp(timestamp: number): string {
|
||||||
@@ -42,11 +46,20 @@ function formatToolCall(call: ToolCall): string {
|
|||||||
return `[${call.name} ${params}]`
|
return `[${call.name} ${params}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
interface MessageComponentProps {
|
||||||
|
message: ChatMessage
|
||||||
|
theme: Theme
|
||||||
|
showStats: boolean
|
||||||
|
showToolCalls: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
|
||||||
|
const roleColor = getRoleColor("user", theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box gap={1}>
|
<Box gap={1}>
|
||||||
<Text color="green" bold>
|
<Text color={roleColor} bold>
|
||||||
You
|
You
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray" dimColor>
|
<Text color="gray" dimColor>
|
||||||
@@ -60,13 +73,19 @@ function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
function AssistantMessage({
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
showStats,
|
||||||
|
showToolCalls,
|
||||||
|
}: MessageComponentProps): React.JSX.Element {
|
||||||
const stats = formatStats(message.stats)
|
const stats = formatStats(message.stats)
|
||||||
|
const roleColor = getRoleColor("assistant", theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box gap={1}>
|
<Box gap={1}>
|
||||||
<Text color="cyan" bold>
|
<Text color={roleColor} bold>
|
||||||
Assistant
|
Assistant
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray" dimColor>
|
<Text color="gray" dimColor>
|
||||||
@@ -74,7 +93,7 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
{showToolCalls && message.toolCalls && message.toolCalls.length > 0 && (
|
||||||
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
|
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
|
||||||
{message.toolCalls.map((call) => (
|
{message.toolCalls.map((call) => (
|
||||||
<Text key={call.id} color="yellow">
|
<Text key={call.id} color="yellow">
|
||||||
@@ -90,7 +109,7 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stats && (
|
{showStats && stats && (
|
||||||
<Box marginLeft={2} marginTop={1}>
|
<Box marginLeft={2} marginTop={1}>
|
||||||
<Text color="gray" dimColor>
|
<Text color="gray" dimColor>
|
||||||
{stats}
|
{stats}
|
||||||
@@ -101,7 +120,9 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
function ToolMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
|
||||||
|
const roleColor = getRoleColor("tool", theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||||
{message.toolResults?.map((result) => (
|
{message.toolResults?.map((result) => (
|
||||||
@@ -115,31 +136,39 @@ function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
function SystemMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
|
||||||
const isError = message.content.toLowerCase().startsWith("error")
|
const isError = message.content.toLowerCase().startsWith("error")
|
||||||
|
const roleColor = getRoleColor("system", theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box marginBottom={1} marginLeft={2}>
|
<Box marginBottom={1} marginLeft={2}>
|
||||||
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
|
<Text color={isError ? "red" : roleColor} dimColor={!isError}>
|
||||||
{message.content}
|
{message.content}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Element {
|
function MessageComponent({
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
showStats,
|
||||||
|
showToolCalls,
|
||||||
|
}: MessageComponentProps): React.JSX.Element {
|
||||||
|
const props = { message, theme, showStats, showToolCalls }
|
||||||
|
|
||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case "user": {
|
case "user": {
|
||||||
return <UserMessage message={message} />
|
return <UserMessage {...props} />
|
||||||
}
|
}
|
||||||
case "assistant": {
|
case "assistant": {
|
||||||
return <AssistantMessage message={message} />
|
return <AssistantMessage {...props} />
|
||||||
}
|
}
|
||||||
case "tool": {
|
case "tool": {
|
||||||
return <ToolMessage message={message} />
|
return <ToolMessage {...props} />
|
||||||
}
|
}
|
||||||
case "system": {
|
case "system": {
|
||||||
return <SystemMessage message={message} />
|
return <SystemMessage {...props} />
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return <></>
|
return <></>
|
||||||
@@ -147,24 +176,35 @@ function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Elem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThinkingIndicator(): React.JSX.Element {
|
function ThinkingIndicator({ theme }: { theme: Theme }): React.JSX.Element {
|
||||||
|
const color = getRoleColor("assistant", theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text color="yellow">Thinking...</Text>
|
<Text color={color}>Thinking...</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({ messages, isThinking }: ChatProps): React.JSX.Element {
|
export function Chat({
|
||||||
|
messages,
|
||||||
|
isThinking,
|
||||||
|
theme = "dark",
|
||||||
|
showStats = true,
|
||||||
|
showToolCalls = true,
|
||||||
|
}: ChatProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<MessageComponent
|
<MessageComponent
|
||||||
key={`${String(message.timestamp)}-${String(index)}`}
|
key={`${String(message.timestamp)}-${String(index)}`}
|
||||||
message={message}
|
message={message}
|
||||||
|
theme={theme}
|
||||||
|
showStats={showStats}
|
||||||
|
showToolCalls={showToolCalls}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isThinking && <ThinkingIndicator />}
|
{isThinking && <ThinkingIndicator theme={theme} />}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ConfirmDialogProps {
|
|||||||
diff?: DiffViewProps
|
diff?: DiffViewProps
|
||||||
onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void
|
onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void
|
||||||
editableContent?: string[]
|
editableContent?: string[]
|
||||||
|
syntaxHighlight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogMode = "confirm" | "edit"
|
type DialogMode = "confirm" | "edit"
|
||||||
@@ -42,6 +43,7 @@ export function ConfirmDialog({
|
|||||||
diff,
|
diff,
|
||||||
onSelect,
|
onSelect,
|
||||||
editableContent,
|
editableContent,
|
||||||
|
syntaxHighlight = false,
|
||||||
}: ConfirmDialogProps): React.JSX.Element {
|
}: ConfirmDialogProps): React.JSX.Element {
|
||||||
const [mode, setMode] = useState<DialogMode>("confirm")
|
const [mode, setMode] = useState<DialogMode>("confirm")
|
||||||
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
|
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
|
||||||
@@ -113,7 +115,7 @@ export function ConfirmDialog({
|
|||||||
|
|
||||||
{diff && (
|
{diff && (
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<DiffView {...diff} />
|
<DiffView {...diff} syntaxHighlight={syntaxHighlight} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
|
|
||||||
import { Box, Text } from "ink"
|
import { Box, Text } from "ink"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { detectLanguage, highlightLine, type Language } from "../utils/syntax-highlighter.js"
|
||||||
|
|
||||||
export interface DiffViewProps {
|
export interface DiffViewProps {
|
||||||
filePath: string
|
filePath: string
|
||||||
oldLines: string[]
|
oldLines: string[]
|
||||||
newLines: string[]
|
newLines: string[]
|
||||||
startLine: number
|
startLine: number
|
||||||
|
language?: Language
|
||||||
|
syntaxHighlight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiffLine {
|
interface DiffLine {
|
||||||
@@ -97,20 +100,37 @@ function formatLineNumber(num: number | undefined, width: number): string {
|
|||||||
function DiffLine({
|
function DiffLine({
|
||||||
line,
|
line,
|
||||||
lineNumberWidth,
|
lineNumberWidth,
|
||||||
|
language,
|
||||||
|
syntaxHighlight,
|
||||||
}: {
|
}: {
|
||||||
line: DiffLine
|
line: DiffLine
|
||||||
lineNumberWidth: number
|
lineNumberWidth: number
|
||||||
|
language?: Language
|
||||||
|
syntaxHighlight?: boolean
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const prefix = getLinePrefix(line)
|
const prefix = getLinePrefix(line)
|
||||||
const color = getLineColor(line)
|
const color = getLineColor(line)
|
||||||
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
|
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
|
||||||
|
|
||||||
|
const shouldHighlight = syntaxHighlight && language && line.type === "add"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color="gray">{lineNum} </Text>
|
<Text color="gray">{lineNum} </Text>
|
||||||
<Text color={color}>
|
{shouldHighlight ? (
|
||||||
{prefix} {line.content}
|
<Box>
|
||||||
</Text>
|
<Text color={color}>{prefix} </Text>
|
||||||
|
{highlightLine(line.content, language).map((token, idx) => (
|
||||||
|
<Text key={idx} color={token.color}>
|
||||||
|
{token.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text color={color}>
|
||||||
|
{prefix} {line.content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -166,6 +186,8 @@ export function DiffView({
|
|||||||
oldLines,
|
oldLines,
|
||||||
newLines,
|
newLines,
|
||||||
startLine,
|
startLine,
|
||||||
|
language,
|
||||||
|
syntaxHighlight = false,
|
||||||
}: DiffViewProps): React.JSX.Element {
|
}: DiffViewProps): React.JSX.Element {
|
||||||
const diffLines = computeDiff(oldLines, newLines, startLine)
|
const diffLines = computeDiff(oldLines, newLines, startLine)
|
||||||
const endLine = startLine + newLines.length - 1
|
const endLine = startLine + newLines.length - 1
|
||||||
@@ -174,6 +196,8 @@ export function DiffView({
|
|||||||
const additions = diffLines.filter((l) => l.type === "add").length
|
const additions = diffLines.filter((l) => l.type === "add").length
|
||||||
const deletions = diffLines.filter((l) => l.type === "remove").length
|
const deletions = diffLines.filter((l) => l.type === "remove").length
|
||||||
|
|
||||||
|
const detectedLanguage = language ?? detectLanguage(filePath)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
|
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
|
||||||
@@ -183,6 +207,8 @@ export function DiffView({
|
|||||||
key={`${line.type}-${String(index)}`}
|
key={`${line.type}-${String(index)}`}
|
||||||
line={line}
|
line={line}
|
||||||
lineNumberWidth={lineNumberWidth}
|
lineNumberWidth={lineNumberWidth}
|
||||||
|
language={detectedLanguage}
|
||||||
|
syntaxHighlight={syntaxHighlight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface InputProps {
|
|||||||
storage?: IStorage
|
storage?: IStorage
|
||||||
projectRoot?: string
|
projectRoot?: string
|
||||||
autocompleteEnabled?: boolean
|
autocompleteEnabled?: boolean
|
||||||
|
multiline?: boolean | "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input({
|
export function Input({
|
||||||
@@ -27,10 +28,15 @@ export function Input({
|
|||||||
storage,
|
storage,
|
||||||
projectRoot = "",
|
projectRoot = "",
|
||||||
autocompleteEnabled = true,
|
autocompleteEnabled = true,
|
||||||
|
multiline = false,
|
||||||
}: InputProps): React.JSX.Element {
|
}: InputProps): React.JSX.Element {
|
||||||
const [value, setValue] = useState("")
|
const [value, setValue] = useState("")
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1)
|
const [historyIndex, setHistoryIndex] = useState(-1)
|
||||||
const [savedInput, setSavedInput] = useState("")
|
const [savedInput, setSavedInput] = useState("")
|
||||||
|
const [lines, setLines] = useState<string[]>([""])
|
||||||
|
const [currentLineIndex, setCurrentLineIndex] = useState(0)
|
||||||
|
|
||||||
|
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Initialize autocomplete hook if storage is provided
|
* Initialize autocomplete hook if storage is provided
|
||||||
@@ -62,6 +68,8 @@ export function Input({
|
|||||||
}
|
}
|
||||||
onSubmit(text)
|
onSubmit(text)
|
||||||
setValue("")
|
setValue("")
|
||||||
|
setLines([""])
|
||||||
|
setCurrentLineIndex(0)
|
||||||
setHistoryIndex(-1)
|
setHistoryIndex(-1)
|
||||||
setSavedInput("")
|
setSavedInput("")
|
||||||
autocomplete.reset()
|
autocomplete.reset()
|
||||||
@@ -69,6 +77,31 @@ export function Input({
|
|||||||
[disabled, onSubmit, autocomplete],
|
[disabled, onSubmit, autocomplete],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleLineChange = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
const newLines = [...lines]
|
||||||
|
newLines[currentLineIndex] = newValue
|
||||||
|
setLines(newLines)
|
||||||
|
setValue(newLines.join("\n"))
|
||||||
|
},
|
||||||
|
[lines, currentLineIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddLine = useCallback(() => {
|
||||||
|
const newLines = [...lines]
|
||||||
|
newLines.splice(currentLineIndex + 1, 0, "")
|
||||||
|
setLines(newLines)
|
||||||
|
setCurrentLineIndex(currentLineIndex + 1)
|
||||||
|
setValue(newLines.join("\n"))
|
||||||
|
}, [lines, currentLineIndex])
|
||||||
|
|
||||||
|
const handleMultilineSubmit = useCallback(() => {
|
||||||
|
const fullText = lines.join("\n").trim()
|
||||||
|
if (fullText) {
|
||||||
|
handleSubmit(fullText)
|
||||||
|
}
|
||||||
|
}, [lines, handleSubmit])
|
||||||
|
|
||||||
const handleTabKey = useCallback(() => {
|
const handleTabKey = useCallback(() => {
|
||||||
if (storage && autocompleteEnabled && value.trim()) {
|
if (storage && autocompleteEnabled && value.trim()) {
|
||||||
const suggestions = autocomplete.suggestions
|
const suggestions = autocomplete.suggestions
|
||||||
@@ -116,11 +149,22 @@ export function Input({
|
|||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
handleTabKey()
|
handleTabKey()
|
||||||
}
|
}
|
||||||
|
if (key.return && key.shift && isMultilineActive) {
|
||||||
|
handleAddLine()
|
||||||
|
}
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
handleUpArrow()
|
if (isMultilineActive && currentLineIndex > 0) {
|
||||||
|
setCurrentLineIndex(currentLineIndex - 1)
|
||||||
|
} else if (!isMultilineActive) {
|
||||||
|
handleUpArrow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (key.downArrow) {
|
if (key.downArrow) {
|
||||||
handleDownArrow()
|
if (isMultilineActive && currentLineIndex < lines.length - 1) {
|
||||||
|
setCurrentLineIndex(currentLineIndex + 1)
|
||||||
|
} else if (!isMultilineActive) {
|
||||||
|
handleDownArrow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: !disabled },
|
{ isActive: !disabled },
|
||||||
@@ -130,21 +174,56 @@ export function Input({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
|
<Box
|
||||||
<Text color={disabled ? "gray" : "green"} bold>
|
borderStyle="single"
|
||||||
{">"}{" "}
|
borderColor={disabled ? "gray" : "cyan"}
|
||||||
</Text>
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
<Text color="gray" dimColor>
|
<Box>
|
||||||
{placeholder}
|
<Text color="gray" bold>
|
||||||
</Text>
|
{">"}{" "}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
{placeholder}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : isMultilineActive ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<Box key={index}>
|
||||||
|
<Text color="green" bold>
|
||||||
|
{index === currentLineIndex ? ">" : " "}{" "}
|
||||||
|
</Text>
|
||||||
|
{index === currentLineIndex ? (
|
||||||
|
<TextInput
|
||||||
|
value={line}
|
||||||
|
onChange={handleLineChange}
|
||||||
|
onSubmit={handleMultilineSubmit}
|
||||||
|
placeholder={index === 0 ? placeholder : ""}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>{line}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>Shift+Enter: new line | Enter: submit</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<Box>
|
||||||
value={value}
|
<Text color="green" bold>
|
||||||
onChange={handleChange}
|
{">"}{" "}
|
||||||
onSubmit={handleSubmit}
|
</Text>
|
||||||
placeholder={placeholder}
|
<TextInput
|
||||||
/>
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{hasSuggestions && !disabled && (
|
{hasSuggestions && !disabled && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Box, Text } from "ink"
|
import { Box, Text } from "ink"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import type { BranchInfo, TuiStatus } from "../types.js"
|
import type { BranchInfo, TuiStatus } from "../types.js"
|
||||||
|
import { getContextColor, getStatusColor, type Theme } from "../utils/theme.js"
|
||||||
|
|
||||||
export interface StatusBarProps {
|
export interface StatusBarProps {
|
||||||
contextUsage: number
|
contextUsage: number
|
||||||
@@ -13,27 +14,30 @@ export interface StatusBarProps {
|
|||||||
branch: BranchInfo
|
branch: BranchInfo
|
||||||
sessionTime: string
|
sessionTime: string
|
||||||
status: TuiStatus
|
status: TuiStatus
|
||||||
|
theme?: Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusIndicator(status: TuiStatus): { text: string; color: string } {
|
function getStatusIndicator(status: TuiStatus, theme: Theme): { text: string; color: string } {
|
||||||
|
const color = getStatusColor(status, theme)
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ready": {
|
case "ready": {
|
||||||
return { text: "ready", color: "green" }
|
return { text: "ready", color }
|
||||||
}
|
}
|
||||||
case "thinking": {
|
case "thinking": {
|
||||||
return { text: "thinking...", color: "yellow" }
|
return { text: "thinking...", color }
|
||||||
}
|
}
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
return { text: "executing...", color: "cyan" }
|
return { text: "executing...", color }
|
||||||
}
|
}
|
||||||
case "awaiting_confirmation": {
|
case "awaiting_confirmation": {
|
||||||
return { text: "confirm?", color: "magenta" }
|
return { text: "confirm?", color }
|
||||||
}
|
}
|
||||||
case "error": {
|
case "error": {
|
||||||
return { text: "error", color: "red" }
|
return { text: "error", color }
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return { text: "ready", color: "green" }
|
return { text: "ready", color }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,9 +52,11 @@ export function StatusBar({
|
|||||||
branch,
|
branch,
|
||||||
sessionTime,
|
sessionTime,
|
||||||
status,
|
status,
|
||||||
|
theme = "dark",
|
||||||
}: StatusBarProps): React.JSX.Element {
|
}: StatusBarProps): React.JSX.Element {
|
||||||
const statusIndicator = getStatusIndicator(status)
|
const statusIndicator = getStatusIndicator(status, theme)
|
||||||
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
|
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
|
||||||
|
const contextColor = getContextColor(contextUsage, theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
|
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
|
||||||
@@ -59,11 +65,7 @@ export function StatusBar({
|
|||||||
[ipuaro]
|
[ipuaro]
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray">
|
<Text color="gray">
|
||||||
[ctx:{" "}
|
[ctx: <Text color={contextColor}>{formatContextUsage(contextUsage)}</Text>]
|
||||||
<Text color={contextUsage > 0.8 ? "red" : "white"}>
|
|
||||||
{formatContextUsage(contextUsage)}
|
|
||||||
</Text>
|
|
||||||
]
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray">
|
<Text color="gray">
|
||||||
[<Text color="blue">{projectName}</Text>]
|
[<Text color="blue">{projectName}</Text>]
|
||||||
|
|||||||
11
packages/ipuaro/src/tui/utils/bell.ts
Normal file
11
packages/ipuaro/src/tui/utils/bell.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Bell notification utility for terminal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring the terminal bell.
|
||||||
|
* Works by outputting the ASCII bell character (\u0007).
|
||||||
|
*/
|
||||||
|
export function ringBell(): void {
|
||||||
|
process.stdout.write("\u0007")
|
||||||
|
}
|
||||||
167
packages/ipuaro/src/tui/utils/syntax-highlighter.ts
Normal file
167
packages/ipuaro/src/tui/utils/syntax-highlighter.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Simple syntax highlighter for terminal UI.
|
||||||
|
* Highlights keywords, strings, comments, numbers, and operators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Language = "typescript" | "javascript" | "tsx" | "jsx" | "json" | "yaml" | "unknown"
|
||||||
|
|
||||||
|
export interface HighlightedToken {
|
||||||
|
text: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYWORDS = new Set([
|
||||||
|
"abstract",
|
||||||
|
"any",
|
||||||
|
"as",
|
||||||
|
"async",
|
||||||
|
"await",
|
||||||
|
"boolean",
|
||||||
|
"break",
|
||||||
|
"case",
|
||||||
|
"catch",
|
||||||
|
"class",
|
||||||
|
"const",
|
||||||
|
"constructor",
|
||||||
|
"continue",
|
||||||
|
"debugger",
|
||||||
|
"declare",
|
||||||
|
"default",
|
||||||
|
"delete",
|
||||||
|
"do",
|
||||||
|
"else",
|
||||||
|
"enum",
|
||||||
|
"export",
|
||||||
|
"extends",
|
||||||
|
"false",
|
||||||
|
"finally",
|
||||||
|
"for",
|
||||||
|
"from",
|
||||||
|
"function",
|
||||||
|
"get",
|
||||||
|
"if",
|
||||||
|
"implements",
|
||||||
|
"import",
|
||||||
|
"in",
|
||||||
|
"instanceof",
|
||||||
|
"interface",
|
||||||
|
"let",
|
||||||
|
"module",
|
||||||
|
"namespace",
|
||||||
|
"new",
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"of",
|
||||||
|
"package",
|
||||||
|
"private",
|
||||||
|
"protected",
|
||||||
|
"public",
|
||||||
|
"readonly",
|
||||||
|
"require",
|
||||||
|
"return",
|
||||||
|
"set",
|
||||||
|
"static",
|
||||||
|
"string",
|
||||||
|
"super",
|
||||||
|
"switch",
|
||||||
|
"this",
|
||||||
|
"throw",
|
||||||
|
"true",
|
||||||
|
"try",
|
||||||
|
"type",
|
||||||
|
"typeof",
|
||||||
|
"undefined",
|
||||||
|
"var",
|
||||||
|
"void",
|
||||||
|
"while",
|
||||||
|
"with",
|
||||||
|
"yield",
|
||||||
|
])
|
||||||
|
|
||||||
|
export function detectLanguage(filePath: string): Language {
|
||||||
|
const ext = filePath.split(".").pop()?.toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case "ts":
|
||||||
|
return "typescript"
|
||||||
|
case "tsx":
|
||||||
|
return "tsx"
|
||||||
|
case "js":
|
||||||
|
return "javascript"
|
||||||
|
case "jsx":
|
||||||
|
return "jsx"
|
||||||
|
case "json":
|
||||||
|
return "json"
|
||||||
|
case "yaml":
|
||||||
|
case "yml":
|
||||||
|
return "yaml"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMENT_REGEX = /^(\/\/.*|\/\*[\s\S]*?\*\/)/
|
||||||
|
const STRING_REGEX = /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/
|
||||||
|
const NUMBER_REGEX = /^(\b\d+\.?\d*\b)/
|
||||||
|
const WORD_REGEX = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/
|
||||||
|
const OPERATOR_REGEX = /^([+\-*/%=<>!&|^~?:;,.()[\]{}])/
|
||||||
|
const WHITESPACE_REGEX = /^(\s+)/
|
||||||
|
|
||||||
|
export function highlightLine(line: string, language: Language): HighlightedToken[] {
|
||||||
|
if (language === "unknown" || language === "json" || language === "yaml") {
|
||||||
|
return [{ text: line, color: "white" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: HighlightedToken[] = []
|
||||||
|
let remaining = line
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const commentMatch = COMMENT_REGEX.exec(remaining)
|
||||||
|
if (commentMatch) {
|
||||||
|
tokens.push({ text: commentMatch[0], color: "gray" })
|
||||||
|
remaining = remaining.slice(commentMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringMatch = STRING_REGEX.exec(remaining)
|
||||||
|
if (stringMatch) {
|
||||||
|
tokens.push({ text: stringMatch[0], color: "green" })
|
||||||
|
remaining = remaining.slice(stringMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberMatch = NUMBER_REGEX.exec(remaining)
|
||||||
|
if (numberMatch) {
|
||||||
|
tokens.push({ text: numberMatch[0], color: "cyan" })
|
||||||
|
remaining = remaining.slice(numberMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordMatch = WORD_REGEX.exec(remaining)
|
||||||
|
if (wordMatch) {
|
||||||
|
const word = wordMatch[0]
|
||||||
|
const color = KEYWORDS.has(word) ? "magenta" : "white"
|
||||||
|
tokens.push({ text: word, color })
|
||||||
|
remaining = remaining.slice(word.length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatorMatch = OPERATOR_REGEX.exec(remaining)
|
||||||
|
if (operatorMatch) {
|
||||||
|
tokens.push({ text: operatorMatch[0], color: "yellow" })
|
||||||
|
remaining = remaining.slice(operatorMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitespaceMatch = WHITESPACE_REGEX.exec(remaining)
|
||||||
|
if (whitespaceMatch) {
|
||||||
|
tokens.push({ text: whitespaceMatch[0], color: "white" })
|
||||||
|
remaining = remaining.slice(whitespaceMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ text: remaining[0] ?? "", color: "white" })
|
||||||
|
remaining = remaining.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
115
packages/ipuaro/src/tui/utils/theme.ts
Normal file
115
packages/ipuaro/src/tui/utils/theme.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Theme color utilities for TUI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Theme = "dark" | "light"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color scheme for a theme.
|
||||||
|
*/
|
||||||
|
export interface ColorScheme {
|
||||||
|
primary: string
|
||||||
|
secondary: string
|
||||||
|
success: string
|
||||||
|
warning: string
|
||||||
|
error: string
|
||||||
|
info: string
|
||||||
|
muted: string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dark theme color scheme (default).
|
||||||
|
*/
|
||||||
|
const DARK_THEME: ColorScheme = {
|
||||||
|
primary: "cyan",
|
||||||
|
secondary: "blue",
|
||||||
|
success: "green",
|
||||||
|
warning: "yellow",
|
||||||
|
error: "red",
|
||||||
|
info: "cyan",
|
||||||
|
muted: "gray",
|
||||||
|
background: "black",
|
||||||
|
foreground: "white",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light theme color scheme.
|
||||||
|
*/
|
||||||
|
const LIGHT_THEME: ColorScheme = {
|
||||||
|
primary: "blue",
|
||||||
|
secondary: "cyan",
|
||||||
|
success: "green",
|
||||||
|
warning: "yellow",
|
||||||
|
error: "red",
|
||||||
|
info: "blue",
|
||||||
|
muted: "gray",
|
||||||
|
background: "white",
|
||||||
|
foreground: "black",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color scheme for a theme.
|
||||||
|
*/
|
||||||
|
export function getColorScheme(theme: Theme): ColorScheme {
|
||||||
|
return theme === "dark" ? DARK_THEME : LIGHT_THEME
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a status.
|
||||||
|
*/
|
||||||
|
export function getStatusColor(
|
||||||
|
status: "ready" | "thinking" | "error" | "tool_call" | "awaiting_confirmation",
|
||||||
|
theme: Theme = "dark",
|
||||||
|
): string {
|
||||||
|
const scheme = getColorScheme(theme)
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "ready":
|
||||||
|
return scheme.success
|
||||||
|
case "thinking":
|
||||||
|
case "tool_call":
|
||||||
|
return scheme.warning
|
||||||
|
case "awaiting_confirmation":
|
||||||
|
return scheme.info
|
||||||
|
case "error":
|
||||||
|
return scheme.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a message role.
|
||||||
|
*/
|
||||||
|
export function getRoleColor(
|
||||||
|
role: "user" | "assistant" | "system" | "tool",
|
||||||
|
theme: Theme = "dark",
|
||||||
|
): string {
|
||||||
|
const scheme = getColorScheme(theme)
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case "user":
|
||||||
|
return scheme.success
|
||||||
|
case "assistant":
|
||||||
|
return scheme.primary
|
||||||
|
case "system":
|
||||||
|
return scheme.muted
|
||||||
|
case "tool":
|
||||||
|
return scheme.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for context usage percentage.
|
||||||
|
*/
|
||||||
|
export function getContextColor(usage: number, theme: Theme = "dark"): string {
|
||||||
|
const scheme = getColorScheme(theme)
|
||||||
|
|
||||||
|
if (usage >= 0.8) {
|
||||||
|
return scheme.error
|
||||||
|
}
|
||||||
|
if (usage >= 0.6) {
|
||||||
|
return scheme.warning
|
||||||
|
}
|
||||||
|
return scheme.success
|
||||||
|
}
|
||||||
150
packages/ipuaro/tests/unit/shared/display-config.test.ts
Normal file
150
packages/ipuaro/tests/unit/shared/display-config.test.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DisplayConfigSchema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { DisplayConfigSchema } from "../../../src/shared/constants/config.js"
|
||||||
|
|
||||||
|
describe("DisplayConfigSchema", () => {
|
||||||
|
describe("default values", () => {
|
||||||
|
it("should use defaults when empty object provided", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
showStats: true,
|
||||||
|
showToolCalls: true,
|
||||||
|
theme: "dark",
|
||||||
|
bellOnComplete: false,
|
||||||
|
progressBar: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use defaults via .default({})", () => {
|
||||||
|
const result = DisplayConfigSchema.default({}).parse({})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
showStats: true,
|
||||||
|
showToolCalls: true,
|
||||||
|
theme: "dark",
|
||||||
|
bellOnComplete: false,
|
||||||
|
progressBar: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("showStats", () => {
|
||||||
|
it("should accept true", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ showStats: true })
|
||||||
|
expect(result.showStats).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept false", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ showStats: false })
|
||||||
|
expect(result.showStats).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-boolean", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ showStats: "yes" })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("showToolCalls", () => {
|
||||||
|
it("should accept true", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ showToolCalls: true })
|
||||||
|
expect(result.showToolCalls).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept false", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ showToolCalls: false })
|
||||||
|
expect(result.showToolCalls).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-boolean", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ showToolCalls: "yes" })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("theme", () => {
|
||||||
|
it("should accept dark", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ theme: "dark" })
|
||||||
|
expect(result.theme).toBe("dark")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept light", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ theme: "light" })
|
||||||
|
expect(result.theme).toBe("light")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject invalid theme", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ theme: "blue" })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-string", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ theme: 123 })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("bellOnComplete", () => {
|
||||||
|
it("should accept true", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ bellOnComplete: true })
|
||||||
|
expect(result.bellOnComplete).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept false", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ bellOnComplete: false })
|
||||||
|
expect(result.bellOnComplete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-boolean", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ bellOnComplete: "yes" })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("progressBar", () => {
|
||||||
|
it("should accept true", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ progressBar: true })
|
||||||
|
expect(result.progressBar).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept false", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({ progressBar: false })
|
||||||
|
expect(result.progressBar).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-boolean", () => {
|
||||||
|
expect(() => DisplayConfigSchema.parse({ progressBar: "yes" })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("partial config", () => {
|
||||||
|
it("should merge partial config with defaults", () => {
|
||||||
|
const result = DisplayConfigSchema.parse({
|
||||||
|
theme: "light",
|
||||||
|
bellOnComplete: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
showStats: true,
|
||||||
|
showToolCalls: true,
|
||||||
|
theme: "light",
|
||||||
|
bellOnComplete: true,
|
||||||
|
progressBar: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("full config", () => {
|
||||||
|
it("should accept valid full config", () => {
|
||||||
|
const config = {
|
||||||
|
showStats: false,
|
||||||
|
showToolCalls: false,
|
||||||
|
theme: "light" as const,
|
||||||
|
bellOnComplete: true,
|
||||||
|
progressBar: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = DisplayConfigSchema.parse(config)
|
||||||
|
expect(result).toEqual(config)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -181,4 +181,170 @@ describe("Input", () => {
|
|||||||
expect(savedInput).toBe("")
|
expect(savedInput).toBe("")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("multiline support", () => {
|
||||||
|
describe("InputProps with multiline", () => {
|
||||||
|
it("should accept multiline as boolean", () => {
|
||||||
|
const props: InputProps = {
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
history: [],
|
||||||
|
disabled: false,
|
||||||
|
multiline: true,
|
||||||
|
}
|
||||||
|
expect(props.multiline).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept multiline as 'auto'", () => {
|
||||||
|
const props: InputProps = {
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
history: [],
|
||||||
|
disabled: false,
|
||||||
|
multiline: "auto",
|
||||||
|
}
|
||||||
|
expect(props.multiline).toBe("auto")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have multiline false by default", () => {
|
||||||
|
const props: InputProps = {
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
history: [],
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
expect(props.multiline).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiline activation logic", () => {
|
||||||
|
it("should be active when multiline is true", () => {
|
||||||
|
const multiline = true
|
||||||
|
const lines = ["single line"]
|
||||||
|
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||||
|
expect(isMultilineActive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not be active when multiline is false", () => {
|
||||||
|
const multiline = false
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||||
|
expect(isMultilineActive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be active in auto mode with multiple lines", () => {
|
||||||
|
const multiline = "auto"
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||||
|
expect(isMultilineActive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not be active in auto mode with single line", () => {
|
||||||
|
const multiline = "auto"
|
||||||
|
const lines = ["single line"]
|
||||||
|
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||||
|
expect(isMultilineActive).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("line management", () => {
|
||||||
|
it("should update current line on change", () => {
|
||||||
|
const lines = ["first", "second", "third"]
|
||||||
|
const currentLineIndex = 1
|
||||||
|
const newValue = "updated second"
|
||||||
|
|
||||||
|
const newLines = [...lines]
|
||||||
|
newLines[currentLineIndex] = newValue
|
||||||
|
|
||||||
|
expect(newLines).toEqual(["first", "updated second", "third"])
|
||||||
|
expect(newLines.join("\n")).toBe("first\nupdated second\nthird")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should add new line at current position", () => {
|
||||||
|
const lines = ["first", "second"]
|
||||||
|
const currentLineIndex = 0
|
||||||
|
|
||||||
|
const newLines = [...lines]
|
||||||
|
newLines.splice(currentLineIndex + 1, 0, "")
|
||||||
|
|
||||||
|
expect(newLines).toEqual(["first", "", "second"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should join lines with newline for submit", () => {
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const fullText = lines.join("\n")
|
||||||
|
expect(fullText).toBe("line 1\nline 2\nline 3")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("line navigation", () => {
|
||||||
|
it("should navigate up in multiline mode", () => {
|
||||||
|
const lines = ["line1", "line2", "line3"]
|
||||||
|
let currentLineIndex = 2
|
||||||
|
|
||||||
|
currentLineIndex = currentLineIndex - 1
|
||||||
|
expect(currentLineIndex).toBe(1)
|
||||||
|
|
||||||
|
currentLineIndex = currentLineIndex - 1
|
||||||
|
expect(currentLineIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not navigate up past first line", () => {
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const currentLineIndex = 0
|
||||||
|
const isMultilineActive = true
|
||||||
|
|
||||||
|
const canNavigateUp = isMultilineActive && currentLineIndex > 0
|
||||||
|
expect(canNavigateUp).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should navigate down in multiline mode", () => {
|
||||||
|
const lines = ["line1", "line2", "line3"]
|
||||||
|
let currentLineIndex = 0
|
||||||
|
|
||||||
|
currentLineIndex = currentLineIndex + 1
|
||||||
|
expect(currentLineIndex).toBe(1)
|
||||||
|
|
||||||
|
currentLineIndex = currentLineIndex + 1
|
||||||
|
expect(currentLineIndex).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not navigate down past last line", () => {
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const currentLineIndex = 1
|
||||||
|
const isMultilineActive = true
|
||||||
|
|
||||||
|
const canNavigateDown = isMultilineActive && currentLineIndex < lines.length - 1
|
||||||
|
expect(canNavigateDown).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiline submit", () => {
|
||||||
|
it("should submit trimmed multiline text", () => {
|
||||||
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
const fullText = lines.join("\n").trim()
|
||||||
|
expect(fullText).toBe("line 1\nline 2\nline 3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not submit empty multiline text", () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const lines = ["", "", ""]
|
||||||
|
const fullText = lines.join("\n").trim()
|
||||||
|
|
||||||
|
if (fullText) {
|
||||||
|
onSubmit(fullText)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reset lines after submit", () => {
|
||||||
|
let lines = ["line1", "line2"]
|
||||||
|
let currentLineIndex = 1
|
||||||
|
|
||||||
|
lines = [""]
|
||||||
|
currentLineIndex = 0
|
||||||
|
|
||||||
|
expect(lines).toEqual([""])
|
||||||
|
expect(currentLineIndex).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
29
packages/ipuaro/tests/unit/tui/utils/bell.test.ts
Normal file
29
packages/ipuaro/tests/unit/tui/utils/bell.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Tests for bell utility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { ringBell } from "../../../../src/tui/utils/bell.js"
|
||||||
|
|
||||||
|
describe("ringBell", () => {
|
||||||
|
it("should write bell character to stdout", () => {
|
||||||
|
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||||
|
|
||||||
|
ringBell()
|
||||||
|
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith("\u0007")
|
||||||
|
writeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should write correct ASCII bell character", () => {
|
||||||
|
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||||
|
|
||||||
|
ringBell()
|
||||||
|
|
||||||
|
const callArg = writeSpy.mock.calls[0]?.[0]
|
||||||
|
expect(callArg).toBe("\u0007")
|
||||||
|
expect(callArg?.charCodeAt(0)).toBe(7)
|
||||||
|
|
||||||
|
writeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
155
packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts
Normal file
155
packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Tests for syntax-highlighter utility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { detectLanguage, highlightLine } from "../../../../src/tui/utils/syntax-highlighter.js"
|
||||||
|
|
||||||
|
describe("syntax-highlighter", () => {
|
||||||
|
describe("detectLanguage", () => {
|
||||||
|
it("should detect typescript from .ts extension", () => {
|
||||||
|
expect(detectLanguage("src/index.ts")).toBe("typescript")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect tsx from .tsx extension", () => {
|
||||||
|
expect(detectLanguage("src/Component.tsx")).toBe("tsx")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect javascript from .js extension", () => {
|
||||||
|
expect(detectLanguage("dist/bundle.js")).toBe("javascript")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect jsx from .jsx extension", () => {
|
||||||
|
expect(detectLanguage("src/App.jsx")).toBe("jsx")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect json from .json extension", () => {
|
||||||
|
expect(detectLanguage("package.json")).toBe("json")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect yaml from .yaml extension", () => {
|
||||||
|
expect(detectLanguage("config.yaml")).toBe("yaml")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect yaml from .yml extension", () => {
|
||||||
|
expect(detectLanguage("config.yml")).toBe("yaml")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return unknown for unsupported extensions", () => {
|
||||||
|
expect(detectLanguage("image.png")).toBe("unknown")
|
||||||
|
expect(detectLanguage("file")).toBe("unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle case insensitive extensions", () => {
|
||||||
|
expect(detectLanguage("FILE.TS")).toBe("typescript")
|
||||||
|
expect(detectLanguage("FILE.JSX")).toBe("jsx")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("highlightLine", () => {
|
||||||
|
describe("unknown language", () => {
|
||||||
|
it("should return plain text for unknown language", () => {
|
||||||
|
const tokens = highlightLine("hello world", "unknown")
|
||||||
|
expect(tokens).toEqual([{ text: "hello world", color: "white" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("json language", () => {
|
||||||
|
it("should return plain text for json", () => {
|
||||||
|
const tokens = highlightLine('{"key": "value"}', "json")
|
||||||
|
expect(tokens).toEqual([{ text: '{"key": "value"}', color: "white" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("yaml language", () => {
|
||||||
|
it("should return plain text for yaml", () => {
|
||||||
|
const tokens = highlightLine("key: value", "yaml")
|
||||||
|
expect(tokens).toEqual([{ text: "key: value", color: "white" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("typescript/javascript highlighting", () => {
|
||||||
|
it("should highlight keywords", () => {
|
||||||
|
const tokens = highlightLine("const x = 10", "typescript")
|
||||||
|
expect(tokens[0]).toEqual({ text: "const", color: "magenta" })
|
||||||
|
expect(tokens.find((t) => t.text === "x")).toEqual({ text: "x", color: "white" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight strings with double quotes", () => {
|
||||||
|
const tokens = highlightLine('const s = "hello"', "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === '"hello"')).toEqual({
|
||||||
|
text: '"hello"',
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight strings with single quotes", () => {
|
||||||
|
const tokens = highlightLine("const s = 'hello'", "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "'hello'")).toEqual({
|
||||||
|
text: "'hello'",
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight template literals", () => {
|
||||||
|
const tokens = highlightLine("const s = `hello`", "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "`hello`")).toEqual({
|
||||||
|
text: "`hello`",
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight numbers", () => {
|
||||||
|
const tokens = highlightLine("const n = 42", "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight single-line comments", () => {
|
||||||
|
const tokens = highlightLine("// this is a comment", "typescript")
|
||||||
|
expect(tokens[0]).toEqual({ text: "// this is a comment", color: "gray" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight multi-line comments", () => {
|
||||||
|
const tokens = highlightLine("/* comment */", "typescript")
|
||||||
|
expect(tokens[0]).toEqual({ text: "/* comment */", color: "gray" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight operators", () => {
|
||||||
|
const tokens = highlightLine("x + y = z", "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "+")).toEqual({ text: "+", color: "yellow" })
|
||||||
|
expect(tokens.find((t) => t.text === "=")).toEqual({ text: "=", color: "yellow" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should highlight parentheses and brackets", () => {
|
||||||
|
const tokens = highlightLine("foo(bar[0])", "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "(")).toEqual({ text: "(", color: "yellow" })
|
||||||
|
expect(tokens.find((t) => t.text === "[")).toEqual({ text: "[", color: "yellow" })
|
||||||
|
expect(tokens.find((t) => t.text === "]")).toEqual({ text: "]", color: "yellow" })
|
||||||
|
expect(tokens.find((t) => t.text === ")")).toEqual({ text: ")", color: "yellow" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle mixed content", () => {
|
||||||
|
const tokens = highlightLine('const x = "test" + 42', "typescript")
|
||||||
|
expect(tokens.find((t) => t.text === "const")).toEqual({
|
||||||
|
text: "const",
|
||||||
|
color: "magenta",
|
||||||
|
})
|
||||||
|
expect(tokens.find((t) => t.text === '"test"')).toEqual({
|
||||||
|
text: '"test"',
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve whitespace", () => {
|
||||||
|
const tokens = highlightLine(" const x = 10 ", "typescript")
|
||||||
|
expect(tokens[0]).toEqual({ text: " ", color: "white" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty lines", () => {
|
||||||
|
const tokens = highlightLine("", "typescript")
|
||||||
|
expect(tokens).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
158
packages/ipuaro/tests/unit/tui/utils/theme.test.ts
Normal file
158
packages/ipuaro/tests/unit/tui/utils/theme.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Tests for theme utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { getColorScheme, getContextColor, getRoleColor, getStatusColor } from "../../../../src/tui/utils/theme.js"
|
||||||
|
|
||||||
|
describe("theme utilities", () => {
|
||||||
|
describe("getColorScheme", () => {
|
||||||
|
it("should return dark theme colors for dark", () => {
|
||||||
|
const scheme = getColorScheme("dark")
|
||||||
|
|
||||||
|
expect(scheme).toEqual({
|
||||||
|
primary: "cyan",
|
||||||
|
secondary: "blue",
|
||||||
|
success: "green",
|
||||||
|
warning: "yellow",
|
||||||
|
error: "red",
|
||||||
|
info: "cyan",
|
||||||
|
muted: "gray",
|
||||||
|
background: "black",
|
||||||
|
foreground: "white",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return light theme colors for light", () => {
|
||||||
|
const scheme = getColorScheme("light")
|
||||||
|
|
||||||
|
expect(scheme).toEqual({
|
||||||
|
primary: "blue",
|
||||||
|
secondary: "cyan",
|
||||||
|
success: "green",
|
||||||
|
warning: "yellow",
|
||||||
|
error: "red",
|
||||||
|
info: "blue",
|
||||||
|
muted: "gray",
|
||||||
|
background: "white",
|
||||||
|
foreground: "black",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getStatusColor", () => {
|
||||||
|
it("should return success color for ready status", () => {
|
||||||
|
const color = getStatusColor("ready", "dark")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return warning color for thinking status", () => {
|
||||||
|
const color = getStatusColor("thinking", "dark")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return warning color for tool_call status", () => {
|
||||||
|
const color = getStatusColor("tool_call", "dark")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return info color for awaiting_confirmation status", () => {
|
||||||
|
const color = getStatusColor("awaiting_confirmation", "dark")
|
||||||
|
expect(color).toBe("cyan")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error color for error status", () => {
|
||||||
|
const color = getStatusColor("error", "dark")
|
||||||
|
expect(color).toBe("red")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use light theme colors when theme is light", () => {
|
||||||
|
const color = getStatusColor("awaiting_confirmation", "light")
|
||||||
|
expect(color).toBe("blue")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use dark theme by default", () => {
|
||||||
|
const color = getStatusColor("ready")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getRoleColor", () => {
|
||||||
|
it("should return success color for user role", () => {
|
||||||
|
const color = getRoleColor("user", "dark")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return primary color for assistant role", () => {
|
||||||
|
const color = getRoleColor("assistant", "dark")
|
||||||
|
expect(color).toBe("cyan")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return muted color for system role", () => {
|
||||||
|
const color = getRoleColor("system", "dark")
|
||||||
|
expect(color).toBe("gray")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return secondary color for tool role", () => {
|
||||||
|
const color = getRoleColor("tool", "dark")
|
||||||
|
expect(color).toBe("blue")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use light theme colors when theme is light", () => {
|
||||||
|
const color = getRoleColor("assistant", "light")
|
||||||
|
expect(color).toBe("blue")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use dark theme by default", () => {
|
||||||
|
const color = getRoleColor("user")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getContextColor", () => {
|
||||||
|
it("should return success color for low usage", () => {
|
||||||
|
const color = getContextColor(0.5, "dark")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return warning color for medium usage", () => {
|
||||||
|
const color = getContextColor(0.7, "dark")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error color for high usage", () => {
|
||||||
|
const color = getContextColor(0.9, "dark")
|
||||||
|
expect(color).toBe("red")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return success color at 59% usage", () => {
|
||||||
|
const color = getContextColor(0.59, "dark")
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return warning color at 60% usage", () => {
|
||||||
|
const color = getContextColor(0.6, "dark")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return warning color at 79% usage", () => {
|
||||||
|
const color = getContextColor(0.79, "dark")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return error color at 80% usage", () => {
|
||||||
|
const color = getContextColor(0.8, "dark")
|
||||||
|
expect(color).toBe("red")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use light theme colors when theme is light", () => {
|
||||||
|
const color = getContextColor(0.7, "light")
|
||||||
|
expect(color).toBe("yellow")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use dark theme by default", () => {
|
||||||
|
const color = getContextColor(0.5)
|
||||||
|
expect(color).toBe("green")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user