omniharness-cli 0.1.38 → 0.1.39
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 +14 -0
- package/dist/agent/mastraEngine.js +101 -0
- package/dist/agent/mastraEngine.js.map +1 -1
- package/dist/ui/palette.js +9 -6
- package/dist/ui/palette.js.map +1 -1
- package/dist/ui/terminalInterface.js +156 -324
- package/dist/ui/terminalInterface.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,26 @@
|
|
|
1
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
|
-
import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
|
|
3
|
+
import { Box, Static, Text, useApp, useInput, useStdin, useStdout } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import { appendPromptHistory, loadPromptHistory } from '../promptHistory.js';
|
|
6
6
|
import { listSessions, loadSnapshot, saveSnapshot, deleteSnapshot } from '../sessionList.js';
|
|
7
7
|
import { deleteAt, deleteBefore, insertAt, layoutEditor, lineEndAt, lineStartAt, moveHorizontal, moveVerticalWrapped, normalizePaste } from './editor.js';
|
|
8
8
|
import { renderMarkdown } from './markdown.js';
|
|
9
9
|
import { looksLikeDiff, diffSegments } from './diff.js';
|
|
10
|
-
import { foldToolGroups } from './groups.js';
|
|
11
10
|
import { palette } from './palette.js';
|
|
12
11
|
import { contextMeter, meterBar } from './modelWindows.js';
|
|
13
12
|
import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
|
|
14
13
|
import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
|
|
14
|
+
const PALETTE = palette();
|
|
15
|
+
/** Border / accent colour per working mode — the input frame changes with it. */
|
|
16
|
+
const MODE_ACCENT = {
|
|
17
|
+
plan: PALETTE.info,
|
|
18
|
+
build: PALETTE.success,
|
|
19
|
+
research: PALETTE.accent,
|
|
20
|
+
crazy: PALETTE.error,
|
|
21
|
+
};
|
|
22
|
+
/** Distinct hues for swarm agents — identity, not severity. */
|
|
23
|
+
const AGENT_COLORS = [PALETTE.accent, PALETTE.info, PALETTE.warn, PALETTE.success, 'magenta', PALETTE.error];
|
|
15
24
|
function describeTarget(name, input) {
|
|
16
25
|
if (input && typeof input === 'object') {
|
|
17
26
|
const record = input;
|
|
@@ -104,23 +113,22 @@ function labelFor(role, model, toolName, fallback) {
|
|
|
104
113
|
switch (role) {
|
|
105
114
|
case 'user': return 'you';
|
|
106
115
|
case 'error': return 'error';
|
|
107
|
-
case 'thinking': return '
|
|
116
|
+
case 'thinking': return 'thinking';
|
|
108
117
|
case 'tool': return toolName ? `tool · ${toolName}` : 'tool';
|
|
109
118
|
default: return model ?? fallback ?? 'assistant';
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
|
-
const PALETTE = palette();
|
|
113
121
|
function colorFor(role, p = PALETTE) {
|
|
114
122
|
switch (role) {
|
|
115
123
|
case 'user': return p.info;
|
|
116
124
|
case 'error': return p.error;
|
|
117
125
|
case 'thinking': return p.warn;
|
|
118
126
|
case 'tool': return p.muted;
|
|
119
|
-
default: return p.
|
|
127
|
+
default: return p.accent;
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
function SegmentText({ segments, role }) {
|
|
123
|
-
const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ?
|
|
131
|
+
const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? undefined : role === 'user' ? PALETTE.info : undefined;
|
|
124
132
|
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
133
|
}
|
|
126
134
|
/** Expanded body of a tool card, rendered per tool type. */
|
|
@@ -133,7 +141,6 @@ function renderToolBody(card, width, p) {
|
|
|
133
141
|
}
|
|
134
142
|
const rows = trail.split('\n').slice(0, 14);
|
|
135
143
|
if (card.name === 'write_file') {
|
|
136
|
-
// A write trail is the new file content — show it as added (green) lines.
|
|
137
144
|
return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { color: p.success, children: line.slice(0, width) }, index)) });
|
|
138
145
|
}
|
|
139
146
|
if (card.name === 'run_command') {
|
|
@@ -145,23 +152,26 @@ function renderToolBody(card, width, p) {
|
|
|
145
152
|
}
|
|
146
153
|
return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { dimColor: true, children: line.slice(0, width) }, index)) });
|
|
147
154
|
}
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
/**
|
|
156
|
+
* One settled transcript entry, rendered exactly once into `<Static>` (native
|
|
157
|
+
* scrollback). Label row + wrapped body; tool/error lines render literally,
|
|
158
|
+
* everything else as markdown.
|
|
159
|
+
*/
|
|
160
|
+
function TranscriptEntry({ line, width, fallbackModel }) {
|
|
161
|
+
const baseLabel = labelFor(line.role, line.model, line.toolName, fallbackModel);
|
|
153
162
|
const label = line.role === 'assistant' && line.provider
|
|
154
163
|
? `${baseLabel} · via ${line.provider}${line.fallback ? ' (failover)' : ''}`
|
|
155
164
|
: baseLabel;
|
|
156
|
-
const
|
|
157
|
-
const
|
|
165
|
+
const asMarkdown = line.role !== 'tool' && line.role !== 'error';
|
|
166
|
+
const rows = asMarkdown
|
|
158
167
|
? renderMarkdown(line.text, width)
|
|
159
168
|
: wrap(line.text, width).map((text) => [{ text }]);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
const bullet = line.role === 'user' ? '❯' : line.role === 'assistant' ? '◆' : line.role === 'thinking' ? '·' : line.role === 'error' ? '✕' : '⋯';
|
|
170
|
+
return _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: colorFor(line.role), children: [bullet, " ", label] }), rows.map((segments, index) => _jsx(SegmentText, { segments: segments, role: line.role }, index)), line.saved ? _jsxs(Text, { dimColor: true, children: [" ", line.saved] }) : null] });
|
|
171
|
+
}
|
|
172
|
+
/** Branded splash shown until the first prompt. */
|
|
173
|
+
function Hero({ width, endpoint, model, mode }) {
|
|
174
|
+
return _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, paddingY: 1, marginBottom: 1, width: Math.min(width, 76), children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "\u25C7 OMNIHARNESS" }), _jsx(Text, { dimColor: true, children: "the OmniRoute-native agent harness" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "gateway " }), endpoint] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "model " }), model] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "mode " }), _jsx(Text, { color: MODE_ACCENT[mode], children: mode }), _jsx(Text, { dimColor: true, children: " \u00B7 Ctrl+E cycles plan \u00B7 build \u00B7 research \u00B7 crazy" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "describe the work and press enter \u00B7 /help for commands" }) })] });
|
|
165
175
|
}
|
|
166
176
|
export function TerminalInterface({ engine }) {
|
|
167
177
|
const { exit } = useApp();
|
|
@@ -170,13 +180,10 @@ export function TerminalInterface({ engine }) {
|
|
|
170
180
|
const [width, setWidth] = useState(() => widthOf(stdout));
|
|
171
181
|
const [edit, setEdit] = useState({ value: '', cursor: 0 });
|
|
172
182
|
const inputWidth = Math.max(16, width - 12);
|
|
173
|
-
//
|
|
183
|
+
// Settled transcript. Rendered once each into <Static> — the terminal's own
|
|
184
|
+
// scrollback is the history; there is no in-app viewport to scroll.
|
|
174
185
|
const [lines, setLines] = useState(() => engine.state.messages.map(lineFromMessage));
|
|
175
|
-
const [
|
|
176
|
-
const followTranscriptRef = useRef(true);
|
|
177
|
-
const previousRowCountRef = useRef(0);
|
|
178
|
-
const maxScrollRef = useRef(0);
|
|
179
|
-
const pageSizeRef = useRef(8);
|
|
186
|
+
const [staticKey, setStaticKey] = useState(0); // bumped on /clear to reset <Static>
|
|
180
187
|
const promptHistoryRef = useRef([]);
|
|
181
188
|
const historyIdxRef = useRef(-1);
|
|
182
189
|
const syncPromptHistory = (next) => { promptHistoryRef.current = next; };
|
|
@@ -195,27 +202,21 @@ export function TerminalInterface({ engine }) {
|
|
|
195
202
|
const [sessionsList, setSessionsList] = useState([]);
|
|
196
203
|
const [sessionsOpen, setSessionsOpen] = useState(false);
|
|
197
204
|
const [sessionsIndex, setSessionsIndex] = useState(0);
|
|
198
|
-
const [groupsExpanded, setGroupsExpanded] = useState(false);
|
|
199
205
|
const [taskQueue, setTaskQueue] = useState(engine.state.taskQueue);
|
|
200
206
|
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
207
|
const [toolCards, setToolCards] = useState([]);
|
|
204
208
|
const [expandedTool, setExpandedTool] = useState();
|
|
205
|
-
|
|
209
|
+
const [agents, setAgents] = useState([]);
|
|
206
210
|
const runStartedAt = useRef(null);
|
|
207
211
|
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
212
|
const queuedRef = useRef(null);
|
|
210
213
|
const [queued, setQueued] = useState();
|
|
211
|
-
// Ctrl+L paints the row budget of every layout region over the transcript.
|
|
212
214
|
const [layoutDebug, setLayoutDebug] = useState(false);
|
|
213
|
-
// Restores plain stdout.write when synchronized-output bracketing is torn down.
|
|
214
215
|
const syncRestoreRef = useRef(null);
|
|
216
|
+
const pushLine = (line) => setLines((current) => [...current, line]);
|
|
217
|
+
const pushTool = (text, toolName = 'system') => pushLine({ role: 'tool', text, toolName });
|
|
215
218
|
useEffect(() => {
|
|
216
219
|
let alive = true;
|
|
217
|
-
// Seed history from disk, but only if the user hasn't already submitted a
|
|
218
|
-
// prompt this session (handles the async load racing a submit).
|
|
219
220
|
void loadPromptHistory().then((history) => {
|
|
220
221
|
if (alive && promptHistoryRef.current.length === 0)
|
|
221
222
|
syncPromptHistory(history);
|
|
@@ -227,10 +228,8 @@ export function TerminalInterface({ engine }) {
|
|
|
227
228
|
stdout.on('resize', onResize);
|
|
228
229
|
stdout.write(KITTY_PUSH);
|
|
229
230
|
let kittyTimer;
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
// reply lets us bracket every frame so streaming never tears mid-repaint.
|
|
233
|
-
const onKitty = (chunk) => {
|
|
231
|
+
// One probe pass: kitty keyboard protocol + synchronized output (DECSET 2026).
|
|
232
|
+
const onProbe = (chunk) => {
|
|
234
233
|
const text = chunk.toString();
|
|
235
234
|
if (isSyncOutputReply(text) && syncRestoreRef.current === null) {
|
|
236
235
|
syncRestoreRef.current = wrapSynchronizedOutput(stdout);
|
|
@@ -239,12 +238,12 @@ export function TerminalInterface({ engine }) {
|
|
|
239
238
|
return;
|
|
240
239
|
if (kittyTimer)
|
|
241
240
|
clearTimeout(kittyTimer);
|
|
242
|
-
stdin?.off('data',
|
|
241
|
+
stdin?.off('data', onProbe);
|
|
243
242
|
setKitty(true);
|
|
244
243
|
};
|
|
245
244
|
if (stdin) {
|
|
246
|
-
stdin.on('data',
|
|
247
|
-
kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data',
|
|
245
|
+
stdin.on('data', onProbe);
|
|
246
|
+
kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onProbe); }, 300);
|
|
248
247
|
stdout.write(SYNC_QUERY);
|
|
249
248
|
stdout.write(KITTY_QUERY);
|
|
250
249
|
}
|
|
@@ -261,24 +260,31 @@ export function TerminalInterface({ engine }) {
|
|
|
261
260
|
break;
|
|
262
261
|
case 'thinking':
|
|
263
262
|
if (event.text)
|
|
264
|
-
|
|
263
|
+
pushLine({ role: 'thinking', text: event.text });
|
|
265
264
|
setLiveThink('');
|
|
266
265
|
break;
|
|
267
266
|
case 'text':
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
pushLine({
|
|
268
|
+
role: 'assistant', text: event.content, model: event.model,
|
|
269
|
+
provider: event.provider, fallback: event.fallback,
|
|
270
|
+
saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
|
|
271
|
+
});
|
|
273
272
|
setLiveAnswer('');
|
|
274
273
|
break;
|
|
275
274
|
case 'route':
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
pushLine({
|
|
276
|
+
role: 'tool', toolName: 'route',
|
|
277
|
+
text: event.fallback
|
|
278
|
+
? `route · failover → ${event.provider ?? 'unknown'} (attempt ${event.attempts + 1})${event.reason ? ` · ${event.reason}` : ''}`
|
|
279
|
+
: `route · ${event.provider ?? 'unknown'}`,
|
|
280
|
+
});
|
|
281
|
+
break;
|
|
282
|
+
case 'agent':
|
|
283
|
+
setAgents((current) => {
|
|
284
|
+
const next = current.filter((lane) => lane.id !== event.id);
|
|
285
|
+
next.push({ id: event.id, label: event.label, status: event.status, note: event.note });
|
|
286
|
+
return next.sort((a, b) => a.id.localeCompare(b.id));
|
|
287
|
+
});
|
|
282
288
|
break;
|
|
283
289
|
case 'tool_start':
|
|
284
290
|
setToolCards((current) => [...current, {
|
|
@@ -302,10 +308,10 @@ export function TerminalInterface({ engine }) {
|
|
|
302
308
|
setTaskQueue(event.todos);
|
|
303
309
|
break;
|
|
304
310
|
case 'preview':
|
|
305
|
-
|
|
311
|
+
pushLine({ role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url });
|
|
306
312
|
break;
|
|
307
313
|
case 'attach':
|
|
308
|
-
|
|
314
|
+
pushLine({ role: 'tool', text: `attached: ${event.name} (${event.kind}, ${event.size} bytes)`, toolName: 'attach' });
|
|
309
315
|
break;
|
|
310
316
|
}
|
|
311
317
|
});
|
|
@@ -318,7 +324,7 @@ export function TerminalInterface({ engine }) {
|
|
|
318
324
|
return () => {
|
|
319
325
|
if (kittyTimer)
|
|
320
326
|
clearTimeout(kittyTimer);
|
|
321
|
-
stdin?.off('data',
|
|
327
|
+
stdin?.off('data', onProbe);
|
|
322
328
|
stdout.off('resize', onResize);
|
|
323
329
|
const pendingApproval = approvalResolve.current;
|
|
324
330
|
approvalResolve.current = null;
|
|
@@ -357,7 +363,7 @@ export function TerminalInterface({ engine }) {
|
|
|
357
363
|
const next = MODE_SEQ[(MODE_SEQ.indexOf(mode) + 1) % MODE_SEQ.length];
|
|
358
364
|
setMode(next);
|
|
359
365
|
engine.state.mode = next;
|
|
360
|
-
|
|
366
|
+
pushTool(`mode → ${next}`, 'mode');
|
|
361
367
|
};
|
|
362
368
|
const approve = (approved, trust) => {
|
|
363
369
|
const resolve = approvalResolve.current;
|
|
@@ -365,31 +371,30 @@ export function TerminalInterface({ engine }) {
|
|
|
365
371
|
approvalResolve.current = null;
|
|
366
372
|
resolve?.({ approved, trust });
|
|
367
373
|
};
|
|
368
|
-
/** Copy the most recent assistant reply
|
|
374
|
+
/** Copy the most recent assistant reply to the clipboard via OSC 52. */
|
|
369
375
|
const yankLastBlock = () => {
|
|
370
376
|
const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
|
|
371
377
|
if (!target)
|
|
372
378
|
return;
|
|
373
379
|
const seq = osc52Copy(target.text);
|
|
374
380
|
if (!seq) {
|
|
375
|
-
|
|
381
|
+
pushTool('clipboard: block too large to copy', 'clipboard');
|
|
376
382
|
return;
|
|
377
383
|
}
|
|
378
384
|
try {
|
|
379
385
|
stdout.write(seq);
|
|
380
|
-
|
|
386
|
+
pushTool(`copied ${target.text.length} chars to clipboard`, 'clipboard');
|
|
381
387
|
}
|
|
382
388
|
catch { /* clipboard write is best-effort */ }
|
|
383
389
|
};
|
|
384
390
|
/** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
|
|
385
391
|
const startRun = (prompt, attachSpec) => {
|
|
386
|
-
followTranscriptRef.current = true;
|
|
387
|
-
setScrollOffset(0);
|
|
388
392
|
setEdit({ value: '', cursor: 0 });
|
|
389
393
|
setBusy(true);
|
|
390
394
|
setError(undefined);
|
|
391
395
|
setToolCards([]);
|
|
392
396
|
setExpandedTool(undefined);
|
|
397
|
+
setAgents([]);
|
|
393
398
|
if (runStartedAt.current === null)
|
|
394
399
|
runStartedAt.current = Date.now();
|
|
395
400
|
setNow(Date.now());
|
|
@@ -399,28 +404,33 @@ export function TerminalInterface({ engine }) {
|
|
|
399
404
|
void appendPromptHistory(prompt).catch(() => { });
|
|
400
405
|
}
|
|
401
406
|
if (!attachSpec)
|
|
402
|
-
|
|
407
|
+
pushLine({ role: 'user', text: prompt });
|
|
403
408
|
void (async () => {
|
|
404
409
|
try {
|
|
405
410
|
if (attachSpec)
|
|
406
411
|
await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
|
|
407
412
|
await engine.run(prompt);
|
|
408
|
-
|
|
413
|
+
// CRAZY mode: once a plan exists, fan the rest of it out across parallel workers.
|
|
414
|
+
if (engine.state.mode === 'crazy' && typeof engine.runSwarm === 'function') {
|
|
415
|
+
const pending = engine.state.taskQueue.filter((item) => item.status === 'pending').length;
|
|
416
|
+
if (pending >= 2)
|
|
417
|
+
await engine.runSwarm({ maxAgents: 3 });
|
|
418
|
+
}
|
|
409
419
|
}
|
|
410
420
|
catch (reason) {
|
|
411
421
|
const message = reason instanceof Error ? reason.message : String(reason);
|
|
412
422
|
setError(message);
|
|
413
|
-
|
|
423
|
+
pushLine({ role: 'error', text: message });
|
|
414
424
|
}
|
|
415
425
|
finally {
|
|
416
426
|
const startedAt = runStartedAt.current;
|
|
417
427
|
setBusy(false);
|
|
418
428
|
setCurrentTool(undefined);
|
|
419
429
|
setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
|
|
430
|
+
setAgents((current) => current.map((lane) => lane.status === 'working' || lane.status === 'spawned' ? { ...lane, status: 'done' } : lane));
|
|
420
431
|
runStartedAt.current = null;
|
|
421
432
|
setLiveThink('');
|
|
422
433
|
setLiveAnswer('');
|
|
423
|
-
// A long run probably pulled focus elsewhere: nudge with a bell + OSC 9 notification.
|
|
424
434
|
if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
|
|
425
435
|
try {
|
|
426
436
|
stdout.write(osc9Notify('OmniHarness — run finished'));
|
|
@@ -443,37 +453,23 @@ export function TerminalInterface({ engine }) {
|
|
|
443
453
|
historyIdxRef.current = next;
|
|
444
454
|
setEdit(next < 0 ? { value: '', cursor: 0 } : { value: history[next] ?? '', cursor: (history[next] ?? '').length });
|
|
445
455
|
};
|
|
446
|
-
/** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
|
|
447
456
|
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
457
|
/** Chapters: one per user turn, titled by the prompt's first line. */
|
|
463
458
|
const chapters = () => lines.flatMap((line, index) => line.role === 'user'
|
|
464
459
|
? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
|
|
465
460
|
: []);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
461
|
+
const HELP = [
|
|
462
|
+
'/help — show commands',
|
|
463
|
+
'/clear — start a fresh conversation',
|
|
464
|
+
'/sessions — list saved sessions (enter to resume)',
|
|
465
|
+
'/save <name> — snapshot the current session',
|
|
466
|
+
'/forget <name> — delete a saved session',
|
|
467
|
+
'/attach <files> — attach files to the next message',
|
|
468
|
+
'/find <text> — list transcript lines containing <text>',
|
|
469
|
+
'/chapters — list the turns in this session',
|
|
470
|
+
'keys: Ctrl+O models · Ctrl+E mode · Ctrl+T tool card · Ctrl+Y copy reply · Ctrl+L layout · Ctrl+C cancel/quit · ↑/↓ history',
|
|
471
|
+
'history scrolls in your terminal · a prompt typed mid-run is queued and sent when the run ends',
|
|
472
|
+
];
|
|
477
473
|
/** Apply a semantic key action, honoring the active overlay (approval, picker). */
|
|
478
474
|
const applyAction = (action) => {
|
|
479
475
|
if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
|
|
@@ -493,12 +489,12 @@ export function TerminalInterface({ engine }) {
|
|
|
493
489
|
if (selected) {
|
|
494
490
|
void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
|
|
495
491
|
if (snapshot == null) {
|
|
496
|
-
|
|
492
|
+
pushLine({ role: 'error', text: `snapshot ${selected.name} is unreadable` });
|
|
497
493
|
return;
|
|
498
494
|
}
|
|
499
|
-
|
|
495
|
+
setStaticKey((k) => k + 1);
|
|
496
|
+
setLines([...snapshot.messages.map(lineFromMessage), { role: 'tool', toolName: 'sessions', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)` }]);
|
|
500
497
|
setTaskQueue(snapshot.taskQueue);
|
|
501
|
-
setLines((current) => [...current, { role: 'tool', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)`, toolName: 'sessions' }]);
|
|
502
498
|
});
|
|
503
499
|
}
|
|
504
500
|
setSessionsOpen(false);
|
|
@@ -509,7 +505,7 @@ export function TerminalInterface({ engine }) {
|
|
|
509
505
|
if (selected) {
|
|
510
506
|
void engine.selectModel(selected.id);
|
|
511
507
|
setPickerOpen(false);
|
|
512
|
-
|
|
508
|
+
pushTool(`model → ${selected.id} (saved as default)`, 'model');
|
|
513
509
|
}
|
|
514
510
|
return;
|
|
515
511
|
}
|
|
@@ -517,18 +513,7 @@ export function TerminalInterface({ engine }) {
|
|
|
517
513
|
const raw = edit.value.trim();
|
|
518
514
|
const attachMatch = /^\/attach\s+(.+)$/.exec(raw);
|
|
519
515
|
if (raw === '/help') {
|
|
520
|
-
|
|
521
|
-
{ role: 'tool', text: '/help — show commands', toolName: 'commands' },
|
|
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' },
|
|
526
|
-
{ role: 'tool', text: '/attach <files> — attach files to the next message', toolName: 'commands' },
|
|
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
|
-
]);
|
|
516
|
+
HELP.forEach((text) => pushTool(text, 'help'));
|
|
532
517
|
setEdit({ value: '', cursor: 0 });
|
|
533
518
|
historyIdxRef.current = -1;
|
|
534
519
|
return;
|
|
@@ -536,36 +521,23 @@ export function TerminalInterface({ engine }) {
|
|
|
536
521
|
const findMatch = /^\/find\s+(.+)$/.exec(raw);
|
|
537
522
|
if (findMatch) {
|
|
538
523
|
const needle = findMatch[1].toLowerCase();
|
|
539
|
-
const hits = lines.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
|
|
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]}` }]);
|
|
524
|
+
const hits = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
525
|
+
if (hits.length === 0)
|
|
526
|
+
pushTool(`no match for "${findMatch[1]}"`, 'find');
|
|
527
|
+
else {
|
|
528
|
+
pushTool(`find "${findMatch[1]}" · ${hits.length} match${hits.length === 1 ? '' : 'es'}`, 'find');
|
|
529
|
+
hits.slice(-6).forEach((hit) => pushTool(` ${labelFor(hit.role, hit.model, hit.toolName)} · ${clip(hit.text.replace(/\n/g, ' '), Math.max(20, width - 16))}`, 'find'));
|
|
530
|
+
}
|
|
558
531
|
setEdit({ value: '', cursor: 0 });
|
|
559
532
|
historyIdxRef.current = -1;
|
|
560
533
|
return;
|
|
561
534
|
}
|
|
562
535
|
if (raw === '/chapters' || raw === '/chapter') {
|
|
563
536
|
const list = chapters();
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
]);
|
|
537
|
+
if (list.length === 0)
|
|
538
|
+
pushTool('no chapters yet — each prompt starts one', 'chapters');
|
|
539
|
+
else
|
|
540
|
+
list.forEach((chapter, order) => pushTool(`${order + 1}. ${chapter.title}`, 'chapters'));
|
|
569
541
|
setEdit({ value: '', cursor: 0 });
|
|
570
542
|
historyIdxRef.current = -1;
|
|
571
543
|
return;
|
|
@@ -575,9 +547,8 @@ export function TerminalInterface({ engine }) {
|
|
|
575
547
|
setSessionsList(sessions);
|
|
576
548
|
setSessionsIndex(0);
|
|
577
549
|
setSessionsOpen(sessions.length > 0);
|
|
578
|
-
if (sessions.length === 0)
|
|
579
|
-
|
|
580
|
-
}
|
|
550
|
+
if (sessions.length === 0)
|
|
551
|
+
pushTool('no saved sessions — use /save <name> to snapshot this one', 'sessions');
|
|
581
552
|
});
|
|
582
553
|
setEdit({ value: '', cursor: 0 });
|
|
583
554
|
return;
|
|
@@ -587,35 +558,30 @@ export function TerminalInterface({ engine }) {
|
|
|
587
558
|
const name = saveMatch[1];
|
|
588
559
|
void saveSnapshot(engine.state.workspace.root, name, {
|
|
589
560
|
messages: [...engine.state.messages], taskQueue: [...engine.state.taskQueue], savedAt: new Date().toISOString(),
|
|
590
|
-
}).then(() => {
|
|
591
|
-
|
|
592
|
-
}).catch((reason) => {
|
|
593
|
-
setLines((current) => [...current, { role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }]);
|
|
594
|
-
});
|
|
561
|
+
}).then(() => pushTool(`session saved: ${name}`, 'sessions'))
|
|
562
|
+
.catch((reason) => pushLine({ role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }));
|
|
595
563
|
setEdit({ value: '', cursor: 0 });
|
|
596
564
|
return;
|
|
597
565
|
}
|
|
598
566
|
const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
|
|
599
567
|
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
|
-
});
|
|
568
|
+
void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => pushTool(`session deleted: ${delMatch[1]}`, 'sessions'));
|
|
603
569
|
setEdit({ value: '', cursor: 0 });
|
|
604
570
|
return;
|
|
605
571
|
}
|
|
606
572
|
if (raw === '/clear') {
|
|
607
573
|
historyIdxRef.current = -1;
|
|
608
|
-
followTranscriptRef.current = true;
|
|
609
|
-
setScrollOffset(0);
|
|
610
574
|
setTaskQueue([]);
|
|
611
575
|
setError(undefined);
|
|
612
576
|
setCurrentTool(undefined);
|
|
613
577
|
setToolCards([]);
|
|
614
578
|
setExpandedTool(undefined);
|
|
579
|
+
setAgents([]);
|
|
615
580
|
runStartedAt.current = null;
|
|
616
581
|
setLiveThink('');
|
|
617
582
|
setLiveAnswer('');
|
|
618
583
|
syncPromptHistory([]);
|
|
584
|
+
setStaticKey((k) => k + 1);
|
|
619
585
|
setLines([]);
|
|
620
586
|
void engine.clearHistory().catch(() => { });
|
|
621
587
|
setEdit({ value: '', cursor: 0 });
|
|
@@ -625,8 +591,6 @@ export function TerminalInterface({ engine }) {
|
|
|
625
591
|
const attachSpec = attachMatch ? attachMatch[1] : undefined;
|
|
626
592
|
if (!prompt && !attachSpec)
|
|
627
593
|
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
594
|
if (busy) {
|
|
631
595
|
queuedRef.current = { prompt, attachSpec };
|
|
632
596
|
setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
|
|
@@ -652,7 +616,6 @@ export function TerminalInterface({ engine }) {
|
|
|
652
616
|
}
|
|
653
617
|
return;
|
|
654
618
|
case 'ctrlC':
|
|
655
|
-
// While a run is in flight, Ctrl+C cancels that run; when idle it quits.
|
|
656
619
|
if (busy) {
|
|
657
620
|
engine.cancel();
|
|
658
621
|
return;
|
|
@@ -725,7 +688,6 @@ export function TerminalInterface({ engine }) {
|
|
|
725
688
|
return;
|
|
726
689
|
}
|
|
727
690
|
};
|
|
728
|
-
// Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
|
|
729
691
|
useInput((value, key) => {
|
|
730
692
|
if (approval) {
|
|
731
693
|
if (value === 'y' || value === 'Y' || key.return) {
|
|
@@ -801,14 +763,6 @@ export function TerminalInterface({ engine }) {
|
|
|
801
763
|
}
|
|
802
764
|
return;
|
|
803
765
|
}
|
|
804
|
-
if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
|
|
805
|
-
scrollTranscript(pageSizeRef.current);
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
if (key.pageDown || (key.ctrl && value.toLowerCase() === 'd')) {
|
|
809
|
-
scrollTranscript(-pageSizeRef.current);
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
766
|
if (key.ctrl && value.toLowerCase() === 'l') {
|
|
813
767
|
setLayoutDebug((current) => !current);
|
|
814
768
|
return;
|
|
@@ -817,7 +771,6 @@ export function TerminalInterface({ engine }) {
|
|
|
817
771
|
yankLastBlock();
|
|
818
772
|
return;
|
|
819
773
|
}
|
|
820
|
-
// Ctrl+T toggles the most recent tool card between collapsed and expanded.
|
|
821
774
|
if (key.ctrl && value.toLowerCase() === 't') {
|
|
822
775
|
const latest = toolCards[toolCards.length - 1];
|
|
823
776
|
setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
|
|
@@ -844,7 +797,7 @@ export function TerminalInterface({ engine }) {
|
|
|
844
797
|
return;
|
|
845
798
|
}
|
|
846
799
|
if (!key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow)
|
|
847
|
-
historyIdxRef.current = -1;
|
|
800
|
+
historyIdxRef.current = -1;
|
|
848
801
|
if (key.upArrow) {
|
|
849
802
|
applyAction({ kind: 'up' });
|
|
850
803
|
return;
|
|
@@ -861,7 +814,6 @@ export function TerminalInterface({ engine }) {
|
|
|
861
814
|
applyAction({ kind: 'right' });
|
|
862
815
|
return;
|
|
863
816
|
}
|
|
864
|
-
// 0x7f is Backspace on Windows ConPTY and the kitty-encoded keys are owned by the raw stdin listener.
|
|
865
817
|
if (value.length > 1 && !isEncodedKey(value)) {
|
|
866
818
|
setEdit((current) => insertAt(current.value, current.cursor, normalizePaste(value)));
|
|
867
819
|
return;
|
|
@@ -869,7 +821,6 @@ export function TerminalInterface({ engine }) {
|
|
|
869
821
|
if (!key.ctrl && !key.meta && value && !isEncodedKey(value))
|
|
870
822
|
setEdit((current) => insertAt(current.value, current.cursor, value));
|
|
871
823
|
});
|
|
872
|
-
// Raw stdin: disambiguate Windows Backspace (0x7f), the real Delete key, and kitty-protocol keys.
|
|
873
824
|
useEffect(() => {
|
|
874
825
|
if (!stdin)
|
|
875
826
|
return;
|
|
@@ -881,151 +832,12 @@ export function TerminalInterface({ engine }) {
|
|
|
881
832
|
stdin.on('data', onData);
|
|
882
833
|
return () => { stdin.off('data', onData); };
|
|
883
834
|
}, [stdin, applyAction]);
|
|
884
|
-
// Legacy Ctrl+M is the CR byte — identical to Enter — so mode cycling needs a
|
|
885
|
-
// distinguishable key (Ctrl+E works everywhere); kitty terminals also keep Ctrl+M.
|
|
886
|
-
const modeKey = kitty === true ? 'M' : 'E';
|
|
887
|
-
const metrics = engine.client.snapshotMetrics();
|
|
888
|
-
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '—';
|
|
889
|
-
const hud = [mode, engine.state.activeModel];
|
|
890
|
-
if (metrics.fallback.activeProvider)
|
|
891
|
-
hud.push(metrics.fallback.activeProvider);
|
|
892
|
-
if (metrics.remainingQuota !== undefined)
|
|
893
|
-
hud.push(`quota ${metrics.remainingQuota}`);
|
|
894
|
-
if (metrics.fallback.attempts > 0)
|
|
895
|
-
hud.push(`fb ${metrics.fallback.attempts}`);
|
|
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
|
-
: '';
|
|
911
|
-
const contentWidth = Math.max(20, width - 8);
|
|
912
|
-
const terminalRows = stdout.rows ?? 24;
|
|
913
|
-
const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
|
|
914
|
-
const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
|
|
915
|
-
const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
|
|
916
|
-
const liveRows = useMemo(() => {
|
|
917
|
-
const rows = [];
|
|
918
|
-
if (liveThink !== '') {
|
|
919
|
-
rows.push({ key: 'live-think-label', kind: 'label', role: 'thinking' });
|
|
920
|
-
liveThinkLines.forEach((segments, index) => rows.push({ key: `live-think-${index}`, kind: 'content', role: 'thinking', segments }));
|
|
921
|
-
}
|
|
922
|
-
if (liveAnswer !== '') {
|
|
923
|
-
rows.push({ key: 'live-answer-label', kind: 'label', role: 'assistant' });
|
|
924
|
-
liveAnswerLines.forEach((segments, index) => rows.push({ key: `live-answer-${index}`, kind: 'content', role: 'assistant', segments }));
|
|
925
|
-
}
|
|
926
|
-
return rows;
|
|
927
|
-
}, [liveThink, liveAnswer, liveThinkLines, liveAnswerLines]);
|
|
928
|
-
// Cache markdown/wrapping per Line object. Streaming updates create new live
|
|
929
|
-
// text, but completed historical lines retain their rendered rows.
|
|
930
|
-
const rowCacheRef = useRef(null);
|
|
931
|
-
const allRows = useMemo(() => {
|
|
932
|
-
const fallback = engine.state.activeModel;
|
|
933
|
-
let cache = rowCacheRef.current;
|
|
934
|
-
if (cache === null || cache.width !== contentWidth || cache.fallback !== fallback) {
|
|
935
|
-
cache = { width: contentWidth, fallback, rows: new WeakMap() };
|
|
936
|
-
rowCacheRef.current = cache;
|
|
937
|
-
}
|
|
938
|
-
const out = [];
|
|
939
|
-
lines.forEach((line, lineIndex) => {
|
|
940
|
-
let rendered = cache.rows.get(line);
|
|
941
|
-
if (rendered === undefined) {
|
|
942
|
-
rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
|
|
943
|
-
cache.rows.set(line, rendered);
|
|
944
|
-
}
|
|
945
|
-
out.push(...rendered);
|
|
946
|
-
});
|
|
947
|
-
return out;
|
|
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]);
|
|
973
|
-
const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
|
|
974
|
-
const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
|
|
975
|
-
const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
|
|
976
|
-
const approvalRows = approval ? 8 : 0;
|
|
977
|
-
const inputRows = 3 + editorLayout.lines.length;
|
|
978
|
-
const footerRows = 3;
|
|
979
|
-
const chromeRows = 3 + footerRows + inputRows + (kitty !== null ? 1 : 0) + planRows + pickerRows + approvalRows;
|
|
980
|
-
const messageHeight = Math.max(3, terminalRows - chromeRows);
|
|
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;
|
|
983
|
-
const storedHeight = Math.max(0, messageHeight - Math.min(messageHeight, liveRows.length + statusRows));
|
|
984
|
-
const liveHeight = Math.max(0, messageHeight - storedHeight - statusRows);
|
|
985
|
-
const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
|
|
986
|
-
const maxScroll = Math.max(0, allRows.length - storedHeight);
|
|
987
|
-
maxScrollRef.current = maxScroll;
|
|
988
|
-
allRowsRef.current = allRows;
|
|
989
|
-
storedHeightRef.current = storedHeight;
|
|
990
|
-
pageSizeRef.current = Math.max(1, storedHeight - 2);
|
|
991
|
-
const boundedScroll = clamp(scrollOffset, 0, maxScroll);
|
|
992
|
-
const endRow = allRows.length - boundedScroll;
|
|
993
|
-
const startRow = Math.max(0, endRow - storedHeight);
|
|
994
|
-
const visibleRows = allRows.slice(startRow, endRow);
|
|
995
|
-
const hiddenAbove = startRow;
|
|
996
|
-
const hiddenBelow = boundedScroll;
|
|
997
|
-
const scrollStatus = allRows.length === 0
|
|
998
|
-
? 'transcript empty'
|
|
999
|
-
: storedHeight === 0
|
|
1000
|
-
? `live output · ${allRows.length} transcript rows`
|
|
1001
|
-
: hiddenBelow === 0
|
|
1002
|
-
? `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · following latest`
|
|
1003
|
-
: `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · ${hiddenAbove} older · ${hiddenBelow} newer`;
|
|
1004
|
-
useEffect(() => {
|
|
1005
|
-
const previous = previousRowCountRef.current;
|
|
1006
|
-
const added = allRows.length - previous;
|
|
1007
|
-
previousRowCountRef.current = allRows.length;
|
|
1008
|
-
if (previous === 0 || added <= 0)
|
|
1009
|
-
return;
|
|
1010
|
-
if (followTranscriptRef.current) {
|
|
1011
|
-
setScrollOffset(0);
|
|
1012
|
-
}
|
|
1013
|
-
else {
|
|
1014
|
-
setScrollOffset((current) => clamp(current + added, 0, maxScrollRef.current));
|
|
1015
|
-
}
|
|
1016
|
-
}, [allRows.length]);
|
|
1017
|
-
useEffect(() => {
|
|
1018
|
-
setScrollOffset((current) => clamp(current, 0, maxScroll));
|
|
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
835
|
useEffect(() => {
|
|
1023
836
|
if (!busy)
|
|
1024
837
|
return;
|
|
1025
838
|
const id = setInterval(() => setNow(Date.now()), 250);
|
|
1026
839
|
return () => clearInterval(id);
|
|
1027
840
|
}, [busy]);
|
|
1028
|
-
// Drain a prompt that was queued while the previous run was in flight.
|
|
1029
841
|
useEffect(() => {
|
|
1030
842
|
if (busy)
|
|
1031
843
|
return;
|
|
@@ -1036,31 +848,51 @@ export function TerminalInterface({ engine }) {
|
|
|
1036
848
|
setQueued(undefined);
|
|
1037
849
|
startRun(pending.prompt, pending.attachSpec);
|
|
1038
850
|
}, [busy]);
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
851
|
+
const modeKey = kitty === true ? 'M' : 'E';
|
|
852
|
+
const modeAccent = MODE_ACCENT[mode];
|
|
853
|
+
const contentWidth = Math.max(20, width - 6);
|
|
854
|
+
const terminalRows = stdout.rows ?? 24;
|
|
855
|
+
const metrics = engine.client.snapshotMetrics();
|
|
856
|
+
const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
|
|
857
|
+
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
858
|
+
const contextLabel = metrics.compression.inputTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
|
|
859
|
+
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
|
|
860
|
+
const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
|
|
861
|
+
const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
|
|
862
|
+
const elapsed = busy
|
|
863
|
+
? (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`)
|
|
864
|
+
: '';
|
|
865
|
+
const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
|
|
866
|
+
const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
|
|
867
|
+
const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
|
|
868
|
+
// Cap the streaming region so a long think/answer can't crowd out the chrome;
|
|
869
|
+
// the complete text lands in <Static> once the event fires.
|
|
870
|
+
const liveBudget = Math.max(3, terminalRows - 14 - Math.min(6, taskQueue.length) - Math.min(4, agents.length) - editorLayout.lines.length);
|
|
871
|
+
const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
|
|
872
|
+
const liveAnswerView = liveAnswerLines.slice(-liveBudget);
|
|
873
|
+
const doneAgents = agents.filter((lane) => lane.status === 'done').length;
|
|
874
|
+
return _jsxs(Box, { flexDirection: "column", width: width, paddingX: 2, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), _jsxs(Box, { flexDirection: "column", children: [lines.length === 0 && !busy && _jsx(Hero, { width: width, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.accent, children: ["\u25C6 ", engine.state.activeModel] }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), toolCards.slice(-5).map((card) => {
|
|
1046
875
|
const expanded = expandedTool === card.id;
|
|
1047
|
-
const
|
|
876
|
+
const dot = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: "\u25CD" }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "\u2715" }) : _jsx(Text, { color: PALETTE.success, children: "\u2713" });
|
|
1048
877
|
const head = card.name === 'run_command'
|
|
1049
|
-
? `$ ${clip(card.target || '…', Math.max(10, contentWidth -
|
|
1050
|
-
: `${toolVerb(card.name)}${card.target ? `
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
: null
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
878
|
+
? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 20))}`
|
|
879
|
+
: `${toolVerb(card.name)}${card.target ? ` ${clip(card.target, Math.max(10, contentWidth - 24))}` : ''}`;
|
|
880
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [dot, " ", head, expanded ? ' Ctrl+T collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
|
|
881
|
+
}), agents.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.error, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.error, children: "\u26A1 swarm" }), _jsxs(Text, { dimColor: true, children: [doneAgents, "/", agents.length, " lanes done"] })] }), agents.map((lane, index) => {
|
|
882
|
+
const color = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
883
|
+
const glyph = lane.status === 'done' ? '✓' : lane.status === 'error' ? '✕' : lane.status === 'working' ? '◍' : '○';
|
|
884
|
+
return _jsxs(Text, { color: color, children: [glyph, " ", lane.id, " ", _jsx(Text, { dimColor: true, children: clip(lane.note ?? lane.label, Math.max(12, contentWidth - 8)) })] }, lane.id);
|
|
885
|
+
})] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "\u25C7 plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
|
|
886
|
+
const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '◈' : '○';
|
|
887
|
+
const color = item.status === 'done' ? PALETTE.success : item.status === 'active' ? PALETTE.accent : undefined;
|
|
888
|
+
return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
|
|
889
|
+
})] }), engine.state.preview && _jsxs(Text, { color: PALETTE.success, children: ["\u25B8 preview live \u00B7 ", engine.state.preview.url] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.info, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.info, 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 ? PALETTE.info : 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: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, 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: PALETTE.error, children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "no models returned by OmniRoute." }), pickerItems.map((item, index) => {
|
|
890
|
+
const header = index === 0 || pickerItems[index - 1].group !== item.group
|
|
891
|
+
? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
|
|
892
|
+
: null;
|
|
893
|
+
return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? PALETTE.accent : undefined, children: [index === pickerIndex ? '❯ ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' ✓' : ''] })] }, item.id);
|
|
894
|
+
})] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warn, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.warn, 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: PALETTE.muted, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["static entries ", lines.length, " \u00B7 live budget ", liveBudget, " \u00B7 think ", liveThinkLines.length, " \u00B7 answer ", liveAnswerLines.length] }), _jsxs(Text, { dimColor: true, children: ["plan ", taskQueue.length, " \u00B7 swarm ", agents.length, " \u00B7 tool cards ", toolCards.length, " \u00B7 editor rows ", editorLayout.lines.length] })] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["\u23CE queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] }), _jsx(Box, { borderStyle: "round", borderColor: error ? PALETTE.error : modeAccent, paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
|
|
895
|
+
? _jsxs(Text, { color: modeAccent, children: ["\u276F ", _jsx(Text, { dimColor: true, children: busy ? 'type to queue the next task' : 'describe the work and press enter' })] })
|
|
896
|
+
: editorLayout.lines.map((text, index) => _jsxs(Text, { color: modeAccent, 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 ? modeAccent : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), busy ? ' ' : '', phase, elapsed ? ` · ${elapsed}` : '', agents.length > 0 ? ` · swarm ${doneAgents}/${agents.length}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [_jsx(Text, { color: modeAccent, children: mode }), " \u00B7 ", engine.state.activeModel, metrics.fallback.activeProvider ? _jsxs(Text, { children: [" \u00B7 via ", metrics.fallback.activeProvider] }) : null, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Ctrl+J newline \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+T tool \u00B7 Ctrl+Y copy \u00B7 Ctrl+C ", busy ? 'cancel' : 'quit', " \u00B7 /help"] }), compression ? _jsxs(Text, { dimColor: true, children: ["saved ", compression, metrics.remainingQuota !== undefined ? ` · quota ${metrics.remainingQuota}` : ''] }) : null] })] })] })] });
|
|
1065
897
|
}
|
|
1066
898
|
//# sourceMappingURL=terminalInterface.js.map
|