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

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