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.
@@ -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 'think';
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.success;
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' ? 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;
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
- /** Render one Line into transcript Rows (label row + content rows). */
149
- function renderLineToRows(line, lineIndex, width, fallback) {
150
- const baseLabel = labelFor(line.role, line.model, line.toolName, fallback);
151
- // Route ribbon: the provider a given answer actually came from is provenance —
152
- // it belongs on the answer's label, next to the model badge.
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 markdown = line.role !== 'tool' && line.role !== 'error';
157
- const wrapped = markdown
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 next = [];
161
- wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
162
- if (line.saved)
163
- next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
164
- return next;
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
- // 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.
174
185
  const [lines, setLines] = useState(() => engine.state.messages.map(lineFromMessage));
175
- const [scrollOffset, setScrollOffset] = useState(0);
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
- // Run timing: monotonic start time plus a ticking elapsed counter while busy.
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
- // Terminals with kitty support answer the query (ESC[? flags u); others ignore it.
231
- // The same probe pass asks about synchronized output (DECSET 2026): a positive
232
- // reply lets us bracket every frame so streaming never tears mid-repaint.
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', onKitty);
241
+ stdin?.off('data', onProbe);
243
242
  setKitty(true);
244
243
  };
245
244
  if (stdin) {
246
- stdin.on('data', onKitty);
247
- kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKitty); }, 300);
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
- setLines((current) => [...current, { role: 'thinking', text: event.text }]);
263
+ pushLine({ role: 'thinking', text: event.text });
265
264
  setLiveThink('');
266
265
  break;
267
266
  case 'text':
268
- setLines((current) => [...current, {
269
- role: 'assistant', text: event.content, model: event.model,
270
- provider: event.provider, fallback: event.fallback,
271
- saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
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
- setLines((current) => [...current, {
277
- role: 'tool', toolName: 'route',
278
- text: event.fallback
279
- ? `route · failover → ${event.provider ?? 'unknown'} (attempt ${event.attempts + 1})${event.reason ? ` · ${event.reason}` : ''}`
280
- : `route · ${event.provider ?? 'unknown'}`,
281
- }]);
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
- setLines((current) => [...current, { role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url }]);
313
+ pushLine({ role: 'tool', text: `preview: ${event.url}`, toolName: 'preview', url: event.url });
306
314
  break;
307
315
  case 'attach':
308
- setLines((current) => [...current, { role: 'tool', text: `attached: ${event.name} (${event.kind}, ${event.size} bytes)`, toolName: 'attach' }]);
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', onKitty);
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
- setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
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 (or last transcript line) to the clipboard via OSC 52. */
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
- setLines((current) => [...current, { role: 'tool', text: 'clipboard: block too large to copy', toolName: 'clipboard' }]);
383
+ pushTool('clipboard: block too large to copy', 'clipboard');
376
384
  return;
377
385
  }
378
386
  try {
379
387
  stdout.write(seq);
380
- setLines((current) => [...current, { role: 'tool', text: `copied ${target.text.length} chars to clipboard`, toolName: 'clipboard' }]);
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
- setLines((current) => [...current, { role: 'user', text: prompt }]);
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
- /* answer is streamed live via text_delta / text events */
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
- setLines((current) => [...current, { role: 'error', text: message }]);
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
- /** Scroll by rendered transcript rows; positive values move toward older output. */
467
- const scrollTranscript = (delta) => {
468
- if (delta > 0 && maxScrollRef.current > 0)
469
- followTranscriptRef.current = false;
470
- setScrollOffset((current) => {
471
- const next = clamp(current + delta, 0, maxScrollRef.current);
472
- if (next === 0 && delta < 0)
473
- followTranscriptRef.current = true;
474
- return next;
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
- setLines((current) => [...current, { role: 'error', text: `snapshot ${selected.name} is unreadable` }]);
497
+ pushLine({ role: 'error', text: `snapshot ${selected.name} is unreadable` });
497
498
  return;
498
499
  }
499
- setLines(snapshot.messages.map(lineFromMessage));
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
- setLines((current) => [...current, { role: 'assistant', text: `model selected: ${selected.id} (saved as default)` }]);
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
- setLines((current) => [...current,
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.map((line, index) => ({ line, index })).filter(({ line }) => line.text.toLowerCase().includes(needle));
540
- const last = hits[hits.length - 1];
541
- setLines((current) => [...current, {
542
- role: 'tool', toolName: 'find',
543
- text: last ? `find "${findMatch[1]}" · ${hits.length} match${hits.length === 1 ? '' : 'es'} · jumped to the most recent` : `no match for "${findMatch[1]}"`,
544
- }]);
545
- if (last)
546
- jumpToLine(last.index);
547
- setEdit({ value: '', cursor: 0 });
548
- historyIdxRef.current = -1;
549
- return;
550
- }
551
- const chapterJump = /^\/chapters?\s+(\d+)$/.exec(raw);
552
- if (chapterJump) {
553
- const list = chapters();
554
- const pick = list[Number(chapterJump[1]) - 1];
555
- if (pick)
556
- jumpToLine(pick.index);
557
- setLines((current) => [...current, { role: 'tool', toolName: 'chapters', text: pick ? `jumped to chapter ${chapterJump[1]}: ${pick.title}` : `no chapter ${chapterJump[1]}` }]);
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
- setLines((current) => [...current,
565
- ...(list.length === 0 ? [{ role: 'tool', toolName: 'chapters', text: 'no chapters yet — each prompt starts one' }]
566
- : list.map((chapter, order) => ({ role: 'tool', toolName: 'chapters', text: `${order + 1}. ${chapter.title}` }))),
567
- ...(list.length > 0 ? [{ role: 'tool', toolName: 'chapters', text: '/chapters <n> to jump' }] : []),
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
- setLines((current) => [...current, { role: 'tool', text: 'no saved sessions — use /save <name> to snapshot this one', toolName: 'sessions' }]);
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
- setLines((current) => [...current, { role: 'tool', text: `session saved: ${name}`, toolName: 'sessions' }]);
592
- }).catch((reason) => {
593
- setLines((current) => [...current, { role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }]);
594
- });
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; // typing exits history recall
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
- 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
1040
- ? _jsx(Text, { dimColor: true, children: row.saved }, row.key)
1041
- : row.first
1042
- ? _jsxs(Box, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [_jsx(Text, { color: colorFor(row.role), bold: true, children: row.label }), _jsx(SegmentText, { segments: row.segments, role: row.role })] }, row.key)
1043
- : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), visibleLiveRows.map((row) => row.kind === 'label'
1044
- ? _jsx(Text, { color: colorFor(row.role), bold: true, children: row.role === 'thinking' ? 'think' : engine.state.activeModel }, row.key)
1045
- : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), toolCards.slice(-6).map((card) => {
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 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" });
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 - 26))}`
1050
- : `${toolVerb(card.name)}${card.target ? ` · ${clip(card.target, Math.max(10, contentWidth - 30))}` : ''}`;
1051
- const badge = expanded ? '' : '';
1052
- return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [badge, " ", head, " \u00B7 ", status, expanded ? ' · Ctrl+T to collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
1053
- }), engine.state.preview && _jsxs(Text, { color: "green", dimColor: true, children: ["preview live \u00B7 ", engine.state.preview.url] }), busy && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " working in ", mode, " mode on ", engine.state.activeModel, currentTool ? ` · now ${currentTool}` : ''] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["\u23CE queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
1054
- const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '' : '○';
1055
- const color = item.status === 'done' ? 'green' : item.status === 'active' ? 'cyan' : undefined;
1056
- return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
1057
- })] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "Saved sessions" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u00B7 enter resume \u00B7 esc close" }), sessionsList.map((session, index) => (_jsxs(Text, { color: index === sessionsIndex ? 'blue' : undefined, children: [index === sessionsIndex ? '› ' : ' ', session.name, session.savedAt ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", session.savedAt] }) : null] }, session.name)))] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: "red", children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "No models returned by OmniRoute." }), pickerItems.map((item, index) => {
1058
- const header = index === 0 || pickerItems[index - 1].group !== item.group
1059
- ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
1060
- : null;
1061
- return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? 'cyan' : undefined, children: [index === pickerIndex ? ' ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' ✓' : ''] })] }, item.id);
1062
- })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(JSON.stringify(approval.input), contentWidth)] }), approval.scopes.map((scope, index) => _jsxs(Text, { dimColor: true, children: [" ", index + 1, " \u00B7 ", clip(scope.label, Math.max(12, contentWidth - 6))] }, scope.id)), _jsxs(Text, { dimColor: true, children: ["y allow once \u00B7 n deny \u00B7 t always allow \u00B7 1\u2013", approval.scopes.length, " pick a trust scope"] })] }), layoutDebug && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 2, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["chrome ", chromeRows, " \u00B7 input ", inputRows, " \u00B7 plan ", planRows, " \u00B7 overlay ", pickerRows + approvalRows, " \u00B7 caps ", kitty !== null ? 1 : 0] }), _jsxs(Text, { dimColor: true, children: ["message ", messageHeight, " = stored ", storedHeight, " + live ", liveHeight, " + status ", statusRows] }), _jsxs(Text, { dimColor: true, children: ["transcript rows ", allRows.length, " \u00B7 scroll ", boundedScroll, "/", maxScroll, " \u00B7 page ", pageSizeRef.current] })] }), _jsx(Box, { borderStyle: "round", borderColor: error ? 'red' : 'cyan', paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
1063
- ? _jsxs(Text, { color: "cyan", children: ["\u203A ", _jsx(Text, { dimColor: true, children: "type a task and press enter" })] })
1064
- : editorLayout.lines.map((text, index) => _jsxs(Text, { color: "cyan", children: [index === 0 ? '› ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? PALETTE.info : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), phase, elapsed ? ` · ${elapsed}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [engine.state.activeModel, " \u00B7 ", mode, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null, " \u00B7 ", scrollStatus] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Ctrl+J new line \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+T tool \u00B7 Ctrl+Y copy \u00B7 Ctrl+L layout \u00B7 Ctrl+C cancel/quit \u00B7 /help"] }), hud.length > 0 && _jsx(Text, { dimColor: true, children: hud.join(' · ') })] })] })] });
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