import React, {useEffect, useMemo, useRef, useState} from 'react'; import {render, Box, Text, useApp, useInput, useStdout} from 'ink'; import stringWidth from 'string-width'; import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {existsSync} from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; import {applyStreamEvent, createMappingState} from './lineMapping.js'; import {matchingSlashCommands, shouldShowSlashCommands} from './slashCommands.js'; import {compactionLabel, compactTargetFromEnv, resolveWorkspacePath, type WorkspaceMeta} from './workspaceMeta.js'; import type {MappingState, StreamEvent, WorkspaceLine} from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..', '..'); const THINKING_FRAMES = ['', '.', '..', '...']; const WORKSPACE_BORDER_COLORS = ['blue', 'blueBright', 'cyan', 'blueBright', 'blue']; type CliOptions = { agentArgs: string[]; }; const cliOptions = parseCliArgs(process.argv.slice(2)); function parseCliArgs(argv: string[]): CliOptions { const agentArgs: string[] = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--new-session') { agentArgs.push('--new-session'); continue; } if (arg === '--session') { const sessionId = argv[index + 1]; if (!sessionId) throw new Error('--session requires a session id'); agentArgs.push('--session', sessionId); index += 1; continue; } if (arg === '--help' || arg === '-h') { printHelpAndExit(); } throw new Error(`Unknown TUI argument: ${arg}`); } return {agentArgs}; } function printHelpAndExit(): never { console.log(`Usage: ./vimax tui ./vimax tui new ./vimax tui resume [session_id] Direct TUI args: --new-session create and activate a new empty session --session activate an existing session`); process.exit(0); } function gradientColor(index: number, total: number): string { if (total <= 1) return WORKSPACE_BORDER_COLORS[0] ?? 'blue'; const scaled = (index / (total - 1)) * (WORKSPACE_BORDER_COLORS.length - 1); return WORKSPACE_BORDER_COLORS[Math.min(WORKSPACE_BORDER_COLORS.length - 1, Math.max(0, Math.round(scaled)))] ?? 'blue'; } function useThinkingFrame(active: boolean): string { const [frame, setFrame] = useState(0); useEffect(() => { if (!active) { setFrame(0); return; } const timer = setInterval(() => setFrame((value) => (value + 1) % THINKING_FRAMES.length), 220); return () => clearInterval(timer); }, [active]); return THINKING_FRAMES[frame]; } function useTerminalWidth(stdout: NodeJS.WriteStream): number { const [terminal, setTerminal] = useState({width: Math.max(20, stdout.columns || 100), revision: 0}); useEffect(() => { let resizeTimer: NodeJS.Timeout | null = null; const redraw = (clear: boolean) => { if (clear) { // Ink does not always erase cells from the previous frame when the // terminal is resized quickly. Clear only after resize, then force a // render even when the new width equals the previous width. stdout.write('\u001b[2J\u001b[3J\u001b[H'); } setTerminal((current) => ({width: Math.max(20, stdout.columns || 100), revision: current.revision + 1})); }; const update = () => { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(() => redraw(true), 60); }; redraw(false); stdout.on('resize', update); return () => { if (resizeTimer) clearTimeout(resizeTimer); stdout.off('resize', update); }; }, [stdout]); return terminal.width; } function baseAgentArgs(): string[] { return ['main_agent.py', '--jsonl', '--stdin-repl', ...cliOptions.agentArgs]; } function agentCommand(): {command: string; args: string[]} { if (process.env.VIMAX_AGENT_COMMAND) { return {command: process.env.VIMAX_AGENT_COMMAND, args: splitArgs(process.env.VIMAX_AGENT_ARGS ?? '')}; } if (process.env.VIMAX_PYTHON_CMD) { return {command: process.env.VIMAX_PYTHON_CMD, args: baseAgentArgs()}; } const bundledUv = process.env.VIMAX_UV_CMD ?? '/home/xavierhuang/.local/bin/uv'; if (existsSync(bundledUv)) { return {command: bundledUv, args: ['run', 'python', ...baseAgentArgs()]}; } const venvPython = path.join(repoRoot, '.venv', 'bin', 'python3'); if (existsSync(venvPython)) { return {command: venvPython, args: baseAgentArgs()}; } return {command: 'uv', args: ['run', 'python', ...baseAgentArgs()]}; } function splitArgs(value: string): string[] { return value.split(/\s+/).map((part) => part.trim()).filter(Boolean); } function App() { const {exit} = useApp(); const {stdout} = useStdout(); const terminalWidth = useTerminalWidth(stdout); const [lines, setLines] = useState([]); const [input, setInput] = useState(''); const [cursor, setCursor] = useState(0); const inputRef = useRef(''); const cursorRef = useRef(0); const [busy, setBusy] = useState(false); const [activityText, setActivityText] = useState('ViMax thinking'); const [workspaceMeta, setWorkspaceMeta] = useState({ workspacePath: '.working_dir', sessionId: '', stage: '', compactionUsed: 0, compactionTarget: compactTargetFromEnv(process.env), }); const stateRef = useRef(createMappingState()); const childRef = useRef(null); const bufferRef = useRef(''); const responseIdleTimerRef = useRef(null); const width = useMemo(() => Math.max(20, terminalWidth - 6), [terminalWidth]); const thinkingFrame = useThinkingFrame(busy); const slashMatches = useMemo(() => matchingSlashCommands(input), [input]); const showSlashPopup = shouldShowSlashCommands(input, busy); useEffect(() => { inputRef.current = input; const length = Array.from(input).length; if (cursorRef.current > length) { cursorRef.current = length; setCursor(length); } }, [input]); useEffect(() => { cursorRef.current = cursor; }, [cursor]); function updateInput(next: string, nextCursor: number) { const length = Array.from(next).length; const boundedCursor = Math.max(0, Math.min(nextCursor, length)); inputRef.current = next; cursorRef.current = boundedCursor; setInput(next); setCursor(boundedCursor); } useInput((value, key) => { if (key.ctrl && value === 'c') { childRef.current?.kill(); exit(); return; } if (busy) return; const currentChars = Array.from(inputRef.current); const currentCursor = Math.max(0, Math.min(cursorRef.current, currentChars.length)); if (key.leftArrow) { updateInput(inputRef.current, currentCursor - 1); return; } if (key.rightArrow) { updateInput(inputRef.current, currentCursor + 1); return; } if ((key as {home?: boolean}).home) { updateInput(inputRef.current, 0); return; } if ((key as {end?: boolean}).end) { updateInput(inputRef.current, currentChars.length); return; } if (value.includes('\r') || value.includes('\n')) { const [beforeBreak] = value.split(/[\r\n]/, 1); const pasted = Array.from(beforeBreak ?? ''); const next = [...currentChars.slice(0, currentCursor), ...pasted, ...currentChars.slice(currentCursor)].join(''); submit(next); return; } if (key.return) { submit(inputRef.current); return; } const isBackspace = key.backspace || value === '\u007f' || value === '\b' || value === '\u001b\u007f'; const isDelete = key.delete || value === '\u001b[3~' || value === '\u001b[P'; if (isBackspace) { if (currentCursor === 0) return; const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); updateInput(next, currentCursor - 1); return; } if (isDelete) { if (currentCursor < currentChars.length) { const next = [...currentChars.slice(0, currentCursor), ...currentChars.slice(currentCursor + 1)].join(''); updateInput(next, currentCursor); return; } if (currentCursor > 0) { const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); updateInput(next, currentCursor - 1); } return; } if (!key.ctrl && !key.meta && value) { const inserted = Array.from(value); const next = [...currentChars.slice(0, currentCursor), ...inserted, ...currentChars.slice(currentCursor)].join(''); updateInput(next, currentCursor + inserted.length); } }); useEffect(() => { const {command, args} = agentCommand(); const child = spawn(command, args, {cwd: repoRoot, env: process.env}); childRef.current = child; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { bufferRef.current += chunk; const parts = bufferRef.current.split('\n'); bufferRef.current = parts.pop() ?? ''; for (const part of parts) { consumeJsonLine(part); } }); child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk: string) => { for (const line of chunk.split('\n')) { if (!line.trim()) continue; appendLine({kind: 'terminal', text: `[stderr]: ${line}`}); } }); child.on('error', (error) => { appendLine({kind: 'error', text: `agent process error: ${error.message}`}); }); child.on('exit', (code, signal) => { childRef.current = null; setBusy(false); if (code && code !== 0) { appendLine({kind: 'error', text: `agent process exited with code ${code}`}); } else if (signal) { appendLine({kind: 'status', text: `agent process stopped by ${signal}`}); } }); return () => { if (responseIdleTimerRef.current) clearTimeout(responseIdleTimerRef.current); child.kill(); }; }, []); function appendLine(line: WorkspaceLine) { stateRef.current = createMappingState(); setLines((current) => [...current, line]); } function stripThinking(lines: WorkspaceLine[]): WorkspaceLine[] { return lines.filter((line) => line.kind !== 'thinking'); } function consumeJsonLine(line: string) { const trimmed = line.trim(); if (!trimmed) return; let event: StreamEvent; try { event = JSON.parse(trimmed) as StreamEvent; } catch (error) { appendLine({kind: 'error', text: `invalid JSONL event: ${trimmed}`}); return; } updateWorkspaceMeta(event); updateActivity(event); if (event.type === 'done' || event.type === 'error' || event.type === 'session') { clearResponseIdleTimer(); setBusy(false); } setLines((current) => { const mapped = applyStreamEvent(stripThinking(current), stateRef.current, event); stateRef.current = mapped.state; return mapped.lines; }); } function clearResponseIdleTimer() { if (!responseIdleTimerRef.current) return; clearTimeout(responseIdleTimerRef.current); responseIdleTimerRef.current = null; } function scheduleResponseIdleClear() { clearResponseIdleTimer(); responseIdleTimerRef.current = setTimeout(() => { responseIdleTimerRef.current = null; setBusy(false); }, 1500); } function updateActivity(event: StreamEvent) { if (event.type === 'tool_start') { clearResponseIdleTimer(); setActivityText(`tool ${event.tool?.name ?? 'unknown'} running`); return; } if (event.type === 'tool_progress') { clearResponseIdleTimer(); const stage = event.progress?.stage; setActivityText(stage ? `tool ${event.tool?.name ?? 'unknown'}: ${stage}` : `tool ${event.tool?.name ?? 'unknown'} running`); return; } if (event.type === 'tool_result') { clearResponseIdleTimer(); setActivityText('ViMax thinking'); return; } if (event.type === 'token') { setActivityText('ViMax responding'); scheduleResponseIdleClear(); return; } if (event.type === 'status') { clearResponseIdleTimer(); setActivityText(statusActivityLabel(event.phase, event.message)); return; } if (event.type === 'done' || event.type === 'error' || event.type === 'session') { clearResponseIdleTimer(); setActivityText('ViMax thinking'); return; } if (event.type === 'turn') { clearResponseIdleTimer(); setActivityText('ViMax thinking'); } } function updateWorkspaceMeta(event: StreamEvent) { if (event.type === 'prompt_trace') { const used = event.prompt_trace?.totals?.total_tokens ?? event.prompt_trace?.totals?.total_estimated_tokens ?? event.prompt_trace?.total_estimated_tokens; if (typeof used === 'number' && Number.isFinite(used)) { setWorkspaceMeta((current) => { const nextUsed = Math.max(0, Math.round(used)); const currentPercent = current.compactionTarget > 0 ? Math.round((current.compactionUsed / current.compactionTarget) * 100) : 0; const nextPercent = current.compactionTarget > 0 ? Math.round((nextUsed / current.compactionTarget) * 100) : 0; if (currentPercent === nextPercent && Math.abs(nextUsed - current.compactionUsed) < 100) return current; return {...current, compactionUsed: nextUsed}; }); } return; } if (event.type === 'session') { const session = event.session?.session; if (!session) return; setWorkspaceMeta((current) => ({ ...current, workspacePath: resolveWorkspacePath(repoRoot, session.working_dir), sessionId: session.session_id ?? current.sessionId, stage: session.stage ?? current.stage, })); } } function submit(value: string) { const prompt = value.trim(); if (!prompt || busy) return; const child = childRef.current; if (!child || child.killed || !child.stdin.writable) { appendLine({kind: 'error', text: 'agent process is not available'}); return; } setLines((current) => [...stripThinking(current), {kind: 'user', text: prompt}]); clearResponseIdleTimer(); setActivityText('ViMax thinking'); stateRef.current = createMappingState(); updateInput('', 0); setBusy(true); child.stdin.write(`${prompt}\n`); } return ( {showSlashPopup && } {busy ? '· ' : '› '} ); } function InputText({value, cursor, busy}: {value: string; cursor: number; busy: boolean}) { const chars = Array.from(value); const boundedCursor = Math.max(0, Math.min(cursor, chars.length)); const before = chars.slice(0, boundedCursor).join(''); const current = chars[boundedCursor] ?? ' '; const after = chars.slice(boundedCursor + 1).join(''); if (busy) { return {value}; } return ( {before} {current} {after} ); } function SlashCommandPopup({matches, width}: {matches: ReturnType; width: number}) { const panelWidth = Math.max(20, width); const visibleMatches = matches.slice(0, 6); return ( {visibleMatches.length > 0 ? ( visibleMatches.map((command) => ( {command.matchedPrefix} {command.unmatchedSuffix} {command.description} )) ) : ( No matching slash commands )} ); } function WorkspacePanel({lines, width, thinkingFrame, meta, busy, activityText}: {lines: WorkspaceLine[]; width: number; thinkingFrame: string; meta: WorkspaceMeta; busy: boolean; activityText: string}) { const panelWidth = Math.max(20, width); const contentWidth = Math.max(1, panelWidth - 4); return ( {workspaceHeaderLines(meta, contentWidth).map((line, index) => ( ))} {lines.flatMap((line, index) => { const rawText = `› ${line.text}`; return wrapText(rawText, contentWidth).map((part, partIndex) => ( )); })} {busy && wrapText(`› ${activityText}${thinkingFrame}`, contentWidth).map((part, index) => ( ))} ); } function workspaceHeaderLines(meta: WorkspaceMeta, width: number): Array<{text: string; color: string}> { const rows: Array<{text: string; color: string}> = []; for (const part of wrapText(`Path: ${meta.workspacePath}`, width)) { rows.push({text: part, color: 'gray'}); } const session = [meta.sessionId, displayStage(meta.stage)].filter(Boolean).join(' · '); if (session) { for (const part of wrapText(`Session: ${session}`, width)) { rows.push({text: part, color: 'gray'}); } } for (const part of wrapText(compactionLabel(meta.compactionUsed, meta.compactionTarget), width)) { rows.push({text: part, color: 'cyanBright'}); } return rows; } function statusActivityLabel(phase: string | undefined, message: string | undefined): string { if (phase === 'compact') return 'compacting context'; if (phase === 'sampling_assistant') return 'ViMax thinking'; if (phase === 'executing_tools') return 'running tools'; const normalized = String(message ?? '').trim(); return normalized || 'ViMax thinking'; } function displayStage(stage: string): string { const labels: Record = { created: 'Created', narrative_planning: 'Planning text', narrative_planned: 'Text planned', novel_planning: 'Planning novel', novel_planned: 'Novel planned', rendering: 'Rendering', rendered: 'Rendered', error: 'Error', }; return labels[stage] ?? stage.replace(/_/g, ' '); } function GradientBorderLine({left, fill, right, width}: {left: string; fill: string; right: string; width: number}) { const fillWidth = Math.max(0, width - 2); return ( {left} {Array.from({length: fillWidth}, (_, index) => ( {fill} ))} {right} ); } function WorkspaceContentLine({text, color, width}: {text: string; color: string; width: number}) { const contentWidth = Math.max(1, width - 4); const padding = Math.max(0, contentWidth - stringWidth(text)); return ( {text} {' '.repeat(padding)} ); } function wrapText(text: string, width: number): string[] { if (width <= 0) return [text]; const rows: string[] = []; for (const segment of text.split(/\r?\n/)) { let current = ''; let currentWidth = 0; for (const char of Array.from(segment)) { const charWidth = stringWidth(char); if (current && currentWidth + charWidth > width) { rows.push(current); current = char; currentWidth = charWidth; } else { current += char; currentWidth += charWidth; } } rows.push(current); } return rows; } function lineColor(line: WorkspaceLine): string { if (line.kind === 'user') return 'yellow'; if (line.kind === 'assistant') return 'white'; if (line.kind === 'thinking') return 'cyanBright'; if (line.kind === 'terminal') return 'cyan'; if (line.kind === 'error') return 'red'; if (line.kind === 'tool' && line.status === 'error') return 'red'; if (line.kind === 'tool') return 'magenta'; return 'gray'; } function clearTerminalForTuiStart() { if (process.env.VIMAX_TUI_NO_CLEAR === '1') return; if (!process.stdout.isTTY) return; process.stdout.write('\u001b[2J\u001b[3J\u001b[H'); } clearTerminalForTuiStart(); render();