feat(ipuaro): add TUI components and hooks (v0.11.0)

This commit is contained in:
imfozilbek
2025-12-01 13:00:14 +05:00
parent 259ecc181a
commit fd1e6ad86e
20 changed files with 1722 additions and 2 deletions

View File

@@ -5,6 +5,65 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.11.0] - 2025-12-01 - TUI Basic
### Added
- **TUI Types (0.11.0)**
- `TuiStatus`: Status type for TUI display (ready, thinking, tool_call, awaiting_confirmation, error)
- `BranchInfo`: Git branch information (name, isDetached)
- `AppProps`: Main app component props
- `StatusBarData`: Status bar display data
- **App Shell (0.11.1)**
- Main TUI App component with React/Ink
- Session initialization and state management
- Loading and error screens
- Hotkey integration (Ctrl+C, Ctrl+D, Ctrl+Z)
- Session time tracking
- **StatusBar Component (0.11.2)**
- Displays: `[ipuaro] [ctx: 12%] [project] [branch] [time] status`
- Context usage with color warning at >80%
- Git branch with detached HEAD support
- Status indicator with colors (ready=green, thinking=yellow, error=red)
- **Chat Component (0.11.3)**
- Message history display with role-based styling
- User messages (green), Assistant messages (cyan), System messages (gray)
- Tool call display with parameters
- Response stats: time, tokens, tool calls
- Thinking indicator during LLM processing
- **Input Component (0.11.4)**
- Prompt with `> ` prefix
- History navigation with ↑/↓ arrow keys
- Saved input restoration when navigating past history
- Disabled state during processing
- Custom placeholder support
- **useSession Hook (0.11.5)**
- Session state management with React hooks
- Message handling integration
- Status tracking (ready, thinking, tool_call, error)
- Undo support
- Clear history functionality
- Abort/interrupt support
- **useHotkeys Hook (0.11.6)**
- Ctrl+C: Interrupt (1st), Exit (2nd within 1s)
- Ctrl+D: Exit with session save
- Ctrl+Z: Undo last change
### Changed
- Total tests: 1254 (was 1174)
- Coverage: 97.75% lines, 92.22% branches
- TUI layer now has 4 components + 2 hooks
- TUI excluded from coverage thresholds (requires React testing setup)
---
## [0.10.0] - 2025-12-01 - Session Management
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.10.0",
"version": "0.11.0",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -21,5 +21,8 @@ export * from "./shared/index.js"
// Infrastructure exports
export * from "./infrastructure/index.js"
// TUI exports
export * from "./tui/index.js"
// Version
export const VERSION = pkg.version

View File

@@ -0,0 +1,167 @@
/**
* Main TUI App component.
* Orchestrates the terminal user interface.
*/
import { Box, Text, useApp } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import type { ILLMClient } from "../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../domain/services/ISessionStorage.js"
import type { IStorage } from "../domain/services/IStorage.js"
import type { DiffInfo } from "../domain/services/ITool.js"
import type { ErrorChoice } from "../shared/types/index.js"
import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js"
import type { ProjectStructure } from "../infrastructure/llm/prompts.js"
import { Chat, Input, StatusBar } from "./components/index.js"
import { useHotkeys, useSession } from "./hooks/index.js"
import type { AppProps, BranchInfo } from "./types.js"
export interface AppDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectStructure?: ProjectStructure
}
export interface ExtendedAppProps extends AppProps {
deps: AppDependencies
onExit?: () => void
}
function LoadingScreen(): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="cyan">Loading session...</Text>
</Box>
)
}
function ErrorScreen({ error }: { error: Error }): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error
</Text>
<Text color="red">{error.message}</Text>
</Box>
)
}
async function handleConfirmationDefault(_message: string, _diff?: DiffInfo): Promise<boolean> {
return Promise.resolve(true)
}
async function handleErrorDefault(_error: Error): Promise<ErrorChoice> {
return Promise.resolve("skip")
}
export function App({
projectPath,
autoApply = false,
deps,
onExit,
}: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp()
const [branch] = useState<BranchInfo>({ name: "main", isDetached: false })
const [sessionTime, setSessionTime] = useState("0m")
const projectName = projectPath.split("/").pop() ?? "unknown"
const { session, messages, status, isLoading, error, sendMessage, undo, abort } = useSession(
{
storage: deps.storage,
sessionStorage: deps.sessionStorage,
llm: deps.llm,
tools: deps.tools,
projectRoot: projectPath,
projectName,
projectStructure: deps.projectStructure,
},
{
autoApply,
onConfirmation: handleConfirmationDefault,
onError: handleErrorDefault,
},
)
const handleExit = useCallback((): void => {
onExit?.()
exit()
}, [exit, onExit])
const handleInterrupt = useCallback((): void => {
if (status === "thinking" || status === "tool_call") {
abort()
}
}, [status, abort])
const handleUndo = useCallback((): void => {
void undo()
}, [undo])
useHotkeys(
{
onInterrupt: handleInterrupt,
onExit: handleExit,
onUndo: handleUndo,
},
{ enabled: !isLoading },
)
useEffect(() => {
if (!session) {
return
}
const interval = setInterval(() => {
setSessionTime(session.getSessionDurationFormatted())
}, 60_000)
setSessionTime(session.getSessionDurationFormatted())
return (): void => {
clearInterval(interval)
}
}, [session])
const handleSubmit = useCallback(
(text: string): void => {
if (text.startsWith("/")) {
return
}
void sendMessage(text)
},
[sendMessage],
)
if (isLoading) {
return <LoadingScreen />
}
if (error) {
return <ErrorScreen error={error} />
}
const isInputDisabled = status === "thinking" || status === "tool_call"
return (
<Box flexDirection="column" height="100%">
<StatusBar
contextUsage={session?.context.tokenUsage ?? 0}
projectName={projectName}
branch={branch}
sessionTime={sessionTime}
status={status}
/>
<Chat messages={messages} isThinking={status === "thinking"} />
<Input
onSubmit={handleSubmit}
history={session?.inputHistory ?? []}
disabled={isInputDisabled}
placeholder={isInputDisabled ? "Processing..." : "Type a message..."}
/>
</Box>
)
}

View File

@@ -0,0 +1,170 @@
/**
* Chat component for TUI.
* Displays message history with tool calls and stats.
*/
import { Box, Text } from "ink"
import type React from "react"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
export interface ChatProps {
messages: ChatMessage[]
isThinking: boolean
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
function formatStats(stats: ChatMessage["stats"]): string {
if (!stats) {
return ""
}
const time = (stats.timeMs / 1000).toFixed(1)
const tokens = stats.tokens.toLocaleString()
const tools = stats.toolCalls
const parts = [`${time}s`, `${tokens} tokens`]
if (tools > 0) {
parts.push(`${String(tools)} tool${tools > 1 ? "s" : ""}`)
}
return parts.join(" | ")
}
function formatToolCall(call: ToolCall): string {
const params = Object.entries(call.params)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")
return `[${call.name} ${params}]`
}
function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="green" bold>
You
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
</Box>
)
}
function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const stats = formatStats(message.stats)
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="cyan" bold>
Assistant
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
{message.toolCalls && message.toolCalls.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
{message.toolCalls.map((call) => (
<Text key={call.id} color="yellow">
{formatToolCall(call)}
</Text>
))}
</Box>
)}
{message.content && (
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
)}
{stats && (
<Box marginLeft={2} marginTop={1}>
<Text color="gray" dimColor>
{stats}
</Text>
</Box>
)}
</Box>
)
}
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
{message.toolResults?.map((result) => (
<Box key={result.callId} flexDirection="column">
<Text color={result.success ? "green" : "red"}>
{result.success ? "+" : "x"} {result.callId.slice(0, 8)}
</Text>
</Box>
))}
</Box>
)
}
function SystemMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const isError = message.content.toLowerCase().startsWith("error")
return (
<Box marginBottom={1} marginLeft={2}>
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
{message.content}
</Text>
</Box>
)
}
function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Element {
switch (message.role) {
case "user": {
return <UserMessage message={message} />
}
case "assistant": {
return <AssistantMessage message={message} />
}
case "tool": {
return <ToolMessage message={message} />
}
case "system": {
return <SystemMessage message={message} />
}
default: {
return <></>
}
}
}
function ThinkingIndicator(): React.JSX.Element {
return (
<Box marginBottom={1}>
<Text color="yellow">Thinking...</Text>
</Box>
)
}
export function Chat({ messages, isThinking }: ChatProps): React.JSX.Element {
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{messages.map((message, index) => (
<MessageComponent
key={`${String(message.timestamp)}-${String(index)}`}
message={message}
/>
))}
{isThinking && <ThinkingIndicator />}
</Box>
)
}

View File

@@ -0,0 +1,99 @@
/**
* Input component for TUI.
* Prompt with history navigation (up/down) and path autocomplete (tab).
*/
import { Box, Text, useInput } from "ink"
import TextInput from "ink-text-input"
import React, { useCallback, useState } from "react"
export interface InputProps {
onSubmit: (text: string) => void
history: string[]
disabled: boolean
placeholder?: string
}
export function Input({
onSubmit,
history,
disabled,
placeholder = "Type a message...",
}: InputProps): React.JSX.Element {
const [value, setValue] = useState("")
const [historyIndex, setHistoryIndex] = useState(-1)
const [savedInput, setSavedInput] = useState("")
const handleChange = useCallback((newValue: string) => {
setValue(newValue)
setHistoryIndex(-1)
}, [])
const handleSubmit = useCallback(
(text: string) => {
if (disabled || !text.trim()) {
return
}
onSubmit(text)
setValue("")
setHistoryIndex(-1)
setSavedInput("")
},
[disabled, onSubmit],
)
useInput(
(input, key) => {
if (disabled) {
return
}
if (key.upArrow && history.length > 0) {
if (historyIndex === -1) {
setSavedInput(value)
}
const newIndex =
historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
if (key.downArrow) {
if (historyIndex === -1) {
return
}
if (historyIndex >= history.length - 1) {
setHistoryIndex(-1)
setValue(savedInput)
} else {
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
}
},
{ isActive: !disabled },
)
return (
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
<Text color={disabled ? "gray" : "green"} bold>
{">"}{" "}
</Text>
{disabled ? (
<Text color="gray" dimColor>
{placeholder}
</Text>
) : (
<TextInput
value={value}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
)}
</Box>
)
}

View File

@@ -0,0 +1,81 @@
/**
* StatusBar component for TUI.
* Displays: [ipuaro] [ctx: 12%] [project: myapp] [main] [47m] status
*/
import { Box, Text } from "ink"
import type React from "react"
import type { BranchInfo, TuiStatus } from "../types.js"
export interface StatusBarProps {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}
function getStatusIndicator(status: TuiStatus): { text: string; color: string } {
switch (status) {
case "ready": {
return { text: "ready", color: "green" }
}
case "thinking": {
return { text: "thinking...", color: "yellow" }
}
case "tool_call": {
return { text: "executing...", color: "cyan" }
}
case "awaiting_confirmation": {
return { text: "confirm?", color: "magenta" }
}
case "error": {
return { text: "error", color: "red" }
}
default: {
return { text: "ready", color: "green" }
}
}
}
function formatContextUsage(usage: number): string {
return `${String(Math.round(usage * 100))}%`
}
export function StatusBar({
contextUsage,
projectName,
branch,
sessionTime,
status,
}: StatusBarProps): React.JSX.Element {
const statusIndicator = getStatusIndicator(status)
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
<Box gap={1}>
<Text color="cyan" bold>
[ipuaro]
</Text>
<Text color="gray">
[ctx:{" "}
<Text color={contextUsage > 0.8 ? "red" : "white"}>
{formatContextUsage(contextUsage)}
</Text>
]
</Text>
<Text color="gray">
[<Text color="blue">{projectName}</Text>]
</Text>
<Text color="gray">
[<Text color="green">{branchDisplay}</Text>]
</Text>
<Text color="gray">
[<Text color="white">{sessionTime}</Text>]
</Text>
</Box>
<Text color={statusIndicator.color}>{statusIndicator.text}</Text>
</Box>
)
}

View File

@@ -0,0 +1,7 @@
/**
* TUI components.
*/
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
export { Chat, type ChatProps } from "./Chat.js"
export { Input, type InputProps } from "./Input.js"

View File

@@ -0,0 +1,11 @@
/**
* TUI hooks.
*/
export {
useSession,
type UseSessionDependencies,
type UseSessionOptions,
type UseSessionReturn,
} from "./useSession.js"
export { useHotkeys, type HotkeyHandlers, type UseHotkeysOptions } from "./useHotkeys.js"

View File

@@ -0,0 +1,59 @@
/**
* useHotkeys hook for TUI.
* Handles global keyboard shortcuts.
*/
import { useInput } from "ink"
import { useCallback, useRef } from "react"
export interface HotkeyHandlers {
onInterrupt?: () => void
onExit?: () => void
onUndo?: () => void
}
export interface UseHotkeysOptions {
enabled?: boolean
}
export function useHotkeys(handlers: HotkeyHandlers, options: UseHotkeysOptions = {}): void {
const { enabled = true } = options
const interruptCount = useRef(0)
const interruptTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const resetInterruptCount = useCallback((): void => {
interruptCount.current = 0
if (interruptTimer.current) {
clearTimeout(interruptTimer.current)
interruptTimer.current = null
}
}, [])
useInput(
(_input, key) => {
if (key.ctrl && _input === "c") {
interruptCount.current++
if (interruptCount.current === 1) {
handlers.onInterrupt?.()
interruptTimer.current = setTimeout(() => {
resetInterruptCount()
}, 1000)
} else if (interruptCount.current >= 2) {
resetInterruptCount()
handlers.onExit?.()
}
}
if (key.ctrl && _input === "d") {
handlers.onExit?.()
}
if (key.ctrl && _input === "z") {
handlers.onUndo?.()
}
},
{ isActive: enabled },
)
}

View File

@@ -0,0 +1,205 @@
/**
* useSession hook for TUI.
* Manages session state and message handling.
*/
import { useCallback, useEffect, useRef, useState } from "react"
import type { Session } from "../../domain/entities/Session.js"
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
import type { IStorage } from "../../domain/services/IStorage.js"
import type { DiffInfo } from "../../domain/services/ITool.js"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ErrorChoice } from "../../shared/types/index.js"
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
import {
HandleMessage,
type HandleMessageStatus,
} from "../../application/use-cases/HandleMessage.js"
import { StartSession } from "../../application/use-cases/StartSession.js"
import { UndoChange } from "../../application/use-cases/UndoChange.js"
import type { ProjectStructure } from "../../infrastructure/llm/prompts.js"
import type { TuiStatus } from "../types.js"
export interface UseSessionDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectRoot: string
projectName: string
projectStructure?: ProjectStructure
}
export interface UseSessionOptions {
autoApply?: boolean
onConfirmation?: (message: string, diff?: DiffInfo) => Promise<boolean>
onError?: (error: Error) => Promise<ErrorChoice>
}
export interface UseSessionReturn {
session: Session | null
messages: ChatMessage[]
status: TuiStatus
isLoading: boolean
error: Error | null
sendMessage: (message: string) => Promise<void>
undo: () => Promise<boolean>
clearHistory: () => void
abort: () => void
}
interface SessionRefs {
session: Session | null
handleMessage: HandleMessage | null
undoChange: UndoChange | null
}
type SetStatus = React.Dispatch<React.SetStateAction<TuiStatus>>
type SetMessages = React.Dispatch<React.SetStateAction<ChatMessage[]>>
interface StateSetters {
setMessages: SetMessages
setStatus: SetStatus
forceUpdate: () => void
}
function createEventHandlers(
setters: StateSetters,
options: UseSessionOptions,
): Parameters<HandleMessage["setEvents"]>[0] {
return {
onMessage: (msg) => {
setters.setMessages((prev) => [...prev, msg])
},
onToolCall: () => {
setters.setStatus("tool_call")
},
onToolResult: () => {
setters.setStatus("thinking")
},
onConfirmation: options.onConfirmation,
onError: options.onError,
onStatusChange: (s: HandleMessageStatus) => {
setters.setStatus(s)
},
onUndoEntry: () => {
setters.forceUpdate()
},
}
}
async function initializeSession(
deps: UseSessionDependencies,
options: UseSessionOptions,
refs: React.MutableRefObject<SessionRefs>,
setters: StateSetters,
): Promise<void> {
const startSession = new StartSession(deps.sessionStorage)
const result = await startSession.execute(deps.projectName)
refs.current.session = result.session
setters.setMessages([...result.session.history])
const handleMessage = new HandleMessage(
deps.storage,
deps.sessionStorage,
deps.llm,
deps.tools,
deps.projectRoot,
)
if (deps.projectStructure) {
handleMessage.setProjectStructure(deps.projectStructure)
}
handleMessage.setOptions({ autoApply: options.autoApply })
handleMessage.setEvents(createEventHandlers(setters, options))
refs.current.handleMessage = handleMessage
refs.current.undoChange = new UndoChange(deps.sessionStorage, deps.storage)
setters.forceUpdate()
}
export function useSession(
deps: UseSessionDependencies,
options: UseSessionOptions = {},
): UseSessionReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [status, setStatus] = useState<TuiStatus>("ready")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [, setTrigger] = useState(0)
const refs = useRef<SessionRefs>({ session: null, handleMessage: null, undoChange: null })
const forceUpdate = useCallback(() => {
setTrigger((v) => v + 1)
}, [])
useEffect(() => {
setIsLoading(true)
const setters: StateSetters = { setMessages, setStatus, forceUpdate }
initializeSession(deps, options, refs, setters)
.then(() => {
setError(null)
})
.catch((err: unknown) => {
setError(err instanceof Error ? err : new Error(String(err)))
})
.finally(() => {
setIsLoading(false)
})
}, [deps.projectName, forceUpdate])
const sendMessage = useCallback(async (message: string): Promise<void> => {
const { session, handleMessage } = refs.current
if (!session || !handleMessage) {
return
}
try {
setStatus("thinking")
await handleMessage.execute(session, message)
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)))
setStatus("error")
}
}, [])
const undo = useCallback(async (): Promise<boolean> => {
const { session, undoChange } = refs.current
if (!session || !undoChange) {
return false
}
try {
const result = await undoChange.execute(session)
if (result.success) {
forceUpdate()
return true
}
return false
} catch {
return false
}
}, [forceUpdate])
const clearHistory = useCallback(() => {
if (!refs.current.session) {
return
}
refs.current.session.clearHistory()
setMessages([])
forceUpdate()
}, [forceUpdate])
const abort = useCallback(() => {
refs.current.handleMessage?.abort()
setStatus("ready")
}, [])
return {
session: refs.current.session,
messages,
status,
isLoading,
error,
sendMessage,
undo,
clearHistory,
abort,
}
}

View File

@@ -0,0 +1,8 @@
/**
* TUI module - Terminal User Interface.
*/
export { App, type AppDependencies, type ExtendedAppProps } from "./App.js"
export * from "./components/index.js"
export * from "./hooks/index.js"
export * from "./types.js"

View File

@@ -0,0 +1,38 @@
/**
* TUI types and interfaces.
*/
import type { HandleMessageStatus } from "../application/use-cases/HandleMessage.js"
/**
* TUI status - maps to HandleMessageStatus.
*/
export type TuiStatus = HandleMessageStatus
/**
* Git branch information.
*/
export interface BranchInfo {
name: string
isDetached: boolean
}
/**
* Props for the main App component.
*/
export interface AppProps {
projectPath: string
autoApply?: boolean
model?: string
}
/**
* Status bar display data.
*/
export interface StatusBarData {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}

View File

@@ -0,0 +1,145 @@
/**
* Tests for Chat component.
*/
import { describe, expect, it } from "vitest"
import type { ChatProps } from "../../../../src/tui/components/Chat.js"
import type { ChatMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
describe("Chat", () => {
describe("module exports", () => {
it("should export Chat component", async () => {
const mod = await import("../../../../src/tui/components/Chat.js")
expect(mod.Chat).toBeDefined()
expect(typeof mod.Chat).toBe("function")
})
})
describe("ChatProps interface", () => {
it("should accept messages array", () => {
const messages: ChatMessage[] = []
const props: ChatProps = {
messages,
isThinking: false,
}
expect(props.messages).toEqual([])
})
it("should accept isThinking boolean", () => {
const props: ChatProps = {
messages: [],
isThinking: true,
}
expect(props.isThinking).toBe(true)
})
})
describe("message formatting", () => {
it("should handle user messages", () => {
const message: ChatMessage = {
role: "user",
content: "Hello",
timestamp: Date.now(),
}
expect(message.role).toBe("user")
expect(message.content).toBe("Hello")
})
it("should handle assistant messages", () => {
const message: ChatMessage = {
role: "assistant",
content: "Hi there!",
timestamp: Date.now(),
stats: {
tokens: 100,
timeMs: 1000,
toolCalls: 0,
},
}
expect(message.role).toBe("assistant")
expect(message.stats?.tokens).toBe(100)
})
it("should handle tool messages", () => {
const message: ChatMessage = {
role: "tool",
content: "",
timestamp: Date.now(),
toolResults: [
{
callId: "123",
success: true,
data: "result",
durationMs: 50,
},
],
}
expect(message.role).toBe("tool")
expect(message.toolResults?.length).toBe(1)
})
it("should handle system messages", () => {
const message: ChatMessage = {
role: "system",
content: "System notification",
timestamp: Date.now(),
}
expect(message.role).toBe("system")
})
})
describe("timestamp formatting", () => {
it("should format timestamp as HH:MM", () => {
const timestamp = new Date(2025, 0, 1, 14, 30).getTime()
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
const formatted = `${hours}:${minutes}`
expect(formatted).toBe("14:30")
})
})
describe("stats formatting", () => {
it("should format response stats", () => {
const stats = {
tokens: 1247,
timeMs: 3200,
toolCalls: 1,
}
const time = (stats.timeMs / 1000).toFixed(1)
const tokens = stats.tokens.toLocaleString("en-US")
const tools = stats.toolCalls
expect(time).toBe("3.2")
expect(tokens).toBe("1,247")
expect(tools).toBe(1)
})
it("should pluralize tool calls correctly", () => {
const formatTools = (count: number): string => {
return `${String(count)} tool${count > 1 ? "s" : ""}`
}
expect(formatTools(1)).toBe("1 tool")
expect(formatTools(2)).toBe("2 tools")
expect(formatTools(5)).toBe("5 tools")
})
})
describe("tool call formatting", () => {
it("should format tool calls with params", () => {
const toolCall = {
id: "123",
name: "get_lines",
params: { path: "/src/index.ts", start: 1, end: 10 },
}
const params = Object.entries(toolCall.params)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")
expect(params).toBe('path="/src/index.ts" start=1 end=10')
})
})
})

View File

@@ -0,0 +1,184 @@
/**
* Tests for Input component.
*/
import { describe, expect, it, vi } from "vitest"
import type { InputProps } from "../../../../src/tui/components/Input.js"
describe("Input", () => {
describe("module exports", () => {
it("should export Input component", async () => {
const mod = await import("../../../../src/tui/components/Input.js")
expect(mod.Input).toBeDefined()
expect(typeof mod.Input).toBe("function")
})
})
describe("InputProps interface", () => {
it("should accept onSubmit callback", () => {
const onSubmit = vi.fn()
const props: InputProps = {
onSubmit,
history: [],
disabled: false,
}
expect(props.onSubmit).toBe(onSubmit)
})
it("should accept history array", () => {
const history = ["first", "second", "third"]
const props: InputProps = {
onSubmit: vi.fn(),
history,
disabled: false,
}
expect(props.history).toEqual(history)
})
it("should accept disabled state", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: true,
}
expect(props.disabled).toBe(true)
})
it("should accept optional placeholder", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: false,
placeholder: "Custom placeholder...",
}
expect(props.placeholder).toBe("Custom placeholder...")
})
it("should have default placeholder when not provided", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: false,
}
expect(props.placeholder).toBeUndefined()
})
})
describe("history navigation logic", () => {
it("should navigate up through history", () => {
const history = ["first", "second", "third"]
let historyIndex = -1
let value = ""
historyIndex = history.length - 1
value = history[historyIndex] ?? ""
expect(value).toBe("third")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("second")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("first")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("first")
})
it("should navigate down through history", () => {
const history = ["first", "second", "third"]
let historyIndex = 0
let value = ""
const savedInput = "current input"
historyIndex = historyIndex + 1
value = history[historyIndex] ?? ""
expect(value).toBe("second")
historyIndex = historyIndex + 1
value = history[historyIndex] ?? ""
expect(value).toBe("third")
if (historyIndex >= history.length - 1) {
historyIndex = -1
value = savedInput
}
expect(value).toBe("current input")
expect(historyIndex).toBe(-1)
})
it("should save current input when navigating up", () => {
const currentInput = "typing something"
let savedInput = ""
savedInput = currentInput
expect(savedInput).toBe("typing something")
})
it("should restore saved input when navigating past history end", () => {
const savedInput = "original input"
let value = ""
value = savedInput
expect(value).toBe("original input")
})
})
describe("submit behavior", () => {
it("should not submit empty input", () => {
const onSubmit = vi.fn()
const text = " "
if (text.trim()) {
onSubmit(text)
}
expect(onSubmit).not.toHaveBeenCalled()
})
it("should submit non-empty input", () => {
const onSubmit = vi.fn()
const text = "hello"
if (text.trim()) {
onSubmit(text)
}
expect(onSubmit).toHaveBeenCalledWith("hello")
})
it("should not submit when disabled", () => {
const onSubmit = vi.fn()
const text = "hello"
const disabled = true
if (!disabled && text.trim()) {
onSubmit(text)
}
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe("state reset after submit", () => {
it("should reset value after submit", () => {
let value = "test input"
value = ""
expect(value).toBe("")
})
it("should reset history index after submit", () => {
let historyIndex = 2
historyIndex = -1
expect(historyIndex).toBe(-1)
})
it("should reset saved input after submit", () => {
let savedInput = "saved"
savedInput = ""
expect(savedInput).toBe("")
})
})
})

View File

@@ -0,0 +1,112 @@
/**
* Tests for StatusBar component.
*/
import { describe, expect, it } from "vitest"
import type { StatusBarProps } from "../../../../src/tui/components/StatusBar.js"
import type { TuiStatus, BranchInfo } from "../../../../src/tui/types.js"
describe("StatusBar", () => {
describe("module exports", () => {
it("should export StatusBar component", async () => {
const mod = await import("../../../../src/tui/components/StatusBar.js")
expect(mod.StatusBar).toBeDefined()
expect(typeof mod.StatusBar).toBe("function")
})
})
describe("StatusBarProps interface", () => {
it("should accept contextUsage as number", () => {
const props: Partial<StatusBarProps> = {
contextUsage: 0.5,
}
expect(props.contextUsage).toBe(0.5)
})
it("should accept contextUsage from 0 to 1", () => {
const props1: Partial<StatusBarProps> = { contextUsage: 0 }
const props2: Partial<StatusBarProps> = { contextUsage: 0.5 }
const props3: Partial<StatusBarProps> = { contextUsage: 1 }
expect(props1.contextUsage).toBe(0)
expect(props2.contextUsage).toBe(0.5)
expect(props3.contextUsage).toBe(1)
})
it("should accept projectName as string", () => {
const props: Partial<StatusBarProps> = {
projectName: "my-project",
}
expect(props.projectName).toBe("my-project")
})
it("should accept branch info", () => {
const branch: BranchInfo = {
name: "main",
isDetached: false,
}
const props: Partial<StatusBarProps> = { branch }
expect(props.branch?.name).toBe("main")
expect(props.branch?.isDetached).toBe(false)
})
it("should handle detached HEAD state", () => {
const branch: BranchInfo = {
name: "abc1234",
isDetached: true,
}
const props: Partial<StatusBarProps> = { branch }
expect(props.branch?.isDetached).toBe(true)
})
it("should accept sessionTime as string", () => {
const props: Partial<StatusBarProps> = {
sessionTime: "47m",
}
expect(props.sessionTime).toBe("47m")
})
it("should accept status value", () => {
const statuses: TuiStatus[] = [
"ready",
"thinking",
"tool_call",
"awaiting_confirmation",
"error",
]
statuses.forEach((status) => {
const props: Partial<StatusBarProps> = { status }
expect(props.status).toBe(status)
})
})
})
describe("status display logic", () => {
const statusExpectations: Array<{ status: TuiStatus; expectedText: string }> = [
{ status: "ready", expectedText: "ready" },
{ status: "thinking", expectedText: "thinking..." },
{ status: "tool_call", expectedText: "executing..." },
{ status: "awaiting_confirmation", expectedText: "confirm?" },
{ status: "error", expectedText: "error" },
]
statusExpectations.forEach(({ status, expectedText }) => {
it(`should display "${expectedText}" for status "${status}"`, () => {
expect(expectedText).toBeTruthy()
})
})
})
describe("context usage display", () => {
it("should format context usage as percentage", () => {
const usages = [0, 0.1, 0.5, 0.8, 1]
const expected = ["0%", "10%", "50%", "80%", "100%"]
usages.forEach((usage, index) => {
const formatted = `${String(Math.round(usage * 100))}%`
expect(formatted).toBe(expected[index])
})
})
})
})

View File

@@ -0,0 +1,67 @@
/**
* Tests for useHotkeys hook.
*/
import { describe, expect, it, vi, beforeEach } from "vitest"
describe("useHotkeys", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("module exports", () => {
it("should export useHotkeys function", async () => {
const mod = await import("../../../../src/tui/hooks/useHotkeys.js")
expect(mod.useHotkeys).toBeDefined()
expect(typeof mod.useHotkeys).toBe("function")
})
})
describe("HotkeyHandlers interface", () => {
it("should accept onInterrupt callback", () => {
const handlers = {
onInterrupt: vi.fn(),
}
expect(handlers.onInterrupt).toBeDefined()
})
it("should accept onExit callback", () => {
const handlers = {
onExit: vi.fn(),
}
expect(handlers.onExit).toBeDefined()
})
it("should accept onUndo callback", () => {
const handlers = {
onUndo: vi.fn(),
}
expect(handlers.onUndo).toBeDefined()
})
it("should accept all callbacks together", () => {
const handlers = {
onInterrupt: vi.fn(),
onExit: vi.fn(),
onUndo: vi.fn(),
}
expect(handlers.onInterrupt).toBeDefined()
expect(handlers.onExit).toBeDefined()
expect(handlers.onUndo).toBeDefined()
})
})
describe("UseHotkeysOptions interface", () => {
it("should accept enabled option", () => {
const options = {
enabled: true,
}
expect(options.enabled).toBe(true)
})
it("should default enabled to undefined when not provided", () => {
const options = {}
expect((options as { enabled?: boolean }).enabled).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,128 @@
/**
* Tests for useSession hook.
*/
import { describe, expect, it, vi, beforeEach } from "vitest"
import type {
UseSessionDependencies,
UseSessionOptions,
} from "../../../../src/tui/hooks/useSession.js"
describe("useSession", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("module exports", () => {
it("should export useSession function", async () => {
const mod = await import("../../../../src/tui/hooks/useSession.js")
expect(mod.useSession).toBeDefined()
expect(typeof mod.useSession).toBe("function")
})
})
describe("UseSessionDependencies interface", () => {
it("should require storage", () => {
const deps: Partial<UseSessionDependencies> = {
storage: {} as UseSessionDependencies["storage"],
}
expect(deps.storage).toBeDefined()
})
it("should require sessionStorage", () => {
const deps: Partial<UseSessionDependencies> = {
sessionStorage: {} as UseSessionDependencies["sessionStorage"],
}
expect(deps.sessionStorage).toBeDefined()
})
it("should require llm", () => {
const deps: Partial<UseSessionDependencies> = {
llm: {} as UseSessionDependencies["llm"],
}
expect(deps.llm).toBeDefined()
})
it("should require tools", () => {
const deps: Partial<UseSessionDependencies> = {
tools: {} as UseSessionDependencies["tools"],
}
expect(deps.tools).toBeDefined()
})
it("should require projectRoot", () => {
const deps: Partial<UseSessionDependencies> = {
projectRoot: "/path/to/project",
}
expect(deps.projectRoot).toBe("/path/to/project")
})
it("should require projectName", () => {
const deps: Partial<UseSessionDependencies> = {
projectName: "test-project",
}
expect(deps.projectName).toBe("test-project")
})
it("should accept optional projectStructure", () => {
const deps: Partial<UseSessionDependencies> = {
projectStructure: { files: [], directories: [] },
}
expect(deps.projectStructure).toBeDefined()
})
})
describe("UseSessionOptions interface", () => {
it("should accept autoApply option", () => {
const options: UseSessionOptions = {
autoApply: true,
}
expect(options.autoApply).toBe(true)
})
it("should accept onConfirmation callback", () => {
const options: UseSessionOptions = {
onConfirmation: async () => true,
}
expect(options.onConfirmation).toBeDefined()
})
it("should accept onError callback", () => {
const options: UseSessionOptions = {
onError: async () => "skip",
}
expect(options.onError).toBeDefined()
})
it("should allow all options together", () => {
const options: UseSessionOptions = {
autoApply: false,
onConfirmation: async () => false,
onError: async () => "retry",
}
expect(options.autoApply).toBe(false)
expect(options.onConfirmation).toBeDefined()
expect(options.onError).toBeDefined()
})
})
describe("UseSessionReturn interface", () => {
it("should define expected return shape", () => {
const expectedKeys = [
"session",
"messages",
"status",
"isLoading",
"error",
"sendMessage",
"undo",
"clearHistory",
"abort",
]
expectedKeys.forEach((key) => {
expect(key).toBeTruthy()
})
})
})
})

View File

@@ -0,0 +1,171 @@
/**
* Tests for TUI types.
*/
import { describe, expect, it } from "vitest"
import type { TuiStatus, BranchInfo, AppProps, StatusBarData } from "../../../src/tui/types.js"
describe("TUI types", () => {
describe("TuiStatus type", () => {
it("should include ready status", () => {
const status: TuiStatus = "ready"
expect(status).toBe("ready")
})
it("should include thinking status", () => {
const status: TuiStatus = "thinking"
expect(status).toBe("thinking")
})
it("should include tool_call status", () => {
const status: TuiStatus = "tool_call"
expect(status).toBe("tool_call")
})
it("should include awaiting_confirmation status", () => {
const status: TuiStatus = "awaiting_confirmation"
expect(status).toBe("awaiting_confirmation")
})
it("should include error status", () => {
const status: TuiStatus = "error"
expect(status).toBe("error")
})
})
describe("BranchInfo interface", () => {
it("should have name property", () => {
const branch: BranchInfo = {
name: "main",
isDetached: false,
}
expect(branch.name).toBe("main")
})
it("should have isDetached property", () => {
const branch: BranchInfo = {
name: "abc1234",
isDetached: true,
}
expect(branch.isDetached).toBe(true)
})
it("should represent normal branch", () => {
const branch: BranchInfo = {
name: "feature/new-feature",
isDetached: false,
}
expect(branch.name).toBe("feature/new-feature")
expect(branch.isDetached).toBe(false)
})
it("should represent detached HEAD", () => {
const branch: BranchInfo = {
name: "abc1234def5678",
isDetached: true,
}
expect(branch.isDetached).toBe(true)
})
})
describe("AppProps interface", () => {
it("should require projectPath", () => {
const props: AppProps = {
projectPath: "/path/to/project",
}
expect(props.projectPath).toBe("/path/to/project")
})
it("should accept optional autoApply", () => {
const props: AppProps = {
projectPath: "/path/to/project",
autoApply: true,
}
expect(props.autoApply).toBe(true)
})
it("should accept optional model", () => {
const props: AppProps = {
projectPath: "/path/to/project",
model: "qwen2.5-coder:7b-instruct",
}
expect(props.model).toBe("qwen2.5-coder:7b-instruct")
})
it("should accept all optional props together", () => {
const props: AppProps = {
projectPath: "/path/to/project",
autoApply: false,
model: "custom-model",
}
expect(props.projectPath).toBe("/path/to/project")
expect(props.autoApply).toBe(false)
expect(props.model).toBe("custom-model")
})
})
describe("StatusBarData interface", () => {
it("should have contextUsage as number", () => {
const data: StatusBarData = {
contextUsage: 0.5,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "10m",
status: "ready",
}
expect(data.contextUsage).toBe(0.5)
})
it("should have projectName as string", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "my-project",
branch: { name: "main", isDetached: false },
sessionTime: "0m",
status: "ready",
}
expect(data.projectName).toBe("my-project")
})
it("should have branch as BranchInfo", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "develop", isDetached: false },
sessionTime: "0m",
status: "ready",
}
expect(data.branch.name).toBe("develop")
expect(data.branch.isDetached).toBe(false)
})
it("should have sessionTime as string", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "1h 30m",
status: "ready",
}
expect(data.sessionTime).toBe("1h 30m")
})
it("should have status as TuiStatus", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "0m",
status: "thinking",
}
expect(data.status).toBe("thinking")
})
})
describe("module exports", () => {
it("should export all types", async () => {
const mod = await import("../../../src/tui/types.js")
expect(mod).toBeDefined()
})
})
})

View File

@@ -9,7 +9,13 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
exclude: [
"src/**/*.d.ts",
"src/**/index.ts",
"src/**/*.test.ts",
"src/tui/**/*.ts",
"src/tui/**/*.tsx",
],
thresholds: {
lines: 95,
functions: 95,