omniharness-cli 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/mastraEngine.d.ts +22 -1
- package/dist/agent/mastraEngine.js +43 -6
- package/dist/agent/mastraEngine.js.map +1 -1
- package/dist/cli.js +33 -1
- package/dist/cli.js.map +1 -1
- package/dist/sessionList.d.ts +20 -0
- package/dist/sessionList.js +67 -0
- package/dist/sessionList.js.map +1 -0
- package/dist/ui/diff.d.ts +20 -0
- package/dist/ui/diff.js +91 -0
- package/dist/ui/diff.js.map +1 -0
- package/dist/ui/groups.d.ts +34 -0
- package/dist/ui/groups.js +50 -0
- package/dist/ui/groups.js.map +1 -0
- package/dist/ui/highlight.d.ts +22 -0
- package/dist/ui/highlight.js +148 -0
- package/dist/ui/highlight.js.map +1 -0
- package/dist/ui/markdown.js +4 -5
- package/dist/ui/markdown.js.map +1 -1
- package/dist/ui/modelWindows.d.ts +29 -0
- package/dist/ui/modelWindows.js +68 -0
- package/dist/ui/modelWindows.js.map +1 -0
- package/dist/ui/palette.d.ts +26 -0
- package/dist/ui/palette.js +56 -0
- package/dist/ui/palette.js.map +1 -0
- package/dist/ui/termcaps.d.ts +56 -0
- package/dist/ui/termcaps.js +76 -0
- package/dist/ui/termcaps.js.map +1 -0
- package/dist/ui/terminalInterface.js +479 -66
- package/dist/ui/terminalInterface.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,59 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import { appendPromptHistory, loadPromptHistory } from '../promptHistory.js';
|
|
6
|
+
import { listSessions, loadSnapshot, saveSnapshot, deleteSnapshot } from '../sessionList.js';
|
|
6
7
|
import { deleteAt, deleteBefore, insertAt, layoutEditor, lineEndAt, lineStartAt, moveHorizontal, moveVerticalWrapped, normalizePaste } from './editor.js';
|
|
7
8
|
import { renderMarkdown } from './markdown.js';
|
|
9
|
+
import { looksLikeDiff, diffSegments } from './diff.js';
|
|
10
|
+
import { foldToolGroups } from './groups.js';
|
|
11
|
+
import { palette } from './palette.js';
|
|
12
|
+
import { contextMeter, meterBar } from './modelWindows.js';
|
|
13
|
+
import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
|
|
8
14
|
import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
|
|
15
|
+
function describeTarget(name, input) {
|
|
16
|
+
if (input && typeof input === 'object') {
|
|
17
|
+
const record = input;
|
|
18
|
+
if (typeof record.path === 'string')
|
|
19
|
+
return record.path;
|
|
20
|
+
if (typeof record.command === 'string')
|
|
21
|
+
return record.command;
|
|
22
|
+
if (typeof record.query === 'string')
|
|
23
|
+
return record.query.slice(0, 48);
|
|
24
|
+
}
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
/** Short verb shown on a tool card header, per tool type. */
|
|
28
|
+
function toolVerb(tool) {
|
|
29
|
+
switch (tool) {
|
|
30
|
+
case 'read_file': return 'read';
|
|
31
|
+
case 'write_file': return 'edit';
|
|
32
|
+
case 'run_command': return '$';
|
|
33
|
+
case 'git_diff': return 'diff';
|
|
34
|
+
case 'semantic_search': return 'search';
|
|
35
|
+
case 'index_workspace': return 'index';
|
|
36
|
+
case 'update_todo': return 'plan';
|
|
37
|
+
case 'write_memory': return 'memory';
|
|
38
|
+
case 'start_preview': return 'preview';
|
|
39
|
+
case 'route': return 'route';
|
|
40
|
+
default: return tool;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function phaseFor(tool) {
|
|
44
|
+
switch (tool) {
|
|
45
|
+
case 'read_file': return 'reading files';
|
|
46
|
+
case 'write_file': return 'editing files';
|
|
47
|
+
case 'run_command': return 'running commands';
|
|
48
|
+
case 'semantic_search': return 'searching the workspace';
|
|
49
|
+
case 'index_workspace': return 'indexing the workspace';
|
|
50
|
+
case 'git_diff': return 'checking the diff';
|
|
51
|
+
case 'update_todo': return 'updating the plan';
|
|
52
|
+
case 'start_preview': return 'starting preview';
|
|
53
|
+
case 'write_memory': return 'remembering';
|
|
54
|
+
default: return tool;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
9
57
|
/** Map a restored transcript message into a rendered line (used on startup). */
|
|
10
58
|
function lineFromMessage(message) {
|
|
11
59
|
switch (message.role) {
|
|
@@ -61,18 +109,59 @@ function labelFor(role, model, toolName, fallback) {
|
|
|
61
109
|
default: return model ?? fallback ?? 'assistant';
|
|
62
110
|
}
|
|
63
111
|
}
|
|
64
|
-
|
|
112
|
+
const PALETTE = palette();
|
|
113
|
+
function colorFor(role, p = PALETTE) {
|
|
65
114
|
switch (role) {
|
|
66
|
-
case 'user': return
|
|
67
|
-
case 'error': return
|
|
68
|
-
case 'thinking': return
|
|
69
|
-
case 'tool': return
|
|
70
|
-
default: return
|
|
115
|
+
case 'user': return p.info;
|
|
116
|
+
case 'error': return p.error;
|
|
117
|
+
case 'thinking': return p.warn;
|
|
118
|
+
case 'tool': return p.muted;
|
|
119
|
+
default: return p.success;
|
|
71
120
|
}
|
|
72
121
|
}
|
|
73
122
|
function SegmentText({ segments, role }) {
|
|
74
|
-
const base = role === 'thinking' ? '
|
|
75
|
-
return _jsx(Text, { color: base, children: segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, italic: segment.italic, strikethrough: segment.strikethrough, underline: segment.underline, dimColor: segment.dim, color: segment.color, children: segment.text }, index))) });
|
|
123
|
+
const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? PALETTE.success : role === 'user' ? PALETTE.info : undefined;
|
|
124
|
+
return _jsx(Text, { color: base, children: segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, italic: segment.italic, strikethrough: segment.strikethrough, underline: segment.underline, dimColor: segment.dim, color: segment.color ?? base, children: segment.text }, index))) });
|
|
125
|
+
}
|
|
126
|
+
/** Expanded body of a tool card, rendered per tool type. */
|
|
127
|
+
function renderToolBody(card, width, p) {
|
|
128
|
+
const trail = card.trail;
|
|
129
|
+
if (!trail)
|
|
130
|
+
return card.summary ? _jsx(Text, { dimColor: true, children: card.summary.slice(0, width) }) : _jsx(Text, { dimColor: true, children: "(no output)" });
|
|
131
|
+
if (looksLikeDiff(trail)) {
|
|
132
|
+
return _jsx(_Fragment, { children: diffSegments(trail, width).slice(0, 14).map((segments, index) => _jsx(SegmentText, { segments: segments, role: "tool" }, index)) });
|
|
133
|
+
}
|
|
134
|
+
const rows = trail.split('\n').slice(0, 14);
|
|
135
|
+
if (card.name === 'write_file') {
|
|
136
|
+
// A write trail is the new file content — show it as added (green) lines.
|
|
137
|
+
return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { color: p.success, children: line.slice(0, width) }, index)) });
|
|
138
|
+
}
|
|
139
|
+
if (card.name === 'run_command') {
|
|
140
|
+
return _jsx(_Fragment, { children: rows.map((line, index) => {
|
|
141
|
+
const exit = /^exit (\d+)/.exec(line);
|
|
142
|
+
const color = exit ? (exit[1] === '0' ? p.success : p.error) : undefined;
|
|
143
|
+
return _jsx(Text, { color: color, dimColor: color === undefined, children: line.slice(0, width) }, index);
|
|
144
|
+
}) });
|
|
145
|
+
}
|
|
146
|
+
return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { dimColor: true, children: line.slice(0, width) }, index)) });
|
|
147
|
+
}
|
|
148
|
+
/** Render one Line into transcript Rows (label row + content rows). */
|
|
149
|
+
function renderLineToRows(line, lineIndex, width, fallback) {
|
|
150
|
+
const baseLabel = labelFor(line.role, line.model, line.toolName, fallback);
|
|
151
|
+
// Route ribbon: the provider a given answer actually came from is provenance —
|
|
152
|
+
// it belongs on the answer's label, next to the model badge.
|
|
153
|
+
const label = line.role === 'assistant' && line.provider
|
|
154
|
+
? `${baseLabel} · via ${line.provider}${line.fallback ? ' (failover)' : ''}`
|
|
155
|
+
: baseLabel;
|
|
156
|
+
const markdown = line.role !== 'tool' && line.role !== 'error';
|
|
157
|
+
const wrapped = markdown
|
|
158
|
+
? renderMarkdown(line.text, width)
|
|
159
|
+
: wrap(line.text, width).map((text) => [{ text }]);
|
|
160
|
+
const next = [];
|
|
161
|
+
wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
|
|
162
|
+
if (line.saved)
|
|
163
|
+
next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
|
|
164
|
+
return next;
|
|
76
165
|
}
|
|
77
166
|
export function TerminalInterface({ engine }) {
|
|
78
167
|
const { exit } = useApp();
|
|
@@ -103,8 +192,26 @@ export function TerminalInterface({ engine }) {
|
|
|
103
192
|
const [liveThink, setLiveThink] = useState('');
|
|
104
193
|
const [liveAnswer, setLiveAnswer] = useState('');
|
|
105
194
|
const [kitty, setKitty] = useState(null);
|
|
195
|
+
const [sessionsList, setSessionsList] = useState([]);
|
|
196
|
+
const [sessionsOpen, setSessionsOpen] = useState(false);
|
|
197
|
+
const [sessionsIndex, setSessionsIndex] = useState(0);
|
|
198
|
+
const [groupsExpanded, setGroupsExpanded] = useState(false);
|
|
106
199
|
const [taskQueue, setTaskQueue] = useState(engine.state.taskQueue);
|
|
107
200
|
const [currentTool, setCurrentTool] = useState();
|
|
201
|
+
// Tool activity rendered as collapsible cards: each completed/ongoing tool
|
|
202
|
+
// call carries its target, status, and optional summary/diff trail.
|
|
203
|
+
const [toolCards, setToolCards] = useState([]);
|
|
204
|
+
const [expandedTool, setExpandedTool] = useState();
|
|
205
|
+
// Run timing: monotonic start time plus a ticking elapsed counter while busy.
|
|
206
|
+
const runStartedAt = useRef(null);
|
|
207
|
+
const [now, setNow] = useState(() => Date.now());
|
|
208
|
+
// A prompt typed while a run is in flight: stashed here and submitted when the run ends.
|
|
209
|
+
const queuedRef = useRef(null);
|
|
210
|
+
const [queued, setQueued] = useState();
|
|
211
|
+
// Ctrl+L paints the row budget of every layout region over the transcript.
|
|
212
|
+
const [layoutDebug, setLayoutDebug] = useState(false);
|
|
213
|
+
// Restores plain stdout.write when synchronized-output bracketing is torn down.
|
|
214
|
+
const syncRestoreRef = useRef(null);
|
|
108
215
|
useEffect(() => {
|
|
109
216
|
let alive = true;
|
|
110
217
|
// Seed history from disk, but only if the user hasn't already submitted a
|
|
@@ -121,8 +228,14 @@ export function TerminalInterface({ engine }) {
|
|
|
121
228
|
stdout.write(KITTY_PUSH);
|
|
122
229
|
let kittyTimer;
|
|
123
230
|
// Terminals with kitty support answer the query (ESC[? flags u); others ignore it.
|
|
231
|
+
// The same probe pass asks about synchronized output (DECSET 2026): a positive
|
|
232
|
+
// reply lets us bracket every frame so streaming never tears mid-repaint.
|
|
124
233
|
const onKitty = (chunk) => {
|
|
125
|
-
|
|
234
|
+
const text = chunk.toString();
|
|
235
|
+
if (isSyncOutputReply(text) && syncRestoreRef.current === null) {
|
|
236
|
+
syncRestoreRef.current = wrapSynchronizedOutput(stdout);
|
|
237
|
+
}
|
|
238
|
+
if (!isKittyQueryResponse(text))
|
|
126
239
|
return;
|
|
127
240
|
if (kittyTimer)
|
|
128
241
|
clearTimeout(kittyTimer);
|
|
@@ -132,6 +245,7 @@ export function TerminalInterface({ engine }) {
|
|
|
132
245
|
if (stdin) {
|
|
133
246
|
stdin.on('data', onKitty);
|
|
134
247
|
kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKitty); }, 300);
|
|
248
|
+
stdout.write(SYNC_QUERY);
|
|
135
249
|
stdout.write(KITTY_QUERY);
|
|
136
250
|
}
|
|
137
251
|
else {
|
|
@@ -153,16 +267,35 @@ export function TerminalInterface({ engine }) {
|
|
|
153
267
|
case 'text':
|
|
154
268
|
setLines((current) => [...current, {
|
|
155
269
|
role: 'assistant', text: event.content, model: event.model,
|
|
270
|
+
provider: event.provider, fallback: event.fallback,
|
|
156
271
|
saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
|
|
157
272
|
}]);
|
|
158
273
|
setLiveAnswer('');
|
|
159
274
|
break;
|
|
275
|
+
case 'route':
|
|
276
|
+
setLines((current) => [...current, {
|
|
277
|
+
role: 'tool', toolName: 'route',
|
|
278
|
+
text: event.fallback
|
|
279
|
+
? `route · failover → ${event.provider ?? 'unknown'} (attempt ${event.attempts + 1})${event.reason ? ` · ${event.reason}` : ''}`
|
|
280
|
+
: `route · ${event.provider ?? 'unknown'}`,
|
|
281
|
+
}]);
|
|
282
|
+
break;
|
|
160
283
|
case 'tool_start':
|
|
161
|
-
|
|
284
|
+
setToolCards((current) => [...current, {
|
|
285
|
+
id: `tc-${Date.now().toString(36)}-${current.length}`, name: event.tool,
|
|
286
|
+
target: describeTarget(event.tool, event.input), status: 'running',
|
|
287
|
+
trail: typeof event.input === 'object' && event.input !== null
|
|
288
|
+
&& typeof event.input.content === 'string'
|
|
289
|
+
? String(event.input.content) : undefined,
|
|
290
|
+
}]);
|
|
162
291
|
setCurrentTool(event.tool);
|
|
163
292
|
break;
|
|
164
293
|
case 'tool_result':
|
|
165
|
-
|
|
294
|
+
setToolCards((current) => current.length === 0
|
|
295
|
+
? current
|
|
296
|
+
: current.map((card, index) => index === current.length - 1
|
|
297
|
+
? { ...card, status: 'done', summary: event.summary, trail: card.trail ?? event.detail }
|
|
298
|
+
: card));
|
|
166
299
|
setCurrentTool(undefined);
|
|
167
300
|
break;
|
|
168
301
|
case 'todos':
|
|
@@ -189,10 +322,12 @@ export function TerminalInterface({ engine }) {
|
|
|
189
322
|
stdout.off('resize', onResize);
|
|
190
323
|
const pendingApproval = approvalResolve.current;
|
|
191
324
|
approvalResolve.current = null;
|
|
192
|
-
pendingApproval?.(false);
|
|
325
|
+
pendingApproval?.({ approved: false });
|
|
193
326
|
engine.stop();
|
|
194
327
|
unsubscribe();
|
|
195
328
|
process.off('exit', onUnload);
|
|
329
|
+
syncRestoreRef.current?.();
|
|
330
|
+
syncRestoreRef.current = null;
|
|
196
331
|
stdout.write(KITTY_POP);
|
|
197
332
|
};
|
|
198
333
|
}, [engine, stdout, stdin]);
|
|
@@ -224,11 +359,77 @@ export function TerminalInterface({ engine }) {
|
|
|
224
359
|
engine.state.mode = next;
|
|
225
360
|
setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
|
|
226
361
|
};
|
|
227
|
-
const approve = (
|
|
362
|
+
const approve = (approved, trust) => {
|
|
228
363
|
const resolve = approvalResolve.current;
|
|
229
364
|
setApproval(null);
|
|
230
365
|
approvalResolve.current = null;
|
|
231
|
-
resolve?.(
|
|
366
|
+
resolve?.({ approved, trust });
|
|
367
|
+
};
|
|
368
|
+
/** Copy the most recent assistant reply (or last transcript line) to the clipboard via OSC 52. */
|
|
369
|
+
const yankLastBlock = () => {
|
|
370
|
+
const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
|
|
371
|
+
if (!target)
|
|
372
|
+
return;
|
|
373
|
+
const seq = osc52Copy(target.text);
|
|
374
|
+
if (!seq) {
|
|
375
|
+
setLines((current) => [...current, { role: 'tool', text: 'clipboard: block too large to copy', toolName: 'clipboard' }]);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
stdout.write(seq);
|
|
380
|
+
setLines((current) => [...current, { role: 'tool', text: `copied ${target.text.length} chars to clipboard`, toolName: 'clipboard' }]);
|
|
381
|
+
}
|
|
382
|
+
catch { /* clipboard write is best-effort */ }
|
|
383
|
+
};
|
|
384
|
+
/** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
|
|
385
|
+
const startRun = (prompt, attachSpec) => {
|
|
386
|
+
followTranscriptRef.current = true;
|
|
387
|
+
setScrollOffset(0);
|
|
388
|
+
setEdit({ value: '', cursor: 0 });
|
|
389
|
+
setBusy(true);
|
|
390
|
+
setError(undefined);
|
|
391
|
+
setToolCards([]);
|
|
392
|
+
setExpandedTool(undefined);
|
|
393
|
+
if (runStartedAt.current === null)
|
|
394
|
+
runStartedAt.current = Date.now();
|
|
395
|
+
setNow(Date.now());
|
|
396
|
+
historyIdxRef.current = -1;
|
|
397
|
+
if (prompt) {
|
|
398
|
+
syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
|
|
399
|
+
void appendPromptHistory(prompt).catch(() => { });
|
|
400
|
+
}
|
|
401
|
+
if (!attachSpec)
|
|
402
|
+
setLines((current) => [...current, { role: 'user', text: prompt }]);
|
|
403
|
+
void (async () => {
|
|
404
|
+
try {
|
|
405
|
+
if (attachSpec)
|
|
406
|
+
await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
|
|
407
|
+
await engine.run(prompt);
|
|
408
|
+
/* answer is streamed live via text_delta / text events */
|
|
409
|
+
}
|
|
410
|
+
catch (reason) {
|
|
411
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
412
|
+
setError(message);
|
|
413
|
+
setLines((current) => [...current, { role: 'error', text: message }]);
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
const startedAt = runStartedAt.current;
|
|
417
|
+
setBusy(false);
|
|
418
|
+
setCurrentTool(undefined);
|
|
419
|
+
setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
|
|
420
|
+
runStartedAt.current = null;
|
|
421
|
+
setLiveThink('');
|
|
422
|
+
setLiveAnswer('');
|
|
423
|
+
// A long run probably pulled focus elsewhere: nudge with a bell + OSC 9 notification.
|
|
424
|
+
if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
|
|
425
|
+
try {
|
|
426
|
+
stdout.write(osc9Notify('OmniHarness — run finished'));
|
|
427
|
+
stdout.write(BEL);
|
|
428
|
+
}
|
|
429
|
+
catch { /* nudge is best-effort */ }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
232
433
|
};
|
|
233
434
|
/** ↑ walks older prompts, ↓ walks newer; ↓ past the newest clears the input. */
|
|
234
435
|
const navigateHistory = (older) => {
|
|
@@ -244,6 +445,24 @@ export function TerminalInterface({ engine }) {
|
|
|
244
445
|
};
|
|
245
446
|
/** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
|
|
246
447
|
const browsingHistory = () => historyIdxRef.current >= 0 || edit.value === '';
|
|
448
|
+
// Kept current each render so jumpToLine (defined before the row layout is
|
|
449
|
+
// computed) can resolve a transcript-line index to a scroll position.
|
|
450
|
+
const allRowsRef = useRef([]);
|
|
451
|
+
const storedHeightRef = useRef(0);
|
|
452
|
+
/** Scroll so the first rendered row of transcript line `lineIndex` sits at the top. */
|
|
453
|
+
const jumpToLine = (lineIndex) => {
|
|
454
|
+
const rows = allRowsRef.current;
|
|
455
|
+
const targetRow = rows.findIndex((row) => row.key.startsWith(`message-${lineIndex}-`));
|
|
456
|
+
if (targetRow < 0)
|
|
457
|
+
return;
|
|
458
|
+
followTranscriptRef.current = false;
|
|
459
|
+
const desired = rows.length - targetRow - storedHeightRef.current;
|
|
460
|
+
setScrollOffset(clamp(desired, 0, maxScrollRef.current));
|
|
461
|
+
};
|
|
462
|
+
/** Chapters: one per user turn, titled by the prompt's first line. */
|
|
463
|
+
const chapters = () => lines.flatMap((line, index) => line.role === 'user'
|
|
464
|
+
? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
|
|
465
|
+
: []);
|
|
247
466
|
/** Scroll by rendered transcript rows; positive values move toward older output. */
|
|
248
467
|
const scrollTranscript = (delta) => {
|
|
249
468
|
if (delta > 0 && maxScrollRef.current > 0)
|
|
@@ -259,6 +478,8 @@ export function TerminalInterface({ engine }) {
|
|
|
259
478
|
const applyAction = (action) => {
|
|
260
479
|
if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
|
|
261
480
|
return;
|
|
481
|
+
if (sessionsOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
|
|
482
|
+
return;
|
|
262
483
|
if (pickerOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
|
|
263
484
|
return;
|
|
264
485
|
switch (action.kind) {
|
|
@@ -267,6 +488,22 @@ export function TerminalInterface({ engine }) {
|
|
|
267
488
|
approve(true);
|
|
268
489
|
return;
|
|
269
490
|
}
|
|
491
|
+
if (sessionsOpen) {
|
|
492
|
+
const selected = sessionsList[sessionsIndex];
|
|
493
|
+
if (selected) {
|
|
494
|
+
void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
|
|
495
|
+
if (snapshot == null) {
|
|
496
|
+
setLines((current) => [...current, { role: 'error', text: `snapshot ${selected.name} is unreadable` }]);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
setLines(snapshot.messages.map(lineFromMessage));
|
|
500
|
+
setTaskQueue(snapshot.taskQueue);
|
|
501
|
+
setLines((current) => [...current, { role: 'tool', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)`, toolName: 'sessions' }]);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
setSessionsOpen(false);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
270
507
|
if (pickerOpen) {
|
|
271
508
|
const selected = pickerItems[pickerIndex];
|
|
272
509
|
if (selected) {
|
|
@@ -283,13 +520,89 @@ export function TerminalInterface({ engine }) {
|
|
|
283
520
|
setLines((current) => [...current,
|
|
284
521
|
{ role: 'tool', text: '/help — show commands', toolName: 'commands' },
|
|
285
522
|
{ role: 'tool', text: '/clear — start a fresh conversation', toolName: 'commands' },
|
|
523
|
+
{ role: 'tool', text: '/sessions — list saved sessions (enter to resume)', toolName: 'commands' },
|
|
524
|
+
{ role: 'tool', text: '/save <name> — snapshot the current session', toolName: 'commands' },
|
|
525
|
+
{ role: 'tool', text: '/forget <name> — delete a saved session', toolName: 'commands' },
|
|
286
526
|
{ role: 'tool', text: '/attach <files> — attach files to the next message', toolName: 'commands' },
|
|
287
|
-
{ role: 'tool', text: '
|
|
527
|
+
{ role: 'tool', text: '/find <text> — jump to the most recent line containing <text>', toolName: 'commands' },
|
|
528
|
+
{ role: 'tool', text: '/chapters — list turns and jump: /chapters <n>', toolName: 'commands' },
|
|
529
|
+
{ role: 'tool', text: 'keys: Ctrl+O models · Ctrl+E mode · Ctrl+C cancel · PgUp/PgDn scroll · Ctrl+G fold tool groups · Ctrl+T tool card · Ctrl+Y copy last reply · Ctrl+L layout budget · ↑/↓ prompt history', toolName: 'commands' },
|
|
530
|
+
{ role: 'tool', text: 'a prompt typed while a run is working is queued and sent when it finishes', toolName: 'commands' },
|
|
531
|
+
]);
|
|
532
|
+
setEdit({ value: '', cursor: 0 });
|
|
533
|
+
historyIdxRef.current = -1;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const findMatch = /^\/find\s+(.+)$/.exec(raw);
|
|
537
|
+
if (findMatch) {
|
|
538
|
+
const needle = findMatch[1].toLowerCase();
|
|
539
|
+
const hits = lines.map((line, index) => ({ line, index })).filter(({ line }) => line.text.toLowerCase().includes(needle));
|
|
540
|
+
const last = hits[hits.length - 1];
|
|
541
|
+
setLines((current) => [...current, {
|
|
542
|
+
role: 'tool', toolName: 'find',
|
|
543
|
+
text: last ? `find "${findMatch[1]}" · ${hits.length} match${hits.length === 1 ? '' : 'es'} · jumped to the most recent` : `no match for "${findMatch[1]}"`,
|
|
544
|
+
}]);
|
|
545
|
+
if (last)
|
|
546
|
+
jumpToLine(last.index);
|
|
547
|
+
setEdit({ value: '', cursor: 0 });
|
|
548
|
+
historyIdxRef.current = -1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const chapterJump = /^\/chapters?\s+(\d+)$/.exec(raw);
|
|
552
|
+
if (chapterJump) {
|
|
553
|
+
const list = chapters();
|
|
554
|
+
const pick = list[Number(chapterJump[1]) - 1];
|
|
555
|
+
if (pick)
|
|
556
|
+
jumpToLine(pick.index);
|
|
557
|
+
setLines((current) => [...current, { role: 'tool', toolName: 'chapters', text: pick ? `jumped to chapter ${chapterJump[1]}: ${pick.title}` : `no chapter ${chapterJump[1]}` }]);
|
|
558
|
+
setEdit({ value: '', cursor: 0 });
|
|
559
|
+
historyIdxRef.current = -1;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (raw === '/chapters' || raw === '/chapter') {
|
|
563
|
+
const list = chapters();
|
|
564
|
+
setLines((current) => [...current,
|
|
565
|
+
...(list.length === 0 ? [{ role: 'tool', toolName: 'chapters', text: 'no chapters yet — each prompt starts one' }]
|
|
566
|
+
: list.map((chapter, order) => ({ role: 'tool', toolName: 'chapters', text: `${order + 1}. ${chapter.title}` }))),
|
|
567
|
+
...(list.length > 0 ? [{ role: 'tool', toolName: 'chapters', text: '/chapters <n> to jump' }] : []),
|
|
288
568
|
]);
|
|
289
569
|
setEdit({ value: '', cursor: 0 });
|
|
290
570
|
historyIdxRef.current = -1;
|
|
291
571
|
return;
|
|
292
572
|
}
|
|
573
|
+
if (raw === '/sessions') {
|
|
574
|
+
void listSessions(engine.state.workspace.root).then((sessions) => {
|
|
575
|
+
setSessionsList(sessions);
|
|
576
|
+
setSessionsIndex(0);
|
|
577
|
+
setSessionsOpen(sessions.length > 0);
|
|
578
|
+
if (sessions.length === 0) {
|
|
579
|
+
setLines((current) => [...current, { role: 'tool', text: 'no saved sessions — use /save <name> to snapshot this one', toolName: 'sessions' }]);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
setEdit({ value: '', cursor: 0 });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const saveMatch = /^\/save\s+([\w.-]+)$/.exec(raw);
|
|
586
|
+
if (saveMatch) {
|
|
587
|
+
const name = saveMatch[1];
|
|
588
|
+
void saveSnapshot(engine.state.workspace.root, name, {
|
|
589
|
+
messages: [...engine.state.messages], taskQueue: [...engine.state.taskQueue], savedAt: new Date().toISOString(),
|
|
590
|
+
}).then(() => {
|
|
591
|
+
setLines((current) => [...current, { role: 'tool', text: `session saved: ${name}`, toolName: 'sessions' }]);
|
|
592
|
+
}).catch((reason) => {
|
|
593
|
+
setLines((current) => [...current, { role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }]);
|
|
594
|
+
});
|
|
595
|
+
setEdit({ value: '', cursor: 0 });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
|
|
599
|
+
if (delMatch) {
|
|
600
|
+
void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => {
|
|
601
|
+
setLines((current) => [...current, { role: 'tool', text: `session deleted: ${delMatch[1]}`, toolName: 'sessions' }]);
|
|
602
|
+
});
|
|
603
|
+
setEdit({ value: '', cursor: 0 });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
293
606
|
if (raw === '/clear') {
|
|
294
607
|
historyIdxRef.current = -1;
|
|
295
608
|
followTranscriptRef.current = true;
|
|
@@ -297,6 +610,9 @@ export function TerminalInterface({ engine }) {
|
|
|
297
610
|
setTaskQueue([]);
|
|
298
611
|
setError(undefined);
|
|
299
612
|
setCurrentTool(undefined);
|
|
613
|
+
setToolCards([]);
|
|
614
|
+
setExpandedTool(undefined);
|
|
615
|
+
runStartedAt.current = null;
|
|
300
616
|
setLiveThink('');
|
|
301
617
|
setLiveAnswer('');
|
|
302
618
|
syncPromptHistory([]);
|
|
@@ -306,39 +622,19 @@ export function TerminalInterface({ engine }) {
|
|
|
306
622
|
return;
|
|
307
623
|
}
|
|
308
624
|
const prompt = attachMatch ? '' : raw;
|
|
309
|
-
|
|
625
|
+
const attachSpec = attachMatch ? attachMatch[1] : undefined;
|
|
626
|
+
if (!prompt && !attachSpec)
|
|
627
|
+
return;
|
|
628
|
+
// Input stays live during a run: a prompt typed now is queued, not dropped,
|
|
629
|
+
// and fires the moment the current run ends.
|
|
630
|
+
if (busy) {
|
|
631
|
+
queuedRef.current = { prompt, attachSpec };
|
|
632
|
+
setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
|
|
633
|
+
setEdit({ value: '', cursor: 0 });
|
|
634
|
+
historyIdxRef.current = -1;
|
|
310
635
|
return;
|
|
311
|
-
followTranscriptRef.current = true;
|
|
312
|
-
setScrollOffset(0);
|
|
313
|
-
setEdit({ value: '', cursor: 0 });
|
|
314
|
-
setBusy(true);
|
|
315
|
-
setError(undefined);
|
|
316
|
-
historyIdxRef.current = -1;
|
|
317
|
-
if (prompt) {
|
|
318
|
-
syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
|
|
319
|
-
void appendPromptHistory(prompt).catch(() => { });
|
|
320
636
|
}
|
|
321
|
-
|
|
322
|
-
setLines((current) => [...current, { role: 'user', text: prompt }]);
|
|
323
|
-
void (async () => {
|
|
324
|
-
try {
|
|
325
|
-
if (attachMatch)
|
|
326
|
-
await engine.attach(attachMatch[1].split(/\s+/).filter(Boolean));
|
|
327
|
-
await engine.run(prompt);
|
|
328
|
-
/* answer is streamed live via text_delta / text events */
|
|
329
|
-
}
|
|
330
|
-
catch (reason) {
|
|
331
|
-
const message = reason instanceof Error ? reason.message : String(reason);
|
|
332
|
-
setError(message);
|
|
333
|
-
setLines((current) => [...current, { role: 'error', text: message }]);
|
|
334
|
-
}
|
|
335
|
-
finally {
|
|
336
|
-
setBusy(false);
|
|
337
|
-
setCurrentTool(undefined);
|
|
338
|
-
setLiveThink('');
|
|
339
|
-
setLiveAnswer('');
|
|
340
|
-
}
|
|
341
|
-
})();
|
|
637
|
+
startRun(prompt, attachSpec);
|
|
342
638
|
}
|
|
343
639
|
return;
|
|
344
640
|
case 'escape':
|
|
@@ -346,6 +642,10 @@ export function TerminalInterface({ engine }) {
|
|
|
346
642
|
approve(false);
|
|
347
643
|
return;
|
|
348
644
|
}
|
|
645
|
+
if (sessionsOpen) {
|
|
646
|
+
setSessionsOpen(false);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
349
649
|
if (pickerOpen) {
|
|
350
650
|
setPickerOpen(false);
|
|
351
651
|
return;
|
|
@@ -370,6 +670,10 @@ export function TerminalInterface({ engine }) {
|
|
|
370
670
|
cycleMode();
|
|
371
671
|
return;
|
|
372
672
|
case 'up':
|
|
673
|
+
if (sessionsOpen) {
|
|
674
|
+
setSessionsIndex((current) => clamp(current - 1, 0, Math.max(0, sessionsList.length - 1)));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
373
677
|
if (pickerOpen) {
|
|
374
678
|
setPickerIndex((current) => clamp(current - 1, 0, Math.max(0, pickerItems.length - 1)));
|
|
375
679
|
return;
|
|
@@ -381,6 +685,10 @@ export function TerminalInterface({ engine }) {
|
|
|
381
685
|
setEdit((current) => ({ ...current, cursor: moveVerticalWrapped(current.value, current.cursor, -1, inputWidth) }));
|
|
382
686
|
return;
|
|
383
687
|
case 'down':
|
|
688
|
+
if (sessionsOpen) {
|
|
689
|
+
setSessionsIndex((current) => clamp(current + 1, 0, Math.max(0, sessionsList.length - 1)));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
384
692
|
if (pickerOpen) {
|
|
385
693
|
setPickerIndex((current) => clamp(current + 1, 0, Math.max(0, pickerItems.length - 1)));
|
|
386
694
|
return;
|
|
@@ -420,10 +728,23 @@ export function TerminalInterface({ engine }) {
|
|
|
420
728
|
// Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
|
|
421
729
|
useInput((value, key) => {
|
|
422
730
|
if (approval) {
|
|
423
|
-
if (value === 'y' || value === 'Y' || key.return)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
731
|
+
if (value === 'y' || value === 'Y' || key.return) {
|
|
732
|
+
approve(true);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (value === 'n' || value === 'N' || key.escape) {
|
|
736
|
+
approve(false);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (value === 't' || value === 'T') {
|
|
740
|
+
approve(true, approval.scopes[0]?.id);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const digit = Number(value);
|
|
744
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= approval.scopes.length) {
|
|
745
|
+
approve(true, approval.scopes[digit - 1]?.id);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
427
748
|
return;
|
|
428
749
|
}
|
|
429
750
|
if (key.ctrl && value === 'c') {
|
|
@@ -461,6 +782,25 @@ export function TerminalInterface({ engine }) {
|
|
|
461
782
|
}
|
|
462
783
|
return;
|
|
463
784
|
}
|
|
785
|
+
if (sessionsOpen) {
|
|
786
|
+
if (key.escape) {
|
|
787
|
+
applyAction({ kind: 'escape' });
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (key.upArrow) {
|
|
791
|
+
applyAction({ kind: 'up' });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (key.downArrow) {
|
|
795
|
+
applyAction({ kind: 'down' });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (key.return) {
|
|
799
|
+
applyAction({ kind: 'submit' });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
464
804
|
if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
|
|
465
805
|
scrollTranscript(pageSizeRef.current);
|
|
466
806
|
return;
|
|
@@ -469,6 +809,20 @@ export function TerminalInterface({ engine }) {
|
|
|
469
809
|
scrollTranscript(-pageSizeRef.current);
|
|
470
810
|
return;
|
|
471
811
|
}
|
|
812
|
+
if (key.ctrl && value.toLowerCase() === 'l') {
|
|
813
|
+
setLayoutDebug((current) => !current);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (key.ctrl && value.toLowerCase() === 'y') {
|
|
817
|
+
yankLastBlock();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// Ctrl+T toggles the most recent tool card between collapsed and expanded.
|
|
821
|
+
if (key.ctrl && value.toLowerCase() === 't') {
|
|
822
|
+
const latest = toolCards[toolCards.length - 1];
|
|
823
|
+
setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
472
826
|
if (key.tab) {
|
|
473
827
|
applyAction({ kind: 'tab' });
|
|
474
828
|
return;
|
|
@@ -540,6 +894,20 @@ export function TerminalInterface({ engine }) {
|
|
|
540
894
|
if (metrics.fallback.attempts > 0)
|
|
541
895
|
hud.push(`fb ${metrics.fallback.attempts}`);
|
|
542
896
|
hud.push(`saved ${compression}`);
|
|
897
|
+
// Live statusline fields: model, mode, running phase, tokens/context, elapsed.
|
|
898
|
+
const runningTool = toolCards[toolCards.length - 1];
|
|
899
|
+
const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
|
|
900
|
+
const elapsedMs = busy && runStartedAt.current !== null
|
|
901
|
+
? Math.max(0, now - runStartedAt.current)
|
|
902
|
+
: 0;
|
|
903
|
+
const elapsed = busy
|
|
904
|
+
? (elapsedMs >= 60_000 ? `${Math.floor(elapsedMs / 60_000)}m${String(Math.floor((elapsedMs % 60_000) / 1000)).padStart(2, '0')}s` : `${Math.floor(elapsedMs / 1000)}s`)
|
|
905
|
+
: '';
|
|
906
|
+
const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
|
|
907
|
+
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
908
|
+
const contextLabel = metrics.compression.inputTokens > 0
|
|
909
|
+
? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%`
|
|
910
|
+
: '';
|
|
543
911
|
const contentWidth = Math.max(20, width - 8);
|
|
544
912
|
const terminalRows = stdout.rows ?? 24;
|
|
545
913
|
const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
|
|
@@ -571,22 +939,37 @@ export function TerminalInterface({ engine }) {
|
|
|
571
939
|
lines.forEach((line, lineIndex) => {
|
|
572
940
|
let rendered = cache.rows.get(line);
|
|
573
941
|
if (rendered === undefined) {
|
|
574
|
-
|
|
575
|
-
const markdown = line.role !== 'tool' && line.role !== 'error';
|
|
576
|
-
const wrapped = markdown
|
|
577
|
-
? renderMarkdown(line.text, contentWidth)
|
|
578
|
-
: wrap(line.text, contentWidth).map((text) => [{ text }]);
|
|
579
|
-
const next = [];
|
|
580
|
-
wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
|
|
581
|
-
if (line.saved)
|
|
582
|
-
next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
|
|
583
|
-
rendered = next;
|
|
942
|
+
rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
|
|
584
943
|
cache.rows.set(line, rendered);
|
|
585
944
|
}
|
|
586
945
|
out.push(...rendered);
|
|
587
946
|
});
|
|
588
947
|
return out;
|
|
589
948
|
}, [lines, contentWidth, engine.state.activeModel]);
|
|
949
|
+
// Fold tool-role rows into collapsible groups. Expansion is global (Ctrl+G);
|
|
950
|
+
// folded groups render as a single summary line instead of a wall of output.
|
|
951
|
+
const displayRows = useMemo(() => {
|
|
952
|
+
const lastUserIdx = lines.map((line) => line.role).lastIndexOf('user');
|
|
953
|
+
const folded = foldToolGroups(lines.map((line) => ({ role: line.role, text: line.text, toolName: line.toolName })), groupsExpanded, lastUserIdx >= 0 ? lastUserIdx : lines.length);
|
|
954
|
+
const out = [];
|
|
955
|
+
const fallback = engine.state.activeModel;
|
|
956
|
+
folded.forEach((gline, lineIndex) => {
|
|
957
|
+
const isGroup = 'group' in gline && gline.group !== undefined;
|
|
958
|
+
const key = isGroup ? `group-${lineIndex}` : `message-${lineIndex}`;
|
|
959
|
+
if (isGroup) {
|
|
960
|
+
out.push({
|
|
961
|
+
key, role: 'tool',
|
|
962
|
+
segments: [{ text: gline.text, dim: true }],
|
|
963
|
+
label: labelFor('tool', undefined, 'group'), first: true,
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const line = gline;
|
|
968
|
+
const rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
|
|
969
|
+
out.push(...rendered);
|
|
970
|
+
});
|
|
971
|
+
return out;
|
|
972
|
+
}, [lines, contentWidth, engine.state.activeModel, groupsExpanded]);
|
|
590
973
|
const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
|
|
591
974
|
const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
|
|
592
975
|
const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
|
|
@@ -595,12 +978,15 @@ export function TerminalInterface({ engine }) {
|
|
|
595
978
|
const footerRows = 3;
|
|
596
979
|
const chromeRows = 3 + footerRows + inputRows + (kitty !== null ? 1 : 0) + planRows + pickerRows + approvalRows;
|
|
597
980
|
const messageHeight = Math.max(3, terminalRows - chromeRows);
|
|
598
|
-
const
|
|
981
|
+
const toolRows = toolCards.length > 0 ? toolCards.length : 0; // one collapsed card per tool call
|
|
982
|
+
const statusRows = (engine.state.preview ? 1 : 0) + (busy ? 1 : 0) + toolRows;
|
|
599
983
|
const storedHeight = Math.max(0, messageHeight - Math.min(messageHeight, liveRows.length + statusRows));
|
|
600
984
|
const liveHeight = Math.max(0, messageHeight - storedHeight - statusRows);
|
|
601
985
|
const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
|
|
602
986
|
const maxScroll = Math.max(0, allRows.length - storedHeight);
|
|
603
987
|
maxScrollRef.current = maxScroll;
|
|
988
|
+
allRowsRef.current = allRows;
|
|
989
|
+
storedHeightRef.current = storedHeight;
|
|
604
990
|
pageSizeRef.current = Math.max(1, storedHeight - 2);
|
|
605
991
|
const boundedScroll = clamp(scrollOffset, 0, maxScroll);
|
|
606
992
|
const endRow = allRows.length - boundedScroll;
|
|
@@ -631,23 +1017,50 @@ export function TerminalInterface({ engine }) {
|
|
|
631
1017
|
useEffect(() => {
|
|
632
1018
|
setScrollOffset((current) => clamp(current, 0, maxScroll));
|
|
633
1019
|
}, [maxScroll]);
|
|
1020
|
+
// Tick a clock while a run is in flight so the statusline can show elapsed time
|
|
1021
|
+
// without re-rendering on every event. runStartedAt is set when a run begins.
|
|
1022
|
+
useEffect(() => {
|
|
1023
|
+
if (!busy)
|
|
1024
|
+
return;
|
|
1025
|
+
const id = setInterval(() => setNow(Date.now()), 250);
|
|
1026
|
+
return () => clearInterval(id);
|
|
1027
|
+
}, [busy]);
|
|
1028
|
+
// Drain a prompt that was queued while the previous run was in flight.
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
if (busy)
|
|
1031
|
+
return;
|
|
1032
|
+
const pending = queuedRef.current;
|
|
1033
|
+
if (!pending)
|
|
1034
|
+
return;
|
|
1035
|
+
queuedRef.current = null;
|
|
1036
|
+
setQueued(undefined);
|
|
1037
|
+
startRun(pending.prompt, pending.attachSpec);
|
|
1038
|
+
}, [busy]);
|
|
634
1039
|
return _jsxs(Box, { flexDirection: "column", width: width, height: terminalRows, paddingX: 2, overflow: "hidden", children: [_jsxs(Box, { justifyContent: "space-between", paddingY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["OMNIHARNESS ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", mode, " mode"] })] }), _jsx(Text, { dimColor: true, children: "OMNIROUTE :20128" })] }), _jsxs(Box, { flexDirection: "column", height: messageHeight, overflow: "hidden", children: [lines.length === 0 && _jsxs(Box, { flexDirection: "column", marginTop: 2, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ready when you are." }), _jsxs(Text, { dimColor: true, children: ["Describe the work. OmniHarness routes it through your OmniRoute account. Ctrl+", modeKey, " cycles plan \u00B7 build \u00B7 research \u00B7 crazy."] })] }), visibleRows.map((row, index) => row.saved
|
|
635
1040
|
? _jsx(Text, { dimColor: true, children: row.saved }, row.key)
|
|
636
1041
|
: row.first
|
|
637
1042
|
? _jsxs(Box, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [_jsx(Text, { color: colorFor(row.role), bold: true, children: row.label }), _jsx(SegmentText, { segments: row.segments, role: row.role })] }, row.key)
|
|
638
1043
|
: _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), visibleLiveRows.map((row) => row.kind === 'label'
|
|
639
1044
|
? _jsx(Text, { color: colorFor(row.role), bold: true, children: row.role === 'thinking' ? 'think' : engine.state.activeModel }, row.key)
|
|
640
|
-
: _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)),
|
|
1045
|
+
: _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), toolCards.slice(-6).map((card) => {
|
|
1046
|
+
const expanded = expandedTool === card.id;
|
|
1047
|
+
const status = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: "\u2022 running" }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "\u2715 error" }) : _jsx(Text, { color: PALETTE.success, children: "\u2713 done" });
|
|
1048
|
+
const head = card.name === 'run_command'
|
|
1049
|
+
? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 26))}`
|
|
1050
|
+
: `${toolVerb(card.name)}${card.target ? ` · ${clip(card.target, Math.max(10, contentWidth - 30))}` : ''}`;
|
|
1051
|
+
const badge = expanded ? '▾' : '▸';
|
|
1052
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [badge, " ", head, " \u00B7 ", status, expanded ? ' · Ctrl+T to collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
|
|
1053
|
+
}), engine.state.preview && _jsxs(Text, { color: "green", dimColor: true, children: ["preview live \u00B7 ", engine.state.preview.url] }), busy && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " working in ", mode, " mode on ", engine.state.activeModel, currentTool ? ` · now ${currentTool}` : ''] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["\u23CE queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
|
|
641
1054
|
const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '▸' : '○';
|
|
642
1055
|
const color = item.status === 'done' ? 'green' : item.status === 'active' ? 'cyan' : undefined;
|
|
643
1056
|
return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
|
|
644
|
-
})] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: "red", children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "No models returned by OmniRoute." }), pickerItems.map((item, index) => {
|
|
1057
|
+
})] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "Saved sessions" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u00B7 enter resume \u00B7 esc close" }), sessionsList.map((session, index) => (_jsxs(Text, { color: index === sessionsIndex ? 'blue' : undefined, children: [index === sessionsIndex ? '› ' : ' ', session.name, session.savedAt ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", session.savedAt] }) : null] }, session.name)))] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: "red", children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "No models returned by OmniRoute." }), pickerItems.map((item, index) => {
|
|
645
1058
|
const header = index === 0 || pickerItems[index - 1].group !== item.group
|
|
646
1059
|
? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
|
|
647
1060
|
: null;
|
|
648
1061
|
return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? 'cyan' : undefined, children: [index === pickerIndex ? '› ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' ✓' : ''] })] }, item.id);
|
|
649
|
-
})] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(
|
|
1062
|
+
})] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(JSON.stringify(approval.input), contentWidth)] }), approval.scopes.map((scope, index) => _jsxs(Text, { dimColor: true, children: [" ", index + 1, " \u00B7 ", clip(scope.label, Math.max(12, contentWidth - 6))] }, scope.id)), _jsxs(Text, { dimColor: true, children: ["y allow once \u00B7 n deny \u00B7 t always allow \u00B7 1\u2013", approval.scopes.length, " pick a trust scope"] })] }), layoutDebug && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 2, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["chrome ", chromeRows, " \u00B7 input ", inputRows, " \u00B7 plan ", planRows, " \u00B7 overlay ", pickerRows + approvalRows, " \u00B7 caps ", kitty !== null ? 1 : 0] }), _jsxs(Text, { dimColor: true, children: ["message ", messageHeight, " = stored ", storedHeight, " + live ", liveHeight, " + status ", statusRows] }), _jsxs(Text, { dimColor: true, children: ["transcript rows ", allRows.length, " \u00B7 scroll ", boundedScroll, "/", maxScroll, " \u00B7 page ", pageSizeRef.current] })] }), _jsx(Box, { borderStyle: "round", borderColor: error ? 'red' : 'cyan', paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
|
|
650
1063
|
? _jsxs(Text, { color: "cyan", children: ["\u203A ", _jsx(Text, { dimColor: true, children: "type a task and press enter" })] })
|
|
651
|
-
: editorLayout.lines.map((text, index) => _jsxs(Text, { color: "cyan", children: [index === 0 ? '› ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7
|
|
1064
|
+
: editorLayout.lines.map((text, index) => _jsxs(Text, { color: "cyan", children: [index === 0 ? '› ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? PALETTE.info : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), phase, elapsed ? ` · ${elapsed}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [engine.state.activeModel, " \u00B7 ", mode, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null, " \u00B7 ", scrollStatus] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Ctrl+J new line \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+T tool \u00B7 Ctrl+Y copy \u00B7 Ctrl+L layout \u00B7 Ctrl+C cancel/quit \u00B7 /help"] }), hud.length > 0 && _jsx(Text, { dimColor: true, children: hud.join(' · ') })] })] })] });
|
|
652
1065
|
}
|
|
653
1066
|
//# sourceMappingURL=terminalInterface.js.map
|