@itookit/dsht 0.3.7 → 0.5.1

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 (132) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -11
  3. package/README.zh.md +31 -11
  4. package/dist/cli/dsht.js +207 -19
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +619 -164
  18. package/dist/controller/controller.js +1420 -141
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +82 -72
  49. package/dist/session/controller.js +211 -209
  50. package/dist/session/history.d.ts +9 -1
  51. package/dist/session/history.js +1 -9
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +97 -0
  77. package/dist/shell/controller.js +158 -0
  78. package/dist/shell/index.d.ts +5 -0
  79. package/dist/shell/index.js +3 -0
  80. package/dist/shell/runner.d.ts +38 -0
  81. package/dist/shell/runner.js +147 -0
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +865 -431
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/history-view.js +1 -1
  102. package/dist/ui/chat/loop-status.d.ts +11 -0
  103. package/dist/ui/chat/loop-status.js +28 -0
  104. package/dist/ui/chat/navigation-model.d.ts +86 -0
  105. package/dist/ui/chat/navigation-model.js +107 -0
  106. package/dist/ui/chat/shell-view.d.ts +47 -0
  107. package/dist/ui/chat/shell-view.js +145 -0
  108. package/dist/ui/chat/status.d.ts +47 -3
  109. package/dist/ui/chat/status.js +65 -50
  110. package/dist/ui/chat/viewport.d.ts +1 -1
  111. package/dist/ui/dialogs/cost.d.ts +21 -4
  112. package/dist/ui/dialogs/cost.js +7 -12
  113. package/dist/ui/dialogs/index.d.ts +22 -5
  114. package/dist/ui/dialogs/index.js +19 -3
  115. package/dist/ui/dialogs/loop.d.ts +43 -0
  116. package/dist/ui/dialogs/loop.js +224 -0
  117. package/dist/ui/dialogs/peek.d.ts +25 -0
  118. package/dist/ui/dialogs/peek.js +35 -0
  119. package/dist/ui/dialogs/picker.d.ts +2 -0
  120. package/dist/ui/dialogs/picker.js +4 -2
  121. package/dist/ui/input/mouse.d.ts +12 -2
  122. package/dist/ui/input/mouse.js +20 -7
  123. package/dist/ui/input/references.d.ts +1 -1
  124. package/dist/ui/status/model.d.ts +7 -0
  125. package/dist/ui/status/model.js +5 -0
  126. package/dist/ui/theme/index.d.ts +6 -1
  127. package/dist/ui/theme/index.js +2 -1
  128. package/package.json +6 -4
  129. package/dist/ui/commands/parse.d.ts +0 -99
  130. package/dist/ui/commands/parse.js +0 -126
  131. package/dist/ui/commands/registry.d.ts +0 -33
  132. package/dist/ui/commands/registry.js +0 -73
package/dist/ui/app.js CHANGED
@@ -6,53 +6,85 @@ 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
8
  import { ReferenceMenu } from "./input/references.js";
9
- import { historyLayout } from "../session/history.js";
10
- import { toolLine } from "../session/transcript.js";
11
- import { activeReference, fileMention } from "../session/references.js";
9
+ import { costText } from "./status/model.js";
10
+ import { toolLine } from "../text.js";
11
+ import { activeReference, fileMention } from "../references.js";
12
12
  import { CostPanel } from "./dialogs/cost.js";
13
13
  import { StatusBar } from "./chat/status.js";
14
14
  import { ChatHeader } from "./chat/header.js";
15
+ import { LoopStatus } from "./chat/loop-status.js";
15
16
  import { ChatViewport } from "./chat/viewport.js";
17
+ import { mergeShellRuns, plainRows } from "./chat/shell-view.js";
16
18
  import { Frozen } from "./frozen.js";
17
19
  import { CopyMode } from "./copy-mode.js";
18
- import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
19
- import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
20
- import { classifySubmission } from "./commands/parse.js";
21
- import { ROLLUP_LEGEND, sessionLabel, sessionStatus, workspaceCounts, workspaceDetail, workspaceSegments, workspaceStatus } from "../session/navigation.js";
22
- import { array, errorText, object, safeText, string } from "../transport/wire.js";
20
+ import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, PromptsDialog, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
21
+ import { LoopDialog, LoopMenu } from "./dialogs/loop.js";
22
+ import { PeekPanel } from "./dialogs/peek.js";
23
+ import { COMMAND_HINTS, argumentHint, completeCommand as completeDraft, suggestedCommands } from "../slash/registry.js";
24
+ import { interpret, normalize, authorize } from "../slash/index.js";
25
+ import { loopNameQuery } from "../slash/parse.js";
26
+ import { removalIntent, runCommand } from "../controller/commands.js";
27
+ import { sessionLabel } from "../session-title.js";
28
+ import { ROLLUP_LEGEND, sessionStatus, workspaceCounts, workspaceDetail, workspaceSegments, workspaceStatus } from "./chat/navigation-model.js";
29
+ import { array, string } from "../json.js";
30
+ import { errorText } from "../text.js";
31
+ import { safeText } from "../text.js";
23
32
  /** Transient notices expire; interactive panels remain open until dismissed. */
24
33
  const PANEL_LIFETIME_MS = 10_000;
25
34
  /** Pages one boundary recall press may walk before it reports that nothing older holds a prompt. */
26
35
  const RECALL_PAGE_SCAN = 20;
36
+ /** No folds: the read-only view has no expand state of its own, and one empty set keeps layout cached. */
37
+ const NO_FOLDS = new Set();
38
+ /** Rows one PgUp/PgDn moves in the read-only view. */
39
+ const PEEK_PAGE = 10;
40
+ /** How long a Ctrl+C that could not exit stays armed to exit on the next press. */
41
+ const FORCE_EXIT_MS = 5000;
27
42
  /** The caller owns starting and stopping the controller around the Ink render lifetime.
28
43
  * @param props - Controller, panel lifetime and semantic theme.
29
44
  * @returns The rendered terminal interface.
30
45
  */
31
46
  export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = mocha }) {
32
47
  const state = useSyncExternalStore(controller.subscribe, controller.snapshot);
33
- const presetId = controller.telemetry.view(state.sessionId).values.agentPreset;
48
+ const presetId = controller.queries.telemetry.view(state.sessionId).values.agentPreset;
34
49
  useEffect(() => { if (typeof presetId === 'string')
35
- controller.loadPresetNames(); }, [controller, state.online, presetId]);
50
+ controller.actions.loadPresetNames(); }, [controller, state.online, presetId]);
36
51
  const { exit } = useApp();
37
52
  const { stdout } = useStdout();
38
53
  const [copyMode, setCopyMode] = useState(false);
39
54
  // The composer belongs to the selected session: it resets when another session opens, and every
40
55
  // callback reads the controller rather than a render closure Ink may not have refreshed yet.
41
- const { draft: input, cursor } = state.session.composer;
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('');
42
63
  const historyPaging = useRef(false);
43
64
  const setInput = (value, recalled = false) => {
44
65
  if (!recalled)
45
- controller.resetRecall();
66
+ controller.actions.resetRecall();
46
67
  // A new message draft returns the view to the live end, so composing never needs a scroll first.
47
68
  // A slash command is not a message, and the reader keeps their place while typing one.
48
- if (state.screen === 'chat' && controller.composer.draft === '' && value !== '' && !value.startsWith('/'))
49
- controller.setScroll(0);
50
- controller.setComposer(value);
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);
51
75
  };
52
76
  // The reading view is session-owned too: which record is shown, where the reader is, and what is
53
77
  // expanded. `SessionInfo` releases the detached window, so the composer and this view reset together.
54
- const { window: historyWindow, scroll, folds: reasoningOverrides, liveReasoning } = state.session.view;
55
- const [historyLoading, setHistoryLoading] = useState();
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
+ const historyWindow = state.session.window;
84
+ /** The operation the controller owns right now; the front end only renders it. */
85
+ 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([]);
56
88
  const displayTranscript = historyWindow ?? state.session.record;
57
89
  const displayRef = useRef(displayTranscript);
58
90
  displayRef.current = displayTranscript;
@@ -66,17 +98,27 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
66
98
  }
67
99
  });
68
100
  const [removal, setRemoval] = useState();
69
- // Session-owned panels: visibility and query text only, because every row comes from the record.
70
- const { thoughts: thoughtList, queue: queueOpen, model: models, history, search: searchResults } = state.session.panels;
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 }));
71
115
  const historyQuery = history?.query;
72
116
  const contentSearch = history?.contentSearch === true;
73
117
  const historyMatches = history?.matches;
74
- const historyAbort = useRef(undefined);
75
- useEffect(() => () => historyAbort.current?.abort(), []);
76
118
  const [costExpanded, setCostExpanded] = useState(false);
77
119
  const [statusExpanded, setStatusExpanded] = useState(false);
78
120
  const reasoning = 'row';
79
- useEffect(() => { controller.setLiveReasoning('row'); }, [state.session.record, state.session.record.liveAttemptKey]);
121
+ useEffect(() => { setLiveReasoning('row'); }, [state.session.record, state.session.record.liveAttemptKey]);
80
122
  const [notice, setNotice] = useState();
81
123
  const [help, setHelp] = useState(false);
82
124
  const [helpPage, setHelpPage] = useState(0);
@@ -84,6 +126,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
84
126
  const helpPages = Math.ceil(COMMAND_HINTS.length / helpPageSize);
85
127
  const currentHelpPage = Math.min(helpPage, helpPages - 1);
86
128
  const [statusScroll, setStatusScroll] = useState(0);
129
+ /** When the last Ctrl+C that could not exit was armed, so the next one leaves regardless. */
130
+ const exitArmed = useRef(0);
87
131
  const [statusOverflow, setStatusOverflow] = useState(false);
88
132
  // The panel owns two border rows and a one-row footer, and the two header rows and the
89
133
  // three-row composer above it never shrink, so only the remainder of the screen height shows rows.
@@ -92,7 +136,12 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
92
136
  // Answers and menu highlights are session-owned too: the waterfall itself is derived per session
93
137
  // by the controller, so only the local selection is kept here.
94
138
  const { answers, option: optionState, approval: approvalSelection } = state.session.interaction;
95
- const { index: referenceIndex, dismissed: dismissedReference } = state.session.reference;
139
+ const [referenceIndex, setReferenceIndex] = useState(0);
140
+ const [dismissedReference, setDismissedReference] = useState();
141
+ // The `/loop` record list under the composer: which row is highlighted, and the draft Esc dismissed
142
+ // it for, so the next keystroke brings it back instead of it staying gone.
143
+ const [loopMenuIndex, setLoopMenuIndex] = useState(0);
144
+ const [dismissedLoopMenu, setDismissedLoopMenu] = useState();
96
145
  const [lookup, setLookup] = useState();
97
146
  // Interactive panels remain stable while read; only transient notices expire.
98
147
  useEffect(() => {
@@ -108,19 +157,97 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
108
157
  useEffect(() => {
109
158
  if (copyMode || contentSearch || historyQuery === undefined)
110
159
  return;
111
- const timer = setTimeout(() => { controller.setHistoryPanel(undefined); }, panelLifetimeMs);
160
+ const timer = setTimeout(() => { setHistoryPanel(undefined); }, panelLifetimeMs);
112
161
  return () => clearTimeout(timer);
113
162
  }, [historyQuery, contentSearch, panelLifetimeMs, copyMode]);
114
163
  const pending = state.pending[0];
115
- const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
116
- useEffect(() => { controller.openQueue(false); }, [state.sessionId, pending?.eventId]);
164
+ // The loop lives in the application; the UI only renders its progress snapshot.
165
+ const loop = controller.queries.loop;
166
+ // The record the open form edits, read from the same list the runner uses.
167
+ const loopRecord = loopForm === undefined ? undefined
168
+ : controller.queries.loopRecords.find(record => record.name === loopForm.name);
169
+ const queued = controller.queries.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
170
+ // The read-only view: the application owns which source is followed and what it holds, the front end
171
+ // owns only the reader's position in it.
172
+ const peek = controller.queries.peek;
173
+ const [peekScroll, setPeekScroll] = useState(0);
174
+ const peekBox = useRef(null);
175
+ const [peekRows, setPeekRows] = useState(20);
176
+ // The rows above the transcript are measured, not guessed: a click is placed by counting down from
177
+ // the first line, and the header and the connection/foreground notices are what stand above it.
178
+ const headerSection = useRef(null);
179
+ const bodyPrefix = useRef(null);
180
+ const [chromeRows, setChromeRows] = useState({ header: 2, prefix: 0 });
181
+ useLayoutEffect(() => {
182
+ if (peekBox.current) {
183
+ const height = Math.floor(measureElement(peekBox.current).height);
184
+ if (height !== peekRows)
185
+ setPeekRows(height);
186
+ }
187
+ });
188
+ // A new source is a new document: it opens at its newest row rather than wherever the last one was.
189
+ useEffect(() => { setPeekScroll(0); }, [peek?.source.id]);
190
+ useLayoutEffect(() => {
191
+ const header = headerSection.current ? Math.floor(measureElement(headerSection.current).height) : 0;
192
+ const prefix = bodyPrefix.current ? Math.floor(measureElement(bodyPrefix.current).height) : 0;
193
+ if (header !== chromeRows.header || prefix !== chromeRows.prefix)
194
+ setChromeRows({ header, prefix });
195
+ });
196
+ useEffect(() => {
197
+ setPanels({ thoughts: false, queue: false });
198
+ setReferenceIndex(0);
199
+ 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
+ setLoopMenuIndex(0);
209
+ setDismissedLoopMenu(undefined);
210
+ }, [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]);
117
215
  // A replayed interaction (same eventId after a reconnect) starts unselected again.
118
- useEffect(() => { controller.setApproval(undefined); }, [state.sessionId, state.online, pending?.eventId]);
119
- const token = state.screen === 'chat' && state.online && !state.busy && !pending
216
+ useEffect(() => { controller.actions.setApproval(undefined); }, [state.sessionId, state.online, pending?.eventId]);
217
+ const token = state.screen === 'chat' && state.online && foreground === undefined && !pending
120
218
  && !input.startsWith('/') && dismissedReference !== input && cursor === input.length
121
219
  ? activeReference(input) : undefined;
122
220
  const referenceOpen = token !== undefined;
123
- const dialogOpen = !!(queueOpen || removal || models || thoughtList || historyQuery !== undefined || searchResults || costExpanded || statusExpanded || help || pending || referenceOpen || state.screen !== 'chat');
221
+ // `/loop` offers its records while the draft is still a name: the composer menu is the one place a
222
+ // name can be chosen, and every row carries the defaults the form would open with. Once a second
223
+ // word (a flag or value) is typed the operator has decided, so the menu steps aside. The candidates
224
+ // survive a dismissal, because Esc only hides the list — it does not withdraw the choice.
225
+ const loopDraft = state.screen === 'chat' && state.online && foreground === undefined && !pending
226
+ && !copyMode && !referenceOpen ? loopNameQuery(input) : undefined;
227
+ const loopCandidates = loopDraft === undefined ? []
228
+ : controller.queries.loopRecords.filter(record => record.name.startsWith(loopDraft));
229
+ const loopMenuOpen = loopCandidates.length > 0 && loopForm === undefined && dismissedLoopMenu !== input;
230
+ 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 ?? []))];
250
+ const dialogOpen = !!(openSurfaces.length || pending || referenceOpen || state.screen !== 'chat');
124
251
  const displayPaused = copyMode || dialogOpen;
125
252
  // Startup screens need live connection feedback even while their picker remains open.
126
253
  const statusPaused = copyMode || (state.screen === 'chat' && dialogOpen);
@@ -130,8 +257,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
130
257
  return;
131
258
  const abort = new AbortController();
132
259
  setLookup(undefined);
133
- controller.setReferenceIndex(0);
134
- void controller.references(activeReference(input).query, abort.signal).then(items => {
260
+ setReferenceIndex(0);
261
+ void controller.queries.references(activeReference(input).query, abort.signal).then(items => {
135
262
  if (!abort.signal.aborted)
136
263
  setLookup({ draft: input, sessionId: state.sessionId, items });
137
264
  }, error => {
@@ -142,7 +269,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
142
269
  }, [controller, input, state.sessionId, referenceOpen]);
143
270
  const pickReference = () => {
144
271
  const candidate = matches?.items[referenceIndex];
145
- if (!candidate || !token || controller.composer.draft !== input)
272
+ if (!candidate || !token)
146
273
  return;
147
274
  const mention = fileMention(candidate, token.quoted);
148
275
  setInput(input.slice(0, -token.prefix.length) + mention + (candidate.kind === 'file' ? ' ' : ''));
@@ -153,64 +280,100 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
153
280
  if (completed !== undefined)
154
281
  setInput(completed);
155
282
  };
156
- const questions = pending?.event === 'user-questions/request' ? array(object(pending.request).questions).map(object) : [];
157
- const eventId = pending ? string(pending.eventId) : '';
283
+ const questions = pending?.kind === 'question' ? pending.questions : [];
284
+ const eventId = pending?.eventId ?? '';
158
285
  const answered = answers[eventId] ?? [];
159
286
  const question = questions[answered.length];
160
287
  const optionKey = `${eventId}:${answered.length}`;
161
- const options = question ? array(question.options ?? []).map(object) : [];
288
+ const options = question ? question.options : [];
162
289
  const choiceState = optionState?.key === optionKey ? optionState : { key: optionKey, cursor: 0, selected: [], custom: false };
163
290
  const optionCursor = Math.min(choiceState.cursor, options.length);
164
291
  // Reserve the header, composer and question instructions; each choice may have a description.
165
292
  const optionPageSize = Math.max(1, Math.min(6, Math.floor(((stdout.rows ?? 30) - 16) / 2)));
166
293
  const optionStart = Math.max(0, optionCursor - optionPageSize + 1);
167
- // One open panel owns the arrow and digit keys; the picker screens and the composer are not keyboard owners.
168
- const panelBlocksKeys = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || help || costExpanded || statusExpanded);
294
+ // One open surface owns the arrow and digit keys; the picker screens and the composer are not
295
+ // keyboard owners. The traits live in `surfaces`, so no panel is named here.
296
+ const panelBlocksKeys = openSurfaces.some(surface => surface.blocksKeys);
169
297
  // Composer recall yields only to a surface that uses the arrows itself: the pickers, and a status
170
298
  // panel with more lines than the view holds. The help and cost panels and a fitting status panel
171
299
  // leave the arrows with the history, and Ctrl+P/N reach it from every surface.
172
- const recallBlocked = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || (statusExpanded && statusOverflow));
300
+ const recallBlocked = openSurfaces.some(surface => surface.arrows) || (statusExpanded && statusOverflow) || composerIntent !== undefined;
173
301
  const questionKeysActive = !!question && options.length > 0 && !choiceState.custom && !copyMode && !panelBlocksKeys;
174
302
  // A dialog that demands an answer owns the keyboard until it is settled: the draft being written is
175
303
  // parked rather than typed into, and comes back when the last one closes. A question switches to
176
304
  // the composer itself for a free-text or "Other" answer, so it keeps the keys then.
177
- const answerPending = !!pending && (pending.event === 'approval/request' || options.length > 0 && !choiceState.custom);
305
+ const answerPending = !!pending && (pending.kind === 'approval' || options.length > 0 && !choiceState.custom);
178
306
  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.
179
310
  if (answerPending) {
180
- controller.parkComposer();
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
+ }
181
320
  return;
182
321
  }
183
322
  if (pending)
184
323
  return;
185
- // Restoring writes the draft directly: it was neither recalled from history nor a new message,
186
- // so it must not move a reader's scroll position.
187
- controller.restoreComposer();
324
+ if (parkedDraft.current !== '') {
325
+ const parked = parkedDraft.current;
326
+ parkedDraft.current = '';
327
+ setInputValue(parked);
328
+ setCursor(parked.length);
329
+ }
188
330
  }, [answerPending, pending]);
189
- const approvalKeysActive = pending?.event === 'approval/request' && !copyMode && !panelBlocksKeys;
331
+ const approvalKeysActive = pending?.kind === 'approval' && !copyMode && !panelBlocksKeys;
190
332
  const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
191
- const answerQuestion = async (selected, custom) => {
192
- if (controller.state.pending[0]?.eventId !== eventId)
193
- throw new Error('The pending question has changed');
194
- const answer = { id: string(question.id), selected, ...(custom ? { custom } : {}) };
195
- const next = [...answered, answer];
196
- if (next.length === questions.length) {
197
- await controller.answer({ answers: next });
198
- const rest = { ...controller.interaction.answers };
199
- delete rest[eventId];
200
- controller.setAnswers(rest);
201
- }
202
- else
203
- controller.setAnswers({ ...controller.interaction.answers, [eventId]: next });
204
- controller.setOption(undefined);
205
- };
206
- const operate = (fn) => { void controller.perform(fn); };
207
- const requestRemoval = async (kind, query) => {
208
- const target = await controller.removalTarget(kind, query);
209
- if (target.kind === 'session' && target.empty)
210
- await controller.removeTarget(target);
211
- else
212
- setRemoval(target);
213
- };
333
+ // Actions own their busy/error envelope; this only surfaces a UI-local orchestration failure.
334
+ const operate = (fn) => { void fn().catch(error => setNotice(errorText(error))); };
335
+ /** Resolve and apply a removal; shared by the pickers' `d` key and the delete commands. */
336
+ async function requestRemoval(kind, query) {
337
+ const effects = await removalIntent(controller, kind, query);
338
+ if (effects === undefined)
339
+ return false;
340
+ for (const effect of effects)
341
+ await applyEffect(effect);
342
+ return true;
343
+ }
344
+ /** Run one record with the values the form confirmed.
345
+ *
346
+ * The line is handed back to the application as a parsed command, so the form and a typed command
347
+ * start a run through exactly the same path and cannot disagree about what the values mean.
348
+ * @param name - Record key to run.
349
+ * @param run - The four limits and the record variables as the form left them.
350
+ */
351
+ function startLoopFromForm(name, run) {
352
+ controller.traceNote('loop-ui', { phase: 'start', name, from: run.limits.from, to: run.limits.to,
353
+ score: run.limits.score, tries: run.limits.tries, vars: Object.keys(run.vars) });
354
+ operate(async () => {
355
+ const port = { interactive: true, run: (label, operation) => controller.actions.foreground('command', label, operation) };
356
+ // Start is a second submission (§3.4): the form may have been open while a turn started or a
357
+ // question arrived, so the line is authorized again here rather than trusting the earlier one.
358
+ const verdict = verdictFor({ kind: 'loop', name, options: { ...run.limits, vars: run.vars } });
359
+ // A turn that started while the form was open holds the run: the values are confirmed, so the
360
+ // line is queued and the form closes, exactly like a refusal keeps it open.
361
+ if (holdIfDeferred(verdict, `/loop ${name}`)) {
362
+ setLoopForm(undefined);
363
+ return;
364
+ }
365
+ const result = await runCommand(controller, verdict.allow ? verdict.command : verdict.error, port);
366
+ // A rejected result carries its own error and keeps the form open; only a missing one would leave
367
+ // Start looking ignored, so it says the same thing here.
368
+ if (result === undefined) {
369
+ setNotice(`Loop did not start: ${controller.state.lastFailure || 'the host did not accept the request'}`);
370
+ return;
371
+ }
372
+ await applyResult(result);
373
+ if (result.disposition === 'consume')
374
+ setLoopForm(undefined);
375
+ });
376
+ }
214
377
  useInput((_value, key) => {
215
378
  if (key.eventType === 'release')
216
379
  return;
@@ -219,69 +382,200 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
219
382
  setCopyMode(false);
220
383
  return;
221
384
  }
385
+ // The loop form owns every key but Ctrl+C: it reads them itself, and leaving them to this handler
386
+ // would let a digit meant for a value start a composer draft or a recall step.
387
+ if (loopForm && !(key.ctrl && _value === 'c'))
388
+ return;
222
389
  if (key.ctrl && _value === 's') {
223
390
  setCopyMode(true);
224
391
  return;
225
392
  }
226
- if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
227
- historyAbort.current.abort();
228
- // Esc also leaves a history list that was already on screen when the load was aborted.
229
- if (key.escape && historyQuery !== undefined) {
230
- controller.setHistoryPanel(undefined);
393
+ // Esc is a table, not a chain (§5.4): the first rule whose condition holds wins. A table is what
394
+ // keeps a new surface from either shipping with no Escape rule or placing one after the fallback,
395
+ // where it can never run — the two mistakes this replaces.
396
+ const escapeRules = [
397
+ // 1 Copy mode is handled above: it owns the keyboard outright.
398
+ // 2 A borrowed composer drops the edit and the draft it loaded.
399
+ { when: composerIntent !== undefined, run: () => { setInput(''); setComposerIntent(undefined); } },
400
+ // 3 A cancellable load in flight is the most urgent thing Esc can stop.
401
+ { when: foreground !== undefined, run: () => {
402
+ controller.actions.cancelForeground();
403
+ // Esc also leaves a history list that was already on screen when the load was aborted.
404
+ if (historyQuery !== undefined)
405
+ setHistoryPanel(undefined);
406
+ } },
407
+ // 4 The read-only view sits on top of the conversation it was opened from.
408
+ { when: peek !== undefined, run: () => controller.actions.closePeek() },
409
+ // 5 An approval or a question owns the keyboard until it is settled.
410
+ { when: pending !== undefined, run: () => {
411
+ controller.actions.setApproval(undefined);
412
+ setRemoval(undefined);
413
+ setModelPanel(undefined);
414
+ openThoughts(false);
415
+ setSearchPanel(undefined);
416
+ setHistoryPanel(undefined);
417
+ setHelp(false);
418
+ setCostExpanded(false);
419
+ setStatusExpanded(false);
420
+ // A question batch is one request, so Esc leaves the whole set the way the Web client's close
421
+ // button does. The free-text row keeps its own step back: its first Esc returns to the options,
422
+ // and only the next one dismisses. Approval keeps every choice explicit.
423
+ if (pending?.kind === 'question' && !choiceState.custom) {
424
+ if (controller.state.online && controller.queries.foreground === undefined && controller.state.pending[0]?.eventId === eventId) {
425
+ controller.actions.setOption(undefined);
426
+ const rest = { ...controller.queries.interaction.answers };
427
+ delete rest[eventId];
428
+ controller.actions.setAnswers(rest);
429
+ operate(() => controller.actions.dismissQuestion());
430
+ }
431
+ return;
432
+ }
433
+ controller.actions.setOption({ ...choiceState, custom: false });
434
+ } },
435
+ // 6 The panels a command opened.
436
+ { when: queueOpen === true, run: () => openQueue(false) },
437
+ { when: promptsOpen === true, run: () => openPrompts(false) },
438
+ { when: removal !== undefined, run: () => setRemoval(undefined) },
439
+ { when: models !== undefined, run: () => setModelPanel(undefined) },
440
+ { when: thoughtList === true, run: () => { openThoughts(false); if (controller.queries.running)
441
+ void controller.actions.interrupt(true); } },
442
+ { when: searchResults !== undefined, run: () => { setSearchPanel(undefined); if (controller.queries.running)
443
+ void controller.actions.interrupt(true); } },
444
+ { when: historyQuery !== undefined, run: () => { setHistoryPanel(undefined); if (controller.queries.running)
445
+ void controller.actions.interrupt(true); } },
446
+ // 7 Screens. The typed host path is one of them: the picker behind it is disabled while a draft
447
+ // exists, so a leftover path would leave no way back at all.
448
+ { when: state.screen === 'path', run: () => { setInput(''); operate(() => controller.actions.showPicker('workspaces')); } },
449
+ // A picker opened over a conversation is a detour, so Esc returns to that conversation; from the
450
+ // session list with nothing selected it steps back to the workspace list instead, which is the
451
+ // only thing behind the startup picker.
452
+ { when: state.screen === 'sessions', run: () => { if (!controller.actions.showChat())
453
+ operate(() => controller.actions.showPicker('workspaces')); } },
454
+ { when: state.screen === 'workspaces', run: () => { controller.actions.showChat(); } },
455
+ // 8 The composer's own menus: hiding one never undoes the choice it was offering.
456
+ { when: loopMenuOpen === true, run: () => setDismissedLoopMenu(input) },
457
+ { when: referenceOpen === true, run: () => { setDismissedReference(input); if (controller.queries.running)
458
+ void controller.actions.interrupt(true); } },
459
+ // 9 The read-only surfaces.
460
+ { when: help || costExpanded || statusExpanded || notice !== undefined, run: () => {
461
+ setHelp(false);
462
+ setCostExpanded(false);
463
+ setStatusExpanded(false);
464
+ setStatusScroll(0);
465
+ setNotice(undefined);
466
+ if (controller.queries.running)
467
+ void controller.actions.interrupt(true);
468
+ } },
469
+ // 10 A local command in the transcript is more immediate than the agent behind it.
470
+ { when: state.shell.running && input === '' && !panelBlocksKeys && pending === undefined && state.screen === 'chat',
471
+ run: () => controller.shell.cancel() },
472
+ // 11 Anything left on the conversation screen cancels whatever the agent is doing.
473
+ { when: state.screen === 'chat', run: () => { void controller.actions.interrupt(true); } },
474
+ ];
475
+ if (key.escape) {
476
+ const rule = escapeRules.find(candidate => candidate.when);
477
+ if (rule !== undefined) {
478
+ rule.run();
479
+ return;
480
+ }
481
+ }
482
+ // The read-only view owns the arrows and paging: it is a document the reader scrolls, so its keys
483
+ // must not fall through to prompt recall or to the conversation behind it.
484
+ if (peek !== undefined && !key.ctrl && !key.meta) {
485
+ if (key.pageUp) {
486
+ setPeekScroll(value => value + PEEK_PAGE);
487
+ return;
488
+ }
489
+ if (key.pageDown) {
490
+ setPeekScroll(value => Math.max(0, value - PEEK_PAGE));
491
+ return;
492
+ }
493
+ if (key.upArrow) {
494
+ setPeekScroll(value => value + 1);
495
+ return;
496
+ }
497
+ if (key.downArrow) {
498
+ setPeekScroll(value => Math.max(0, value - 1));
499
+ return;
231
500
  }
501
+ }
502
+ // Ctrl+O opens the read-only view on the newest source — the work most likely worth watching — so
503
+ // the view is reachable without a list. Clicking a transcript row opens a specific source.
504
+ if (key.ctrl && _value === 'o' && state.screen === 'chat' && pending === undefined) {
505
+ const source = controller.queries.sources[0];
506
+ if (source !== undefined)
507
+ controller.actions.openPeek(source.id);
232
508
  return;
233
509
  }
234
- if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
235
- if (key.ctrl && controller.composer.draft)
510
+ // Ctrl+C is not Esc: it never dismisses a dialog, and when nothing is pending it stops the agent
511
+ // and then exits. The branches below keep that order.
512
+ if (key.ctrl && _value === 'c') {
513
+ // Every branch below can fail to end the client: the host may never report the turn idle, an
514
+ // operation may ignore the abort, an interaction may be waiting. A press that could not exit
515
+ // arms the next one, so no state can trap the reader. Leaving still asks the host to stop the
516
+ // selected turn, because every exit path runs `Controller.shutdown`.
517
+ if (exitArmed.current !== 0 && Date.now() - exitArmed.current < FORCE_EXIT_MS) {
518
+ exitArmed.current = 0;
519
+ exit();
520
+ return;
521
+ }
522
+ exitArmed.current = 0;
523
+ /** Arm the escape after a press that asked for something and did not get to leave. */
524
+ const offerExit = () => {
525
+ exitArmed.current = Date.now();
526
+ setNotice('Press Ctrl+C again to exit without waiting for the host');
527
+ };
528
+ if (controller.queries.foreground !== undefined) {
529
+ controller.actions.cancelForeground();
530
+ offerExit();
531
+ return;
532
+ }
533
+ if (controller.state.pending.length) {
534
+ if (input)
535
+ setInput('');
536
+ offerExit();
537
+ return;
538
+ }
539
+ if (input === '' && state.shell.running) {
540
+ controller.shell.cancel();
541
+ return;
542
+ }
543
+ // A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
544
+ if (input !== '') {
236
545
  setInput('');
237
- if (key.escape) {
238
- controller.setApproval(undefined);
239
- setRemoval(undefined);
240
- controller.setModelPanel(undefined);
241
- controller.openThoughts(false);
242
- controller.setSearchPanel(undefined);
243
- controller.setHistoryPanel(undefined);
244
- setHelp(false);
245
- setCostExpanded(false);
246
- setStatusExpanded(false);
247
- // A question batch is one request, so Esc leaves the whole set the way the Web client's
248
- // close button does. The free-text row keeps its own step back: its first Esc returns to
249
- // the options, and only the next one dismisses. Approval keeps every choice explicit.
250
- if (pending?.event === 'user-questions/request' && !choiceState.custom) {
251
- if (controller.state.online && !controller.state.busy && controller.state.pending[0]?.eventId === eventId) {
252
- controller.setOption(undefined);
253
- const rest = { ...controller.interaction.answers };
254
- delete rest[eventId];
255
- controller.setAnswers(rest);
256
- operate(() => controller.dismissQuestion());
257
- }
258
- return;
259
- }
260
- controller.setOption({ ...choiceState, custom: false });
546
+ return;
261
547
  }
548
+ if (composerIntent) {
549
+ setComposerIntent(undefined);
550
+ return;
551
+ }
552
+ void controller.actions.interrupt().then(shouldExit => { if (shouldExit)
553
+ exit();
554
+ else
555
+ offerExit(); });
262
556
  return;
263
557
  }
264
- if (approvalKeysActive && !controller.composer.draft && controller.state.online && !controller.state.busy
558
+ if (approvalKeysActive && !input && controller.state.online && controller.queries.foreground === undefined
265
559
  && controller.state.pending[0]?.eventId === eventId && !key.ctrl && !key.meta) {
266
560
  const digit = /^[1-3]$/.test(_value) ? Number(_value) - 1 : -1;
267
561
  if (digit >= 0 || key.upArrow || key.downArrow) {
268
562
  // An unselected list enters at the first, non-destructive choice, so a stray arrow plus Enter cannot cancel.
269
563
  const index = digit >= 0 ? digit : approvalIndex < 0 ? 0
270
564
  : Math.max(0, Math.min(2, approvalIndex + (key.upArrow ? -1 : 1)));
271
- controller.setApproval({ eventId, index });
565
+ controller.actions.setApproval({ eventId, index });
272
566
  return;
273
567
  }
274
568
  if (key.return) {
275
569
  // Choice 3 cancels the turn instead of answering the request, so it sends no event result.
276
570
  if (approvalIndex >= 0)
277
- operate(() => approvalIndex === 2 ? controller.cancelTurn() : controller.approve(approvalIndex === 0));
571
+ operate(() => approvalIndex === 2 ? controller.actions.cancelTurn() : controller.actions.approve(approvalIndex === 0));
278
572
  return;
279
573
  }
280
574
  }
281
- if (questionKeysActive && !controller.composer.draft && !controller.state.busy && !key.ctrl && !key.meta) {
575
+ if (questionKeysActive && !input && controller.queries.foreground === undefined && !key.ctrl && !key.meta) {
282
576
  const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
283
577
  if (key.upArrow || key.downArrow) {
284
- controller.setOption({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
578
+ controller.actions.setOption({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
285
579
  return;
286
580
  }
287
581
  if (digit >= 0 && digit <= options.length || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
@@ -290,12 +584,12 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
290
584
  const selected = question.multiSelect === true && label
291
585
  ? choiceState.selected.includes(label) ? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label]
292
586
  : choiceState.selected;
293
- controller.setOption({ ...choiceState, cursor: index, selected });
587
+ controller.actions.setOption({ ...choiceState, cursor: index, selected });
294
588
  return;
295
589
  }
296
590
  if (key.return) {
297
591
  if (optionCursor === options.length) {
298
- controller.setOption({ ...choiceState, custom: true });
592
+ controller.actions.setOption({ ...choiceState, custom: true });
299
593
  return;
300
594
  }
301
595
  const selected = question.multiSelect === true ? choiceState.selected : [string(options[optionCursor].label)];
@@ -303,7 +597,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
303
597
  setNotice('Select at least one option with Space or a number');
304
598
  return;
305
599
  }
306
- operate(() => answerQuestion(selected));
600
+ operate(() => controller.actions.answerQuestion({ selected }));
307
601
  return;
308
602
  }
309
603
  }
@@ -323,291 +617,313 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
323
617
  scrollHistory(key.pageUp ? 10 : -10);
324
618
  return;
325
619
  }
326
- if (key.escape && queueOpen) {
327
- controller.openQueue(false);
328
- return;
329
- }
330
- if (key.escape && removal) {
331
- setRemoval(undefined);
332
- return;
333
- }
334
- if (key.escape && models) {
335
- controller.setModelPanel(undefined);
336
- return;
337
- }
338
- if (key.escape && thoughtList) {
339
- controller.openThoughts(false);
340
- if (controller.running)
341
- void controller.interrupt(true);
342
- return;
343
- }
344
- if (key.escape && searchResults) {
345
- controller.setSearchPanel(undefined);
346
- if (controller.running)
347
- void controller.interrupt(true);
348
- return;
349
- }
350
- if (key.escape && historyQuery !== undefined) {
351
- controller.setHistoryPanel(undefined);
352
- if (controller.running)
353
- void controller.interrupt(true);
354
- return;
355
- }
356
- // The typed host path is a screen of its own, so Esc has to leave it: the picker behind it is
357
- // disabled while a draft exists, so a leftover path would leave no way back at all.
358
- if (key.escape && state.screen === 'path') {
359
- setInput('');
360
- operate(() => controller.showPicker('workspaces'));
361
- return;
362
- }
363
- if (key.ctrl && _value === 'c') {
364
- // A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
365
- if (input !== '') {
366
- setInput('');
620
+ if (loopMenuOpen) {
621
+ if (key.tab) {
622
+ const chosen = loopCandidates[loopMenuCursor];
623
+ if (chosen !== undefined)
624
+ setInput(`/loop ${chosen.name}`);
625
+ return;
626
+ }
627
+ if (key.upArrow) {
628
+ setLoopMenuIndex(Math.max(0, loopMenuCursor - 1));
629
+ return;
630
+ }
631
+ if (key.downArrow) {
632
+ setLoopMenuIndex(Math.min(loopCandidates.length - 1, loopMenuCursor + 1));
367
633
  return;
368
634
  }
369
- void controller.interrupt().then(shouldExit => { if (shouldExit)
370
- exit(); });
371
- return;
372
635
  }
373
636
  if (referenceOpen) {
374
- if (key.escape) {
375
- controller.setReferenceDismissed(input);
376
- if (controller.running)
377
- void controller.interrupt(true);
378
- }
379
- else if (key.tab)
637
+ if (key.tab)
380
638
  pickReference();
381
639
  else if (key.upArrow)
382
- controller.setReferenceIndex(Math.max(0, controller.reference.index - 1));
640
+ setReferenceIndex(Math.max(0, referenceIndex - 1));
383
641
  else if (key.downArrow)
384
- controller.setReferenceIndex(Math.max(0, Math.min((matches?.items.length ?? 1) - 1, controller.reference.index + 1)));
642
+ setReferenceIndex(Math.max(0, Math.min((matches?.items.length ?? 1) - 1, referenceIndex + 1)));
385
643
  return;
386
644
  }
387
645
  const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
388
646
  const recallNext = key.downArrow || key.ctrl && _value === 'n';
389
- if ((recallPrevious || recallNext) && state.online && !controller.state.busy && !pending
647
+ if ((recallPrevious || recallNext) && state.online && controller.queries.foreground === undefined && !pending
390
648
  && !queueOpen && (!recallBlocked || key.ctrl)
391
- && (state.screen === 'chat' || controller.composer.draft !== '' || key.ctrl)) {
649
+ && (state.screen === 'chat' || input !== '' || key.ctrl)) {
392
650
  // The oldest seeded entry is where the session happened to open, not where it began: stepping
393
651
  // past it fetches the page before the retained window, and the step is applied once the page
394
652
  // lands, so recall covers prompts from before this client connected. Paging stays with the
395
653
  // conversation: on a picker screen the arrows only ever walked what was already retained.
396
654
  if (recallPrevious && state.screen === 'chat' && !historyPaging.current
397
- && (controller.recallAtOldest || controller.recallLength === 0)
398
- && state.session.record.ready && controller.recallHasOlder) {
655
+ && (controller.queries.recallAtOldest || controller.queries.recallLength === 0)
656
+ && state.session.record.ready && controller.queries.recallHasOlder) {
399
657
  void recallOlderPrompts();
400
658
  return;
401
659
  }
402
- setInput(controller.recall(recallPrevious ? -1 : 1, controller.composer.draft), true);
660
+ setInput(controller.queries.recall(recallPrevious ? -1 : 1, input), true);
403
661
  return;
404
662
  }
405
- if (key.tab) {
663
+ if (key.tab && !composerIntent) {
406
664
  completeCommand();
407
665
  return;
408
666
  }
409
- if (key.escape && (help || costExpanded || statusExpanded || notice !== undefined)) {
410
- setHelp(false);
411
- setCostExpanded(false);
412
- setStatusExpanded(false);
413
- setStatusScroll(0);
414
- setNotice(undefined);
415
- if (controller.running)
416
- void controller.interrupt(true);
417
- return;
418
- }
419
- if (key.escape && state.screen === 'chat') {
420
- void controller.interrupt(true);
421
- }
422
667
  });
668
+ /** The application facts `normalize`/`authorize` read, read in one place.
669
+ *
670
+ * Every submission uses these — the composer's line and the loop form's Start — so a command cannot
671
+ * pass one door and not the other (§3.4: authorize is per submission, not per user action).
672
+ */
673
+ function applicationFacts() {
674
+ return {
675
+ sessionSelected: state.sessionId !== undefined,
676
+ pending: pending !== undefined,
677
+ foreground: foreground !== undefined,
678
+ // A loop is reported as such because stopping it is what the refusal has to name.
679
+ during: controller.queries.loop?.active === true ? 'loop'
680
+ : controller.queries.running ? 'turn' : 'idle',
681
+ };
682
+ }
683
+ /** What a held line is waiting for, in the words the notice uses. */
684
+ function describeDefer(defer) {
685
+ return defer === 'busy' ? 'the running operation finishes' : defer === 'loop' ? 'the loop ends' : 'the turn finishes';
686
+ }
687
+ /** Hold one accepted-but-deferred line, and say whether it was deferred.
688
+ *
689
+ * A deferred line is the operator's committed submission, not a draft, so the caller is free to use
690
+ * up its draft; the queue runs it when the fact it named clears.
691
+ * @param verdict - What `authorize` said about the line.
692
+ * @param label - Text the notice shows.
693
+ * @returns True when the line was queued instead of running now.
694
+ */
695
+ function holdIfDeferred(verdict, label) {
696
+ if (!verdict.allow || verdict.defer === undefined)
697
+ return false;
698
+ const defer = verdict.defer;
699
+ controller.traceNote('command', { phase: 'queued', kind: verdict.command.kind, reason: defer });
700
+ setQueuedLines(list => [...list, { command: verdict.command, defer, sessionId: state.sessionId }]);
701
+ setNotice(`Queued ${label} · runs when ${describeDefer(verdict.defer)}`);
702
+ return true;
703
+ }
704
+ /** Authorize one already-normalized line against the facts as they are *now*. */
705
+ function verdictFor(command) {
706
+ const facts = applicationFacts();
707
+ return authorize(command, { sessionSelected: facts.sessionSelected, pending: facts.pending,
708
+ during: facts.during, foreground: facts.foreground });
709
+ }
423
710
  const submit = async (raw) => {
424
- const submission = classifySubmission(raw, {
425
- referenceOpen, copyMode, pending: pending !== undefined, question: question !== undefined, screen: state.screen,
426
- });
427
- if (submission.kind === 'ignore')
711
+ // A borrowed composer commits on Enter instead of sending, so an edit is never delivered.
712
+ 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
+ }
428
722
  return;
429
- if (submission.kind === 'reference') {
723
+ }
724
+ // A composer menu completes the draft before it is submitted: Enter on the record list accepts the
725
+ // highlighted record exactly as if its name had been typed, so even that decision belongs to the
726
+ // pipeline below — the application still says whether a bare known name opens its form.
727
+ const highlighted = loopDraft !== undefined && loopForm === undefined ? loopCandidates[loopMenuCursor] : undefined;
728
+ if (highlighted !== undefined) {
729
+ controller.traceNote('loop-ui', { phase: 'choose', draft: loopDraft ?? '', index: loopMenuCursor,
730
+ candidates: loopCandidates.length, chosen: highlighted.name });
731
+ }
732
+ const line = highlighted === undefined ? raw : `/loop ${highlighted.name}`;
733
+ // The pipeline decides what the line means; the root only carries out the three stages' results.
734
+ const submission = interpret({ line, referenceOpen, copyMode, screen: state.screen });
735
+ if (submission.kind === 'mode') {
736
+ if (submission.action.kind === 'ignore')
737
+ return;
430
738
  pickReference();
431
739
  return;
432
740
  }
433
- const value = raw.trim();
434
- if (!pending && !/^\/feedback(?:\s|$)/.test(value))
435
- controller.recordRecall(value);
436
- if (submission.kind === 'copy') {
741
+ const facts = applicationFacts();
742
+ const command = normalize(submission, { sessionSelected: facts.sessionSelected, question: question !== undefined, pending: facts.pending });
743
+ // One line completes before the next may start: `authorize` refuses a second submission while the
744
+ // foreground slot is taken, with a reason the operator can read, and admits a control command
745
+ // through (its whole purpose is to interrupt whatever owns the slot).
746
+ const verdict = verdictFor(command);
747
+ // A line the policy holds runs when the fact it named clears (A2). It is accepted here — the
748
+ // operator pressed Enter, so the draft is used up — and the queue below runs it later; a line that
749
+ // was *refused* keeps the draft and is never sent on the operator's behalf (D1).
750
+ if (holdIfDeferred(verdict, line.trim())) {
437
751
  setInput('');
438
- setCopyMode(true);
439
752
  return;
440
753
  }
441
- setRemoval(undefined);
442
- // Each panel belongs to the command that opened it, so any other command closes it.
443
- if (value !== '/queue')
444
- controller.openQueue(false);
445
- if (!/^\/model(?: |$)/.test(value))
446
- controller.setModelPanel(undefined);
447
- if (value !== '/help')
448
- setHelp(false);
449
- if (value !== '/cost')
450
- setCostExpanded(false);
451
- if (value !== '/status')
452
- setStatusExpanded(false);
453
- if (!/^\/think(?: |$)/.test(value))
454
- controller.openThoughts(false);
455
- if (submission.kind === 'quit') {
456
- exit();
457
- return;
458
- }
459
- if (submission.kind === 'panel') {
460
- if (submission.panel === 'cost') {
461
- setCostExpanded(value => !value);
462
- setInput('');
463
- if (!costExpanded)
464
- void controller.perform(() => historyOperation(signal => controller.refreshCosts(signal)));
465
- }
466
- else if (submission.panel === 'status') {
467
- setStatusExpanded(value => !value);
468
- setStatusScroll(0);
469
- setInput('');
470
- }
471
- else {
472
- setHelp(value => !value);
473
- setHelpPage(0);
754
+ const executable = verdict.allow ? verdict.command : verdict.error;
755
+ const value = line.trim();
756
+ if (!pending && !/^\/feedback(?:\s|$)/.test(value))
757
+ controller.actions.recordRecall(value);
758
+ await runLine(executable, raw);
759
+ };
760
+ /** Run one authorized line under the UI's port and apply what it returns.
761
+ *
762
+ * `draft` is the composer text to clear when the line is consumed; a line the UI runs from its own
763
+ * queue passes none, because that draft was used up when the operator submitted it.
764
+ */
765
+ async function runLine(executable, draft) {
766
+ // The application decides what the command does and returns the effects; the UI applies them in
767
+ // order. The port says a surface can be shown, which is what lets `/loop <name>` confirm defaults.
768
+ const port = { interactive: true, run: (label, operation) => controller.actions.foreground('command', label, operation) };
769
+ try {
770
+ const result = await runCommand(controller, executable, port);
771
+ if (result === undefined)
772
+ return;
773
+ await applyResult(result);
774
+ // Consuming the line is the application's decision, so the composer never guesses it. Only the
775
+ // line that was submitted is cleared: the operation may have taken long enough for the operator
776
+ // 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)
474
778
  setInput('');
475
- }
779
+ }
780
+ catch (error) {
781
+ setNotice(errorText(error));
782
+ }
783
+ }
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');
476
801
  return;
477
802
  }
478
- const accepted = await controller.perform(async () => {
479
- switch (submission.kind) {
480
- case 'remove':
481
- await requestRemoval(submission.target, submission.query);
482
- return;
483
- case 'navigate': {
484
- controller.setHistoryPanel(undefined);
485
- controller.setSearchPanel(undefined);
486
- if (submission.target === 'workspace')
487
- await controller.switchWorkspace(submission.query);
488
- else
489
- await controller.switchSession(submission.query);
490
- controller.setScroll(0);
491
- return;
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
+ /** Apply one command result: its effects in the order the application emitted them.
812
+ *
813
+ * This is the whole front-end half of a command — the UI names no command here and never reorders.
814
+ * @param result - What the application did and what should be shown.
815
+ */
816
+ async function applyResult(result) {
817
+ // `closePanels` must know which panel this result keeps, and the contract puts it first, so the
818
+ // kept panel is read from the whole list before any effect runs.
819
+ const keep = result.effects.find(effect => effect.kind === 'open' || effect.kind === 'toggle');
820
+ const keepPanel = keep?.kind === 'open' || keep?.kind === 'toggle' ? keep.panel : undefined;
821
+ for (const effect of result.effects)
822
+ await applyEffect(effect, keepPanel);
823
+ }
824
+ /** Apply one presentational verb. */
825
+ async function applyEffect(effect, keepPanel) {
826
+ switch (effect.kind) {
827
+ case 'quit':
828
+ exit();
829
+ return;
830
+ case 'copy':
831
+ setCopyMode(true);
832
+ return;
833
+ case 'closePanels':
834
+ closePanelsExcept(keepPanel);
835
+ return;
836
+ case 'close':
837
+ closePanel(effect.panel);
838
+ return;
839
+ case 'open':
840
+ if (effect.panel === 'queue')
841
+ openQueue(true);
842
+ else if (effect.panel === 'prompts')
843
+ openPrompts(true);
844
+ else if (effect.panel === 'thoughts')
845
+ openThoughts(true);
846
+ return;
847
+ case 'toggle':
848
+ if (effect.panel === 'help') {
849
+ setHelp(value => !value);
850
+ setHelpPage(0);
492
851
  }
493
- case 'path':
494
- await controller.createWorkspace(submission.value);
495
- return;
496
- case 'latest':
497
- controller.setViewWindow(undefined);
498
- controller.setHistoryPanel(undefined);
499
- controller.setSearchPanel(undefined);
500
- controller.setFolds(new Set());
501
- controller.setScroll(0);
502
- controller.pinHistory(false);
503
- return;
504
- case 'models': {
505
- if (!submission.args.length) {
506
- controller.setHistoryPanel(undefined);
507
- controller.setSearchPanel(undefined);
508
- controller.setModelPanel({ catalog: await controller.modelCatalog() });
509
- }
510
- else {
511
- await controller.selectModel(submission.args[0], submission.args[1], submission.args[2]);
512
- controller.setModelPanel(undefined);
513
- }
514
- return;
852
+ else if (effect.panel === 'cost')
853
+ setCostExpanded(value => !value);
854
+ else if (effect.panel === 'status') {
855
+ setStatusExpanded(value => !value);
856
+ setStatusScroll(0);
515
857
  }
516
- case 'queue':
517
- controller.openQueue(true);
518
- return;
519
- case 'newSession':
520
- await controller.createSession();
521
- return;
522
- case 'history':
523
- controller.setSearchPanel(undefined);
524
- controller.setHistoryPanel({ query: submission.query, contentSearch: false });
525
- return;
526
- case 'sessionSearch':
527
- await historyOperation(async (signal) => {
528
- const result = await controller.searchSessions(submission.query, submission.command === '/ssearch', signal);
529
- controller.setHistoryPanel(undefined);
530
- controller.setSearchPanel({ query: submission.query, ...result });
531
- });
532
- return;
533
- case 'historySearch':
534
- await historyOperation(async (signal) => {
535
- controller.setHistoryPanel({ query: submission.query, contentSearch: true,
536
- matches: await controller.searchHistory(submission.query, signal) });
537
- controller.setSearchPanel(undefined);
538
- });
539
- return;
540
- case 'think': {
541
- if (submission.target === 'live') {
542
- controller.setLiveReasoning(controller.view.liveReasoning === 'row' ? 'full' : 'row');
543
- controller.openThoughts(false);
544
- controller.setScroll(0);
545
- }
546
- else if (submission.target) {
547
- const seq = Number(submission.target);
548
- if (!Number.isSafeInteger(seq) || !displayTranscript.thoughts.some(entry => entry.seq === seq))
549
- throw new Error('Use /think <message sequence> for a loaded reasoning block');
550
- const next = new Set(reasoningOverrides);
551
- if (next.delete(seq)) {
552
- controller.setFolds(next);
553
- return;
554
- }
555
- next.add(seq);
556
- controller.setFolds(next);
557
- await jumpHistory(seq, next);
558
- }
559
- else {
560
- controller.setHistoryPanel(undefined);
561
- controller.setSearchPanel(undefined);
562
- controller.openThoughts(true);
563
- }
564
- return;
858
+ return;
859
+ case 'history':
860
+ setHistoryPanel(effect.history);
861
+ return;
862
+ case 'search':
863
+ setSearchPanel(effect.search);
864
+ return;
865
+ case 'model':
866
+ setModelPanel(effect.model);
867
+ return;
868
+ case 'removal':
869
+ setRemoval(effect.removal);
870
+ return;
871
+ case 'loop': {
872
+ // The application validated the record before asking for the form, so the UI only records that
873
+ // the request arrived and renders it.
874
+ const known = controller.queries.loopRecords.some(record => record.name === effect.loop.name);
875
+ controller.traceNote('loop-ui', { phase: 'open', name: effect.loop.name, known });
876
+ setDismissedLoopMenu(undefined);
877
+ setLoopForm({ name: effect.loop.name });
878
+ return;
879
+ }
880
+ case 'live':
881
+ controller.actions.setViewWindow(undefined);
882
+ return;
883
+ case 'pinLive':
884
+ controller.actions.pinHistory(false);
885
+ return;
886
+ case 'resetFolds':
887
+ setReasoningOverrides(new Set());
888
+ return;
889
+ case 'toggleLiveReasoning':
890
+ setLiveReasoning(liveReasoning === 'row' ? 'full' : 'row');
891
+ return;
892
+ case 'toggleFold': {
893
+ const next = new Set(reasoningOverrides);
894
+ if (next.delete(effect.seq))
895
+ setReasoningOverrides(next);
896
+ else {
897
+ next.add(effect.seq);
898
+ setReasoningOverrides(next);
899
+ await jumpHistory(effect.seq, next);
565
900
  }
566
- case 'older':
567
- await controller.older(undefined, displayTranscript);
568
- controller.setScroll(controller.view.scroll + 10);
569
- return;
570
- case 'compact':
571
- setNotice(undefined);
572
- await historyOperation(async (signal) => { setNotice(await controller.command('/compact', signal)); }, 'Compacting history…');
573
- return;
574
- case 'cancel':
575
- await controller.cancelTurn();
576
- return;
577
- case 'approval':
578
- await controller.approve(submission.allowed);
579
- return;
580
- case 'hostCommand':
581
- setNotice(undefined);
582
- await historyOperation(async (signal) => { setNotice(await controller.command(submission.line, signal)); }, 'Running command…');
583
- return;
584
- case 'export':
585
- await historyOperation(async (signal) => { setNotice(`Saved session log: ${await controller.exportLog(submission.destination, signal)}`); }, 'Exporting session log…');
586
- return;
587
- case 'exportHtml':
588
- await historyOperation(async (signal) => { setNotice(`Saved loaded conversation: ${await controller.exportHtml(submission.destination, signal)}`); }, 'Exporting loaded conversation…');
589
- return;
590
- case 'coredump':
591
- // V8 serializes the heap synchronously, so the client stalls until the file is written;
592
- // the path is reported afterwards so the snapshot can be opened in DevTools.
593
- await historyOperation(async () => {
594
- setNotice(`Heap snapshot saved: ${controller.heapSnapshot(submission.tag)}`);
595
- }, 'Writing heap snapshot…');
596
- return;
597
- case 'answer':
598
- await answerQuestion(question.multiSelect === true ? choiceState.selected : [], submission.text);
599
- return;
600
- case 'error': throw new Error(submission.message);
601
- case 'prompt':
602
- await controller.prompt(submission.text);
603
- controller.setViewWindow(undefined);
604
- controller.setScroll(0);
605
- return;
901
+ return;
606
902
  }
607
- });
608
- if (accepted)
609
- setInput('');
610
- };
903
+ case 'scroll':
904
+ setScroll(effect.position);
905
+ return;
906
+ case 'scrollBy':
907
+ setScroll(current => current + effect.delta);
908
+ return;
909
+ case 'notice':
910
+ setNotice(effect.text);
911
+ return;
912
+ case 'error':
913
+ setNotice(effect.text);
914
+ return;
915
+ }
916
+ }
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
+ }
611
927
  const width = Math.max(10, (stdout.columns ?? 80) - 2);
612
928
  // The composer never trades the conversation away for input. Its window grows with the body and
613
929
  // is clamped so a few conversation rows always survive; width never buys height, so landscape and
@@ -619,7 +935,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
619
935
  // Session activity is a list-level fact, so both pickers read it without loading any history.
620
936
  const listAge = Date.now();
621
937
  // Unanswered interactions are held per session already; a list only has to read the counts.
622
- const pendingCounts = controller.pendingCounts();
938
+ const pendingCounts = controller.queries.pendingCounts();
623
939
  const isPending = (session) => (pendingCounts.get(string(session.sessionId)) ?? 0) > 0;
624
940
  const sessionsOf = (workspace) => {
625
941
  const ids = new Set(array(workspace.sessionIds).map(string));
@@ -644,51 +960,61 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
644
960
  cells: workspaceSegments(counts, rollupStyle).map(segment => ({ text: segment.text, color: stateColor[segment.state] })),
645
961
  ...(detail ? { detail } : {}),
646
962
  remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
647
- action: () => controller.pickWorkspace(string(workspace.workspaceId)) };
963
+ action: () => controller.actions.pickWorkspace(string(workspace.workspaceId)) };
648
964
  }),
649
- { key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
965
+ { key: '@all', label: 'All sessions', action: () => operate(() => controller.actions.switchSession('all')) },
650
966
  // The directory this client runs in is the one case where no path has to be typed, and offering
651
967
  // it only while the host has not registered it keeps the row from repeating itself.
652
968
  ...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
653
969
  : [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
654
- action: () => operate(() => controller.createWorkspace(controller.localDirectory)) }]),
655
- { key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
970
+ action: () => operate(() => controller.actions.createWorkspace(controller.localDirectory)) }]),
971
+ { key: '@new', label: '+ Add workspace (host directory)', action: () => controller.actions.enterPath() },
656
972
  ] : [
657
- ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
658
- ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
973
+ ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.actions.createSession()) }] : []),
974
+ ...controller.queries.visibleSessions.map(session => ({ key: string(session.sessionId),
659
975
  label: `${sessionStatus(session, listAge, isPending(session))} ${sessionLabel(session)} ${session.sessionId}`,
660
976
  remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
661
- action: () => { controller.setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
662
- { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
977
+ action: () => { setScroll(0); operate(() => controller.actions.selectSession(string(session.sessionId))); } })),
978
+ { key: '@back', label: '← Workspaces', action: () => operate(() => controller.actions.showPicker('workspaces')) },
663
979
  ];
664
- const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
980
+ const layout = useMemo(() => controller.queries.render({ transcript: displayTranscript, width, folds: reasoningOverrides, liveReasoning }), [controller, displayTranscript, displayTranscript.version, width, reasoningOverrides, liveReasoning]);
665
981
  const { length, first } = layout;
982
+ // The read-only view lays out its own transcript, so its fold mode never leaks into the
983
+ // conversation's, and a local source needs no layout at all: it already holds plain lines.
984
+ const peekLayout = useMemo(() => peek?.transcript === undefined ? undefined
985
+ : controller.queries.render({ transcript: peek.transcript, width, folds: NO_FOLDS, liveReasoning: 'row' }), [controller, peek?.transcript, peek?.transcript?.version, width]);
986
+ const peekPlainRows = useMemo(() => peek?.lines === undefined ? []
987
+ : 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;
666
992
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
667
993
  const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
668
994
  const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
669
- const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count: length, first, folds: reasoningOverrides, liveReasoning });
995
+ const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning });
670
996
  const previous = previousView.current;
671
997
  const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
672
998
  const adjustedScroll = previous.session !== state.session.record ? 0
673
- : scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + length - previous.count) : scroll;
674
- const maxScroll = Math.max(0, length - pageSize);
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);
675
1001
  const position = Math.min(adjustedScroll, maxScroll);
676
1002
  useLayoutEffect(() => {
677
- previousView.current = { transcript: displayTranscript, session: state.session.record, count: length, first, folds: reasoningOverrides, liveReasoning };
1003
+ previousView.current = { transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning };
678
1004
  if (position !== scroll)
679
- controller.setScroll(position);
680
- }, [state.session.record, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
1005
+ setScroll(position);
1006
+ }, [state.session.record, displayTranscript, totalRows, position, scroll, reasoningOverrides, liveReasoning]);
681
1007
  useLayoutEffect(() => {
682
- controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
1008
+ controller.actions.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
683
1009
  }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.session.record]);
684
1010
  useEffect(() => {
685
1011
  const first = displayTranscript.beforeSeq;
686
1012
  if (first === undefined)
687
1013
  return;
688
- const previous = controller.view.folds;
1014
+ const previous = reasoningOverrides;
689
1015
  const next = new Set([...previous].filter(seq => seq >= first));
690
1016
  if (next.size !== previous.size)
691
- controller.setFolds(next);
1017
+ setReasoningOverrides(next);
692
1018
  }, [displayTranscript, displayTranscript.memoryRevision]);
693
1019
  const loadingPage = useRef(false);
694
1020
  const scrollIntent = useRef(0);
@@ -698,31 +1024,28 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
698
1024
  if (copyMode || state.screen !== 'chat')
699
1025
  return;
700
1026
  const intent = ++scrollIntent.current;
701
- const next = Math.max(0, Math.min(maxScroll, controller.view.scroll + delta));
702
- controller.setScroll(next);
703
- if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
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)
704
1030
  return;
705
1031
  loadingPage.current = true;
706
1032
  const transcript = displayTranscript;
707
- void controller.perform(() => historyOperation(signal => controller.older(signal, transcript))).then(accepted => {
1033
+ void historyOperation(signal => controller.actions.older(signal, transcript)).then(accepted => {
708
1034
  if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
709
- controller.setScroll(controller.view.scroll + delta);
1035
+ setScroll(scroll + delta);
710
1036
  }
711
1037
  }).finally(() => { loadingPage.current = false; });
712
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
+ */
713
1047
  async function historyOperation(operation, label = 'Loading history…') {
714
- const abort = new AbortController();
715
- historyAbort.current = abort;
716
- setHistoryLoading(label);
717
- try {
718
- await operation(abort.signal);
719
- }
720
- finally {
721
- if (historyAbort.current === abort) {
722
- historyAbort.current = undefined;
723
- setHistoryLoading(undefined);
724
- }
725
- }
1048
+ return await controller.actions.foreground('history', label, operation);
726
1049
  }
727
1050
  /** Reach one prompt older than the index's boundary, from memory first and the host second.
728
1051
  *
@@ -737,26 +1060,26 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
737
1060
  const transcript = state.session.record;
738
1061
  const sessionId = state.sessionId;
739
1062
  // Recover from the loaded window first: no request, no focus change, no loading label.
740
- if (controller.refillRecall() || !transcript.hasMore) {
741
- setInput(controller.recall(-1, controller.composer.draft), true);
1063
+ if (controller.actions.refillRecall() || !transcript.hasMore) {
1064
+ setInput(controller.queries.recall(-1, input), true);
742
1065
  return;
743
1066
  }
744
1067
  historyPaging.current = true;
745
1068
  try {
746
- const accepted = await controller.perform(() => historyOperation(async (signal) => {
1069
+ const accepted = await historyOperation(async (signal) => {
747
1070
  // A prompt can be many pages back in an agent session, so scan a healthy stretch before
748
1071
  // giving up; the loading label stays visible and Esc cancels the scan.
749
1072
  for (let page = 0; page < RECALL_PAGE_SCAN; page++) {
750
1073
  if (transcript.beforeSeq === undefined || !transcript.hasMore)
751
1074
  break;
752
- await controller.older(signal, transcript);
1075
+ await controller.actions.older(signal, transcript);
753
1076
  signal.throwIfAborted();
754
- if (controller.refillRecall())
1077
+ if (controller.actions.refillRecall())
755
1078
  return;
756
1079
  }
757
- }, 'Loading older prompts…'));
1080
+ }, 'Loading older prompts…');
758
1081
  if (accepted && mounted.current && controller.state.sessionId === sessionId) {
759
- setInput(controller.recall(-1, controller.composer.draft), true);
1082
+ setInput(controller.queries.recall(-1, input), true);
760
1083
  }
761
1084
  }
762
1085
  finally {
@@ -765,64 +1088,108 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
765
1088
  }
766
1089
  async function openSearchSession(sessionId, query) {
767
1090
  await historyOperation(async (signal) => {
768
- await controller.selectSession(sessionId);
769
- await controller.waitForHistory(signal);
770
- controller.setHistoryPanel({ query, contentSearch: true, matches: await controller.searchHistory(query, signal) });
771
- controller.setSearchPanel(undefined);
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);
772
1095
  });
773
1096
  }
774
1097
  async function jumpHistory(target, folds = reasoningOverrides) {
775
1098
  if (state.screen !== 'chat')
776
1099
  throw new Error('Select a session first');
777
1100
  ++scrollIntent.current;
778
- const abort = new AbortController();
779
- historyAbort.current = abort;
780
- try {
1101
+ await controller.actions.foreground('history', 'Jumping to a message…', async (signal) => {
781
1102
  const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
782
1103
  : state.session.record.messages.some(message => message.seq === target) ? state.session.record
783
- : await controller.historyAt(target, abort.signal);
784
- abort.signal.throwIfAborted();
785
- const current = historyLayout(transcript, width, reasoning, folds, liveReasoning);
1104
+ : await controller.queries.historyAt(target, signal);
1105
+ signal.throwIfAborted();
1106
+ const current = controller.queries.render({ transcript, width, folds, liveReasoning });
786
1107
  const row = current.offsets.get(target);
787
1108
  if (row === undefined)
788
- throw new Error('No visible message at this sequence; use /history to choose a record');
789
- controller.setViewWindow(transcript === state.session.record ? undefined : transcript);
790
- controller.setHistoryPanel(undefined);
791
- controller.openThoughts(false);
792
- controller.setScroll(Math.max(0, current.length - pageSize - row));
793
- }
794
- finally {
795
- if (historyAbort.current === abort)
796
- historyAbort.current = undefined;
797
- }
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));
1114
+ });
798
1115
  }
799
- useMouseWheel(direction => { if (statusExpanded && statusOverflow)
800
- setStatusScroll(value => Math.max(0, value - direction * 3));
801
- else
802
- scrollHistory(direction * 3); }, !copyMode && state.screen === 'chat', () => { if (!dialogOpen)
803
- setCopyMode(true); });
804
1116
  const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
805
- const end = Math.max(pageSize, length - position - trailingGap);
806
- const visible = useMemo(() => layout.viewport(Math.max(0, end - pageSize), end), [layout, end, pageSize]);
1117
+ const end = Math.max(pageSize, totalRows - position - trailingGap);
1118
+ // The first merged row the viewport shows: a short record starts at 0, a scrolled one does not, and
1119
+ // every screen line has to be translated back through this to find its row.
1120
+ const visibleStart = Math.max(0, end - pageSize);
1121
+ const visible = useMemo(() => merged.viewport(visibleStart, end), [merged, visibleStart, end]);
1122
+ // The read-only view's own window: `peekScroll` counts rows back from the newest, like the
1123
+ // conversation's scroll, so new output keeps arriving at the bottom unless the reader looked away.
1124
+ const peekTotal = peekLayout?.length ?? peekPlainRows.length;
1125
+ const peekMaxScroll = Math.max(0, peekTotal - Math.max(1, peekRows));
1126
+ const peekPosition = Math.min(peekScroll, peekMaxScroll);
1127
+ const peekEnd = Math.max(0, peekTotal - peekPosition);
1128
+ const peekStart = Math.max(0, peekEnd - Math.max(1, peekRows));
1129
+ const peekVisible = peekLayout === undefined ? peekPlainRows.slice(peekStart, peekEnd) : peekLayout.viewport(peekStart, peekEnd);
1130
+ // Where the first visible transcript row sits on screen: the copy-mode banner, the measured header,
1131
+ // the measured connection/foreground notices inside the body, and the viewport's own top margin.
1132
+ // Rows are laid out from the top of the viewport, so a given row's screen line is simply this plus
1133
+ // its index in `visible` — no counting back from the composer's variable height.
1134
+ const transcriptTop = (copyMode ? 1 : 0) + chromeRows.header + chromeRows.prefix + (dialogOpen ? 0 : 1);
1135
+ useMouseWheel(direction => {
1136
+ // The read-only view scrolls itself; the conversation behind it must not move.
1137
+ if (peek !== undefined) {
1138
+ setPeekScroll(value => Math.max(0, value + direction * 3));
1139
+ return;
1140
+ }
1141
+ if (statusExpanded && statusOverflow)
1142
+ setStatusScroll(value => Math.max(0, value - direction * 3));
1143
+ else
1144
+ scrollHistory(direction * 3);
1145
+ }, !copyMode && state.screen === 'chat', cell => {
1146
+ // A bar stands for one readable source, and only its own row is the bar: its output rows below
1147
+ // are content, and a press on them still starts a selection. Everything else is copy mode.
1148
+ if (dialogOpen)
1149
+ return;
1150
+ const index = cell.row - 1 - transcriptTop;
1151
+ if (index < 0 || index >= visible.length) {
1152
+ setCopyMode(true);
1153
+ return;
1154
+ }
1155
+ // Screen line -> merged row: the viewport starts at `visibleStart`, so a short record's first row
1156
+ // and a scrolled one's are both placed correctly. Reading the index directly only worked when the
1157
+ // whole transcript fit on screen, which is exactly what a real session is not.
1158
+ const source = merged.sources.get(visibleStart + index);
1159
+ if (source !== undefined) {
1160
+ controller.actions.openPeek(source);
1161
+ return;
1162
+ }
1163
+ setCopyMode(true);
1164
+ });
1165
+ // Clamp the stored offset once the source's length is known, so scrolling back down starts moving
1166
+ // immediately instead of burning the overshoot first.
1167
+ useLayoutEffect(() => {
1168
+ if (peekPosition !== peekScroll)
1169
+ setPeekScroll(peekPosition);
1170
+ }, [peekPosition, peekScroll]);
807
1171
  const liveThought = thoughtList && !historyWindow ? state.session.record.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
808
1172
  const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
809
1173
  const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
810
1174
  key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
811
- action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); controller.setFolds(next); await jumpHistory(entry.seq, next); }),
1175
+ action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq, next); }),
812
1176
  })), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
813
1177
  const thoughtOptions = useMemo(() => [
814
1178
  ...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.session.record.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
815
- action: () => { controller.setLiveReasoning('full'); controller.openThoughts(false); const expanded = historyLayout(state.session.record, width, reasoning, reasoningOverrides, 'full'); controller.setScroll(Math.max(0, expanded.length - pageSize - expanded.liveOffset)); } }] : []),
1179
+ action: () => { setLiveReasoning('full'); openThoughts(false); const expanded = controller.queries.render({ transcript: state.session.record, width, folds: reasoningOverrides, liveReasoning: 'full' }); setScroll(Math.max(0, expanded.length - pageSize - expanded.liveOffset)); } }] : []),
816
1180
  ...thoughtChoices,
817
- ...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.older(signal, displayTranscript))) }] : []),
818
- { key: 'close', label: '← Back to conversation', action: () => controller.openThoughts(false) },
1181
+ ...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.actions.older(signal, displayTranscript))) }] : []),
1182
+ { key: 'close', label: '← Back to conversation', action: () => openThoughts(false) },
819
1183
  ], [liveThought, thoughtChoices, displayTranscript, displayTranscript.hasMore, width, pageSize, reasoningOverrides]);
820
1184
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
821
1185
  const workspaceName = workspace ? string(workspace.title) || string(workspace.path) : state.workspaceId;
822
- const headerTitle = controller.sessionName
823
- ? [controller.sessionName, width >= 60 ? workspaceName : undefined].filter(Boolean).join(' · ')
1186
+ const headerTitle = controller.queries.sessionName
1187
+ ? [controller.queries.sessionName, width >= 60 ? workspaceName : undefined].filter(Boolean).join(' · ')
824
1188
  : workspaceName || 'All workspaces';
825
- const commandSuggestions = input.startsWith('/') && !input.includes(' ') ? suggestedCommands(input) : undefined;
1189
+ const commandSuggestions = !loopMenuOpen && input.startsWith('/') && !input.includes(' ') ? suggestedCommands(input) : undefined;
1190
+ // Once the name is settled and arguments begin, the composer says what this command takes. The
1191
+ // `/loop` record list is more specific, so it replaces the generic line rather than stacking on it.
1192
+ const commandHint = loopMenuOpen ? undefined : argumentHint(input);
826
1193
  // Reading older history pauses the clock without freezing the connection state on picker screens.
827
1194
  // A paused clock is named rather than left frozen: a stopped number looks like a stall.
828
1195
  const pauseReason = copyMode ? 'copy'
@@ -830,13 +1197,80 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
830
1197
  : state.screen === 'chat' && position > 0 ? 'history'
831
1198
  : undefined;
832
1199
  const statusFrozen = pauseReason !== undefined;
833
- 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: () => !controller.composer.draft && !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: () => !controller.composer.draft && !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: () => !controller.composer.draft && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); controller.setModelPanel(undefined); }), onOpen: (provider, model) => controller.setModelPanel({ catalog: models.catalog, provider, model }), onBack: () => controller.setModelPanel({ catalog: models.catalog }), onClose: () => controller.setModelPanel(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !controller.composer.draft && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => controller.setSearchPanel(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,
1200
+ // The bar renders plain data: the composition root is the one place that reads the controller.
1201
+ const statusSource = (() => {
1202
+ const view = controller.queries.telemetry.view(state.sessionId);
1203
+ const ledger = controller.costs;
1204
+ const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
1205
+ const line = (total) => ({ text: costText(total), amount: total.amount, unknown: total.unknown });
1206
+ const sessionOf = () => state.sessionId !== undefined && ledger !== undefined && ledger.hasSession(state.sessionId)
1207
+ ? ledger.total(state.sessionId) : undefined;
1208
+ const session = sessionOf();
1209
+ return {
1210
+ host: controller.base, online: state.online, status: state.status,
1211
+ running: controller.queries.running,
1212
+ // What is busy, and since when, is the controller's answer; the bar only draws it.
1213
+ ...(controller.queries.activity === undefined ? {} : { activity: controller.queries.activity }),
1214
+ sessionId: state.sessionId, sessionMode: controller.queries.sessionMode,
1215
+ workspaceLabel: workspace ? `${string(workspace.title)} · ${string(workspace.path)}` : 'none selected',
1216
+ activeTurnStartedAt: state.session.record.activeTurnStartedAt,
1217
+ pendingCount: state.pending.length,
1218
+ ...(state.session.record.livePhase === undefined ? {} : { livePhase: state.session.record.livePhase }),
1219
+ values: view.values, queued: view.queued, jobs: view.jobs,
1220
+ ...(state.defaultModel === undefined ? {} : { defaultModel: state.defaultModel }),
1221
+ ...(ledger === undefined ? {} : { cost: {
1222
+ sessionText: session === undefined ? '?' : costText(session),
1223
+ today: () => line(ledger.today()),
1224
+ session: () => { const total = sessionOf(); return total === undefined ? undefined : line(total); },
1225
+ coverage: ledger.coverage,
1226
+ ...(ledger.error === undefined ? {} : { error: ledger.error }),
1227
+ } }),
1228
+ ...(state.controlError === undefined ? {} : { controlError: state.controlError }),
1229
+ ...(state.presetError === undefined ? {} : { presetError: state.presetError }),
1230
+ ...(state.modelError === undefined ? {} : { modelError: state.modelError }),
1231
+ };
1232
+ })();
1233
+ const costSource = (() => {
1234
+ const ledger = controller.costs;
1235
+ if (ledger === undefined)
1236
+ return undefined;
1237
+ const line = (total) => ({ text: costText(total), unknown: total.unknown, records: total.records });
1238
+ const id = state.sessionId;
1239
+ const session = id !== undefined && ledger.hasSession(id) ? ledger.total(id) : undefined;
1240
+ return {
1241
+ ...(session === undefined ? {} : { session: line(session) }),
1242
+ today: line(ledger.today()),
1243
+ scanning: ledger.scanning, coverage: ledger.coverage,
1244
+ ...(ledger.scannedAt === undefined ? {} : { scannedAt: ledger.scannedAt }),
1245
+ customPrices: ledger.customPrices,
1246
+ ...(ledger.error === undefined ? {} : { error: ledger.error }),
1247
+ missing: ledger.missing(),
1248
+ };
1249
+ })();
1250
+ 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(Box, { ref: headerSection, flexShrink: 0, children: _jsx(ChatHeader, { title: headerTitle, mode: controller.queries.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [_jsxs(Box, { ref: bodyPrefix, flexDirection: "column", flexShrink: 0, children: [foreground && _jsxs(Text, { dimColor: true, children: [foreground.label, " \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.lastFailure && _jsx(Text, { color: theme.colors.error, children: state.lastFailure })] }), state.screen === 'chat' && (peek === undefined ? _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })
1251
+ : _jsx(PeekPanel, { source: peek.source, ...(peek.error === undefined ? {} : { error: peek.error }), rows: peekVisible, position: peekPosition, total: peekTotal, width: width, now: Date.now(), boxRef: peekBox }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), composerIntent && _jsx(Text, { color: theme.colors.context, children: composerIntent.hint }), loop && _jsx(LoopStatus, { progress: loop }), peek === undefined && _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: loopForm && loopRecord ? _jsx(LoopDialog, { record: loopRecord, enabled: state.online && controller.queries.foreground === undefined, onStart: run => startLoopFromForm(loopForm.name, run), onBack: () => { setLoopForm(undefined); setInput('/loop '); setDismissedLoopMenu(undefined); }, onClose: () => setLoopForm(undefined) }, loopForm.name) : queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.actions.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { if (await controller.actions.removeTarget(removal))
1252
+ setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { if (await controller.actions.selectModel(provider, model, effort))
1253
+ setModelPanel(undefined); }), onOpen: (provider, model) => setModelPanel({ catalog: models.catalog, provider, model }), onBack: () => setModelPanel({ catalog: models.catalog }), onClose: () => setModelPanel(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchPanel(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,
834
1254
  // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
835
1255
  // terminal too narrow for the whole key gets the badges alone rather than half a legend.
836
- legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && !state.busy && !input, canSelect: () => !controller.composer.draft && 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: () => !controller.composer.draft && !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: () => !controller.composer.draft && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => controller.setHistoryPanel(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 }))
837
- .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
1256
+ legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && foreground === undefined && !input, canSelect: () => !input && controller.state.online && controller.queries.foreground === undefined }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined }), promptsOpen && state.screen === 'chat' && _jsx(PromptsDialog, { identity: "prompts", prompts: controller.queries.prompts, error: controller.queries.promptsError, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onChoose: text => { setInput(text); openPrompts(false); }, onEdit: prompt => {
1257
+ setComposerIntent({
1258
+ hint: 'Editing saved prompt · Enter saves · Esc cancels',
1259
+ 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
+ },
1266
+ });
1267
+ setInput(prompt.text);
1268
+ openPrompts(false);
1269
+ }, onRemove: id => operate(async () => { if (await controller.actions.deletePrompt(id))
1270
+ setNotice('Deleted saved prompt'); }) }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => setHistoryPanel(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(question.header)}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? question.question : pending.kind === 'approval' ? pending.description : '') }), question?.detail && _jsx(Text, { children: safeText(question.detail) }), pending.kind === 'approval' && _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 }))
1271
+ .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(option.label) ? '[x] ' : '[ ] ' : '', safeText(option.label)] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(option.description)] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
838
1272
  ? '↑ ↓ 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
839
1273
  ? 'Choose "Other answer" to type · the draft is kept while this is open'
840
1274
  : 'Esc dismisses the question · the draft is kept while this is open'
841
- : '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: cursor => controller.setComposerCursor(cursor), onSubmit: () => { void submit(controller.composer.draft); }, 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 }) })] })] }) }) }) });
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 }) })] })] }) }) }) });
842
1276
  }