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