mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat(ipuaro): add TUI components and hooks (v0.11.0)
This commit is contained in:
@@ -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
|
||||
|
||||
167
packages/ipuaro/src/tui/App.tsx
Normal file
167
packages/ipuaro/src/tui/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
packages/ipuaro/src/tui/components/Chat.tsx
Normal file
170
packages/ipuaro/src/tui/components/Chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
packages/ipuaro/src/tui/components/Input.tsx
Normal file
99
packages/ipuaro/src/tui/components/Input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
packages/ipuaro/src/tui/components/StatusBar.tsx
Normal file
81
packages/ipuaro/src/tui/components/StatusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
packages/ipuaro/src/tui/components/index.ts
Normal file
7
packages/ipuaro/src/tui/components/index.ts
Normal 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"
|
||||
11
packages/ipuaro/src/tui/hooks/index.ts
Normal file
11
packages/ipuaro/src/tui/hooks/index.ts
Normal 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"
|
||||
59
packages/ipuaro/src/tui/hooks/useHotkeys.ts
Normal file
59
packages/ipuaro/src/tui/hooks/useHotkeys.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
205
packages/ipuaro/src/tui/hooks/useSession.ts
Normal file
205
packages/ipuaro/src/tui/hooks/useSession.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
packages/ipuaro/src/tui/index.ts
Normal file
8
packages/ipuaro/src/tui/index.ts
Normal 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"
|
||||
38
packages/ipuaro/src/tui/types.ts
Normal file
38
packages/ipuaro/src/tui/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user