chore: import upstream snapshot with attribution
This commit is contained in:
Generated
+1196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "vimax-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tui": "tsx src/cli.tsx",
|
||||
"test": "tsx src/lineMapping.test.ts && tsx src/slashCommands.test.ts && tsx src/workspaceMeta.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "^4.4.1",
|
||||
"ink-text-input": "^5.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
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 />);
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyStreamEvent, createMappingState} from './lineMapping.js';
|
||||
import type {MappingState, WorkspaceLine} from './types.js';
|
||||
|
||||
let lines: WorkspaceLine[] = [];
|
||||
let state: MappingState = createMappingState();
|
||||
|
||||
lines = [...lines, {kind: 'user', text: 'start'}];
|
||||
assert.deepEqual(lines[0], {kind: 'user', text: 'start'});
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'token', delta: 'hello'}));
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'token', delta: ' world'}));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.deepEqual(lines[1], {kind: 'assistant', text: 'hello world'});
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'tool_start', tool: {name: 'vimax_narrative_planning'}}));
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'tool_progress', tool: {name: 'vimax_narrative_planning'}, progress: {stage: 'running', message: 'planning'}}));
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'tool_result', tool_result: {name: 'vimax_narrative_planning', ok: true}}));
|
||||
assert.equal(lines.at(-3)?.kind, 'tool');
|
||||
assert.equal(lines.at(-2)?.kind, 'tool');
|
||||
assert.deepEqual(lines.at(-1), {kind: 'tool', status: 'done', text: 'tool vimax_narrative_planning done'});
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'tool_result', tool_result: {name: 'vimax_narrative_planning', ok: false, content: 'Developing story failed: Request timed out.'}}));
|
||||
assert.deepEqual(lines.at(-1), {kind: 'tool', status: 'error', text: 'tool vimax_narrative_planning error: Developing story failed: Request timed out.'});
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'terminal', stream: 'stdout', line: 'render log'}));
|
||||
assert.deepEqual(lines.at(-1), {kind: 'terminal', text: '[stdout]: render log'});
|
||||
|
||||
const lengthBeforeInternalEvents = lines.length;
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'turn', turn_id: 'turn-1'}));
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'status', phase: 'sampling_assistant', message: 'Sampling assistant'}));
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'session', session: {active_session_id: 's1', session: {session_id: 's1', stage: 'narrative_planned', working_dir: '.working_dir/s1'}}}));
|
||||
assert.equal(lines.length, lengthBeforeInternalEvents);
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'error', message: 'bad'}));
|
||||
assert.deepEqual(lines.at(-1), {kind: 'error', text: 'bad'});
|
||||
|
||||
({lines, state} = applyStreamEvent(lines, state, {type: 'done', assistant: 'hello world'}));
|
||||
assert.equal(state.assistantStreaming, false);
|
||||
|
||||
console.log('lineMapping tests passed');
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {MappingState, StreamEvent, WorkspaceLine} from './types.js';
|
||||
|
||||
export function createMappingState(): MappingState {
|
||||
return {assistantStreaming: false};
|
||||
}
|
||||
|
||||
export function appendUserLine(lines: WorkspaceLine[], text: string): {lines: WorkspaceLine[]; state: MappingState} {
|
||||
return {lines: [...lines, {kind: 'user', text}], state: createMappingState()};
|
||||
}
|
||||
|
||||
export function applyStreamEvent(lines: WorkspaceLine[], state: MappingState, event: StreamEvent): {lines: WorkspaceLine[]; state: MappingState} {
|
||||
switch (event.type) {
|
||||
case 'turn':
|
||||
case 'status':
|
||||
return {lines, state};
|
||||
case 'token':
|
||||
return appendAssistantToken(lines, state, event.delta ?? '');
|
||||
case 'tool_start':
|
||||
return append(lines, state, {kind: 'tool', status: 'running', text: `tool ${event.tool?.name ?? 'unknown'} started`});
|
||||
case 'tool_progress':
|
||||
return append(lines, state, {
|
||||
kind: 'tool',
|
||||
status: 'running',
|
||||
text: compactJoin([`tool ${event.tool?.name ?? 'unknown'}`, event.progress?.stage, event.progress?.message]),
|
||||
});
|
||||
case 'tool_result': {
|
||||
const result = event.tool_result ?? {};
|
||||
const ok = result.ok !== false;
|
||||
const name = result.name ?? 'unknown';
|
||||
const detail = ok ? '' : cleanToolError(result.content);
|
||||
return append(lines, state, {kind: 'tool', status: ok ? 'done' : 'error', text: detail ? `tool ${name} error: ${detail}` : `tool ${name} ${ok ? 'done' : 'error'}`});
|
||||
}
|
||||
case 'terminal':
|
||||
return append(lines, state, {kind: 'terminal', text: compactJoin([event.stream ? `[${event.stream}]` : '', event.line])});
|
||||
case 'session':
|
||||
return {lines, state};
|
||||
case 'done':
|
||||
return {lines, state: createMappingState()};
|
||||
case 'error':
|
||||
return append(lines, state, {kind: 'error', text: event.message ?? 'Unknown error'});
|
||||
default:
|
||||
return {lines, state};
|
||||
}
|
||||
}
|
||||
|
||||
function append(lines: WorkspaceLine[], state: MappingState, line: WorkspaceLine): {lines: WorkspaceLine[]; state: MappingState} {
|
||||
return {lines: [...lines, line], state: {...state, assistantStreaming: false}};
|
||||
}
|
||||
|
||||
function appendAssistantToken(lines: WorkspaceLine[], state: MappingState, delta: string): {lines: WorkspaceLine[]; state: MappingState} {
|
||||
if (!delta) return {lines, state: {...state, assistantStreaming: true}};
|
||||
const next = [...lines];
|
||||
const last = next[next.length - 1];
|
||||
if (state.assistantStreaming && last?.kind === 'assistant') {
|
||||
next[next.length - 1] = {...last, text: `${last.text}${delta}`};
|
||||
} else {
|
||||
next.push({kind: 'assistant', text: delta});
|
||||
}
|
||||
return {lines: next, state: {assistantStreaming: true}};
|
||||
}
|
||||
|
||||
|
||||
function compactJoin(parts: Array<string | undefined | null>): string {
|
||||
return parts.map((part) => String(part ?? '').trim()).filter(Boolean).join(': ');
|
||||
}
|
||||
|
||||
|
||||
function cleanToolError(content: string | undefined): string {
|
||||
return String(content ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {matchingSlashCommands, shouldShowSlashCommands, slashCommandQuery} from './slashCommands.js';
|
||||
|
||||
assert.equal(shouldShowSlashCommands('/', false), true);
|
||||
assert.equal(shouldShowSlashCommands('/co', false), true);
|
||||
assert.equal(shouldShowSlashCommands('/co', true), false);
|
||||
assert.equal(shouldShowSlashCommands('hello', false), false);
|
||||
|
||||
assert.equal(slashCommandQuery('/compact now'), '/compact');
|
||||
assert.equal(slashCommandQuery('/co'), '/co');
|
||||
|
||||
assert.deepEqual(matchingSlashCommands('/').map((command) => command.name), ['/compact']);
|
||||
assert.deepEqual(matchingSlashCommands('/com').map((command) => command.name), ['/compact']);
|
||||
assert.deepEqual(matchingSlashCommands('/compact').map((command) => command.name), ['/compact']);
|
||||
assert.deepEqual(matchingSlashCommands('/x').map((command) => command.name), []);
|
||||
|
||||
const slashOnly = matchingSlashCommands('/')[0];
|
||||
assert.equal(slashOnly?.matchedPrefix, '/');
|
||||
assert.equal(slashOnly?.unmatchedSuffix, 'compact');
|
||||
|
||||
const partial = matchingSlashCommands('/co')[0];
|
||||
assert.equal(partial?.matchedPrefix, '/co');
|
||||
assert.equal(partial?.unmatchedSuffix, 'mpact');
|
||||
|
||||
const full = matchingSlashCommands('/compact')[0];
|
||||
assert.equal(full?.matchedPrefix, '/compact');
|
||||
assert.equal(full?.unmatchedSuffix, '');
|
||||
|
||||
console.log('slashCommands tests passed');
|
||||
@@ -0,0 +1,33 @@
|
||||
export type SlashCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SlashCommandMatch = SlashCommand & {
|
||||
matchedPrefix: string;
|
||||
unmatchedSuffix: string;
|
||||
};
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{name: '/compact', description: 'Compact the current session context'},
|
||||
];
|
||||
|
||||
export function matchingSlashCommands(input: string): SlashCommandMatch[] {
|
||||
if (!input.startsWith('/')) return [];
|
||||
const query = slashCommandQuery(input);
|
||||
return SLASH_COMMANDS
|
||||
.filter((command) => command.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.map((command) => ({
|
||||
...command,
|
||||
matchedPrefix: command.name.slice(0, query.length),
|
||||
unmatchedSuffix: command.name.slice(query.length),
|
||||
}));
|
||||
}
|
||||
|
||||
export function slashCommandQuery(input: string): string {
|
||||
return input.trimStart().split(/\s+/, 1)[0] ?? '';
|
||||
}
|
||||
|
||||
export function shouldShowSlashCommands(input: string, busy: boolean): boolean {
|
||||
return !busy && input.startsWith('/');
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type WorkspaceLine =
|
||||
| {kind: 'user'; text: string}
|
||||
| {kind: 'assistant'; text: string}
|
||||
| {kind: 'thinking'; text: string}
|
||||
| {kind: 'status'; text: string}
|
||||
| {kind: 'workflow'; text: string}
|
||||
| {kind: 'tool'; text: string; status?: 'running' | 'done' | 'error'}
|
||||
| {kind: 'terminal'; text: string}
|
||||
| {kind: 'session'; text: string}
|
||||
| {kind: 'error'; text: string};
|
||||
|
||||
export type ToolResult = {
|
||||
name?: string;
|
||||
ok?: boolean;
|
||||
content?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StreamEvent = {
|
||||
type?: string;
|
||||
turn_id?: string;
|
||||
delta?: string;
|
||||
phase?: string;
|
||||
message?: string;
|
||||
stream?: string;
|
||||
line?: string;
|
||||
assistant?: string;
|
||||
tool?: {name?: string; requested_name?: string; arguments?: Record<string, unknown>};
|
||||
progress?: {stage?: string; message?: string; metadata?: Record<string, unknown>};
|
||||
tool_result?: ToolResult;
|
||||
session?: {
|
||||
active_session_id?: string;
|
||||
session?: {
|
||||
session_id?: string;
|
||||
stage?: string;
|
||||
working_dir?: string;
|
||||
} | null;
|
||||
};
|
||||
prompt_trace?: {
|
||||
total_estimated_tokens?: number;
|
||||
totals?: {
|
||||
total_tokens?: number;
|
||||
total_estimated_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type MappingState = {
|
||||
assistantStreaming: boolean;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {compactionBar, compactionLabel, compactTargetFromEnv, resolveWorkspacePath} from './workspaceMeta.js';
|
||||
|
||||
assert.equal(compactTargetFromEnv({}), 160000);
|
||||
assert.equal(compactTargetFromEnv({VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD: '100', VIMAX_AUTO_COMPACT_BUFFER_TOKENS: '30'}), 70);
|
||||
assert.equal(compactTargetFromEnv({VIMAX_CONTEXT_WINDOW_TOKENS: '400000', VIMAX_AUTO_COMPACT_RATIO: '0.9', VIMAX_AUTO_COMPACT_BUFFER_TOKENS: '30000'}), 330000);
|
||||
assert.equal(compactionBar(0, 100, 4), '░░░░');
|
||||
assert.equal(compactionBar(50, 100, 4), '██░░');
|
||||
assert.equal(compactionBar(200, 100, 4), '████');
|
||||
assert.equal(compactionLabel(50, 100), 'Compaction [█████████░░░░░░░░░] 50/100 (50%)');
|
||||
assert.equal(resolveWorkspacePath('/repo', '.working_dir/s1'), '.working_dir/s1');
|
||||
assert.equal(resolveWorkspacePath('/repo', '/repo/.working_dir/s1'), '.working_dir/s1');
|
||||
assert.equal(resolveWorkspacePath('/repo'), '.working_dir');
|
||||
assert.equal(resolveWorkspacePath('/repo', '20260608-vimax'), '.working_dir/20260608-vimax');
|
||||
|
||||
console.log('workspaceMeta tests passed');
|
||||
@@ -0,0 +1,53 @@
|
||||
export type WorkspaceMeta = {
|
||||
workspacePath: string;
|
||||
sessionId: string;
|
||||
stage: string;
|
||||
compactionUsed: number;
|
||||
compactionTarget: number;
|
||||
};
|
||||
|
||||
export function compactTargetFromEnv(env: Record<string, string | undefined>): number {
|
||||
const contextWindow = parsePositiveInt(env.VIMAX_CONTEXT_WINDOW_TOKENS, 200000);
|
||||
const ratio = parsePositiveFloat(env.VIMAX_AUTO_COMPACT_RATIO, 0.9);
|
||||
const threshold = parsePositiveInt(env.VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD, Math.round(contextWindow * Math.min(1, Math.max(0, ratio))));
|
||||
const buffer = parsePositiveInt(env.VIMAX_AUTO_COMPACT_BUFFER_TOKENS, 20000);
|
||||
return Math.max(0, threshold - buffer);
|
||||
}
|
||||
|
||||
export function compactionBar(used: number, target: number, width = 18): string {
|
||||
const safeWidth = Math.max(4, width);
|
||||
if (target <= 0) return '░'.repeat(safeWidth);
|
||||
const ratio = Math.min(1, Math.max(0, used / target));
|
||||
const filled = Math.round(ratio * safeWidth);
|
||||
return '█'.repeat(filled) + '░'.repeat(safeWidth - filled);
|
||||
}
|
||||
|
||||
export function compactionLabel(used: number, target: number): string {
|
||||
if (target <= 0) return 'Compaction disabled';
|
||||
const safeUsed = Math.max(0, Math.round(used));
|
||||
const percent = Math.min(999, Math.max(0, Math.round((safeUsed / target) * 100)));
|
||||
return `Compaction [${compactionBar(safeUsed, target)}] ${safeUsed}/${target} (${percent}%)`;
|
||||
}
|
||||
|
||||
export function resolveWorkspacePath(_repoRoot: string, workingDir?: string): string {
|
||||
const normalized = String(workingDir ?? '').trim();
|
||||
if (!normalized) return '.working_dir';
|
||||
const relative = normalized.replace(/^.*\/\.working_dir\//, '.working_dir/').replace(/^\.\//, '');
|
||||
if (relative.startsWith('.working_dir/')) return relative;
|
||||
if (relative === '.working_dir') return relative;
|
||||
return `.working_dir/${relative.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveFloat(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user