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

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