neoctl 0.1.7 → 0.1.9

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.
@@ -23,7 +23,7 @@ import { planTool } from "../tools/builtins/plan-tool.js";
23
23
  import { createAgentTool, resumeAgentTask } from "../agents/agent-tool.js";
24
24
  import { createTaskTools } from "../tasks/task-tools.js";
25
25
  import { TaskStore } from "../tasks/task-store.js";
26
- import { isModelReasoningArgument, isValidReplCommandLine, parseReplCommand, helpText, replCommandDefinitions } from "./commands.js";
26
+ import { cliHelpText, isModelReasoningArgument, isValidReplCommandLine, parseCliReplCommandArgs, parseReplCommand, helpText, replCommandDefinitions } from "./commands.js";
27
27
  import { estimateMarkdownLineCount, markdownRenderKey, MarkdownText } from "./markdown-renderer.js";
28
28
  import { writeSessionMarkdownExport } from "../session/session-export.js";
29
29
  import { readClipboard } from "./clipboard.js";
@@ -86,14 +86,32 @@ function sumUsageTokens(left, right) {
86
86
  return undefined;
87
87
  return (left ?? 0) + (right ?? 0);
88
88
  }
89
- async function main() {
89
+ async function main(argv = process.argv.slice(2)) {
90
+ const initialCommand = parseCliReplCommandArgs(argv);
91
+ if (argv.length > 0 && !initialCommand) {
92
+ console.error(`Unknown or incomplete command: ${argv.join(" ")}\n\n${cliHelpText(binaryName())}`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ if (initialCommand?.definition.name === "/help") {
97
+ console.log(cliHelpText(binaryName()));
98
+ return;
99
+ }
90
100
  const runtime = await createRuntime();
91
- const instance = render(e(InkRepl, { runtime }), {
101
+ const instance = render(e(InkRepl, { runtime, initialCommandLine: initialCommand?.line }), {
92
102
  exitOnCtrlC: false,
93
103
  });
94
104
  await instance.waitUntilExit();
95
105
  console.log("bye.");
96
106
  }
107
+ function binaryName() {
108
+ const arg = process.argv[1];
109
+ if (!arg)
110
+ return "neo";
111
+ const parsed = path.parse(arg);
112
+ const name = parsed.name || "neo";
113
+ return name === "index" ? "neo" : name;
114
+ }
97
115
  function createTaskNotificationSource(taskStore) {
98
116
  return {
99
117
  collectUnnotifiedCompletions() {
@@ -205,6 +223,12 @@ function initialContextMetrics(model, messageCount, toolCount) {
205
223
  : undefined,
206
224
  };
207
225
  }
226
+ function activeBackgroundTasks(runtime) {
227
+ return runtime.taskStore.list().filter((task) => !runtime.taskStore.isTerminal(task));
228
+ }
229
+ function runningSessionIds(runs) {
230
+ return [...runs.keys()];
231
+ }
208
232
  function initialStatus(runtime, metrics = runtime.initialMetrics) {
209
233
  return {
210
234
  phase: "ready",
@@ -216,8 +240,8 @@ function initialStatus(runtime, metrics = runtime.initialMetrics) {
216
240
  activityTick: 0,
217
241
  };
218
242
  }
219
- function resetStatus(runtime) {
220
- return initialStatus(runtime, initialContextMetrics(runtime.engine.getModelSettings().model, runtime.engine.snapshot().messages, runtime.initialMetrics.toolCount));
243
+ async function resetStatus(runtime) {
244
+ return initialStatus(runtime, await runtime.engine.contextMetrics());
221
245
  }
222
246
  function setTerminalTitle(title, prefix = TERMINAL_TITLE_WORKING_PREFIX) {
223
247
  if (!stdout.isTTY)
@@ -346,7 +370,7 @@ function pushTextBlock(blocks, text) {
346
370
  function escapeRegExp(value) {
347
371
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
348
372
  }
349
- function InkRepl({ runtime }) {
373
+ function InkRepl({ runtime, initialCommandLine }) {
350
374
  const app = useApp();
351
375
  const lineId = useRef(0);
352
376
  const assistantLineId = useRef(undefined);
@@ -367,10 +391,15 @@ function InkRepl({ runtime }) {
367
391
  const [busy, setBusy] = useState(false);
368
392
  const [status, setStatus] = useState(() => initialStatus(runtime));
369
393
  const sessionTitleRef = useRef(sessionTerminalTitle(runtime.engine.snapshot().session));
370
- const [backgroundTaskCount, setBackgroundTaskCount] = useState(() => runtime.taskStore.activeCount());
394
+ const [backgroundTasks, setBackgroundTasks] = useState(() => activeBackgroundTasks(runtime));
395
+ const [backgroundSessionRuns, setBackgroundSessionRuns] = useState([]);
396
+ const backgroundSessionRunsRef = useRef(new Map());
397
+ const suppressReattachedStreamingRef = useRef(new Set());
398
+ const activePromptRunRef = useRef(undefined);
371
399
  const [animationTick, setAnimationTick] = useState(0);
372
400
  const [terminalTitlePrefix, setTerminalTitlePrefix] = useState(TERMINAL_TITLE_READY_PREFIX);
373
- const terminalTitleWorking = isActivePhase(status.phase) || backgroundTaskCount > 0;
401
+ const backgroundTaskCount = backgroundTasks.length;
402
+ const terminalTitleWorking = isActivePhase(status.phase) || backgroundTaskCount > 0 || backgroundSessionRuns.length > 0;
374
403
  const [sessionsBrowser, setSessionsBrowser] = useState(undefined);
375
404
  const inputRef = useRef(input);
376
405
  const queuedInputRef = useRef(undefined);
@@ -398,15 +427,15 @@ function InkRepl({ runtime }) {
398
427
  };
399
428
  }, []);
400
429
  useEffect(() => {
401
- if (!busy && backgroundTaskCount === 0)
430
+ if (!busy && backgroundTaskCount === 0 && backgroundSessionRuns.length === 0)
402
431
  return undefined;
403
432
  const interval = setInterval(() => setAnimationTick((current) => current + 1), REPL_ANIMATION_INTERVAL_MS);
404
433
  return () => clearInterval(interval);
405
- }, [busy, backgroundTaskCount]);
434
+ }, [busy, backgroundTaskCount, backgroundSessionRuns.length]);
406
435
  useEffect(() => {
407
- const updateBackgroundTaskCount = () => setBackgroundTaskCount(runtime.taskStore.activeCount());
408
- updateBackgroundTaskCount();
409
- return runtime.taskStore.subscribe(updateBackgroundTaskCount);
436
+ const updateBackgroundTasks = () => setBackgroundTasks(activeBackgroundTasks(runtime));
437
+ updateBackgroundTasks();
438
+ return runtime.taskStore.subscribe(updateBackgroundTasks);
410
439
  }, [runtime]);
411
440
  useEffect(() => {
412
441
  if (!terminalTitleWorking) {
@@ -559,7 +588,44 @@ function InkRepl({ runtime }) {
559
588
  const replaceLine = (id, patch) => {
560
589
  setLines((current) => current.map((line) => line.id === id ? { ...line, ...patch, renderedKey: undefined } : line));
561
590
  };
562
- const resumeSnapshot = (snapshot, metrics) => {
591
+ const syncBackgroundSessionRuns = () => {
592
+ setBackgroundSessionRuns([...backgroundSessionRunsRef.current.values()]);
593
+ };
594
+ const detachRunningForeground = (reason) => {
595
+ if (!busyRef.current)
596
+ return false;
597
+ const snapshot = runtime.engine.snapshot().session;
598
+ const sessionId = snapshot?.sessionId ?? `session-${Date.now().toString(36)}`;
599
+ const run = activePromptRunRef.current;
600
+ if (run && !backgroundSessionRunsRef.current.has(sessionId)) {
601
+ const backgroundRun = {
602
+ sessionId,
603
+ title: snapshot?.title,
604
+ reason,
605
+ startedAt: Date.now(),
606
+ engine: runtime.engine,
607
+ abortController: activeAbortController.current ?? new AbortController(),
608
+ promise: run,
609
+ };
610
+ backgroundSessionRunsRef.current.set(sessionId, backgroundRun);
611
+ syncBackgroundSessionRuns();
612
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
613
+ run.finally(() => {
614
+ backgroundSessionRunsRef.current.delete(sessionId);
615
+ suppressReattachedStreamingRef.current.delete(backgroundRun.engine);
616
+ syncBackgroundSessionRuns();
617
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
618
+ }).catch(() => undefined);
619
+ }
620
+ activeAbortController.current = undefined;
621
+ interruptArmed.current = false;
622
+ setQueuedPromptState(undefined);
623
+ setBusyState(false);
624
+ setStatus((current) => ({ ...current, phase: "ready", detail: undefined }));
625
+ append(systemLine(`Detached running ${sessionId} to background for ${reason}.`));
626
+ return true;
627
+ };
628
+ const resetForegroundView = (metrics) => {
563
629
  runtime.usage.reset();
564
630
  setStatus(initialStatus(runtime, metrics));
565
631
  resetLinesToHistory(runtime, setLines, lineId);
@@ -568,8 +634,27 @@ function InkRepl({ runtime }) {
568
634
  finalizedThinkingLineId.current = undefined;
569
635
  toolLineIds.current.clear();
570
636
  clearPendingToolResultTimers();
637
+ };
638
+ const resumeSnapshot = (snapshot, metrics) => {
639
+ resetForegroundView(metrics);
571
640
  append(systemLine(formatResume(snapshot)));
572
641
  };
642
+ const reattachRunningSession = async (run) => {
643
+ detachRunningForeground("session switch");
644
+ backgroundSessionRunsRef.current.delete(run.sessionId);
645
+ syncBackgroundSessionRuns();
646
+ setSessionsBrowser((current) => current ? { ...current, runningSessionIds: runningSessionIds(backgroundSessionRunsRef.current) } : current);
647
+ runtime.engine = run.engine;
648
+ activeAbortController.current = run.abortController;
649
+ interruptArmed.current = false;
650
+ activePromptRunRef.current = run.promise;
651
+ suppressReattachedStreamingRef.current.add(run.engine);
652
+ const metrics = await runtime.engine.contextMetrics();
653
+ resetForegroundView(metrics);
654
+ setBusyState(true);
655
+ setStatus((current) => ({ ...current, phase: "running", detail: "working" }));
656
+ append(systemLine(`reattached running session ${run.sessionId}`));
657
+ };
573
658
  const finalizeLiveLine = (id) => {
574
659
  if (id === undefined)
575
660
  return;
@@ -719,13 +804,7 @@ function InkRepl({ runtime }) {
719
804
  return;
720
805
  }
721
806
  if (busyRef.current) {
722
- if (queuedInputRef.current !== undefined)
723
- return;
724
- setQueuedPromptState(text, submitAttachments);
725
- setHistorySelection(undefined);
726
- setPromptState("", 0);
727
- clearAttachments();
728
- return;
807
+ detachRunningForeground("new prompt");
729
808
  }
730
809
  history.current = [text, ...history.current.filter((entry) => entry !== text)].slice(0, 100);
731
810
  setHistorySelection(undefined);
@@ -823,12 +902,13 @@ function InkRepl({ runtime }) {
823
902
  if (command.type === "reset") {
824
903
  runtime.engine.reset();
825
904
  runtime.usage.reset();
826
- setStatus(resetStatus(runtime));
905
+ setStatus(await resetStatus(runtime));
827
906
  append(systemLine("transcript reset"));
828
907
  return;
829
908
  }
830
909
  if (command.type === "state") {
831
- append(systemLine(formatReplData({ ...runtime.engine.snapshot(), communicationLog: runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
910
+ const contextMetrics = await runtime.engine.contextMetrics();
911
+ append(systemLine(formatReplData({ ...runtime.engine.snapshot(), contextMetrics, communicationLog: runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
832
912
  return;
833
913
  }
834
914
  if (command.type === "export") {
@@ -859,8 +939,25 @@ function InkRepl({ runtime }) {
859
939
  }
860
940
  return;
861
941
  }
942
+ if (command.type === "new") {
943
+ detachRunningForeground("new session");
944
+ runtime.engine = runtime.engine.forkForSession(undefined, false);
945
+ await runtime.engine.initialize();
946
+ const snapshot = runtime.engine.snapshot().session;
947
+ const metrics = await runtime.engine.contextMetrics();
948
+ runtime.usage.reset();
949
+ setStatus(initialStatus(runtime, metrics));
950
+ resetLinesToHistory(runtime, setLines, lineId);
951
+ assistantLineId.current = undefined;
952
+ thinkingLineId.current = undefined;
953
+ finalizedThinkingLineId.current = undefined;
954
+ toolLineIds.current.clear();
955
+ clearPendingToolResultTimers();
956
+ append(systemLine(snapshot ? `new session ${snapshot.sessionId}` : "new session"));
957
+ return;
958
+ }
862
959
  if (command.type === "sessions") {
863
- await handleSessionsCommand(runtime, setSessionsBrowser, (line) => append(line));
960
+ await handleSessionsCommand(runtime, runningSessionIds(backgroundSessionRunsRef.current), setSessionsBrowser, (line) => append(line));
864
961
  return;
865
962
  }
866
963
  if (command.type === "login") {
@@ -916,20 +1013,41 @@ function InkRepl({ runtime }) {
916
1013
  outputTokenUpdatedAt: undefined,
917
1014
  retryCooldownUntil: undefined,
918
1015
  }));
919
- try {
920
- for await (const event of runtime.engine.sendUserText(promptPayload.text, { abortSignal: abortController.signal, blocks: promptPayload.blocks, displayText: text })) {
1016
+ const engine = runtime.engine;
1017
+ const run = (async () => {
1018
+ for await (const event of engine.sendUserText(promptPayload.text, { abortSignal: abortController.signal, blocks: promptPayload.blocks, displayText: text })) {
1019
+ if (runtime.engine !== engine)
1020
+ continue;
1021
+ if (suppressReattachedStreamingRef.current.has(engine)) {
1022
+ if (event.type === "message" || event.type === "terminal" || event.type === "error" || event.type === "context.metrics" || event.type === "usage") {
1023
+ if (event.type === "message" || event.type === "terminal" || event.type === "error")
1024
+ suppressReattachedStreamingRef.current.delete(engine);
1025
+ handleEvent(event);
1026
+ }
1027
+ continue;
1028
+ }
921
1029
  handleEvent(event);
922
1030
  }
1031
+ })();
1032
+ activePromptRunRef.current = run;
1033
+ try {
1034
+ await run;
923
1035
  }
924
1036
  catch (error) {
925
- finalizeLiveLine(assistantLineId.current);
926
- finalizeThinkingLine();
927
- finalizeActiveToolLines();
928
- assistantLineId.current = undefined;
929
- finalizedThinkingLineId.current = undefined;
930
- append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
1037
+ if (runtime.engine === engine) {
1038
+ finalizeLiveLine(assistantLineId.current);
1039
+ finalizeThinkingLine();
1040
+ finalizeActiveToolLines();
1041
+ assistantLineId.current = undefined;
1042
+ finalizedThinkingLineId.current = undefined;
1043
+ append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
1044
+ }
931
1045
  }
932
1046
  finally {
1047
+ if (activePromptRunRef.current === run)
1048
+ activePromptRunRef.current = undefined;
1049
+ if (runtime.engine !== engine)
1050
+ return;
933
1051
  if (activeAbortController.current === abortController)
934
1052
  activeAbortController.current = undefined;
935
1053
  interruptArmed.current = false;
@@ -969,6 +1087,11 @@ function InkRepl({ runtime }) {
969
1087
  setQueuedPromptState(undefined);
970
1088
  setPromptState("", 0);
971
1089
  }, [runtime]);
1090
+ useEffect(() => {
1091
+ if (initialCommandLine === undefined)
1092
+ return;
1093
+ void submitLine(initialCommandLine);
1094
+ }, []);
972
1095
  const terminalSize = useTerminalSize();
973
1096
  const width = terminalSize.columns;
974
1097
  const inputLockedByQueue = busy && queuedInput !== undefined;
@@ -995,7 +1118,7 @@ function InkRepl({ runtime }) {
995
1118
  const blockIndex = staticLines.length + i;
996
1119
  return sum + (blockIndex > 0 ? MESSAGE_BLOCK_SPACING_LINES : 0);
997
1120
  }, 0);
998
- const statusRenderRows = STATUS_BAR_RENDER_ROWS + (backgroundTaskCount > 0 ? BACKGROUND_TASK_STATUS_RENDER_ROWS : 0);
1121
+ const statusRenderRows = STATUS_BAR_RENDER_ROWS + backgroundTaskStatusRenderRows(backgroundTasks.length);
999
1122
  const sessionsBrowserHeight = sessionsBrowser ? sessionsBrowserViewHeight(sessionsBrowser) : 0;
1000
1123
  const loginFormHeight = loginForm ? loginFormViewHeight(loginForm) : 0;
1001
1124
  const liveViewportLines = Math.max(MIN_LIVE_VIEWPORT_LINES, terminalSize.rows - promptHeight - statusRenderRows - sessionsBrowserHeight - loginFormHeight - dynamicMarginOverhead - 1);
@@ -1075,10 +1198,17 @@ function InkRepl({ runtime }) {
1075
1198
  const selected = sessionsBrowser.sessions[sessionAbsoluteIndex(sessionsBrowser)];
1076
1199
  if (selected) {
1077
1200
  setSessionsBrowser(undefined);
1078
- void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((result) => {
1079
- if (result)
1080
- resumeSnapshot(result.snapshot, result.metrics);
1081
- });
1201
+ const running = backgroundSessionRunsRef.current.get(selected.sessionId);
1202
+ if (running) {
1203
+ void reattachRunningSession(running);
1204
+ }
1205
+ else {
1206
+ detachRunningForeground("session switch");
1207
+ void handleResumeCommand(selected.sessionId, runtime, (line) => append(line)).then((result) => {
1208
+ if (result)
1209
+ resumeSnapshot(result.snapshot, result.metrics);
1210
+ });
1211
+ }
1082
1212
  }
1083
1213
  return;
1084
1214
  }
@@ -1220,7 +1350,7 @@ function InkRepl({ runtime }) {
1220
1350
  return;
1221
1351
  }
1222
1352
  });
1223
- return e(Box, { flexDirection: "column" }, e((Static), { items: staticLines, children: (line, index) => e(MessageBlock, { key: line.id, line, width, blockIndex: index }) }), e(MessageList, { lines: dynamicLines, width, liveMaxLines: liveViewportLines, lineIndexOffset: staticLines.length, onMarkdownRenderComplete: markLineRendered }), sessionsBrowser ? e(SessionsBrowser, { state: sessionsBrowser, width }) : null, loginForm ? e(LoginFormView, { state: loginForm, width }) : null, e(StatusBar, { status, animationTick, width }), backgroundTaskCount > 0 ? e(BackgroundTaskStatusLine, { count: backgroundTaskCount, width }) : null, pasteStatus ? e(PasteStatusLine, { text: pasteStatus, width }) : null, queuedInput !== undefined ? e(QueuedInputLine, { text: queuedInput, width }) : null, e(PromptLine, { text: promptDisplayText, cursor: promptDisplayCursor, busy, locked: inputLockedByQueue, placeholder: input.length === 0 && promptPlaceholder !== undefined, ghostText: activePlaceholder, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }));
1353
+ return e(Box, { flexDirection: "column" }, e((Static), { items: staticLines, children: (line, index) => e(MessageBlock, { key: line.id, line, width, blockIndex: index }) }), e(MessageList, { lines: dynamicLines, width, liveMaxLines: liveViewportLines, lineIndexOffset: staticLines.length, onMarkdownRenderComplete: markLineRendered }), sessionsBrowser ? e(SessionsBrowser, { state: sessionsBrowser, width }) : null, loginForm ? e(LoginFormView, { state: loginForm, width }) : null, e(StatusBar, { status, animationTick, width }), backgroundTasks.length > 0 ? e(BackgroundTaskStatusLine, { tasks: backgroundTasks, width }) : null, pasteStatus ? e(PasteStatusLine, { text: pasteStatus, width }) : null, queuedInput !== undefined ? e(QueuedInputLine, { text: queuedInput, width }) : null, e(PromptLine, { text: promptDisplayText, cursor: promptDisplayCursor, busy, locked: inputLockedByQueue, placeholder: input.length === 0 && promptPlaceholder !== undefined, ghostText: activePlaceholder, width, prompt, slashCompletions, selectedSlashCompletionIndex, attachments }));
1224
1354
  }
1225
1355
  const MessageList = React.memo(function MessageList({ lines, width, liveMaxLines, lineIndexOffset = 0, onMarkdownRenderComplete }) {
1226
1356
  const contentWidth = messageContentWidth(width);
@@ -1572,10 +1702,27 @@ function StatusBar({ status, animationTick, width: terminalWidth }) {
1572
1702
  const segments = fitStatusSegments(renderCompactStatusSegments(status, animationTick, width, inputTokens, outputTokens, displayPhase), width);
1573
1703
  return e(Box, { marginTop: 1, width, height: 1, overflow: "hidden" }, ...segments.map((segment, index) => e(Text, { key: index, color: segment.color ?? "gray", bold: segment.bold ?? false }, segment.text)));
1574
1704
  }
1575
- function BackgroundTaskStatusLine({ count, width: terminalWidth }) {
1705
+ function backgroundTaskStatusRenderRows(taskCount) {
1706
+ if (taskCount <= 0)
1707
+ return 0;
1708
+ return 1 + Math.min(taskCount, 2);
1709
+ }
1710
+ function BackgroundTaskStatusLine({ tasks, width: terminalWidth }) {
1576
1711
  const width = statusBarWidth(terminalWidth);
1577
- const text = count <= 3 ? "".repeat(Math.max(0, count)) : `◇×${count}`;
1578
- return e(Box, { width, height: 1, overflow: "hidden" }, e(Text, { color: "yellow" }, fitToWidth(text, width)));
1712
+ const summary = `◇ background tools: ${tasks.length} task${tasks.length === 1 ? "" : "s"}`;
1713
+ const detailTasks = tasks.slice(0, 2);
1714
+ return e(Box, { flexDirection: "column", width, overflow: "hidden" }, e(Text, { color: "yellow" }, fitToWidth(summary, width)), ...detailTasks.map((task) => e(Text, { key: task.taskId, color: "yellow" }, fitToWidth(` ${task.type}:${truncateMiddle(task.description || task.agentId || task.taskId, Math.max(12, width - 30))} · ${task.status} · ${formatElapsed(Date.now() - new Date(task.createdAt).getTime())}`, width))));
1715
+ }
1716
+ function formatElapsed(ms) {
1717
+ const seconds = Math.max(0, Math.floor(ms / 1000));
1718
+ if (seconds < 60)
1719
+ return `${seconds}s`;
1720
+ const minutes = Math.floor(seconds / 60);
1721
+ const remainder = seconds % 60;
1722
+ if (minutes < 60)
1723
+ return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
1724
+ const hours = Math.floor(minutes / 60);
1725
+ return `${hours}h${(minutes % 60).toString().padStart(2, "0")}m`;
1579
1726
  }
1580
1727
  function renderCompactStatusSegments(status, animationTick, width, inputTokens, outputTokens, displayPhase = status.phase) {
1581
1728
  const phase = displayPhase;
@@ -1586,7 +1733,7 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1586
1733
  const context = renderContextParts(status.metrics);
1587
1734
  const fixedText = [
1588
1735
  phaseText,
1589
- `ctx ${context.percent} of ${context.limit}`,
1736
+ context.percent,
1590
1737
  `↑ ${inputValue}`,
1591
1738
  `↓ ${outputValue}`,
1592
1739
  ].join(STATUS_SEPARATOR);
@@ -1603,9 +1750,7 @@ function renderCompactStatusSegments(status, animationTick, width, inputTokens,
1603
1750
  statusDividerSegment(),
1604
1751
  { text: model },
1605
1752
  statusDividerSegment(),
1606
- statusLabelSegment("ctx"),
1607
- { text: ` ${context.percent}`, color: contextColor(status.metrics) },
1608
- { text: ` of ${context.limit}` },
1753
+ { text: context.percent, color: contextColor(status.metrics) },
1609
1754
  statusDividerSegment(),
1610
1755
  statusLabelSegment("↑", tokenInputColor),
1611
1756
  { text: ` ${inputValue}` },
@@ -2144,14 +2289,14 @@ function reduceStatus(status, event) {
2144
2289
  }
2145
2290
  return status;
2146
2291
  }
2147
- async function handleSessionsCommand(runtime, setBrowser, append) {
2292
+ async function handleSessionsCommand(runtime, runningSessionIds, setBrowser, append) {
2148
2293
  const sessions = await runtime.engine.listSessions(Number.POSITIVE_INFINITY);
2149
2294
  if (sessions.length === 0) {
2150
2295
  setBrowser(undefined);
2151
2296
  append(systemLine("No saved sessions found."));
2152
2297
  return;
2153
2298
  }
2154
- setBrowser({ sessions, pageSize: SESSIONS_DEFAULT_PAGE_SIZE, pageIndex: 0, selectedIndex: 0 });
2299
+ setBrowser({ sessions, runningSessionIds, pageSize: SESSIONS_DEFAULT_PAGE_SIZE, pageIndex: 0, selectedIndex: 0 });
2155
2300
  }
2156
2301
  async function handleExportCommand(command, runtime) {
2157
2302
  const snapshot = runtime.engine.snapshot();
@@ -2169,7 +2314,11 @@ async function handleExportCommand(command, runtime) {
2169
2314
  }
2170
2315
  async function handleResumeCommand(sessionId, runtime, append) {
2171
2316
  try {
2172
- const snapshot = await runtime.engine.resumeSession(sessionId);
2317
+ runtime.engine = runtime.engine.forkForSession(sessionId, true);
2318
+ await runtime.engine.initialize();
2319
+ const snapshot = runtime.engine.snapshot().session;
2320
+ if (!snapshot)
2321
+ throw new Error("session transcripts are disabled");
2173
2322
  const metrics = await runtime.engine.contextMetrics();
2174
2323
  return { snapshot, metrics };
2175
2324
  }
@@ -2196,6 +2345,7 @@ async function handleDeleteSessionCommand(sessionId, current, runtime, setBrowse
2196
2345
  setBrowser({
2197
2346
  ...current,
2198
2347
  sessions: nextSessions,
2348
+ runningSessionIds: current.runningSessionIds.filter((id) => id !== sessionId),
2199
2349
  pageIndex,
2200
2350
  selectedIndex: Math.min(current.selectedIndex, Math.max(0, pageLength - 1)),
2201
2351
  });
@@ -2342,7 +2492,7 @@ function SessionsBrowser({ state, width }) {
2342
2492
  return e(Box, { flexDirection: "column", marginTop: 1 }, e(Text, { color: "cyan", bold: true }, fitToWidth(header, contentWidth)), ...pageItems.map((session, index) => {
2343
2493
  const selected = index === state.selectedIndex;
2344
2494
  const absoluteIndex = state.pageIndex * state.pageSize + index;
2345
- const row = formatSessionBrowserRow(session, absoluteIndex, contentWidth);
2495
+ const row = formatSessionBrowserRow(session, absoluteIndex, contentWidth, state.runningSessionIds.includes(session.sessionId));
2346
2496
  return e(Text, { key: session.sessionId, color: "white" }, e(Text, {
2347
2497
  color: selected ? "black" : "white",
2348
2498
  backgroundColor: selected ? "cyan" : undefined,
@@ -2685,16 +2835,17 @@ function stripEnvQuotes(value) {
2685
2835
  return value.slice(1, -1);
2686
2836
  return value;
2687
2837
  }
2688
- function formatSessionBrowserRow(session, absoluteIndex, width) {
2838
+ function formatSessionBrowserRow(session, absoluteIndex, width, running = false) {
2689
2839
  const numberPrefix = `${absoluteIndex + 1}.`.padStart(4);
2690
2840
  const title = session.title?.trim() || "(untitled)";
2841
+ const runningTag = running ? " · running" : "";
2691
2842
  const updated = session.updatedAt ? ` · ${formatSessionTimestamp(session.updatedAt)}` : "";
2692
2843
  const messages = ` · ${session.messages} messages`;
2693
- const fixedParts = `${numberPrefix} ${updated}${messages}`;
2844
+ const fixedParts = `${numberPrefix} ${runningTag}${updated}${messages}`;
2694
2845
  const idBudget = Math.max(12, Math.min(32, Math.floor(width * 0.28)));
2695
2846
  const id = truncateMiddle(session.sessionId, idBudget);
2696
2847
  const titleBudget = Math.max(8, width - fixedParts.length - id.length - 5);
2697
- const row = fitToWidth(`${numberPrefix} ${truncateMiddle(title, titleBudget)} · ${id}${updated}${messages}`, width);
2848
+ const row = fitToWidth(`${numberPrefix} ${truncateMiddle(title, titleBudget)} · ${id}${runningTag}${updated}${messages}`, width);
2698
2849
  return { numberPrefix, rest: row.slice(numberPrefix.length) };
2699
2850
  }
2700
2851
  function formatSessionTimestamp(value) {
@@ -2978,26 +3129,11 @@ function isReplScalar(value) {
2978
3129
  return value === null || value === undefined || typeof value !== "object" || value instanceof Date;
2979
3130
  }
2980
3131
  function formatToolResult(toolName, output, ok) {
2981
- if (toolName === "edit" && isRecord(output) && isEditToolOutput(output)) {
3132
+ if ((toolName === "edit" || toolName === "write") && isRecord(output) && isEditToolOutput(output)) {
2982
3133
  return { text: formatEditToolDiff(output, ok), format: "ansi", summaryMaxLines: EDIT_TOOL_SUMMARY_MAX_LINES };
2983
3134
  }
2984
3135
  if (isExecOutput(output)) {
2985
- const status = output.timedOut
2986
- ? "timed out"
2987
- : output.exitCode === 0
2988
- ? "exit 0"
2989
- : `exit ${output.exitCode ?? output.signal ?? "unknown"}`;
2990
- const sections = [
2991
- `${status} · ${output.durationMs}ms`,
2992
- `$ ${output.command}`,
2993
- ];
2994
- if (output.stdout)
2995
- sections.push("stdout:", output.stdout.replace(/\s+$/u, ""));
2996
- if (output.stderr)
2997
- sections.push("stderr:", output.stderr.replace(/\s+$/u, ""));
2998
- if (!output.stdout && !output.stderr)
2999
- sections.push(ok ? "no output" : "no captured output");
3000
- return { text: sections.join("\n"), format: "ansi" };
3136
+ return { text: formatExecToolResult(output, ok), format: "ansi", summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
3001
3137
  }
3002
3138
  if (typeof output === "string" && hasAnsi(output)) {
3003
3139
  return { text: output, format: "ansi" };
@@ -3104,6 +3240,28 @@ function isExecOutput(value) {
3104
3240
  typeof record.stdout === "string" &&
3105
3241
  typeof record.stderr === "string");
3106
3242
  }
3243
+ function formatExecToolResult(output, ok) {
3244
+ const status = output.timedOut
3245
+ ? "timed out"
3246
+ : output.exitCode === 0
3247
+ ? "exit 0"
3248
+ : `exit ${output.exitCode ?? output.signal ?? "unknown"}`;
3249
+ const lines = [
3250
+ "exec result",
3251
+ `status: ${status}`,
3252
+ `duration: ${output.durationMs}ms`,
3253
+ `command: ${output.command}`,
3254
+ ];
3255
+ const stdout = output.stdout.replace(/\s+$/u, "");
3256
+ const stderr = output.stderr.replace(/\s+$/u, "");
3257
+ if (stdout)
3258
+ lines.push("stdout:", stdout);
3259
+ if (stderr)
3260
+ lines.push("stderr:", stderr);
3261
+ if (!stdout && !stderr)
3262
+ lines.push(ok ? "output: (none)" : "output: (not captured)");
3263
+ return lines.join("\n");
3264
+ }
3107
3265
  function isRecord(value) {
3108
3266
  return !!value && typeof value === "object" && !Array.isArray(value);
3109
3267
  }
@@ -3254,11 +3412,9 @@ function formatGrepContextLine(line, marker) {
3254
3412
  }
3255
3413
  function renderContextParts(metrics) {
3256
3414
  if (!metrics)
3257
- return { used: "?", limit: "?", percent: "?" };
3258
- const used = compactNumber(metrics.estimatedInputTokens);
3259
- const limit = metrics.contextWindowTokens ? compactNumber(metrics.contextWindowTokens) : "?";
3415
+ return { percent: "?" };
3260
3416
  const percent = metrics.contextUsageRatio === undefined ? "?" : `${(metrics.contextUsageRatio * 100).toFixed(1)}%`;
3261
- return { used, limit, percent };
3417
+ return { percent };
3262
3418
  }
3263
3419
  function contextColor(metrics) {
3264
3420
  const ratio = metrics?.contextUsageRatio;