@itookit/dsht 0.2.1 → 0.3.0

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/app.js CHANGED
@@ -1,47 +1,155 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Ink terminal interface: startup pickers, transcript, and slash-command composer. */
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ import { HistoryViewport } from "./history-view.js";
4
+ import { mocha, ThemeContext, useTheme } from "./theme.js";
5
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, memo, createContext, useContext } from 'react';
6
+ import { Box, Text, measureElement, useApp, useInput, useStdin, useStdout } from 'ink';
5
7
  import { useMouseWheel } from "./mouse.js";
6
8
  import { TextInput } from "./input.js";
7
- import { historyLayout, jumpTarget } from "./history.js";
9
+ import { InputHistory } from "./input-history.js";
10
+ import { historyLayout, releaseHistoryLayout } from "./history.js";
8
11
  import { toolLine } from "./transcript.js";
9
12
  import { activeReference, fileMention } from "./references.js";
10
13
  import { CostPanel } from "./cost-view.js";
11
14
  import { StatusBar } from "./status.js";
12
15
  import { navigationCommand, sessionLabel } from "./navigation.js";
13
16
  import { array, errorText, object, safeText, string } from "./wire.js";
14
- const COMMANDS = ['/ws', '/s', '/new', '/older', '/history', '/jump', '/search', '/ssearch', '/wsearch', '/cancel', '/steer', '/allow', '/deny', '/status', '/cost', '/help', '/quit'];
15
- const HELP = '/ws [name or ID] · /s [title or ID] · /s all · /new · /older · /history [text] · /jump <seq|first|last> · /search text · /ssearch text · /wsearch text · /cancel · /steer text · /allow · /deny · /status · /cost · /quit';
16
- /** A slash-command panel closes on the next command or after this long, whichever comes first. */
17
+ /** Command discovery catalog shared by Tab completion and the `/help` panel. */
18
+ export const COMMAND_HINTS = [
19
+ { command: '/ws', usage: '[name or ID]', description: 'List/switch workspaces; --delete name removes registration' },
20
+ { command: '/resume', usage: '[title or ID]', description: 'List/switch sessions; --delete ID archives with confirmation' },
21
+ { command: '/model', usage: '[provider model [effort]]', description: 'Choose a model and reasoning effort for subsequent requests' },
22
+ { command: '/new', description: 'Create a session in the selected workspace' },
23
+ { command: '/copy', description: 'Freeze for native selection; Esc resumes (Ctrl+S shortcut)' },
24
+ { command: '/latest', description: 'Return to the live conversation' },
25
+ { command: '/older', description: 'Load earlier history' },
26
+ { command: '/history', usage: '[text]', description: 'List your prompts, optionally filtered' },
27
+ { command: '/search', usage: 'text', description: 'Search history page by page and open a match' },
28
+ { command: '/ssearch', usage: 'text', description: 'Search sessions in the current workspace' },
29
+ { command: '/wsearch', usage: 'text', description: 'Search sessions across all workspaces' },
30
+ { command: '/compact', description: 'Compact older history while the session is idle' },
31
+ { command: '/cancel', description: 'Cancel the active turn' },
32
+ { command: '/queue', description: 'View and remove pending input' },
33
+ { command: '/plan', usage: '[off|message]', description: 'Enter or leave host plan mode' },
34
+ { command: '/goal', usage: '[action|objective]', description: 'View or manage the host task goal' },
35
+ { command: '/permission', usage: '[preset]', description: 'View or switch the host permission preset' },
36
+ { command: '/feedback', usage: 'text', description: 'Record feedback about the session' },
37
+ { command: '/export', usage: '[local.zip]', description: 'Save the session log ZIP to a new local file' },
38
+ { command: '/allow', description: 'Approve the pending request once' },
39
+ { command: '/deny', description: 'Reject the pending request' },
40
+ { command: '/status', description: 'Show full session status details' },
41
+ { command: '/cost', description: 'Show cost estimates and refresh usage' },
42
+ { command: '/think', usage: '[seq or live]', description: 'Inspect reasoning with user prompt summaries' },
43
+ { command: '/help', description: 'Show this command list' },
44
+ { command: '/quit', description: 'Exit dsht' },
45
+ ];
46
+ const COMMANDS = COMMAND_HINTS.map(hint => hint.command);
47
+ /** Command column text per hint, aligned in the `/help` panel. */
48
+ const COMMAND_LABELS = COMMAND_HINTS.map(hint => hint.usage === undefined ? hint.command : `${hint.command} ${hint.usage}`);
49
+ /** Widest command column, so descriptions start on one column. */
50
+ const COMMAND_LABEL_WIDTH = Math.max(...COMMAND_LABELS.map(label => label.length)) + 2;
51
+ /** Longest common prefix of the candidate commands, so Tab can extend an ambiguous draft. */
52
+ export function commonPrefix(values) {
53
+ let prefix = values[0] ?? '';
54
+ for (const value of values) {
55
+ let index = 0;
56
+ while (index < prefix.length && index < value.length && prefix[index] === value[index])
57
+ index++;
58
+ prefix = prefix.slice(0, index);
59
+ }
60
+ return prefix;
61
+ }
62
+ /** Transient notices expire; interactive panels remain open until dismissed. */
17
63
  const PANEL_LIFETIME_MS = 10_000;
18
- function Picker({ choices, enabled, canSelect }) {
64
+ const CopyMode = createContext(false);
65
+ /** Retain rendered children while frozen; local dialog interactions remain outside this wrapper. */
66
+ const Frozen = memo(function Frozen({ children }) {
67
+ return _jsx(_Fragment, { children: children });
68
+ }, (previous, next) => previous.frozen && next.frozen && previous.identity === next.identity);
69
+ function Picker({ choices, enabled, canSelect, pageSize = 12, hint }) {
70
+ const theme = useTheme();
71
+ const copyMode = useContext(CopyMode);
19
72
  const [selected, setSelected] = useState(0);
20
73
  const current = Math.min(selected, choices.length - 1);
74
+ const { internal_eventEmitter } = useStdin();
75
+ const rawKey = useRef('');
76
+ useEffect(() => {
77
+ const remember = (raw) => { rawKey.current = raw; };
78
+ internal_eventEmitter.prependListener('input', remember);
79
+ return () => { internal_eventEmitter.removeListener('input', remember); };
80
+ }, [internal_eventEmitter]);
21
81
  useInput((_input, key) => {
22
- if (!canSelect())
82
+ if (!canSelect() || key.eventType === 'release')
23
83
  return;
24
84
  if (key.upArrow)
25
85
  setSelected(Math.max(0, current - 1));
26
86
  else if (key.downArrow)
27
87
  setSelected(Math.min(choices.length - 1, current + 1));
88
+ else if (_input === 'd' && !key.ctrl && !key.meta || key.delete && /^\x1b\[3(?:;\d+)?~$/.test(rawKey.current))
89
+ choices[current]?.remove?.();
28
90
  else if (key.return)
29
91
  choices[current]?.action();
30
- }, { isActive: enabled });
31
- const start = Math.max(0, current - 7);
32
- return _jsxs(Box, { flexDirection: "column", children: [choices.slice(start, start + 12).map((choice, index) => _jsxs(Text, { color: start + index === current ? 'cyan' : undefined, children: [start + index === current ? '❯ ' : ' ', safeText(choice.label)] }, choice.key)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 select \u00B7 Enter open \u00B7 Ctrl+C stop / exit" })] });
92
+ }, { isActive: enabled && !copyMode });
93
+ const start = Math.max(0, current - Math.max(0, pageSize - 1));
94
+ return _jsxs(Box, { flexDirection: "column", children: [choices.slice(start, start + pageSize).map((choice, index) => _jsxs(Text, { color: start + index === current ? theme.accent : undefined, children: [start + index === current ? '❯ ' : ' ', safeText(choice.label)] }, choice.key)), _jsx(Text, { dimColor: true, children: hint ?? `↑ ↓ select · Enter open${choices.some(choice => choice.remove) ? ' · d/Delete remove / archive' : ''} · Ctrl+C stop / exit` })] });
33
95
  }
34
96
  /** The caller owns starting and stopping the controller around the Ink render lifetime. */
35
- export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
97
+ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = mocha }) {
36
98
  const state = useSyncExternalStore(controller.subscribe, controller.snapshot);
99
+ const presetId = controller.telemetry.view(state.sessionId).values.agentPreset;
100
+ useEffect(() => { if (typeof presetId === 'string')
101
+ controller.loadPresetNames(); }, [controller, state.online, presetId]);
37
102
  const { exit } = useApp();
38
103
  const { stdout } = useStdout();
104
+ const [copyMode, setCopyMode] = useState(false);
39
105
  const [input, updateInput] = useState('');
40
106
  const draft = useRef('');
107
+ const inputHistory = useRef(new InputHistory());
108
+ useLayoutEffect(() => {
109
+ const history = new InputHistory();
110
+ inputHistory.current = history;
111
+ if (!state.transcript.ready)
112
+ return;
113
+ const messages = state.transcript.messagesForWidth(Math.max(20, (stdout.columns ?? 100) - 2));
114
+ // Seed once per loaded session, never scan historical messages on stream ticks or arrow presses.
115
+ const prompts = messages.filter(message => message.role === 'You').slice(-200);
116
+ for (const message of prompts)
117
+ history.record(message.text.replace(/\r?\n/g, ' ').trim());
118
+ }, [state.sessionId, state.transcript, state.transcript.ready]);
41
119
  const [cursor, setCursor] = useState(0);
42
120
  // Input callbacks may run before Ink refreshes the controlled field's listener.
43
- const setInput = (value) => { draft.current = value; updateInput(value); setCursor(value.length); };
121
+ const setInput = (value, recalled = false) => {
122
+ if (!recalled)
123
+ inputHistory.current.reset();
124
+ draft.current = value;
125
+ updateInput(value);
126
+ setCursor(value.length);
127
+ };
128
+ const [historyWindow, setHistoryWindow] = useState();
129
+ const [historyLoading, setHistoryLoading] = useState();
130
+ const [historyMatches, setHistoryMatches] = useState();
131
+ const displayTranscript = historyWindow ?? state.transcript;
132
+ const displayRef = useRef(displayTranscript);
133
+ displayRef.current = displayTranscript;
134
+ useEffect(() => () => { if (historyWindow) {
135
+ releaseHistoryLayout(historyWindow);
136
+ historyWindow.dispose();
137
+ } }, [historyWindow]);
138
+ const conversationBox = useRef(null);
139
+ const [conversationRows, setConversationRows] = useState(20);
140
+ useLayoutEffect(() => {
141
+ if (conversationBox.current) {
142
+ const height = Math.floor(measureElement(conversationBox.current).height);
143
+ if (height !== conversationRows)
144
+ setConversationRows(height);
145
+ }
146
+ });
44
147
  const [scroll, setScroll] = useState(0);
148
+ const [liveReasoning, setLiveReasoning] = useState('row');
149
+ const [removal, setRemoval] = useState();
150
+ const [models, setModels] = useState();
151
+ const [thoughtList, setThoughtList] = useState(false);
152
+ const [queueOpen, setQueueOpen] = useState(false);
45
153
  const [historyQuery, setHistoryQuery] = useState();
46
154
  const [contentSearch, setContentSearch] = useState(false);
47
155
  const [searchResults, setSearchResults] = useState();
@@ -49,23 +157,39 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
49
157
  useEffect(() => () => historyAbort.current?.abort(), []);
50
158
  const [costExpanded, setCostExpanded] = useState(false);
51
159
  const [statusExpanded, setStatusExpanded] = useState(false);
160
+ const [reasoningOverrides, setReasoningOverrides] = useState(new Set());
161
+ const reasoning = 'row';
162
+ useEffect(() => { setReasoningOverrides(new Set()); setModels(undefined); setThoughtList(false); setHistoryWindow(undefined); setHistoryMatches(undefined); }, [state.transcript]);
163
+ useEffect(() => { setLiveReasoning('row'); }, [state.transcript, state.transcript.liveAttemptKey]);
164
+ const [notice, setNotice] = useState();
52
165
  const [help, setHelp] = useState(false);
166
+ const [helpPage, setHelpPage] = useState(0);
167
+ const helpPageSize = Math.max(1, (stdout.rows ?? 30) - 12);
168
+ const helpPages = Math.ceil(COMMAND_HINTS.length / helpPageSize);
169
+ const currentHelpPage = Math.min(helpPage, helpPages - 1);
53
170
  const [answers, setAnswers] = useState({});
171
+ const [optionState, setOptionState] = useState();
54
172
  const [referenceIndex, setReferenceIndex] = useState(0);
55
173
  const [dismissedReference, dismissReference] = useState();
56
174
  const [lookup, setLookup] = useState();
57
- // A slash-command panel is temporary: the next command or its lifetime closes it.
175
+ // Interactive panels remain stable while read; only transient notices expire.
58
176
  useEffect(() => {
59
- if (!help && !costExpanded && !statusExpanded)
177
+ if (copyMode)
60
178
  return;
61
- const timer = setTimeout(() => { setHelp(false); setCostExpanded(false); setStatusExpanded(false); }, panelLifetimeMs);
179
+ if (notice === undefined)
180
+ return;
181
+ const timer = setTimeout(() => setNotice(undefined), panelLifetimeMs);
62
182
  return () => clearTimeout(timer);
63
- }, [help, costExpanded, statusExpanded, panelLifetimeMs]);
183
+ }, [notice, panelLifetimeMs, copyMode]);
64
184
  const pending = state.pending[0];
185
+ const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
186
+ useEffect(() => { setQueueOpen(false); }, [state.sessionId, pending?.eventId]);
65
187
  const token = state.screen === 'chat' && state.online && !state.busy && !pending
66
- && (!input.startsWith('/') || input.startsWith('/steer ')) && dismissedReference !== input && cursor === input.length
188
+ && !input.startsWith('/') && dismissedReference !== input && cursor === input.length
67
189
  ? activeReference(input) : undefined;
68
190
  const referenceOpen = token !== undefined;
191
+ const dialogOpen = !!(queueOpen || removal || models || thoughtList || historyQuery !== undefined || searchResults || costExpanded || statusExpanded || help || pending || referenceOpen || state.screen !== 'chat');
192
+ const displayPaused = copyMode || dialogOpen;
69
193
  const matches = referenceOpen && lookup?.draft === input && lookup.sessionId === state.sessionId ? lookup : undefined;
70
194
  useEffect(() => {
71
195
  if (!referenceOpen)
@@ -89,16 +213,142 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
89
213
  const mention = fileMention(candidate, token.quoted);
90
214
  setInput(input.slice(0, -token.prefix.length) + mention + (candidate.kind === 'file' ? ' ' : ''));
91
215
  };
216
+ /** Complete the leading slash command; an ambiguous draft extends to the shared prefix. */
217
+ const completeCommand = () => {
218
+ if (!input.startsWith('/') || input.includes(' '))
219
+ return;
220
+ const matches = COMMANDS.filter(command => command.startsWith(input));
221
+ const only = matches.length === 1 ? matches[0] : undefined;
222
+ if (only !== undefined) {
223
+ setInput(`${only} `);
224
+ return;
225
+ }
226
+ const prefix = commonPrefix(matches);
227
+ if (prefix.length > input.length)
228
+ setInput(prefix);
229
+ };
92
230
  const questions = pending?.event === 'user-questions/request' ? array(object(pending.request).questions).map(object) : [];
93
231
  const eventId = pending ? string(pending.eventId) : '';
94
232
  const answered = answers[eventId] ?? [];
95
233
  const question = questions[answered.length];
234
+ const optionKey = `${eventId}:${answered.length}`;
235
+ const options = question ? array(question.options ?? []).map(object) : [];
236
+ const choiceState = optionState?.key === optionKey ? optionState : { key: optionKey, cursor: 0, selected: [], custom: false };
237
+ const optionCursor = Math.min(choiceState.cursor, options.length);
238
+ // Reserve the header, composer and question instructions; each choice may have a description.
239
+ const optionPageSize = Math.max(1, Math.min(6, Math.floor(((stdout.rows ?? 30) - 16) / 2)));
240
+ const optionStart = Math.max(0, optionCursor - optionPageSize + 1);
241
+ const questionKeysActive = !!question && options.length > 0 && !choiceState.custom && !copyMode
242
+ && !removal && !models && !thoughtList && historyQuery === undefined && !searchResults && !help && !costExpanded && !statusExpanded;
243
+ const answerQuestion = async (selected, custom) => {
244
+ if (controller.state.pending[0]?.eventId !== eventId)
245
+ throw new Error('The pending question has changed');
246
+ const answer = { id: string(question.id), selected, ...(custom ? { custom } : {}) };
247
+ const next = [...answered, answer];
248
+ if (next.length === questions.length) {
249
+ await controller.answer({ answers: next });
250
+ setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
251
+ }
252
+ else
253
+ setAnswers(previous => ({ ...previous, [eventId]: next }));
254
+ setOptionState(undefined);
255
+ };
96
256
  const operate = (fn) => { void controller.perform(fn); };
257
+ const requestRemoval = async (kind, query) => {
258
+ const target = await controller.removalTarget(kind, query);
259
+ if (target.kind === 'session' && target.empty)
260
+ await controller.removeTarget(target);
261
+ else
262
+ setRemoval(target);
263
+ };
97
264
  useInput((_value, key) => {
265
+ if (key.eventType === 'release')
266
+ return;
267
+ if (copyMode) {
268
+ if (key.escape || key.ctrl && (_value === 's' || _value === 'c'))
269
+ setCopyMode(false);
270
+ return;
271
+ }
272
+ if (key.ctrl && _value === 's') {
273
+ setCopyMode(true);
274
+ return;
275
+ }
98
276
  if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
99
277
  historyAbort.current.abort();
100
278
  return;
101
279
  }
280
+ if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
281
+ if (key.ctrl && draft.current)
282
+ setInput('');
283
+ if (key.escape) {
284
+ setOptionState({ ...choiceState, custom: false });
285
+ setRemoval(undefined);
286
+ setModels(undefined);
287
+ setThoughtList(false);
288
+ setSearchResults(undefined);
289
+ setHistoryQuery(undefined);
290
+ setHistoryMatches(undefined);
291
+ setHelp(false);
292
+ setCostExpanded(false);
293
+ setStatusExpanded(false);
294
+ }
295
+ return;
296
+ }
297
+ if (questionKeysActive && !draft.current && !controller.state.busy && !key.ctrl && !key.meta) {
298
+ const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
299
+ if (key.upArrow || key.downArrow) {
300
+ setOptionState({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
301
+ return;
302
+ }
303
+ if (digit >= 0 && digit <= options.length || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
304
+ const index = digit >= 0 ? digit : optionCursor;
305
+ const label = index < options.length ? string(options[index].label) : undefined;
306
+ const selected = question.multiSelect === true && label
307
+ ? choiceState.selected.includes(label) ? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label]
308
+ : choiceState.selected;
309
+ setOptionState({ ...choiceState, cursor: index, selected });
310
+ return;
311
+ }
312
+ if (key.return) {
313
+ if (optionCursor === options.length) {
314
+ setOptionState({ ...choiceState, custom: true });
315
+ return;
316
+ }
317
+ const selected = question.multiSelect === true ? choiceState.selected : [string(options[optionCursor].label)];
318
+ if (!selected.length) {
319
+ setNotice('Select at least one option with Space or a number');
320
+ return;
321
+ }
322
+ operate(() => answerQuestion(selected));
323
+ return;
324
+ }
325
+ }
326
+ if (help && (key.pageUp || key.pageDown)) {
327
+ setHelpPage(Math.max(0, Math.min(helpPages - 1, currentHelpPage + (key.pageUp ? -1 : 1))));
328
+ return;
329
+ }
330
+ if (key.pageUp || key.pageDown) {
331
+ scrollHistory(key.pageUp ? 10 : -10);
332
+ return;
333
+ }
334
+ if (key.escape && queueOpen) {
335
+ setQueueOpen(false);
336
+ return;
337
+ }
338
+ if (key.escape && removal) {
339
+ setRemoval(undefined);
340
+ return;
341
+ }
342
+ if (key.escape && models) {
343
+ setModels(undefined);
344
+ return;
345
+ }
346
+ if (key.escape && thoughtList) {
347
+ setThoughtList(false);
348
+ if (controller.running)
349
+ void controller.interrupt(true);
350
+ return;
351
+ }
102
352
  if (key.escape && searchResults) {
103
353
  setSearchResults(undefined);
104
354
  if (controller.running)
@@ -107,11 +357,17 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
107
357
  }
108
358
  if (key.escape && historyQuery !== undefined) {
109
359
  setHistoryQuery(undefined);
360
+ setHistoryMatches(undefined);
110
361
  if (controller.running)
111
362
  void controller.interrupt(true);
112
363
  return;
113
364
  }
114
365
  if (key.ctrl && _value === 'c') {
366
+ // A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
367
+ if (input !== '') {
368
+ setInput('');
369
+ return;
370
+ }
115
371
  void controller.interrupt().then(shouldExit => { if (shouldExit)
116
372
  exit(); });
117
373
  return;
@@ -130,29 +386,62 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
130
386
  setReferenceIndex(value => Math.max(0, Math.min((matches?.items.length ?? 1) - 1, value + 1)));
131
387
  return;
132
388
  }
389
+ const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
390
+ const recallNext = key.downArrow || key.ctrl && _value === 'n';
391
+ if ((recallPrevious || recallNext) && state.online && !controller.state.busy && !pending
392
+ && !queueOpen && !removal && !models && !thoughtList && historyQuery === undefined && !searchResults && !help && !costExpanded && !statusExpanded
393
+ && (state.screen === 'chat' || draft.current !== '' || key.ctrl)) {
394
+ setInput(inputHistory.current.move(recallPrevious ? -1 : 1, draft.current), true);
395
+ return;
396
+ }
397
+ if (key.tab) {
398
+ completeCommand();
399
+ return;
400
+ }
401
+ if (key.escape && (help || costExpanded || statusExpanded || notice !== undefined)) {
402
+ setHelp(false);
403
+ setCostExpanded(false);
404
+ setStatusExpanded(false);
405
+ setNotice(undefined);
406
+ if (controller.running)
407
+ void controller.interrupt(true);
408
+ return;
409
+ }
133
410
  if (key.escape && state.screen === 'chat') {
134
411
  void controller.interrupt(true);
135
412
  }
136
- if (key.pageUp)
137
- scrollHistory(10);
138
- if (key.pageDown)
139
- scrollHistory(-10);
140
413
  });
141
414
  const submit = async (raw) => {
142
415
  if (referenceOpen) {
143
416
  pickReference();
144
417
  return;
145
418
  }
419
+ if (copyMode)
420
+ return;
146
421
  const value = raw.trim();
147
422
  if (!value)
148
423
  return;
424
+ if (!pending && !/^\/feedback(?:\s|$)/.test(value))
425
+ inputHistory.current.record(value);
426
+ if (value === '/copy') {
427
+ setInput('');
428
+ setCopyMode(true);
429
+ return;
430
+ }
431
+ setRemoval(undefined);
432
+ if (value !== '/queue')
433
+ setQueueOpen(false);
149
434
  // Each panel belongs to the command that opened it, so any other command closes it.
435
+ if (!/^\/model(?: |$)/.test(value))
436
+ setModels(undefined);
150
437
  if (value !== '/help')
151
438
  setHelp(false);
152
439
  if (value !== '/cost')
153
440
  setCostExpanded(false);
154
441
  if (value !== '/status')
155
442
  setStatusExpanded(false);
443
+ if (!/^\/think(?: |$)/.test(value))
444
+ setThoughtList(false);
156
445
  if (value === '/quit') {
157
446
  exit();
158
447
  return;
@@ -171,12 +460,19 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
171
460
  }
172
461
  if (value === '/help') {
173
462
  setHelp(value => !value);
463
+ setHelpPage(0);
174
464
  setInput('');
175
465
  return;
176
466
  }
177
467
  const accepted = await controller.perform(async () => {
178
468
  const navigation = navigationCommand(value);
179
- if (navigation) {
469
+ if (navigation && /^--(?:delete|archive)(?:\s|$)/.test(navigation.query ?? '')) {
470
+ const query = navigation.query.replace(/^--(?:delete|archive)\s*/, '');
471
+ if (!query)
472
+ throw new Error('Specify the name or ID to remove');
473
+ await requestRemoval(navigation.kind, query);
474
+ }
475
+ else if (navigation) {
180
476
  setHistoryQuery(undefined);
181
477
  setSearchResults(undefined);
182
478
  if (navigation.kind === 'workspace')
@@ -187,6 +483,38 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
187
483
  }
188
484
  else if (state.screen === 'path')
189
485
  await controller.createWorkspace(value);
486
+ else if (value === '/latest') {
487
+ setHistoryWindow(undefined);
488
+ setHistoryMatches(undefined);
489
+ setHistoryQuery(undefined);
490
+ setSearchResults(undefined);
491
+ setReasoningOverrides(new Set());
492
+ setScroll(0);
493
+ controller.pinHistory(false);
494
+ }
495
+ else if (/^\/model(?: |$)/.test(value)) {
496
+ if (state.screen !== 'chat')
497
+ throw new Error('Select a session first');
498
+ const args = value.split(/\s+/).slice(1);
499
+ if (!args.length) {
500
+ setHistoryQuery(undefined);
501
+ setSearchResults(undefined);
502
+ setModels({ catalog: await controller.modelCatalog() });
503
+ }
504
+ else {
505
+ if (args.length < 2 || args.length > 3)
506
+ throw new Error('Use /model [provider model [effort]]');
507
+ await controller.selectModel(args[0], args[1], args[2]);
508
+ setModels(undefined);
509
+ }
510
+ }
511
+ else if (value === '/queue') {
512
+ if (state.screen !== 'chat')
513
+ throw new Error('Select a session first');
514
+ if (pending)
515
+ throw new Error('Answer the pending question or approval first');
516
+ setQueueOpen(true);
517
+ }
190
518
  else if (value === '/new')
191
519
  await controller.createSession();
192
520
  else if (value === '/history' || value.startsWith('/history ')) {
@@ -203,7 +531,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
203
531
  throw new Error(`Use ${command} <text>`);
204
532
  await historyOperation(async (signal) => {
205
533
  if (command === '/search') {
206
- await controller.historyThrough('first', signal);
534
+ setHistoryMatches(await controller.searchHistory(query, signal));
207
535
  setSearchResults(undefined);
208
536
  setContentSearch(true);
209
537
  setHistoryQuery(query);
@@ -215,35 +543,77 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
215
543
  }
216
544
  });
217
545
  }
218
- else if (value === '/jump' || value.startsWith('/jump '))
219
- await jumpHistory(jumpTarget(value.slice(5).trim()));
546
+ else if (/^\/think(?: |$)/.test(value)) {
547
+ if (state.screen !== 'chat')
548
+ throw new Error('Select a session first');
549
+ const target = value.slice(6).trim();
550
+ if (target === 'live') {
551
+ setLiveReasoning(value => value === 'row' ? 'full' : 'row');
552
+ setThoughtList(false);
553
+ setScroll(0);
554
+ }
555
+ else if (target) {
556
+ const seq = Number(target);
557
+ if (!Number.isSafeInteger(seq) || !displayTranscript.thoughts.some(entry => entry.seq === seq))
558
+ throw new Error('Use /think <message sequence> for a loaded reasoning block');
559
+ const next = new Set(reasoningOverrides);
560
+ if (next.delete(seq)) {
561
+ setReasoningOverrides(next);
562
+ return;
563
+ }
564
+ next.add(seq);
565
+ setReasoningOverrides(next);
566
+ await jumpHistory(seq, next);
567
+ }
568
+ else {
569
+ setHistoryQuery(undefined);
570
+ setSearchResults(undefined);
571
+ setThoughtList(true);
572
+ }
573
+ }
220
574
  else if (value === '/older') {
221
- await controller.older();
575
+ await controller.older(undefined, displayTranscript);
222
576
  setScroll(value => value + 10);
223
577
  }
578
+ else if (/^\/compact(?: |$)/.test(value)) {
579
+ if (state.screen !== 'chat')
580
+ throw new Error('Select a session first');
581
+ if (value !== '/compact')
582
+ throw new Error('Use /compact (no arguments)');
583
+ setNotice(undefined);
584
+ await historyOperation(async (signal) => { setNotice(await controller.command('/compact', signal)); }, 'Compacting history…');
585
+ }
224
586
  else if (value === '/cancel')
225
587
  await controller.cancelTurn();
226
588
  else if (value === '/allow')
227
589
  await controller.approve(true);
228
590
  else if (value === '/deny')
229
591
  await controller.approve(false);
230
- else if (value.startsWith('/steer '))
231
- await controller.prompt(value.slice(7), 'steer');
232
- else if (question) {
233
- const answer = { id: string(question.id), selected: [], custom: value };
234
- const next = [...answered, answer];
235
- if (next.length === questions.length)
236
- await controller.answer({ answers: next });
237
- setAnswers(previous => ({ ...previous, [eventId]: next }));
592
+ else if (/^\/(?:plan|goal|permission|feedback)(?:\s|$)/.test(value)) {
593
+ if (state.screen !== 'chat')
594
+ throw new Error('Select a session first');
595
+ if (pending)
596
+ throw new Error('Answer the pending question or approval first');
597
+ setNotice(undefined);
598
+ await historyOperation(async (signal) => { setNotice(await controller.command(value, signal)); }, 'Running command…');
599
+ }
600
+ else if (/^\/export(?:\s|$)/.test(value)) {
601
+ if (state.screen !== 'chat')
602
+ throw new Error('Select a session first');
603
+ const destination = value.slice(7).trim().replace(/^(["'])(.*)\1$/, '$2');
604
+ await historyOperation(async (signal) => { setNotice(`Saved session log: ${await controller.exportLog(destination || undefined, signal)}`); }, 'Exporting session log…');
238
605
  }
606
+ else if (question)
607
+ await answerQuestion(question.multiSelect === true ? choiceState.selected : [], value);
239
608
  else if (pending)
240
609
  throw new Error('Answer the approval with /allow or /deny');
241
610
  else if (value.startsWith('/'))
242
611
  throw new Error('Unknown command. Use /help.');
243
612
  else if (state.screen !== 'chat')
244
- throw new Error('Choose a session or type /ws or /s');
613
+ throw new Error('Choose a session or type /ws or /resume');
245
614
  else {
246
615
  await controller.prompt(value);
616
+ setHistoryWindow(undefined);
247
617
  setScroll(0);
248
618
  }
249
619
  });
@@ -253,6 +623,7 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
253
623
  const choices = state.screen === 'workspaces' ? [
254
624
  ...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
255
625
  label: `${string(workspace.title)} ${string(workspace.path)}`,
626
+ remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
256
627
  action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
257
628
  { key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
258
629
  { key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
@@ -260,25 +631,39 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
260
631
  ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
261
632
  ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
262
633
  label: `${session.running ? '● ' : ''}${sessionLabel(session)} ${session.sessionId}`,
634
+ remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
263
635
  action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
264
636
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
265
637
  ];
266
638
  const width = Math.max(10, (stdout.columns ?? 80) - 2);
267
- const layout = useMemo(() => historyLayout(state.transcript, width), [state.transcript, state.transcript.version, width]);
268
- const { lines, first } = layout;
269
- const pageSize = Math.max(5, (stdout.rows ?? 30) - (pending ? 17 : 12) - (statusExpanded ? 9 : 0));
270
- const previousView = useRef({ transcript: state.transcript, count: lines.length, first });
639
+ const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
640
+ const { length, first } = layout;
641
+ const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
642
+ const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
643
+ const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
644
+ const previousView = useRef({ transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning });
271
645
  const previous = previousView.current;
272
646
  const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
273
- const adjustedScroll = previous.transcript !== state.transcript ? 0
274
- : scroll > 0 && !prepended ? Math.max(0, scroll + lines.length - previous.count) : scroll;
275
- const maxScroll = Math.max(0, lines.length - pageSize);
647
+ const adjustedScroll = previous.session !== state.transcript ? 0
648
+ : scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + length - previous.count) : scroll;
649
+ const maxScroll = Math.max(0, length - pageSize);
276
650
  const position = Math.min(adjustedScroll, maxScroll);
277
651
  useLayoutEffect(() => {
278
- previousView.current = { transcript: state.transcript, count: lines.length, first };
652
+ previousView.current = { transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning };
279
653
  if (position !== scroll)
280
654
  setScroll(position);
281
- }, [state.transcript, lines, position, scroll]);
655
+ }, [state.transcript, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
656
+ useLayoutEffect(() => {
657
+ controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
658
+ }, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.transcript]);
659
+ useEffect(() => {
660
+ const first = displayTranscript.beforeSeq;
661
+ if (first !== undefined)
662
+ setReasoningOverrides(previous => {
663
+ const next = new Set([...previous].filter(seq => seq >= first));
664
+ return next.size === previous.size ? previous : next;
665
+ });
666
+ }, [displayTranscript, displayTranscript.memoryRevision]);
282
667
  const scrollPosition = useRef(position);
283
668
  scrollPosition.current = position;
284
669
  const loadingPage = useRef(false);
@@ -286,84 +671,135 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS }) {
286
671
  const mounted = useRef(true);
287
672
  useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
288
673
  function scrollHistory(delta) {
289
- if (state.screen !== 'chat' || historyQuery !== undefined || searchResults !== undefined)
674
+ if (copyMode || state.screen !== 'chat')
290
675
  return;
291
676
  const intent = ++scrollIntent.current;
292
677
  const next = Math.max(0, Math.min(maxScroll, scrollPosition.current + delta));
293
678
  scrollPosition.current = next;
294
679
  setScroll(next);
295
- if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !state.transcript.ready || !state.transcript.hasMore)
680
+ if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
296
681
  return;
297
682
  loadingPage.current = true;
298
- const transcript = state.transcript;
299
- void controller.perform(() => historyOperation(signal => controller.older(signal))).then(accepted => {
300
- if (accepted && mounted.current && controller.state.transcript === transcript && scrollIntent.current === intent) {
683
+ const transcript = displayTranscript;
684
+ void controller.perform(() => historyOperation(signal => controller.older(signal, transcript))).then(accepted => {
685
+ if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
301
686
  setScroll(value => value + delta);
302
687
  }
303
688
  }).finally(() => { loadingPage.current = false; });
304
689
  }
305
- async function historyOperation(operation) {
690
+ async function historyOperation(operation, label = 'Loading history…') {
306
691
  const abort = new AbortController();
307
692
  historyAbort.current = abort;
693
+ setHistoryLoading(label);
308
694
  try {
309
695
  await operation(abort.signal);
310
696
  }
311
697
  finally {
312
- if (historyAbort.current === abort)
698
+ if (historyAbort.current === abort) {
313
699
  historyAbort.current = undefined;
700
+ setHistoryLoading(undefined);
701
+ }
314
702
  }
315
703
  }
316
704
  async function openSearchSession(sessionId, query) {
317
705
  await historyOperation(async (signal) => {
318
706
  await controller.selectSession(sessionId);
319
707
  await controller.waitForHistory(signal);
320
- await controller.historyThrough('first', signal);
708
+ setHistoryMatches(await controller.searchHistory(query, signal));
321
709
  setSearchResults(undefined);
322
710
  setContentSearch(true);
323
711
  setHistoryQuery(query);
324
712
  });
325
713
  }
326
- async function jumpHistory(target) {
714
+ async function jumpHistory(target, folds = reasoningOverrides) {
327
715
  if (state.screen !== 'chat')
328
716
  throw new Error('Select a session first');
329
717
  ++scrollIntent.current;
330
- if (target === 'last') {
331
- setHistoryQuery(undefined);
332
- setScroll(0);
333
- return;
334
- }
335
718
  const abort = new AbortController();
336
719
  historyAbort.current = abort;
337
720
  try {
338
- await controller.historyThrough(target, abort.signal);
721
+ const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
722
+ : state.transcript.messages.some(message => message.seq === target) ? state.transcript
723
+ : await controller.historyAt(target, abort.signal);
339
724
  abort.signal.throwIfAborted();
340
- const current = historyLayout(controller.state.transcript, width);
341
- const seq = target === 'first' ? current.messages[0]?.seq : target;
342
- const row = seq === undefined ? undefined : current.offsets.get(seq);
725
+ const current = historyLayout(transcript, width, reasoning, folds, liveReasoning);
726
+ const row = current.offsets.get(target);
343
727
  if (row === undefined)
344
728
  throw new Error('No visible message at this sequence; use /history to choose a record');
729
+ setHistoryWindow(transcript === state.transcript ? undefined : transcript);
345
730
  setHistoryQuery(undefined);
346
- setScroll(Math.max(0, current.lines.length - pageSize - row));
731
+ setHistoryMatches(undefined);
732
+ setThoughtList(false);
733
+ setScroll(Math.max(0, current.length - pageSize - row));
347
734
  }
348
735
  finally {
349
736
  if (historyAbort.current === abort)
350
737
  historyAbort.current = undefined;
351
738
  }
352
739
  }
353
- useMouseWheel(direction => scrollHistory(direction * 3));
354
- const end = Math.max(pageSize, lines.length - position);
355
- const visible = lines.slice(Math.max(0, end - pageSize), end);
740
+ useMouseWheel(direction => scrollHistory(direction * 3), !copyMode && state.screen === 'chat', () => { if (!dialogOpen)
741
+ setCopyMode(true); });
742
+ const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
743
+ const end = Math.max(pageSize, length - position - trailingGap);
744
+ const visible = useMemo(() => layout.viewport(Math.max(0, end - pageSize), end), [layout, end, pageSize]);
745
+ const liveThought = thoughtList && !historyWindow ? state.transcript.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
746
+ const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
747
+ const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
748
+ key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
749
+ action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq, next); }),
750
+ })), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
751
+ const thoughtOptions = useMemo(() => [
752
+ ...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.transcript.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
753
+ action: () => { setLiveReasoning('full'); setThoughtList(false); const expanded = historyLayout(state.transcript, width, reasoning, reasoningOverrides, 'full'); setScroll(Math.max(0, expanded.length - pageSize - expanded.liveOffset)); } }] : []),
754
+ ...thoughtChoices,
755
+ ...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.older(signal, displayTranscript))) }] : []),
756
+ { key: 'close', label: '← Back to conversation', action: () => setThoughtList(false) },
757
+ ], [liveThought, thoughtChoices, displayTranscript, displayTranscript.hasMore, width, pageSize, reasoningOverrides]);
356
758
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
357
- return _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "DeepSeek Harness \u00B7 HTTP TUI" }), _jsxs(Text, { dimColor: true, children: [controller.base, " \u00B7 ", state.status, state.busy ? ' · Working…' : ''] }), _jsx(Text, { wrap: "truncate-end", children: toolLine(`${workspace ? string(workspace.title) : 'All workspaces'}${state.sessionId ? ` / ${controller.sessionName}` : ''}`, Math.max(1, width - 4)) })] }), state.error && _jsx(Text, { color: "red", children: state.error }), searchResults ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Session search \u00B7 ", safeText(searchResults.query)] }), searchResults.hasMore && _jsx(Text, { color: "yellow", children: "Host returned only the first 20 global matches; workspace results may be incomplete. Refine your query." }), !searchResults.items.length && _jsx(Text, { children: "No sessions in the returned results" }), _jsx(Picker, { choices: [...searchResults.items.map(item => ({ key: string(item.sessionId),
358
- label: toolLine(`${item.sessionId} · ${item.snippet}`, width - 2),
359
- action: () => operate(() => openSearchSession(string(item.sessionId), searchResults.query)),
360
- })), { key: 'close', label: '← Back', action: () => setSearchResults(undefined) }], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy })] }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session' }), _jsx(Picker, { choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }, `${state.screen}:${state.workspaceId ?? ''}`)] }) : _jsxs(_Fragment, { children: [state.screen === 'chat' && historyQuery === undefined && !searchResults && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: visible.length ? visible.join('\n') : 'Start a conversation with the host agent.' }), state.transcript.hasMore && _jsx(Text, { dimColor: true, children: "Scroll up or /older to load earlier history" })] }), state.screen === 'chat' && historyQuery !== undefined && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: [contentSearch ? 'Search · session history' : 'History · loaded records', " \u00B7 Esc close"] }), _jsx(Picker, { choices: [
361
- ...layout.messages.filter(message => (contentSearch ? message.text : `${message.seq} ${message.role} ${message.text}`).toLowerCase().includes(historyQuery.toLowerCase())).map(message => ({
362
- key: String(message.seq), label: toolLine(`#${message.seq} ${message.role} · ${message.text}`, width - 2),
363
- action: () => operate(() => jumpHistory(message.seq)),
364
- })),
365
- { key: 'close', label: '← Back to conversation', action: () => setHistoryQuery(undefined) },
366
- ], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }, `history:${historyQuery}`)] }), pending && _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: question ? 'Question' : '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)) }), question?.options && _jsx(Text, { children: array(question.options).map(option => string(object(option).label)).join(' · ') }), _jsx(Text, { dimColor: true, children: question ? 'Type your answer below' : '/allow approves once · /deny rejects' })] })] }), _jsxs(Box, { borderStyle: "round", borderColor: state.online ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "cyan", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, focus: state.online && !state.busy, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Host files \u00B7 \u2191 \u2193 select \u00B7 Tab/Enter insert \u00B7 Esc close" }), !matches ? _jsx(Text, { dimColor: true, children: "Searching\u2026" }) : matches.error ? _jsx(Text, { color: "red", children: matches.error })
367
- : matches.items.length === 0 ? _jsx(Text, { dimColor: true, children: "No matching host files" })
368
- : matches.items.slice(Math.max(0, referenceIndex - 5), Math.max(0, referenceIndex - 5) + 6).map((item, index) => _jsxs(Text, { color: index + Math.max(0, referenceIndex - 5) === referenceIndex ? 'cyan' : undefined, children: [index + Math.max(0, referenceIndex - 5) === referenceIndex ? '❯ ' : ' ', item.path, item.kind === 'directory' ? '/' : ''] }, `${item.kind}:${item.path}`))] }), _jsx(Text, { dimColor: true, children: "Enter send \u00B7 Esc cancel \u00B7 Wheel/PgUp/PgDn scroll \u00B7 Ctrl+C stop / exit" }), input.startsWith('/') && !input.includes(' ') && _jsx(Text, { dimColor: true, children: COMMANDS.filter(command => command.startsWith(input)).join(' ') }), help && _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: HELP }), _jsx(Text, { dimColor: true, children: "Editing: Ctrl+A/E start/end \u00B7 Ctrl+K/U kill right/left \u00B7 Ctrl+W kill word \u00B7 Ctrl+Y restore" })] }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(StatusBar, { controller: controller, expanded: statusExpanded, revision: state.version })] });
759
+ const workspaceName = workspace ? string(workspace.title) || string(workspace.path) : state.workspaceId;
760
+ const headerTitle = controller.sessionName
761
+ ? [controller.sessionName, width >= 60 ? workspaceName : undefined].filter(Boolean).join(' · ')
762
+ : workspaceName || 'All workspaces';
763
+ 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(Frozen, { frozen: displayPaused, identity: `${width}:${state.sessionId}`, children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { width: width, children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, minWidth: 0, children: _jsx(Text, { bold: true, color: theme.accent, wrap: "truncate-end", children: toolLine(headerTitle, width) }) }), width >= 60 && controller.sessionMode && _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.accent, children: toolLine(controller.sessionMode, Math.min(24, Math.floor(width / 3))) }) })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(width) })] }) }), _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: displayPaused, 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' && _jsxs(Box, { ref: conversationBox, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", marginY: dialogOpen ? 0 : 1, children: [visible.length ? _jsx(Frozen, { frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, children: _jsx(HistoryViewport, { rows: visible }) }) : _jsx(Text, { color: theme.colors.muted, children: "Start a conversation with the host agent." }), showHistoryHint && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: dialogOpen ? 'Wheel/PgUp/PgDn · Scroll history' : historyWindow ? 'Earlier history · /latest returns to live conversation' : 'Scroll up or /older to load earlier history' })] })] }), _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 ? _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pending input \u00B7 Esc close" }), !queued.length && _jsx(Text, { dimColor: true, children: state.controlError ? 'Host queue unavailable' : 'No pending input' }), _jsx(Picker, { choices: queued.map(item => ({
764
+ key: item.id, label: toolLine(item.text, width - 6),
765
+ action: () => operate(() => controller.removeQueued(item.id)),
766
+ remove: () => operate(() => controller.removeQueued(item.id)),
767
+ })), pageSize: Math.max(1, Math.min(6, (stdout.rows ?? 30) - 12)), hint: "\u2191 \u2193 select \u00B7 Enter / d / Delete remove \u00B7 Esc close", enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length })] }) : removal ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.context, children: removal.kind === 'workspace' ? 'Remove workspace registration?' : 'Archive session?' }), _jsx(Text, { wrap: "truncate-end", children: safeText(removal.name) }), _jsxs(Text, { wrap: "truncate-end", children: ["ID: ", safeText(removal.id)] }), removal.path && _jsxs(Text, { wrap: "truncate-end", children: ["Path: ", safeText(removal.path)] }), _jsx(Text, { children: removal.kind === 'workspace' ? 'Removes the workspace from the list. Directory and sessions are kept.' : 'Hides the session from lists. History is kept; /resume ID can reopen it.' }), _jsx(Text, { dimColor: true, children: "Running tasks continue. Esc cancels this dialog." }), _jsx(Picker, { choices: [
768
+ { key: 'cancel', label: 'Cancel', action: () => setRemoval(undefined) },
769
+ { key: 'confirm', label: removal.kind === 'workspace' ? 'Remove workspace' : 'Archive session',
770
+ action: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) },
771
+ ], enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online }, `${removal.kind}:${removal.id}`)] }) : models ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: models.model ? 'Choose reasoning effort' : 'Choose model' }), _jsx(Text, { dimColor: true, children: "Applies to subsequent requests; host also saves the default." }), array(models.catalog.failures).map(object).map(failure => _jsx(Text, { color: theme.colors.error, children: safeText(`${failure.name}: ${failure.message}`) }, string(failure.id))), _jsx(Picker, { pageSize: Math.max(1, Math.min(8, (stdout.rows ?? 30) - 15)), choices: models.model ? [
772
+ { key: 'default', label: `Default effort${object(models.model.reasoning).defaultEffort ? ` · ${string(object(models.model.reasoning).defaultEffort)}` : ''}`,
773
+ action: () => operate(async () => { await controller.selectModel(models.provider, string(models.model.id)); setModels(undefined); }) },
774
+ ...array(object(models.model.reasoning).efforts).map(object).map(effort => ({ key: string(effort.id), label: toolLine(`${effort.name} (${effort.id})${effort.description ? ` · ${effort.description}` : ''}`, width - 2),
775
+ action: () => operate(async () => { await controller.selectModel(models.provider, string(models.model.id), string(effort.id)); setModels(undefined); }) })),
776
+ { key: 'back', label: '← Models', action: () => setModels({ catalog: models.catalog }) },
777
+ ] : [
778
+ ...array(models.catalog.groups).map(object).flatMap(group => array(group.models).map(object).map(model => ({
779
+ key: `${group.id}:${model.id}`, label: toolLine(`${group.name} · ${model.name} (${model.id})`, width - 2),
780
+ action: () => {
781
+ if (array(object(model.reasoning ?? { efforts: [] }).efforts).length)
782
+ setModels({ catalog: models.catalog, provider: string(group.id), model });
783
+ else
784
+ operate(async () => { await controller.selectModel(string(group.id), string(model.id)); setModels(undefined); });
785
+ },
786
+ }))),
787
+ { key: 'close', label: '← Back to conversation', action: () => setModels(undefined) },
788
+ ], enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online }, models.model ? `${models.provider}:${models.model.id}` : 'models')] }) : searchResults ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Session search \u00B7 ", safeText(searchResults.query)] }), searchResults.hasMore && _jsx(Text, { color: theme.colors.context, children: "Host returned only the first 20 global matches; workspace results may be incomplete. Refine your query." }), !searchResults.items.length && _jsx(Text, { children: "No sessions in the returned results" }), _jsx(Picker, { choices: [...searchResults.items.map(item => ({ key: string(item.sessionId),
789
+ label: toolLine(`${item.sessionId} · ${item.snippet}`, width - 2),
790
+ action: () => operate(() => openSearchSession(string(item.sessionId), searchResults.query)),
791
+ })), { key: 'close', label: '← Back', action: () => setSearchResults(undefined) }], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy })] }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session' }), _jsx(Picker, { choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }, `${state.screen}:${state.workspaceId ?? ''}`)] }) : _jsxs(_Fragment, { children: [thoughtList && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.reasoning, children: "Reasoning history \u00B7 User prompts \u00B7 Esc close" }), !thoughtEntries?.length && !liveThought && _jsx(Text, { dimColor: true, children: "No reasoning in loaded history" }), _jsx(Picker, { choices: thoughtOptions, pageSize: Math.max(1, Math.min(6, Math.floor(((stdout.rows ?? 30) - 14) / 2))), enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }, `thoughts:${state.sessionId}`)] }), state.screen === 'chat' && historyQuery !== undefined && _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: [contentSearch ? 'Search · session history' : 'History · loaded records', " \u00B7 Esc close"] }), contentSearch && historyMatches?.truncated && _jsx(Text, { color: theme.colors.context, children: "Showing the first 200 matches \u00B7 Refine your search" }), _jsx(Picker, { choices: [
792
+ ...(contentSearch && historyMatches ? historyMatches.items.map(message => ({
793
+ key: String(message.seq), label: toolLine(`#${message.seq} ${message.role} · ${message.preview}`, width - 2),
794
+ action: () => operate(() => jumpHistory(message.seq)),
795
+ })) : layout.messages.filter(message => message.role === 'You' && `${message.seq} ${message.text}`.toLowerCase().includes(historyQuery.toLowerCase())).map(message => ({
796
+ key: String(message.seq), label: toolLine(`#${message.seq} ${message.role} · ${message.text}`, width - 2),
797
+ action: () => operate(() => jumpHistory(message.seq)),
798
+ }))),
799
+ { key: 'close', label: '← Back to conversation', action: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } },
800
+ ], enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }, `history:${historyQuery}`)] }), 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)) }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
801
+ .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
802
+ ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Text answers supported · Ctrl+C clears · /cancel stops' : '/allow approves once · /deny rejects · /cancel stops' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { dimColor: true, children: ["Waiting: ", queued.length, " \u00B7 /queue to remove"] }), queued.slice(0, 2).map(item => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [item.placement === 'steering' ? '↳ ' : '· ', safeText(toolLine(item.text, width - 6))] }, item.id))] }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: theme.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: 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, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Host files \u00B7 \u2191 \u2193 select \u00B7 Tab/Enter insert \u00B7 Esc close" }), !matches ? _jsx(Text, { dimColor: true, children: "Searching\u2026" }) : matches.error ? _jsx(Text, { color: theme.colors.error, children: matches.error })
803
+ : matches.items.length === 0 ? _jsx(Text, { dimColor: true, children: "No matching host files" })
804
+ : matches.items.slice(Math.max(0, referenceIndex - 5), Math.max(0, referenceIndex - 5) + 6).map((item, index) => _jsxs(Text, { color: index + Math.max(0, referenceIndex - 5) === referenceIndex ? theme.accent : undefined, children: [index + Math.max(0, referenceIndex - 5) === referenceIndex ? '❯ ' : ' ', item.path, item.kind === 'directory' ? '/' : ''] }, `${item.kind}:${item.path}`))] })] }), input.startsWith('/') && !input.includes(' ') && _jsx(Text, { dimColor: true, children: COMMANDS.filter(command => command.startsWith(input)).join(' ') }), help && _jsxs(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [COMMAND_HINTS.slice(currentHelpPage * helpPageSize, (currentHelpPage + 1) * helpPageSize).map((hint, index) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: theme.accent, children: COMMAND_LABELS[currentHelpPage * helpPageSize + index].padEnd(COMMAND_LABEL_WIDTH) }), hint.description] }, hint.command)), _jsxs(Text, { dimColor: true, children: ["Help ", currentHelpPage + 1, "/", helpPages, " \u00B7 PgUp/PgDn pages \u00B7 Esc close"] }), _jsx(Text, { dimColor: true, children: "Enter send \u00B7 Tab complete \u00B7 Esc cancel \u00B7 Wheel/PgUp/PgDn scroll \u00B7 Ctrl+C clear / stop / exit" }), _jsx(Text, { dimColor: true, children: "History: \u2191/\u2193 or Ctrl+P/N recall \u00B7 Editing: Ctrl+A/E start/end \u00B7 Ctrl+K/U kill right/left \u00B7 Ctrl+W kill word \u00B7 Ctrl+Y restore" })] }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: displayPaused || position > 0, identity: `${width}:${state.sessionId}:${statusExpanded}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, revision: state.version, paused: displayPaused || position > 0 }) })] })] }) }) }) });
369
805
  }