omniharness-cli 0.1.38 → 0.1.40
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 +162 -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,33 @@ 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 prev = current.find((lane) => lane.id === event.id);
|
|
285
|
+
const next = current.filter((lane) => lane.id !== event.id);
|
|
286
|
+
// Keep the last meaningful note when a status-only update carries none.
|
|
287
|
+
next.push({ id: event.id, label: event.label, status: event.status, note: event.note ?? prev?.note });
|
|
288
|
+
return next.sort((a, b) => a.id.localeCompare(b.id));
|
|
289
|
+
});
|
|
282
290
|
break;
|
|
283
291
|
case 'tool_start':
|
|
284
292
|
setToolCards((current) => [...current, {
|
|
@@ -302,10 +310,10 @@ export function TerminalInterface({ engine }) {
|
|
|
302
310
|
setTaskQueue(event.todos);
|
|
303
311
|
break;
|
|
304
312
|
case 'preview':
|
|
305
|
-
|
|
313
|
+
pushLine({ role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url });
|
|
306
314
|
break;
|
|
307
315
|
case 'attach':
|
|
308
|
-
|
|
316
|
+
pushLine({ role: 'tool', text: `attached: ${event.name} (${event.kind}, ${event.size} bytes)`, toolName: 'attach' });
|
|
309
317
|
break;
|
|
310
318
|
}
|
|
311
319
|
});
|
|
@@ -318,7 +326,7 @@ export function TerminalInterface({ engine }) {
|
|
|
318
326
|
return () => {
|
|
319
327
|
if (kittyTimer)
|
|
320
328
|
clearTimeout(kittyTimer);
|
|
321
|
-
stdin?.off('data',
|
|
329
|
+
stdin?.off('data', onProbe);
|
|
322
330
|
stdout.off('resize', onResize);
|
|
323
331
|
const pendingApproval = approvalResolve.current;
|
|
324
332
|
approvalResolve.current = null;
|
|
@@ -357,7 +365,7 @@ export function TerminalInterface({ engine }) {
|
|
|
357
365
|
const next = MODE_SEQ[(MODE_SEQ.indexOf(mode) + 1) % MODE_SEQ.length];
|
|
358
366
|
setMode(next);
|
|
359
367
|
engine.state.mode = next;
|
|
360
|
-
|
|
368
|
+
pushTool(`mode → ${next}`, 'mode');
|
|
361
369
|
};
|
|
362
370
|
const approve = (approved, trust) => {
|
|
363
371
|
const resolve = approvalResolve.current;
|
|
@@ -365,31 +373,30 @@ export function TerminalInterface({ engine }) {
|
|
|
365
373
|
approvalResolve.current = null;
|
|
366
374
|
resolve?.({ approved, trust });
|
|
367
375
|
};
|
|
368
|
-
/** Copy the most recent assistant reply
|
|
376
|
+
/** Copy the most recent assistant reply to the clipboard via OSC 52. */
|
|
369
377
|
const yankLastBlock = () => {
|
|
370
378
|
const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
|
|
371
379
|
if (!target)
|
|
372
380
|
return;
|
|
373
381
|
const seq = osc52Copy(target.text);
|
|
374
382
|
if (!seq) {
|
|
375
|
-
|
|
383
|
+
pushTool('clipboard: block too large to copy', 'clipboard');
|
|
376
384
|
return;
|
|
377
385
|
}
|
|
378
386
|
try {
|
|
379
387
|
stdout.write(seq);
|
|
380
|
-
|
|
388
|
+
pushTool(`copied ${target.text.length} chars to clipboard`, 'clipboard');
|
|
381
389
|
}
|
|
382
390
|
catch { /* clipboard write is best-effort */ }
|
|
383
391
|
};
|
|
384
392
|
/** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
|
|
385
393
|
const startRun = (prompt, attachSpec) => {
|
|
386
|
-
followTranscriptRef.current = true;
|
|
387
|
-
setScrollOffset(0);
|
|
388
394
|
setEdit({ value: '', cursor: 0 });
|
|
389
395
|
setBusy(true);
|
|
390
396
|
setError(undefined);
|
|
391
397
|
setToolCards([]);
|
|
392
398
|
setExpandedTool(undefined);
|
|
399
|
+
setAgents([]);
|
|
393
400
|
if (runStartedAt.current === null)
|
|
394
401
|
runStartedAt.current = Date.now();
|
|
395
402
|
setNow(Date.now());
|
|
@@ -399,28 +406,36 @@ export function TerminalInterface({ engine }) {
|
|
|
399
406
|
void appendPromptHistory(prompt).catch(() => { });
|
|
400
407
|
}
|
|
401
408
|
if (!attachSpec)
|
|
402
|
-
|
|
409
|
+
pushLine({ role: 'user', text: prompt });
|
|
403
410
|
void (async () => {
|
|
404
411
|
try {
|
|
405
412
|
if (attachSpec)
|
|
406
413
|
await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
|
|
407
414
|
await engine.run(prompt);
|
|
408
|
-
|
|
415
|
+
// CRAZY mode: once a plan exists, fan the rest of it out across parallel workers.
|
|
416
|
+
if (engine.state.mode === 'crazy' && typeof engine.runSwarm === 'function') {
|
|
417
|
+
const pending = engine.state.taskQueue.filter((item) => item.status === 'pending').length;
|
|
418
|
+
if (pending >= 2) {
|
|
419
|
+
setToolCards([]); // the planning turn's cards give way to the swarm rail
|
|
420
|
+
setExpandedTool(undefined);
|
|
421
|
+
await engine.runSwarm({ maxAgents: 3 });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
409
424
|
}
|
|
410
425
|
catch (reason) {
|
|
411
426
|
const message = reason instanceof Error ? reason.message : String(reason);
|
|
412
427
|
setError(message);
|
|
413
|
-
|
|
428
|
+
pushLine({ role: 'error', text: message });
|
|
414
429
|
}
|
|
415
430
|
finally {
|
|
416
431
|
const startedAt = runStartedAt.current;
|
|
417
432
|
setBusy(false);
|
|
418
433
|
setCurrentTool(undefined);
|
|
419
434
|
setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
|
|
435
|
+
setAgents((current) => current.map((lane) => lane.status === 'working' || lane.status === 'spawned' ? { ...lane, status: 'done' } : lane));
|
|
420
436
|
runStartedAt.current = null;
|
|
421
437
|
setLiveThink('');
|
|
422
438
|
setLiveAnswer('');
|
|
423
|
-
// A long run probably pulled focus elsewhere: nudge with a bell + OSC 9 notification.
|
|
424
439
|
if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
|
|
425
440
|
try {
|
|
426
441
|
stdout.write(osc9Notify('OmniHarness — run finished'));
|
|
@@ -443,37 +458,23 @@ export function TerminalInterface({ engine }) {
|
|
|
443
458
|
historyIdxRef.current = next;
|
|
444
459
|
setEdit(next < 0 ? { value: '', cursor: 0 } : { value: history[next] ?? '', cursor: (history[next] ?? '').length });
|
|
445
460
|
};
|
|
446
|
-
/** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
|
|
447
461
|
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
462
|
/** Chapters: one per user turn, titled by the prompt's first line. */
|
|
463
463
|
const chapters = () => lines.flatMap((line, index) => line.role === 'user'
|
|
464
464
|
? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
|
|
465
465
|
: []);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
466
|
+
const HELP = [
|
|
467
|
+
'/help — show commands',
|
|
468
|
+
'/clear — start a fresh conversation',
|
|
469
|
+
'/sessions — list saved sessions (enter to resume)',
|
|
470
|
+
'/save <name> — snapshot the current session',
|
|
471
|
+
'/forget <name> — delete a saved session',
|
|
472
|
+
'/attach <files> — attach files to the next message',
|
|
473
|
+
'/find <text> — list transcript lines containing <text>',
|
|
474
|
+
'/chapters — list the turns in this session',
|
|
475
|
+
'keys: Ctrl+O models · Ctrl+E mode · Ctrl+T tool card · Ctrl+Y copy reply · Ctrl+L layout · Ctrl+C cancel/quit · ↑/↓ history',
|
|
476
|
+
'history scrolls in your terminal · a prompt typed mid-run is queued and sent when the run ends',
|
|
477
|
+
];
|
|
477
478
|
/** Apply a semantic key action, honoring the active overlay (approval, picker). */
|
|
478
479
|
const applyAction = (action) => {
|
|
479
480
|
if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
|
|
@@ -493,12 +494,12 @@ export function TerminalInterface({ engine }) {
|
|
|
493
494
|
if (selected) {
|
|
494
495
|
void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
|
|
495
496
|
if (snapshot == null) {
|
|
496
|
-
|
|
497
|
+
pushLine({ role: 'error', text: `snapshot ${selected.name} is unreadable` });
|
|
497
498
|
return;
|
|
498
499
|
}
|
|
499
|
-
|
|
500
|
+
setStaticKey((k) => k + 1);
|
|
501
|
+
setLines([...snapshot.messages.map(lineFromMessage), { role: 'tool', toolName: 'sessions', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)` }]);
|
|
500
502
|
setTaskQueue(snapshot.taskQueue);
|
|
501
|
-
setLines((current) => [...current, { role: 'tool', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)`, toolName: 'sessions' }]);
|
|
502
503
|
});
|
|
503
504
|
}
|
|
504
505
|
setSessionsOpen(false);
|
|
@@ -509,7 +510,7 @@ export function TerminalInterface({ engine }) {
|
|
|
509
510
|
if (selected) {
|
|
510
511
|
void engine.selectModel(selected.id);
|
|
511
512
|
setPickerOpen(false);
|
|
512
|
-
|
|
513
|
+
pushTool(`model → ${selected.id} (saved as default)`, 'model');
|
|
513
514
|
}
|
|
514
515
|
return;
|
|
515
516
|
}
|
|
@@ -517,18 +518,7 @@ export function TerminalInterface({ engine }) {
|
|
|
517
518
|
const raw = edit.value.trim();
|
|
518
519
|
const attachMatch = /^\/attach\s+(.+)$/.exec(raw);
|
|
519
520
|
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
|
-
]);
|
|
521
|
+
HELP.forEach((text) => pushTool(text, 'help'));
|
|
532
522
|
setEdit({ value: '', cursor: 0 });
|
|
533
523
|
historyIdxRef.current = -1;
|
|
534
524
|
return;
|
|
@@ -536,36 +526,23 @@ export function TerminalInterface({ engine }) {
|
|
|
536
526
|
const findMatch = /^\/find\s+(.+)$/.exec(raw);
|
|
537
527
|
if (findMatch) {
|
|
538
528
|
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]}` }]);
|
|
529
|
+
const hits = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
530
|
+
if (hits.length === 0)
|
|
531
|
+
pushTool(`no match for "${findMatch[1]}"`, 'find');
|
|
532
|
+
else {
|
|
533
|
+
pushTool(`find "${findMatch[1]}" · ${hits.length} match${hits.length === 1 ? '' : 'es'}`, 'find');
|
|
534
|
+
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'));
|
|
535
|
+
}
|
|
558
536
|
setEdit({ value: '', cursor: 0 });
|
|
559
537
|
historyIdxRef.current = -1;
|
|
560
538
|
return;
|
|
561
539
|
}
|
|
562
540
|
if (raw === '/chapters' || raw === '/chapter') {
|
|
563
541
|
const list = chapters();
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
]);
|
|
542
|
+
if (list.length === 0)
|
|
543
|
+
pushTool('no chapters yet — each prompt starts one', 'chapters');
|
|
544
|
+
else
|
|
545
|
+
list.forEach((chapter, order) => pushTool(`${order + 1}. ${chapter.title}`, 'chapters'));
|
|
569
546
|
setEdit({ value: '', cursor: 0 });
|
|
570
547
|
historyIdxRef.current = -1;
|
|
571
548
|
return;
|
|
@@ -575,9 +552,8 @@ export function TerminalInterface({ engine }) {
|
|
|
575
552
|
setSessionsList(sessions);
|
|
576
553
|
setSessionsIndex(0);
|
|
577
554
|
setSessionsOpen(sessions.length > 0);
|
|
578
|
-
if (sessions.length === 0)
|
|
579
|
-
|
|
580
|
-
}
|
|
555
|
+
if (sessions.length === 0)
|
|
556
|
+
pushTool('no saved sessions — use /save <name> to snapshot this one', 'sessions');
|
|
581
557
|
});
|
|
582
558
|
setEdit({ value: '', cursor: 0 });
|
|
583
559
|
return;
|
|
@@ -587,35 +563,30 @@ export function TerminalInterface({ engine }) {
|
|
|
587
563
|
const name = saveMatch[1];
|
|
588
564
|
void saveSnapshot(engine.state.workspace.root, name, {
|
|
589
565
|
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
|
-
});
|
|
566
|
+
}).then(() => pushTool(`session saved: ${name}`, 'sessions'))
|
|
567
|
+
.catch((reason) => pushLine({ role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }));
|
|
595
568
|
setEdit({ value: '', cursor: 0 });
|
|
596
569
|
return;
|
|
597
570
|
}
|
|
598
571
|
const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
|
|
599
572
|
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
|
-
});
|
|
573
|
+
void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => pushTool(`session deleted: ${delMatch[1]}`, 'sessions'));
|
|
603
574
|
setEdit({ value: '', cursor: 0 });
|
|
604
575
|
return;
|
|
605
576
|
}
|
|
606
577
|
if (raw === '/clear') {
|
|
607
578
|
historyIdxRef.current = -1;
|
|
608
|
-
followTranscriptRef.current = true;
|
|
609
|
-
setScrollOffset(0);
|
|
610
579
|
setTaskQueue([]);
|
|
611
580
|
setError(undefined);
|
|
612
581
|
setCurrentTool(undefined);
|
|
613
582
|
setToolCards([]);
|
|
614
583
|
setExpandedTool(undefined);
|
|
584
|
+
setAgents([]);
|
|
615
585
|
runStartedAt.current = null;
|
|
616
586
|
setLiveThink('');
|
|
617
587
|
setLiveAnswer('');
|
|
618
588
|
syncPromptHistory([]);
|
|
589
|
+
setStaticKey((k) => k + 1);
|
|
619
590
|
setLines([]);
|
|
620
591
|
void engine.clearHistory().catch(() => { });
|
|
621
592
|
setEdit({ value: '', cursor: 0 });
|
|
@@ -625,8 +596,6 @@ export function TerminalInterface({ engine }) {
|
|
|
625
596
|
const attachSpec = attachMatch ? attachMatch[1] : undefined;
|
|
626
597
|
if (!prompt && !attachSpec)
|
|
627
598
|
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
599
|
if (busy) {
|
|
631
600
|
queuedRef.current = { prompt, attachSpec };
|
|
632
601
|
setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
|
|
@@ -652,7 +621,6 @@ export function TerminalInterface({ engine }) {
|
|
|
652
621
|
}
|
|
653
622
|
return;
|
|
654
623
|
case 'ctrlC':
|
|
655
|
-
// While a run is in flight, Ctrl+C cancels that run; when idle it quits.
|
|
656
624
|
if (busy) {
|
|
657
625
|
engine.cancel();
|
|
658
626
|
return;
|
|
@@ -725,7 +693,6 @@ export function TerminalInterface({ engine }) {
|
|
|
725
693
|
return;
|
|
726
694
|
}
|
|
727
695
|
};
|
|
728
|
-
// Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
|
|
729
696
|
useInput((value, key) => {
|
|
730
697
|
if (approval) {
|
|
731
698
|
if (value === 'y' || value === 'Y' || key.return) {
|
|
@@ -801,14 +768,6 @@ export function TerminalInterface({ engine }) {
|
|
|
801
768
|
}
|
|
802
769
|
return;
|
|
803
770
|
}
|
|
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
771
|
if (key.ctrl && value.toLowerCase() === 'l') {
|
|
813
772
|
setLayoutDebug((current) => !current);
|
|
814
773
|
return;
|
|
@@ -817,7 +776,6 @@ export function TerminalInterface({ engine }) {
|
|
|
817
776
|
yankLastBlock();
|
|
818
777
|
return;
|
|
819
778
|
}
|
|
820
|
-
// Ctrl+T toggles the most recent tool card between collapsed and expanded.
|
|
821
779
|
if (key.ctrl && value.toLowerCase() === 't') {
|
|
822
780
|
const latest = toolCards[toolCards.length - 1];
|
|
823
781
|
setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
|
|
@@ -844,7 +802,7 @@ export function TerminalInterface({ engine }) {
|
|
|
844
802
|
return;
|
|
845
803
|
}
|
|
846
804
|
if (!key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow)
|
|
847
|
-
historyIdxRef.current = -1;
|
|
805
|
+
historyIdxRef.current = -1;
|
|
848
806
|
if (key.upArrow) {
|
|
849
807
|
applyAction({ kind: 'up' });
|
|
850
808
|
return;
|
|
@@ -861,7 +819,6 @@ export function TerminalInterface({ engine }) {
|
|
|
861
819
|
applyAction({ kind: 'right' });
|
|
862
820
|
return;
|
|
863
821
|
}
|
|
864
|
-
// 0x7f is Backspace on Windows ConPTY and the kitty-encoded keys are owned by the raw stdin listener.
|
|
865
822
|
if (value.length > 1 && !isEncodedKey(value)) {
|
|
866
823
|
setEdit((current) => insertAt(current.value, current.cursor, normalizePaste(value)));
|
|
867
824
|
return;
|
|
@@ -869,7 +826,6 @@ export function TerminalInterface({ engine }) {
|
|
|
869
826
|
if (!key.ctrl && !key.meta && value && !isEncodedKey(value))
|
|
870
827
|
setEdit((current) => insertAt(current.value, current.cursor, value));
|
|
871
828
|
});
|
|
872
|
-
// Raw stdin: disambiguate Windows Backspace (0x7f), the real Delete key, and kitty-protocol keys.
|
|
873
829
|
useEffect(() => {
|
|
874
830
|
if (!stdin)
|
|
875
831
|
return;
|
|
@@ -881,151 +837,12 @@ export function TerminalInterface({ engine }) {
|
|
|
881
837
|
stdin.on('data', onData);
|
|
882
838
|
return () => { stdin.off('data', onData); };
|
|
883
839
|
}, [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
840
|
useEffect(() => {
|
|
1023
841
|
if (!busy)
|
|
1024
842
|
return;
|
|
1025
843
|
const id = setInterval(() => setNow(Date.now()), 250);
|
|
1026
844
|
return () => clearInterval(id);
|
|
1027
845
|
}, [busy]);
|
|
1028
|
-
// Drain a prompt that was queued while the previous run was in flight.
|
|
1029
846
|
useEffect(() => {
|
|
1030
847
|
if (busy)
|
|
1031
848
|
return;
|
|
@@ -1036,31 +853,52 @@ export function TerminalInterface({ engine }) {
|
|
|
1036
853
|
setQueued(undefined);
|
|
1037
854
|
startRun(pending.prompt, pending.attachSpec);
|
|
1038
855
|
}, [busy]);
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
856
|
+
const modeKey = kitty === true ? 'M' : 'E';
|
|
857
|
+
const modeAccent = MODE_ACCENT[mode];
|
|
858
|
+
const contentWidth = Math.max(20, width - 6);
|
|
859
|
+
const terminalRows = stdout.rows ?? 24;
|
|
860
|
+
const metrics = engine.client.snapshotMetrics();
|
|
861
|
+
const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
|
|
862
|
+
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
863
|
+
const contextLabel = metrics.compression.inputTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
|
|
864
|
+
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
|
|
865
|
+
const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
|
|
866
|
+
const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
|
|
867
|
+
const elapsed = busy
|
|
868
|
+
? (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`)
|
|
869
|
+
: '';
|
|
870
|
+
const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
|
|
871
|
+
const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
|
|
872
|
+
const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
|
|
873
|
+
// Cap the streaming region so a long think/answer can't crowd out the chrome;
|
|
874
|
+
// the complete text lands in <Static> once the event fires.
|
|
875
|
+
const liveBudget = Math.max(3, terminalRows - 14 - Math.min(6, taskQueue.length) - Math.min(4, agents.length) - editorLayout.lines.length);
|
|
876
|
+
const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
|
|
877
|
+
const liveAnswerView = liveAnswerLines.slice(-liveBudget);
|
|
878
|
+
const doneAgents = agents.filter((lane) => lane.status === 'done').length;
|
|
879
|
+
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
880
|
const expanded = expandedTool === card.id;
|
|
1047
|
-
const
|
|
881
|
+
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
882
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
883
|
+
? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 20))}`
|
|
884
|
+
: `${toolVerb(card.name)}${card.target ? ` ${clip(card.target, Math.max(10, contentWidth - 24))}` : ''}`;
|
|
885
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [dot, " ", head, expanded ? ' Ctrl+T collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
|
|
886
|
+
}), 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) => {
|
|
887
|
+
const color = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
888
|
+
const glyph = lane.status === 'done' ? '✓' : lane.status === 'error' ? '✕' : lane.status === 'working' ? '◍' : '○';
|
|
889
|
+
const detail = lane.note ?? (lane.label !== lane.id ? lane.label : lane.status);
|
|
890
|
+
return _jsxs(Text, { color: color, children: [glyph, " ", lane.id, " ", _jsx(Text, { dimColor: true, children: clip(detail, Math.max(12, contentWidth - 8)) })] }, lane.id);
|
|
891
|
+
})] }), 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) => {
|
|
892
|
+
const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '◈' : '○';
|
|
893
|
+
const color = item.status === 'done' ? PALETTE.success : item.status === 'active' ? PALETTE.accent : undefined;
|
|
894
|
+
return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
|
|
895
|
+
})] }), 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) => {
|
|
896
|
+
const header = index === 0 || pickerItems[index - 1].group !== item.group
|
|
897
|
+
? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
|
|
898
|
+
: null;
|
|
899
|
+
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);
|
|
900
|
+
})] }), 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 === ''
|
|
901
|
+
? _jsxs(Text, { color: modeAccent, children: ["\u276F ", _jsx(Text, { dimColor: true, children: busy ? 'type to queue the next task' : 'describe the work and press enter' })] })
|
|
902
|
+
: 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
903
|
}
|
|
1066
904
|
//# sourceMappingURL=terminalInterface.js.map
|