omniharness-cli 0.1.37 → 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,12 +1,16 @@
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';
8
9
  import { looksLikeDiff, diffSegments } from './diff.js';
10
+ import { foldToolGroups } from './groups.js';
9
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';
10
14
  import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
11
15
  function describeTarget(name, input) {
12
16
  if (input && typeof input === 'object') {
@@ -20,6 +24,22 @@ function describeTarget(name, input) {
20
24
  }
21
25
  return '';
22
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
+ }
23
43
  function phaseFor(tool) {
24
44
  switch (tool) {
25
45
  case 'read_file': return 'reading files';
@@ -103,6 +123,46 @@ function SegmentText({ segments, role }) {
103
123
  const base = role === 'thinking' ? PALETTE.warn : role === 'tool' ? PALETTE.muted : role === 'assistant' ? PALETTE.success : role === 'user' ? PALETTE.info : undefined;
104
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))) });
105
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;
165
+ }
106
166
  export function TerminalInterface({ engine }) {
107
167
  const { exit } = useApp();
108
168
  const { stdout } = useStdout();
@@ -132,6 +192,10 @@ export function TerminalInterface({ engine }) {
132
192
  const [liveThink, setLiveThink] = useState('');
133
193
  const [liveAnswer, setLiveAnswer] = useState('');
134
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);
135
199
  const [taskQueue, setTaskQueue] = useState(engine.state.taskQueue);
136
200
  const [currentTool, setCurrentTool] = useState();
137
201
  // Tool activity rendered as collapsible cards: each completed/ongoing tool
@@ -141,6 +205,13 @@ export function TerminalInterface({ engine }) {
141
205
  // Run timing: monotonic start time plus a ticking elapsed counter while busy.
142
206
  const runStartedAt = useRef(null);
143
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);
144
215
  useEffect(() => {
145
216
  let alive = true;
146
217
  // Seed history from disk, but only if the user hasn't already submitted a
@@ -157,8 +228,14 @@ export function TerminalInterface({ engine }) {
157
228
  stdout.write(KITTY_PUSH);
158
229
  let kittyTimer;
159
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.
160
233
  const onKitty = (chunk) => {
161
- 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))
162
239
  return;
163
240
  if (kittyTimer)
164
241
  clearTimeout(kittyTimer);
@@ -168,6 +245,7 @@ export function TerminalInterface({ engine }) {
168
245
  if (stdin) {
169
246
  stdin.on('data', onKitty);
170
247
  kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKitty); }, 300);
248
+ stdout.write(SYNC_QUERY);
171
249
  stdout.write(KITTY_QUERY);
172
250
  }
173
251
  else {
@@ -189,10 +267,19 @@ export function TerminalInterface({ engine }) {
189
267
  case 'text':
190
268
  setLines((current) => [...current, {
191
269
  role: 'assistant', text: event.content, model: event.model,
270
+ provider: event.provider, fallback: event.fallback,
192
271
  saved: event.compression ? `${Math.round((1 - event.compression.ratio) * 100)}% saved (${event.compression.strategy.toUpperCase()}) · ${event.compression.savedTokens.toLocaleString()} tokens` : undefined,
193
272
  }]);
194
273
  setLiveAnswer('');
195
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;
196
283
  case 'tool_start':
197
284
  setToolCards((current) => [...current, {
198
285
  id: `tc-${Date.now().toString(36)}-${current.length}`, name: event.tool,
@@ -206,7 +293,9 @@ export function TerminalInterface({ engine }) {
206
293
  case 'tool_result':
207
294
  setToolCards((current) => current.length === 0
208
295
  ? current
209
- : current.map((card, index) => index === current.length - 1 ? { ...card, status: 'done', summary: event.summary } : card));
296
+ : current.map((card, index) => index === current.length - 1
297
+ ? { ...card, status: 'done', summary: event.summary, trail: card.trail ?? event.detail }
298
+ : card));
210
299
  setCurrentTool(undefined);
211
300
  break;
212
301
  case 'todos':
@@ -233,10 +322,12 @@ export function TerminalInterface({ engine }) {
233
322
  stdout.off('resize', onResize);
234
323
  const pendingApproval = approvalResolve.current;
235
324
  approvalResolve.current = null;
236
- pendingApproval?.(false);
325
+ pendingApproval?.({ approved: false });
237
326
  engine.stop();
238
327
  unsubscribe();
239
328
  process.off('exit', onUnload);
329
+ syncRestoreRef.current?.();
330
+ syncRestoreRef.current = null;
240
331
  stdout.write(KITTY_POP);
241
332
  };
242
333
  }, [engine, stdout, stdin]);
@@ -268,11 +359,77 @@ export function TerminalInterface({ engine }) {
268
359
  engine.state.mode = next;
269
360
  setLines((current) => [...current, { role: 'tool', text: `mode → ${next}`, toolName: 'mode' }]);
270
361
  };
271
- const approve = (ok) => {
362
+ const approve = (approved, trust) => {
272
363
  const resolve = approvalResolve.current;
273
364
  setApproval(null);
274
365
  approvalResolve.current = null;
275
- 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
+ })();
276
433
  };
277
434
  /** ↑ walks older prompts, ↓ walks newer; ↓ past the newest clears the input. */
278
435
  const navigateHistory = (older) => {
@@ -288,6 +445,24 @@ export function TerminalInterface({ engine }) {
288
445
  };
289
446
  /** Whether ↑/↓ should browse prompt history instead of moving the text caret. */
290
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
+ : []);
291
466
  /** Scroll by rendered transcript rows; positive values move toward older output. */
292
467
  const scrollTranscript = (delta) => {
293
468
  if (delta > 0 && maxScrollRef.current > 0)
@@ -303,6 +478,8 @@ export function TerminalInterface({ engine }) {
303
478
  const applyAction = (action) => {
304
479
  if (approval && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'ctrlC')
305
480
  return;
481
+ if (sessionsOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
482
+ return;
306
483
  if (pickerOpen && action.kind !== 'submit' && action.kind !== 'escape' && action.kind !== 'up' && action.kind !== 'down')
307
484
  return;
308
485
  switch (action.kind) {
@@ -311,6 +488,22 @@ export function TerminalInterface({ engine }) {
311
488
  approve(true);
312
489
  return;
313
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
+ }
314
507
  if (pickerOpen) {
315
508
  const selected = pickerItems[pickerIndex];
316
509
  if (selected) {
@@ -327,13 +520,89 @@ export function TerminalInterface({ engine }) {
327
520
  setLines((current) => [...current,
328
521
  { role: 'tool', text: '/help — show commands', toolName: 'commands' },
329
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' },
330
526
  { role: 'tool', text: '/attach <files> — attach files to the next message', toolName: 'commands' },
331
- { role: 'tool', text: 'keys: Ctrl+O models · Ctrl+E mode · Ctrl+C cancel · PgUp/PgDn scroll · ↑/↓ prompt history', toolName: 'commands' },
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' }] : []),
332
568
  ]);
333
569
  setEdit({ value: '', cursor: 0 });
334
570
  historyIdxRef.current = -1;
335
571
  return;
336
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
+ }
337
606
  if (raw === '/clear') {
338
607
  historyIdxRef.current = -1;
339
608
  followTranscriptRef.current = true;
@@ -353,46 +622,19 @@ export function TerminalInterface({ engine }) {
353
622
  return;
354
623
  }
355
624
  const prompt = attachMatch ? '' : raw;
356
- 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;
357
635
  return;
358
- followTranscriptRef.current = true;
359
- setScrollOffset(0);
360
- setEdit({ value: '', cursor: 0 });
361
- setBusy(true);
362
- setError(undefined);
363
- setToolCards([]);
364
- setExpandedTool(undefined);
365
- if (runStartedAt.current === null)
366
- runStartedAt.current = Date.now();
367
- setNow(Date.now());
368
- historyIdxRef.current = -1;
369
- if (prompt) {
370
- syncPromptHistory([prompt, ...promptHistoryRef.current.filter((entry) => entry !== prompt)].slice(0, 200));
371
- void appendPromptHistory(prompt).catch(() => { });
372
636
  }
373
- if (!attachMatch)
374
- setLines((current) => [...current, { role: 'user', text: prompt }]);
375
- void (async () => {
376
- try {
377
- if (attachMatch)
378
- await engine.attach(attachMatch[1].split(/\s+/).filter(Boolean));
379
- await engine.run(prompt);
380
- /* answer is streamed live via text_delta / text events */
381
- }
382
- catch (reason) {
383
- const message = reason instanceof Error ? reason.message : String(reason);
384
- setError(message);
385
- setLines((current) => [...current, { role: 'error', text: message }]);
386
- }
387
- finally {
388
- setBusy(false);
389
- setCurrentTool(undefined);
390
- setToolCards((current) => current.map((card) => card.status === 'running' ? { ...card, status: 'error' } : card));
391
- runStartedAt.current = null;
392
- setLiveThink('');
393
- setLiveAnswer('');
394
- }
395
- })();
637
+ startRun(prompt, attachSpec);
396
638
  }
397
639
  return;
398
640
  case 'escape':
@@ -400,6 +642,10 @@ export function TerminalInterface({ engine }) {
400
642
  approve(false);
401
643
  return;
402
644
  }
645
+ if (sessionsOpen) {
646
+ setSessionsOpen(false);
647
+ return;
648
+ }
403
649
  if (pickerOpen) {
404
650
  setPickerOpen(false);
405
651
  return;
@@ -424,6 +670,10 @@ export function TerminalInterface({ engine }) {
424
670
  cycleMode();
425
671
  return;
426
672
  case 'up':
673
+ if (sessionsOpen) {
674
+ setSessionsIndex((current) => clamp(current - 1, 0, Math.max(0, sessionsList.length - 1)));
675
+ return;
676
+ }
427
677
  if (pickerOpen) {
428
678
  setPickerIndex((current) => clamp(current - 1, 0, Math.max(0, pickerItems.length - 1)));
429
679
  return;
@@ -435,6 +685,10 @@ export function TerminalInterface({ engine }) {
435
685
  setEdit((current) => ({ ...current, cursor: moveVerticalWrapped(current.value, current.cursor, -1, inputWidth) }));
436
686
  return;
437
687
  case 'down':
688
+ if (sessionsOpen) {
689
+ setSessionsIndex((current) => clamp(current + 1, 0, Math.max(0, sessionsList.length - 1)));
690
+ return;
691
+ }
438
692
  if (pickerOpen) {
439
693
  setPickerIndex((current) => clamp(current + 1, 0, Math.max(0, pickerItems.length - 1)));
440
694
  return;
@@ -474,10 +728,23 @@ export function TerminalInterface({ engine }) {
474
728
  // Legacy keys Ink parses correctly (\r, \n, \x08, ESC[A arrows, ctrl+letters) plus text and paste.
475
729
  useInput((value, key) => {
476
730
  if (approval) {
477
- if (value === 'y' || value === 'Y' || key.return)
478
- applyAction({ kind: 'submit' });
479
- else if (value === 'n' || value === 'N' || key.escape)
480
- applyAction({ kind: 'escape' });
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
+ }
481
748
  return;
482
749
  }
483
750
  if (key.ctrl && value === 'c') {
@@ -515,6 +782,25 @@ export function TerminalInterface({ engine }) {
515
782
  }
516
783
  return;
517
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
+ }
518
804
  if (key.pageUp || (key.ctrl && value.toLowerCase() === 'u')) {
519
805
  scrollTranscript(pageSizeRef.current);
520
806
  return;
@@ -523,6 +809,14 @@ export function TerminalInterface({ engine }) {
523
809
  scrollTranscript(-pageSizeRef.current);
524
810
  return;
525
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
+ }
526
820
  // Ctrl+T toggles the most recent tool card between collapsed and expanded.
527
821
  if (key.ctrl && value.toLowerCase() === 't') {
528
822
  const latest = toolCards[toolCards.length - 1];
@@ -609,8 +903,10 @@ export function TerminalInterface({ engine }) {
609
903
  const elapsed = busy
610
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`)
611
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;
612
908
  const contextLabel = metrics.compression.inputTokens > 0
613
- ? `${(metrics.compression.inputTokens / 1000).toFixed(1)}k in`
909
+ ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%`
614
910
  : '';
615
911
  const contentWidth = Math.max(20, width - 8);
616
912
  const terminalRows = stdout.rows ?? 24;
@@ -643,22 +939,37 @@ export function TerminalInterface({ engine }) {
643
939
  lines.forEach((line, lineIndex) => {
644
940
  let rendered = cache.rows.get(line);
645
941
  if (rendered === undefined) {
646
- const label = labelFor(line.role, line.model, line.toolName, fallback);
647
- const markdown = line.role !== 'tool' && line.role !== 'error';
648
- const wrapped = markdown
649
- ? renderMarkdown(line.text, contentWidth)
650
- : wrap(line.text, contentWidth).map((text) => [{ text }]);
651
- const next = [];
652
- wrapped.forEach((segments, rowIndex) => next.push({ key: `message-${lineIndex}-${rowIndex}`, role: line.role, segments, label, first: rowIndex === 0 }));
653
- if (line.saved)
654
- next.push({ key: `message-${lineIndex}-saved`, role: 'assistant', segments: [{ text: line.saved }], label: '', first: false, saved: line.saved });
655
- rendered = next;
942
+ rendered = renderLineToRows(line, lineIndex, contentWidth, fallback);
656
943
  cache.rows.set(line, rendered);
657
944
  }
658
945
  out.push(...rendered);
659
946
  });
660
947
  return out;
661
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]);
662
973
  const planRows = taskQueue.length > 0 ? 6 + Math.min(6, taskQueue.length) : 0;
663
974
  const pickerGroups = new Set(pickerItems.map((item) => item.group)).size;
664
975
  const pickerRows = pickerOpen ? 6 + pickerItems.length + pickerGroups + (pickerError || pickerItems.length === 0 ? 1 : 0) : 0;
@@ -674,6 +985,8 @@ export function TerminalInterface({ engine }) {
674
985
  const visibleLiveRows = liveHeight > 0 ? liveRows.slice(-liveHeight) : [];
675
986
  const maxScroll = Math.max(0, allRows.length - storedHeight);
676
987
  maxScrollRef.current = maxScroll;
988
+ allRowsRef.current = allRows;
989
+ storedHeightRef.current = storedHeight;
677
990
  pageSizeRef.current = Math.max(1, storedHeight - 2);
678
991
  const boundedScroll = clamp(scrollOffset, 0, maxScroll);
679
992
  const endRow = allRows.length - boundedScroll;
@@ -712,6 +1025,17 @@ export function TerminalInterface({ engine }) {
712
1025
  const id = setInterval(() => setNow(Date.now()), 250);
713
1026
  return () => clearInterval(id);
714
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]);
715
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
716
1040
  ? _jsx(Text, { dimColor: true, children: row.saved }, row.key)
717
1041
  : row.first
@@ -721,27 +1045,22 @@ export function TerminalInterface({ engine }) {
721
1045
  : _jsx(SegmentText, { segments: row.segments, role: row.role }, row.key)), toolCards.slice(-6).map((card) => {
722
1046
  const expanded = expandedTool === card.id;
723
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" });
724
- const target = card.target ? ` · ${clip(card.target, Math.max(10, contentWidth - 40))}` : '';
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))}` : ''}`;
725
1051
  const badge = expanded ? '▾' : '▸';
726
- return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [badge, " ", card.name, target, " \u00B7 ", status, expanded ? ' · Ctrl+T to collapse' : ''] }), expanded && (card.trail
727
- ? looksLikeDiff(card.trail)
728
- ? diffSegments(card.trail, contentWidth).slice(0, 12).map((segments, index) => _jsx(SegmentText, { segments: segments, role: "tool" }, index))
729
- // A file-edit trail is the new content; show it as added (green) lines.
730
- : card.trail.split('\n').slice(0, 12).map((line, index) => _jsx(Text, { color: PALETTE.success, children: line }, index))
731
- : card.summary
732
- ? _jsx(Text, { dimColor: true, children: clip(card.summary, contentWidth) })
733
- : _jsx(Text, { dimColor: true, children: "(no output)" }))] }, card.id);
734
- }), engine.state.preview && _jsxs(Text, { color: "green", dimColor: true, children: ["preview live \u00B7 ", engine.state.preview.url] }), busy && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " working in ", mode, " mode on ", engine.state.activeModel, currentTool ? ` · now ${currentTool}` : ''] })] }), taskQueue.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "plan" }), _jsxs(Text, { dimColor: true, children: [taskQueue.filter((item) => item.status === 'done').length, "/", taskQueue.length, " done"] })] }), taskQueue.slice(-6).map((item) => {
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) => {
735
1054
  const marker = item.status === 'done' ? '✓' : item.status === 'active' ? '▸' : '○';
736
1055
  const color = item.status === 'done' ? 'green' : item.status === 'active' ? 'cyan' : undefined;
737
1056
  return _jsxs(Text, { color: color, dimColor: item.status === 'done', children: [marker, " ", clip(item.title, contentWidth - 4)] }, item.id);
738
- })] }), pickerOpen && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Choose an OmniRoute model" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / j k navigate \u00B7 enter select \u00B7 esc close" }), pickerError && _jsx(Text, { color: "red", children: clip(pickerError, contentWidth) }), pickerItems.length === 0 && !pickerError && _jsx(Text, { dimColor: true, children: "No models returned by OmniRoute." }), pickerItems.map((item, index) => {
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) => {
739
1058
  const header = index === 0 || pickerItems[index - 1].group !== item.group
740
1059
  ? _jsx(Text, { dimColor: true, bold: true, children: item.group === 'combos' ? 'your combos' : 'auto engine' }, `h-${item.group}`)
741
1060
  : null;
742
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);
743
- })] }), approval && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Approve ", approval.tool, "?"] }), _jsxs(Text, { dimColor: true, children: ["args: ", clip(typeof approval.input === 'string' ? approval.input : JSON.stringify(approval.input), contentWidth)] }), _jsx(Text, { dimColor: true, children: "y approve \u00B7 n deny" })] }), _jsx(Box, { borderStyle: "round", borderColor: error ? 'red' : 'cyan', paddingX: 1, marginTop: 1, flexDirection: "column", children: edit.value === ''
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 === ''
744
1063
  ? _jsxs(Text, { color: "cyan", children: ["\u203A ", _jsx(Text, { dimColor: true, children: "type a task and press enter" })] })
745
- : editorLayout.lines.map((text, index) => _jsxs(Text, { color: "cyan", children: [index === 0 ? '› ' : ' ', text] }, index)) }), kitty !== null && _jsx(Text, { dimColor: true, children: kitty ? 'kitty protocol active — Shift+Enter makes a new line' : 'this terminal can\'t distinguish Shift+Enter from Enter — use Ctrl+J for a new line' }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: busy ? PALETTE.info : PALETTE.muted, children: [busy && _jsx(Spinner, { type: "dots" }), phase, elapsed ? ` · ${elapsed}` : ''] }), _jsx(Text, { color: PALETTE.muted, children: [engine.state.activeModel, mode, contextLabel, scrollStatus].filter(Boolean).join(' · ') })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Enter send \u00B7 Shift+Enter/Ctrl+J new line \u00B7 Ctrl+O models \u00B7 Ctrl+", modeKey, " mode \u00B7 Ctrl+C cancel/quit \u00B7 /help \u00B7 Ctrl+T tool"] }), hud.length > 0 && _jsx(Text, { dimColor: true, children: hud.join(' · ') })] })] })] });
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(' · ') })] })] })] });
746
1065
  }
747
1066
  //# sourceMappingURL=terminalInterface.js.map