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