@itookit/dsht 0.3.3 → 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.
Files changed (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -10
  3. package/README.zh.md +7 -7
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -123
  7. package/dist/controller/controller.d.ts +115 -0
  8. package/dist/controller/controller.js +126 -1
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/controller.d.ts +5 -0
  12. package/dist/cost/controller.js +2 -1
  13. package/dist/cost/scanner.d.ts +4 -2
  14. package/dist/cost/scanner.js +6 -3
  15. package/dist/session/controller.d.ts +157 -1
  16. package/dist/session/controller.js +395 -29
  17. package/dist/session/index.d.ts +2 -0
  18. package/dist/session/index.js +1 -0
  19. package/dist/session/info.d.ts +262 -0
  20. package/dist/session/info.js +326 -0
  21. package/dist/session/navigation.d.ts +62 -8
  22. package/dist/session/navigation.js +72 -13
  23. package/dist/session/transcript.d.ts +37 -1
  24. package/dist/session/transcript.js +73 -0
  25. package/dist/state.d.ts +3 -2
  26. package/dist/state.js +2 -2
  27. package/dist/ui/app.js +230 -178
  28. package/dist/ui/chat/status.js +15 -10
  29. package/dist/ui/dialogs/index.d.ts +9 -8
  30. package/dist/ui/dialogs/index.js +3 -3
  31. package/dist/ui/dialogs/picker.d.ts +21 -3
  32. package/dist/ui/dialogs/picker.js +37 -5
  33. package/dist/ui/input/input.d.ts +18 -3
  34. package/dist/ui/input/input.js +61 -22
  35. package/dist/ui/input/viewport.d.ts +96 -0
  36. package/dist/ui/input/viewport.js +173 -0
  37. package/dsht-m.png +0 -0
  38. package/package.json +3 -3
  39. package/dist/ui/input/history.d.ts +0 -19
  40. package/dist/ui/input/history.js +0 -43
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";
@@ -19,10 +18,12 @@ import { CopyMode } from "./copy-mode.js";
19
18
  import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
20
19
  import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
21
20
  import { classifySubmission } from "./commands/parse.js";
22
- import { sessionLabel, sessionStatus, workspaceStatus } from "../session/navigation.js";
21
+ import { ROLLUP_LEGEND, sessionLabel, sessionStatus, workspaceCounts, workspaceDetail, workspaceSegments, workspaceStatus } from "../session/navigation.js";
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,37 +227,48 @@ 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
- setOptionState({ ...choiceState, custom: false });
270
- setApprovalSelection(undefined);
238
+ controller.setApproval(undefined);
271
239
  setRemoval(undefined);
272
- setModels(undefined);
273
- setThoughtList(false);
274
- setSearchResults(undefined);
275
- setHistoryQuery(undefined);
276
- setHistoryMatches(undefined);
240
+ controller.setModelPanel(undefined);
241
+ controller.openThoughts(false);
242
+ controller.setSearchPanel(undefined);
243
+ controller.setHistoryPanel(undefined);
277
244
  setHelp(false);
278
245
  setCostExpanded(false);
279
246
  setStatusExpanded(false);
247
+ // A question batch is one request, so Esc leaves the whole set the way the Web client's
248
+ // close button does. The free-text row keeps its own step back: its first Esc returns to
249
+ // the options, and only the next one dismisses. Approval keeps every choice explicit.
250
+ if (pending?.event === 'user-questions/request' && !choiceState.custom) {
251
+ if (controller.state.online && !controller.state.busy && controller.state.pending[0]?.eventId === eventId) {
252
+ controller.setOption(undefined);
253
+ const rest = { ...controller.interaction.answers };
254
+ delete rest[eventId];
255
+ controller.setAnswers(rest);
256
+ operate(() => controller.dismissQuestion());
257
+ }
258
+ return;
259
+ }
260
+ controller.setOption({ ...choiceState, custom: false });
280
261
  }
281
262
  return;
282
263
  }
283
- if (approvalKeysActive && !draft.current && controller.state.online && !controller.state.busy
264
+ if (approvalKeysActive && !controller.composer.draft && controller.state.online && !controller.state.busy
284
265
  && controller.state.pending[0]?.eventId === eventId && !key.ctrl && !key.meta) {
285
266
  const digit = /^[1-3]$/.test(_value) ? Number(_value) - 1 : -1;
286
267
  if (digit >= 0 || key.upArrow || key.downArrow) {
287
268
  // An unselected list enters at the first, non-destructive choice, so a stray arrow plus Enter cannot cancel.
288
269
  const index = digit >= 0 ? digit : approvalIndex < 0 ? 0
289
270
  : Math.max(0, Math.min(2, approvalIndex + (key.upArrow ? -1 : 1)));
290
- setApprovalSelection({ eventId, index });
271
+ controller.setApproval({ eventId, index });
291
272
  return;
292
273
  }
293
274
  if (key.return) {
@@ -297,10 +278,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
297
278
  return;
298
279
  }
299
280
  }
300
- if (questionKeysActive && !draft.current && !controller.state.busy && !key.ctrl && !key.meta) {
281
+ if (questionKeysActive && !controller.composer.draft && !controller.state.busy && !key.ctrl && !key.meta) {
301
282
  const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
302
283
  if (key.upArrow || key.downArrow) {
303
- 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))) });
304
285
  return;
305
286
  }
306
287
  if (digit >= 0 && digit <= options.length || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
@@ -309,12 +290,12 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
309
290
  const selected = question.multiSelect === true && label
310
291
  ? choiceState.selected.includes(label) ? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label]
311
292
  : choiceState.selected;
312
- setOptionState({ ...choiceState, cursor: index, selected });
293
+ controller.setOption({ ...choiceState, cursor: index, selected });
313
294
  return;
314
295
  }
315
296
  if (key.return) {
316
297
  if (optionCursor === options.length) {
317
- setOptionState({ ...choiceState, custom: true });
298
+ controller.setOption({ ...choiceState, custom: true });
318
299
  return;
319
300
  }
320
301
  const selected = question.multiSelect === true ? choiceState.selected : [string(options[optionCursor].label)];
@@ -343,7 +324,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
343
324
  return;
344
325
  }
345
326
  if (key.escape && queueOpen) {
346
- setQueueOpen(false);
327
+ controller.openQueue(false);
347
328
  return;
348
329
  }
349
330
  if (key.escape && removal) {
@@ -351,24 +332,23 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
351
332
  return;
352
333
  }
353
334
  if (key.escape && models) {
354
- setModels(undefined);
335
+ controller.setModelPanel(undefined);
355
336
  return;
356
337
  }
357
338
  if (key.escape && thoughtList) {
358
- setThoughtList(false);
339
+ controller.openThoughts(false);
359
340
  if (controller.running)
360
341
  void controller.interrupt(true);
361
342
  return;
362
343
  }
363
344
  if (key.escape && searchResults) {
364
- setSearchResults(undefined);
345
+ controller.setSearchPanel(undefined);
365
346
  if (controller.running)
366
347
  void controller.interrupt(true);
367
348
  return;
368
349
  }
369
350
  if (key.escape && historyQuery !== undefined) {
370
- setHistoryQuery(undefined);
371
- setHistoryMatches(undefined);
351
+ controller.setHistoryPanel(undefined);
372
352
  if (controller.running)
373
353
  void controller.interrupt(true);
374
354
  return;
@@ -392,24 +372,34 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
392
372
  }
393
373
  if (referenceOpen) {
394
374
  if (key.escape) {
395
- dismissReference(input);
375
+ controller.setReferenceDismissed(input);
396
376
  if (controller.running)
397
377
  void controller.interrupt(true);
398
378
  }
399
379
  else if (key.tab)
400
380
  pickReference();
401
381
  else if (key.upArrow)
402
- setReferenceIndex(value => Math.max(0, value - 1));
382
+ controller.setReferenceIndex(Math.max(0, controller.reference.index - 1));
403
383
  else if (key.downArrow)
404
- 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)));
405
385
  return;
406
386
  }
407
387
  const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
408
388
  const recallNext = key.downArrow || key.ctrl && _value === 'n';
409
389
  if ((recallPrevious || recallNext) && state.online && !controller.state.busy && !pending
410
390
  && !queueOpen && (!recallBlocked || key.ctrl)
411
- && (state.screen === 'chat' || draft.current !== '' || key.ctrl)) {
412
- 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);
413
403
  return;
414
404
  }
415
405
  if (key.tab) {
@@ -442,7 +432,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
442
432
  }
443
433
  const value = raw.trim();
444
434
  if (!pending && !/^\/feedback(?:\s|$)/.test(value))
445
- inputHistory.current.record(value);
435
+ controller.recordRecall(value);
446
436
  if (submission.kind === 'copy') {
447
437
  setInput('');
448
438
  setCopyMode(true);
@@ -451,9 +441,9 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
451
441
  setRemoval(undefined);
452
442
  // Each panel belongs to the command that opened it, so any other command closes it.
453
443
  if (value !== '/queue')
454
- setQueueOpen(false);
444
+ controller.openQueue(false);
455
445
  if (!/^\/model(?: |$)/.test(value))
456
- setModels(undefined);
446
+ controller.setModelPanel(undefined);
457
447
  if (value !== '/help')
458
448
  setHelp(false);
459
449
  if (value !== '/cost')
@@ -461,7 +451,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
461
451
  if (value !== '/status')
462
452
  setStatusExpanded(false);
463
453
  if (!/^\/think(?: |$)/.test(value))
464
- setThoughtList(false);
454
+ controller.openThoughts(false);
465
455
  if (submission.kind === 'quit') {
466
456
  exit();
467
457
  return;
@@ -491,70 +481,67 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
491
481
  await requestRemoval(submission.target, submission.query);
492
482
  return;
493
483
  case 'navigate': {
494
- setHistoryQuery(undefined);
495
- setSearchResults(undefined);
484
+ controller.setHistoryPanel(undefined);
485
+ controller.setSearchPanel(undefined);
496
486
  if (submission.target === 'workspace')
497
487
  await controller.switchWorkspace(submission.query);
498
488
  else
499
489
  await controller.switchSession(submission.query);
500
- setScroll(0);
490
+ controller.setScroll(0);
501
491
  return;
502
492
  }
503
493
  case 'path':
504
494
  await controller.createWorkspace(submission.value);
505
495
  return;
506
496
  case 'latest':
507
- setHistoryWindow(undefined);
508
- setHistoryMatches(undefined);
509
- setHistoryQuery(undefined);
510
- setSearchResults(undefined);
511
- setReasoningOverrides(new Set());
512
- setScroll(0);
497
+ controller.setViewWindow(undefined);
498
+ controller.setHistoryPanel(undefined);
499
+ controller.setSearchPanel(undefined);
500
+ controller.setFolds(new Set());
501
+ controller.setScroll(0);
513
502
  controller.pinHistory(false);
514
503
  return;
515
504
  case 'models': {
516
505
  if (!submission.args.length) {
517
- setHistoryQuery(undefined);
518
- setSearchResults(undefined);
519
- setModels({ catalog: await controller.modelCatalog() });
506
+ controller.setHistoryPanel(undefined);
507
+ controller.setSearchPanel(undefined);
508
+ controller.setModelPanel({ catalog: await controller.modelCatalog() });
520
509
  }
521
510
  else {
522
511
  await controller.selectModel(submission.args[0], submission.args[1], submission.args[2]);
523
- setModels(undefined);
512
+ controller.setModelPanel(undefined);
524
513
  }
525
514
  return;
526
515
  }
527
516
  case 'queue':
528
- setQueueOpen(true);
517
+ controller.openQueue(true);
529
518
  return;
530
519
  case 'newSession':
531
520
  await controller.createSession();
532
521
  return;
533
522
  case 'history':
534
- setSearchResults(undefined);
535
- setContentSearch(false);
536
- setHistoryQuery(submission.query);
523
+ controller.setSearchPanel(undefined);
524
+ controller.setHistoryPanel({ query: submission.query, contentSearch: false });
537
525
  return;
538
526
  case 'sessionSearch':
539
527
  await historyOperation(async (signal) => {
540
528
  const result = await controller.searchSessions(submission.query, submission.command === '/ssearch', signal);
541
- setHistoryQuery(undefined);
542
- setSearchResults({ query: submission.query, ...result });
529
+ controller.setHistoryPanel(undefined);
530
+ controller.setSearchPanel({ query: submission.query, ...result });
543
531
  });
544
532
  return;
545
533
  case 'historySearch':
546
534
  await historyOperation(async (signal) => {
547
- setHistoryMatches(await controller.searchHistory(submission.query, signal));
548
- setSearchResults(undefined);
549
- setContentSearch(true);
550
- setHistoryQuery(submission.query);
535
+ controller.setHistoryPanel({ query: submission.query, contentSearch: true,
536
+ matches: await controller.searchHistory(submission.query, signal) });
537
+ controller.setSearchPanel(undefined);
551
538
  });
552
539
  return;
553
540
  case 'think': {
554
541
  if (submission.target === 'live') {
555
- setLiveReasoning(value => value === 'row' ? 'full' : 'row');
556
- setThoughtList(false);
557
- setScroll(0);
542
+ controller.setLiveReasoning(controller.view.liveReasoning === 'row' ? 'full' : 'row');
543
+ controller.openThoughts(false);
544
+ controller.setScroll(0);
558
545
  }
559
546
  else if (submission.target) {
560
547
  const seq = Number(submission.target);
@@ -562,23 +549,23 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
562
549
  throw new Error('Use /think <message sequence> for a loaded reasoning block');
563
550
  const next = new Set(reasoningOverrides);
564
551
  if (next.delete(seq)) {
565
- setReasoningOverrides(next);
552
+ controller.setFolds(next);
566
553
  return;
567
554
  }
568
555
  next.add(seq);
569
- setReasoningOverrides(next);
556
+ controller.setFolds(next);
570
557
  await jumpHistory(seq, next);
571
558
  }
572
559
  else {
573
- setHistoryQuery(undefined);
574
- setSearchResults(undefined);
575
- setThoughtList(true);
560
+ controller.setHistoryPanel(undefined);
561
+ controller.setSearchPanel(undefined);
562
+ controller.openThoughts(true);
576
563
  }
577
564
  return;
578
565
  }
579
566
  case 'older':
580
567
  await controller.older(undefined, displayTranscript);
581
- setScroll(value => value + 10);
568
+ controller.setScroll(controller.view.scroll + 10);
582
569
  return;
583
570
  case 'compact':
584
571
  setNotice(undefined);
@@ -613,25 +600,52 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
613
600
  case 'error': throw new Error(submission.message);
614
601
  case 'prompt':
615
602
  await controller.prompt(submission.text);
616
- setHistoryWindow(undefined);
617
- setScroll(0);
603
+ controller.setViewWindow(undefined);
604
+ controller.setScroll(0);
618
605
  return;
619
606
  }
620
607
  });
621
608
  if (accepted)
622
609
  setInput('');
623
610
  };
611
+ const width = Math.max(10, (stdout.columns ?? 80) - 2);
612
+ // The composer never trades the conversation away for input. Its window grows with the body and
613
+ // is clamped so a few conversation rows always survive; width never buys height, so landscape and
614
+ // a wide desktop only wrap less instead of showing more input rows. The budget subtracts the one
615
+ // row the frame, the header, the status bar and a transient notice/suggestion line may each take.
616
+ const bodyRows = Math.max(4, (stdout.rows ?? 30) - 4 - statusBarRows);
617
+ const composerRows = Math.max(1, Math.min(Math.min(5, Math.max(2, Math.floor(bodyRows / 3))), bodyRows - 7));
618
+ const draftWidth = Math.max(1, width - 6);
624
619
  // Session activity is a list-level fact, so both pickers read it without loading any history.
625
620
  const listAge = Date.now();
621
+ // Unanswered interactions are held per session already; a list only has to read the counts.
622
+ const pendingCounts = controller.pendingCounts();
623
+ const isPending = (session) => (pendingCounts.get(string(session.sessionId)) ?? 0) > 0;
626
624
  const sessionsOf = (workspace) => {
627
625
  const ids = new Set(array(workspace.sessionIds).map(string));
628
626
  return state.sessions.filter(session => ids.has(string(session.sessionId)));
629
627
  };
628
+ // `width` counts only the frame's outer padding, so the list subtracts the composer border and
629
+ // padding it renders inside; measured against the wider value a long row would still wrap.
630
+ const pickerWidth = Math.max(16, width - 4);
631
+ // Words explain themselves. Badges fit a narrow terminal but need the marker key beside them.
632
+ const rollupStyle = pickerWidth >= 80 ? 'words' : 'badges';
633
+ // Needs-you is the one state that asks for action, so it outranks working; ready stays quiet.
634
+ const stateColor = { needs: theme.status.critical, running: theme.status.working, idle: theme.colors.muted };
630
635
  const choices = state.screen === 'workspaces' ? [
631
- ...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
632
- label: `${[workspaceStatus(sessionsOf(workspace)), string(workspace.title), string(workspace.path)].filter(Boolean).join(' ')}`,
633
- remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
634
- action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
636
+ ...state.workspaces.map(workspace => {
637
+ const counts = workspaceCounts(sessionsOf(workspace), new Set(pendingCounts.keys()));
638
+ const name = string(workspace.title);
639
+ const title = name || string(workspace.path);
640
+ // A path only earns a column when words are affordable and the title does not already say it.
641
+ const detail = rollupStyle === 'words' && name ? workspaceDetail(string(workspace.path), name) : '';
642
+ return { key: string(workspace.workspaceId),
643
+ label: [workspaceStatus(counts, rollupStyle), title].filter(Boolean).join(' '), title,
644
+ cells: workspaceSegments(counts, rollupStyle).map(segment => ({ text: segment.text, color: stateColor[segment.state] })),
645
+ ...(detail ? { detail } : {}),
646
+ remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
647
+ action: () => controller.pickWorkspace(string(workspace.workspaceId)) };
648
+ }),
635
649
  { key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
636
650
  // The directory this client runs in is the one case where no path has to be typed, and offering
637
651
  // it only while the host has not registered it keeps the row from repeating itself.
@@ -642,42 +656,40 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
642
656
  ] : [
643
657
  ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
644
658
  ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
645
- label: `${sessionStatus(session, listAge)} ${sessionLabel(session)} ${session.sessionId}`,
659
+ label: `${sessionStatus(session, listAge, isPending(session))} ${sessionLabel(session)} ${session.sessionId}`,
646
660
  remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
647
- action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
661
+ action: () => { controller.setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
648
662
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
649
663
  ];
650
- const width = Math.max(10, (stdout.columns ?? 80) - 2);
651
664
  const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
652
665
  const { length, first } = layout;
653
666
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
654
667
  const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
655
668
  const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
656
- 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 });
657
670
  const previous = previousView.current;
658
671
  const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
659
- const adjustedScroll = previous.session !== state.transcript ? 0
672
+ const adjustedScroll = previous.session !== state.session.record ? 0
660
673
  : scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + length - previous.count) : scroll;
661
674
  const maxScroll = Math.max(0, length - pageSize);
662
675
  const position = Math.min(adjustedScroll, maxScroll);
663
676
  useLayoutEffect(() => {
664
- 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 };
665
678
  if (position !== scroll)
666
- setScroll(position);
667
- }, [state.transcript, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
679
+ controller.setScroll(position);
680
+ }, [state.session.record, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
668
681
  useLayoutEffect(() => {
669
682
  controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
670
- }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.transcript]);
683
+ }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.session.record]);
671
684
  useEffect(() => {
672
685
  const first = displayTranscript.beforeSeq;
673
- if (first !== undefined)
674
- setReasoningOverrides(previous => {
675
- const next = new Set([...previous].filter(seq => seq >= first));
676
- return next.size === previous.size ? previous : next;
677
- });
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);
678
692
  }, [displayTranscript, displayTranscript.memoryRevision]);
679
- const scrollPosition = useRef(position);
680
- scrollPosition.current = position;
681
693
  const loadingPage = useRef(false);
682
694
  const scrollIntent = useRef(0);
683
695
  const mounted = useRef(true);
@@ -686,16 +698,15 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
686
698
  if (copyMode || state.screen !== 'chat')
687
699
  return;
688
700
  const intent = ++scrollIntent.current;
689
- const next = Math.max(0, Math.min(maxScroll, scrollPosition.current + delta));
690
- scrollPosition.current = next;
691
- setScroll(next);
701
+ const next = Math.max(0, Math.min(maxScroll, controller.view.scroll + delta));
702
+ controller.setScroll(next);
692
703
  if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
693
704
  return;
694
705
  loadingPage.current = true;
695
706
  const transcript = displayTranscript;
696
707
  void controller.perform(() => historyOperation(signal => controller.older(signal, transcript))).then(accepted => {
697
708
  if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
698
- setScroll(value => value + delta);
709
+ controller.setScroll(controller.view.scroll + delta);
699
710
  }
700
711
  }).finally(() => { loadingPage.current = false; });
701
712
  }
@@ -713,14 +724,51 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
713
724
  }
714
725
  }
715
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
+ }
716
766
  async function openSearchSession(sessionId, query) {
717
767
  await historyOperation(async (signal) => {
718
768
  await controller.selectSession(sessionId);
719
769
  await controller.waitForHistory(signal);
720
- setHistoryMatches(await controller.searchHistory(query, signal));
721
- setSearchResults(undefined);
722
- setContentSearch(true);
723
- setHistoryQuery(query);
770
+ controller.setHistoryPanel({ query, contentSearch: true, matches: await controller.searchHistory(query, signal) });
771
+ controller.setSearchPanel(undefined);
724
772
  });
725
773
  }
726
774
  async function jumpHistory(target, folds = reasoningOverrides) {
@@ -731,18 +779,17 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
731
779
  historyAbort.current = abort;
732
780
  try {
733
781
  const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
734
- : state.transcript.messages.some(message => message.seq === target) ? state.transcript
782
+ : state.session.record.messages.some(message => message.seq === target) ? state.session.record
735
783
  : await controller.historyAt(target, abort.signal);
736
784
  abort.signal.throwIfAborted();
737
785
  const current = historyLayout(transcript, width, reasoning, folds, liveReasoning);
738
786
  const row = current.offsets.get(target);
739
787
  if (row === undefined)
740
788
  throw new Error('No visible message at this sequence; use /history to choose a record');
741
- setHistoryWindow(transcript === state.transcript ? undefined : transcript);
742
- setHistoryQuery(undefined);
743
- setHistoryMatches(undefined);
744
- setThoughtList(false);
745
- 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));
746
793
  }
747
794
  finally {
748
795
  if (historyAbort.current === abort)
@@ -757,18 +804,18 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
757
804
  const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
758
805
  const end = Math.max(pageSize, length - position - trailingGap);
759
806
  const visible = useMemo(() => layout.viewport(Math.max(0, end - pageSize), end), [layout, end, pageSize]);
760
- 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;
761
808
  const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
762
809
  const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
763
810
  key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
764
- 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); }),
765
812
  })), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
766
813
  const thoughtOptions = useMemo(() => [
767
- ...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.transcript.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
768
- 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)); } }] : []),
769
816
  ...thoughtChoices,
770
817
  ...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.older(signal, displayTranscript))) }] : []),
771
- { key: 'close', label: '← Back to conversation', action: () => setThoughtList(false) },
818
+ { key: 'close', label: '← Back to conversation', action: () => controller.openThoughts(false) },
772
819
  ], [liveThought, thoughtChoices, displayTranscript, displayTranscript.hasMore, width, pageSize, reasoningOverrides]);
773
820
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
774
821
  const workspaceName = workspace ? string(workspace.title) || string(workspace.path) : state.workspaceId;
@@ -783,8 +830,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
783
830
  : state.screen === 'chat' && position > 0 ? 'history'
784
831
  : undefined;
785
832
  const statusFrozen = pauseReason !== undefined;
786
- 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, 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 }))
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,
834
+ // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
835
+ // terminal too narrow for the whole key gets the badges alone rather than half a legend.
836
+ legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && !state.busy && !input, canSelect: () => !controller.composer.draft && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !controller.composer.draft && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !controller.composer.draft && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => controller.setHistoryPanel(undefined) }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
787
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
788
- ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Choose "Other answer" to type · the draft is kept while this is open'
789
- : '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 }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: answerPending ? theme.colors.muted : theme.accent, children: "\u276F " }), _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, 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 }) })] })] }) }) }) });
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
839
+ ? 'Choose "Other answer" to type · the draft is kept while this is open'
840
+ : 'Esc dismisses the question · the draft is kept while this is open'
841
+ : 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: cursor => controller.setComposerCursor(cursor), onSubmit: () => { void submit(controller.composer.draft); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
790
842
  }