Compare commits

..

4 Commits

Author SHA1 Message Date
imfozilbek
98b365bd94 chore(ipuaro): release v0.22.2 2025-12-02 01:39:37 +05:00
imfozilbek
a7669f8947 feat(ipuaro): add session configuration
- Add SessionConfigSchema with persistIndefinitely, maxHistoryMessages, saveInputHistory
- Implement Session.truncateHistory() method for limiting message history
- Update HandleMessage to support history truncation and input history toggle
- Add config flow through useSession and App components
- Add 19 unit tests for SessionConfigSchema
- Update CHANGELOG.md and ROADMAP.md for v0.22.2
2025-12-02 01:34:04 +05:00
imfozilbek
7f0ec49c90 chore(ipuaro): release v0.22.1 2025-12-02 01:03:11 +05:00
imfozilbek
077d160343 feat(ipuaro): add display configuration
Add DisplayConfigSchema with theme support (dark/light), stats/tool calls visibility toggles, bell notification on completion, and progress bar control. Includes theme utilities with dynamic color schemes and 46 new tests.
2025-12-02 01:01:54 +05:00
27 changed files with 999 additions and 112 deletions

View File

@@ -5,6 +5,148 @@ 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.2] - 2025-12-02 - Session Configuration
### Added
- **SessionConfigSchema (0.22.2)**
- New configuration schema for session settings in `src/shared/constants/config.ts`
- `persistIndefinitely: boolean` (default: true) - toggle indefinite session persistence
- `maxHistoryMessages: number` (default: 100) - maximum number of messages to keep in session history
- `saveInputHistory: boolean` (default: true) - toggle saving user input to history
- Integrated into main ConfigSchema with `.default({})`
- Exported `SessionConfig` type from config module
- **Session.truncateHistory() Method**
- New method in `src/domain/entities/Session.ts`
- Truncates message history to specified maximum length
- Keeps most recent messages when truncating
### Changed
- **HandleMessage Use Case**
- Added `maxHistoryMessages?: number` option to `HandleMessageOptions`
- Added `saveInputHistory?: boolean` option to `HandleMessageOptions`
- Added `truncateHistoryIfNeeded()` private method for automatic history truncation
- Calls `truncateHistoryIfNeeded()` after every message addition (6 locations)
- Checks `saveInputHistory` before saving input to history
- Ensures history stays within configured limits automatically
- **useSession Hook**
- Added `config?: Config` to `UseSessionDependencies`
- Passes `maxHistoryMessages` and `saveInputHistory` from config to HandleMessage options
- Session configuration now flows from config through to message handling
- **App Component**
- Added `config?: Config` to `AppDependencies`
- Passes config to useSession hook
- Enables configuration-driven session management
### Technical Details
- Total tests: 1590 passed (was 1571, +19 new tests)
- New test file: `session-config.test.ts` with 19 tests
- Default values validation
- `persistIndefinitely` boolean validation
- `maxHistoryMessages` positive integer validation (including edge cases: zero, negative, float rejection)
- `saveInputHistory` boolean validation
- Partial and full config merging tests
- Coverage: 97.62% lines, 91.32% branches, 98.77% functions, 97.62% statements
- 0 ESLint errors, 0 warnings
- Build successful with no TypeScript errors
### Notes
This release completes the second item (0.22.2) of the v0.22.0 Extended Configuration milestone. Remaining items for v0.22.0:
- 0.22.3 - Context Configuration
- 0.22.4 - Autocomplete Configuration
- 0.22.5 - Commands Configuration
---
## [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 ## [0.21.4] - 2025-12-02 - Syntax Highlighting in DiffView
### Added ### Added

View File

@@ -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 (2/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,13 +1664,13 @@ 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
```typescript ```typescript
// src/shared/constants/config.ts additions // src/shared/constants/config.ts additions
@@ -1682,10 +1682,10 @@ export const SessionConfigSchema = z.object({
``` ```
**Deliverables:** **Deliverables:**
- [ ] SessionConfigSchema in config.ts - [x] SessionConfigSchema in config.ts
- [ ] History truncation based on maxHistoryMessages - [x] History truncation based on maxHistoryMessages
- [ ] Input history persistence toggle - [x] Input history persistence toggle
- [ ] Unit tests - [x] Unit tests (19 new tests)
### 0.22.3 - Context Configuration ### 0.22.3 - Context 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

View File

@@ -79,7 +79,7 @@ export class AuthService {
return { return {
token, token,
expiresAt, expiresAt,
userId: user.id userId: user.id,
} }
} }
} }

View File

@@ -21,7 +21,7 @@ async function main(): Promise<void> {
email: "demo@example.com", email: "demo@example.com",
name: "Demo User", name: "Demo User",
password: "password123", password: "password123",
role: "admin" role: "admin",
}) })
logger.info("Demo user created", { userId: user.id }) logger.info("Demo user created", { userId: user.id })

View File

@@ -25,9 +25,7 @@ export class UserService {
} }
// Check if user already exists // Check if user already exists
const existingUser = Array.from(this.users.values()).find( const existingUser = Array.from(this.users.values()).find((u) => u.email === dto.email)
(u) => u.email === dto.email
)
if (existingUser) { if (existingUser) {
throw new Error("User with this email already exists") throw new Error("User with this email already exists")
@@ -40,7 +38,7 @@ export class UserService {
name: dto.name, name: dto.name,
role: dto.role || "user", role: dto.role || "user",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date(),
} }
this.users.set(user.id, user) this.users.set(user.id, user)
@@ -71,7 +69,7 @@ export class UserService {
...user, ...user,
...(dto.name && { name: dto.name }), ...(dto.name && { name: dto.name }),
...(dto.role && { role: dto.role }), ...(dto.role && { role: dto.role }),
updatedAt: new Date() updatedAt: new Date(),
} }
this.users.set(id, updated) this.users.set(id, updated)

View File

@@ -30,7 +30,7 @@ export class Logger {
level, level,
context: this.context, context: this.context,
message, message,
...(meta && { meta }) ...(meta && { meta }),
} }
console.log(JSON.stringify(logEntry)) console.log(JSON.stringify(logEntry))
} }

View File

@@ -20,7 +20,7 @@ export function sanitizeInput(input: string): string {
export class ValidationError extends Error { export class ValidationError extends Error {
constructor( constructor(
message: string, message: string,
public field: string public field: string,
) { ) {
super(message) super(message)
this.name = "ValidationError" this.name = "ValidationError"

View File

@@ -18,7 +18,7 @@ describe("UserService", () => {
const user = await userService.createUser({ const user = await userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) })
expect(user).toBeDefined() expect(user).toBeDefined()
@@ -32,8 +32,8 @@ describe("UserService", () => {
userService.createUser({ userService.createUser({
email: "invalid-email", email: "invalid-email",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) }),
).rejects.toThrow(ValidationError) ).rejects.toThrow(ValidationError)
}) })
@@ -42,8 +42,8 @@ describe("UserService", () => {
userService.createUser({ userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "weak" password: "weak",
}) }),
).rejects.toThrow(ValidationError) ).rejects.toThrow(ValidationError)
}) })
@@ -51,15 +51,15 @@ describe("UserService", () => {
await userService.createUser({ await userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) })
await expect( await expect(
userService.createUser({ userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Another User", name: "Another User",
password: "password123" password: "password123",
}) }),
).rejects.toThrow("already exists") ).rejects.toThrow("already exists")
}) })
}) })
@@ -69,7 +69,7 @@ describe("UserService", () => {
const created = await userService.createUser({ const created = await userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) })
const found = await userService.getUserById(created.id) const found = await userService.getUserById(created.id)
@@ -87,11 +87,11 @@ describe("UserService", () => {
const user = await userService.createUser({ const user = await userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) })
const updated = await userService.updateUser(user.id, { const updated = await userService.updateUser(user.id, {
name: "Updated Name" name: "Updated Name",
}) })
expect(updated.name).toBe("Updated Name") expect(updated.name).toBe("Updated Name")
@@ -99,9 +99,9 @@ describe("UserService", () => {
}) })
it("should throw error for non-existent user", async () => { it("should throw error for non-existent user", async () => {
await expect( await expect(userService.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
userService.updateUser("non-existent", { name: "Test" }) "not found",
).rejects.toThrow("not found") )
}) })
}) })
@@ -110,7 +110,7 @@ describe("UserService", () => {
const user = await userService.createUser({ const user = await userService.createUser({
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
password: "password123" password: "password123",
}) })
await userService.deleteUser(user.id) await userService.deleteUser(user.id)
@@ -125,13 +125,13 @@ describe("UserService", () => {
await userService.createUser({ await userService.createUser({
email: "user1@example.com", email: "user1@example.com",
name: "User 1", name: "User 1",
password: "password123" password: "password123",
}) })
await userService.createUser({ await userService.createUser({
email: "user2@example.com", email: "user2@example.com",
name: "User 2", name: "User 2",
password: "password123" password: "password123",
}) })
const users = await userService.listUsers() const users = await userService.listUsers()

View File

@@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: "node" environment: "node",
} },
}) })

View File

@@ -1,6 +1,6 @@
{ {
"name": "@samiyev/ipuaro", "name": "@samiyev/ipuaro",
"version": "0.21.4", "version": "0.22.2",
"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",

View File

@@ -68,6 +68,8 @@ export interface HandleMessageEvents {
export interface HandleMessageOptions { export interface HandleMessageOptions {
autoApply?: boolean autoApply?: boolean
maxToolCalls?: number maxToolCalls?: number
maxHistoryMessages?: number
saveInputHistory?: boolean
} }
const DEFAULT_MAX_TOOL_CALLS = 20 const DEFAULT_MAX_TOOL_CALLS = 20
@@ -135,6 +137,15 @@ export class HandleMessage {
this.llm.abort() this.llm.abort()
} }
/**
* Truncate session history if maxHistoryMessages is set.
*/
private truncateHistoryIfNeeded(session: Session): void {
if (this.options.maxHistoryMessages !== undefined) {
session.truncateHistory(this.options.maxHistoryMessages)
}
}
/** /**
* Execute the message handling flow. * Execute the message handling flow.
*/ */
@@ -145,7 +156,12 @@ export class HandleMessage {
if (message.trim()) { if (message.trim()) {
const userMessage = createUserMessage(message) const userMessage = createUserMessage(message)
session.addMessage(userMessage) session.addMessage(userMessage)
session.addInputToHistory(message) this.truncateHistoryIfNeeded(session)
if (this.options.saveInputHistory !== false) {
session.addInputToHistory(message)
}
this.emitMessage(userMessage) this.emitMessage(userMessage)
} }
@@ -183,6 +199,7 @@ export class HandleMessage {
toolCalls: 0, toolCalls: 0,
}) })
session.addMessage(assistantMessage) session.addMessage(assistantMessage)
this.truncateHistoryIfNeeded(session)
this.emitMessage(assistantMessage) this.emitMessage(assistantMessage)
this.contextManager.addTokens(response.tokens) this.contextManager.addTokens(response.tokens)
this.contextManager.updateSession(session) this.contextManager.updateSession(session)
@@ -197,6 +214,7 @@ export class HandleMessage {
toolCalls: parsed.toolCalls.length, toolCalls: parsed.toolCalls.length,
}) })
session.addMessage(assistantMessage) session.addMessage(assistantMessage)
this.truncateHistoryIfNeeded(session)
this.emitMessage(assistantMessage) this.emitMessage(assistantMessage)
toolCallCount += parsed.toolCalls.length toolCallCount += parsed.toolCalls.length
@@ -204,6 +222,7 @@ export class HandleMessage {
const errorMsg = `Maximum tool calls (${String(maxToolCalls)}) exceeded` const errorMsg = `Maximum tool calls (${String(maxToolCalls)}) exceeded`
const errorMessage = createSystemMessage(errorMsg) const errorMessage = createSystemMessage(errorMsg)
session.addMessage(errorMessage) session.addMessage(errorMessage)
this.truncateHistoryIfNeeded(session)
this.emitMessage(errorMessage) this.emitMessage(errorMessage)
this.emitStatus("ready") this.emitStatus("ready")
return return
@@ -227,6 +246,7 @@ export class HandleMessage {
const toolMessage = createToolMessage(results) const toolMessage = createToolMessage(results)
session.addMessage(toolMessage) session.addMessage(toolMessage)
this.truncateHistoryIfNeeded(session)
this.contextManager.addTokens(response.tokens) this.contextManager.addTokens(response.tokens)
@@ -306,6 +326,7 @@ export class HandleMessage {
const errorMessage = createSystemMessage(`Error: ${ipuaroError.message}`) const errorMessage = createSystemMessage(`Error: ${ipuaroError.message}`)
session.addMessage(errorMessage) session.addMessage(errorMessage)
this.truncateHistoryIfNeeded(session)
this.emitMessage(errorMessage) this.emitMessage(errorMessage)
this.emitStatus("ready") this.emitStatus("ready")

View File

@@ -94,6 +94,12 @@ export class Session {
} }
} }
truncateHistory(maxMessages: number): void {
if (this.history.length > maxMessages) {
this.history = this.history.slice(-maxMessages)
}
}
clearHistory(): void { clearHistory(): void {
this.history = [] this.history = []
this.context = { this.context = {

View File

@@ -86,6 +86,26 @@ export const InputConfigSchema = z.object({
multiline: z.union([z.boolean(), z.literal("auto")]).default(false), 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),
})
/**
* Session configuration schema.
*/
export const SessionConfigSchema = z.object({
persistIndefinitely: z.boolean().default(true),
maxHistoryMessages: z.number().int().positive().default(100),
saveInputHistory: z.boolean().default(true),
})
/** /**
* Full configuration schema. * Full configuration schema.
*/ */
@@ -97,6 +117,8 @@ export const ConfigSchema = z.object({
undo: UndoConfigSchema.default({}), undo: UndoConfigSchema.default({}),
edit: EditConfigSchema.default({}), edit: EditConfigSchema.default({}),
input: InputConfigSchema.default({}), input: InputConfigSchema.default({}),
display: DisplayConfigSchema.default({}),
session: SessionConfigSchema.default({}),
}) })
/** /**
@@ -110,6 +132,8 @@ 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 InputConfig = z.infer<typeof InputConfigSchema>
export type DisplayConfig = z.infer<typeof DisplayConfigSchema>
export type SessionConfig = z.infer<typeof SessionConfigSchema>
/** /**
* Default configuration. * Default configuration.

View File

@@ -10,6 +10,7 @@ import type { ISessionStorage } from "../domain/services/ISessionStorage.js"
import type { IStorage } from "../domain/services/IStorage.js" import type { IStorage } from "../domain/services/IStorage.js"
import type { DiffInfo } from "../domain/services/ITool.js" import type { DiffInfo } from "../domain/services/ITool.js"
import type { ErrorOption } from "../shared/errors/IpuaroError.js" import type { ErrorOption } from "../shared/errors/IpuaroError.js"
import type { Config } from "../shared/constants/config.js"
import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js" import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js"
import type { ConfirmationResult } from "../application/use-cases/ExecuteTool.js" import type { ConfirmationResult } from "../application/use-cases/ExecuteTool.js"
import type { ProjectStructure } from "../infrastructure/llm/prompts.js" import type { ProjectStructure } from "../infrastructure/llm/prompts.js"
@@ -17,6 +18,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
@@ -24,6 +26,7 @@ export interface AppDependencies {
llm: ILLMClient llm: ILLMClient
tools: IToolRegistry tools: IToolRegistry
projectStructure?: ProjectStructure projectStructure?: ProjectStructure
config?: Config
} }
export interface ExtendedAppProps extends AppProps { export interface ExtendedAppProps extends AppProps {
@@ -31,6 +34,10 @@ export interface ExtendedAppProps extends AppProps {
onExit?: () => void onExit?: () => void
multiline?: boolean | "auto" multiline?: boolean | "auto"
syntaxHighlight?: boolean syntaxHighlight?: boolean
theme?: "dark" | "light"
showStats?: boolean
showToolCalls?: boolean
bellOnComplete?: boolean
} }
function LoadingScreen(): React.JSX.Element { function LoadingScreen(): React.JSX.Element {
@@ -69,6 +76,10 @@ export function App({
onExit, onExit,
multiline = false, multiline = false,
syntaxHighlight = true, syntaxHighlight = true,
theme = "dark",
showStats = true,
showToolCalls = true,
bellOnComplete = false,
}: ExtendedAppProps): React.JSX.Element { }: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp() const { exit } = useApp()
@@ -120,6 +131,7 @@ export function App({
projectRoot: projectPath, projectRoot: projectPath,
projectName, projectName,
projectStructure: deps.projectStructure, projectStructure: deps.projectStructure,
config: deps.config,
}, },
{ {
autoApply, autoApply,
@@ -193,6 +205,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)) {
@@ -228,8 +246,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"

View File

@@ -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>
) )
} }

View File

@@ -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>]

View File

@@ -11,6 +11,7 @@ import type { IStorage } from "../../domain/services/IStorage.js"
import type { DiffInfo } from "../../domain/services/ITool.js" import type { DiffInfo } from "../../domain/services/ITool.js"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js" import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ErrorOption } from "../../shared/errors/IpuaroError.js" import type { ErrorOption } from "../../shared/errors/IpuaroError.js"
import type { Config } from "../../shared/constants/config.js"
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js" import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
import { import {
HandleMessage, HandleMessage,
@@ -30,6 +31,7 @@ export interface UseSessionDependencies {
projectRoot: string projectRoot: string
projectName: string projectName: string
projectStructure?: ProjectStructure projectStructure?: ProjectStructure
config?: Config
} }
export interface UseSessionOptions { export interface UseSessionOptions {
@@ -111,7 +113,11 @@ async function initializeSession(
if (deps.projectStructure) { if (deps.projectStructure) {
handleMessage.setProjectStructure(deps.projectStructure) handleMessage.setProjectStructure(deps.projectStructure)
} }
handleMessage.setOptions({ autoApply: options.autoApply }) handleMessage.setOptions({
autoApply: options.autoApply,
maxHistoryMessages: deps.config?.session.maxHistoryMessages,
saveInputHistory: deps.config?.session.saveInputHistory,
})
handleMessage.setEvents(createEventHandlers(setters, options)) handleMessage.setEvents(createEventHandlers(setters, options))
refs.current.handleMessage = handleMessage refs.current.handleMessage = handleMessage
refs.current.undoChange = new UndoChange(deps.sessionStorage, deps.storage) refs.current.undoChange = new UndoChange(deps.sessionStorage, deps.storage)

View 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")
}

View 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
}

View File

@@ -19,7 +19,7 @@ vi.mock("../../../../src/infrastructure/indexer/FileScanner.js", () => ({
return 'export function main() { return "hello" }' return 'export function main() { return "hello" }'
} }
if (path.includes("utils.ts")) { if (path.includes("utils.ts")) {
return 'export const add = (a: number, b: number) => a + b' return "export const add = (a: number, b: number) => a + b"
} }
return null return null
} }
@@ -31,7 +31,16 @@ vi.mock("../../../../src/infrastructure/indexer/ASTParser.js", () => ({
parse() { parse() {
return { return {
...createEmptyFileAST(), ...createEmptyFileAST(),
functions: [{ name: "test", lineStart: 1, lineEnd: 5, params: [], isAsync: false, isExported: true }], functions: [
{
name: "test",
lineStart: 1,
lineEnd: 5,
params: [],
isAsync: false,
isExported: true,
},
],
} }
} }
}, },
@@ -116,7 +125,7 @@ describe("IndexProject", () => {
expect.objectContaining({ expect.objectContaining({
hash: expect.any(String), hash: expect.any(String),
lines: expect.any(Array), lines: expect.any(Array),
}) }),
) )
}) })
@@ -128,7 +137,7 @@ describe("IndexProject", () => {
"src/index.ts", "src/index.ts",
expect.objectContaining({ expect.objectContaining({
functions: expect.any(Array), functions: expect.any(Array),
}) }),
) )
}) })
@@ -136,19 +145,14 @@ describe("IndexProject", () => {
await useCase.execute("/test/project") await useCase.execute("/test/project")
expect(mockStorage.setMeta).toHaveBeenCalledTimes(2) expect(mockStorage.setMeta).toHaveBeenCalledTimes(2)
expect(mockStorage.setMeta).toHaveBeenCalledWith( expect(mockStorage.setMeta).toHaveBeenCalledWith("src/index.ts", expect.any(Object))
"src/index.ts",
expect.any(Object)
)
}) })
it("should build and store symbol index", async () => { it("should build and store symbol index", async () => {
await useCase.execute("/test/project") await useCase.execute("/test/project")
expect(mockStorage.setSymbolIndex).toHaveBeenCalledTimes(1) expect(mockStorage.setSymbolIndex).toHaveBeenCalledTimes(1)
expect(mockStorage.setSymbolIndex).toHaveBeenCalledWith( expect(mockStorage.setSymbolIndex).toHaveBeenCalledWith(expect.any(Map))
expect.any(Map)
)
}) })
it("should build and store dependency graph", async () => { it("should build and store dependency graph", async () => {
@@ -159,7 +163,7 @@ describe("IndexProject", () => {
expect.objectContaining({ expect.objectContaining({
imports: expect.any(Map), imports: expect.any(Map),
importedBy: expect.any(Map), importedBy: expect.any(Map),
}) }),
) )
}) })
@@ -168,7 +172,7 @@ describe("IndexProject", () => {
expect(mockStorage.setProjectConfig).toHaveBeenCalledWith( expect(mockStorage.setProjectConfig).toHaveBeenCalledWith(
"last_indexed", "last_indexed",
expect.any(Number) expect.any(Number),
) )
}) })
@@ -186,7 +190,7 @@ describe("IndexProject", () => {
total: expect.any(Number), total: expect.any(Number),
currentFile: expect.any(String), currentFile: expect.any(String),
phase: expect.stringMatching(/scanning|parsing|analyzing|indexing/), phase: expect.stringMatching(/scanning|parsing|analyzing|indexing/),
}) }),
) )
}) })
@@ -198,7 +202,7 @@ describe("IndexProject", () => {
}) })
const scanningCalls = progressCallback.mock.calls.filter( const scanningCalls = progressCallback.mock.calls.filter(
(call) => call[0].phase === "scanning" (call) => call[0].phase === "scanning",
) )
expect(scanningCalls.length).toBeGreaterThan(0) expect(scanningCalls.length).toBeGreaterThan(0)
}) })
@@ -211,7 +215,7 @@ describe("IndexProject", () => {
}) })
const parsingCalls = progressCallback.mock.calls.filter( const parsingCalls = progressCallback.mock.calls.filter(
(call) => call[0].phase === "parsing" (call) => call[0].phase === "parsing",
) )
expect(parsingCalls.length).toBeGreaterThan(0) expect(parsingCalls.length).toBeGreaterThan(0)
}) })
@@ -224,7 +228,7 @@ describe("IndexProject", () => {
}) })
const analyzingCalls = progressCallback.mock.calls.filter( const analyzingCalls = progressCallback.mock.calls.filter(
(call) => call[0].phase === "analyzing" (call) => call[0].phase === "analyzing",
) )
expect(analyzingCalls.length).toBeGreaterThan(0) expect(analyzingCalls.length).toBeGreaterThan(0)
}) })
@@ -237,7 +241,7 @@ describe("IndexProject", () => {
}) })
const indexingCalls = progressCallback.mock.calls.filter( const indexingCalls = progressCallback.mock.calls.filter(
(call) => call[0].phase === "indexing" (call) => call[0].phase === "indexing",
) )
expect(indexingCalls.length).toBeGreaterThan(0) expect(indexingCalls.length).toBeGreaterThan(0)
}) })
@@ -245,10 +249,7 @@ describe("IndexProject", () => {
it("should detect TypeScript files", async () => { it("should detect TypeScript files", async () => {
await useCase.execute("/test/project") await useCase.execute("/test/project")
expect(mockStorage.setAST).toHaveBeenCalledWith( expect(mockStorage.setAST).toHaveBeenCalledWith("src/index.ts", expect.any(Object))
"src/index.ts",
expect.any(Object)
)
}) })
it("should handle files without parseable language", async () => { it("should handle files without parseable language", async () => {
@@ -276,7 +277,7 @@ describe("IndexProject", () => {
expect(mockStorage.setAST).toHaveBeenCalledWith( expect(mockStorage.setAST).toHaveBeenCalledWith(
expect.stringContaining(".ts"), expect.stringContaining(".ts"),
expect.any(Object) expect.any(Object),
) )
}) })
}) })
@@ -294,7 +295,7 @@ describe("IndexProject", () => {
}) })
const callsWithFiles = progressCallback.mock.calls.filter( const callsWithFiles = progressCallback.mock.calls.filter(
(call) => call[0].currentFile && call[0].currentFile.length > 0 (call) => call[0].currentFile && call[0].currentFile.length > 0,
) )
expect(callsWithFiles.length).toBeGreaterThan(0) expect(callsWithFiles.length).toBeGreaterThan(0)
}) })
@@ -307,7 +308,7 @@ describe("IndexProject", () => {
}) })
const parsingCalls = progressCallback.mock.calls.filter( const parsingCalls = progressCallback.mock.calls.filter(
(call) => call[0].phase === "parsing" (call) => call[0].phase === "parsing",
) )
if (parsingCalls.length > 0) { if (parsingCalls.length > 0) {
expect(parsingCalls[0][0].total).toBe(2) expect(parsingCalls[0][0].total).toBe(2)

View File

@@ -123,8 +123,7 @@ describe("OllamaClient", () => {
mockOllamaInstance.chat.mockResolvedValue({ mockOllamaInstance.chat.mockResolvedValue({
message: { message: {
role: "assistant", role: "assistant",
content: content: '<tool_call name="get_lines"><path>src/index.ts</path></tool_call>',
'<tool_call name="get_lines"><path>src/index.ts</path></tool_call>',
tool_calls: undefined, tool_calls: undefined,
}, },
eval_count: 30, eval_count: 30,
@@ -408,7 +407,6 @@ describe("OllamaClient", () => {
}) })
}) })
describe("error handling", () => { describe("error handling", () => {
it("should handle ECONNREFUSED errors", async () => { it("should handle ECONNREFUSED errors", async () => {
mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED")) mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED"))
@@ -435,7 +433,9 @@ describe("OllamaClient", () => {
const client = new OllamaClient(defaultConfig) const client = new OllamaClient(defaultConfig)
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(/Request was aborted/) await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
/Request was aborted/,
)
}) })
it("should handle model not found errors", async () => { it("should handle model not found errors", async () => {
@@ -443,7 +443,9 @@ describe("OllamaClient", () => {
const client = new OllamaClient(defaultConfig) const client = new OllamaClient(defaultConfig)
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(/Model.*not found/) await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
/Model.*not found/,
)
}) })
}) })
}) })

View File

@@ -303,7 +303,9 @@ describe("GetFunctionTool", () => {
}) })
it("should handle error when reading lines fails", async () => { it("should handle error when reading lines fails", async () => {
const ast = createMockAST([createMockFunction({ name: "test", lineStart: 1, lineEnd: 1 })]) const ast = createMockAST([
createMockFunction({ name: "test", lineStart: 1, lineEnd: 1 }),
])
const storage: IStorage = { const storage: IStorage = {
getFile: vi.fn().mockResolvedValue(null), getFile: vi.fn().mockResolvedValue(null),
getAST: vi.fn().mockResolvedValue(ast), getAST: vi.fn().mockResolvedValue(ast),

View 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)
})
})
})

View File

@@ -0,0 +1,146 @@
/**
* Tests for SessionConfigSchema.
*/
import { describe, expect, it } from "vitest"
import { SessionConfigSchema } from "../../../src/shared/constants/config.js"
describe("SessionConfigSchema", () => {
describe("default values", () => {
it("should use defaults when empty object provided", () => {
const result = SessionConfigSchema.parse({})
expect(result).toEqual({
persistIndefinitely: true,
maxHistoryMessages: 100,
saveInputHistory: true,
})
})
it("should use defaults via .default({})", () => {
const result = SessionConfigSchema.default({}).parse({})
expect(result).toEqual({
persistIndefinitely: true,
maxHistoryMessages: 100,
saveInputHistory: true,
})
})
})
describe("persistIndefinitely", () => {
it("should accept true", () => {
const result = SessionConfigSchema.parse({ persistIndefinitely: true })
expect(result.persistIndefinitely).toBe(true)
})
it("should accept false", () => {
const result = SessionConfigSchema.parse({ persistIndefinitely: false })
expect(result.persistIndefinitely).toBe(false)
})
it("should reject non-boolean", () => {
expect(() => SessionConfigSchema.parse({ persistIndefinitely: "yes" })).toThrow()
})
})
describe("maxHistoryMessages", () => {
it("should accept valid positive integer", () => {
const result = SessionConfigSchema.parse({ maxHistoryMessages: 50 })
expect(result.maxHistoryMessages).toBe(50)
})
it("should accept default value", () => {
const result = SessionConfigSchema.parse({ maxHistoryMessages: 100 })
expect(result.maxHistoryMessages).toBe(100)
})
it("should accept large value", () => {
const result = SessionConfigSchema.parse({ maxHistoryMessages: 1000 })
expect(result.maxHistoryMessages).toBe(1000)
})
it("should reject zero", () => {
expect(() => SessionConfigSchema.parse({ maxHistoryMessages: 0 })).toThrow()
})
it("should reject negative number", () => {
expect(() => SessionConfigSchema.parse({ maxHistoryMessages: -10 })).toThrow()
})
it("should reject float", () => {
expect(() => SessionConfigSchema.parse({ maxHistoryMessages: 10.5 })).toThrow()
})
it("should reject non-number", () => {
expect(() => SessionConfigSchema.parse({ maxHistoryMessages: "100" })).toThrow()
})
})
describe("saveInputHistory", () => {
it("should accept true", () => {
const result = SessionConfigSchema.parse({ saveInputHistory: true })
expect(result.saveInputHistory).toBe(true)
})
it("should accept false", () => {
const result = SessionConfigSchema.parse({ saveInputHistory: false })
expect(result.saveInputHistory).toBe(false)
})
it("should reject non-boolean", () => {
expect(() => SessionConfigSchema.parse({ saveInputHistory: "yes" })).toThrow()
})
})
describe("partial config", () => {
it("should merge partial config with defaults", () => {
const result = SessionConfigSchema.parse({
maxHistoryMessages: 50,
})
expect(result).toEqual({
persistIndefinitely: true,
maxHistoryMessages: 50,
saveInputHistory: true,
})
})
it("should merge multiple partial fields", () => {
const result = SessionConfigSchema.parse({
persistIndefinitely: false,
saveInputHistory: false,
})
expect(result).toEqual({
persistIndefinitely: false,
maxHistoryMessages: 100,
saveInputHistory: false,
})
})
})
describe("full config", () => {
it("should accept valid full config", () => {
const config = {
persistIndefinitely: false,
maxHistoryMessages: 200,
saveInputHistory: false,
}
const result = SessionConfigSchema.parse(config)
expect(result).toEqual(config)
})
it("should accept all defaults explicitly", () => {
const config = {
persistIndefinitely: true,
maxHistoryMessages: 100,
saveInputHistory: true,
}
const result = SessionConfigSchema.parse(config)
expect(result).toEqual(config)
})
})
})

View File

@@ -218,28 +218,32 @@ describe("Input", () => {
it("should be active when multiline is true", () => { it("should be active when multiline is true", () => {
const multiline = true const multiline = true
const lines = ["single line"] const lines = ["single line"]
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) const isMultilineActive =
multiline === true || (multiline === "auto" && lines.length > 1)
expect(isMultilineActive).toBe(true) expect(isMultilineActive).toBe(true)
}) })
it("should not be active when multiline is false", () => { it("should not be active when multiline is false", () => {
const multiline = false const multiline = false
const lines = ["line1", "line2"] const lines = ["line1", "line2"]
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) const isMultilineActive =
multiline === true || (multiline === "auto" && lines.length > 1)
expect(isMultilineActive).toBe(false) expect(isMultilineActive).toBe(false)
}) })
it("should be active in auto mode with multiple lines", () => { it("should be active in auto mode with multiple lines", () => {
const multiline = "auto" const multiline = "auto"
const lines = ["line1", "line2"] const lines = ["line1", "line2"]
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) const isMultilineActive =
multiline === true || (multiline === "auto" && lines.length > 1)
expect(isMultilineActive).toBe(true) expect(isMultilineActive).toBe(true)
}) })
it("should not be active in auto mode with single line", () => { it("should not be active in auto mode with single line", () => {
const multiline = "auto" const multiline = "auto"
const lines = ["single line"] const lines = ["single line"]
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) const isMultilineActive =
multiline === true || (multiline === "auto" && lines.length > 1)
expect(isMultilineActive).toBe(false) expect(isMultilineActive).toBe(false)
}) })
}) })

View 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()
})
})

View File

@@ -0,0 +1,163 @@
/**
* 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")
})
})
})