omniharness-cli 0.1.36 → 0.1.38

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,11 +1,59 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import { appendPromptHistory, loadPromptHistory } from '../promptHistory.js';
6
+ import { listSessions, loadSnapshot, saveSnapshot, deleteSnapshot } from '../sessionList.js';
6
7
  import { deleteAt, deleteBefore, insertAt, layoutEditor, lineEndAt, lineStartAt, moveHorizontal, moveVerticalWrapped, normalizePaste } from './editor.js';
7
8
  import { renderMarkdown } from './markdown.js';
9
+ import { looksLikeDiff, diffSegments } from './diff.js';
10
+ import { foldToolGroups } from './groups.js';
11
+ import { palette } from './palette.js';
12
+ import { contextMeter, meterBar } from './modelWindows.js';
13
+ import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
8
14
  import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
15
+ function describeTarget(name, input) {
16
+ if (input && typeof input === 'object') {
17
+ const record = input;
18
+ if (typeof record.path === 'string')
19
+ return record.path;
20
+ if (typeof record.command === 'string')
21
+ return record.command;
22
+ if (typeof record.query === 'string')
23
+ return record.query.slice(0, 48);
24
+ }
25
+ return '';
26
+ }
27
+ /** Short verb shown on a tool card header, per tool type. */
28
+ function toolVerb(tool) {
29
+ switch (tool) {
30
+ case 'read_file': return 'read';
31
+ case 'write_file': return 'edit';
32
+ case 'run_command': return '$';
33
+ case 'git_diff': return 'diff';
34
+ case 'semantic_search': return 'search';
35
+ case 'index_workspace': return 'index';
36
+ case 'update_todo': return 'plan';
37
+ case 'write_memory': return 'memory';
38
+ case 'start_preview': return 'preview';
39
+ case 'route': return 'route';
40
+ default: return tool;
41
+ }
42
+ }
43
+ function phaseFor(tool) {
44
+ switch (tool) {
45
+ case 'read_file': return 'reading files';
46
+ case 'write_file': return 'editing files';
47
+ case 'run_command': return 'running commands';
48
+ case 'semantic_search': return 'searching the workspace';
49
+ case 'index_workspace': return 'indexing the workspace';
50
+ case 'git_diff': return 'checking the diff';
51
+ case 'update_todo': return 'updating the plan';
52
+ case 'start_preview': return 'starting preview';
53
+ case 'write_memory': return 'remembering';
54
+ default: return tool;
55
+ }
56
+ }
9
57
  /** Map a restored transcript message into a rendered line (used on startup). */
10
58
  function lineFromMessage(message) {
11
59
  switch (message.role) {
@@ -61,18 +109,59 @@ function labelFor(role, model, toolName, fallback) {
61
109
  default: return model ?? fallback ?? 'assistant';
62
110
  }
63
111
  }
64
- function colorFor(role) {
112
+ const PALETTE = palette();
113
+ function colorFor(role, p = PALETTE) {
65
114
  switch (role) {
66
- case 'user': return 'blue';
67
- case 'error': return 'red';
68
- case 'thinking': return 'yellow';
69
- case 'tool': return 'magenta';
70
- default: return 'green';
115
+ case 'user': return p.info;
116
+ case 'error': return p.error;
117
+ case 'thinking': return p.warn;
118
+ case 'tool': return p.muted;
119
+ default: return p.success;
71
120
  }
72
121
  }
73
122
  function SegmentText({ segments, role }) {
74
- const base = role === 'thinking' ? 'yellow' : role === 'tool' ? 'magenta' : undefined;
75
- 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, children: segment.text }, index))) });
123
+ const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? PALETTE.success : role === 'user' ? PALETTE.info : undefined;
124
+ 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
+ }
126
+ /** Expanded body of a tool card, rendered per tool type. */
127
+ function renderToolBody(card, width, p) {
128
+ const trail = card.trail;
129
+ if (!trail)
130
+ return card.summary ? _jsx(Text, { dimColor: true, children: card.summary.slice(0, width) }) : _jsx(Text, { dimColor: true, children: "(no output)" });
131
+ if (looksLikeDiff(trail)) {
132
+ return _jsx(_Fragment, { children: diffSegments(trail, width).slice(0, 14).map((segments, index) => _jsx(SegmentText, { segments: segments, role: "tool" }, index)) });
133
+ }
134
+ const rows = trail.split('\n').slice(0, 14);
135
+ if (card.name === 'write_file') {
136
+ // A write trail is the new file content — show it as added (green) lines.
137
+ return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { color: p.success, children: line.slice(0, width) }, index)) });
138
+ }
139
+ if (card.name === 'run_command') {
140
+ return _jsx(_Fragment, { children: rows.map((line, index) => {
141
+ const exit = /^exit (\d+)/.exec(line);
142
+ const color = exit ? (exit[1] === '0' ? p.success : p.error) : undefined;
143
+ return _jsx(Text, { color: color, dimColor: color === undefined, children: line.slice(0, width) }, index);
144
+ }) });
145
+ }
146
+ return _jsx(_Fragment, { children: rows.map((line, index) => _jsx(Text, { dimColor: true, children: line.slice(0, width) }, index)) });
147
+ }
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.
153
+ const label = line.role === 'assistant' && line.provider
154
+ ? `${baseLabel} · via ${line.provider}${line.fallback ? ' (failover)' : ''}`
155
+ : baseLabel;
156
+ const markdown = line.role !== 'tool' && line.role !== 'error';
157
+ const wrapped = markdown
158
+ ? renderMarkdown(line.text, width)
159
+ : 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;
76
165
  }
77
166
  export function TerminalInterface({ engine }) {
78
167
  const { exit } = useApp();
@@ -103,8 +192,26 @@ export function TerminalInterface({ engine }) {
103
192
  const [liveThink, setLiveThink] = useState('');
104
193
  const [liveAnswer, setLiveAnswer] = useState('');
105
194
  const [kitty, setKitty] = useState(null);
195
+ const [sessionsList, setSessionsList] = useState([]);
196
+ const [sessionsOpen, setSessionsOpen] = useState(false);
197
+ const [sessionsIndex, setSessionsIndex] = useState(0);
198
+ const [groupsExpanded, setGroupsExpanded] = useState(false);
106
199
  const [taskQueue, setTaskQueue] = useState(engine.state.taskQueue);
107
200
  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
+ const [toolCards, setToolCards] = useState([]);
204
+ const [expandedTool, setExpandedTool] = useState();
205
+ // Run timing: monotonic start time plus a ticking elapsed counter while busy.
206
+ const runStartedAt = useRef(null);
207
+ 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
+ const queuedRef = useRef(null);
210
+ const [queued, setQueued] = useState();
211
+ // Ctrl+L paints the row budget of every layout region over the transcript.
212
+ const [layoutDebug, setLayoutDebug] = useState(false);
213
+ // Restores plain stdout.write when synchronized-output bracketing is torn down.
214
+ const syncRestoreRef = useRef(null);
108
215
  useEffect(() => {
109
216
  let alive = true;
110
217
  // Seed history from disk, but only if the user hasn't already submitted a
@@ -121,8 +228,14 @@ export function TerminalInterface({ engine }) {
121
228
  stdout.write(KITTY_PUSH);
122
229
  let kittyTimer;
123
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.
124
233
  const onKitty = (chunk) => {
125
- if (!isKittyQueryResponse(chunk.toString()))
234
+ const text = chunk.toString();
235
+ if (isSyncOutputReply(text) && syncRestoreRef.current === null) {
236
+ syncRestoreRef.current = wrapSynchronizedOutput(stdout);
237
+ }
238
+ if (!isKittyQueryResponse(text))
126
239
  return;
127
240
  if (kittyTimer)
128
241
  clearTimeout(kittyTimer);
@@ -132,6 +245,7 @@ export function TerminalInterface({ engine }) {
132
245
  if (stdin) {
133
246
  stdin.on('data', onKitty);
134
247
  kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKitty); }, 300);
248
+ stdout.write(SYNC_QUERY);
135
249
  stdout.write(KITTY_QUERY);
136
250
  }
137
251
  else {
@@ -153,16 +267,35 @@ export function TerminalInterface({ engine }) {
153
267
  case 'text':
154
268
  setLines((current) => [...current, {
155
269
  role: 'assistant', text: event.content, model: event.model,
270
+ provider: event.provider, fallback: event.fallback,
156
271
  saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
157
272
  }]);
158
273
  setLiveAnswer('');
159
274
  break;
275
+ 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
+ }]);
282
+ break;
160
283
  case 'tool_start':
161
- setLines((current) => [...current, { role: 'tool', text: event.tool, toolName: `${event.tool} →` }]);
284
+ setToolCards((current) => [...current, {
285
+ id: `tc-${Date.now().toString(36)}-${current.length}`, name: event.tool,
286
+ target: describeTarget(event.tool, event.input), status: 'running',
287
+ trail: typeof event.input === 'object' && event.input !== null
288
+ && typeof event.input.content === 'string'
289
+ ? String(event.input.content) : undefined,
290
+ }]);
162
291
  setCurrentTool(event.tool);
163
292
  break;
164
293
  case 'tool_result':
165
- setLines((current) => [...current, { role: 'tool', text: ` ${event.summary}`, toolName: 'result' }]);
294
+ setToolCards((current) => current.length === 0
295
+ ? current
296
+ : current.map((card, index) => index === current.length - 1
297
+ ? { ...card, status: 'done', summary: event.summary, trail: card.trail ?? event.detail }
298
+ : card));
166
299
  setCurrentTool(undefined);
167
300
  break;
168
301
  case 'todos':
@@ -189,10 +322,12 @@ export function TerminalInterface({ engine }) {
189
322
  stdout.off('resize', onResize);
190
323
  const pendingApproval = approvalResolve.current;
191
324
  approvalResolve.current = null;
192
- pendingApproval?.(false);
325
+ pendingApproval?.({ approved: false });
193
326
  engine.stop();
194
327
  unsubscribe();
195
328
  process.off('exit', onUnload);
329
+ syncRestoreRef.current?.();
330
+ syncRestoreRef.current = null;
196
331
  stdout.write(KITTY_POP);
197
332
  };
198
333
  }, [engine, stdout, stdin]);
@@ -224,11 +359,77 @@ export function TerminalInterface({ engine }) {
224
359
  engine.state.mode = next;
225
360
  setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
226
361
  };
227
- const approve = (ok) => {
362
+ const approve = (approved, trust) => {
228
363
  const resolve = approvalResolve.current;
229
364
  setApproval(null);
230
365
  approvalResolve.current = null;
231
- resolve?.(ok);
366
+ resolve?.({ approved, trust });
367
+ };
368
+ /** Copy the most recent assistant reply (or last transcript line) to the clipboard via OSC 52. */
369
+ const yankLastBlock = () => {
370
+ const target = [...lines].reverse().find((entry) => entry.role === 'assistant') ?? lines[lines.length - 1];
371
+ if (!target)
372
+ return;
373
+ const seq = osc52Copy(target.text);
374
+ if (!seq) {
375
+ setLines((current) => [...current, { role: 'tool', text: 'clipboard: block too large to copy', toolName: 'clipboard' }]);
376
+ return;
377
+ }
378
+ try {
379
+ stdout.write(seq);
380
+ setLines((current) => [...current, { role: 'tool', text: `copied ${target.text.length} chars to clipboard`, toolName: 'clipboard' }]);
381
+ }
382
+ catch { /* clipboard write is best-effort */ }
383
+ };
384
+ /** Kick off an engine run for `prompt`, attaching `attachSpec` files first when given. */
385
+ const startRun = (prompt, attachSpec) => {
386
+ followTranscriptRef.current = true;
387
+ setScrollOffset(0);
388
+ setEdit({ value: '', cursor: 0 });
389
+ setBusy(true);
390
+ setError(undefined);
391
+ setToolCards([]);
392
+ setExpandedTool(undefined);
393
+ if (runStartedAt.current === null)
394
+ runStartedAt.current = Date.now();
395
+ setNow(Date.now());
396
+ historyIdxRef.current = -1;
397
+ if (prompt) {
398
+ syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
399
+ void appendPromptHistory(prompt).catch(() => { });
400
+ }
401
+ if (!attachSpec)
402
+ setLines((current) => [...current, { role: 'user', text: prompt }]);
403
+ void (async () => {
404
+ try {
405
+ if (attachSpec)
406
+ await engine.attach(attachSpec.split(/\s+/).filter(Boolean));
407
+ await engine.run(prompt);
408
+ /* answer is streamed live via text_delta / text events */
409
+ }
410
+ catch (reason) {
411
+ const message = reason instanceof Error ? reason.message : String(reason);
412
+ setError(message);
413
+ setLines((current) => [...current, { role: 'error', text: message }]);
414
+ }
415
+ finally {
416
+ const startedAt = runStartedAt.current;
417
+ setBusy(false);
418
+ setCurrentTool(undefined);
419
+ setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
420
+ runStartedAt.current = null;
421
+ setLiveThink('');
422
+ setLiveAnswer('');
423
+ // A long run probably pulled focus elsewhere: nudge with a bell + OSC 9 notification.
424
+ if (startedAt !== null && shouldNudgeOnFinish(Date.now() - startedAt)) {
425
+ try {
426
+ stdout.write(osc9Notify('OmniHarness — run finished'));
427
+ stdout.write(BEL);
428
+ }
429
+ catch { /* nudge is best-effort */ }
430
+ }
431
+ }
432
+ })();
232
433
  };
233
434
  /** ↑ walks older prompts, ↓ walks newer; ↓ past the newest clears the input. */
234
435
  const navigateHistory = (older) => {
@@ -244,6 +445,24 @@ export function TerminalInterface({ engine }) {
244
445
  };
245
446
  /** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
246
447
  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
+ /** Chapters: one per user turn, titled by the prompt's first line. */
463
+ const chapters = () => lines.flatMap((line, index) => line.role === 'user'
464
+ ? [{ index, title: line.text.split('\n')[0].slice(0, 60) || '(empty prompt)' }]
465
+ : []);
247
466
  /** Scroll by rendered transcript rows; positive values move toward older output. */
248
467
  const scrollTranscript = (delta) => {
249
468
  if (delta > 0 && maxScrollRef.current > 0)
@@ -259,6 +478,8 @@ export function TerminalInterface({ engine }) {
259
478
  const applyAction = (action) => {
260
479
  if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
261
480
  return;
481
+ if (sessionsOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
482
+ return;
262
483
  if (pickerOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
263
484
  return;
264
485
  switch (action.kind) {
@@ -267,6 +488,22 @@ export function TerminalInterface({ engine }) {
267
488
  approve(true);
268
489
  return;
269
490
  }
491
+ if (sessionsOpen) {
492
+ const selected = sessionsList[sessionsIndex];
493
+ if (selected) {
494
+ void loadSnapshot(engine.state.workspace.root, selected.name).then((snapshot) => {
495
+ if (snapshot == null) {
496
+ setLines((current) => [...current, { role: 'error', text: `snapshot ${selected.name} is unreadable` }]);
497
+ return;
498
+ }
499
+ setLines(snapshot.messages.map(lineFromMessage));
500
+ setTaskQueue(snapshot.taskQueue);
501
+ setLines((current) => [...current, { role: 'tool', text: `session resumed: ${selected.name} (${snapshot.messages.length} messages)`, toolName: 'sessions' }]);
502
+ });
503
+ }
504
+ setSessionsOpen(false);
505
+ return;
506
+ }
270
507
  if (pickerOpen) {
271
508
  const selected = pickerItems[pickerIndex];
272
509
  if (selected) {
@@ -283,13 +520,89 @@ export function TerminalInterface({ engine }) {
283
520
  setLines((current) => [...current,
284
521
  { role: 'tool', text: '/help — show commands', toolName: 'commands' },
285
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' },
286
526
  { role: 'tool', text: '/attach <files> — attach files to the next message', toolName: 'commands' },
287
- { role: 'tool', text: 'keys: Ctrl+O models · Ctrl+E mode · Ctrl+C cancel · PgUp/PgDn scroll · ↑/↓ prompt history', 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
+ ]);
532
+ setEdit({ value: '', cursor: 0 });
533
+ historyIdxRef.current = -1;
534
+ return;
535
+ }
536
+ const findMatch = /^\/find\s+(.+)$/.exec(raw);
537
+ if (findMatch) {
538
+ 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]}` }]);
558
+ setEdit({ value: '', cursor: 0 });
559
+ historyIdxRef.current = -1;
560
+ return;
561
+ }
562
+ if (raw === '/chapters' || raw === '/chapter') {
563
+ 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' }] : []),
288
568
  ]);
289
569
  setEdit({ value: '', cursor: 0 });
290
570
  historyIdxRef.current = -1;
291
571
  return;
292
572
  }
573
+ if (raw === '/sessions') {
574
+ void listSessions(engine.state.workspace.root).then((sessions) => {
575
+ setSessionsList(sessions);
576
+ setSessionsIndex(0);
577
+ 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
+ }
581
+ });
582
+ setEdit({ value: '', cursor: 0 });
583
+ return;
584
+ }
585
+ const saveMatch = /^\/save\s+([\w.-]+)$/.exec(raw);
586
+ if (saveMatch) {
587
+ const name = saveMatch[1];
588
+ void saveSnapshot(engine.state.workspace.root, name, {
589
+ 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
+ });
595
+ setEdit({ value: '', cursor: 0 });
596
+ return;
597
+ }
598
+ const delMatch = /^\/forget\s+([\w.-]+)$/.exec(raw);
599
+ 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
+ });
603
+ setEdit({ value: '', cursor: 0 });
604
+ return;
605
+ }
293
606
  if (raw === '/clear') {
294
607
  historyIdxRef.current = -1;
295
608
  followTranscriptRef.current = true;
@@ -297,6 +610,9 @@ export function TerminalInterface({ engine }) {
297
610
  setTaskQueue([]);
298
611
  setError(undefined);
299
612
  setCurrentTool(undefined);
613
+ setToolCards([]);
614
+ setExpandedTool(undefined);
615
+ runStartedAt.current = null;
300
616
  setLiveThink('');
301
617
  setLiveAnswer('');
302
618
  syncPromptHistory([]);
@@ -306,39 +622,19 @@ export function TerminalInterface({ engine }) {
306
622
  return;
307
623
  }
308
624
  const prompt = attachMatch ? '' : raw;
309
- if (busy || (!prompt && !attachMatch))
625
+ const attachSpec = attachMatch ? attachMatch[1] : undefined;
626
+ if (!prompt && !attachSpec)
627
+ 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
+ if (busy) {
631
+ queuedRef.current = { prompt, attachSpec };
632
+ setQueued(prompt || `/attach ${attachSpec ?? ''}`.trim());
633
+ setEdit({ value: '', cursor: 0 });
634
+ historyIdxRef.current = -1;
310
635
  return;
311
- followTranscriptRef.current = true;
312
- setScrollOffset(0);
313
- setEdit({ value: '', cursor: 0 });
314
- setBusy(true);
315
- setError(undefined);
316
- historyIdxRef.current = -1;
317
- if (prompt) {
318
- syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
319
- void appendPromptHistory(prompt).catch(() => { });
320
636
  }
321
- if (!attachMatch)
322
- setLines((current) => [...current, { role: 'user', text: prompt }]);
323
- void (async () => {
324
- try {
325
- if (attachMatch)
326
- await engine.attach(attachMatch[1].split(/\s+/).filter(Boolean));
327
- await engine.run(prompt);
328
- /* answer is streamed live via text_delta / text events */
329
- }
330
- catch (reason) {
331
- const message = reason instanceof Error ? reason.message : String(reason);
332
- setError(message);
333
- setLines((current) => [...current, { role: 'error', text: message }]);
334
- }
335
- finally {
336
- setBusy(false);
337
- setCurrentTool(undefined);
338
- setLiveThink('');
339
- setLiveAnswer('');
340
- }
341
- })();
637
+ startRun(prompt, attachSpec);
342
638
  }
343
639
  return;
344
640
  case 'escape':
@@ -346,6 +642,10 @@ export function TerminalInterface({ engine }) {
346
642
  approve(false);
347
643
  return;
348
644
  }
645
+ if (sessionsOpen) {
646
+ setSessionsOpen(false);
647
+ return;
648
+ }
349
649
  if (pickerOpen) {
350
650
  setPickerOpen(false);
351
651
  return;
@@ -370,6 +670,10 @@ export function TerminalInterface({ engine }) {
370
670
  cycleMode();
371
671
  return;
372
672
  case 'up':
673
+ if (sessionsOpen) {
674
+ setSessionsIndex((current) => clamp(current - 1, 0, Math.max(0, sessionsList.length - 1)));
675
+ return;
676
+ }
373
677
  if (pickerOpen) {
374
678
  setPickerIndex((current) => clamp(current - 1, 0, Math.max(0, pickerItems.length - 1)));
375
679
  return;
@@ -381,6 +685,10 @@ export function TerminalInterface({ engine }) {
381
685
  setEdit((current) => ({ ...current, cursor: moveVerticalWrapped(current.value, current.cursor, -1, inputWidth) }));
382
686
  return;
383
687
  case 'down':
688
+ if (sessionsOpen) {
689
+ setSessionsIndex((current) => clamp(current + 1, 0, Math.max(0, sessionsList.length - 1)));
690
+ return;
691
+ }
384
692
  if (pickerOpen) {
385
693
  setPickerIndex((current) => clamp(current + 1, 0, Math.max(0, pickerItems.length - 1)));
386
694
  return;
@@ -420,10 +728,23 @@ export function TerminalInterface({ engine }) {
420
728
  // Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
421
729
  useInput((value, key) => {
422
730
  if (approval) {
423
- if (value === 'y' || value === 'Y' || key.return)
424
- applyAction({ kind: 'submit' });
425
- else if (value === 'n' || value === 'N' || key.escape)
426
- applyAction({ kind: 'escape' });
731
+ if (value === 'y' || value === 'Y' || key.return) {
732
+ approve(true);
733
+ return;
734
+ }
735
+ if (value === 'n' || value === 'N' || key.escape) {
736
+ approve(false);
737
+ return;
738
+ }
739
+ if (value === 't' || value === 'T') {
740
+ approve(true, approval.scopes[0]?.id);
741
+ return;
742
+ }
743
+ const digit = Number(value);
744
+ if (Number.isInteger(digit) && digit >= 1 && digit <= approval.scopes.length) {
745
+ approve(true, approval.scopes[digit - 1]?.id);
746
+ return;
747
+ }
427
748
  return;
428
749
  }
429
750
  if (key.ctrl && value === 'c') {
@@ -461,6 +782,25 @@ export function TerminalInterface({ engine }) {
461
782
  }
462
783
  return;
463
784
  }
785
+ if (sessionsOpen) {
786
+ if (key.escape) {
787
+ applyAction({ kind: 'escape' });
788
+ return;
789
+ }
790
+ if (key.upArrow) {
791
+ applyAction({ kind: 'up' });
792
+ return;
793
+ }
794
+ if (key.downArrow) {
795
+ applyAction({ kind: 'down' });
796
+ return;
797
+ }
798
+ if (key.return) {
799
+ applyAction({ kind: 'submit' });
800
+ return;
801
+ }
802
+ return;
803
+ }
464
804
  if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
465
805
  scrollTranscript(pageSizeRef.current);
466
806
  return;
@@ -469,6 +809,20 @@ export function TerminalInterface({ engine }) {
469
809
  scrollTranscript(-pageSizeRef.current);
470
810
  return;
471
811
  }
812
+ if (key.ctrl && value.toLowerCase() === 'l') {
813
+ setLayoutDebug((current) => !current);
814
+ return;
815
+ }
816
+ if (key.ctrl && value.toLowerCase() === 'y') {
817
+ yankLastBlock();
818
+ return;
819
+ }
820
+ // Ctrl+T toggles the most recent tool card between collapsed and expanded.
821
+ if (key.ctrl && value.toLowerCase() === 't') {
822
+ const latest = toolCards[toolCards.length - 1];
823
+ setExpandedTool((current) => (latest && current === latest.id) ? undefined : (latest ? latest.id : undefined));
824
+ return;
825
+ }
472
826
  if (key.tab) {
473
827
  applyAction({ kind: 'tab' });
474
828
  return;
@@ -540,6 +894,20 @@ export function TerminalInterface({ engine }) {
540
894
  if (metrics.fallback.attempts > 0)
541
895
  hud.push(`fb ${metrics.fallback.attempts}`);
542
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
+ : '';
543
911
  const contentWidth = Math.max(20, width - 8);
544
912
  const terminalRows = stdout.rows ?? 24;
545
913
  const editorLayout = useMemo(() => layoutEditor(edit.value, edit.cursor, inputWidth), [edit.value, edit.cursor, inputWidth]);
@@ -571,22 +939,37 @@ export function TerminalInterface({ engine }) {
571
939
  lines.forEach((line, lineIndex) => {
572
940
  let rendered = cache.rows.get(line);
573
941
  if (rendered === undefined) {
574
- const label = labelFor(line.role, line.model, line.toolName, fallback);
575
- const markdown = line.role !== 'tool' && line.role !== 'error';
576
- const wrapped = markdown
577
- ? renderMarkdown(line.text, contentWidth)
578
- : wrap(line.text, contentWidth).map((text) => [{ text }]);
579
- const next = [];
580
- wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
581
- if (line.saved)
582
- next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
583
- rendered = next;
942
+ rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
584
943
  cache.rows.set(line, rendered);
585
944
  }
586
945
  out.push(...rendered);
587
946
  });
588
947
  return out;
589
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]);
590
973
  const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
591
974
  const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
592
975
  const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
@@ -595,12 +978,15 @@ export function TerminalInterface({ engine }) {
595
978
  const footerRows = 3;
596
979
  const chromeRows = 3 + footerRows + inputRows + (kitty !== null ? 1 : 0) + planRows + pickerRows + approvalRows;
597
980
  const messageHeight = Math.max(3, terminalRows - chromeRows);
598
- const statusRows = (engine.state.preview ? 1 : 0) + (busy ? 1 : 0);
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;
599
983
  const storedHeight = Math.max(0, messageHeight - Math.min(messageHeight, liveRows.length + statusRows));
600
984
  const liveHeight = Math.max(0, messageHeight - storedHeight - statusRows);
601
985
  const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
602
986
  const maxScroll = Math.max(0, allRows.length - storedHeight);
603
987
  maxScrollRef.current = maxScroll;
988
+ allRowsRef.current = allRows;
989
+ storedHeightRef.current = storedHeight;
604
990
  pageSizeRef.current = Math.max(1, storedHeight - 2);
605
991
  const boundedScroll = clamp(scrollOffset, 0, maxScroll);
606
992
  const endRow = allRows.length - boundedScroll;
@@ -631,23 +1017,50 @@ export function TerminalInterface({ engine }) {
631
1017
  useEffect(() => {
632
1018
  setScrollOffset((current) => clamp(current, 0, maxScroll));
633
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
+ useEffect(() => {
1023
+ if (!busy)
1024
+ return;
1025
+ const id = setInterval(() => setNow(Date.now()), 250);
1026
+ return () => clearInterval(id);
1027
+ }, [busy]);
1028
+ // Drain a prompt that was queued while the previous run was in flight.
1029
+ useEffect(() => {
1030
+ if (busy)
1031
+ return;
1032
+ const pending = queuedRef.current;
1033
+ if (!pending)
1034
+ return;
1035
+ queuedRef.current = null;
1036
+ setQueued(undefined);
1037
+ startRun(pending.prompt, pending.attachSpec);
1038
+ }, [busy]);
634
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
635
1040
  ? _jsx(Text, { dimColor: true, children: row.saved }, row.key)
636
1041
  : row.first
637
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)
638
1043
  : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), visibleLiveRows.map((row) => row.kind === 'label'
639
1044
  ? _jsx(Text, { color: colorFor(row.role), bold: true, children: row.role === 'thinking' ? 'think' : engine.state.activeModel }, row.key)
640
- : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), engine.state.preview && _jsxs(Text, { color: "green", dimColor: true, children: ["preview live \u00B7 ", engine.state.preview.url] }), busy && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " working in ", mode, " mode on ", engine.state.activeModel, currentTool ? ` · now ${currentTool}` : ''] })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
1045
+ : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), toolCards.slice(-6).map((card) => {
1046
+ 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" });
1048
+ 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) => {
641
1054
  const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '▸' : '○';
642
1055
  const color = item.status === 'done' ? 'green' : item.status === 'active' ? 'cyan' : undefined;
643
1056
  return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
644
- })] }), 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) => {
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) => {
645
1058
  const header = index === 0 || pickerItems[index - 1].group !== item.group
646
1059
  ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
647
1060
  : null;
648
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);
649
- })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(typeof approval.input === 'string' ? approval.input : JSON.stringify(approval.input), contentWidth)] }), _jsx(Text, { dimColor: true, children: "y approve \u00B7 n deny" })] }), _jsx(Box, { borderStyle: "round", borderColor: error ? 'red' : 'cyan', paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
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 === ''
650
1063
  ? _jsxs(Text, { color: "cyan", children: ["\u203A ", _jsx(Text, { dimColor: true, children: "type a task and press enter" })] })
651
- : 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, { dimColor: true, children: ["Enter send \u00B7 Shift+Enter/Ctrl+J new line \u00B7 PgUp/PgDn or Ctrl+U/D scroll \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 \u2191/\u2193 recall \u00B7 Ctrl+C cancel/quit \u00B7 /help"] }), _jsx(Text, { dimColor: true, children: hud.join(' · ') })] }), _jsx(Text, { dimColor: true, children: clip(scrollStatus, contentWidth) })] })] });
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(' · ') })] })] })] });
652
1065
  }
653
1066
  //# sourceMappingURL=terminalInterface.js.map