@itookit/dsht 0.3.0 → 0.3.3
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 +39 -23
- package/README.zh.md +40 -24
- package/dist/catalog/controller.d.ts +32 -0
- package/dist/catalog/controller.js +88 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/{cli.js → cli/index.js} +42 -29
- package/dist/controller/connection.d.ts +80 -0
- package/dist/controller/connection.js +190 -0
- package/dist/controller/controller.d.ts +269 -0
- package/dist/controller/controller.js +372 -0
- package/dist/controller/index.d.ts +5 -0
- package/dist/controller/index.js +3 -0
- package/dist/controller/memory-log.d.ts +35 -0
- package/dist/controller/memory-log.js +95 -0
- package/dist/cost/config.d.ts +17 -0
- package/dist/cost/config.js +68 -0
- package/dist/cost/controller.d.ts +41 -0
- package/dist/cost/controller.js +115 -0
- package/dist/cost/index.d.ts +10 -0
- package/dist/cost/index.js +8 -0
- package/dist/cost/ledger-files.d.ts +16 -0
- package/dist/cost/ledger-files.js +103 -0
- package/dist/cost/ledger.d.ts +78 -0
- package/dist/cost/ledger.js +146 -0
- package/dist/cost/pricing.d.ts +86 -0
- package/dist/cost/pricing.js +223 -0
- package/dist/cost/records.d.ts +17 -0
- package/dist/cost/records.js +79 -0
- package/dist/cost/scanner.d.ts +22 -0
- package/dist/cost/scanner.js +95 -0
- package/dist/cost/types.d.ts +85 -0
- package/dist/cost/types.js +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/session/connection-view.d.ts +18 -0
- package/dist/session/connection-view.js +1 -0
- package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
- package/dist/session/controller.js +616 -0
- package/dist/session/export-html.d.ts +9 -0
- package/dist/session/export-html.js +39 -0
- package/dist/{export.d.ts → session/export.d.ts} +1 -1
- package/dist/{export.js → session/export.js} +6 -20
- package/dist/{history.d.ts → session/history.d.ts} +17 -0
- package/dist/{history.js → session/history.js} +163 -2
- package/dist/session/index.d.ts +17 -0
- package/dist/session/index.js +10 -0
- package/dist/session/markdown.d.ts +39 -0
- package/dist/session/markdown.js +255 -0
- package/dist/session/math.d.ts +11 -0
- package/dist/session/math.js +82 -0
- package/dist/session/navigation.d.ts +37 -0
- package/dist/session/navigation.js +84 -0
- package/dist/{references.js → session/references.js} +1 -1
- package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
- package/dist/{telemetry.js → session/telemetry.js} +1 -1
- package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
- package/dist/{transcript.js → session/transcript.js} +141 -20
- package/dist/session/types.d.ts +18 -0
- package/dist/session/types.js +2 -0
- package/dist/state.d.ts +41 -0
- package/dist/state.js +9 -0
- package/dist/storage/directories.d.ts +14 -0
- package/dist/storage/directories.js +24 -0
- package/dist/storage/files.d.ts +51 -0
- package/dist/storage/files.js +152 -0
- package/dist/storage/heap-snapshot.d.ts +19 -0
- package/dist/storage/heap-snapshot.js +29 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +4 -0
- package/dist/transport/auth.js +65 -0
- package/dist/transport/host.d.ts +13 -0
- package/dist/transport/host.js +1 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +790 -0
- package/dist/ui/chat/header.d.ts +11 -0
- package/dist/ui/chat/header.js +14 -0
- package/dist/ui/chat/history-view.d.ts +12 -0
- package/dist/ui/chat/history-view.js +17 -0
- package/dist/ui/chat/status.d.ts +91 -0
- package/dist/ui/chat/status.js +386 -0
- package/dist/ui/chat/viewport.d.ts +17 -0
- package/dist/ui/chat/viewport.js +14 -0
- package/dist/ui/commands/parse.d.ts +99 -0
- package/dist/ui/commands/parse.js +126 -0
- package/dist/ui/commands/registry.d.ts +33 -0
- package/dist/ui/commands/registry.js +73 -0
- package/dist/ui/copy-mode.d.ts +4 -0
- package/dist/ui/copy-mode.js +6 -0
- package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
- package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
- package/dist/ui/dialogs/index.d.ts +120 -0
- package/dist/ui/dialogs/index.js +113 -0
- package/dist/ui/dialogs/picker.d.ts +18 -0
- package/dist/ui/dialogs/picker.js +38 -0
- package/dist/ui/frozen.d.ts +8 -0
- package/dist/ui/frozen.js +7 -0
- package/dist/ui/input/references.d.ts +12 -0
- package/dist/ui/input/references.js +15 -0
- package/dist/ui/mount.d.ts +6 -0
- package/dist/ui/mount.js +11 -0
- package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
- package/package.json +19 -13
- package/dist/app.d.ts +0 -21
- package/dist/app.js +0 -805
- package/dist/auth.js +0 -108
- package/dist/controller.js +0 -961
- package/dist/cost.d.ts +0 -119
- package/dist/cost.js +0 -313
- package/dist/history-view.d.ts +0 -8
- package/dist/history-view.js +0 -12
- package/dist/navigation.d.ts +0 -11
- package/dist/navigation.js +0 -36
- package/dist/status.d.ts +0 -28
- package/dist/status.js +0 -157
- /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
- /package/dist/{memory.js → session/memory.js} +0 -0
- /package/dist/{references.d.ts → session/references.d.ts} +0 -0
- /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
- /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
- /package/dist/{client.js → transport/client.js} +0 -0
- /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
- /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
- /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
- /package/dist/{wire.js → transport/wire.js} +0 -0
- /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
- /package/dist/{input-history.js → ui/input/history.js} +0 -0
- /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
- /package/dist/{input.js → ui/input/input.js} +0 -0
- /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
- /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
- /package/dist/{theme.js → ui/theme/index.js} +0 -0
package/dist/ui/app.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/** Ink terminal interface: startup pickers, transcript, and slash-command composer. */
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import { mocha, ThemeContext } from "./theme/index.js";
|
|
5
|
+
import { Box, Text, measureElement, useApp, useInput, useStdout } from 'ink';
|
|
6
|
+
import { useMouseWheel } from "./input/mouse.js";
|
|
7
|
+
import { TextInput } from "./input/input.js";
|
|
8
|
+
import { InputHistory } from "./input/history.js";
|
|
9
|
+
import { ReferenceMenu } from "./input/references.js";
|
|
10
|
+
import { historyLayout, releaseHistoryLayout } from "../session/history.js";
|
|
11
|
+
import { toolLine } from "../session/transcript.js";
|
|
12
|
+
import { activeReference, fileMention } from "../session/references.js";
|
|
13
|
+
import { CostPanel } from "./dialogs/cost.js";
|
|
14
|
+
import { StatusBar } from "./chat/status.js";
|
|
15
|
+
import { ChatHeader } from "./chat/header.js";
|
|
16
|
+
import { ChatViewport } from "./chat/viewport.js";
|
|
17
|
+
import { Frozen } from "./frozen.js";
|
|
18
|
+
import { CopyMode } from "./copy-mode.js";
|
|
19
|
+
import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
|
|
20
|
+
import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
|
|
21
|
+
import { classifySubmission } from "./commands/parse.js";
|
|
22
|
+
import { sessionLabel, sessionStatus, workspaceStatus } from "../session/navigation.js";
|
|
23
|
+
import { array, errorText, object, safeText, string } from "../transport/wire.js";
|
|
24
|
+
/** Transient notices expire; interactive panels remain open until dismissed. */
|
|
25
|
+
const PANEL_LIFETIME_MS = 10_000;
|
|
26
|
+
/** The caller owns starting and stopping the controller around the Ink render lifetime.
|
|
27
|
+
* @param props - Controller, panel lifetime and semantic theme.
|
|
28
|
+
* @returns The rendered terminal interface.
|
|
29
|
+
*/
|
|
30
|
+
export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = mocha }) {
|
|
31
|
+
const state = useSyncExternalStore(controller.subscribe, controller.snapshot);
|
|
32
|
+
const presetId = controller.telemetry.view(state.sessionId).values.agentPreset;
|
|
33
|
+
useEffect(() => { if (typeof presetId === 'string')
|
|
34
|
+
controller.loadPresetNames(); }, [controller, state.online, presetId]);
|
|
35
|
+
const { exit } = useApp();
|
|
36
|
+
const { stdout } = useStdout();
|
|
37
|
+
const [copyMode, setCopyMode] = useState(false);
|
|
38
|
+
const [input, updateInput] = useState('');
|
|
39
|
+
const draft = useRef('');
|
|
40
|
+
const inputHistory = useRef(new InputHistory());
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const history = new InputHistory();
|
|
43
|
+
inputHistory.current = history;
|
|
44
|
+
if (!state.transcript.ready)
|
|
45
|
+
return;
|
|
46
|
+
const messages = state.transcript.messagesForWidth(Math.max(20, (stdout.columns ?? 100) - 2));
|
|
47
|
+
// Seed once per loaded session, never scan historical messages on stream ticks or arrow presses.
|
|
48
|
+
const prompts = messages.filter(message => message.role === 'You').slice(-200);
|
|
49
|
+
for (const message of prompts)
|
|
50
|
+
history.record(message.text.replace(/\r?\n/g, ' ').trim());
|
|
51
|
+
}, [state.sessionId, state.transcript, state.transcript.ready]);
|
|
52
|
+
const [cursor, setCursor] = useState(0);
|
|
53
|
+
// Input callbacks may run before Ink refreshes the controlled field's listener.
|
|
54
|
+
const setInput = (value, recalled = false) => {
|
|
55
|
+
if (!recalled)
|
|
56
|
+
inputHistory.current.reset();
|
|
57
|
+
// A new message draft returns the view to the live end, so composing never needs a scroll first.
|
|
58
|
+
// A slash command is not a message, and the reader keeps their place while typing one.
|
|
59
|
+
if (state.screen === 'chat' && draft.current === '' && value !== '' && !value.startsWith('/'))
|
|
60
|
+
setScroll(0);
|
|
61
|
+
draft.current = value;
|
|
62
|
+
updateInput(value);
|
|
63
|
+
setCursor(value.length);
|
|
64
|
+
};
|
|
65
|
+
const [historyWindow, setHistoryWindow] = useState();
|
|
66
|
+
const [historyLoading, setHistoryLoading] = useState();
|
|
67
|
+
const [historyMatches, setHistoryMatches] = useState();
|
|
68
|
+
const displayTranscript = historyWindow ?? state.transcript;
|
|
69
|
+
const displayRef = useRef(displayTranscript);
|
|
70
|
+
displayRef.current = displayTranscript;
|
|
71
|
+
useEffect(() => () => { if (historyWindow) {
|
|
72
|
+
releaseHistoryLayout(historyWindow);
|
|
73
|
+
historyWindow.dispose();
|
|
74
|
+
} }, [historyWindow]);
|
|
75
|
+
const conversationBox = useRef(null);
|
|
76
|
+
const [conversationRows, setConversationRows] = useState(20);
|
|
77
|
+
useLayoutEffect(() => {
|
|
78
|
+
if (conversationBox.current) {
|
|
79
|
+
const height = Math.floor(measureElement(conversationBox.current).height);
|
|
80
|
+
if (height !== conversationRows)
|
|
81
|
+
setConversationRows(height);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const [scroll, setScroll] = useState(0);
|
|
85
|
+
const [liveReasoning, setLiveReasoning] = useState('row');
|
|
86
|
+
const [removal, setRemoval] = useState();
|
|
87
|
+
const [models, setModels] = useState();
|
|
88
|
+
const [thoughtList, setThoughtList] = useState(false);
|
|
89
|
+
const [queueOpen, setQueueOpen] = useState(false);
|
|
90
|
+
const [historyQuery, setHistoryQuery] = useState();
|
|
91
|
+
const [contentSearch, setContentSearch] = useState(false);
|
|
92
|
+
const [searchResults, setSearchResults] = useState();
|
|
93
|
+
const historyAbort = useRef(undefined);
|
|
94
|
+
useEffect(() => () => historyAbort.current?.abort(), []);
|
|
95
|
+
const [costExpanded, setCostExpanded] = useState(false);
|
|
96
|
+
const [statusExpanded, setStatusExpanded] = useState(false);
|
|
97
|
+
const [reasoningOverrides, setReasoningOverrides] = useState(new Set());
|
|
98
|
+
const reasoning = 'row';
|
|
99
|
+
useEffect(() => { setReasoningOverrides(new Set()); setModels(undefined); setThoughtList(false); setHistoryWindow(undefined); setHistoryMatches(undefined); }, [state.transcript]);
|
|
100
|
+
useEffect(() => { setLiveReasoning('row'); }, [state.transcript, state.transcript.liveAttemptKey]);
|
|
101
|
+
const [notice, setNotice] = useState();
|
|
102
|
+
const [help, setHelp] = useState(false);
|
|
103
|
+
const [helpPage, setHelpPage] = useState(0);
|
|
104
|
+
const helpPageSize = Math.max(1, (stdout.rows ?? 30) - 12);
|
|
105
|
+
const helpPages = Math.ceil(COMMAND_HINTS.length / helpPageSize);
|
|
106
|
+
const currentHelpPage = Math.min(helpPage, helpPages - 1);
|
|
107
|
+
const [statusScroll, setStatusScroll] = useState(0);
|
|
108
|
+
const [statusOverflow, setStatusOverflow] = useState(false);
|
|
109
|
+
// The panel owns two border rows and a one-row footer, and the two header rows and the
|
|
110
|
+
// three-row composer above it never shrink, so only the remainder of the screen height shows rows.
|
|
111
|
+
const [statusBarRows, setStatusBarRows] = useState(1);
|
|
112
|
+
const statusViewRows = Math.max(1, (stdout.rows ?? 30) - 9 - (statusBarRows - 1));
|
|
113
|
+
const [answers, setAnswers] = useState({});
|
|
114
|
+
const [optionState, setOptionState] = useState();
|
|
115
|
+
const [approvalSelection, setApprovalSelection] = useState();
|
|
116
|
+
const [referenceIndex, setReferenceIndex] = useState(0);
|
|
117
|
+
const [dismissedReference, dismissReference] = useState();
|
|
118
|
+
const [lookup, setLookup] = useState();
|
|
119
|
+
// Interactive panels remain stable while read; only transient notices expire.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (copyMode)
|
|
122
|
+
return;
|
|
123
|
+
if (notice === undefined)
|
|
124
|
+
return;
|
|
125
|
+
const timer = setTimeout(() => setNotice(undefined), panelLifetimeMs);
|
|
126
|
+
return () => clearTimeout(timer);
|
|
127
|
+
}, [notice, panelLifetimeMs, copyMode]);
|
|
128
|
+
// `/history` is a lookup rather than a panel to read, so it expires with the notice lifetime;
|
|
129
|
+
// Esc closes it sooner, and its own content search stays open until the reader leaves it.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (copyMode || contentSearch || historyQuery === undefined)
|
|
132
|
+
return;
|
|
133
|
+
const timer = setTimeout(() => { setHistoryQuery(undefined); setHistoryMatches(undefined); }, panelLifetimeMs);
|
|
134
|
+
return () => clearTimeout(timer);
|
|
135
|
+
}, [historyQuery, contentSearch, panelLifetimeMs, copyMode]);
|
|
136
|
+
const pending = state.pending[0];
|
|
137
|
+
const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
|
|
138
|
+
useEffect(() => { setQueueOpen(false); }, [state.sessionId, pending?.eventId]);
|
|
139
|
+
// A replayed interaction (same eventId after a reconnect) starts unselected again.
|
|
140
|
+
useEffect(() => { setApprovalSelection(undefined); }, [state.sessionId, state.online, pending?.eventId]);
|
|
141
|
+
const token = state.screen === 'chat' && state.online && !state.busy && !pending
|
|
142
|
+
&& !input.startsWith('/') && dismissedReference !== input && cursor === input.length
|
|
143
|
+
? activeReference(input) : undefined;
|
|
144
|
+
const referenceOpen = token !== undefined;
|
|
145
|
+
const dialogOpen = !!(queueOpen || removal || models || thoughtList || historyQuery !== undefined || searchResults || costExpanded || statusExpanded || help || pending || referenceOpen || state.screen !== 'chat');
|
|
146
|
+
const displayPaused = copyMode || dialogOpen;
|
|
147
|
+
// Startup screens need live connection feedback even while their picker remains open.
|
|
148
|
+
const statusPaused = copyMode || (state.screen === 'chat' && dialogOpen);
|
|
149
|
+
const matches = referenceOpen && lookup?.draft === input && lookup.sessionId === state.sessionId ? lookup : undefined;
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!referenceOpen)
|
|
152
|
+
return;
|
|
153
|
+
const abort = new AbortController();
|
|
154
|
+
setLookup(undefined);
|
|
155
|
+
setReferenceIndex(0);
|
|
156
|
+
void controller.references(activeReference(input).query, abort.signal).then(items => {
|
|
157
|
+
if (!abort.signal.aborted)
|
|
158
|
+
setLookup({ draft: input, sessionId: state.sessionId, items });
|
|
159
|
+
}, error => {
|
|
160
|
+
if (!abort.signal.aborted)
|
|
161
|
+
setLookup({ draft: input, sessionId: state.sessionId, items: [], error: errorText(error) });
|
|
162
|
+
});
|
|
163
|
+
return () => abort.abort();
|
|
164
|
+
}, [controller, input, state.sessionId, referenceOpen]);
|
|
165
|
+
const pickReference = () => {
|
|
166
|
+
const candidate = matches?.items[referenceIndex];
|
|
167
|
+
if (!candidate || !token || draft.current !== input)
|
|
168
|
+
return;
|
|
169
|
+
const mention = fileMention(candidate, token.quoted);
|
|
170
|
+
setInput(input.slice(0, -token.prefix.length) + mention + (candidate.kind === 'file' ? ' ' : ''));
|
|
171
|
+
};
|
|
172
|
+
/** Complete the leading slash command; an ambiguous draft extends to the shared prefix. */
|
|
173
|
+
const completeCommand = () => {
|
|
174
|
+
const completed = completeDraft(input);
|
|
175
|
+
if (completed !== undefined)
|
|
176
|
+
setInput(completed);
|
|
177
|
+
};
|
|
178
|
+
const questions = pending?.event === 'user-questions/request' ? array(object(pending.request).questions).map(object) : [];
|
|
179
|
+
const eventId = pending ? string(pending.eventId) : '';
|
|
180
|
+
const answered = answers[eventId] ?? [];
|
|
181
|
+
const question = questions[answered.length];
|
|
182
|
+
const optionKey = `${eventId}:${answered.length}`;
|
|
183
|
+
const options = question ? array(question.options ?? []).map(object) : [];
|
|
184
|
+
const choiceState = optionState?.key === optionKey ? optionState : { key: optionKey, cursor: 0, selected: [], custom: false };
|
|
185
|
+
const optionCursor = Math.min(choiceState.cursor, options.length);
|
|
186
|
+
// Reserve the header, composer and question instructions; each choice may have a description.
|
|
187
|
+
const optionPageSize = Math.max(1, Math.min(6, Math.floor(((stdout.rows ?? 30) - 16) / 2)));
|
|
188
|
+
const optionStart = Math.max(0, optionCursor - optionPageSize + 1);
|
|
189
|
+
// One open panel owns the arrow and digit keys; the picker screens and the composer are not keyboard owners.
|
|
190
|
+
const panelBlocksKeys = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || help || costExpanded || statusExpanded);
|
|
191
|
+
// Composer recall yields only to a surface that uses the arrows itself: the pickers, and a status
|
|
192
|
+
// panel with more lines than the view holds. The help and cost panels and a fitting status panel
|
|
193
|
+
// leave the arrows with the history, and Ctrl+P/N reach it from every surface.
|
|
194
|
+
const recallBlocked = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || (statusExpanded && statusOverflow));
|
|
195
|
+
const questionKeysActive = !!question && options.length > 0 && !choiceState.custom && !copyMode && !panelBlocksKeys;
|
|
196
|
+
// A dialog that demands an answer owns the keyboard until it is settled: the draft being written is
|
|
197
|
+
// parked rather than typed into, and comes back when the last one closes. A question switches to
|
|
198
|
+
// the composer itself for a free-text or "Other" answer, so it keeps the keys then.
|
|
199
|
+
const answerPending = !!pending && (pending.event === 'approval/request' || options.length > 0 && !choiceState.custom);
|
|
200
|
+
const parkedDraft = useRef('');
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (answerPending) {
|
|
203
|
+
if (draft.current === '')
|
|
204
|
+
return;
|
|
205
|
+
parkedDraft.current = draft.current;
|
|
206
|
+
draft.current = '';
|
|
207
|
+
updateInput('');
|
|
208
|
+
setCursor(0);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (pending || parkedDraft.current === '')
|
|
212
|
+
return;
|
|
213
|
+
// Restoring writes the states directly: the draft was neither recalled from history nor a new
|
|
214
|
+
// message, so it must not move a reader's scroll position.
|
|
215
|
+
const restore = parkedDraft.current;
|
|
216
|
+
parkedDraft.current = '';
|
|
217
|
+
draft.current = restore;
|
|
218
|
+
updateInput(restore);
|
|
219
|
+
setCursor(restore.length);
|
|
220
|
+
}, [answerPending, pending]);
|
|
221
|
+
const approvalKeysActive = pending?.event === 'approval/request' && !copyMode && !panelBlocksKeys;
|
|
222
|
+
const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
|
|
223
|
+
const answerQuestion = async (selected, custom) => {
|
|
224
|
+
if (controller.state.pending[0]?.eventId !== eventId)
|
|
225
|
+
throw new Error('The pending question has changed');
|
|
226
|
+
const answer = { id: string(question.id), selected, ...(custom ? { custom } : {}) };
|
|
227
|
+
const next = [...answered, answer];
|
|
228
|
+
if (next.length === questions.length) {
|
|
229
|
+
await controller.answer({ answers: next });
|
|
230
|
+
setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
|
|
231
|
+
}
|
|
232
|
+
else
|
|
233
|
+
setAnswers(previous => ({ ...previous, [eventId]: next }));
|
|
234
|
+
setOptionState(undefined);
|
|
235
|
+
};
|
|
236
|
+
const operate = (fn) => { void controller.perform(fn); };
|
|
237
|
+
const requestRemoval = async (kind, query) => {
|
|
238
|
+
const target = await controller.removalTarget(kind, query);
|
|
239
|
+
if (target.kind === 'session' && target.empty)
|
|
240
|
+
await controller.removeTarget(target);
|
|
241
|
+
else
|
|
242
|
+
setRemoval(target);
|
|
243
|
+
};
|
|
244
|
+
useInput((_value, key) => {
|
|
245
|
+
if (key.eventType === 'release')
|
|
246
|
+
return;
|
|
247
|
+
if (copyMode) {
|
|
248
|
+
if (key.escape || key.ctrl && (_value === 's' || _value === 'c'))
|
|
249
|
+
setCopyMode(false);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (key.ctrl && _value === 's') {
|
|
253
|
+
setCopyMode(true);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
|
|
257
|
+
historyAbort.current.abort();
|
|
258
|
+
// Esc also leaves a history list that was already on screen when the load was aborted.
|
|
259
|
+
if (key.escape && historyQuery !== undefined) {
|
|
260
|
+
setHistoryQuery(undefined);
|
|
261
|
+
setHistoryMatches(undefined);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
|
|
266
|
+
if (key.ctrl && draft.current)
|
|
267
|
+
setInput('');
|
|
268
|
+
if (key.escape) {
|
|
269
|
+
setOptionState({ ...choiceState, custom: false });
|
|
270
|
+
setApprovalSelection(undefined);
|
|
271
|
+
setRemoval(undefined);
|
|
272
|
+
setModels(undefined);
|
|
273
|
+
setThoughtList(false);
|
|
274
|
+
setSearchResults(undefined);
|
|
275
|
+
setHistoryQuery(undefined);
|
|
276
|
+
setHistoryMatches(undefined);
|
|
277
|
+
setHelp(false);
|
|
278
|
+
setCostExpanded(false);
|
|
279
|
+
setStatusExpanded(false);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (approvalKeysActive && !draft.current && controller.state.online && !controller.state.busy
|
|
284
|
+
&& controller.state.pending[0]?.eventId === eventId && !key.ctrl && !key.meta) {
|
|
285
|
+
const digit = /^[1-3]$/.test(_value) ? Number(_value) - 1 : -1;
|
|
286
|
+
if (digit >= 0 || key.upArrow || key.downArrow) {
|
|
287
|
+
// An unselected list enters at the first, non-destructive choice, so a stray arrow plus Enter cannot cancel.
|
|
288
|
+
const index = digit >= 0 ? digit : approvalIndex < 0 ? 0
|
|
289
|
+
: Math.max(0, Math.min(2, approvalIndex + (key.upArrow ? -1 : 1)));
|
|
290
|
+
setApprovalSelection({ eventId, index });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (key.return) {
|
|
294
|
+
// Choice 3 cancels the turn instead of answering the request, so it sends no event result.
|
|
295
|
+
if (approvalIndex >= 0)
|
|
296
|
+
operate(() => approvalIndex === 2 ? controller.cancelTurn() : controller.approve(approvalIndex === 0));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (questionKeysActive && !draft.current && !controller.state.busy && !key.ctrl && !key.meta) {
|
|
301
|
+
const digit = /^[1-9]$/.test(_value) ? Number(_value) - 1 : -1;
|
|
302
|
+
if (key.upArrow || key.downArrow) {
|
|
303
|
+
setOptionState({ ...choiceState, cursor: Math.max(0, Math.min(options.length, optionCursor + (key.upArrow ? -1 : 1))) });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (digit >= 0 && digit <= options.length || _value === ' ' && question.multiSelect === true && optionCursor < options.length) {
|
|
307
|
+
const index = digit >= 0 ? digit : optionCursor;
|
|
308
|
+
const label = index < options.length ? string(options[index].label) : undefined;
|
|
309
|
+
const selected = question.multiSelect === true && label
|
|
310
|
+
? choiceState.selected.includes(label) ? choiceState.selected.filter(item => item !== label) : [...choiceState.selected, label]
|
|
311
|
+
: choiceState.selected;
|
|
312
|
+
setOptionState({ ...choiceState, cursor: index, selected });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (key.return) {
|
|
316
|
+
if (optionCursor === options.length) {
|
|
317
|
+
setOptionState({ ...choiceState, custom: true });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const selected = question.multiSelect === true ? choiceState.selected : [string(options[optionCursor].label)];
|
|
321
|
+
if (!selected.length) {
|
|
322
|
+
setNotice('Select at least one option with Space or a number');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
operate(() => answerQuestion(selected));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (help && (key.pageUp || key.pageDown)) {
|
|
330
|
+
setHelpPage(Math.max(0, Math.min(helpPages - 1, currentHelpPage + (key.pageUp ? -1 : 1))));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (statusExpanded && statusOverflow && (key.upArrow || key.downArrow)) {
|
|
334
|
+
setStatusScroll(value => Math.max(0, value + (key.upArrow ? -1 : 1)));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (statusExpanded && statusOverflow && (key.pageUp || key.pageDown)) {
|
|
338
|
+
setStatusScroll(value => Math.max(0, value + (key.pageUp ? -statusViewRows : statusViewRows)));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (key.pageUp || key.pageDown) {
|
|
342
|
+
scrollHistory(key.pageUp ? 10 : -10);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (key.escape && queueOpen) {
|
|
346
|
+
setQueueOpen(false);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (key.escape && removal) {
|
|
350
|
+
setRemoval(undefined);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (key.escape && models) {
|
|
354
|
+
setModels(undefined);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (key.escape && thoughtList) {
|
|
358
|
+
setThoughtList(false);
|
|
359
|
+
if (controller.running)
|
|
360
|
+
void controller.interrupt(true);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (key.escape && searchResults) {
|
|
364
|
+
setSearchResults(undefined);
|
|
365
|
+
if (controller.running)
|
|
366
|
+
void controller.interrupt(true);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (key.escape && historyQuery !== undefined) {
|
|
370
|
+
setHistoryQuery(undefined);
|
|
371
|
+
setHistoryMatches(undefined);
|
|
372
|
+
if (controller.running)
|
|
373
|
+
void controller.interrupt(true);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// The typed host path is a screen of its own, so Esc has to leave it: the picker behind it is
|
|
377
|
+
// disabled while a draft exists, so a leftover path would leave no way back at all.
|
|
378
|
+
if (key.escape && state.screen === 'path') {
|
|
379
|
+
setInput('');
|
|
380
|
+
operate(() => controller.showPicker('workspaces'));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (key.ctrl && _value === 'c') {
|
|
384
|
+
// A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
|
|
385
|
+
if (input !== '') {
|
|
386
|
+
setInput('');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
void controller.interrupt().then(shouldExit => { if (shouldExit)
|
|
390
|
+
exit(); });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (referenceOpen) {
|
|
394
|
+
if (key.escape) {
|
|
395
|
+
dismissReference(input);
|
|
396
|
+
if (controller.running)
|
|
397
|
+
void controller.interrupt(true);
|
|
398
|
+
}
|
|
399
|
+
else if (key.tab)
|
|
400
|
+
pickReference();
|
|
401
|
+
else if (key.upArrow)
|
|
402
|
+
setReferenceIndex(value => Math.max(0, value - 1));
|
|
403
|
+
else if (key.downArrow)
|
|
404
|
+
setReferenceIndex(value => Math.max(0, Math.min((matches?.items.length ?? 1) - 1, value + 1)));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const recallPrevious = key.upArrow || key.ctrl && _value === 'p';
|
|
408
|
+
const recallNext = key.downArrow || key.ctrl && _value === 'n';
|
|
409
|
+
if ((recallPrevious || recallNext) && state.online && !controller.state.busy && !pending
|
|
410
|
+
&& !queueOpen && (!recallBlocked || key.ctrl)
|
|
411
|
+
&& (state.screen === 'chat' || draft.current !== '' || key.ctrl)) {
|
|
412
|
+
setInput(inputHistory.current.move(recallPrevious ? -1 : 1, draft.current), true);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (key.tab) {
|
|
416
|
+
completeCommand();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (key.escape && (help || costExpanded || statusExpanded || notice !== undefined)) {
|
|
420
|
+
setHelp(false);
|
|
421
|
+
setCostExpanded(false);
|
|
422
|
+
setStatusExpanded(false);
|
|
423
|
+
setStatusScroll(0);
|
|
424
|
+
setNotice(undefined);
|
|
425
|
+
if (controller.running)
|
|
426
|
+
void controller.interrupt(true);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (key.escape && state.screen === 'chat') {
|
|
430
|
+
void controller.interrupt(true);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
const submit = async (raw) => {
|
|
434
|
+
const submission = classifySubmission(raw, {
|
|
435
|
+
referenceOpen, copyMode, pending: pending !== undefined, question: question !== undefined, screen: state.screen,
|
|
436
|
+
});
|
|
437
|
+
if (submission.kind === 'ignore')
|
|
438
|
+
return;
|
|
439
|
+
if (submission.kind === 'reference') {
|
|
440
|
+
pickReference();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const value = raw.trim();
|
|
444
|
+
if (!pending && !/^\/feedback(?:\s|$)/.test(value))
|
|
445
|
+
inputHistory.current.record(value);
|
|
446
|
+
if (submission.kind === 'copy') {
|
|
447
|
+
setInput('');
|
|
448
|
+
setCopyMode(true);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
setRemoval(undefined);
|
|
452
|
+
// Each panel belongs to the command that opened it, so any other command closes it.
|
|
453
|
+
if (value !== '/queue')
|
|
454
|
+
setQueueOpen(false);
|
|
455
|
+
if (!/^\/model(?: |$)/.test(value))
|
|
456
|
+
setModels(undefined);
|
|
457
|
+
if (value !== '/help')
|
|
458
|
+
setHelp(false);
|
|
459
|
+
if (value !== '/cost')
|
|
460
|
+
setCostExpanded(false);
|
|
461
|
+
if (value !== '/status')
|
|
462
|
+
setStatusExpanded(false);
|
|
463
|
+
if (!/^\/think(?: |$)/.test(value))
|
|
464
|
+
setThoughtList(false);
|
|
465
|
+
if (submission.kind === 'quit') {
|
|
466
|
+
exit();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (submission.kind === 'panel') {
|
|
470
|
+
if (submission.panel === 'cost') {
|
|
471
|
+
setCostExpanded(value => !value);
|
|
472
|
+
setInput('');
|
|
473
|
+
if (!costExpanded)
|
|
474
|
+
void controller.perform(() => historyOperation(signal => controller.refreshCosts(signal)));
|
|
475
|
+
}
|
|
476
|
+
else if (submission.panel === 'status') {
|
|
477
|
+
setStatusExpanded(value => !value);
|
|
478
|
+
setStatusScroll(0);
|
|
479
|
+
setInput('');
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
setHelp(value => !value);
|
|
483
|
+
setHelpPage(0);
|
|
484
|
+
setInput('');
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const accepted = await controller.perform(async () => {
|
|
489
|
+
switch (submission.kind) {
|
|
490
|
+
case 'remove':
|
|
491
|
+
await requestRemoval(submission.target, submission.query);
|
|
492
|
+
return;
|
|
493
|
+
case 'navigate': {
|
|
494
|
+
setHistoryQuery(undefined);
|
|
495
|
+
setSearchResults(undefined);
|
|
496
|
+
if (submission.target === 'workspace')
|
|
497
|
+
await controller.switchWorkspace(submission.query);
|
|
498
|
+
else
|
|
499
|
+
await controller.switchSession(submission.query);
|
|
500
|
+
setScroll(0);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
case 'path':
|
|
504
|
+
await controller.createWorkspace(submission.value);
|
|
505
|
+
return;
|
|
506
|
+
case 'latest':
|
|
507
|
+
setHistoryWindow(undefined);
|
|
508
|
+
setHistoryMatches(undefined);
|
|
509
|
+
setHistoryQuery(undefined);
|
|
510
|
+
setSearchResults(undefined);
|
|
511
|
+
setReasoningOverrides(new Set());
|
|
512
|
+
setScroll(0);
|
|
513
|
+
controller.pinHistory(false);
|
|
514
|
+
return;
|
|
515
|
+
case 'models': {
|
|
516
|
+
if (!submission.args.length) {
|
|
517
|
+
setHistoryQuery(undefined);
|
|
518
|
+
setSearchResults(undefined);
|
|
519
|
+
setModels({ catalog: await controller.modelCatalog() });
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
await controller.selectModel(submission.args[0], submission.args[1], submission.args[2]);
|
|
523
|
+
setModels(undefined);
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
case 'queue':
|
|
528
|
+
setQueueOpen(true);
|
|
529
|
+
return;
|
|
530
|
+
case 'newSession':
|
|
531
|
+
await controller.createSession();
|
|
532
|
+
return;
|
|
533
|
+
case 'history':
|
|
534
|
+
setSearchResults(undefined);
|
|
535
|
+
setContentSearch(false);
|
|
536
|
+
setHistoryQuery(submission.query);
|
|
537
|
+
return;
|
|
538
|
+
case 'sessionSearch':
|
|
539
|
+
await historyOperation(async (signal) => {
|
|
540
|
+
const result = await controller.searchSessions(submission.query, submission.command === '/ssearch', signal);
|
|
541
|
+
setHistoryQuery(undefined);
|
|
542
|
+
setSearchResults({ query: submission.query, ...result });
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
case 'historySearch':
|
|
546
|
+
await historyOperation(async (signal) => {
|
|
547
|
+
setHistoryMatches(await controller.searchHistory(submission.query, signal));
|
|
548
|
+
setSearchResults(undefined);
|
|
549
|
+
setContentSearch(true);
|
|
550
|
+
setHistoryQuery(submission.query);
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
case 'think': {
|
|
554
|
+
if (submission.target === 'live') {
|
|
555
|
+
setLiveReasoning(value => value === 'row' ? 'full' : 'row');
|
|
556
|
+
setThoughtList(false);
|
|
557
|
+
setScroll(0);
|
|
558
|
+
}
|
|
559
|
+
else if (submission.target) {
|
|
560
|
+
const seq = Number(submission.target);
|
|
561
|
+
if (!Number.isSafeInteger(seq) || !displayTranscript.thoughts.some(entry => entry.seq === seq))
|
|
562
|
+
throw new Error('Use /think <message sequence> for a loaded reasoning block');
|
|
563
|
+
const next = new Set(reasoningOverrides);
|
|
564
|
+
if (next.delete(seq)) {
|
|
565
|
+
setReasoningOverrides(next);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
next.add(seq);
|
|
569
|
+
setReasoningOverrides(next);
|
|
570
|
+
await jumpHistory(seq, next);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
setHistoryQuery(undefined);
|
|
574
|
+
setSearchResults(undefined);
|
|
575
|
+
setThoughtList(true);
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
case 'older':
|
|
580
|
+
await controller.older(undefined, displayTranscript);
|
|
581
|
+
setScroll(value => value + 10);
|
|
582
|
+
return;
|
|
583
|
+
case 'compact':
|
|
584
|
+
setNotice(undefined);
|
|
585
|
+
await historyOperation(async (signal) => { setNotice(await controller.command('/compact', signal)); }, 'Compacting history…');
|
|
586
|
+
return;
|
|
587
|
+
case 'cancel':
|
|
588
|
+
await controller.cancelTurn();
|
|
589
|
+
return;
|
|
590
|
+
case 'approval':
|
|
591
|
+
await controller.approve(submission.allowed);
|
|
592
|
+
return;
|
|
593
|
+
case 'hostCommand':
|
|
594
|
+
setNotice(undefined);
|
|
595
|
+
await historyOperation(async (signal) => { setNotice(await controller.command(submission.line, signal)); }, 'Running command…');
|
|
596
|
+
return;
|
|
597
|
+
case 'export':
|
|
598
|
+
await historyOperation(async (signal) => { setNotice(`Saved session log: ${await controller.exportLog(submission.destination, signal)}`); }, 'Exporting session log…');
|
|
599
|
+
return;
|
|
600
|
+
case 'exportHtml':
|
|
601
|
+
await historyOperation(async (signal) => { setNotice(`Saved loaded conversation: ${await controller.exportHtml(submission.destination, signal)}`); }, 'Exporting loaded conversation…');
|
|
602
|
+
return;
|
|
603
|
+
case 'coredump':
|
|
604
|
+
// V8 serializes the heap synchronously, so the client stalls until the file is written;
|
|
605
|
+
// the path is reported afterwards so the snapshot can be opened in DevTools.
|
|
606
|
+
await historyOperation(async () => {
|
|
607
|
+
setNotice(`Heap snapshot saved: ${controller.heapSnapshot(submission.tag)}`);
|
|
608
|
+
}, 'Writing heap snapshot…');
|
|
609
|
+
return;
|
|
610
|
+
case 'answer':
|
|
611
|
+
await answerQuestion(question.multiSelect === true ? choiceState.selected : [], submission.text);
|
|
612
|
+
return;
|
|
613
|
+
case 'error': throw new Error(submission.message);
|
|
614
|
+
case 'prompt':
|
|
615
|
+
await controller.prompt(submission.text);
|
|
616
|
+
setHistoryWindow(undefined);
|
|
617
|
+
setScroll(0);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
if (accepted)
|
|
622
|
+
setInput('');
|
|
623
|
+
};
|
|
624
|
+
// Session activity is a list-level fact, so both pickers read it without loading any history.
|
|
625
|
+
const listAge = Date.now();
|
|
626
|
+
const sessionsOf = (workspace) => {
|
|
627
|
+
const ids = new Set(array(workspace.sessionIds).map(string));
|
|
628
|
+
return state.sessions.filter(session => ids.has(string(session.sessionId)));
|
|
629
|
+
};
|
|
630
|
+
const choices = state.screen === 'workspaces' ? [
|
|
631
|
+
...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
|
|
632
|
+
label: `${[workspaceStatus(sessionsOf(workspace)), string(workspace.title), string(workspace.path)].filter(Boolean).join(' ')}`,
|
|
633
|
+
remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
|
|
634
|
+
action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
|
|
635
|
+
{ key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
|
|
636
|
+
// The directory this client runs in is the one case where no path has to be typed, and offering
|
|
637
|
+
// it only while the host has not registered it keeps the row from repeating itself.
|
|
638
|
+
...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
|
|
639
|
+
: [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
|
|
640
|
+
action: () => operate(() => controller.createWorkspace(controller.localDirectory)) }]),
|
|
641
|
+
{ key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
|
|
642
|
+
] : [
|
|
643
|
+
...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
|
|
644
|
+
...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
|
|
645
|
+
label: `${sessionStatus(session, listAge)} ${sessionLabel(session)} ${session.sessionId}`,
|
|
646
|
+
remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
|
|
647
|
+
action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
|
|
648
|
+
{ key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
|
|
649
|
+
];
|
|
650
|
+
const width = Math.max(10, (stdout.columns ?? 80) - 2);
|
|
651
|
+
const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
|
|
652
|
+
const { length, first } = layout;
|
|
653
|
+
const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
|
|
654
|
+
const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
|
|
655
|
+
const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
|
|
656
|
+
const previousView = useRef({ transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning });
|
|
657
|
+
const previous = previousView.current;
|
|
658
|
+
const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
|
|
659
|
+
const adjustedScroll = previous.session !== state.transcript ? 0
|
|
660
|
+
: scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + length - previous.count) : scroll;
|
|
661
|
+
const maxScroll = Math.max(0, length - pageSize);
|
|
662
|
+
const position = Math.min(adjustedScroll, maxScroll);
|
|
663
|
+
useLayoutEffect(() => {
|
|
664
|
+
previousView.current = { transcript: displayTranscript, session: state.transcript, count: length, first, folds: reasoningOverrides, liveReasoning };
|
|
665
|
+
if (position !== scroll)
|
|
666
|
+
setScroll(position);
|
|
667
|
+
}, [state.transcript, displayTranscript, length, position, scroll, reasoningOverrides, liveReasoning]);
|
|
668
|
+
useLayoutEffect(() => {
|
|
669
|
+
controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
|
|
670
|
+
}, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.transcript]);
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
const first = displayTranscript.beforeSeq;
|
|
673
|
+
if (first !== undefined)
|
|
674
|
+
setReasoningOverrides(previous => {
|
|
675
|
+
const next = new Set([...previous].filter(seq => seq >= first));
|
|
676
|
+
return next.size === previous.size ? previous : next;
|
|
677
|
+
});
|
|
678
|
+
}, [displayTranscript, displayTranscript.memoryRevision]);
|
|
679
|
+
const scrollPosition = useRef(position);
|
|
680
|
+
scrollPosition.current = position;
|
|
681
|
+
const loadingPage = useRef(false);
|
|
682
|
+
const scrollIntent = useRef(0);
|
|
683
|
+
const mounted = useRef(true);
|
|
684
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
685
|
+
function scrollHistory(delta) {
|
|
686
|
+
if (copyMode || state.screen !== 'chat')
|
|
687
|
+
return;
|
|
688
|
+
const intent = ++scrollIntent.current;
|
|
689
|
+
const next = Math.max(0, Math.min(maxScroll, scrollPosition.current + delta));
|
|
690
|
+
scrollPosition.current = next;
|
|
691
|
+
setScroll(next);
|
|
692
|
+
if (delta <= 0 || next < maxScroll || loadingPage.current || state.busy || !state.online || !displayTranscript.ready || !displayTranscript.hasMore)
|
|
693
|
+
return;
|
|
694
|
+
loadingPage.current = true;
|
|
695
|
+
const transcript = displayTranscript;
|
|
696
|
+
void controller.perform(() => historyOperation(signal => controller.older(signal, transcript))).then(accepted => {
|
|
697
|
+
if (accepted && mounted.current && displayRef.current === transcript && scrollIntent.current === intent) {
|
|
698
|
+
setScroll(value => value + delta);
|
|
699
|
+
}
|
|
700
|
+
}).finally(() => { loadingPage.current = false; });
|
|
701
|
+
}
|
|
702
|
+
async function historyOperation(operation, label = 'Loading history…') {
|
|
703
|
+
const abort = new AbortController();
|
|
704
|
+
historyAbort.current = abort;
|
|
705
|
+
setHistoryLoading(label);
|
|
706
|
+
try {
|
|
707
|
+
await operation(abort.signal);
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
if (historyAbort.current === abort) {
|
|
711
|
+
historyAbort.current = undefined;
|
|
712
|
+
setHistoryLoading(undefined);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async function openSearchSession(sessionId, query) {
|
|
717
|
+
await historyOperation(async (signal) => {
|
|
718
|
+
await controller.selectSession(sessionId);
|
|
719
|
+
await controller.waitForHistory(signal);
|
|
720
|
+
setHistoryMatches(await controller.searchHistory(query, signal));
|
|
721
|
+
setSearchResults(undefined);
|
|
722
|
+
setContentSearch(true);
|
|
723
|
+
setHistoryQuery(query);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async function jumpHistory(target, folds = reasoningOverrides) {
|
|
727
|
+
if (state.screen !== 'chat')
|
|
728
|
+
throw new Error('Select a session first');
|
|
729
|
+
++scrollIntent.current;
|
|
730
|
+
const abort = new AbortController();
|
|
731
|
+
historyAbort.current = abort;
|
|
732
|
+
try {
|
|
733
|
+
const transcript = displayTranscript.messages.some(message => message.seq === target) ? displayTranscript
|
|
734
|
+
: state.transcript.messages.some(message => message.seq === target) ? state.transcript
|
|
735
|
+
: await controller.historyAt(target, abort.signal);
|
|
736
|
+
abort.signal.throwIfAborted();
|
|
737
|
+
const current = historyLayout(transcript, width, reasoning, folds, liveReasoning);
|
|
738
|
+
const row = current.offsets.get(target);
|
|
739
|
+
if (row === undefined)
|
|
740
|
+
throw new Error('No visible message at this sequence; use /history to choose a record');
|
|
741
|
+
setHistoryWindow(transcript === state.transcript ? undefined : transcript);
|
|
742
|
+
setHistoryQuery(undefined);
|
|
743
|
+
setHistoryMatches(undefined);
|
|
744
|
+
setThoughtList(false);
|
|
745
|
+
setScroll(Math.max(0, current.length - pageSize - row));
|
|
746
|
+
}
|
|
747
|
+
finally {
|
|
748
|
+
if (historyAbort.current === abort)
|
|
749
|
+
historyAbort.current = undefined;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
useMouseWheel(direction => { if (statusExpanded && statusOverflow)
|
|
753
|
+
setStatusScroll(value => Math.max(0, value - direction * 3));
|
|
754
|
+
else
|
|
755
|
+
scrollHistory(direction * 3); }, !copyMode && state.screen === 'chat', () => { if (!dialogOpen)
|
|
756
|
+
setCopyMode(true); });
|
|
757
|
+
const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
|
|
758
|
+
const end = Math.max(pageSize, length - position - trailingGap);
|
|
759
|
+
const visible = useMemo(() => layout.viewport(Math.max(0, end - pageSize), end), [layout, end, pageSize]);
|
|
760
|
+
const liveThought = thoughtList && !historyWindow ? state.transcript.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
|
|
761
|
+
const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
|
|
762
|
+
const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
|
|
763
|
+
key: String(entry.seq), label: `${toolLine(`#${entry.seq} User · ${entry.prompt}`, width - 2)}\n ${toolLine(`◇ ${entry.preview}`, width - 4)}`,
|
|
764
|
+
action: () => operate(async () => { const next = new Set(reasoningOverrides); next.add(entry.seq); setReasoningOverrides(next); await jumpHistory(entry.seq, next); }),
|
|
765
|
+
})), [thoughtEntries, width, pageSize, reasoningOverrides, liveReasoning, displayTranscript]);
|
|
766
|
+
const thoughtOptions = useMemo(() => [
|
|
767
|
+
...(liveThought ? [{ key: 'live', label: `${toolLine(`Now · User · ${state.transcript.latestPrompt}`, width - 2)}\n ${toolLine(liveThought.text, width - 4)}`,
|
|
768
|
+
action: () => { setLiveReasoning('full'); setThoughtList(false); const expanded = historyLayout(state.transcript, width, reasoning, reasoningOverrides, 'full'); setScroll(Math.max(0, expanded.length - pageSize - expanded.liveOffset)); } }] : []),
|
|
769
|
+
...thoughtChoices,
|
|
770
|
+
...(displayTranscript.hasMore ? [{ key: 'older', label: '↑ Load older reasoning', action: () => operate(() => historyOperation(signal => controller.older(signal, displayTranscript))) }] : []),
|
|
771
|
+
{ key: 'close', label: '← Back to conversation', action: () => setThoughtList(false) },
|
|
772
|
+
], [liveThought, thoughtChoices, displayTranscript, displayTranscript.hasMore, width, pageSize, reasoningOverrides]);
|
|
773
|
+
const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
|
|
774
|
+
const workspaceName = workspace ? string(workspace.title) || string(workspace.path) : state.workspaceId;
|
|
775
|
+
const headerTitle = controller.sessionName
|
|
776
|
+
? [controller.sessionName, width >= 60 ? workspaceName : undefined].filter(Boolean).join(' · ')
|
|
777
|
+
: workspaceName || 'All workspaces';
|
|
778
|
+
const commandSuggestions = input.startsWith('/') && !input.includes(' ') ? suggestedCommands(input) : undefined;
|
|
779
|
+
// Reading older history pauses the clock without freezing the connection state on picker screens.
|
|
780
|
+
// A paused clock is named rather than left frozen: a stopped number looks like a stall.
|
|
781
|
+
const pauseReason = copyMode ? 'copy'
|
|
782
|
+
: state.screen === 'chat' && dialogOpen ? 'dialog'
|
|
783
|
+
: state.screen === 'chat' && position > 0 ? 'history'
|
|
784
|
+
: undefined;
|
|
785
|
+
const statusFrozen = pauseReason !== undefined;
|
|
786
|
+
return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
|
|
787
|
+
.slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index + 1, ". ", question?.multiSelect === true && index < options.length ? choiceState.selected.includes(string(option.label)) ? '[x] ' : '[ ] ' : '', safeText(string(option.label))] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(string(option.description))] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
|
|
788
|
+
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Choose "Other answer" to type · the draft is kept while this is open'
|
|
789
|
+
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: answerPending ? theme.colors.muted : theme.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
790
|
+
}
|