@itookit/dsht 0.3.2 → 0.3.4

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.
Files changed (55) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -20
  3. package/README.zh.md +21 -20
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -126
  7. package/dist/controller/controller.d.ts +18 -1
  8. package/dist/controller/controller.js +24 -2
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/config.d.ts +17 -0
  12. package/dist/cost/config.js +68 -0
  13. package/dist/cost/controller.d.ts +9 -2
  14. package/dist/cost/controller.js +10 -2
  15. package/dist/cost/index.d.ts +5 -4
  16. package/dist/cost/index.js +4 -3
  17. package/dist/cost/ledger-files.d.ts +4 -8
  18. package/dist/cost/ledger-files.js +46 -71
  19. package/dist/cost/ledger.d.ts +33 -20
  20. package/dist/cost/ledger.js +78 -68
  21. package/dist/cost/pricing.d.ts +55 -17
  22. package/dist/cost/pricing.js +126 -44
  23. package/dist/cost/records.js +18 -5
  24. package/dist/cost/types.d.ts +34 -18
  25. package/dist/cost/types.js +4 -0
  26. package/dist/session/controller.d.ts +16 -0
  27. package/dist/session/controller.js +33 -0
  28. package/dist/session/navigation.d.ts +80 -0
  29. package/dist/session/navigation.js +107 -0
  30. package/dist/session/transcript.d.ts +48 -6
  31. package/dist/session/transcript.js +117 -14
  32. package/dist/storage/files.d.ts +8 -0
  33. package/dist/storage/files.js +17 -0
  34. package/dist/storage/heap-snapshot.d.ts +19 -0
  35. package/dist/storage/heap-snapshot.js +29 -0
  36. package/dist/storage/index.d.ts +2 -1
  37. package/dist/storage/index.js +2 -1
  38. package/dist/ui/app.js +116 -10
  39. package/dist/ui/chat/history-view.d.ts +5 -1
  40. package/dist/ui/chat/history-view.js +9 -4
  41. package/dist/ui/chat/status.d.ts +16 -4
  42. package/dist/ui/chat/status.js +112 -32
  43. package/dist/ui/commands/parse.d.ts +3 -0
  44. package/dist/ui/commands/parse.js +5 -0
  45. package/dist/ui/commands/registry.js +1 -0
  46. package/dist/ui/dialogs/cost.js +3 -3
  47. package/dist/ui/dialogs/index.d.ts +6 -2
  48. package/dist/ui/dialogs/index.js +3 -3
  49. package/dist/ui/dialogs/picker.d.ts +21 -3
  50. package/dist/ui/dialogs/picker.js +37 -5
  51. package/dist/ui/input/input.d.ts +18 -3
  52. package/dist/ui/input/input.js +61 -22
  53. package/dist/ui/input/viewport.d.ts +96 -0
  54. package/dist/ui/input/viewport.js +173 -0
  55. package/package.json +3 -3
package/dist/ui/app.js CHANGED
@@ -19,7 +19,7 @@ import { CopyMode } from "./copy-mode.js";
19
19
  import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
20
20
  import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
21
21
  import { classifySubmission } from "./commands/parse.js";
22
- import { sessionLabel } from "../session/navigation.js";
22
+ import { ROLLUP_LEGEND, sessionLabel, sessionStatus, workspaceCounts, workspaceDetail, workspaceSegments, workspaceStatus } from "../session/navigation.js";
23
23
  import { array, errorText, object, safeText, string } from "../transport/wire.js";
24
24
  /** Transient notices expire; interactive panels remain open until dismissed. */
25
25
  const PANEL_LIFETIME_MS = 10_000;
@@ -125,6 +125,14 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
125
125
  const timer = setTimeout(() => setNotice(undefined), panelLifetimeMs);
126
126
  return () => clearTimeout(timer);
127
127
  }, [notice, panelLifetimeMs, copyMode]);
128
+ // `/history` is a lookup rather than a panel to read, so it expires with the notice lifetime;
129
+ // Esc closes it sooner, and its own content search stays open until the reader leaves it.
130
+ useEffect(() => {
131
+ if (copyMode || contentSearch || historyQuery === undefined)
132
+ return;
133
+ const timer = setTimeout(() => { setHistoryQuery(undefined); setHistoryMatches(undefined); }, panelLifetimeMs);
134
+ return () => clearTimeout(timer);
135
+ }, [historyQuery, contentSearch, panelLifetimeMs, copyMode]);
128
136
  const pending = state.pending[0];
129
137
  const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
130
138
  useEffect(() => { setQueueOpen(false); }, [state.sessionId, pending?.eventId]);
@@ -185,6 +193,31 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
185
193
  // leave the arrows with the history, and Ctrl+P/N reach it from every surface.
186
194
  const recallBlocked = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || (statusExpanded && statusOverflow));
187
195
  const questionKeysActive = !!question && options.length > 0 && !choiceState.custom && !copyMode && !panelBlocksKeys;
196
+ // A dialog that demands an answer owns the keyboard until it is settled: the draft being written is
197
+ // parked rather than typed into, and comes back when the last one closes. A question switches to
198
+ // the composer itself for a free-text or "Other" answer, so it keeps the keys then.
199
+ const answerPending = !!pending && (pending.event === 'approval/request' || options.length > 0 && !choiceState.custom);
200
+ const parkedDraft = useRef('');
201
+ useEffect(() => {
202
+ if (answerPending) {
203
+ if (draft.current === '')
204
+ return;
205
+ parkedDraft.current = draft.current;
206
+ draft.current = '';
207
+ updateInput('');
208
+ setCursor(0);
209
+ return;
210
+ }
211
+ if (pending || parkedDraft.current === '')
212
+ return;
213
+ // Restoring writes the states directly: the draft was neither recalled from history nor a new
214
+ // message, so it must not move a reader's scroll position.
215
+ const restore = parkedDraft.current;
216
+ parkedDraft.current = '';
217
+ draft.current = restore;
218
+ updateInput(restore);
219
+ setCursor(restore.length);
220
+ }, [answerPending, pending]);
188
221
  const approvalKeysActive = pending?.event === 'approval/request' && !copyMode && !panelBlocksKeys;
189
222
  const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
190
223
  const answerQuestion = async (selected, custom) => {
@@ -222,13 +255,17 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
222
255
  }
223
256
  if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
224
257
  historyAbort.current.abort();
258
+ // Esc also leaves a history list that was already on screen when the load was aborted.
259
+ if (key.escape && historyQuery !== undefined) {
260
+ setHistoryQuery(undefined);
261
+ setHistoryMatches(undefined);
262
+ }
225
263
  return;
226
264
  }
227
265
  if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
228
266
  if (key.ctrl && draft.current)
229
267
  setInput('');
230
268
  if (key.escape) {
231
- setOptionState({ ...choiceState, custom: false });
232
269
  setApprovalSelection(undefined);
233
270
  setRemoval(undefined);
234
271
  setModels(undefined);
@@ -239,6 +276,18 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
239
276
  setHelp(false);
240
277
  setCostExpanded(false);
241
278
  setStatusExpanded(false);
279
+ // A question batch is one request, so Esc leaves the whole set the way the Web client's
280
+ // close button does. The free-text row keeps its own step back: its first Esc returns to
281
+ // the options, and only the next one dismisses. Approval keeps every choice explicit.
282
+ if (pending?.event === 'user-questions/request' && !choiceState.custom) {
283
+ if (controller.state.online && !controller.state.busy && controller.state.pending[0]?.eventId === eventId) {
284
+ setOptionState(undefined);
285
+ setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
286
+ operate(() => controller.dismissQuestion());
287
+ }
288
+ return;
289
+ }
290
+ setOptionState({ ...choiceState, custom: false });
242
291
  }
243
292
  return;
244
293
  }
@@ -335,6 +384,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
335
384
  void controller.interrupt(true);
336
385
  return;
337
386
  }
387
+ // The typed host path is a screen of its own, so Esc has to leave it: the picker behind it is
388
+ // disabled while a draft exists, so a leftover path would leave no way back at all.
389
+ if (key.escape && state.screen === 'path') {
390
+ setInput('');
391
+ operate(() => controller.showPicker('workspaces'));
392
+ return;
393
+ }
338
394
  if (key.ctrl && _value === 'c') {
339
395
  // A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
340
396
  if (input !== '') {
@@ -555,6 +611,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
555
611
  case 'exportHtml':
556
612
  await historyOperation(async (signal) => { setNotice(`Saved loaded conversation: ${await controller.exportHtml(submission.destination, signal)}`); }, 'Exporting loaded conversation…');
557
613
  return;
614
+ case 'coredump':
615
+ // V8 serializes the heap synchronously, so the client stalls until the file is written;
616
+ // the path is reported afterwards so the snapshot can be opened in DevTools.
617
+ await historyOperation(async () => {
618
+ setNotice(`Heap snapshot saved: ${controller.heapSnapshot(submission.tag)}`);
619
+ }, 'Writing heap snapshot…');
620
+ return;
558
621
  case 'answer':
559
622
  await answerQuestion(question.multiSelect === true ? choiceState.selected : [], submission.text);
560
623
  return;
@@ -569,22 +632,59 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
569
632
  if (accepted)
570
633
  setInput('');
571
634
  };
635
+ const width = Math.max(10, (stdout.columns ?? 80) - 2);
636
+ // The composer never trades the conversation away for input. Its window grows with the body and
637
+ // is clamped so a few conversation rows always survive; width never buys height, so landscape and
638
+ // a wide desktop only wrap less instead of showing more input rows. The budget subtracts the one
639
+ // row the frame, the header, the status bar and a transient notice/suggestion line may each take.
640
+ const bodyRows = Math.max(4, (stdout.rows ?? 30) - 4 - statusBarRows);
641
+ const composerRows = Math.max(1, Math.min(Math.min(5, Math.max(2, Math.floor(bodyRows / 3))), bodyRows - 7));
642
+ const draftWidth = Math.max(1, width - 6);
643
+ // Session activity is a list-level fact, so both pickers read it without loading any history.
644
+ const listAge = Date.now();
645
+ // Unanswered interactions are held per session already; a list only has to read the counts.
646
+ const pendingCounts = controller.pendingCounts();
647
+ const isPending = (session) => (pendingCounts.get(string(session.sessionId)) ?? 0) > 0;
648
+ const sessionsOf = (workspace) => {
649
+ const ids = new Set(array(workspace.sessionIds).map(string));
650
+ return state.sessions.filter(session => ids.has(string(session.sessionId)));
651
+ };
652
+ // `width` counts only the frame's outer padding, so the list subtracts the composer border and
653
+ // padding it renders inside; measured against the wider value a long row would still wrap.
654
+ const pickerWidth = Math.max(16, width - 4);
655
+ // Words explain themselves. Badges fit a narrow terminal but need the marker key beside them.
656
+ const rollupStyle = pickerWidth >= 80 ? 'words' : 'badges';
657
+ // Needs-you is the one state that asks for action, so it outranks working; ready stays quiet.
658
+ const stateColor = { needs: theme.status.critical, running: theme.status.working, idle: theme.colors.muted };
572
659
  const choices = state.screen === 'workspaces' ? [
573
- ...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
574
- label: `${string(workspace.title)} ${string(workspace.path)}`,
575
- remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
576
- action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
660
+ ...state.workspaces.map(workspace => {
661
+ const counts = workspaceCounts(sessionsOf(workspace), new Set(pendingCounts.keys()));
662
+ const name = string(workspace.title);
663
+ const title = name || string(workspace.path);
664
+ // A path only earns a column when words are affordable and the title does not already say it.
665
+ const detail = rollupStyle === 'words' && name ? workspaceDetail(string(workspace.path), name) : '';
666
+ return { key: string(workspace.workspaceId),
667
+ label: [workspaceStatus(counts, rollupStyle), title].filter(Boolean).join(' '), title,
668
+ cells: workspaceSegments(counts, rollupStyle).map(segment => ({ text: segment.text, color: stateColor[segment.state] })),
669
+ ...(detail ? { detail } : {}),
670
+ remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
671
+ action: () => controller.pickWorkspace(string(workspace.workspaceId)) };
672
+ }),
577
673
  { key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
674
+ // The directory this client runs in is the one case where no path has to be typed, and offering
675
+ // it only while the host has not registered it keeps the row from repeating itself.
676
+ ...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
677
+ : [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
678
+ action: () => operate(() => controller.createWorkspace(controller.localDirectory)) }]),
578
679
  { key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
579
680
  ] : [
580
681
  ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
581
682
  ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
582
- label: `${session.running ? '● ' : ''}${sessionLabel(session)} ${session.sessionId}`,
683
+ label: `${sessionStatus(session, listAge, isPending(session))} ${sessionLabel(session)} ${session.sessionId}`,
583
684
  remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
584
685
  action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
585
686
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
586
687
  ];
587
- const width = Math.max(10, (stdout.columns ?? 80) - 2);
588
688
  const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
589
689
  const { length, first } = layout;
590
690
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
@@ -720,7 +820,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
720
820
  : state.screen === 'chat' && position > 0 ? 'history'
721
821
  : undefined;
722
822
  const statusFrozen = pauseReason !== undefined;
723
- return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
823
+ return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, width: pickerWidth,
824
+ // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
825
+ // terminal too narrow for the whole key gets the badges alone rather than half a legend.
826
+ legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
724
827
  .slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index + 1, ". ", question?.multiSelect === true && index < options.length ? choiceState.selected.includes(string(option.label)) ? '[x] ' : '[ ] ' : '', safeText(string(option.label))] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(string(option.description))] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
725
- ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Text answers supported · Ctrl+C clears · /cancel stops' : '/allow approves once · /deny rejects · /cancel stops' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: theme.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, focus: state.online && !state.busy && !copyMode, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
828
+ ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓ / 1–9 select · Enter confirm · Esc dismisses' })] }), _jsx(Text, { dimColor: true, children: question ? options.length > 0
829
+ ? 'Choose "Other answer" to type · the draft is kept while this is open'
830
+ : 'Esc dismisses the question · the draft is kept while this is open'
831
+ : 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
726
832
  }
@@ -1,7 +1,11 @@
1
1
  import type { HistoryRow } from '../../session/history.ts';
2
2
  /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
3
+ *
4
+ * Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
5
+ * node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
6
+ * An empty row renders as a single space because Ink gives an empty text node no height.
3
7
  * @param rows - Already wrapped, terminal-safe visible rows.
4
- * @returns Colored text with unchanged row geometry and plain-text role markers.
8
+ * @returns Colored rows with unchanged row geometry and plain-text role markers.
5
9
  */
6
10
  export declare function HistoryViewport({ rows }: {
7
11
  rows: HistoryRow[];
@@ -1,12 +1,17 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /** Only visible semantic rows become React nodes; ANSI colors are applied after remote text sanitation. */
3
- import { Text } from 'ink';
3
+ import { Box, Text } from 'ink';
4
4
  import { useTheme } from "../theme/index.js";
5
5
  /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
6
+ *
7
+ * Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
8
+ * node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
9
+ * An empty row renders as a single space because Ink gives an empty text node no height.
6
10
  * @param rows - Already wrapped, terminal-safe visible rows.
7
- * @returns Colored text with unchanged row geometry and plain-text role markers.
11
+ * @returns Colored rows with unchanged row geometry and plain-text role markers.
8
12
  */
9
13
  export function HistoryViewport({ rows }) {
10
14
  const theme = useTheme();
11
- return _jsx(Text, { children: rows.map((row, index) => _jsxs(Text, { color: theme.colors[row.kind], bold: row.bold, children: [row.spans ? row.spans.map((span, index) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, index)) : row.text, index < rows.length - 1 ? '\n' : ''] }, index)) });
15
+ return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { color: theme.colors[row.kind], bold: row.bold, children: row.spans?.length ? row.spans.map((span, position) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, position))
16
+ : row.text === '' ? ' ' : row.text }, index)) });
12
17
  }
@@ -28,12 +28,12 @@ export interface StatusGroups {
28
28
  phase?: StatusSegment;
29
29
  /** How to stop the running turn. */
30
30
  stop?: StatusSegment;
31
- /** This session's cost, scope-labelled so it cannot read as a share of the day total. */
32
- session?: StatusSegment;
31
+ /** This session's cost with today's spend in parentheses, the only money the bar reports. */
32
+ cost?: StatusSegment;
33
33
  /** Context share, labelled because a bare percentage next to money reads as a budget. */
34
34
  context?: StatusSegment;
35
- /** Today's cost across sessions; shown only where the width allows it. */
36
- day?: StatusSegment;
35
+ /** The same share drawn as a bar; used only where it still fits beside every other kept group. */
36
+ contextBar?: StatusSegment;
37
37
  /** Model name without its reasoning effort, which is dropped first. */
38
38
  model?: StatusSegment;
39
39
  /** Reasoning effort, kept only while the model name still fits beside it. */
@@ -42,7 +42,19 @@ export interface StatusGroups {
42
42
  turns?: StatusSegment;
43
43
  /** Cumulative tokens. */
44
44
  tokens?: StatusSegment;
45
+ /** Cache-hit share of billed prompt input; it restates the token total, so the packer drops it first. */
46
+ cache?: StatusSegment;
45
47
  }
48
+ /** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
49
+ *
50
+ * A prefix served from cache is what the provider charges least for, so the share is the reading
51
+ * that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
52
+ * being billed, so the display gains precision until the number it shows is below a full hit.
53
+ * @param cacheReadTokens - Prompt tokens the provider served from cache.
54
+ * @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
55
+ * @returns Percentage text, or undefined when no prompt input has been billed yet.
56
+ */
57
+ export declare function cacheHitText(cacheReadTokens: number | undefined, billedInputTokens: number | undefined): string | undefined;
46
58
  /** Elapsed time as `m:ss`, adding hours only when they exist.
47
59
  * @param milliseconds - Duration to format.
48
60
  * @returns Clock text without a leading zero on minutes.
@@ -51,6 +51,27 @@ export function metricLines(values, defaultModel, running) {
51
51
  `In ${compact(buckets[0])} · Out ${compact(buckets[1])} · Cache ${compact(buckets[2])}/${compact(buckets[3])}`,
52
52
  ];
53
53
  }
54
+ /** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
55
+ *
56
+ * A prefix served from cache is what the provider charges least for, so the share is the reading
57
+ * that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
58
+ * being billed, so the display gains precision until the number it shows is below a full hit.
59
+ * @param cacheReadTokens - Prompt tokens the provider served from cache.
60
+ * @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
61
+ * @returns Percentage text, or undefined when no prompt input has been billed yet.
62
+ */
63
+ export function cacheHitText(cacheReadTokens, billedInputTokens) {
64
+ if (cacheReadTokens === undefined || billedInputTokens === undefined || billedInputTokens <= 0)
65
+ return undefined;
66
+ if (cacheReadTokens >= billedInputTokens)
67
+ return '100%';
68
+ for (let digits = 0; digits <= 3; digits++) {
69
+ const text = (cacheReadTokens / billedInputTokens * 100).toFixed(digits);
70
+ if (Number(text) < 100)
71
+ return `${text}%`;
72
+ }
73
+ return '<100%';
74
+ }
54
75
  /** Elapsed time as `m:ss`, adding hours only when they exist.
55
76
  * @param milliseconds - Duration to format.
56
77
  * @returns Clock text without a leading zero on minutes.
@@ -84,23 +105,61 @@ export function compactStatusRows(groups, width) {
84
105
  return [];
85
106
  // Remote text reaches this bar, so every group is stripped of control characters before packing.
86
107
  const clean = (group) => group === undefined ? undefined : { ...group, text: safeText(group.text).replace(/[\r\n\t]+/g, ' ') };
87
- groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), session: clean(groups.session),
88
- context: clean(groups.context), day: clean(groups.day), model: clean(groups.model), effort: clean(groups.effort),
89
- turns: clean(groups.turns), tokens: clean(groups.tokens) };
108
+ groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), cost: clean(groups.cost),
109
+ context: clean(groups.context), contextBar: clean(groups.contextBar),
110
+ model: clean(groups.model), effort: clean(groups.effort), turns: clean(groups.turns), tokens: clean(groups.tokens),
111
+ cache: clean(groups.cache) };
90
112
  const cluster = [groups.state, groups.phase, groups.stop].filter(Boolean);
91
- const rest = [groups.session, groups.context, groups.day, groups.model, groups.effort, groups.turns, groups.tokens].filter(Boolean);
92
- // One row while enough of the sequence fits; each step drops the least valuable group first.
93
- // The cost is never dropped, only moved to the second row, so the search stops above it.
94
- const floor = groups.session === undefined ? 0 : 1;
95
- for (let keep = rest.length; keep >= floor; keep--) {
96
- const row = pack(cluster, rest.slice(0, keep));
113
+ // The cost is one group carrying two scopes: this session's spend, with today's in parentheses.
114
+ // Display order reads the model beside its effort and the money after the share it sits next to;
115
+ // keep order is by value, so a narrow bar holds the cost and the share before a model it cannot
116
+ // show, and the cache share goes before the token total it restates.
117
+ const order = [groups.model, groups.effort, groups.context, groups.cost, groups.turns, groups.tokens, groups.cache].filter(Boolean);
118
+ const rank = [groups.cost, groups.context, groups.model, groups.effort, groups.turns, groups.tokens, groups.cache].filter(Boolean);
119
+ // One row while enough of the sequence fits; each step drops the least valuable group first. The
120
+ // cost is never dropped while a row can hold it, only moved to the second one, so the search stops
121
+ // above it; a second row that cannot hold even the cost is not opened.
122
+ const floor = groups.cost === undefined ? 0 : 1;
123
+ for (let keep = rank.length; keep >= floor; keep--) {
124
+ const kept = new Set(rank.slice(0, keep));
125
+ const row = pack(cluster, order.filter(group => kept.has(group)));
97
126
  if (measure(row) <= width)
98
- return [row];
127
+ return [keep > floor ? widen(row, groups, width) : row];
99
128
  }
100
- if (groups.session === undefined && measure(pack(cluster, [])) > width)
129
+ if (groups.cost === undefined && measure(pack(cluster, [])) > width)
101
130
  return [fitCluster(cluster, width)];
102
131
  // Not even the state cluster and the cost share a row, so the cost opens the second one.
103
- return [fitCluster(cluster, width), packGreedy(rest, width)];
132
+ const first = fitCluster(cluster, width);
133
+ const second = widen(packGreedy(rank, width), groups, width);
134
+ return second.length === 0 ? [first] : [first, second];
135
+ }
136
+ /** Offer the clearer reading of a group already on the row: the ten-cell context share.
137
+ *
138
+ * The bar is a rendering of the same share the plain percentage reports, not an additional group, so
139
+ * it never displaces a group that fits: below the width that holds it, the plain form keeps its place.
140
+ * @param row - A packed row that already fits.
141
+ * @param groups - Cleaned groups, used to find the pair by identity.
142
+ * @param width - Available terminal columns.
143
+ * @returns The row with the reading that fits.
144
+ */
145
+ function widen(row, groups, width) {
146
+ return swap(row, groups.context, groups.contextBar, width);
147
+ }
148
+ /** Replace one group with its fuller rendering where the whole row still fits.
149
+ * @param row - A packed row that already fits.
150
+ * @param from - Group to replace, matched by identity.
151
+ * @param to - Fuller rendering of that group.
152
+ * @param width - Available terminal columns.
153
+ * @returns The row with the replacement, or the row unchanged where it does not fit.
154
+ */
155
+ function swap(row, from, to, width) {
156
+ if (from === undefined || to === undefined)
157
+ return row;
158
+ const index = row.indexOf(from);
159
+ if (index < 0)
160
+ return row;
161
+ const replaced = [...row.slice(0, index), to, ...row.slice(index + 1)];
162
+ return measure(replaced) <= width ? replaced : row;
104
163
  }
105
164
  /** Fit the state cluster, dropping the phase and then the stop hint before truncating the state.
106
165
  * @param cluster - State, phase and stop hint in that order.
@@ -166,9 +225,11 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
166
225
  const since = controller.workingSince;
167
226
  useEffect(() => {
168
227
  setNow(Date.now());
169
- if (!running || paused)
228
+ if (paused)
170
229
  return;
171
- const timer = setInterval(() => setNow(Date.now()), 1000);
230
+ // An idle bar still re-reads the clock, so a day rollover reaches the cost it reports without
231
+ // waiting for an unrelated render; a running bar keeps its per-second clock.
232
+ const timer = setInterval(() => setNow(Date.now()), running ? 1000 : 60_000);
172
233
  return () => clearInterval(timer);
173
234
  }, [running, since, paused]);
174
235
  const state = controller.state;
@@ -176,7 +237,7 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
176
237
  const view = controller.telemetry.view(state.sessionId);
177
238
  const costs = controller.costs;
178
239
  const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
179
- const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
240
+ const todayCost = costs ? costText(costs.today()) : '?';
180
241
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
181
242
  const coverage = costs?.coverage ?? 'complete';
182
243
  const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
@@ -192,6 +253,10 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
192
253
  const usage = record(view.values.tokenUsage);
193
254
  const buckets = [usage.uncachedInputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens].map(numeric);
194
255
  const total = buckets.every(value => value !== undefined) ? buckets.reduce((a, b) => a + b, 0) : undefined;
256
+ // Billed prompt input is the three disjoint prompt buckets; a provider that reports no cache
257
+ // write has not billed one, which is how the ledger reads the same projection.
258
+ const billed = buckets[0] === undefined || buckets[2] === undefined ? undefined : buckets[0] + buckets[2] + (buckets[3] ?? 0);
259
+ const hit = cacheHitText(buckets[2], billed);
195
260
  const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
196
261
  const phase = state.transcript.livePhase;
197
262
  const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
@@ -199,31 +264,44 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
199
264
  : phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
200
265
  : `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
201
266
  // The state token reports a fact and never guesses: a paused clock is named, offline and errors
202
- // take the token over, and an unknown phase simply leaves the phase group empty.
267
+ // take the token over, and an unknown phase simply leaves the phase group empty. An answer this
268
+ // client still owes outranks the paused reason, because that reason is only why the clock stopped.
203
269
  const stateToken = !state.online
204
270
  ? { text: '! Offline', color: theme.status.offline }
205
271
  : state.controlError || state.modelError
206
272
  ? { text: '⚠ Error', color: theme.status.warning }
207
- : pauseReason !== undefined
208
- ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
209
- : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
210
- // The marker names the scope it belongs to: a subtotal is inexact when a record could not be
211
- // priced, when it is only estimated, or when the scan has not covered every session yet.
273
+ : state.pending.length > 0
274
+ ? { text: '? Needs you', color: theme.status.critical }
275
+ : pauseReason !== undefined
276
+ ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
277
+ : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
278
+ // One marker covers both scopes, because either an unpriceable record or a scan that has not
279
+ // covered every session makes the pair inexact as a reading.
212
280
  const inexact = coverage !== 'complete';
213
- const money = (value) => `¥${value.amount.toFixed(2)}${value.unknown || value.estimated || inexact ? '*' : ''}`;
281
+ const money = (session, today) => `¥: ${session === undefined ? '?' : session.amount.toFixed(2)}(${today.amount.toFixed(2)})${session?.unknown || today.unknown || inexact ? '*' : ''}`;
282
+ const todayTotal = costs?.today();
214
283
  const sessionTotal = costs !== undefined && costs.hasSession(state.sessionId) ? costs.total(state.sessionId) : undefined;
284
+ const contextColor = percent === undefined ? theme.status.usage
285
+ : percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context;
286
+ // Ten cells resolve context to tenths, the same resolution the percentage beside them reports.
287
+ const cells = percent === undefined ? 0 : Math.round(percent / 10);
215
288
  const groups = {
216
289
  state: stateToken,
217
- ...(phaseLabel === undefined || pauseReason !== undefined ? {} : { phase: { text: phaseLabel } }),
290
+ // A paused bar keeps the phase: the reason already says why the clock stopped, and dropping the
291
+ // running tool would leave the one question this bar exists to answer unanswered. A ready bar
292
+ // has none: the transcript keeps the last event it saw, and only the host knows the turn ended.
293
+ ...(phaseLabel === undefined || !running ? {} : { phase: { text: phaseLabel } }),
218
294
  ...(running && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
219
- ...(sessionTotal === undefined ? {} : { session: { text: `S${money(sessionTotal)}`, color: theme.status.cost } }),
220
- ...(percent === undefined ? {} : { context: { text: `ctx ${percent}%`,
221
- color: percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context } }),
222
- ...(costs === undefined ? {} : { day: { text: `D${money(costs.total(undefined, 1, Date.now()))}`, color: theme.status.cost } }),
295
+ ...(todayTotal === undefined ? {} : { cost: { text: money(sessionTotal, todayTotal), color: theme.status.cost } }),
296
+ ...(percent === undefined ? {} : {
297
+ context: { text: `ctx ${percent}%`, color: contextColor },
298
+ contextBar: { text: `ctx: ${'█'.repeat(cells)}${'░'.repeat(10 - cells)} ~${percent}%`, color: contextColor }
299
+ }),
223
300
  ...(model === undefined ? {} : { model: { text: model, color: theme.status.model } }),
224
301
  ...(model === undefined || effort === undefined ? {} : { effort: { text: effort, color: theme.status.model } }),
225
302
  ...(numeric(record(view.values.sessionStats).turns) === undefined ? {} : { turns: { text: `${count(numeric(record(view.values.sessionStats).turns))} turns`, color: theme.status.usage } }),
226
303
  ...(total === undefined ? {} : { tokens: { text: `${compactCount(total)} tok`, color: theme.status.usage } }),
304
+ ...(hit === undefined ? {} : { cache: { text: `hit ${hit}`, color: theme.status.usage } }),
227
305
  };
228
306
  const rows = compactStatusRows(groups, width ?? Math.max(1, (stdout.columns ?? 80) - 2));
229
307
  if (rows.length !== reported) {
@@ -248,7 +326,7 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
248
326
  const view = controller.telemetry.view(state.sessionId);
249
327
  const costs = controller.costs;
250
328
  const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
251
- const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
329
+ const todayCost = costs ? costText(costs.today()) : '?';
252
330
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
253
331
  const coverage = costs?.coverage ?? 'complete';
254
332
  const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
@@ -259,9 +337,11 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
259
337
  const turns = count(numeric(record(view.values.sessionStats).turns));
260
338
  const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
261
339
  const detail = [
262
- { key: 'activity', color: running ? theme.colors.context : theme.colors.muted, text: running
263
- ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
264
- : '● Ready · Ctrl+C exit' },
340
+ { key: 'activity', color: state.pending.length > 0 ? theme.status.critical : running ? theme.colors.context : theme.colors.muted, text: state.pending.length > 0
341
+ ? '? Needs you · answer the request above to continue'
342
+ : running
343
+ ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
344
+ : '● Ready · Ctrl+C exit' },
265
345
  { key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
266
346
  ...state.sessionId
267
347
  ? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
@@ -307,5 +387,5 @@ function modelName(value) {
307
387
  return safeText(`${model.provider}/${model.model}${typeof model.reasoningEffort === 'string' ? ` (${model.reasoningEffort})` : ''}`);
308
388
  }
309
389
  function compactCost(total) {
310
- return `~¥${total.amount.toFixed(2)}${total.unknown || total.estimated ? '*' : ''}`;
390
+ return `~¥${total.amount.toFixed(2)}${total.unknown ? '*' : ''}`;
311
391
  }
@@ -62,6 +62,9 @@ export type Submission = {
62
62
  } | {
63
63
  kind: 'exportHtml';
64
64
  destination?: string;
65
+ } | {
66
+ kind: 'coredump';
67
+ tag?: string;
65
68
  } | {
66
69
  kind: 'answer';
67
70
  text: string;
@@ -109,6 +109,11 @@ export function classifySubmission(raw, context) {
109
109
  const destination = value.slice(12).trim().replace(/^(["'])(.*)\1$/, '$2');
110
110
  return { kind: 'exportHtml', ...(destination ? { destination } : {}) };
111
111
  }
112
+ if (/^\/coredump(?:\s|$)/.test(value)) {
113
+ // A diagnostic of this client's own heap needs neither a session nor a connected host.
114
+ const tag = value.slice(9).trim().replace(/^(["'])(.*)\1$/, '$2');
115
+ return { kind: 'coredump', ...(tag ? { tag } : {}) };
116
+ }
112
117
  if (context.question)
113
118
  return { kind: 'answer', text: value };
114
119
  if (context.pending)
@@ -21,6 +21,7 @@ export const COMMAND_HINTS = [
21
21
  { command: '/feedback', usage: 'text', description: 'Record feedback about the session' },
22
22
  { command: '/export', usage: '[local.zip]', description: 'Save the session log ZIP to a new local file' },
23
23
  { command: '/export-html', usage: '[local.html]', description: 'Save loaded conversation with diagrams and math as offline HTML' },
24
+ { command: '/coredump', usage: '[tag]', description: 'Write a V8 heap snapshot for memory diagnosis' },
24
25
  { command: '/allow', description: 'Approve the pending request once' },
25
26
  { command: '/deny', description: 'Reject the pending request' },
26
27
  { command: '/status', description: 'Show full session status details' },
@@ -16,10 +16,10 @@ export function CostPanel({ controller }) {
16
16
  const id = controller.state.sessionId;
17
17
  const rows = [
18
18
  ['Session', id && costs.hasSession(id) ? costs.total(id) : undefined],
19
- ['Today', costs.total(undefined, 1)], ['3 days (today + previous 2)', costs.total(undefined, 3)],
19
+ ['Today', costs.today()],
20
20
  ];
21
- return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced${total.estimated ? ` · ${total.estimated} estimated` : ''} / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
21
+ return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
22
22
  : costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
23
23
  : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
24
- : 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
24
+ : 'Cached totals from the previous run' }), costs.customPrices && _jsx(Text, { color: theme.colors.context, children: "Rates come from prices.json, not the shipped table." }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
25
25
  }