omniharness-cli 0.1.38 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,31 @@ 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 next = current.filter((lane) => lane.id !== event.id);
285
+ next.push({ id: event.id, label: event.label, status: event.status, note: event.note });
286
+ return next.sort((a, b) => a.id.localeCompare(b.id));
287
+ });
282
288
  break;
283
289
  case 'tool_start':
284
290
  setToolCards((current) => [...current, {
@@ -302,10 +308,10 @@ export function TerminalInterface({ engine }) {
302
308
  setTaskQueue(event.todos);
303
309
  break;
304
310
  case 'preview':
305
- 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 });
306
312
  break;
307
313
  case 'attach':
308
- 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' });
309
315
  break;
310
316
  }
311
317
  });
@@ -318,7 +324,7 @@ export function TerminalInterface({ engine }) {
318
324
  return () => {
319
325
  if (kittyTimer)
320
326
  clearTimeout(kittyTimer);
321
- stdin?.off('data', onKitty);
327
+ stdin?.off('data', onProbe);
322
328
  stdout.off('resize', onResize);
323
329
  const pendingApproval = approvalResolve.current;
324
330
  approvalResolve.current = null;
@@ -357,7 +363,7 @@ export function TerminalInterface({ engine }) {
357
363
  const next = MODE_SEQ[(MODE_SEQ.indexOf(mode) + 1) % MODE_SEQ.length];
358
364
  setMode(next);
359
365
  engine.state.mode = next;
360
- setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
366
+ pushTool(`mode → ${next}`, 'mode');
361
367
  };
362
368
  const approve = (approved, trust) => {
363
369
  const resolve = approvalResolve.current;
@@ -365,31 +371,30 @@ export function TerminalInterface({ engine }) {
365
371
  approvalResolve.current = null;
366
372
  resolve?.({ approved, trust });
367
373
  };
368
- /** Copy the most recent assistant reply (or last transcript line) to the clipboard via OSC 52. */
374
+ /** Copy the most recent assistant reply to the clipboard via OSC 52. */
369
375
  const yankLastBlock = () => {
370
376
  const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
371
377
  if (!target)
372
378
  return;
373
379
  const seq = osc52Copy(target.text);
374
380
  if (!seq) {
375
- setLines((current) => [...current, { role: 'tool', text: 'clipboard: block too large to copy', toolName: 'clipboard' }]);
381
+ pushTool('clipboard: block too large to copy', 'clipboard');
376
382
  return;
377
383
  }
378
384
  try {
379
385
  stdout.write(seq);
380
- setLines((current) => [...current, { role: 'tool', text: `copied ${target.text.length} chars to clipboard`, toolName: 'clipboard' }]);
386
+ pushTool(`copied ${target.text.length} chars to clipboard`, 'clipboard');
381
387
  }
382
388
  catch { /* clipboard write is best-effort */ }
383
389
  };
384
390
  /** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
385
391
  const startRun = (prompt, attachSpec) => {
386
- followTranscriptRef.current = true;
387
- setScrollOffset(0);
388
392
  setEdit({ value: '', cursor: 0 });
389
393
  setBusy(true);
390
394
  setError(undefined);
391
395
  setToolCards([]);
392
396
  setExpandedTool(undefined);
397
+ setAgents([]);
393
398
  if (runStartedAt.current === null)
394
399
  runStartedAt.current = Date.now();
395
400
  setNow(Date.now());
@@ -399,28 +404,33 @@ export function TerminalInterface({ engine }) {
399
404
  void appendPromptHistory(prompt).catch(() => { });
400
405
  }
401
406
  if (!attachSpec)
402
- setLines((current) => [...current, { role: 'user', text: prompt }]);
407
+ pushLine({ role: 'user', text: prompt });
403
408
  void (async () => {
404
409
  try {
405
410
  if (attachSpec)
406
411
  await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
407
412
  await engine.run(prompt);
408
- /* answer is streamed live via text_delta / text events */
413
+ // CRAZY mode: once a plan exists, fan the rest of it out across parallel workers.
414
+ if (engine.state.mode === 'crazy' && typeof engine.runSwarm === 'function') {
415
+ const pending = engine.state.taskQueue.filter((item) => item.status === 'pending').length;
416
+ if (pending >= 2)
417
+ await engine.runSwarm({ maxAgents: 3 });
418
+ }
409
419
  }
410
420
  catch (reason) {
411
421
  const message = reason instanceof Error ? reason.message : String(reason);
412
422
  setError(message);
413
- setLines((current) => [...current, { role: 'error', text: message }]);
423
+ pushLine({ role: 'error', text: message });
414
424
  }
415
425
  finally {
416
426
  const startedAt = runStartedAt.current;
417
427
  setBusy(false);
418
428
  setCurrentTool(undefined);
419
429
  setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
430
+ setAgents((current) => current.map((lane) => lane.status === 'working' || lane.status === 'spawned' ? { ...lane, status: 'done' } : lane));
420
431
  runStartedAt.current = null;
421
432
  setLiveThink('');
422
433
  setLiveAnswer('');
423
- // A long run probably pulled focus elsewhere: nudge with a bell + OSC 9 notification.
424
434
  if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
425
435
  try {
426
436
  stdout.write(osc9Notify('OmniHarness — run finished'));
@@ -443,37 +453,23 @@ export function TerminalInterface({ engine }) {
443
453
  historyIdxRef.current = next;
444
454
  setEdit(next < 0 ? { value: '', cursor: 0 } : { value: history[next] ?? '', cursor: (history[next] ?? '').length });
445
455
  };
446
- /** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
447
456
  const browsingHistory = () => historyIdxRef.current >= 0 || edit.value === '';
448
- // Kept current each render so jumpToLine (defined before the row layout is
449
- // computed) can resolve a transcript-line index to a scroll position.
450
- const allRowsRef = useRef([]);
451
- const storedHeightRef = useRef(0);
452
- /** Scroll so the first rendered row of transcript line `lineIndex` sits at the top. */
453
- const jumpToLine = (lineIndex) => {
454
- const rows = allRowsRef.current;
455
- const targetRow = rows.findIndex((row) => row.key.startsWith(`message-${lineIndex}-`));
456
- if (targetRow < 0)
457
- return;
458
- followTranscriptRef.current = false;
459
- const desired = rows.length - targetRow - storedHeightRef.current;
460
- setScrollOffset(clamp(desired, 0, maxScrollRef.current));
461
- };
462
457
  /** Chapters: one per user turn, titled by the prompt's first line. */
463
458
  const chapters = () => lines.flatMap((line, index) => line.role === 'user'
464
459
  ? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
465
460
  : []);
466
- /** 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
- };
461
+ const HELP = [
462
+ '/help show commands',
463
+ '/clear start a fresh conversation',
464
+ '/sessions list saved sessions (enter to resume)',
465
+ '/save <name> — snapshot the current session',
466
+ '/forget <name> delete a saved session',
467
+ '/attach <files> attach files to the next message',
468
+ '/find <text> — list transcript lines containing <text>',
469
+ '/chapters — list the turns in this session',
470
+ 'keys: Ctrl+O models · Ctrl+E mode · Ctrl+T tool card · Ctrl+Y copy reply · Ctrl+L layout · Ctrl+C cancel/quit · ↑/↓ history',
471
+ 'history scrolls in your terminal · a prompt typed mid-run is queued and sent when the run ends',
472
+ ];
477
473
  /** Apply a semantic key action, honoring the active overlay (approval, picker). */
478
474
  const applyAction = (action) => {
479
475
  if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
@@ -493,12 +489,12 @@ export function TerminalInterface({ engine }) {
493
489
  if (selected) {
494
490
  void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
495
491
  if (snapshot == null) {
496
- setLines((current) => [...current, { role: 'error', text: `snapshot ${selected.name} is unreadable` }]);
492
+ pushLine({ role: 'error', text: `snapshot ${selected.name} is unreadable` });
497
493
  return;
498
494
  }
499
- setLines(snapshot.messages.map(lineFromMessage));
495
+ setStaticKey((k) => k + 1);
496
+ setLines([...snapshot.messages.map(lineFromMessage), { role: 'tool', toolName: 'sessions', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)` }]);
500
497
  setTaskQueue(snapshot.taskQueue);
501
- setLines((current) => [...current, { role: 'tool', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)`, toolName: 'sessions' }]);
502
498
  });
503
499
  }
504
500
  setSessionsOpen(false);
@@ -509,7 +505,7 @@ export function TerminalInterface({ engine }) {
509
505
  if (selected) {
510
506
  void engine.selectModel(selected.id);
511
507
  setPickerOpen(false);
512
- setLines((current) => [...current, { role: 'assistant', text: `model selected: ${selected.id} (saved as default)` }]);
508
+ pushTool(`model ${selected.id} (saved as default)`, 'model');
513
509
  }
514
510
  return;
515
511
  }
@@ -517,18 +513,7 @@ export function TerminalInterface({ engine }) {
517
513
  const raw = edit.value.trim();
518
514
  const attachMatch = /^\/attach\s+(.+)$/.exec(raw);
519
515
  if (raw === '/help') {
520
- 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
- ]);
516
+ HELP.forEach((text) => pushTool(text, 'help'));
532
517
  setEdit({ value: '', cursor: 0 });
533
518
  historyIdxRef.current = -1;
534
519
  return;
@@ -536,36 +521,23 @@ export function TerminalInterface({ engine }) {
536
521
  const findMatch = /^\/find\s+(.+)$/.exec(raw);
537
522
  if (findMatch) {
538
523
  const needle = findMatch[1].toLowerCase();
539
- const hits = lines.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]}` }]);
524
+ const hits = lines.filter((line) => line.text.toLowerCase().includes(needle));
525
+ if (hits.length === 0)
526
+ pushTool(`no match for "${findMatch[1]}"`, 'find');
527
+ else {
528
+ pushTool(`find "${findMatch[1]}" · ${hits.length} match${hits.length === 1 ? '' : 'es'}`, 'find');
529
+ hits.slice(-6).forEach((hit) => pushTool(` ${labelFor(hit.role, hit.model, hit.toolName)} · ${clip(hit.text.replace(/\n/g, ' '), Math.max(20, width - 16))}`, 'find'));
530
+ }
558
531
  setEdit({ value: '', cursor: 0 });
559
532
  historyIdxRef.current = -1;
560
533
  return;
561
534
  }
562
535
  if (raw === '/chapters' || raw === '/chapter') {
563
536
  const list = chapters();
564
- 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
- ]);
537
+ if (list.length === 0)
538
+ pushTool('no chapters yet — each prompt starts one', 'chapters');
539
+ else
540
+ list.forEach((chapter, order) => pushTool(`${order + 1}. ${chapter.title}`, 'chapters'));
569
541
  setEdit({ value: '', cursor: 0 });
570
542
  historyIdxRef.current = -1;
571
543
  return;
@@ -575,9 +547,8 @@ export function TerminalInterface({ engine }) {
575
547
  setSessionsList(sessions);
576
548
  setSessionsIndex(0);
577
549
  setSessionsOpen(sessions.length > 0);
578
- if (sessions.length === 0) {
579
- setLines((current) => [...current, { role: 'tool', text: 'no saved sessions — use /save <name> to snapshot this one', toolName: 'sessions' }]);
580
- }
550
+ if (sessions.length === 0)
551
+ pushTool('no saved sessions — use /save <name> to snapshot this one', 'sessions');
581
552
  });
582
553
  setEdit({ value: '', cursor: 0 });
583
554
  return;
@@ -587,35 +558,30 @@ export function TerminalInterface({ engine }) {
587
558
  const name = saveMatch[1];
588
559
  void saveSnapshot(engine.state.workspace.root, name, {
589
560
  messages: [...engine.state.messages], taskQueue: [...engine.state.taskQueue], savedAt: new Date().toISOString(),
590
- }).then(() => {
591
- 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
- });
561
+ }).then(() => pushTool(`session saved: ${name}`, 'sessions'))
562
+ .catch((reason) => pushLine({ role: 'error', text: `save failed: ${reason instanceof Error ? reason.message : String(reason)}` }));
595
563
  setEdit({ value: '', cursor: 0 });
596
564
  return;
597
565
  }
598
566
  const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
599
567
  if (delMatch) {
600
- void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => {
601
- setLines((current) => [...current, { role: 'tool', text: `session deleted: ${delMatch[1]}`, toolName: 'sessions' }]);
602
- });
568
+ void deleteSnapshot(engine.state.workspace.root, delMatch[1]).then(() => pushTool(`session deleted: ${delMatch[1]}`, 'sessions'));
603
569
  setEdit({ value: '', cursor: 0 });
604
570
  return;
605
571
  }
606
572
  if (raw === '/clear') {
607
573
  historyIdxRef.current = -1;
608
- followTranscriptRef.current = true;
609
- setScrollOffset(0);
610
574
  setTaskQueue([]);
611
575
  setError(undefined);
612
576
  setCurrentTool(undefined);
613
577
  setToolCards([]);
614
578
  setExpandedTool(undefined);
579
+ setAgents([]);
615
580
  runStartedAt.current = null;
616
581
  setLiveThink('');
617
582
  setLiveAnswer('');
618
583
  syncPromptHistory([]);
584
+ setStaticKey((k) => k + 1);
619
585
  setLines([]);
620
586
  void engine.clearHistory().catch(() => { });
621
587
  setEdit({ value: '', cursor: 0 });
@@ -625,8 +591,6 @@ export function TerminalInterface({ engine }) {
625
591
  const attachSpec = attachMatch ? attachMatch[1] : undefined;
626
592
  if (!prompt && !attachSpec)
627
593
  return;
628
- // Input stays live during a run: a prompt typed now is queued, not dropped,
629
- // and fires the moment the current run ends.
630
594
  if (busy) {
631
595
  queuedRef.current = { prompt, attachSpec };
632
596
  setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
@@ -652,7 +616,6 @@ export function TerminalInterface({ engine }) {
652
616
  }
653
617
  return;
654
618
  case 'ctrlC':
655
- // While a run is in flight, Ctrl+C cancels that run; when idle it quits.
656
619
  if (busy) {
657
620
  engine.cancel();
658
621
  return;
@@ -725,7 +688,6 @@ export function TerminalInterface({ engine }) {
725
688
  return;
726
689
  }
727
690
  };
728
- // Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
729
691
  useInput((value, key) => {
730
692
  if (approval) {
731
693
  if (value === 'y' || value === 'Y' || key.return) {
@@ -801,14 +763,6 @@ export function TerminalInterface({ engine }) {
801
763
  }
802
764
  return;
803
765
  }
804
- if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
805
- scrollTranscript(pageSizeRef.current);
806
- return;
807
- }
808
- if (key.pageDown || (key.ctrl && value.toLowerCase() === 'd')) {
809
- scrollTranscript(-pageSizeRef.current);
810
- return;
811
- }
812
766
  if (key.ctrl && value.toLowerCase() === 'l') {
813
767
  setLayoutDebug((current) => !current);
814
768
  return;
@@ -817,7 +771,6 @@ export function TerminalInterface({ engine }) {
817
771
  yankLastBlock();
818
772
  return;
819
773
  }
820
- // Ctrl+T toggles the most recent tool card between collapsed and expanded.
821
774
  if (key.ctrl && value.toLowerCase() === 't') {
822
775
  const latest = toolCards[toolCards.length - 1];
823
776
  setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
@@ -844,7 +797,7 @@ export function TerminalInterface({ engine }) {
844
797
  return;
845
798
  }
846
799
  if (!key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow)
847
- historyIdxRef.current = -1; // typing exits history recall
800
+ historyIdxRef.current = -1;
848
801
  if (key.upArrow) {
849
802
  applyAction({ kind: 'up' });
850
803
  return;
@@ -861,7 +814,6 @@ export function TerminalInterface({ engine }) {
861
814
  applyAction({ kind: 'right' });
862
815
  return;
863
816
  }
864
- // 0x7f is Backspace on Windows ConPTY and the kitty-encoded keys are owned by the raw stdin listener.
865
817
  if (value.length > 1 && !isEncodedKey(value)) {
866
818
  setEdit((current) => insertAt(current.value, current.cursor, normalizePaste(value)));
867
819
  return;
@@ -869,7 +821,6 @@ export function TerminalInterface({ engine }) {
869
821
  if (!key.ctrl && !key.meta && value && !isEncodedKey(value))
870
822
  setEdit((current) => insertAt(current.value, current.cursor, value));
871
823
  });
872
- // Raw stdin: disambiguate Windows Backspace (0x7f), the real Delete key, and kitty-protocol keys.
873
824
  useEffect(() => {
874
825
  if (!stdin)
875
826
  return;
@@ -881,151 +832,12 @@ export function TerminalInterface({ engine }) {
881
832
  stdin.on('data', onData);
882
833
  return () => { stdin.off('data', onData); };
883
834
  }, [stdin, applyAction]);
884
- // Legacy Ctrl+M is the CR byte — identical to Enter — so mode cycling needs a
885
- // distinguishable key (Ctrl+E works everywhere); kitty terminals also keep Ctrl+M.
886
- const modeKey = kitty === true ? 'M' : 'E';
887
- const metrics = engine.client.snapshotMetrics();
888
- const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '—';
889
- const hud = [mode, engine.state.activeModel];
890
- if (metrics.fallback.activeProvider)
891
- hud.push(metrics.fallback.activeProvider);
892
- if (metrics.remainingQuota !== undefined)
893
- hud.push(`quota ${metrics.remainingQuota}`);
894
- if (metrics.fallback.attempts > 0)
895
- hud.push(`fb ${metrics.fallback.attempts}`);
896
- hud.push(`saved ${compression}`);
897
- // Live statusline fields: model, mode, running phase, tokens/context, elapsed.
898
- const runningTool = toolCards[toolCards.length - 1];
899
- const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
900
- const elapsedMs = busy && runStartedAt.current !== null
901
- ? Math.max(0, now - runStartedAt.current)
902
- : 0;
903
- const elapsed = busy
904
- ? (elapsedMs >= 60_000 ? `${Math.floor(elapsedMs / 60_000)}m${String(Math.floor((elapsedMs % 60_000) / 1000)).padStart(2, '0')}s` : `${Math.floor(elapsedMs / 1000)}s`)
905
- : '';
906
- const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
907
- const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
908
- const contextLabel = metrics.compression.inputTokens > 0
909
- ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%`
910
- : '';
911
- const contentWidth = Math.max(20, width - 8);
912
- const terminalRows = stdout.rows ?? 24;
913
- const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
914
- const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
915
- const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
916
- const liveRows = useMemo(() => {
917
- const rows = [];
918
- if (liveThink !== '') {
919
- rows.push({ key: 'live-think-label', kind: 'label', role: 'thinking' });
920
- liveThinkLines.forEach((segments, index) => rows.push({ key: `live-think-${index}`, kind: 'content', role: 'thinking', segments }));
921
- }
922
- if (liveAnswer !== '') {
923
- rows.push({ key: 'live-answer-label', kind: 'label', role: 'assistant' });
924
- liveAnswerLines.forEach((segments, index) => rows.push({ key: `live-answer-${index}`, kind: 'content', role: 'assistant', segments }));
925
- }
926
- return rows;
927
- }, [liveThink, liveAnswer, liveThinkLines, liveAnswerLines]);
928
- // Cache markdown/wrapping per Line object. Streaming updates create new live
929
- // text, but completed historical lines retain their rendered rows.
930
- const rowCacheRef = useRef(null);
931
- const allRows = useMemo(() => {
932
- const fallback = engine.state.activeModel;
933
- let cache = rowCacheRef.current;
934
- if (cache === null || cache.width !== contentWidth || cache.fallback !== fallback) {
935
- cache = { width: contentWidth, fallback, rows: new WeakMap() };
936
- rowCacheRef.current = cache;
937
- }
938
- const out = [];
939
- lines.forEach((line, lineIndex) => {
940
- let rendered = cache.rows.get(line);
941
- if (rendered === undefined) {
942
- rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
943
- cache.rows.set(line, rendered);
944
- }
945
- out.push(...rendered);
946
- });
947
- return out;
948
- }, [lines, contentWidth, engine.state.activeModel]);
949
- // Fold tool-role rows into collapsible groups. Expansion is global (Ctrl+G);
950
- // folded groups render as a single summary line instead of a wall of output.
951
- const displayRows = useMemo(() => {
952
- const lastUserIdx = lines.map((line) => line.role).lastIndexOf('user');
953
- const folded = foldToolGroups(lines.map((line) => ({ role: line.role, text: line.text, toolName: line.toolName })), groupsExpanded, lastUserIdx >= 0 ? lastUserIdx : lines.length);
954
- const out = [];
955
- const fallback = engine.state.activeModel;
956
- folded.forEach((gline, lineIndex) => {
957
- const isGroup = 'group' in gline && gline.group !== undefined;
958
- const key = isGroup ? `group-${lineIndex}` : `message-${lineIndex}`;
959
- if (isGroup) {
960
- out.push({
961
- key, role: 'tool',
962
- segments: [{ text: gline.text, dim: true }],
963
- label: labelFor('tool', undefined, 'group'), first: true,
964
- });
965
- return;
966
- }
967
- const line = gline;
968
- const rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
969
- out.push(...rendered);
970
- });
971
- return out;
972
- }, [lines, contentWidth, engine.state.activeModel, groupsExpanded]);
973
- const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
974
- const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
975
- const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
976
- const approvalRows = approval ? 8 : 0;
977
- const inputRows = 3 + editorLayout.lines.length;
978
- const footerRows = 3;
979
- const chromeRows = 3 + footerRows + inputRows + (kitty !== null ? 1 : 0) + planRows + pickerRows + approvalRows;
980
- const messageHeight = Math.max(3, terminalRows - chromeRows);
981
- const toolRows = toolCards.length > 0 ? toolCards.length : 0; // one collapsed card per tool call
982
- const statusRows = (engine.state.preview ? 1 : 0) + (busy ? 1 : 0) + toolRows;
983
- const storedHeight = Math.max(0, messageHeight - Math.min(messageHeight, liveRows.length + statusRows));
984
- const liveHeight = Math.max(0, messageHeight - storedHeight - statusRows);
985
- const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
986
- const maxScroll = Math.max(0, allRows.length - storedHeight);
987
- maxScrollRef.current = maxScroll;
988
- allRowsRef.current = allRows;
989
- storedHeightRef.current = storedHeight;
990
- pageSizeRef.current = Math.max(1, storedHeight - 2);
991
- const boundedScroll = clamp(scrollOffset, 0, maxScroll);
992
- const endRow = allRows.length - boundedScroll;
993
- const startRow = Math.max(0, endRow - storedHeight);
994
- const visibleRows = allRows.slice(startRow, endRow);
995
- const hiddenAbove = startRow;
996
- const hiddenBelow = boundedScroll;
997
- const scrollStatus = allRows.length === 0
998
- ? 'transcript empty'
999
- : storedHeight === 0
1000
- ? `live output · ${allRows.length} transcript rows`
1001
- : hiddenBelow === 0
1002
- ? `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · following latest`
1003
- : `showing rows ${startRow + 1}-${endRow} of ${allRows.length} · ${hiddenAbove} older · ${hiddenBelow} newer`;
1004
- useEffect(() => {
1005
- const previous = previousRowCountRef.current;
1006
- const added = allRows.length - previous;
1007
- previousRowCountRef.current = allRows.length;
1008
- if (previous === 0 || added <= 0)
1009
- return;
1010
- if (followTranscriptRef.current) {
1011
- setScrollOffset(0);
1012
- }
1013
- else {
1014
- setScrollOffset((current) => clamp(current + added, 0, maxScrollRef.current));
1015
- }
1016
- }, [allRows.length]);
1017
- useEffect(() => {
1018
- setScrollOffset((current) => clamp(current, 0, maxScroll));
1019
- }, [maxScroll]);
1020
- // Tick a clock while a run is in flight so the statusline can show elapsed time
1021
- // without re-rendering on every event. runStartedAt is set when a run begins.
1022
835
  useEffect(() => {
1023
836
  if (!busy)
1024
837
  return;
1025
838
  const id = setInterval(() => setNow(Date.now()), 250);
1026
839
  return () => clearInterval(id);
1027
840
  }, [busy]);
1028
- // Drain a prompt that was queued while the previous run was in flight.
1029
841
  useEffect(() => {
1030
842
  if (busy)
1031
843
  return;
@@ -1036,31 +848,51 @@ export function TerminalInterface({ engine }) {
1036
848
  setQueued(undefined);
1037
849
  startRun(pending.prompt, pending.attachSpec);
1038
850
  }, [busy]);
1039
- 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) => {
851
+ const modeKey = kitty === true ? 'M' : 'E';
852
+ const modeAccent = MODE_ACCENT[mode];
853
+ const contentWidth = Math.max(20, width - 6);
854
+ const terminalRows = stdout.rows ?? 24;
855
+ const metrics = engine.client.snapshotMetrics();
856
+ const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
857
+ const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
858
+ const contextLabel = metrics.compression.inputTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
859
+ const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
860
+ const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
861
+ const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
862
+ const elapsed = busy
863
+ ? (elapsedMs >= 60_000 ? `${Math.floor(elapsedMs / 60_000)}m${String(Math.floor((elapsedMs % 60_000) / 1000)).padStart(2, '0')}s` : `${Math.floor(elapsedMs / 1000)}s`)
864
+ : '';
865
+ const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
866
+ const liveThinkLines = useMemo(() => renderMarkdown(liveThink, contentWidth), [liveThink, contentWidth]);
867
+ const liveAnswerLines = useMemo(() => renderMarkdown(liveAnswer, contentWidth), [liveAnswer, contentWidth]);
868
+ // Cap the streaming region so a long think/answer can't crowd out the chrome;
869
+ // the complete text lands in <Static> once the event fires.
870
+ const liveBudget = Math.max(3, terminalRows - 14 - Math.min(6, taskQueue.length) - Math.min(4, agents.length) - editorLayout.lines.length);
871
+ const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
872
+ const liveAnswerView = liveAnswerLines.slice(-liveBudget);
873
+ const doneAgents = agents.filter((lane) => lane.status === 'done').length;
874
+ return _jsxs(Box, { flexDirection: "column", width: width, paddingX: 2, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), _jsxs(Box, { flexDirection: "column", children: [lines.length === 0 && !busy && _jsx(Hero, { width: width, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.accent, children: ["\u25C6 ", engine.state.activeModel] }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), toolCards.slice(-5).map((card) => {
1046
875
  const expanded = expandedTool === card.id;
1047
- const 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" });
876
+ const dot = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: "\u25CD" }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "\u2715" }) : _jsx(Text, { color: PALETTE.success, children: "\u2713" });
1048
877
  const head = card.name === 'run_command'
1049
- ? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 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(' · ') })] })] })] });
878
+ ? `$ ${clip(card.target || '…', Math.max(10, contentWidth - 20))}`
879
+ : `${toolVerb(card.name)}${card.target ? ` ${clip(card.target, Math.max(10, contentWidth - 24))}` : ''}`;
880
+ return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [dot, " ", head, expanded ? ' Ctrl+T collapse' : ''] }), expanded && renderToolBody(card, contentWidth, PALETTE)] }, card.id);
881
+ }), agents.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.error, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.error, children: "\u26A1 swarm" }), _jsxs(Text, { dimColor: true, children: [doneAgents, "/", agents.length, " lanes done"] })] }), agents.map((lane, index) => {
882
+ const color = AGENT_COLORS[index % AGENT_COLORS.length];
883
+ const glyph = lane.status === 'done' ? '✓' : lane.status === 'error' ? '' : lane.status === 'working' ? '◍' : '○';
884
+ return _jsxs(Text, { color: color, children: [glyph, " ", lane.id, " ", _jsx(Text, { dimColor: true, children: clip(lane.note ?? lane.label, Math.max(12, contentWidth - 8)) })] }, lane.id);
885
+ })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "\u25C7 plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
886
+ const marker = item.status === 'done' ? '' : item.status === 'active' ? '' : '';
887
+ const color = item.status === 'done' ? PALETTE.success : item.status === 'active' ? PALETTE.accent : undefined;
888
+ return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
889
+ })] }), engine.state.preview && _jsxs(Text, { color: PALETTE.success, children: ["\u25B8 preview live \u00B7 ", engine.state.preview.url] }), sessionsOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.info, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.info, children: "saved sessions" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u00B7 enter resume \u00B7 esc close" }), sessionsList.map((session, index) => (_jsxs(Text, { color: index === sessionsIndex ? PALETTE.info : undefined, children: [index === sessionsIndex ? '❯ ' : ' ', session.name, session.savedAt ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", session.savedAt] }) : null] }, session.name)))] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: "choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: PALETTE.error, children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "no models returned by OmniRoute." }), pickerItems.map((item, index) => {
890
+ const header = index === 0 || pickerItems[index - 1].group !== item.group
891
+ ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
892
+ : null;
893
+ return _jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Text, { color: index === pickerIndex ? PALETTE.accent : undefined, children: [index === pickerIndex ? '❯ ' : ' ', item.id, item.strategy ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.strategy] }) : null, item.id === engine.state.activeModel ? ' ✓' : ''] })] }, item.id);
894
+ })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.warn, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.warn, children: ["approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(JSON.stringify(approval.input), contentWidth)] }), approval.scopes.map((scope, index) => _jsxs(Text, { dimColor: true, children: [" ", index + 1, " \u00B7 ", clip(scope.label, Math.max(12, contentWidth - 6))] }, scope.id)), _jsxs(Text, { dimColor: true, children: ["y allow once \u00B7 n deny \u00B7 t always allow \u00B7 1\u2013", approval.scopes.length, " pick a trust scope"] })] }), layoutDebug && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 2, marginTop: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: ["layout \u00B7 ", width, "\u00D7", terminalRows, " \u00B7 Ctrl+L to hide"] }), _jsxs(Text, { dimColor: true, children: ["static entries ", lines.length, " \u00B7 live budget ", liveBudget, " \u00B7 think ", liveThinkLines.length, " \u00B7 answer ", liveAnswerLines.length] }), _jsxs(Text, { dimColor: true, children: ["plan ", taskQueue.length, " \u00B7 swarm ", agents.length, " \u00B7 tool cards ", toolCards.length, " \u00B7 editor rows ", editorLayout.lines.length] })] }), queued && _jsxs(Text, { color: PALETTE.warn, children: ["\u23CE queued \u00B7 ", clip(queued, Math.max(12, contentWidth - 12))] }), _jsx(Box, { borderStyle: "round", borderColor: error ? PALETTE.error : modeAccent, paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
895
+ ? _jsxs(Text, { color: modeAccent, children: ["\u276F ", _jsx(Text, { dimColor: true, children: busy ? 'type to queue the next task' : 'describe the work and press enter' })] })
896
+ : editorLayout.lines.map((text, index) => _jsxs(Text, { color: modeAccent, children: [index === 0 ? '❯ ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? modeAccent : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), busy ? ' ' : '', phase, elapsed ? ` · ${elapsed}` : '', agents.length > 0 ? ` · swarm ${doneAgents}/${agents.length}` : ''] }), _jsxs(Text, { color: PALETTE.muted, children: [_jsx(Text, { color: modeAccent, children: mode }), " \u00B7 ", engine.state.activeModel, metrics.fallback.activeProvider ? _jsxs(Text, { children: [" \u00B7 via ", metrics.fallback.activeProvider] }) : null, contextLabel ? _jsxs(Text, { color: meterColor, children: [" \u00B7 ", contextLabel] }) : null] })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Ctrl+J newline \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+T tool \u00B7 Ctrl+Y copy \u00B7 Ctrl+C ", busy ? 'cancel' : 'quit', " \u00B7 /help"] }), compression ? _jsxs(Text, { dimColor: true, children: ["saved ", compression, metrics.remainingQuota !== undefined ? ` · quota ${metrics.remainingQuota}` : ''] }) : null] })] })] })] });
1065
897
  }
1066
898
  //# sourceMappingURL=terminalInterface.js.map