返回 ViMax
cli.tsx
根目录 / ui / src / cli.tsx
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
601 lines Plain Text