@itookit/dsht 0.3.4 → 0.3.8

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 (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/dist/cli/dsht.js +5 -2
  5. package/dist/controller/controller.d.ts +113 -1
  6. package/dist/controller/controller.js +145 -2
  7. package/dist/cost/controller.d.ts +5 -0
  8. package/dist/cost/controller.js +2 -1
  9. package/dist/cost/scanner.d.ts +4 -2
  10. package/dist/cost/scanner.js +6 -3
  11. package/dist/session/controller.d.ts +150 -1
  12. package/dist/session/controller.js +388 -29
  13. package/dist/session/history.d.ts +21 -1
  14. package/dist/session/history.js +15 -0
  15. package/dist/session/index.d.ts +2 -0
  16. package/dist/session/index.js +1 -0
  17. package/dist/session/info.d.ts +262 -0
  18. package/dist/session/info.js +326 -0
  19. package/dist/session/transcript.d.ts +37 -1
  20. package/dist/session/transcript.js +73 -0
  21. package/dist/shell/controller.d.ts +67 -0
  22. package/dist/shell/controller.js +126 -0
  23. package/dist/shell/index.d.ts +5 -0
  24. package/dist/shell/index.js +3 -0
  25. package/dist/shell/runner.d.ts +28 -0
  26. package/dist/shell/runner.js +108 -0
  27. package/dist/state.d.ts +3 -2
  28. package/dist/state.js +2 -2
  29. package/dist/ui/app.js +206 -177
  30. package/dist/ui/chat/history-view.js +1 -1
  31. package/dist/ui/chat/shell-view.d.ts +34 -0
  32. package/dist/ui/chat/shell-view.js +111 -0
  33. package/dist/ui/chat/status.js +4 -4
  34. package/dist/ui/commands/parse.d.ts +5 -0
  35. package/dist/ui/commands/parse.js +9 -0
  36. package/dist/ui/dialogs/index.d.ts +3 -6
  37. package/dist/ui/theme/index.d.ts +5 -0
  38. package/dist/ui/theme/index.js +2 -1
  39. package/dsht-m.png +0 -0
  40. package/package.json +1 -1
  41. package/dist/ui/input/history.d.ts +0 -19
  42. package/dist/ui/input/history.js +0 -43
package/dist/ui/app.js CHANGED
@@ -5,15 +5,15 @@ 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 { InputHistory } from "./input/history.js";
9
8
  import { ReferenceMenu } from "./input/references.js";
10
- import { historyLayout, releaseHistoryLayout } from "../session/history.js";
9
+ import { historyLayout } from "../session/history.js";
11
10
  import { toolLine } from "../session/transcript.js";
12
11
  import { activeReference, fileMention } from "../session/references.js";
13
12
  import { CostPanel } from "./dialogs/cost.js";
14
13
  import { StatusBar } from "./chat/status.js";
15
14
  import { ChatHeader } from "./chat/header.js";
16
15
  import { ChatViewport } from "./chat/viewport.js";
16
+ import { mergeShellRuns } from "./chat/shell-view.js";
17
17
  import { Frozen } from "./frozen.js";
18
18
  import { CopyMode } from "./copy-mode.js";
19
19
  import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
@@ -23,6 +23,8 @@ import { ROLLUP_LEGEND, sessionLabel, sessionStatus, workspaceCounts, workspaceD
23
23
  import { array, errorText, object, safeText, string } from "../transport/wire.js";
24
24
  /** Transient notices expire; interactive panels remain open until dismissed. */
25
25
  const PANEL_LIFETIME_MS = 10_000;
26
+ /** Pages one boundary recall press may walk before it reports that nothing older holds a prompt. */
27
+ const RECALL_PAGE_SCAN = 20;
26
28
  /** The caller owns starting and stopping the controller around the Ink render lifetime.
27
29
  * @param props - Controller, panel lifetime and semantic theme.
28
30
  * @returns The rendered terminal interface.
@@ -35,43 +37,26 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
35
37
  const { exit } = useApp();
36
38
  const { stdout } = useStdout();
37
39
  const [copyMode, setCopyMode] = useState(false);
38
- const [input, updateInput] = useState('');
39
- const draft = useRef('');
40
- const inputHistory = useRef(new InputHistory());
41
- useLayoutEffect(() => {
42
- const history = new InputHistory();
43
- inputHistory.current = history;
44
- if (!state.transcript.ready)
45
- return;
46
- const messages = state.transcript.messagesForWidth(Math.max(20, (stdout.columns ?? 100) - 2));
47
- // Seed once per loaded session, never scan historical messages on stream ticks or arrow presses.
48
- const prompts = messages.filter(message => message.role === 'You').slice(-200);
49
- for (const message of prompts)
50
- history.record(message.text.replace(/\r?\n/g, ' ').trim());
51
- }, [state.sessionId, state.transcript, state.transcript.ready]);
52
- const [cursor, setCursor] = useState(0);
53
- // Input callbacks may run before Ink refreshes the controlled field's listener.
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);
54
44
  const setInput = (value, recalled = false) => {
55
45
  if (!recalled)
56
- inputHistory.current.reset();
46
+ controller.resetRecall();
57
47
  // A new message draft returns the view to the live end, so composing never needs a scroll first.
58
48
  // A slash command is not a message, and the reader keeps their place while typing one.
59
- if (state.screen === 'chat' && draft.current === '' && value !== '' && !value.startsWith('/'))
60
- setScroll(0);
61
- draft.current = value;
62
- updateInput(value);
63
- setCursor(value.length);
49
+ if (state.screen === 'chat' && controller.composer.draft === '' && value !== '' && !value.startsWith('/'))
50
+ controller.setScroll(0);
51
+ controller.setComposer(value);
64
52
  };
65
- const [historyWindow, setHistoryWindow] = useState();
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;
66
56
  const [historyLoading, setHistoryLoading] = useState();
67
- const [historyMatches, setHistoryMatches] = useState();
68
- const displayTranscript = historyWindow ?? state.transcript;
57
+ const displayTranscript = historyWindow ?? state.session.record;
69
58
  const displayRef = useRef(displayTranscript);
70
59
  displayRef.current = displayTranscript;
71
- useEffect(() => () => { if (historyWindow) {
72
- releaseHistoryLayout(historyWindow);
73
- historyWindow.dispose();
74
- } }, [historyWindow]);
75
60
  const conversationBox = useRef(null);
76
61
  const [conversationRows, setConversationRows] = useState(20);
77
62
  useLayoutEffect(() => {
@@ -81,23 +66,18 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
81
66
  setConversationRows(height);
82
67
  }
83
68
  });
84
- const [scroll, setScroll] = useState(0);
85
- const [liveReasoning, setLiveReasoning] = useState('row');
86
69
  const [removal, setRemoval] = useState();
87
- const [models, setModels] = useState();
88
- const [thoughtList, setThoughtList] = useState(false);
89
- const [queueOpen, setQueueOpen] = useState(false);
90
- const [historyQuery, setHistoryQuery] = useState();
91
- const [contentSearch, setContentSearch] = useState(false);
92
- const [searchResults, setSearchResults] = 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;
72
+ const historyQuery = history?.query;
73
+ const contentSearch = history?.contentSearch === true;
74
+ const historyMatches = history?.matches;
93
75
  const historyAbort = useRef(undefined);
94
76
  useEffect(() => () => historyAbort.current?.abort(), []);
95
77
  const [costExpanded, setCostExpanded] = useState(false);
96
78
  const [statusExpanded, setStatusExpanded] = useState(false);
97
- const [reasoningOverrides, setReasoningOverrides] = useState(new Set());
98
79
  const reasoning = 'row';
99
- useEffect(() => { setReasoningOverrides(new Set()); setModels(undefined); setThoughtList(false); setHistoryWindow(undefined); setHistoryMatches(undefined); }, [state.transcript]);
100
- useEffect(() => { setLiveReasoning('row'); }, [state.transcript, state.transcript.liveAttemptKey]);
80
+ useEffect(() => { controller.setLiveReasoning('row'); }, [state.session.record, state.session.record.liveAttemptKey]);
101
81
  const [notice, setNotice] = useState();
102
82
  const [help, setHelp] = useState(false);
103
83
  const [helpPage, setHelpPage] = useState(0);
@@ -110,11 +90,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
110
90
  // three-row composer above it never shrink, so only the remainder of the screen height shows rows.
111
91
  const [statusBarRows, setStatusBarRows] = useState(1);
112
92
  const statusViewRows = Math.max(1, (stdout.rows ?? 30) - 9 - (statusBarRows - 1));
113
- const [answers, setAnswers] = useState({});
114
- const [optionState, setOptionState] = useState();
115
- const [approvalSelection, setApprovalSelection] = useState();
116
- const [referenceIndex, setReferenceIndex] = useState(0);
117
- const [dismissedReference, dismissReference] = useState();
93
+ // Answers and menu highlights are session-owned too: the waterfall itself is derived per session
94
+ // by the controller, so only the local selection is kept here.
95
+ const { answers, option: optionState, approval: approvalSelection } = state.session.interaction;
96
+ const { index: referenceIndex, dismissed: dismissedReference } = state.session.reference;
118
97
  const [lookup, setLookup] = useState();
119
98
  // Interactive panels remain stable while read; only transient notices expire.
120
99
  useEffect(() => {
@@ -130,14 +109,14 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
130
109
  useEffect(() => {
131
110
  if (copyMode || contentSearch || historyQuery === undefined)
132
111
  return;
133
- const timer = setTimeout(() => { setHistoryQuery(undefined); setHistoryMatches(undefined); }, panelLifetimeMs);
112
+ const timer = setTimeout(() => { controller.setHistoryPanel(undefined); }, panelLifetimeMs);
134
113
  return () => clearTimeout(timer);
135
114
  }, [historyQuery, contentSearch, panelLifetimeMs, copyMode]);
136
115
  const pending = state.pending[0];
137
116
  const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
138
- useEffect(() => { setQueueOpen(false); }, [state.sessionId, pending?.eventId]);
117
+ useEffect(() => { controller.openQueue(false); }, [state.sessionId, pending?.eventId]);
139
118
  // A replayed interaction (same eventId after a reconnect) starts unselected again.
140
- useEffect(() => { setApprovalSelection(undefined); }, [state.sessionId, state.online, pending?.eventId]);
119
+ useEffect(() => { controller.setApproval(undefined); }, [state.sessionId, state.online, pending?.eventId]);
141
120
  const token = state.screen === 'chat' && state.online && !state.busy && !pending
142
121
  && !input.startsWith('/') && dismissedReference !== input && cursor === input.length
143
122
  ? activeReference(input) : undefined;
@@ -152,7 +131,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
152
131
  return;
153
132
  const abort = new AbortController();
154
133
  setLookup(undefined);
155
- setReferenceIndex(0);
134
+ controller.setReferenceIndex(0);
156
135
  void controller.references(activeReference(input).query, abort.signal).then(items => {
157
136
  if (!abort.signal.aborted)
158
137
  setLookup({ draft: input, sessionId: state.sessionId, items });
@@ -164,7 +143,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
164
143
  }, [controller, input, state.sessionId, referenceOpen]);
165
144
  const pickReference = () => {
166
145
  const candidate = matches?.items[referenceIndex];
167
- if (!candidate || !token || draft.current !== input)
146
+ if (!candidate || !token || controller.composer.draft !== input)
168
147
  return;
169
148
  const mention = fileMention(candidate, token.quoted);
170
149
  setInput(input.slice(0, -token.prefix.length) + mention + (candidate.kind === 'file' ? ' ' : ''));
@@ -197,26 +176,16 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
197
176
  // parked rather than typed into, and comes back when the last one closes. A question switches to
198
177
  // the composer itself for a free-text or "Other" answer, so it keeps the keys then.
199
178
  const answerPending = !!pending && (pending.event === 'approval/request' || options.length > 0 && !choiceState.custom);
200
- const parkedDraft = useRef('');
201
179
  useEffect(() => {
202
180
  if (answerPending) {
203
- if (draft.current === '')
204
- return;
205
- parkedDraft.current = draft.current;
206
- draft.current = '';
207
- updateInput('');
208
- setCursor(0);
181
+ controller.parkComposer();
209
182
  return;
210
183
  }
211
- if (pending || parkedDraft.current === '')
184
+ if (pending)
212
185
  return;
213
- // Restoring writes the states directly: the draft was neither recalled from history nor a new
214
- // message, so it must not move a reader's scroll position.
215
- const restore = parkedDraft.current;
216
- parkedDraft.current = '';
217
- draft.current = restore;
218
- updateInput(restore);
219
- setCursor(restore.length);
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();
220
189
  }, [answerPending, pending]);
221
190
  const approvalKeysActive = pending?.event === 'approval/request' && !copyMode && !panelBlocksKeys;
222
191
  const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
@@ -227,11 +196,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
227
196
  const next = [...answered, answer];
228
197
  if (next.length === questions.length) {
229
198
  await controller.answer({ answers: next });
230
- setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
199
+ const rest = { ...controller.interaction.answers };
200
+ delete rest[eventId];
201
+ controller.setAnswers(rest);
231
202
  }
232
203
  else
233
- setAnswers(previous => ({ ...previous, [eventId]: next }));
234
- setOptionState(undefined);
204
+ controller.setAnswers({ ...controller.interaction.answers, [eventId]: next });
205
+ controller.setOption(undefined);
235
206
  };
236
207
  const operate = (fn) => { void controller.perform(fn); };
237
208
  const requestRemoval = async (kind, query) => {
@@ -257,22 +228,20 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
257
228
  historyAbort.current.abort();
258
229
  // Esc also leaves a history list that was already on screen when the load was aborted.
259
230
  if (key.escape && historyQuery !== undefined) {
260
- setHistoryQuery(undefined);
261
- setHistoryMatches(undefined);
231
+ controller.setHistoryPanel(undefined);
262
232
  }
263
233
  return;
264
234
  }
265
235
  if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
266
- if (key.ctrl && draft.current)
236
+ if (key.ctrl && controller.composer.draft)
267
237
  setInput('');
268
238
  if (key.escape) {
269
- setApprovalSelection(undefined);
239
+ controller.setApproval(undefined);
270
240
  setRemoval(undefined);
271
- setModels(undefined);
272
- setThoughtList(false);
273
- setSearchResults(undefined);
274
- setHistoryQuery(undefined);
275
- setHistoryMatches(undefined);
241
+ controller.setModelPanel(undefined);
242
+ controller.openThoughts(false);
243
+ controller.setSearchPanel(undefined);
244
+ controller.setHistoryPanel(undefined);
276
245
  setHelp(false);
277
246
  setCostExpanded(false);
278
247
  setStatusExpanded(false);
@@ -281,24 +250,26 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
281
250
  // the options, and only the next one dismisses. Approval keeps every choice explicit.
282
251
  if (pending?.event === 'user-questions/request' && !choiceState.custom) {
283
252
  if (controller.state.online && !controller.state.busy && controller.state.pending[0]?.eventId === eventId) {
284
- setOptionState(undefined);
285
- setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
253
+ controller.setOption(undefined);
254
+ const rest = { ...controller.interaction.answers };
255
+ delete rest[eventId];
256
+ controller.setAnswers(rest);
286
257
  operate(() => controller.dismissQuestion());
287
258
  }
288
259
  return;
289
260
  }
290
- setOptionState({ ...choiceState, custom: false });
261
+ controller.setOption({ ...choiceState, custom: false });
291
262
  }
292
263
  return;
293
264
  }
294
- if (approvalKeysActive && !draft.current && controller.state.online && !controller.state.busy
265
+ if (approvalKeysActive && !controller.composer.draft && controller.state.online && !controller.state.busy
295
266
  && controller.state.pending[0]?.eventId === eventId && !key.ctrl && !key.meta) {
296
267
  const digit = /^[1-3]$/.test(_value) ? Number(_value) - 1 : -1;
297
268
  if (digit >= 0 || key.upArrow || key.downArrow) {
298
269
  // An unselected list enters at the first, non-destructive choice, so a stray arrow plus Enter cannot cancel.
299
270
  const index = digit >= 0 ? digit : approvalIndex < 0 ? 0
300
271
  : Math.max(0, Math.min(2, approvalIndex + (key.upArrow ? -1 : 1)));
301
- setApprovalSelection({ eventId, index });
272
+ controller.setApproval({ eventId, index });
302
273
  return;
303
274
  }
304
275
  if (key.return) {
@@ -308,10 +279,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
308
279
  return;
309
280
  }
310
281
  }
311
- if (questionKeysActive && !draft.current && !controller.state.busy && !key.ctrl && !key.meta) {
282
+ if (questionKeysActive && !controller.composer.draft && !controller.state.busy && !key.ctrl && !key.meta) {
312
283
  const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
313
284
  if (key.upArrow || key.downArrow) {
314
- setOptionState({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
285
+ controller.setOption({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
315
286
  return;
316
287
  }
317
288
  if (digit >= 0 && digit <= options.length || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
@@ -320,12 +291,12 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
320
291
  const selected = question.multiSelect === true && label
321
292
  ? choiceState.selected.includes(label) ? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label]
322
293
  : choiceState.selected;
323
- setOptionState({ ...choiceState, cursor: index, selected });
294
+ controller.setOption({ ...choiceState, cursor: index, selected });
324
295
  return;
325
296
  }
326
297
  if (key.return) {
327
298
  if (optionCursor === options.length) {
328
- setOptionState({ ...choiceState, custom: true });
299
+ controller.setOption({ ...choiceState, custom: true });
329
300
  return;
330
301
  }
331
302
  const selected = question.multiSelect === true ? choiceState.selected : [string(options[optionCursor].label)];
@@ -354,7 +325,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
354
325
  return;
355
326
  }
356
327
  if (key.escape && queueOpen) {
357
- setQueueOpen(false);
328
+ controller.openQueue(false);
358
329
  return;
359
330
  }
360
331
  if (key.escape && removal) {
@@ -362,24 +333,23 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
362
333
  return;
363
334
  }
364
335
  if (key.escape && models) {
365
- setModels(undefined);
336
+ controller.setModelPanel(undefined);
366
337
  return;
367
338
  }
368
339
  if (key.escape && thoughtList) {
369
- setThoughtList(false);
340
+ controller.openThoughts(false);
370
341
  if (controller.running)
371
342
  void controller.interrupt(true);
372
343
  return;
373
344
  }
374
345
  if (key.escape && searchResults) {
375
- setSearchResults(undefined);
346
+ controller.setSearchPanel(undefined);
376
347
  if (controller.running)
377
348
  void controller.interrupt(true);
378
349
  return;
379
350
  }
380
351
  if (key.escape && historyQuery !== undefined) {
381
- setHistoryQuery(undefined);
382
- setHistoryMatches(undefined);
352
+ controller.setHistoryPanel(undefined);
383
353
  if (controller.running)
384
354
  void controller.interrupt(true);
385
355
  return;
@@ -391,6 +361,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
391
361
  operate(() => controller.showPicker('workspaces'));
392
362
  return;
393
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
+ }
394
369
  if (key.ctrl && _value === 'c') {
395
370
  // A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
396
371
  if (input !== '') {
@@ -403,24 +378,34 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
403
378
  }
404
379
  if (referenceOpen) {
405
380
  if (key.escape) {
406
- dismissReference(input);
381
+ controller.setReferenceDismissed(input);
407
382
  if (controller.running)
408
383
  void controller.interrupt(true);
409
384
  }
410
385
  else if (key.tab)
411
386
  pickReference();
412
387
  else if (key.upArrow)
413
- setReferenceIndex(value => Math.max(0, value - 1));
388
+ controller.setReferenceIndex(Math.max(0, controller.reference.index - 1));
414
389
  else if (key.downArrow)
415
- setReferenceIndex(value => Math.max(0, Math.min((matches?.items.length ?? 1) - 1, value + 1)));
390
+ controller.setReferenceIndex(Math.max(0, Math.min((matches?.items.length ?? 1) - 1, controller.reference.index + 1)));
416
391
  return;
417
392
  }
418
393
  const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
419
394
  const recallNext = key.downArrow || key.ctrl && _value === 'n';
420
395
  if ((recallPrevious || recallNext) && state.online && !controller.state.busy && !pending
421
396
  && !queueOpen && (!recallBlocked || key.ctrl)
422
- && (state.screen === 'chat' || draft.current !== '' || key.ctrl)) {
423
- setInput(inputHistory.current.move(recallPrevious ? -1 : 1, draft.current), true);
397
+ && (state.screen === 'chat' || controller.composer.draft !== '' || key.ctrl)) {
398
+ // The oldest seeded entry is where the session happened to open, not where it began: stepping
399
+ // past it fetches the page before the retained window, and the step is applied once the page
400
+ // lands, so recall covers prompts from before this client connected. Paging stays with the
401
+ // 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) {
405
+ void recallOlderPrompts();
406
+ return;
407
+ }
408
+ setInput(controller.recall(recallPrevious ? -1 : 1, controller.composer.draft), true);
424
409
  return;
425
410
  }
426
411
  if (key.tab) {
@@ -437,6 +422,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
437
422
  void controller.interrupt(true);
438
423
  return;
439
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
+ }
440
430
  if (key.escape && state.screen === 'chat') {
441
431
  void controller.interrupt(true);
442
432
  }
@@ -453,7 +443,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
453
443
  }
454
444
  const value = raw.trim();
455
445
  if (!pending && !/^\/feedback(?:\s|$)/.test(value))
456
- inputHistory.current.record(value);
446
+ controller.recordRecall(value);
457
447
  if (submission.kind === 'copy') {
458
448
  setInput('');
459
449
  setCopyMode(true);
@@ -462,9 +452,9 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
462
452
  setRemoval(undefined);
463
453
  // Each panel belongs to the command that opened it, so any other command closes it.
464
454
  if (value !== '/queue')
465
- setQueueOpen(false);
455
+ controller.openQueue(false);
466
456
  if (!/^\/model(?: |$)/.test(value))
467
- setModels(undefined);
457
+ controller.setModelPanel(undefined);
468
458
  if (value !== '/help')
469
459
  setHelp(false);
470
460
  if (value !== '/cost')
@@ -472,7 +462,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
472
462
  if (value !== '/status')
473
463
  setStatusExpanded(false);
474
464
  if (!/^\/think(?: |$)/.test(value))
475
- setThoughtList(false);
465
+ controller.openThoughts(false);
476
466
  if (submission.kind === 'quit') {
477
467
  exit();
478
468
  return;
@@ -502,70 +492,71 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
502
492
  await requestRemoval(submission.target, submission.query);
503
493
  return;
504
494
  case 'navigate': {
505
- setHistoryQuery(undefined);
506
- setSearchResults(undefined);
495
+ controller.setHistoryPanel(undefined);
496
+ controller.setSearchPanel(undefined);
507
497
  if (submission.target === 'workspace')
508
498
  await controller.switchWorkspace(submission.query);
509
499
  else
510
500
  await controller.switchSession(submission.query);
511
- setScroll(0);
501
+ controller.setScroll(0);
512
502
  return;
513
503
  }
514
504
  case 'path':
515
505
  await controller.createWorkspace(submission.value);
516
506
  return;
517
507
  case 'latest':
518
- setHistoryWindow(undefined);
519
- setHistoryMatches(undefined);
520
- setHistoryQuery(undefined);
521
- setSearchResults(undefined);
522
- setReasoningOverrides(new Set());
523
- setScroll(0);
508
+ controller.setViewWindow(undefined);
509
+ controller.setHistoryPanel(undefined);
510
+ controller.setSearchPanel(undefined);
511
+ controller.setFolds(new Set());
512
+ controller.setScroll(0);
524
513
  controller.pinHistory(false);
525
514
  return;
526
515
  case 'models': {
527
516
  if (!submission.args.length) {
528
- setHistoryQuery(undefined);
529
- setSearchResults(undefined);
530
- setModels({ catalog: await controller.modelCatalog() });
517
+ controller.setHistoryPanel(undefined);
518
+ controller.setSearchPanel(undefined);
519
+ controller.setModelPanel({ catalog: await controller.modelCatalog() });
531
520
  }
532
521
  else {
533
522
  await controller.selectModel(submission.args[0], submission.args[1], submission.args[2]);
534
- setModels(undefined);
523
+ controller.setModelPanel(undefined);
535
524
  }
536
525
  return;
537
526
  }
538
527
  case 'queue':
539
- setQueueOpen(true);
528
+ controller.openQueue(true);
529
+ return;
530
+ case 'shell':
531
+ controller.shell.start(submission.command);
532
+ controller.setScroll(0);
540
533
  return;
541
534
  case 'newSession':
542
535
  await controller.createSession();
543
536
  return;
544
537
  case 'history':
545
- setSearchResults(undefined);
546
- setContentSearch(false);
547
- setHistoryQuery(submission.query);
538
+ controller.setSearchPanel(undefined);
539
+ controller.setHistoryPanel({ query: submission.query, contentSearch: false });
548
540
  return;
549
541
  case 'sessionSearch':
550
542
  await historyOperation(async (signal) => {
551
543
  const result = await controller.searchSessions(submission.query, submission.command === '/ssearch', signal);
552
- setHistoryQuery(undefined);
553
- setSearchResults({ query: submission.query, ...result });
544
+ controller.setHistoryPanel(undefined);
545
+ controller.setSearchPanel({ query: submission.query, ...result });
554
546
  });
555
547
  return;
556
548
  case 'historySearch':
557
549
  await historyOperation(async (signal) => {
558
- setHistoryMatches(await controller.searchHistory(submission.query, signal));
559
- setSearchResults(undefined);
560
- setContentSearch(true);
561
- setHistoryQuery(submission.query);
550
+ controller.setHistoryPanel({ query: submission.query, contentSearch: true,
551
+ matches: await controller.searchHistory(submission.query, signal) });
552
+ controller.setSearchPanel(undefined);
562
553
  });
563
554
  return;
564
555
  case 'think': {
565
556
  if (submission.target === 'live') {
566
- setLiveReasoning(value => value === 'row' ? 'full' : 'row');
567
- setThoughtList(false);
568
- setScroll(0);
557
+ controller.setLiveReasoning(controller.view.liveReasoning === 'row' ? 'full' : 'row');
558
+ controller.openThoughts(false);
559
+ controller.setScroll(0);
569
560
  }
570
561
  else if (submission.target) {
571
562
  const seq = Number(submission.target);
@@ -573,23 +564,23 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
573
564
  throw new Error('Use /think <message sequence> for a loaded reasoning block');
574
565
  const next = new Set(reasoningOverrides);
575
566
  if (next.delete(seq)) {
576
- setReasoningOverrides(next);
567
+ controller.setFolds(next);
577
568
  return;
578
569
  }
579
570
  next.add(seq);
580
- setReasoningOverrides(next);
571
+ controller.setFolds(next);
581
572
  await jumpHistory(seq, next);
582
573
  }
583
574
  else {
584
- setHistoryQuery(undefined);
585
- setSearchResults(undefined);
586
- setThoughtList(true);
575
+ controller.setHistoryPanel(undefined);
576
+ controller.setSearchPanel(undefined);
577
+ controller.openThoughts(true);
587
578
  }
588
579
  return;
589
580
  }
590
581
  case 'older':
591
582
  await controller.older(undefined, displayTranscript);
592
- setScroll(value => value + 10);
583
+ controller.setScroll(controller.view.scroll + 10);
593
584
  return;
594
585
  case 'compact':
595
586
  setNotice(undefined);
@@ -624,8 +615,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
624
615
  case 'error': throw new Error(submission.message);
625
616
  case 'prompt':
626
617
  await controller.prompt(submission.text);
627
- setHistoryWindow(undefined);
628
- setScroll(0);
618
+ controller.setViewWindow(undefined);
619
+ controller.setScroll(0);
629
620
  return;
630
621
  }
631
622
  });
@@ -682,39 +673,42 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
682
673
  ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
683
674
  label: `${sessionStatus(session, listAge, isPending(session))} ${sessionLabel(session)} ${session.sessionId}`,
684
675
  remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
685
- action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
676
+ action: () => { controller.setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
686
677
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
687
678
  ];
688
679
  const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
689
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;
690
685
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
691
686
  const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
692
687
  const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
693
- const previousView = useRef({ transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning });
688
+ const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning });
694
689
  const previous = previousView.current;
695
690
  const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
696
- const adjustedScroll = previous.session !== state.transcript ? 0
697
- : scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + length - previous.count) : scroll;
698
- const maxScroll = Math.max(0, length - pageSize);
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);
699
694
  const position = Math.min(adjustedScroll, maxScroll);
700
695
  useLayoutEffect(() => {
701
- previousView.current = { transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning };
696
+ previousView.current = { transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning };
702
697
  if (position !== scroll)
703
- setScroll(position);
704
- }, [state.transcript, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
698
+ controller.setScroll(position);
699
+ }, [state.session.record, displayTranscript, totalRows, position, scroll, reasoningOverrides, liveReasoning]);
705
700
  useLayoutEffect(() => {
706
701
  controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
707
- }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.transcript]);
702
+ }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.session.record]);
708
703
  useEffect(() => {
709
704
  const first = displayTranscript.beforeSeq;
710
- if (first !== undefined)
711
- setReasoningOverrides(previous => {
712
- const next = new Set([...previous].filter(seq => seq >= first));
713
- return next.size === previous.size ? previous : next;
714
- });
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);
715
711
  }, [displayTranscript, displayTranscript.memoryRevision]);
716
- const scrollPosition = useRef(position);
717
- scrollPosition.current = position;
718
712
  const loadingPage = useRef(false);
719
713
  const scrollIntent = useRef(0);
720
714
  const mounted = useRef(true);
@@ -723,16 +717,15 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
723
717
  if (copyMode || state.screen !== 'chat')
724
718
  return;
725
719
  const intent = ++scrollIntent.current;
726
- const next = Math.max(0, Math.min(maxScroll, scrollPosition.current + delta));
727
- scrollPosition.current = next;
728
- setScroll(next);
720
+ const next = Math.max(0, Math.min(maxScroll, controller.view.scroll + delta));
721
+ controller.setScroll(next);
729
722
  if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
730
723
  return;
731
724
  loadingPage.current = true;
732
725
  const transcript = displayTranscript;
733
726
  void controller.perform(() => historyOperation(signal => controller.older(signal, transcript))).then(accepted => {
734
727
  if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
735
- setScroll(value => value + delta);
728
+ controller.setScroll(controller.view.scroll + delta);
736
729
  }
737
730
  }).finally(() => { loadingPage.current = false; });
738
731
  }
@@ -750,14 +743,51 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
750
743
  }
751
744
  }
752
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
+ }
753
785
  async function openSearchSession(sessionId, query) {
754
786
  await historyOperation(async (signal) => {
755
787
  await controller.selectSession(sessionId);
756
788
  await controller.waitForHistory(signal);
757
- setHistoryMatches(await controller.searchHistory(query, signal));
758
- setSearchResults(undefined);
759
- setContentSearch(true);
760
- setHistoryQuery(query);
789
+ controller.setHistoryPanel({ query, contentSearch: true, matches: await controller.searchHistory(query, signal) });
790
+ controller.setSearchPanel(undefined);
761
791
  });
762
792
  }
763
793
  async function jumpHistory(target, folds = reasoningOverrides) {
@@ -768,18 +798,17 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
768
798
  historyAbort.current = abort;
769
799
  try {
770
800
  const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
771
- : state.transcript.messages.some(message => message.seq === target) ? state.transcript
801
+ : state.session.record.messages.some(message => message.seq === target) ? state.session.record
772
802
  : await controller.historyAt(target, abort.signal);
773
803
  abort.signal.throwIfAborted();
774
804
  const current = historyLayout(transcript, width, reasoning, folds, liveReasoning);
775
805
  const row = current.offsets.get(target);
776
806
  if (row === undefined)
777
807
  throw new Error('No visible message at this sequence; use /history to choose a record');
778
- setHistoryWindow(transcript === state.transcript ? undefined : transcript);
779
- setHistoryQuery(undefined);
780
- setHistoryMatches(undefined);
781
- setThoughtList(false);
782
- setScroll(Math.max(0, current.length - pageSize - row));
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));
783
812
  }
784
813
  finally {
785
814
  if (historyAbort.current === abort)
@@ -792,20 +821,20 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
792
821
  scrollHistory(direction * 3); }, !copyMode && state.screen === 'chat', () => { if (!dialogOpen)
793
822
  setCopyMode(true); });
794
823
  const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
795
- const end = Math.max(pageSize, length - position - trailingGap);
796
- const visible = useMemo(() => layout.viewport(Math.max(0, end - pageSize), end), [layout, end, pageSize]);
797
- const liveThought = thoughtList && !historyWindow ? state.transcript.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
824
+ const end = Math.max(pageSize, totalRows - position - trailingGap);
825
+ const visible = useMemo(() => merged.viewport(Math.max(0, end - pageSize), end), [merged, end, pageSize]);
826
+ const liveThought = thoughtList && !historyWindow ? state.session.record.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
798
827
  const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
799
828
  const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
800
829
  key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
801
- action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq, next); }),
830
+ action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); controller.setFolds(next); await jumpHistory(entry.seq, next); }),
802
831
  })), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
803
832
  const thoughtOptions = useMemo(() => [
804
- ...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.transcript.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
805
- action: () => { setLiveReasoning('full'); setThoughtList(false); const expanded = historyLayout(state.transcript, width, reasoning, reasoningOverrides, 'full'); setScroll(Math.max(0, expanded.length - pageSize - expanded.liveOffset)); } }] : []),
833
+ ...(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)); } }] : []),
806
835
  ...thoughtChoices,
807
836
  ...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.older(signal, displayTranscript))) }] : []),
808
- { key: 'close', label: '← Back to conversation', action: () => setThoughtList(false) },
837
+ { key: 'close', label: '← Back to conversation', action: () => controller.openThoughts(false) },
809
838
  ], [liveThought, thoughtChoices, displayTranscript, displayTranscript.hasMore, width, pageSize, reasoningOverrides]);
810
839
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
811
840
  const workspaceName = workspace ? string(workspace.title) || string(workspace.path) : state.workspaceId;
@@ -820,13 +849,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
820
849
  : state.screen === 'chat' && position > 0 ? 'history'
821
850
  : undefined;
822
851
  const statusFrozen = pauseReason !== undefined;
823
- return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, width: pickerWidth,
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,
824
853
  // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
825
854
  // terminal too narrow for the whole key gets the badges alone rather than half a legend.
826
- legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
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 }))
827
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
828
857
  ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓ / 1–9 select · Enter confirm · Esc dismisses' })] }), _jsx(Text, { dimColor: true, children: question ? options.length > 0
829
858
  ? 'Choose "Other answer" to type · the draft is kept while this is open'
830
859
  : 'Esc dismisses the question · the draft is kept while this is open'
831
- : 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
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 }) })] })] }) }) }) });
832
861
  }