Files
hkuds--vimax/ui/src/cli.tsx
T
2026-07-13 12:23:54 +08:00

602 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <id> 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<WorkspaceLine[]>([]);
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<WorkspaceMeta>({
workspacePath: '.working_dir',
sessionId: '',
stage: '',
compactionUsed: 0,
compactionTarget: compactTargetFromEnv(process.env),
});
const stateRef = useRef<MappingState>(createMappingState());
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
const bufferRef = useRef('');
const responseIdleTimerRef = useRef<NodeJS.Timeout | null>(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 (
<Box flexDirection="column" paddingX={1} width={Math.max(20, terminalWidth - 4)}>
<WorkspacePanel lines={lines} width={width} thinkingFrame={thinkingFrame} meta={workspaceMeta} busy={busy} activityText={activityText} />
{showSlashPopup && <SlashCommandPopup matches={slashMatches} width={width} />}
<Box borderStyle="round" borderColor="white" paddingX={1} marginTop={1} width={width}>
<Text color={busy ? 'gray' : 'white'}>{busy ? '· ' : ' '}</Text>
<InputText value={input} cursor={cursor} busy={busy} />
</Box>
</Box>
);
}
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 <Text color="gray">{value}</Text>;
}
return (
<Text>
<Text color="white">{before}</Text>
<Text color="black" backgroundColor="white">{current}</Text>
<Text color="white">{after}</Text>
</Text>
);
}
function SlashCommandPopup({matches, width}: {matches: ReturnType<typeof matchingSlashCommands>; width: number}) {
const panelWidth = Math.max(20, width);
const visibleMatches = matches.slice(0, 6);
return (
<Box flexDirection="column" borderStyle="round" borderColor="blueBright" paddingX={1} marginTop={1} width={panelWidth}>
{visibleMatches.length > 0 ? (
visibleMatches.map((command) => (
<Text key={command.name}>
<Text color="cyanBright">{command.matchedPrefix}</Text>
<Text color="blueBright">{command.unmatchedSuffix}</Text>
<Text color="gray"> {command.description}</Text>
</Text>
))
) : (
<Text color="gray">No matching slash commands</Text>
)}
</Box>
);
}
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 (
<Box flexDirection="column" width={panelWidth}>
<GradientBorderLine left="╭" fill="─" right="╮" width={panelWidth} />
<WorkspaceContentLine text="ViMax Workspace" color="blueBright" width={panelWidth} />
{workspaceHeaderLines(meta, contentWidth).map((line, index) => (
<WorkspaceContentLine key={`header-${index}`} text={line.text} color={line.color} width={panelWidth} />
))}
{lines.flatMap((line, index) => {
const rawText = ` ${line.text}`;
return wrapText(rawText, contentWidth).map((part, partIndex) => (
<WorkspaceContentLine key={`${line.kind}-${index}-${partIndex}`} text={part} color={lineColor(line)} width={panelWidth} />
));
})}
{busy && wrapText(` ${activityText}${thinkingFrame}`, contentWidth).map((part, index) => (
<WorkspaceContentLine key={`activity-${index}`} text={part} color="cyanBright" width={panelWidth} />
))}
<GradientBorderLine left="╰" fill="─" right="╯" width={panelWidth} />
</Box>
);
}
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<string, string> = {
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 (
<Text>
<Text color={gradientColor(0, width)}>{left}</Text>
{Array.from({length: fillWidth}, (_, index) => (
<Text key={index} color={gradientColor(index + 1, width)}>{fill}</Text>
))}
<Text color={gradientColor(width - 1, width)}>{right}</Text>
</Text>
);
}
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>
<Text color={WORKSPACE_BORDER_COLORS[0]}></Text>
<Text> </Text>
<Text color={color}>{text}</Text>
<Text>{' '.repeat(padding)}</Text>
<Text> </Text>
<Text color={WORKSPACE_BORDER_COLORS[WORKSPACE_BORDER_COLORS.length - 1]}></Text>
</Text>
);
}
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(<App />);