omniharness-cli 0.1.37 → 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.
@@ -1,13 +1,26 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
- 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
+ import { listSessions, loadSnapshot, saveSnapshot, deleteSnapshot } from '../sessionList.js';
6
7
  import { deleteAt, deleteBefore, insertAt, layoutEditor, lineEndAt, lineStartAt, moveHorizontal, moveVerticalWrapped, normalizePaste } from './editor.js';
7
8
  import { renderMarkdown } from './markdown.js';
8
9
  import { looksLikeDiff, diffSegments } from './diff.js';
9
10
  import { palette } from './palette.js';
11
+ import { contextMeter, meterBar } from './modelWindows.js';
12
+ import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
10
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];
11
24
  function describeTarget(name, input) {
12
25
  if (input && typeof input === 'object') {
13
26
  const record = input;
@@ -20,6 +33,22 @@ function describeTarget(name, input) {
20
33
  }
21
34
  return '';
22
35
  }
36
+ /** Short verb shown on a tool card header, per tool type. */
37
+ function toolVerb(tool) {
38
+ switch (tool) {
39
+ case 'read_file': return 'read';
40
+ case 'write_file': return 'edit';
41
+ case 'run_command': return '$';
42
+ case 'git_diff': return 'diff';
43
+ case 'semantic_search': return 'search';
44
+ case 'index_workspace': return 'index';
45
+ case 'update_todo': return 'plan';
46
+ case 'write_memory': return 'memory';
47
+ case 'start_preview': return 'preview';
48
+ case 'route': return 'route';
49
+ default: return tool;
50
+ }
51
+ }
23
52
  function phaseFor(tool) {
24
53
  switch (tool) {
25
54
  case 'read_file': return 'reading files';
@@ -84,25 +113,66 @@ function labelFor(role, model, toolName, fallback) {
84
113
  switch (role) {
85
114
  case 'user': return 'you';
86
115
  case 'error': return 'error';
87
- case 'thinking': return 'think';
116
+ case 'thinking': return 'thinking';
88
117
  case 'tool': return toolName ? `tool · ${toolName}` : 'tool';
89
118
  default: return model ?? fallback ?? 'assistant';
90
119
  }
91
120
  }
92
- const PALETTE = palette();
93
121
  function colorFor(role, p = PALETTE) {
94
122
  switch (role) {
95
123
  case 'user': return p.info;
96
124
  case 'error': return p.error;
97
125
  case 'thinking': return p.warn;
98
126
  case 'tool': return p.muted;
99
- default: return p.success;
127
+ default: return p.accent;
100
128
  }
101
129
  }
102
130
  function SegmentText({ segments, role }) {
103
- const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? PALETTE.success : role === 'user' ? PALETTE.info : undefined;
131
+ const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? undefined : role === 'user' ? PALETTE.info : undefined;
104
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))) });
105
133
  }
134
+ /** Expanded body of a tool card, rendered per tool type. */
135
+ function renderToolBody(card, width, p) {
136
+ const trail = card.trail;
137
+ if (!trail)
138
+ return card.summary ? _jsx(Text, { dimColor: true, children: card.summary.slice(0, width) }) : _jsx(Text, { dimColor: true, children: "(no output)" });
139
+ if (looksLikeDiff(trail)) {
140
+ return _jsx(_Fragment, { children: diffSegments(trail, width).slice(0, 14).map((segments, index) => _jsx(SegmentText, { segments: segments, role: "tool" }, index)) });
141
+ }
142
+ const rows = trail.split('\n').slice(0, 14);
143
+ if (card.name === 'write_file') {
144
+ return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { color: p.success, children: line.slice(0, width) }, index)) });
145
+ }
146
+ if (card.name === 'run_command') {
147
+ return _jsx(_Fragment, { children: rows.map((line, index) => {
148
+ const exit = /^exit (\d+)/.exec(line);
149
+ const color = exit ? (exit[1] === '0' ? p.success : p.error) : undefined;
150
+ return _jsx(Text, { color: color, dimColor: color === undefined, children: line.slice(0, width) }, index);
151
+ }) });
152
+ }
153
+ return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { dimColor: true, children: line.slice(0, width) }, index)) });
154
+ }
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);
162
+ const label = line.role === 'assistant' && line.provider
163
+ ? `${baseLabel} · via ${line.provider}${line.fallback ? ' (failover)' : ''}`
164
+ : baseLabel;
165
+ const asMarkdown = line.role !== 'tool' && line.role !== 'error';
166
+ const rows = asMarkdown
167
+ ? renderMarkdown(line.text, width)
168
+ : wrap(line.text, width).map((text) => [{ text }]);
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" }) })] });
175
+ }
106
176
  export function TerminalInterface({ engine }) {
107
177
  const { exit } = useApp();
108
178
  const { stdout } = useStdout();
@@ -110,13 +180,10 @@ export function TerminalInterface({ engine }) {
110
180
  const [width, setWidth] = useState(() => widthOf(stdout));
111
181
  const [edit, setEdit] = useState({ value: '', cursor: 0 });
112
182
  const inputWidth = Math.max(16, width - 12);
113
- // Replay a resumed transcript (hydrated by the engine) into the chat area.
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.
114
185
  const [lines, setLines] = useState(() => engine.state.messages.map(lineFromMessage));
115
- const [scrollOffset, setScrollOffset] = useState(0);
116
- const followTranscriptRef = useRef(true);
117
- const previousRowCountRef = useRef(0);
118
- const maxScrollRef = useRef(0);
119
- const pageSizeRef = useRef(8);
186
+ const [staticKey, setStaticKey] = useState(0); // bumped on /clear to reset <Static>
120
187
  const promptHistoryRef = useRef([]);
121
188
  const historyIdxRef = useRef(-1);
122
189
  const syncPromptHistory = (next) => { promptHistoryRef.current = next; };
@@ -132,19 +199,24 @@ export function TerminalInterface({ engine }) {
132
199
  const [liveThink, setLiveThink] = useState('');
133
200
  const [liveAnswer, setLiveAnswer] = useState('');
134
201
  const [kitty, setKitty] = useState(null);
202
+ const [sessionsList, setSessionsList] = useState([]);
203
+ const [sessionsOpen, setSessionsOpen] = useState(false);
204
+ const [sessionsIndex, setSessionsIndex] = useState(0);
135
205
  const [taskQueue, setTaskQueue] = useState(engine.state.taskQueue);
136
206
  const [currentTool, setCurrentTool] = useState();
137
- // Tool activity rendered as collapsible cards: each completed/ongoing tool
138
- // call carries its target, status, and optional summary/diff trail.
139
207
  const [toolCards, setToolCards] = useState([]);
140
208
  const [expandedTool, setExpandedTool] = useState();
141
- // Run timing: monotonic start time plus a ticking elapsed counter while busy.
209
+ const [agents, setAgents] = useState([]);
142
210
  const runStartedAt = useRef(null);
143
211
  const [now, setNow] = useState(() => Date.now());
212
+ const queuedRef = useRef(null);
213
+ const [queued, setQueued] = useState();
214
+ const [layoutDebug, setLayoutDebug] = useState(false);
215
+ const syncRestoreRef = useRef(null);
216
+ const pushLine = (line) => setLines((current) => [...current, line]);
217
+ const pushTool = (text, toolName = 'system') => pushLine({ role: 'tool', text, toolName });
144
218
  useEffect(() => {
145
219
  let alive = true;
146
- // Seed history from disk, but only if the user hasn't already submitted a
147
- // prompt this session (handles the async load racing a submit).
148
220
  void loadPromptHistory().then((history) => {
149
221
  if (alive && promptHistoryRef.current.length === 0)
150
222
  syncPromptHistory(history);
@@ -156,18 +228,23 @@ export function TerminalInterface({ engine }) {
156
228
  stdout.on('resize', onResize);
157
229
  stdout.write(KITTY_PUSH);
158
230
  let kittyTimer;
159
- // Terminals with kitty support answer the query (ESC[? flags u); others ignore it.
160
- const onKitty = (chunk) => {
161
- if (!isKittyQueryResponse(chunk.toString()))
231
+ // One probe pass: kitty keyboard protocol + synchronized output (DECSET 2026).
232
+ const onProbe = (chunk) => {
233
+ const text = chunk.toString();
234
+ if (isSyncOutputReply(text) && syncRestoreRef.current === null) {
235
+ syncRestoreRef.current = wrapSynchronizedOutput(stdout);
236
+ }
237
+ if (!isKittyQueryResponse(text))
162
238
  return;
163
239
  if (kittyTimer)
164
240
  clearTimeout(kittyTimer);
165
- stdin?.off('data', onKitty);
241
+ stdin?.off('data', onProbe);
166
242
  setKitty(true);
167
243
  };
168
244
  if (stdin) {
169
- stdin.on('data', onKitty);
170
- kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKitty); }, 300);
245
+ stdin.on('data', onProbe);
246
+ kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onProbe); }, 300);
247
+ stdout.write(SYNC_QUERY);
171
248
  stdout.write(KITTY_QUERY);
172
249
  }
173
250
  else {
@@ -183,16 +260,32 @@ export function TerminalInterface({ engine }) {
183
260
  break;
184
261
  case 'thinking':
185
262
  if (event.text)
186
- setLines((current) => [...current, { role: 'thinking', text: event.text }]);
263
+ pushLine({ role: 'thinking', text: event.text });
187
264
  setLiveThink('');
188
265
  break;
189
266
  case 'text':
190
- setLines((current) => [...current, {
191
- role: 'assistant', text: event.content, model: event.model,
192
- saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
193
- }]);
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
+ });
194
272
  setLiveAnswer('');
195
273
  break;
274
+ case 'route':
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
+ });
288
+ break;
196
289
  case 'tool_start':
197
290
  setToolCards((current) => [...current, {
198
291
  id: `tc-${Date.now().toString(36)}-${current.length}`, name: event.tool,
@@ -206,17 +299,19 @@ export function TerminalInterface({ engine }) {
206
299
  case 'tool_result':
207
300
  setToolCards((current) => current.length === 0
208
301
  ? current
209
- : current.map((card, index) => index === current.length - 1 ? { ...card, status: 'done', summary: event.summary } : card));
302
+ : current.map((card, index) => index === current.length - 1
303
+ ? { ...card, status: 'done', summary: event.summary, trail: card.trail ?? event.detail }
304
+ : card));
210
305
  setCurrentTool(undefined);
211
306
  break;
212
307
  case 'todos':
213
308
  setTaskQueue(event.todos);
214
309
  break;
215
310
  case 'preview':
216
- setLines((current) => [...current, { role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url }]);
311
+ pushLine({ role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url });
217
312
  break;
218
313
  case 'attach':
219
- setLines((current) => [...current, { role: 'tool', text: `attached: ${event.name} (${event.kind}, ${event.size} bytes)`, toolName: 'attach' }]);
314
+ pushLine({ role: 'tool', text: `attached: ${event.name} (${event.kind}, ${event.size} bytes)`, toolName: 'attach' });
220
315
  break;
221
316
  }
222
317
  });
@@ -229,14 +324,16 @@ export function TerminalInterface({ engine }) {
229
324
  return () => {
230
325
  if (kittyTimer)
231
326
  clearTimeout(kittyTimer);
232
- stdin?.off('data', onKitty);
327
+ stdin?.off('data', onProbe);
233
328
  stdout.off('resize', onResize);
234
329
  const pendingApproval = approvalResolve.current;
235
330
  approvalResolve.current = null;
236
- pendingApproval?.(false);
331
+ pendingApproval?.({ approved: false });
237
332
  engine.stop();
238
333
  unsubscribe();
239
334
  process.off('exit', onUnload);
335
+ syncRestoreRef.current?.();
336
+ syncRestoreRef.current = null;
240
337
  stdout.write(KITTY_POP);
241
338
  };
242
339
  }, [engine, stdout, stdin]);
@@ -266,13 +363,83 @@ export function TerminalInterface({ engine }) {
266
363
  const next = MODE_SEQ[(MODE_SEQ.indexOf(mode) + 1) % MODE_SEQ.length];
267
364
  setMode(next);
268
365
  engine.state.mode = next;
269
- setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
366
+ pushTool(`mode → ${next}`, 'mode');
270
367
  };
271
- const approve = (ok) => {
368
+ const approve = (approved, trust) => {
272
369
  const resolve = approvalResolve.current;
273
370
  setApproval(null);
274
371
  approvalResolve.current = null;
275
- resolve?.(ok);
372
+ resolve?.({ approved, trust });
373
+ };
374
+ /** Copy the most recent assistant reply to the clipboard via OSC 52. */
375
+ const yankLastBlock = () => {
376
+ const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
377
+ if (!target)
378
+ return;
379
+ const seq = osc52Copy(target.text);
380
+ if (!seq) {
381
+ pushTool('clipboard: block too large to copy', 'clipboard');
382
+ return;
383
+ }
384
+ try {
385
+ stdout.write(seq);
386
+ pushTool(`copied ${target.text.length} chars to clipboard`, 'clipboard');
387
+ }
388
+ catch { /* clipboard write is best-effort */ }
389
+ };
390
+ /** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
391
+ const startRun = (prompt, attachSpec) => {
392
+ setEdit({ value: '', cursor: 0 });
393
+ setBusy(true);
394
+ setError(undefined);
395
+ setToolCards([]);
396
+ setExpandedTool(undefined);
397
+ setAgents([]);
398
+ if (runStartedAt.current === null)
399
+ runStartedAt.current = Date.now();
400
+ setNow(Date.now());
401
+ historyIdxRef.current = -1;
402
+ if (prompt) {
403
+ syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
404
+ void appendPromptHistory(prompt).catch(() => { });
405
+ }
406
+ if (!attachSpec)
407
+ pushLine({ role: 'user', text: prompt });
408
+ void (async () => {
409
+ try {
410
+ if (attachSpec)
411
+ await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
412
+ await engine.run(prompt);
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
+ }
419
+ }
420
+ catch (reason) {
421
+ const message = reason instanceof Error ? reason.message : String(reason);
422
+ setError(message);
423
+ pushLine({ role: 'error', text: message });
424
+ }
425
+ finally {
426
+ const startedAt = runStartedAt.current;
427
+ setBusy(false);
428
+ setCurrentTool(undefined);
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));
431
+ runStartedAt.current = null;
432
+ setLiveThink('');
433
+ setLiveAnswer('');
434
+ if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
435
+ try {
436
+ stdout.write(osc9Notify('OmniHarness — run finished'));
437
+ stdout.write(BEL);
438
+ }
439
+ catch { /* nudge is best-effort */ }
440
+ }
441
+ }
442
+ })();
276
443
  };
277
444
  /** ↑ walks older prompts, ↓ walks newer; ↓ past the newest clears the input. */
278
445
  const navigateHistory = (older) => {
@@ -286,23 +453,29 @@ export function TerminalInterface({ engine }) {
286
453
  historyIdxRef.current = next;
287
454
  setEdit(next < 0 ? { value: '', cursor: 0 } : { value: history[next] ?? '', cursor: (history[next] ?? '').length });
288
455
  };
289
- /** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
290
456
  const browsingHistory = () => historyIdxRef.current >= 0 || edit.value === '';
291
- /** Scroll by rendered transcript rows; positive values move toward older output. */
292
- const scrollTranscript = (delta) => {
293
- if (delta > 0 && maxScrollRef.current > 0)
294
- followTranscriptRef.current = false;
295
- setScrollOffset((current) => {
296
- const next = clamp(current + delta, 0, maxScrollRef.current);
297
- if (next === 0 && delta < 0)
298
- followTranscriptRef.current = true;
299
- return next;
300
- });
301
- };
457
+ /** Chapters: one per user turn, titled by the prompt's first line. */
458
+ const chapters = () => lines.flatMap((line, index) => line.role === 'user'
459
+ ? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
460
+ : []);
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
+ ];
302
473
  /** Apply a semantic key action, honoring the active overlay (approval, picker). */
303
474
  const applyAction = (action) => {
304
475
  if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
305
476
  return;
477
+ if (sessionsOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
478
+ return;
306
479
  if (pickerOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
307
480
  return;
308
481
  switch (action.kind) {
@@ -311,12 +484,28 @@ export function TerminalInterface({ engine }) {
311
484
  approve(true);
312
485
  return;
313
486
  }
487
+ if (sessionsOpen) {
488
+ const selected = sessionsList[sessionsIndex];
489
+ if (selected) {
490
+ void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
491
+ if (snapshot == null) {
492
+ pushLine({ role: 'error', text: `snapshot ${selected.name} is unreadable` });
493
+ return;
494
+ }
495
+ setStaticKey((k) => k + 1);
496
+ setLines([...snapshot.messages.map(lineFromMessage), { role: 'tool', toolName: 'sessions', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)` }]);
497
+ setTaskQueue(snapshot.taskQueue);
498
+ });
499
+ }
500
+ setSessionsOpen(false);
501
+ return;
502
+ }
314
503
  if (pickerOpen) {
315
504
  const selected = pickerItems[pickerIndex];
316
505
  if (selected) {
317
506
  void engine.selectModel(selected.id);
318
507
  setPickerOpen(false);
319
- setLines((current) => [...current, { role: 'assistant', text: `model selected: ${selected.id} (saved as default)` }]);
508
+ pushTool(`model ${selected.id} (saved as default)`, 'model');
320
509
  }
321
510
  return;
322
511
  }
@@ -324,75 +513,92 @@ export function TerminalInterface({ engine }) {
324
513
  const raw = edit.value.trim();
325
514
  const attachMatch = /^\/attach\s+(.+)$/.exec(raw);
326
515
  if (raw === '/help') {
327
- setLines((current) => [...current,
328
- { role: 'tool', text: '/help — show commands', toolName: 'commands' },
329
- { role: 'tool', text: '/clear — start a fresh conversation', toolName: 'commands' },
330
- { role: 'tool', text: '/attach <files> — attach files to the next message', toolName: 'commands' },
331
- { role: 'tool', text: 'keys: Ctrl+O models · Ctrl+E mode · Ctrl+C cancel · PgUp/PgDn scroll · ↑/↓ prompt history', toolName: 'commands' },
332
- ]);
516
+ HELP.forEach((text) => pushTool(text, 'help'));
517
+ setEdit({ value: '', cursor: 0 });
518
+ historyIdxRef.current = -1;
519
+ return;
520
+ }
521
+ const findMatch = /^\/find\s+(.+)$/.exec(raw);
522
+ if (findMatch) {
523
+ const needle = findMatch[1].toLowerCase();
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
+ }
531
+ setEdit({ value: '', cursor: 0 });
532
+ historyIdxRef.current = -1;
533
+ return;
534
+ }
535
+ if (raw === '/chapters' || raw === '/chapter') {
536
+ const list = chapters();
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'));
333
541
  setEdit({ value: '', cursor: 0 });
334
542
  historyIdxRef.current = -1;
335
543
  return;
336
544
  }
545
+ if (raw === '/sessions') {
546
+ void listSessions(engine.state.workspace.root).then((sessions) => {
547
+ setSessionsList(sessions);
548
+ setSessionsIndex(0);
549
+ setSessionsOpen(sessions.length > 0);
550
+ if (sessions.length === 0)
551
+ pushTool('no saved sessions — use /save <name> to snapshot this one', 'sessions');
552
+ });
553
+ setEdit({ value: '', cursor: 0 });
554
+ return;
555
+ }
556
+ const saveMatch = /^\/save\s+([\w.-]+)$/.exec(raw);
557
+ if (saveMatch) {
558
+ const name = saveMatch[1];
559
+ void saveSnapshot(engine.state.workspace.root, name, {
560
+ messages: [...engine.state.messages], taskQueue: [...engine.state.taskQueue], savedAt: new Date().toISOString(),
561
+ }).then(() => pushTool(`session saved: ${name}`, 'sessions'))
562
+ .catch((reason) => pushLine({ role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }));
563
+ setEdit({ value: '', cursor: 0 });
564
+ return;
565
+ }
566
+ const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
567
+ if (delMatch) {
568
+ void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => pushTool(`session deleted: ${delMatch[1]}`, 'sessions'));
569
+ setEdit({ value: '', cursor: 0 });
570
+ return;
571
+ }
337
572
  if (raw === '/clear') {
338
573
  historyIdxRef.current = -1;
339
- followTranscriptRef.current = true;
340
- setScrollOffset(0);
341
574
  setTaskQueue([]);
342
575
  setError(undefined);
343
576
  setCurrentTool(undefined);
344
577
  setToolCards([]);
345
578
  setExpandedTool(undefined);
579
+ setAgents([]);
346
580
  runStartedAt.current = null;
347
581
  setLiveThink('');
348
582
  setLiveAnswer('');
349
583
  syncPromptHistory([]);
584
+ setStaticKey((k) => k + 1);
350
585
  setLines([]);
351
586
  void engine.clearHistory().catch(() => { });
352
587
  setEdit({ value: '', cursor: 0 });
353
588
  return;
354
589
  }
355
590
  const prompt = attachMatch ? '' : raw;
356
- if (busy || (!prompt && !attachMatch))
591
+ const attachSpec = attachMatch ? attachMatch[1] : undefined;
592
+ if (!prompt && !attachSpec)
593
+ return;
594
+ if (busy) {
595
+ queuedRef.current = { prompt, attachSpec };
596
+ setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
597
+ setEdit({ value: '', cursor: 0 });
598
+ historyIdxRef.current = -1;
357
599
  return;
358
- followTranscriptRef.current = true;
359
- setScrollOffset(0);
360
- setEdit({ value: '', cursor: 0 });
361
- setBusy(true);
362
- setError(undefined);
363
- setToolCards([]);
364
- setExpandedTool(undefined);
365
- if (runStartedAt.current === null)
366
- runStartedAt.current = Date.now();
367
- setNow(Date.now());
368
- historyIdxRef.current = -1;
369
- if (prompt) {
370
- syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
371
- void appendPromptHistory(prompt).catch(() => { });
372
600
  }
373
- if (!attachMatch)
374
- setLines((current) => [...current, { role: 'user', text: prompt }]);
375
- void (async () => {
376
- try {
377
- if (attachMatch)
378
- await engine.attach(attachMatch[1].split(/\s+/).filter(Boolean));
379
- await engine.run(prompt);
380
- /* answer is streamed live via text_delta / text events */
381
- }
382
- catch (reason) {
383
- const message = reason instanceof Error ? reason.message : String(reason);
384
- setError(message);
385
- setLines((current) => [...current, { role: 'error', text: message }]);
386
- }
387
- finally {
388
- setBusy(false);
389
- setCurrentTool(undefined);
390
- setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
391
- runStartedAt.current = null;
392
- setLiveThink('');
393
- setLiveAnswer('');
394
- }
395
- })();
601
+ startRun(prompt, attachSpec);
396
602
  }
397
603
  return;
398
604
  case 'escape':
@@ -400,13 +606,16 @@ export function TerminalInterface({ engine }) {
400
606
  approve(false);
401
607
  return;
402
608
  }
609
+ if (sessionsOpen) {
610
+ setSessionsOpen(false);
611
+ return;
612
+ }
403
613
  if (pickerOpen) {
404
614
  setPickerOpen(false);
405
615
  return;
406
616
  }
407
617
  return;
408
618
  case 'ctrlC':
409
- // While a run is in flight, Ctrl+C cancels that run; when idle it quits.
410
619
  if (busy) {
411
620
  engine.cancel();
412
621
  return;
@@ -424,6 +633,10 @@ export function TerminalInterface({ engine }) {
424
633
  cycleMode();
425
634
  return;
426
635
  case 'up':
636
+ if (sessionsOpen) {
637
+ setSessionsIndex((current) => clamp(current - 1, 0, Math.max(0, sessionsList.length - 1)));
638
+ return;
639
+ }
427
640
  if (pickerOpen) {
428
641
  setPickerIndex((current) => clamp(current - 1, 0, Math.max(0, pickerItems.length - 1)));
429
642
  return;
@@ -435,6 +648,10 @@ export function TerminalInterface({ engine }) {
435
648
  setEdit((current) => ({ ...current, cursor: moveVerticalWrapped(current.value, current.cursor, -1, inputWidth) }));
436
649
  return;
437
650
  case 'down':
651
+ if (sessionsOpen) {
652
+ setSessionsIndex((current) => clamp(current + 1, 0, Math.max(0, sessionsList.length - 1)));
653
+ return;
654
+ }
438
655
  if (pickerOpen) {
439
656
  setPickerIndex((current) => clamp(current + 1, 0, Math.max(0, pickerItems.length - 1)));
440
657
  return;
@@ -471,13 +688,25 @@ export function TerminalInterface({ engine }) {
471
688
  return;
472
689
  }
473
690
  };
474
- // Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
475
691
  useInput((value, key) => {
476
692
  if (approval) {
477
- if (value === 'y' || value === 'Y' || key.return)
478
- applyAction({ kind: 'submit' });
479
- else if (value === 'n' || value === 'N' || key.escape)
480
- applyAction({ kind: 'escape' });
693
+ if (value === 'y' || value === 'Y' || key.return) {
694
+ approve(true);
695
+ return;
696
+ }
697
+ if (value === 'n' || value === 'N' || key.escape) {
698
+ approve(false);
699
+ return;
700
+ }
701
+ if (value === 't' || value === 'T') {
702
+ approve(true, approval.scopes[0]?.id);
703
+ return;
704
+ }
705
+ const digit = Number(value);
706
+ if (Number.isInteger(digit) && digit >= 1 && digit <= approval.scopes.length) {
707
+ approve(true, approval.scopes[digit - 1]?.id);
708
+ return;
709
+ }
481
710
  return;
482
711
  }
483
712
  if (key.ctrl && value === 'c') {
@@ -515,15 +744,33 @@ export function TerminalInterface({ engine }) {
515
744
  }
516
745
  return;
517
746
  }
518
- if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
519
- scrollTranscript(pageSizeRef.current);
747
+ if (sessionsOpen) {
748
+ if (key.escape) {
749
+ applyAction({ kind: 'escape' });
750
+ return;
751
+ }
752
+ if (key.upArrow) {
753
+ applyAction({ kind: 'up' });
754
+ return;
755
+ }
756
+ if (key.downArrow) {
757
+ applyAction({ kind: 'down' });
758
+ return;
759
+ }
760
+ if (key.return) {
761
+ applyAction({ kind: 'submit' });
762
+ return;
763
+ }
764
+ return;
765
+ }
766
+ if (key.ctrl && value.toLowerCase() === 'l') {
767
+ setLayoutDebug((current) => !current);
520
768
  return;
521
769
  }
522
- if (key.pageDown || (key.ctrl && value.toLowerCase() === 'd')) {
523
- scrollTranscript(-pageSizeRef.current);
770
+ if (key.ctrl && value.toLowerCase() === 'y') {
771
+ yankLastBlock();
524
772
  return;
525
773
  }
526
- // Ctrl+T toggles the most recent tool card between collapsed and expanded.
527
774
  if (key.ctrl && value.toLowerCase() === 't') {
528
775
  const latest = toolCards[toolCards.length - 1];
529
776
  setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
@@ -550,7 +797,7 @@ export function TerminalInterface({ engine }) {
550
797
  return;
551
798
  }
552
799
  if (!key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow)
553
- historyIdxRef.current = -1; // typing exits history recall
800
+ historyIdxRef.current = -1;
554
801
  if (key.upArrow) {
555
802
  applyAction({ kind: 'up' });
556
803
  return;
@@ -567,7 +814,6 @@ export function TerminalInterface({ engine }) {
567
814
  applyAction({ kind: 'right' });
568
815
  return;
569
816
  }
570
- // 0x7f is Backspace on Windows ConPTY and the kitty-encoded keys are owned by the raw stdin listener.
571
817
  if (value.length > 1 && !isEncodedKey(value)) {
572
818
  setEdit((current) => insertAt(current.value, current.cursor, normalizePaste(value)));
573
819
  return;
@@ -575,7 +821,6 @@ export function TerminalInterface({ engine }) {
575
821
  if (!key.ctrl && !key.meta && value && !isEncodedKey(value))
576
822
  setEdit((current) => insertAt(current.value, current.cursor, value));
577
823
  });
578
- // Raw stdin: disambiguate Windows Backspace (0x7f), the real Delete key, and kitty-protocol keys.
579
824
  useEffect(() => {
580
825
  if (!stdin)
581
826
  return;
@@ -587,161 +832,67 @@ export function TerminalInterface({ engine }) {
587
832
  stdin.on('data', onData);
588
833
  return () => { stdin.off('data', onData); };
589
834
  }, [stdin, applyAction]);
590
- // Legacy Ctrl+M is the CR byte — identical to Enter — so mode cycling needs a
591
- // distinguishable key (Ctrl+E works everywhere); kitty terminals also keep Ctrl+M.
835
+ useEffect(() => {
836
+ if (!busy)
837
+ return;
838
+ const id = setInterval(() => setNow(Date.now()), 250);
839
+ return () => clearInterval(id);
840
+ }, [busy]);
841
+ useEffect(() => {
842
+ if (busy)
843
+ return;
844
+ const pending = queuedRef.current;
845
+ if (!pending)
846
+ return;
847
+ queuedRef.current = null;
848
+ setQueued(undefined);
849
+ startRun(pending.prompt, pending.attachSpec);
850
+ }, [busy]);
592
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;
593
855
  const metrics = engine.client.snapshotMetrics();
594
- const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '—';
595
- const hud = [mode, engine.state.activeModel];
596
- if (metrics.fallback.activeProvider)
597
- hud.push(metrics.fallback.activeProvider);
598
- if (metrics.remainingQuota !== undefined)
599
- hud.push(`quota ${metrics.remainingQuota}`);
600
- if (metrics.fallback.attempts > 0)
601
- hud.push(`fb ${metrics.fallback.attempts}`);
602
- hud.push(`saved ${compression}`);
603
- // Live statusline fields: model, mode, running phase, tokens/context, elapsed.
604
- const runningTool = toolCards[toolCards.length - 1];
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()}` : '';
605
860
  const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
606
- const elapsedMs = busy && runStartedAt.current !== null
607
- ? Math.max(0, now - runStartedAt.current)
608
- : 0;
861
+ const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
609
862
  const elapsed = busy
610
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`)
611
864
  : '';
612
- const contextLabel = metrics.compression.inputTokens > 0
613
- ? `${(metrics.compression.inputTokens / 1000).toFixed(1)}k in`
614
- : '';
615
- const contentWidth = Math.max(20, width - 8);
616
- const terminalRows = stdout.rows ?? 24;
617
865
  const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
618
866
  const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
619
867
  const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
620
- const liveRows = useMemo(() => {
621
- const rows = [];
622
- if (liveThink !== '') {
623
- rows.push({ key: 'live-think-label', kind: 'label', role: 'thinking' });
624
- liveThinkLines.forEach((segments, index) => rows.push({ key: `live-think-${index}`, kind: 'content', role: 'thinking', segments }));
625
- }
626
- if (liveAnswer !== '') {
627
- rows.push({ key: 'live-answer-label', kind: 'label', role: 'assistant' });
628
- liveAnswerLines.forEach((segments, index) => rows.push({ key: `live-answer-${index}`, kind: 'content', role: 'assistant', segments }));
629
- }
630
- return rows;
631
- }, [liveThink, liveAnswer, liveThinkLines, liveAnswerLines]);
632
- // Cache markdown/wrapping per Line object. Streaming updates create new live
633
- // text, but completed historical lines retain their rendered rows.
634
- const rowCacheRef = useRef(null);
635
- const allRows = useMemo(() => {
636
- const fallback = engine.state.activeModel;
637
- let cache = rowCacheRef.current;
638
- if (cache === null || cache.width !== contentWidth || cache.fallback !== fallback) {
639
- cache = { width: contentWidth, fallback, rows: new WeakMap() };
640
- rowCacheRef.current = cache;
641
- }
642
- const out = [];
643
- lines.forEach((line, lineIndex) => {
644
- let rendered = cache.rows.get(line);
645
- if (rendered === undefined) {
646
- const label = labelFor(line.role, line.model, line.toolName, fallback);
647
- const markdown = line.role !== 'tool' && line.role !== 'error';
648
- const wrapped = markdown
649
- ? renderMarkdown(line.text, contentWidth)
650
- : wrap(line.text, contentWidth).map((text) => [{ text }]);
651
- const next = [];
652
- wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
653
- if (line.saved)
654
- next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
655
- rendered = next;
656
- cache.rows.set(line, rendered);
657
- }
658
- out.push(...rendered);
659
- });
660
- return out;
661
- }, [lines, contentWidth, engine.state.activeModel]);
662
- const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
663
- const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
664
- const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
665
- const approvalRows = approval ? 8 : 0;
666
- const inputRows = 3 + editorLayout.lines.length;
667
- const footerRows = 3;
668
- const chromeRows = 3 + footerRows + inputRows + (kitty !== null ? 1 : 0) + planRows + pickerRows + approvalRows;
669
- const messageHeight = Math.max(3, terminalRows - chromeRows);
670
- const toolRows = toolCards.length > 0 ? toolCards.length : 0; // one collapsed card per tool call
671
- const statusRows = (engine.state.preview ? 1 : 0) + (busy ? 1 : 0) + toolRows;
672
- const storedHeight = Math.max(0, messageHeight - Math.min(messageHeight, liveRows.length + statusRows));
673
- const liveHeight = Math.max(0, messageHeight - storedHeight - statusRows);
674
- const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
675
- const maxScroll = Math.max(0, allRows.length - storedHeight);
676
- maxScrollRef.current = maxScroll;
677
- pageSizeRef.current = Math.max(1, storedHeight - 2);
678
- const boundedScroll = clamp(scrollOffset, 0, maxScroll);
679
- const endRow = allRows.length - boundedScroll;
680
- const startRow = Math.max(0, endRow - storedHeight);
681
- const visibleRows = allRows.slice(startRow, endRow);
682
- const hiddenAbove = startRow;
683
- const hiddenBelow = boundedScroll;
684
- const scrollStatus = allRows.length === 0
685
- ? 'transcript empty'
686
- : storedHeight === 0
687
- ? `live output · ${allRows.length} transcript rows`
688
- : hiddenBelow === 0
689
- ? `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · following latest`
690
- : `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · ${hiddenAbove} older · ${hiddenBelow} newer`;
691
- useEffect(() => {
692
- const previous = previousRowCountRef.current;
693
- const added = allRows.length - previous;
694
- previousRowCountRef.current = allRows.length;
695
- if (previous === 0 || added <= 0)
696
- return;
697
- if (followTranscriptRef.current) {
698
- setScrollOffset(0);
699
- }
700
- else {
701
- setScrollOffset((current) => clamp(current + added, 0, maxScrollRef.current));
702
- }
703
- }, [allRows.length]);
704
- useEffect(() => {
705
- setScrollOffset((current) => clamp(current, 0, maxScroll));
706
- }, [maxScroll]);
707
- // Tick a clock while a run is in flight so the statusline can show elapsed time
708
- // without re-rendering on every event. runStartedAt is set when a run begins.
709
- useEffect(() => {
710
- if (!busy)
711
- return;
712
- const id = setInterval(() => setNow(Date.now()), 250);
713
- return () => clearInterval(id);
714
- }, [busy]);
715
- return _jsxs(Box, { flexDirection: "column", width: width, height: terminalRows, paddingX: 2, overflow: "hidden", children: [_jsxs(Box, { justifyContent: "space-between", paddingY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["OMNIHARNESS ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", mode, " mode"] })] }), _jsx(Text, { dimColor: true, children: "OMNIROUTE :20128" })] }), _jsxs(Box, { flexDirection: "column", height: messageHeight, overflow: "hidden", children: [lines.length === 0 && _jsxs(Box, { flexDirection: "column", marginTop: 2, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ready when you are." }), _jsxs(Text, { dimColor: true, children: ["Describe the work. OmniHarness routes it through your OmniRoute account. Ctrl+", modeKey, " cycles plan \u00B7 build \u00B7 research \u00B7 crazy."] })] }), visibleRows.map((row, index) => row.saved
716
- ? _jsx(Text, { dimColor: true, children: row.saved }, row.key)
717
- : row.first
718
- ? _jsxs(Box, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [_jsx(Text, { color: colorFor(row.role), bold: true, children: row.label }), _jsx(SegmentText, { segments: row.segments, role: row.role })] }, row.key)
719
- : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), visibleLiveRows.map((row) => row.kind === 'label'
720
- ? _jsx(Text, { color: colorFor(row.role), bold: true, children: row.role === 'thinking' ? 'think' : engine.state.activeModel }, row.key)
721
- : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), toolCards.slice(-6).map((card) => {
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) => {
722
875
  const expanded = expandedTool === card.id;
723
- const status = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: "\u2022 running" }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "\u2715 error" }) : _jsx(Text, { color: PALETTE.success, children: "\u2713 done" });
724
- const target = card.target ? ` · ${clip(card.target, Math.max(10, contentWidth - 40))}` : '';
725
- const badge = expanded ? '' : '▸';
726
- return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [badge, " ", card.name, target, " \u00B7 ", status, expanded ? ' · Ctrl+T to collapse' : ''] }), expanded && (card.trail
727
- ? looksLikeDiff(card.trail)
728
- ? diffSegments(card.trail, contentWidth).slice(0, 12).map((segments, index) => _jsx(SegmentText, { segments: segments, role: "tool" }, index))
729
- // A file-edit trail is the new content; show it as added (green) lines.
730
- : card.trail.split('\n').slice(0, 12).map((line, index) => _jsx(Text, { color: PALETTE.success, children: line }, index))
731
- : card.summary
732
- ? _jsx(Text, { dimColor: true, children: clip(card.summary, contentWidth) })
733
- : _jsx(Text, { dimColor: true, children: "(no output)" }))] }, card.id);
734
- }), engine.state.preview && _jsxs(Text, { color: "green", dimColor: true, children: ["preview live \u00B7 ", engine.state.preview.url] }), busy && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " working in ", mode, " mode on ", engine.state.activeModel, currentTool ? ` · now ${currentTool}` : ''] })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
735
- const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '▸' : '○';
736
- const color = item.status === 'done' ? 'green' : item.status === 'active' ? 'cyan' : undefined;
737
- return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
738
- })] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: "red", children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "No models returned by OmniRoute." }), pickerItems.map((item, index) => {
739
- const header = index === 0 || pickerItems[index - 1].group !== item.group
740
- ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
741
- : null;
742
- return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? 'cyan' : undefined, children: [index === pickerIndex ? ' ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' ✓' : ''] })] }, item.id);
743
- })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(typeof approval.input === 'string' ? approval.input : JSON.stringify(approval.input), contentWidth)] }), _jsx(Text, { dimColor: true, children: "y approve \u00B7 n deny" })] }), _jsx(Box, { borderStyle: "round", borderColor: error ? 'red' : 'cyan', paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
744
- ? _jsxs(Text, { color: "cyan", children: ["\u203A ", _jsx(Text, { dimColor: true, children: "type a task and press enter" })] })
745
- : editorLayout.lines.map((text, index) => _jsxs(Text, { color: "cyan", children: [index === 0 ? '› ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? PALETTE.info : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), phase, elapsed ? ` · ${elapsed}` : ''] }), _jsx(Text, { color: PALETTE.muted, children: [engine.state.activeModel, mode, contextLabel, scrollStatus].filter(Boolean).join(' · ') })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Shift+Enter/Ctrl+J new line \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+C cancel/quit \u00B7 /help \u00B7 Ctrl+T tool"] }), hud.length > 0 && _jsx(Text, { dimColor: true, children: hud.join(' · ') })] })] })] });
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" });
877
+ const head = card.name === 'run_command'
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] })] })] })] });
746
897
  }
747
898
  //# sourceMappingURL=terminalInterface.js.map