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