@itookit/dsht 0.3.4 → 0.3.7

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