@itookit/dsht 0.3.8 → 0.5.2

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