| 1 | import React, {useEffect, useMemo, useRef, useState} from 'react'; |
| 2 | import {render, Box, Text, useApp, useInput, useStdout} from 'ink'; |
| 3 | import stringWidth from 'string-width'; |
| 4 | import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; |
| 5 | import {existsSync} from 'node:fs'; |
| 6 | import path from 'node:path'; |
| 7 | import process from 'node:process'; |
| 8 | import {fileURLToPath} from 'node:url'; |
| 9 | import {applyStreamEvent, createMappingState} from './lineMapping.js'; |
| 10 | import {matchingSlashCommands, shouldShowSlashCommands} from './slashCommands.js'; |
| 11 | import {compactionLabel, compactTargetFromEnv, resolveWorkspacePath, type WorkspaceMeta} from './workspaceMeta.js'; |
| 12 | import type {MappingState, StreamEvent, WorkspaceLine} from './types.js'; |
| 13 | |
| 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 15 | const repoRoot = path.resolve(__dirname, '..', '..'); |
| 16 | |
| 17 | const THINKING_FRAMES = ['', '.', '..', '...']; |
| 18 | |
| 19 | const WORKSPACE_BORDER_COLORS = ['blue', 'blueBright', 'cyan', 'blueBright', 'blue']; |
| 20 | |
| 21 | type CliOptions = { |
| 22 | agentArgs: string[]; |
| 23 | }; |
| 24 | |
| 25 | const cliOptions = parseCliArgs(process.argv.slice(2)); |
| 26 | |
| 27 | function parseCliArgs(argv: string[]): CliOptions { |
| 28 | const agentArgs: string[] = []; |
| 29 | for (let index = 0; index < argv.length; index += 1) { |
| 30 | const arg = argv[index]; |
| 31 | if (arg === '--new-session') { |
| 32 | agentArgs.push('--new-session'); |
| 33 | continue; |
| 34 | } |
| 35 | if (arg === '--session') { |
| 36 | const sessionId = argv[index + 1]; |
| 37 | if (!sessionId) throw new Error('--session requires a session id'); |
| 38 | agentArgs.push('--session', sessionId); |
| 39 | index += 1; |
| 40 | continue; |
| 41 | } |
| 42 | if (arg === '--help' || arg === '-h') { |
| 43 | printHelpAndExit(); |
| 44 | } |
| 45 | throw new Error(`Unknown TUI argument: ${arg}`); |
| 46 | } |
| 47 | return {agentArgs}; |
| 48 | } |
| 49 | |
| 50 | function printHelpAndExit(): never { |
| 51 | console.log(`Usage: |
| 52 | ./vimax tui |
| 53 | ./vimax tui new |
| 54 | ./vimax tui resume [session_id] |
| 55 | |
| 56 | Direct TUI args: |
| 57 | --new-session create and activate a new empty session |
| 58 | --session <id> activate an existing session`); |
| 59 | process.exit(0); |
| 60 | } |
| 61 | |
| 62 | function gradientColor(index: number, total: number): string { |
| 63 | if (total <= 1) return WORKSPACE_BORDER_COLORS[0] ?? 'blue'; |
| 64 | const scaled = (index / (total - 1)) * (WORKSPACE_BORDER_COLORS.length - 1); |
| 65 | return WORKSPACE_BORDER_COLORS[Math.min(WORKSPACE_BORDER_COLORS.length - 1, Math.max(0, Math.round(scaled)))] ?? 'blue'; |
| 66 | } |
| 67 | |
| 68 | function useThinkingFrame(active: boolean): string { |
| 69 | const [frame, setFrame] = useState(0); |
| 70 | useEffect(() => { |
| 71 | if (!active) { |
| 72 | setFrame(0); |
| 73 | return; |
| 74 | } |
| 75 | const timer = setInterval(() => setFrame((value) => (value + 1) % THINKING_FRAMES.length), 220); |
| 76 | return () => clearInterval(timer); |
| 77 | }, [active]); |
| 78 | return THINKING_FRAMES[frame]; |
| 79 | } |
| 80 | |
| 81 | function useTerminalWidth(stdout: NodeJS.WriteStream): number { |
| 82 | const [terminal, setTerminal] = useState({width: Math.max(20, stdout.columns || 100), revision: 0}); |
| 83 | useEffect(() => { |
| 84 | let resizeTimer: NodeJS.Timeout | null = null; |
| 85 | const redraw = (clear: boolean) => { |
| 86 | if (clear) { |
| 87 | // Ink does not always erase cells from the previous frame when the |
| 88 | // terminal is resized quickly. Clear only after resize, then force a |
| 89 | // render even when the new width equals the previous width. |
| 90 | stdout.write('\u001b[2J\u001b[3J\u001b[H'); |
| 91 | } |
| 92 | setTerminal((current) => ({width: Math.max(20, stdout.columns || 100), revision: current.revision + 1})); |
| 93 | }; |
| 94 | const update = () => { |
| 95 | if (resizeTimer) clearTimeout(resizeTimer); |
| 96 | resizeTimer = setTimeout(() => redraw(true), 60); |
| 97 | }; |
| 98 | redraw(false); |
| 99 | stdout.on('resize', update); |
| 100 | return () => { |
| 101 | if (resizeTimer) clearTimeout(resizeTimer); |
| 102 | stdout.off('resize', update); |
| 103 | }; |
| 104 | }, [stdout]); |
| 105 | return terminal.width; |
| 106 | } |
| 107 | |
| 108 | |
| 109 | function baseAgentArgs(): string[] { |
| 110 | return ['main_agent.py', '--jsonl', '--stdin-repl', ...cliOptions.agentArgs]; |
| 111 | } |
| 112 | |
| 113 | function agentCommand(): {command: string; args: string[]} { |
| 114 | if (process.env.VIMAX_AGENT_COMMAND) { |
| 115 | return {command: process.env.VIMAX_AGENT_COMMAND, args: splitArgs(process.env.VIMAX_AGENT_ARGS ?? '')}; |
| 116 | } |
| 117 | if (process.env.VIMAX_PYTHON_CMD) { |
| 118 | return {command: process.env.VIMAX_PYTHON_CMD, args: baseAgentArgs()}; |
| 119 | } |
| 120 | if (process.env.VIMAX_UV_CMD) { |
| 121 | return {command: process.env.VIMAX_UV_CMD, args: ['run', 'python', ...baseAgentArgs()]}; |
| 122 | } |
| 123 | const venvPython = path.join(repoRoot, '.venv', 'bin', 'python3'); |
| 124 | if (existsSync(venvPython)) { |
| 125 | return {command: venvPython, args: baseAgentArgs()}; |
| 126 | } |
| 127 | return {command: 'uv', args: ['run', 'python', ...baseAgentArgs()]}; |
| 128 | } |
| 129 | |
| 130 | |
| 131 | function splitArgs(value: string): string[] { |
| 132 | return value.split(/\s+/).map((part) => part.trim()).filter(Boolean); |
| 133 | } |
| 134 | |
| 135 | function App() { |
| 136 | const {exit} = useApp(); |
| 137 | const {stdout} = useStdout(); |
| 138 | const terminalWidth = useTerminalWidth(stdout); |
| 139 | const [lines, setLines] = useState<WorkspaceLine[]>([]); |
| 140 | const [input, setInput] = useState(''); |
| 141 | const [cursor, setCursor] = useState(0); |
| 142 | const inputRef = useRef(''); |
| 143 | const cursorRef = useRef(0); |
| 144 | const [busy, setBusy] = useState(false); |
| 145 | const [activityText, setActivityText] = useState('ViMax thinking'); |
| 146 | const [workspaceMeta, setWorkspaceMeta] = useState<WorkspaceMeta>({ |
| 147 | workspacePath: '.working_dir', |
| 148 | sessionId: '', |
| 149 | stage: '', |
| 150 | compactionUsed: 0, |
| 151 | compactionTarget: compactTargetFromEnv(process.env), |
| 152 | }); |
| 153 | const stateRef = useRef<MappingState>(createMappingState()); |
| 154 | const childRef = useRef<ChildProcessWithoutNullStreams | null>(null); |
| 155 | const bufferRef = useRef(''); |
| 156 | const responseIdleTimerRef = useRef<NodeJS.Timeout | null>(null); |
| 157 | |
| 158 | const width = useMemo(() => Math.max(20, terminalWidth - 6), [terminalWidth]); |
| 159 | const thinkingFrame = useThinkingFrame(busy); |
| 160 | const slashMatches = useMemo(() => matchingSlashCommands(input), [input]); |
| 161 | const showSlashPopup = shouldShowSlashCommands(input, busy); |
| 162 | |
| 163 | useEffect(() => { |
| 164 | inputRef.current = input; |
| 165 | const length = Array.from(input).length; |
| 166 | if (cursorRef.current > length) { |
| 167 | cursorRef.current = length; |
| 168 | setCursor(length); |
| 169 | } |
| 170 | }, [input]); |
| 171 | |
| 172 | useEffect(() => { |
| 173 | cursorRef.current = cursor; |
| 174 | }, [cursor]); |
| 175 | |
| 176 | function updateInput(next: string, nextCursor: number) { |
| 177 | const length = Array.from(next).length; |
| 178 | const boundedCursor = Math.max(0, Math.min(nextCursor, length)); |
| 179 | inputRef.current = next; |
| 180 | cursorRef.current = boundedCursor; |
| 181 | setInput(next); |
| 182 | setCursor(boundedCursor); |
| 183 | } |
| 184 | |
| 185 | useInput((value, key) => { |
| 186 | if (key.ctrl && value === 'c') { |
| 187 | childRef.current?.kill(); |
| 188 | exit(); |
| 189 | return; |
| 190 | } |
| 191 | if (busy) return; |
| 192 | const currentChars = Array.from(inputRef.current); |
| 193 | const currentCursor = Math.max(0, Math.min(cursorRef.current, currentChars.length)); |
| 194 | if (key.leftArrow) { |
| 195 | updateInput(inputRef.current, currentCursor - 1); |
| 196 | return; |
| 197 | } |
| 198 | if (key.rightArrow) { |
| 199 | updateInput(inputRef.current, currentCursor + 1); |
| 200 | return; |
| 201 | } |
| 202 | if ((key as {home?: boolean}).home) { |
| 203 | updateInput(inputRef.current, 0); |
| 204 | return; |
| 205 | } |
| 206 | if ((key as {end?: boolean}).end) { |
| 207 | updateInput(inputRef.current, currentChars.length); |
| 208 | return; |
| 209 | } |
| 210 | if (value.includes('\r') || value.includes('\n')) { |
| 211 | const [beforeBreak] = value.split(/[\r\n]/, 1); |
| 212 | const pasted = Array.from(beforeBreak ?? ''); |
| 213 | const next = [...currentChars.slice(0, currentCursor), ...pasted, ...currentChars.slice(currentCursor)].join(''); |
| 214 | submit(next); |
| 215 | return; |
| 216 | } |
| 217 | if (key.return) { |
| 218 | submit(inputRef.current); |
| 219 | return; |
| 220 | } |
| 221 | const isBackspace = key.backspace || value === '\u007f' || value === '\b' || value === '\u001b\u007f'; |
| 222 | const isDelete = key.delete || value === '\u001b[3~' || value === '\u001b[P'; |
| 223 | if (isBackspace) { |
| 224 | if (currentCursor === 0) return; |
| 225 | const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); |
| 226 | updateInput(next, currentCursor - 1); |
| 227 | return; |
| 228 | } |
| 229 | if (isDelete) { |
| 230 | if (currentCursor < currentChars.length) { |
| 231 | const next = [...currentChars.slice(0, currentCursor), ...currentChars.slice(currentCursor + 1)].join(''); |
| 232 | updateInput(next, currentCursor); |
| 233 | return; |
| 234 | } |
| 235 | if (currentCursor > 0) { |
| 236 | const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); |
| 237 | updateInput(next, currentCursor - 1); |
| 238 | } |
| 239 | return; |
| 240 | } |
| 241 | if (!key.ctrl && !key.meta && value) { |
| 242 | const inserted = Array.from(value); |
| 243 | const next = [...currentChars.slice(0, currentCursor), ...inserted, ...currentChars.slice(currentCursor)].join(''); |
| 244 | updateInput(next, currentCursor + inserted.length); |
| 245 | } |
| 246 | }); |
| 247 | |
| 248 | useEffect(() => { |
| 249 | const {command, args} = agentCommand(); |
| 250 | const child = spawn(command, args, {cwd: repoRoot, env: process.env}); |
| 251 | childRef.current = child; |
| 252 | |
| 253 | child.stdout.setEncoding('utf8'); |
| 254 | child.stdout.on('data', (chunk: string) => { |
| 255 | bufferRef.current += chunk; |
| 256 | const parts = bufferRef.current.split('\n'); |
| 257 | bufferRef.current = parts.pop() ?? ''; |
| 258 | for (const part of parts) { |
| 259 | consumeJsonLine(part); |
| 260 | } |
| 261 | }); |
| 262 | |
| 263 | child.stderr.setEncoding('utf8'); |
| 264 | child.stderr.on('data', (chunk: string) => { |
| 265 | for (const line of chunk.split('\n')) { |
| 266 | if (!line.trim()) continue; |
| 267 | appendLine({kind: 'terminal', text: `[stderr]: ${line}`}); |
| 268 | } |
| 269 | }); |
| 270 | |
| 271 | child.on('error', (error) => { |
| 272 | appendLine({kind: 'error', text: `agent process error: ${error.message}`}); |
| 273 | }); |
| 274 | |
| 275 | child.on('exit', (code, signal) => { |
| 276 | childRef.current = null; |
| 277 | setBusy(false); |
| 278 | if (code && code !== 0) { |
| 279 | appendLine({kind: 'error', text: `agent process exited with code ${code}`}); |
| 280 | } else if (signal) { |
| 281 | appendLine({kind: 'status', text: `agent process stopped by ${signal}`}); |
| 282 | } |
| 283 | }); |
| 284 | |
| 285 | return () => { |
| 286 | if (responseIdleTimerRef.current) clearTimeout(responseIdleTimerRef.current); |
| 287 | child.kill(); |
| 288 | }; |
| 289 | }, []); |
| 290 | |
| 291 | function appendLine(line: WorkspaceLine) { |
| 292 | stateRef.current = createMappingState(); |
| 293 | setLines((current) => [...current, line]); |
| 294 | } |
| 295 | |
| 296 | function stripThinking(lines: WorkspaceLine[]): WorkspaceLine[] { |
| 297 | return lines.filter((line) => line.kind !== 'thinking'); |
| 298 | } |
| 299 | |
| 300 | function consumeJsonLine(line: string) { |
| 301 | const trimmed = line.trim(); |
| 302 | if (!trimmed) return; |
| 303 | let event: StreamEvent; |
| 304 | try { |
| 305 | event = JSON.parse(trimmed) as StreamEvent; |
| 306 | } catch (error) { |
| 307 | appendLine({kind: 'error', text: `invalid JSONL event: ${trimmed}`}); |
| 308 | return; |
| 309 | } |
| 310 | updateWorkspaceMeta(event); |
| 311 | updateActivity(event); |
| 312 | if (event.type === 'done' || event.type === 'error' || event.type === 'session') { |
| 313 | clearResponseIdleTimer(); |
| 314 | setBusy(false); |
| 315 | } |
| 316 | setLines((current) => { |
| 317 | const mapped = applyStreamEvent(stripThinking(current), stateRef.current, event); |
| 318 | stateRef.current = mapped.state; |
| 319 | return mapped.lines; |
| 320 | }); |
| 321 | } |
| 322 | |
| 323 | function clearResponseIdleTimer() { |
| 324 | if (!responseIdleTimerRef.current) return; |
| 325 | clearTimeout(responseIdleTimerRef.current); |
| 326 | responseIdleTimerRef.current = null; |
| 327 | } |
| 328 | |
| 329 | function scheduleResponseIdleClear() { |
| 330 | clearResponseIdleTimer(); |
| 331 | responseIdleTimerRef.current = setTimeout(() => { |
| 332 | responseIdleTimerRef.current = null; |
| 333 | setBusy(false); |
| 334 | }, 1500); |
| 335 | } |
| 336 | |
| 337 | function updateActivity(event: StreamEvent) { |
| 338 | if (event.type === 'tool_start') { |
| 339 | clearResponseIdleTimer(); |
| 340 | setActivityText(`tool ${event.tool?.name ?? 'unknown'} running`); |
| 341 | return; |
| 342 | } |
| 343 | if (event.type === 'tool_progress') { |
| 344 | clearResponseIdleTimer(); |
| 345 | const stage = event.progress?.stage; |
| 346 | setActivityText(stage ? `tool ${event.tool?.name ?? 'unknown'}: ${stage}` : `tool ${event.tool?.name ?? 'unknown'} running`); |
| 347 | return; |
| 348 | } |
| 349 | if (event.type === 'tool_result') { |
| 350 | clearResponseIdleTimer(); |
| 351 | setActivityText('ViMax thinking'); |
| 352 | return; |
| 353 | } |
| 354 | if (event.type === 'token') { |
| 355 | setActivityText('ViMax responding'); |
| 356 | scheduleResponseIdleClear(); |
| 357 | return; |
| 358 | } |
| 359 | if (event.type === 'status') { |
| 360 | clearResponseIdleTimer(); |
| 361 | setActivityText(statusActivityLabel(event.phase, event.message)); |
| 362 | return; |
| 363 | } |
| 364 | if (event.type === 'done' || event.type === 'error' || event.type === 'session') { |
| 365 | clearResponseIdleTimer(); |
| 366 | setActivityText('ViMax thinking'); |
| 367 | return; |
| 368 | } |
| 369 | if (event.type === 'turn') { |
| 370 | clearResponseIdleTimer(); |
| 371 | setActivityText('ViMax thinking'); |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | function updateWorkspaceMeta(event: StreamEvent) { |
| 376 | if (event.type === 'prompt_trace') { |
| 377 | const used = event.prompt_trace?.totals?.total_tokens ?? event.prompt_trace?.totals?.total_estimated_tokens ?? event.prompt_trace?.total_estimated_tokens; |
| 378 | if (typeof used === 'number' && Number.isFinite(used)) { |
| 379 | setWorkspaceMeta((current) => { |
| 380 | const nextUsed = Math.max(0, Math.round(used)); |
| 381 | const currentPercent = current.compactionTarget > 0 ? Math.round((current.compactionUsed / current.compactionTarget) * 100) : 0; |
| 382 | const nextPercent = current.compactionTarget > 0 ? Math.round((nextUsed / current.compactionTarget) * 100) : 0; |
| 383 | if (currentPercent === nextPercent && Math.abs(nextUsed - current.compactionUsed) < 100) return current; |
| 384 | return {...current, compactionUsed: nextUsed}; |
| 385 | }); |
| 386 | } |
| 387 | return; |
| 388 | } |
| 389 | if (event.type === 'session') { |
| 390 | const session = event.session?.session; |
| 391 | if (!session) return; |
| 392 | setWorkspaceMeta((current) => ({ |
| 393 | ...current, |
| 394 | workspacePath: resolveWorkspacePath(repoRoot, session.working_dir), |
| 395 | sessionId: session.session_id ?? current.sessionId, |
| 396 | stage: session.stage ?? current.stage, |
| 397 | })); |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | function submit(value: string) { |
| 402 | const prompt = value.trim(); |
| 403 | if (!prompt || busy) return; |
| 404 | const child = childRef.current; |
| 405 | if (!child || child.killed || !child.stdin.writable) { |
| 406 | appendLine({kind: 'error', text: 'agent process is not available'}); |
| 407 | return; |
| 408 | } |
| 409 | setLines((current) => [...stripThinking(current), {kind: 'user', text: prompt}]); |
| 410 | clearResponseIdleTimer(); |
| 411 | setActivityText('ViMax thinking'); |
| 412 | stateRef.current = createMappingState(); |
| 413 | updateInput('', 0); |
| 414 | setBusy(true); |
| 415 | child.stdin.write(`${prompt}\n`); |
| 416 | } |
| 417 | |
| 418 | return ( |
| 419 | <Box flexDirection="column" paddingX={1} width={Math.max(20, terminalWidth - 4)}> |
| 420 | <WorkspacePanel lines={lines} width={width} thinkingFrame={thinkingFrame} meta={workspaceMeta} busy={busy} activityText={activityText} /> |
| 421 | {showSlashPopup && <SlashCommandPopup matches={slashMatches} width={width} />} |
| 422 | <Box borderStyle="round" borderColor="white" paddingX={1} marginTop={1} width={width}> |
| 423 | <Text color={busy ? 'gray' : 'white'}>{busy ? '· ' : '› '}</Text> |
| 424 | <InputText value={input} cursor={cursor} busy={busy} /> |
| 425 | </Box> |
| 426 | </Box> |
| 427 | ); |
| 428 | } |
| 429 | |
| 430 | |
| 431 | function InputText({value, cursor, busy}: {value: string; cursor: number; busy: boolean}) { |
| 432 | const chars = Array.from(value); |
| 433 | const boundedCursor = Math.max(0, Math.min(cursor, chars.length)); |
| 434 | const before = chars.slice(0, boundedCursor).join(''); |
| 435 | const current = chars[boundedCursor] ?? ' '; |
| 436 | const after = chars.slice(boundedCursor + 1).join(''); |
| 437 | if (busy) { |
| 438 | return <Text color="gray">{value}</Text>; |
| 439 | } |
| 440 | return ( |
| 441 | <Text> |
| 442 | <Text color="white">{before}</Text> |
| 443 | <Text color="black" backgroundColor="white">{current}</Text> |
| 444 | <Text color="white">{after}</Text> |
| 445 | </Text> |
| 446 | ); |
| 447 | } |
| 448 | |
| 449 | function SlashCommandPopup({matches, width}: {matches: ReturnType<typeof matchingSlashCommands>; width: number}) { |
| 450 | const panelWidth = Math.max(20, width); |
| 451 | const visibleMatches = matches.slice(0, 6); |
| 452 | return ( |
| 453 | <Box flexDirection="column" borderStyle="round" borderColor="blueBright" paddingX={1} marginTop={1} width={panelWidth}> |
| 454 | {visibleMatches.length > 0 ? ( |
| 455 | visibleMatches.map((command) => ( |
| 456 | <Text key={command.name}> |
| 457 | <Text color="cyanBright">{command.matchedPrefix}</Text> |
| 458 | <Text color="blueBright">{command.unmatchedSuffix}</Text> |
| 459 | <Text color="gray"> {command.description}</Text> |
| 460 | </Text> |
| 461 | )) |
| 462 | ) : ( |
| 463 | <Text color="gray">No matching slash commands</Text> |
| 464 | )} |
| 465 | </Box> |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | function WorkspacePanel({lines, width, thinkingFrame, meta, busy, activityText}: {lines: WorkspaceLine[]; width: number; thinkingFrame: string; meta: WorkspaceMeta; busy: boolean; activityText: string}) { |
| 470 | const panelWidth = Math.max(20, width); |
| 471 | const contentWidth = Math.max(1, panelWidth - 4); |
| 472 | return ( |
| 473 | <Box flexDirection="column" width={panelWidth}> |
| 474 | <GradientBorderLine left="╭" fill="─" right="╮" width={panelWidth} /> |
| 475 | <WorkspaceContentLine text="ViMax Workspace" color="blueBright" width={panelWidth} /> |
| 476 | {workspaceHeaderLines(meta, contentWidth).map((line, index) => ( |
| 477 | <WorkspaceContentLine key={`header-${index}`} text={line.text} color={line.color} width={panelWidth} /> |
| 478 | ))} |
| 479 | {lines.flatMap((line, index) => { |
| 480 | const rawText = `› ${line.text}`; |
| 481 | return wrapText(rawText, contentWidth).map((part, partIndex) => ( |
| 482 | <WorkspaceContentLine key={`${line.kind}-${index}-${partIndex}`} text={part} color={lineColor(line)} width={panelWidth} /> |
| 483 | )); |
| 484 | })} |
| 485 | {busy && wrapText(`› ${activityText}${thinkingFrame}`, contentWidth).map((part, index) => ( |
| 486 | <WorkspaceContentLine key={`activity-${index}`} text={part} color="cyanBright" width={panelWidth} /> |
| 487 | ))} |
| 488 | <GradientBorderLine left="╰" fill="─" right="╯" width={panelWidth} /> |
| 489 | </Box> |
| 490 | ); |
| 491 | } |
| 492 | |
| 493 | function workspaceHeaderLines(meta: WorkspaceMeta, width: number): Array<{text: string; color: string}> { |
| 494 | const rows: Array<{text: string; color: string}> = []; |
| 495 | for (const part of wrapText(`Path: ${meta.workspacePath}`, width)) { |
| 496 | rows.push({text: part, color: 'gray'}); |
| 497 | } |
| 498 | const session = [meta.sessionId, displayStage(meta.stage)].filter(Boolean).join(' · '); |
| 499 | if (session) { |
| 500 | for (const part of wrapText(`Session: ${session}`, width)) { |
| 501 | rows.push({text: part, color: 'gray'}); |
| 502 | } |
| 503 | } |
| 504 | for (const part of wrapText(compactionLabel(meta.compactionUsed, meta.compactionTarget), width)) { |
| 505 | rows.push({text: part, color: 'cyanBright'}); |
| 506 | } |
| 507 | return rows; |
| 508 | } |
| 509 | |
| 510 | function statusActivityLabel(phase: string | undefined, message: string | undefined): string { |
| 511 | if (phase === 'compact') return 'compacting context'; |
| 512 | if (phase === 'sampling_assistant') return 'ViMax thinking'; |
| 513 | if (phase === 'executing_tools') return 'running tools'; |
| 514 | const normalized = String(message ?? '').trim(); |
| 515 | return normalized || 'ViMax thinking'; |
| 516 | } |
| 517 | |
| 518 | function displayStage(stage: string): string { |
| 519 | const labels: Record<string, string> = { |
| 520 | created: 'Created', |
| 521 | narrative_planning: 'Planning text', |
| 522 | narrative_planned: 'Text planned', |
| 523 | novel_planning: 'Planning novel', |
| 524 | novel_planned: 'Novel planned', |
| 525 | rendering: 'Rendering', |
| 526 | rendered: 'Rendered', |
| 527 | error: 'Error', |
| 528 | }; |
| 529 | return labels[stage] ?? stage.replace(/_/g, ' '); |
| 530 | } |
| 531 | |
| 532 | function GradientBorderLine({left, fill, right, width}: {left: string; fill: string; right: string; width: number}) { |
| 533 | const fillWidth = Math.max(0, width - 2); |
| 534 | return ( |
| 535 | <Text> |
| 536 | <Text color={gradientColor(0, width)}>{left}</Text> |
| 537 | {Array.from({length: fillWidth}, (_, index) => ( |
| 538 | <Text key={index} color={gradientColor(index + 1, width)}>{fill}</Text> |
| 539 | ))} |
| 540 | <Text color={gradientColor(width - 1, width)}>{right}</Text> |
| 541 | </Text> |
| 542 | ); |
| 543 | } |
| 544 | |
| 545 | function WorkspaceContentLine({text, color, width}: {text: string; color: string; width: number}) { |
| 546 | const contentWidth = Math.max(1, width - 4); |
| 547 | const padding = Math.max(0, contentWidth - stringWidth(text)); |
| 548 | return ( |
| 549 | <Text> |
| 550 | <Text color={WORKSPACE_BORDER_COLORS[0]}>│</Text> |
| 551 | <Text> </Text> |
| 552 | <Text color={color}>{text}</Text> |
| 553 | <Text>{' '.repeat(padding)}</Text> |
| 554 | <Text> </Text> |
| 555 | <Text color={WORKSPACE_BORDER_COLORS[WORKSPACE_BORDER_COLORS.length - 1]}>│</Text> |
| 556 | </Text> |
| 557 | ); |
| 558 | } |
| 559 | |
| 560 | function wrapText(text: string, width: number): string[] { |
| 561 | if (width <= 0) return [text]; |
| 562 | const rows: string[] = []; |
| 563 | for (const segment of text.split(/\r?\n/)) { |
| 564 | let current = ''; |
| 565 | let currentWidth = 0; |
| 566 | for (const char of Array.from(segment)) { |
| 567 | const charWidth = stringWidth(char); |
| 568 | if (current && currentWidth + charWidth > width) { |
| 569 | rows.push(current); |
| 570 | current = char; |
| 571 | currentWidth = charWidth; |
| 572 | } else { |
| 573 | current += char; |
| 574 | currentWidth += charWidth; |
| 575 | } |
| 576 | } |
| 577 | rows.push(current); |
| 578 | } |
| 579 | return rows; |
| 580 | } |
| 581 | |
| 582 | function lineColor(line: WorkspaceLine): string { |
| 583 | if (line.kind === 'user') return 'yellow'; |
| 584 | if (line.kind === 'assistant') return 'white'; |
| 585 | if (line.kind === 'thinking') return 'cyanBright'; |
| 586 | if (line.kind === 'terminal') return 'cyan'; |
| 587 | if (line.kind === 'error') return 'red'; |
| 588 | if (line.kind === 'tool' && line.status === 'error') return 'red'; |
| 589 | if (line.kind === 'tool') return 'magenta'; |
| 590 | return 'gray'; |
| 591 | } |
| 592 | |
| 593 | function clearTerminalForTuiStart() { |
| 594 | if (process.env.VIMAX_TUI_NO_CLEAR === '1') return; |
| 595 | if (!process.stdout.isTTY) return; |
| 596 | process.stdout.write('\u001b[2J\u001b[3J\u001b[H'); |
| 597 | } |
| 598 | |
| 599 | clearTerminalForTuiStart(); |
| 600 | render(<App />); |
| 601 |