@itookit/dsht 0.5.1 → 0.5.2

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 (66) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -2
  3. package/README.zh.md +6 -4
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +4 -1
  8. package/dist/cli/startup.js +30 -11
  9. package/dist/cli/verifier.d.ts +4 -0
  10. package/dist/cli/verifier.js +28 -5
  11. package/dist/contracts.d.ts +20 -5
  12. package/dist/controller/connection-streams.d.ts +22 -0
  13. package/dist/controller/connection-streams.js +105 -0
  14. package/dist/controller/connection.d.ts +14 -3
  15. package/dist/controller/connection.js +40 -69
  16. package/dist/controller/controller.d.ts +17 -233
  17. package/dist/controller/controller.js +108 -811
  18. package/dist/controller/foreground.d.ts +44 -0
  19. package/dist/controller/foreground.js +79 -0
  20. package/dist/controller/loop-coordinator.d.ts +48 -0
  21. package/dist/controller/loop-coordinator.js +647 -0
  22. package/dist/controller/verifier.d.ts +4 -0
  23. package/dist/cost/controller.d.ts +1 -1
  24. package/dist/cost/controller.js +12 -5
  25. package/dist/cost/scanner.js +1 -0
  26. package/dist/session/controller.d.ts +23 -35
  27. package/dist/session/controller.js +113 -363
  28. package/dist/session/history-reader.d.ts +32 -0
  29. package/dist/session/history-reader.js +170 -0
  30. package/dist/session/index.d.ts +1 -1
  31. package/dist/session/info.d.ts +3 -38
  32. package/dist/session/info.js +14 -1
  33. package/dist/session/interactions.d.ts +26 -0
  34. package/dist/session/interactions.js +75 -0
  35. package/dist/session/navigator.d.ts +47 -0
  36. package/dist/session/navigator.js +158 -0
  37. package/dist/session/prompt-backfill.d.ts +23 -0
  38. package/dist/session/prompt-backfill.js +88 -0
  39. package/dist/session/state.d.ts +20 -0
  40. package/dist/session/state.js +1 -0
  41. package/dist/session/telemetry.d.ts +15 -6
  42. package/dist/session/telemetry.js +44 -7
  43. package/dist/session/transcript.d.ts +5 -1
  44. package/dist/slash/index.d.ts +1 -1
  45. package/dist/slash/parse.d.ts +2 -126
  46. package/dist/slash/registry.d.ts +1 -1
  47. package/dist/slash/types.d.ts +126 -0
  48. package/dist/slash/types.js +1 -0
  49. package/dist/state.d.ts +5 -17
  50. package/dist/state.js +1 -1
  51. package/dist/transport/client.d.ts +4 -3
  52. package/dist/transport/client.js +71 -25
  53. package/dist/ui/app.js +86 -301
  54. package/dist/ui/chat/shell-view.d.ts +2 -0
  55. package/dist/ui/chat/shell-view.js +8 -0
  56. package/dist/ui/chat/use-history-view.d.ts +69 -0
  57. package/dist/ui/chat/use-history-view.js +123 -0
  58. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  59. package/dist/ui/dialogs/use-panels.js +51 -0
  60. package/dist/ui/input/use-composer.d.ts +35 -0
  61. package/dist/ui/input/use-composer.js +109 -0
  62. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  63. package/dist/ui/input/use-deferred-lines.js +54 -0
  64. package/dist/ui/input/use-history-recall.d.ts +20 -0
  65. package/dist/ui/input/use-history-recall.js +47 -0
  66. package/package.json +1 -1
package/dist/ui/app.js CHANGED
@@ -5,6 +5,9 @@ import { mocha, ThemeContext } from "./theme/index.js";
5
5
  import { Box, Text, measureElement, useApp, useInput, useStdout } from 'ink';
6
6
  import { useMouseWheel } from "./input/mouse.js";
7
7
  import { TextInput } from "./input/input.js";
8
+ import { useComposer } from "./input/use-composer.js";
9
+ import { useDeferredLines } from "./input/use-deferred-lines.js";
10
+ import { useHistoryRecall } from "./input/use-history-recall.js";
8
11
  import { ReferenceMenu } from "./input/references.js";
9
12
  import { costText } from "./status/model.js";
10
13
  import { toolLine } from "../text.js";
@@ -14,11 +17,13 @@ import { StatusBar } from "./chat/status.js";
14
17
  import { ChatHeader } from "./chat/header.js";
15
18
  import { LoopStatus } from "./chat/loop-status.js";
16
19
  import { ChatViewport } from "./chat/viewport.js";
17
- import { mergeShellRuns, plainRows } from "./chat/shell-view.js";
20
+ import { plainRows } from "./chat/shell-view.js";
21
+ import { useHistoryView } from "./chat/use-history-view.js";
18
22
  import { Frozen } from "./frozen.js";
19
23
  import { CopyMode } from "./copy-mode.js";
20
24
  import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, PromptsDialog, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
21
25
  import { LoopDialog, LoopMenu } from "./dialogs/loop.js";
26
+ import { usePanels } from "./dialogs/use-panels.js";
22
27
  import { PeekPanel } from "./dialogs/peek.js";
23
28
  import { COMMAND_HINTS, argumentHint, completeCommand as completeDraft, suggestedCommands } from "../slash/registry.js";
24
29
  import { interpret, normalize, authorize } from "../slash/index.js";
@@ -31,8 +36,6 @@ import { errorText } from "../text.js";
31
36
  import { safeText } from "../text.js";
32
37
  /** Transient notices expire; interactive panels remain open until dismissed. */
33
38
  const PANEL_LIFETIME_MS = 10_000;
34
- /** Pages one boundary recall press may walk before it reports that nothing older holds a prompt. */
35
- const RECALL_PAGE_SCAN = 20;
36
39
  /** No folds: the read-only view has no expand state of its own, and one empty set keeps layout cached. */
37
40
  const NO_FOLDS = new Set();
38
41
  /** Rows one PgUp/PgDn moves in the read-only view. */
@@ -51,43 +54,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
51
54
  const { exit } = useApp();
52
55
  const { stdout } = useStdout();
53
56
  const [copyMode, setCopyMode] = useState(false);
54
- // The composer belongs to the selected session: it resets when another session opens, and every
55
- // callback reads the controller rather than a render closure Ink may not have refreshed yet.
56
- const [input, setInputValue] = useState('');
57
- const [cursor, setCursor] = useState(0);
58
- // The freshest draft, readable from an async continuation: a command that finishes while the
59
- // operator is already writing the next line must not clear that line (D1).
60
- const inputRef = useRef(input);
61
- inputRef.current = input;
62
- const parkedDraft = useRef('');
63
- const historyPaging = useRef(false);
64
- const setInput = (value, recalled = false) => {
65
- if (!recalled)
66
- controller.actions.resetRecall();
67
- // A new message draft returns the view to the live end, so composing never needs a scroll first.
68
- // A slash command is not a message, and the reader keeps their place while typing one.
69
- if (state.screen === 'chat' && input === '' && value !== '' && !value.startsWith('/'))
70
- setScroll(0);
71
- // Replacing the text parks the caret at its end, exactly as the session-owned composer did; the
72
- // editor reports its real caret straight afterwards when the change came from an in-place edit.
73
- setInputValue(value);
74
- setCursor(value.length);
75
- };
76
- // The reading view is session-owned too: which record is shown, where the reader is, and what is
77
- // expanded. `SessionInfo` releases the detached window, so the composer and this view reset together.
78
- // Reading position, folds and the live-reasoning fold mode are component state: switching the
79
- // session remounts the screen, and the record's own detached window stays session-owned.
80
- const [scroll, setScroll] = useState(0);
81
- const [reasoningOverrides, setReasoningOverrides] = useState(new Set());
82
- const [liveReasoning, setLiveReasoning] = useState('row');
83
57
  const historyWindow = state.session.window;
84
58
  /** The operation the controller owns right now; the front end only renders it. */
85
59
  const foreground = controller.queries.foreground;
86
- /** Lines the policy accepted but held until a turn, a loop or the foreground slot clears (A2). */
87
- const [queuedLines, setQueuedLines] = useState([]);
88
60
  const displayTranscript = historyWindow ?? state.session.record;
89
- const displayRef = useRef(displayTranscript);
90
- displayRef.current = displayTranscript;
91
61
  const conversationBox = useRef(null);
92
62
  const [conversationRows, setConversationRows] = useState(20);
93
63
  useLayoutEffect(() => {
@@ -97,30 +67,34 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
97
67
  setConversationRows(height);
98
68
  }
99
69
  });
100
- const [removal, setRemoval] = useState();
101
- // The loop parameter form is one modal step: the record it edits, while the form owns its values.
102
- const [loopForm, setLoopForm] = useState();
103
- // A command may borrow the composer to edit one of its entries; Enter then commits instead of
104
- // sending, and Esc clears the draft. The composition root stays generic about who asked.
105
- const [composerIntent, setComposerIntent] = useState();
106
- // Panels are component state: visibility and query text only, because every row comes from the record.
107
- const [panels, setPanels] = useState({ thoughts: false, queue: false });
108
- const { thoughts: thoughtList, queue: queueOpen, model: models, history, search: searchResults, prompts: promptsOpen } = panels;
109
- const openThoughts = (open) => setPanels(current => ({ ...current, thoughts: open }));
110
- const openQueue = (open) => setPanels(current => ({ ...current, queue: open }));
111
- const openPrompts = (open) => setPanels(current => ({ ...current, prompts: open }));
112
- const setModelPanel = (model) => setPanels(current => ({ ...current, model }));
113
- const setHistoryPanel = (history) => setPanels(current => ({ ...current, history }));
114
- const setSearchPanel = (search) => setPanels(current => ({ ...current, search }));
70
+ const panelState = usePanels({ sessionId: state.sessionId, pendingId: state.pending[0]?.eventId,
71
+ peekOpen: controller.queries.peek !== undefined, closePeek: () => controller.actions.closePeek() });
72
+ const { openThoughts, openQueue, openPrompts, setModelPanel, setHistoryPanel, setSearchPanel, setRemoval, setLoopForm, setHelp, setCostExpanded, setStatusExpanded } = panelState;
73
+ const { thoughts: thoughtList, queue: queueOpen, model: models, history, search: searchResults, prompts: promptsOpen, removal, loop: loopForm, help, cost: costExpanded, status: statusExpanded } = panelState.state;
115
74
  const historyQuery = history?.query;
116
75
  const contentSearch = history?.contentSearch === true;
117
76
  const historyMatches = history?.matches;
118
- const [costExpanded, setCostExpanded] = useState(false);
119
- const [statusExpanded, setStatusExpanded] = useState(false);
120
77
  const reasoning = 'row';
121
- useEffect(() => { setLiveReasoning('row'); }, [state.session.record, state.session.record.liveAttemptKey]);
122
78
  const [notice, setNotice] = useState();
123
- const [help, setHelp] = useState(false);
79
+ const composer = useComposer({
80
+ sessionId: state.sessionId,
81
+ currentSession: () => controller.snapshot().sessionId,
82
+ resetRecall: () => controller.actions.resetRecall(),
83
+ messageStarted: () => { if (controller.snapshot().screen === 'chat')
84
+ setScroll(0); },
85
+ notify: setNotice,
86
+ });
87
+ const { input, cursor, intent: composerIntent, setInput, setCursor, setIntent: setComposerIntent } = composer;
88
+ const recallOlderPrompts = useHistoryRecall({
89
+ source: state.session.record, draft: input,
90
+ capture: composer.capture, restore: composer.recallIfCurrent,
91
+ previous: draft => controller.queries.recall(-1, draft),
92
+ refill: () => controller.actions.refillRecall(),
93
+ hasPrevious: () => controller.queries.recallLength > 0 && !controller.queries.recallAtOldest,
94
+ older: (signal, source) => controller.actions.older(signal, source),
95
+ run: work => controller.actions.foreground('history', 'Loading older prompts…', work),
96
+ notify: setNotice,
97
+ });
124
98
  const [helpPage, setHelpPage] = useState(0);
125
99
  const helpPageSize = Math.max(1, (stdout.rows ?? 30) - 12);
126
100
  const helpPages = Math.ceil(COMMAND_HINTS.length / helpPageSize);
@@ -194,24 +168,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
194
168
  setChromeRows({ header, prefix });
195
169
  });
196
170
  useEffect(() => {
197
- setPanels({ thoughts: false, queue: false });
198
171
  setReferenceIndex(0);
199
172
  setDismissedReference(undefined);
200
- setInputValue('');
201
- setCursor(0);
202
- parkedDraft.current = '';
203
- setComposerIntent(undefined);
204
- setScroll(0);
205
- setReasoningOverrides(new Set());
206
- setLiveReasoning('row');
207
- setLoopForm(undefined);
208
173
  setLoopMenuIndex(0);
209
174
  setDismissedLoopMenu(undefined);
210
175
  }, [state.sessionId]);
211
- useEffect(() => { openQueue(false); }, [state.sessionId, pending?.eventId]);
212
- // A pending interaction owns the keyboard, so the loop form yields rather than swallowing its keys.
213
- useEffect(() => { if (pending)
214
- setLoopForm(undefined); }, [pending?.eventId]);
215
176
  // A replayed interaction (same eventId after a reconnect) starts unselected again.
216
177
  useEffect(() => { controller.actions.setApproval(undefined); }, [state.sessionId, state.online, pending?.eventId]);
217
178
  const token = state.screen === 'chat' && state.online && foreground === undefined && !pending
@@ -228,25 +189,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
228
189
  : controller.queries.loopRecords.filter(record => record.name.startsWith(loopDraft));
229
190
  const loopMenuOpen = loopCandidates.length > 0 && loopForm === undefined && dismissedLoopMenu !== input;
230
191
  const loopMenuCursor = Math.min(loopMenuIndex, Math.max(0, loopCandidates.length - 1));
231
- // The queue dialog is not rendered while an answer is waiting, so it is not "open" then either.
232
- const surfaces = [
233
- // The read-only view is a whole screen, so its rule leads: any other panel open under it is
234
- // unreachable until it closes, and it is the only surface the reader can see.
235
- { name: 'peek', open: !!peek, arrows: true, blocksKeys: true, close: () => controller.actions.closePeek() },
236
- { name: 'queue', open: queueOpen && !pending, reserved: ['d'], close: () => openQueue(false) },
237
- { name: 'prompts', open: !!promptsOpen, arrows: true, blocksKeys: true, reserved: ['d', 'e'], close: () => openPrompts(false) },
238
- { name: 'removal', open: !!removal, arrows: true, blocksKeys: true, close: () => setRemoval(undefined) },
239
- { name: 'model', open: !!models, arrows: true, blocksKeys: true, close: () => setModelPanel(undefined) },
240
- { name: 'loop', open: !!loopForm, arrows: true, blocksKeys: true, close: () => setLoopForm(undefined) },
241
- { name: 'thoughts', open: thoughtList, arrows: true, blocksKeys: true, close: () => openThoughts(false) },
242
- { name: 'history', open: historyQuery !== undefined, arrows: true, blocksKeys: true, close: () => setHistoryPanel(undefined) },
243
- { name: 'search', open: !!searchResults, arrows: true, blocksKeys: true, close: () => setSearchPanel(undefined) },
244
- { name: 'help', open: help, blocksKeys: true, close: () => setHelp(false) },
245
- { name: 'cost', open: costExpanded, blocksKeys: true, close: () => setCostExpanded(false) },
246
- { name: 'status', open: statusExpanded, blocksKeys: true, close: () => setStatusExpanded(false) },
247
- ];
248
- const openSurfaces = surfaces.filter(surface => surface.open);
249
- const surfaceReservedKeys = [...new Set(openSurfaces.flatMap(surface => surface.reserved ?? []))];
192
+ const { openSurfaces, reservedKeys: surfaceReservedKeys } = panelState;
250
193
  const dialogOpen = !!(openSurfaces.length || pending || referenceOpen || state.screen !== 'chat');
251
194
  const displayPaused = copyMode || dialogOpen;
252
195
  // Startup screens need live connection feedback even while their picker remains open.
@@ -304,29 +247,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
304
247
  // the composer itself for a free-text or "Other" answer, so it keeps the keys then.
305
248
  const answerPending = !!pending && (pending.kind === 'approval' || options.length > 0 && !choiceState.custom);
306
249
  useEffect(() => {
307
- // A dialog that demands an answer parks the draft: the text is moved aside while it owns the
308
- // keyboard and given back when it closes. Restoring writes the draft directly, so it neither
309
- // counts as a recall nor moves a reader's scroll position.
310
- if (answerPending) {
311
- // A composer intent cannot survive an answer dialog taking the keyboard: the dialog's text is
312
- // not what the intent edits, so the intent is dropped rather than committed.
313
- if (composerIntent)
314
- setComposerIntent(undefined);
315
- if (input !== '') {
316
- parkedDraft.current = input;
317
- setInputValue('');
318
- setCursor(0);
319
- }
320
- return;
321
- }
322
- if (pending)
323
- return;
324
- if (parkedDraft.current !== '') {
325
- const parked = parkedDraft.current;
326
- parkedDraft.current = '';
327
- setInputValue(parked);
328
- setCursor(parked.length);
329
- }
250
+ composer.interactionChanged(answerPending, pending !== undefined);
330
251
  }, [answerPending, pending]);
331
252
  const approvalKeysActive = pending?.kind === 'approval' && !copyMode && !panelBlocksKeys;
332
253
  const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
@@ -651,7 +572,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
651
572
  // past it fetches the page before the retained window, and the step is applied once the page
652
573
  // lands, so recall covers prompts from before this client connected. Paging stays with the
653
574
  // conversation: on a picker screen the arrows only ever walked what was already retained.
654
- if (recallPrevious && state.screen === 'chat' && !historyPaging.current
575
+ if (recallPrevious && state.screen === 'chat'
655
576
  && (controller.queries.recallAtOldest || controller.queries.recallLength === 0)
656
577
  && state.session.record.ready && controller.queries.recallHasOlder) {
657
578
  void recallOlderPrompts();
@@ -671,15 +592,19 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
671
592
  * pass one door and not the other (§3.4: authorize is per submission, not per user action).
672
593
  */
673
594
  function applicationFacts() {
595
+ const current = controller.snapshot();
674
596
  return {
675
- sessionSelected: state.sessionId !== undefined,
676
- pending: pending !== undefined,
677
- foreground: foreground !== undefined,
597
+ sessionId: current.sessionId,
598
+ ready: current.online && controller.queries.connectionSettled && (current.sessionId === undefined || controller.queries.record.ready),
599
+ sessionSelected: current.sessionId !== undefined,
600
+ pending: current.pending.length > 0,
601
+ foreground: controller.queries.foreground !== undefined,
678
602
  // A loop is reported as such because stopping it is what the refusal has to name.
679
603
  during: controller.queries.loop?.active === true ? 'loop'
680
604
  : controller.queries.running ? 'turn' : 'idle',
681
605
  };
682
606
  }
607
+ const deferredLines = useDeferredLines({ facts: applicationFacts, run: command => runLine(command), notify: setNotice });
683
608
  /** What a held line is waiting for, in the words the notice uses. */
684
609
  function describeDefer(defer) {
685
610
  return defer === 'busy' ? 'the running operation finishes' : defer === 'loop' ? 'the loop ends' : 'the turn finishes';
@@ -697,7 +622,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
697
622
  return false;
698
623
  const defer = verdict.defer;
699
624
  controller.traceNote('command', { phase: 'queued', kind: verdict.command.kind, reason: defer });
700
- setQueuedLines(list => [...list, { command: verdict.command, defer, sessionId: state.sessionId }]);
625
+ deferredLines.enqueue(verdict.command, defer);
701
626
  setNotice(`Queued ${label} · runs when ${describeDefer(verdict.defer)}`);
702
627
  return true;
703
628
  }
@@ -708,17 +633,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
708
633
  during: facts.during, foreground: facts.foreground });
709
634
  }
710
635
  const submit = async (raw) => {
636
+ const receipt = composer.capture();
711
637
  // A borrowed composer commits on Enter instead of sending, so an edit is never delivered.
712
638
  if (composerIntent) {
713
- const text = raw.trim();
714
- if (!text) {
715
- setNotice(composerIntent.emptyNotice);
716
- return;
717
- }
718
- if (await composerIntent.commit(text)) {
719
- setComposerIntent(undefined);
720
- setInput('');
721
- }
639
+ await composer.commit(raw);
722
640
  return;
723
641
  }
724
642
  // A composer menu completes the draft before it is submitted: Enter on the record list accepts the
@@ -755,11 +673,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
755
673
  const value = line.trim();
756
674
  if (!pending && !/^\/feedback(?:\s|$)/.test(value))
757
675
  controller.actions.recordRecall(value);
758
- await runLine(executable, raw);
676
+ await runLine(executable, receipt);
759
677
  };
760
678
  /** Run one authorized line under the UI's port and apply what it returns.
761
679
  *
762
- * `draft` is the composer text to clear when the line is consumed; a line the UI runs from its own
680
+ * `draft` identifies the draft to clear when the line is consumed; a line the UI runs from its own
763
681
  * queue passes none, because that draft was used up when the operator submitted it.
764
682
  */
765
683
  async function runLine(executable, draft) {
@@ -774,40 +692,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
774
692
  // Consuming the line is the application's decision, so the composer never guesses it. Only the
775
693
  // line that was submitted is cleared: the operation may have taken long enough for the operator
776
694
  // to write the next one (D1), and that draft is theirs, not this result's to discard.
777
- if (result.disposition === 'consume' && draft !== undefined && inputRef.current === draft)
778
- setInput('');
695
+ if (result.disposition === 'consume' && draft !== undefined)
696
+ composer.consume(draft);
779
697
  }
780
698
  catch (error) {
781
699
  setNotice(errorText(error));
782
700
  }
783
701
  }
784
- /** Drain one held line when the fact that deferred it has cleared.
785
- *
786
- * One per run, in arrival order: the state update re-runs this effect, so the queue empties without
787
- * a loop of its own. A line whose session is gone is dropped rather than run against another
788
- * conversation, and one that is deferred again goes back to the front.
789
- */
790
- useEffect(() => {
791
- const next = queuedLines[0];
792
- if (next === undefined)
793
- return;
794
- const facts = applicationFacts();
795
- const ready = !facts.foreground && (next.defer === 'busy' || facts.during === 'idle');
796
- if (!ready)
797
- return;
798
- if (next.sessionId !== state.sessionId) {
799
- setQueuedLines(list => list.slice(1));
800
- setNotice('Dropped a queued line: the selected session changed');
801
- return;
802
- }
803
- const verdict = verdictFor(next.command);
804
- if (verdict.allow && verdict.defer !== undefined)
805
- return;
806
- setQueuedLines(list => list.slice(1));
807
- void runLine(verdict.allow ? verdict.command : verdict.error);
808
- // The facts that decide readiness are the dependencies; `queuedLines` re-runs the drain per line.
809
- }, [queuedLines, foreground !== undefined, state.pending, state.sessionId,
810
- controller.queries.loop?.active, controller.queries.running]);
811
702
  /** Apply one command result: its effects in the order the application emitted them.
812
703
  *
813
704
  * This is the whole front-end half of a command — the UI names no command here and never reorders.
@@ -831,10 +722,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
831
722
  setCopyMode(true);
832
723
  return;
833
724
  case 'closePanels':
834
- closePanelsExcept(keepPanel);
725
+ panelState.closeExcept(keepPanel);
835
726
  return;
836
727
  case 'close':
837
- closePanel(effect.panel);
728
+ panelState.close(effect.panel);
838
729
  return;
839
730
  case 'open':
840
731
  if (effect.panel === 'queue')
@@ -878,7 +769,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
878
769
  return;
879
770
  }
880
771
  case 'live':
881
- controller.actions.setViewWindow(undefined);
772
+ controller.actions.showLatest();
882
773
  return;
883
774
  case 'pinLive':
884
775
  controller.actions.pinHistory(false);
@@ -896,7 +787,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
896
787
  else {
897
788
  next.add(effect.seq);
898
789
  setReasoningOverrides(next);
899
- await jumpHistory(effect.seq, next);
790
+ await jumpHistory(effect.seq);
900
791
  }
901
792
  return;
902
793
  }
@@ -914,16 +805,6 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
914
805
  return;
915
806
  }
916
807
  }
917
- /** Close every panel except the one a result keeps open. */
918
- function closePanelsExcept(keep) {
919
- for (const surface of surfaces)
920
- if (surface.name !== keep)
921
- surface.close?.();
922
- }
923
- /** Close one named panel. */
924
- function closePanel(name) {
925
- surfaces.find(surface => surface.name === name)?.close?.();
926
- }
927
808
  const width = Math.max(10, (stdout.columns ?? 80) - 2);
928
809
  // The composer never trades the conversation away for input. Its window grows with the body and
929
810
  // is clamped so a few conversation rows always survive; width never buys height, so landscape and
@@ -977,140 +858,48 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
977
858
  action: () => { setScroll(0); operate(() => controller.actions.selectSession(string(session.sessionId))); } })),
978
859
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.actions.showPicker('workspaces')) },
979
860
  ];
980
- const layout = useMemo(() => controller.queries.render({ transcript: displayTranscript, width, folds: reasoningOverrides, liveReasoning }), [controller, displayTranscript, displayTranscript.version, width, reasoningOverrides, liveReasoning]);
981
- const { length, first } = layout;
861
+ const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
862
+ const historyView = useHistoryView({
863
+ sessionId: state.sessionId, source: state.session.record, display: displayTranscript, detached: !!historyWindow,
864
+ width, rows: conversationRows, showHint: showHistoryHint,
865
+ protect: thoughtList || historyQuery !== undefined && !contentSearch,
866
+ enabled: !copyMode && state.screen === 'chat', online: state.online, busy: foreground !== undefined,
867
+ revision: state.version, blocks: state.shell.blocks,
868
+ current: source => controller.queries.record === source && controller.snapshot().screen === 'chat',
869
+ render: input => controller.queries.render(input),
870
+ run: (work, label) => controller.actions.foreground('history', label, work),
871
+ older: (signal, source) => controller.actions.older(signal, source),
872
+ open: (target, signal) => controller.actions.openHistory(target, signal),
873
+ pin: value => controller.actions.pinHistory(value),
874
+ closePanels: () => { setHistoryPanel(undefined); openThoughts(false); }, notify: setNotice,
875
+ });
876
+ const { layout, merged, totalRows, pageSize, position, reasoningOverrides, liveReasoning, setScroll, setReasoningOverrides, setLiveReasoning, scrollHistory, jumpHistory, historyOperation } = historyView;
877
+ const { length } = layout;
982
878
  // The read-only view lays out its own transcript, so its fold mode never leaks into the
983
879
  // conversation's, and a local source needs no layout at all: it already holds plain lines.
984
880
  const peekLayout = useMemo(() => peek?.transcript === undefined ? undefined
985
881
  : controller.queries.render({ transcript: peek.transcript, width, folds: NO_FOLDS, liveReasoning: 'row' }), [controller, peek?.transcript, peek?.transcript?.version, width]);
986
882
  const peekPlainRows = useMemo(() => peek?.lines === undefined ? []
987
883
  : peek.lines.flatMap(line => plainRows(line, width, 'shell')), [peek?.lines, width]);
988
- // Local `!` blocks live at the end of the transcript: not host records, not persisted, but they
989
- // scroll with the conversation and are counted into its total so the viewport math stays honest.
990
- const merged = useMemo(() => mergeShellRuns(layout, state.shell.blocks, width), [layout, controller, state.version, width]);
991
- const totalRows = merged.total;
992
884
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
993
- const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
994
- const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
995
- const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning });
996
- const previous = previousView.current;
997
- const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
998
- const adjustedScroll = previous.session !== state.session.record ? 0
999
- : scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + totalRows - previous.count) : scroll;
1000
- const maxScroll = Math.max(0, totalRows - pageSize);
1001
- const position = Math.min(adjustedScroll, maxScroll);
1002
- useLayoutEffect(() => {
1003
- previousView.current = { transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning };
1004
- if (position !== scroll)
1005
- setScroll(position);
1006
- }, [state.session.record, displayTranscript, totalRows, position, scroll, reasoningOverrides, liveReasoning]);
1007
- useLayoutEffect(() => {
1008
- controller.actions.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
1009
- }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.session.record]);
1010
- useEffect(() => {
1011
- const first = displayTranscript.beforeSeq;
1012
- if (first === undefined)
1013
- return;
1014
- const previous = reasoningOverrides;
1015
- const next = new Set([...previous].filter(seq => seq >= first));
1016
- if (next.size !== previous.size)
1017
- setReasoningOverrides(next);
1018
- }, [displayTranscript, displayTranscript.memoryRevision]);
1019
- const loadingPage = useRef(false);
1020
- const scrollIntent = useRef(0);
1021
- const mounted = useRef(true);
1022
- useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
1023
- function scrollHistory(delta) {
1024
- if (copyMode || state.screen !== 'chat')
1025
- return;
1026
- const intent = ++scrollIntent.current;
1027
- const next = Math.max(0, Math.min(maxScroll, scroll + delta));
1028
- setScroll(next);
1029
- if (delta <= 0 || next < maxScroll || loadingPage.current || foreground !== undefined || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
1030
- return;
1031
- loadingPage.current = true;
1032
- const transcript = displayTranscript;
1033
- void historyOperation(signal => controller.actions.older(signal, transcript)).then(accepted => {
1034
- if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
1035
- setScroll(scroll + delta);
1036
- }
1037
- }).finally(() => { loadingPage.current = false; });
1038
- }
1039
- /** Run one front-end-orchestrated operation in the controller's foreground slot.
1040
- *
1041
- * The slot, its abort controller and its label live in the controller (§6.2), so this is only a
1042
- * typed name for the common case: paging through history.
1043
- * @param operation - Work handed the signal the controller aborts.
1044
- * @param label - Label the status line shows while it runs.
1045
- * @returns The value, or undefined when the slot was taken or the work was cancelled.
1046
- */
1047
- async function historyOperation(operation, label = 'Loading history…') {
1048
- return await controller.actions.foreground('history', label, operation);
1049
- }
1050
- /** Reach one prompt older than the index's boundary, from memory first and the host second.
1051
- *
1052
- * The transcript window outlives the prompt index's budgets, so an evicted prompt is usually still
1053
- * loaded: refilling from it costs nothing and keeps the key immediate. Only when the window itself
1054
- * is exhausted does this page back, skipping tool-only pages inside one bounded request loop; the
1055
- * fetched page stays readable in the conversation above the composer.
1056
- */
1057
- async function recallOlderPrompts() {
1058
- if (historyPaging.current)
1059
- return;
1060
- const transcript = state.session.record;
1061
- const sessionId = state.sessionId;
1062
- // Recover from the loaded window first: no request, no focus change, no loading label.
1063
- if (controller.actions.refillRecall() || !transcript.hasMore) {
1064
- setInput(controller.queries.recall(-1, input), true);
1065
- return;
1066
- }
1067
- historyPaging.current = true;
1068
- try {
1069
- const accepted = await historyOperation(async (signal) => {
1070
- // A prompt can be many pages back in an agent session, so scan a healthy stretch before
1071
- // giving up; the loading label stays visible and Esc cancels the scan.
1072
- for (let page = 0; page < RECALL_PAGE_SCAN; page++) {
1073
- if (transcript.beforeSeq === undefined || !transcript.hasMore)
1074
- break;
1075
- await controller.actions.older(signal, transcript);
1076
- signal.throwIfAborted();
1077
- if (controller.actions.refillRecall())
1078
- return;
1079
- }
1080
- }, 'Loading older prompts…');
1081
- if (accepted && mounted.current && controller.state.sessionId === sessionId) {
1082
- setInput(controller.queries.recall(-1, input), true);
1083
- }
1084
- }
1085
- finally {
1086
- historyPaging.current = false;
1087
- }
1088
- }
1089
885
  async function openSearchSession(sessionId, query) {
1090
886
  await historyOperation(async (signal) => {
1091
- await controller.actions.selectSession(sessionId);
1092
- await controller.actions.waitForHistory(signal);
1093
- setHistoryPanel({ query, contentSearch: true, matches: await controller.actions.searchHistory(query, signal) });
1094
- setSearchPanel(undefined);
1095
- });
1096
- }
1097
- async function jumpHistory(target, folds = reasoningOverrides) {
1098
- if (state.screen !== 'chat')
1099
- throw new Error('Select a session first');
1100
- ++scrollIntent.current;
1101
- await controller.actions.foreground('history', 'Jumping to a message…', async (signal) => {
1102
- const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
1103
- : state.session.record.messages.some(message => message.seq === target) ? state.session.record
1104
- : await controller.queries.historyAt(target, signal);
887
+ if (!await controller.actions.selectSession(sessionId))
888
+ return;
889
+ const record = controller.queries.record;
890
+ const current = () => controller.snapshot().sessionId === sessionId && controller.queries.record === record
891
+ && controller.snapshot().screen === 'chat';
892
+ if (!current() || !await controller.actions.waitForHistory(signal))
893
+ return;
1105
894
  signal.throwIfAborted();
1106
- const current = controller.queries.render({ transcript, width, folds, liveReasoning });
1107
- const row = current.offsets.get(target);
1108
- if (row === undefined)
1109
- throw new Error('No visible message at that sequence; use /history to choose a record');
1110
- controller.actions.setViewWindow(transcript === state.session.record ? undefined : transcript);
1111
- setHistoryPanel(undefined);
1112
- openThoughts(false);
1113
- setScroll(Math.max(0, current.length - pageSize - row));
895
+ if (!current())
896
+ return;
897
+ const matches = await controller.actions.searchHistory(query, signal);
898
+ signal.throwIfAborted();
899
+ if (!matches || !current())
900
+ return;
901
+ setHistoryPanel({ query, contentSearch: true, matches });
902
+ setSearchPanel(undefined);
1114
903
  });
1115
904
  }
1116
905
  const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
@@ -1172,7 +961,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
1172
961
  const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
1173
962
  const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
1174
963
  key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
1175
- action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq, next); }),
964
+ action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq); }),
1176
965
  })), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
1177
966
  const thoughtOptions = useMemo(() => [
1178
967
  ...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.session.record.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
@@ -1257,12 +1046,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
1257
1046
  setComposerIntent({
1258
1047
  hint: 'Editing saved prompt · Enter saves · Esc cancels',
1259
1048
  emptyNotice: 'Type the prompt text; Enter then saves it',
1260
- commit: async (text) => {
1261
- const saved = await controller.actions.updatePrompt(prompt.id, text);
1262
- if (saved)
1263
- setNotice('Saved prompt updated');
1264
- return saved;
1265
- },
1049
+ successNotice: 'Saved prompt updated',
1050
+ commit: text => controller.actions.updatePrompt(prompt.id, text),
1266
1051
  });
1267
1052
  setInput(prompt.text);
1268
1053
  openPrompts(false);
@@ -1272,5 +1057,5 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
1272
1057
  ? '↑ ↓ 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
1273
1058
  ? 'Choose "Other answer" to type · the draft is kept while this is open'
1274
1059
  : 'Esc dismisses the question · the draft is kept while this is open'
1275
- : '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(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: state.online && !copyMode && !answerPending && !loopForm, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
1060
+ : '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(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: !copyMode && !answerPending && !loopForm, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
1276
1061
  }
@@ -30,6 +30,8 @@ export declare function blockRows(run: ShellBlock, width: number): HistoryRow[];
30
30
  */
31
31
  export declare function mergeShellRuns(layout: RowSource, runs: readonly ShellBlock[], width: number): {
32
32
  total: number;
33
+ /** Translate a host row to its position after local blocks are inserted. */
34
+ hostRow(row: number): number;
33
35
  /** Merged row index of each block bar that opens a readable source, to its source id. */
34
36
  sources: ReadonlyMap<number, string>;
35
37
  viewport(start: number, end: number): HistoryRow[];
@@ -109,6 +109,14 @@ export function mergeShellRuns(layout, runs, width) {
109
109
  return {
110
110
  total,
111
111
  sources,
112
+ hostRow(row) {
113
+ for (const segment of segments) {
114
+ if (segment.host !== undefined && row >= segment.host && row < segment.host + segment.count) {
115
+ return segment.from + row - segment.host;
116
+ }
117
+ }
118
+ return total;
119
+ },
112
120
  viewport(start, end) {
113
121
  const rows = [];
114
122
  for (const segment of segments) {