@duetso/agent 0.1.80 → 0.1.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/memory/observational.d.ts +8 -1
- package/dist/src/memory/observational.d.ts.map +1 -1
- package/dist/src/memory/observational.js +9 -3
- package/dist/src/memory/observational.js.map +1 -1
- package/dist/src/session/session.d.ts +31 -13
- package/dist/src/session/session.d.ts.map +1 -1
- package/dist/src/session/session.js +63 -23
- package/dist/src/session/session.js.map +1 -1
- package/dist/src/tui/app.d.ts +25 -35
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +195 -2308
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/autocomplete-controller.d.ts +83 -0
- package/dist/src/tui/autocomplete-controller.d.ts.map +1 -0
- package/dist/src/tui/autocomplete-controller.js +308 -0
- package/dist/src/tui/autocomplete-controller.js.map +1 -0
- package/dist/src/tui/boot-screen.d.ts +64 -0
- package/dist/src/tui/boot-screen.d.ts.map +1 -0
- package/dist/src/tui/boot-screen.js +137 -0
- package/dist/src/tui/boot-screen.js.map +1 -0
- package/dist/src/tui/controllers.d.ts +39 -0
- package/dist/src/tui/controllers.d.ts.map +1 -0
- package/dist/src/tui/controllers.js +53 -0
- package/dist/src/tui/controllers.js.map +1 -0
- package/dist/src/tui/copy-controller.d.ts +75 -0
- package/dist/src/tui/copy-controller.d.ts.map +1 -0
- package/dist/src/tui/copy-controller.js +140 -0
- package/dist/src/tui/copy-controller.js.map +1 -0
- package/dist/src/tui/history-replay.d.ts +33 -0
- package/dist/src/tui/history-replay.d.ts.map +1 -0
- package/dist/src/tui/history-replay.js +47 -0
- package/dist/src/tui/history-replay.js.map +1 -0
- package/dist/src/tui/initial-prompt.d.ts +24 -0
- package/dist/src/tui/initial-prompt.d.ts.map +1 -0
- package/dist/src/tui/initial-prompt.js +29 -0
- package/dist/src/tui/initial-prompt.js.map +1 -0
- package/dist/src/tui/key-handlers.d.ts +49 -0
- package/dist/src/tui/key-handlers.d.ts.map +1 -0
- package/dist/src/tui/key-handlers.js +192 -0
- package/dist/src/tui/key-handlers.js.map +1 -0
- package/dist/src/tui/layout.d.ts +71 -0
- package/dist/src/tui/layout.d.ts.map +1 -0
- package/dist/src/tui/layout.js +245 -0
- package/dist/src/tui/layout.js.map +1 -0
- package/dist/src/tui/paste-controller.d.ts +90 -0
- package/dist/src/tui/paste-controller.d.ts.map +1 -0
- package/dist/src/tui/paste-controller.js +234 -0
- package/dist/src/tui/paste-controller.js.map +1 -0
- package/dist/src/tui/question-picker.d.ts +111 -0
- package/dist/src/tui/question-picker.d.ts.map +1 -0
- package/dist/src/tui/question-picker.js +308 -0
- package/dist/src/tui/question-picker.js.map +1 -0
- package/dist/src/tui/renderer-lifecycle.d.ts +20 -0
- package/dist/src/tui/renderer-lifecycle.d.ts.map +1 -0
- package/dist/src/tui/renderer-lifecycle.js +50 -0
- package/dist/src/tui/renderer-lifecycle.js.map +1 -0
- package/dist/src/tui/session-subscription.d.ts +30 -0
- package/dist/src/tui/session-subscription.d.ts.map +1 -0
- package/dist/src/tui/session-subscription.js +78 -0
- package/dist/src/tui/session-subscription.js.map +1 -0
- package/dist/src/tui/sidebar.d.ts +2 -2
- package/dist/src/tui/sidebar.d.ts.map +1 -1
- package/dist/src/tui/sidebar.js +1 -1
- package/dist/src/tui/sidebar.js.map +1 -1
- package/dist/src/tui/slash-commands.d.ts +23 -0
- package/dist/src/tui/slash-commands.d.ts.map +1 -0
- package/dist/src/tui/slash-commands.js +79 -0
- package/dist/src/tui/slash-commands.js.map +1 -0
- package/dist/src/tui/starter-section.d.ts +99 -0
- package/dist/src/tui/starter-section.d.ts.map +1 -0
- package/dist/src/tui/starter-section.js +254 -0
- package/dist/src/tui/starter-section.js.map +1 -0
- package/dist/src/tui/status-controller.d.ts +94 -0
- package/dist/src/tui/status-controller.d.ts.map +1 -0
- package/dist/src/tui/status-controller.js +186 -0
- package/dist/src/tui/status-controller.js.map +1 -0
- package/dist/src/tui/step-renderer.d.ts +55 -0
- package/dist/src/tui/step-renderer.d.ts.map +1 -0
- package/dist/src/tui/step-renderer.js +246 -0
- package/dist/src/tui/step-renderer.js.map +1 -0
- package/dist/src/tui/transcript-writer.d.ts +63 -0
- package/dist/src/tui/transcript-writer.d.ts.map +1 -0
- package/dist/src/tui/transcript-writer.js +130 -0
- package/dist/src/tui/transcript-writer.js.map +1 -0
- package/dist/src/turn-runner/turn-runner.d.ts +77 -1
- package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
- package/dist/src/turn-runner/turn-runner.js +150 -12
- package/dist/src/turn-runner/turn-runner.js.map +1 -1
- package/dist/src/turn-runner/usage-accounting.d.ts +18 -1
- package/dist/src/turn-runner/usage-accounting.d.ts.map +1 -1
- package/dist/src/turn-runner/usage-accounting.js +48 -27
- package/dist/src/turn-runner/usage-accounting.js.map +1 -1
- package/dist/src/types/protocol.d.ts +48 -17
- package/dist/src/types/protocol.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/src/tui/app.js
CHANGED
|
@@ -1,2338 +1,225 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
//
|
|
16
|
-
// and external callers; the implementations live in focused leaf modules.
|
|
1
|
+
import { renderBootScreen } from "./boot-screen.js";
|
|
2
|
+
import { createTuiControllers } from "./controllers.js";
|
|
3
|
+
import { replayResumeHistory } from "./history-replay.js";
|
|
4
|
+
import { bootstrapInitialPrompt } from "./initial-prompt.js";
|
|
5
|
+
import { installKeyHandlers } from "./key-handlers.js";
|
|
6
|
+
import { buildLayout } from "./layout.js";
|
|
7
|
+
import { acquireRenderer, waitForRendererDestroy } from "./renderer-lifecycle.js";
|
|
8
|
+
import { bindSessionToUi } from "./session-subscription.js";
|
|
9
|
+
import { StarterSection } from "./starter-section.js";
|
|
10
|
+
import { StatusController } from "./status-controller.js";
|
|
11
|
+
import { StepRenderer } from "./step-renderer.js";
|
|
12
|
+
import { tryDispatchSlashCommand } from "./slash-commands.js";
|
|
13
|
+
import { COLORS } from "./theme.js";
|
|
14
|
+
import { TranscriptWriter } from "./transcript-writer.js";
|
|
15
|
+
// Historical `tui/app.js` re-exports kept for tests + external callers.
|
|
17
16
|
export { activeFileAutocompleteToken, activeSkillAutocompleteToken, commitActiveAnswer, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionHighlight, moveSkillAutocompleteSelection, NO_HIGHLIGHT, questionPickerAnswer, replaceFileAutocompleteToken, restoreSavedAnswer, skillAutocompleteMatches, } from "./autocomplete.js";
|
|
18
17
|
export { DUET_BANNER_LINES, historyDisplayBlocks, limitHistoryDisplayMessages, startupHeaderLines, } from "./history.js";
|
|
19
|
-
import { assembleToolBlock, formatToolBlock, truncateToolText } from "./tool-formatters.js";
|
|
20
|
-
const SKILL_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.skill;
|
|
21
|
-
const FILE_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.file;
|
|
22
|
-
const QUESTION_OPTION_LIMIT = AUTOCOMPLETE_LIMITS.questionOption;
|
|
23
18
|
/**
|
|
24
|
-
* Runs the interactive TUI for a session
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Differentiating Enter vs Shift+Enter requires the terminal to report
|
|
28
|
-
* modifier keys with Enter, which most terminals only do when the Kitty
|
|
29
|
-
* keyboard protocol is enabled. We opt into it via `useKittyKeyboard`.
|
|
19
|
+
* Runs the interactive TUI for a session and resolves with the terminal
|
|
20
|
+
* event that was active when the user exited.
|
|
30
21
|
*/
|
|
31
22
|
export async function runTui(input) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// Shift+Up/Down keyboard bindings below cover terminals or sessions
|
|
39
|
-
// where the wheel does not reach us (e.g. tmux without mouse mode).
|
|
40
|
-
//
|
|
41
|
-
// Bare Ctrl+C remains the always-exit keystroke (handled via
|
|
42
|
-
// exitOnCtrlC) so the convention every other interactive Linux/Windows
|
|
43
|
-
// terminal app follows still works here.
|
|
44
|
-
//
|
|
45
|
-
// Tests inject a `createTestRenderer` instance via `input.renderer`; in
|
|
46
|
-
// that mode we skip the production renderer construction and the
|
|
47
|
-
// `globalThis.window` restore that wraps it (the test renderer never
|
|
48
|
-
// installs the shim).
|
|
49
|
-
let renderer;
|
|
50
|
-
if (input.renderer) {
|
|
51
|
-
renderer = input.renderer;
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
|
55
|
-
renderer = await createCliRenderer({
|
|
56
|
-
exitOnCtrlC: true,
|
|
57
|
-
useMouse: true,
|
|
58
|
-
useKittyKeyboard: {},
|
|
59
|
-
targetFps: 60,
|
|
60
|
-
});
|
|
61
|
-
restoreWindowGlobal(previousWindow);
|
|
62
|
-
}
|
|
63
|
-
// Most recent drag-selected text. OpenTUI emits the `selection` event
|
|
64
|
-
// whenever a drag finishes; we cache the resulting string so /copy and
|
|
65
|
-
// the copy keystroke can prefer the user's actual highlight over the
|
|
66
|
-
// last-message heuristic, and so the bottom hint can advertise the
|
|
67
|
-
// copy keystroke only while it actually does something.
|
|
23
|
+
const renderer = await acquireRenderer(input.renderer);
|
|
24
|
+
const ui = buildLayout(renderer);
|
|
25
|
+
// Drag-selected text cached from the renderer's `selection` event so
|
|
26
|
+
// /copy + the copy keystroke prefer the user's highlight over the
|
|
27
|
+
// last-message heuristic, and the bottom hint advertises the keystroke
|
|
28
|
+
// only while a selection is live.
|
|
68
29
|
let lastSelectionText = "";
|
|
69
|
-
renderer.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// manually scrolled away. ScrollBoxRenderable flips `_hasManualScroll`
|
|
94
|
-
// the moment the user scrolls up, which pauses pinning until they
|
|
95
|
-
// return to the bottom — without this, new output yanks the viewport
|
|
96
|
-
// down while the user is reading history.
|
|
97
|
-
stickyScroll: true,
|
|
98
|
-
stickyStart: "bottom",
|
|
99
|
-
border: true,
|
|
100
|
-
borderColor: COLORS.border,
|
|
101
|
-
padding: 1,
|
|
102
|
-
});
|
|
103
|
-
// Status and hint chrome are excluded from drag-select so a highlight that
|
|
104
|
-
// sweeps the bottom of the screen does not pull the spinner / hint text
|
|
105
|
-
// into the clipboard alongside the transcript content the user wanted.
|
|
106
|
-
const status = new TextRenderable(renderer, {
|
|
107
|
-
content: "",
|
|
108
|
-
fg: COLORS.status,
|
|
109
|
-
height: 1,
|
|
110
|
-
flexShrink: 0,
|
|
111
|
-
selectable: false,
|
|
112
|
-
});
|
|
113
|
-
const hint = new TextRenderable(renderer, {
|
|
114
|
-
content: HINT_IDLE,
|
|
115
|
-
fg: COLORS.hint,
|
|
116
|
-
height: 1,
|
|
117
|
-
flexShrink: 0,
|
|
118
|
-
selectable: false,
|
|
119
|
-
});
|
|
120
|
-
const skillAutocompletePanel = new BoxRenderable(renderer, {
|
|
121
|
-
flexDirection: "column",
|
|
122
|
-
border: true,
|
|
123
|
-
borderColor: COLORS.border,
|
|
124
|
-
paddingLeft: 1,
|
|
125
|
-
paddingRight: 1,
|
|
126
|
-
flexShrink: 0,
|
|
127
|
-
});
|
|
128
|
-
skillAutocompletePanel.visible = false;
|
|
129
|
-
// Two ordered sections: built-in commands first, skills second. Each
|
|
130
|
-
// section has its own header row plus a fixed pool of item rows. Selection
|
|
131
|
-
// navigates the flat ordered list of visible items across both sections.
|
|
132
|
-
// Row height is assigned per render based on the wrapped description
|
|
133
|
-
// length so a one-line description doesn't leave an empty trailing line
|
|
134
|
-
// beneath the name. The renderer sets `height` whenever it writes
|
|
135
|
-
// `content`.
|
|
136
|
-
// Autocomplete and panel chrome are not part of the transcript content,
|
|
137
|
-
// so exclude them from drag-select to keep the clipboard focused on
|
|
138
|
-
// assistant/user messages.
|
|
139
|
-
const makeItemRow = () => {
|
|
140
|
-
const row = new TextRenderable(renderer, {
|
|
141
|
-
content: "",
|
|
142
|
-
fg: COLORS.hint,
|
|
143
|
-
flexShrink: 0,
|
|
144
|
-
selectable: false,
|
|
145
|
-
});
|
|
146
|
-
row.visible = false;
|
|
147
|
-
return row;
|
|
148
|
-
};
|
|
149
|
-
const makeHeaderRow = (label) => new TextRenderable(renderer, {
|
|
150
|
-
content: label,
|
|
151
|
-
fg: COLORS.status,
|
|
152
|
-
height: 1,
|
|
153
|
-
flexShrink: 0,
|
|
154
|
-
selectable: false,
|
|
155
|
-
});
|
|
156
|
-
const commandHeader = makeHeaderRow("commands");
|
|
157
|
-
const commandRows = Array.from({ length: BUILT_IN_SLASH_COMMANDS.length }, makeItemRow);
|
|
158
|
-
const skillHeader = makeHeaderRow("skills");
|
|
159
|
-
const skillRows = Array.from({ length: SKILL_AUTOCOMPLETE_LIMIT }, makeItemRow);
|
|
160
|
-
skillAutocompletePanel.add(commandHeader);
|
|
161
|
-
for (const row of commandRows)
|
|
162
|
-
skillAutocompletePanel.add(row);
|
|
163
|
-
skillAutocompletePanel.add(skillHeader);
|
|
164
|
-
for (const row of skillRows)
|
|
165
|
-
skillAutocompletePanel.add(row);
|
|
166
|
-
// The @-file picker mirrors the slash picker's structure so the renderer
|
|
167
|
-
// logic and key handling can stay parallel between the two pickers.
|
|
168
|
-
const fileAutocompletePanel = new BoxRenderable(renderer, {
|
|
169
|
-
flexDirection: "column",
|
|
170
|
-
border: true,
|
|
171
|
-
borderColor: COLORS.border,
|
|
172
|
-
paddingLeft: 1,
|
|
173
|
-
paddingRight: 1,
|
|
174
|
-
flexShrink: 0,
|
|
175
|
-
});
|
|
176
|
-
fileAutocompletePanel.visible = false;
|
|
177
|
-
const fileAutocompleteTitle = new TextRenderable(renderer, {
|
|
178
|
-
content: "files",
|
|
179
|
-
fg: COLORS.status,
|
|
180
|
-
height: 1,
|
|
181
|
-
flexShrink: 0,
|
|
182
|
-
selectable: false,
|
|
183
|
-
});
|
|
184
|
-
const fileAutocompleteRows = Array.from({ length: FILE_AUTOCOMPLETE_LIMIT }, () => {
|
|
185
|
-
const row = new TextRenderable(renderer, {
|
|
186
|
-
content: "",
|
|
187
|
-
fg: COLORS.hint,
|
|
188
|
-
height: 1,
|
|
189
|
-
flexShrink: 0,
|
|
190
|
-
selectable: false,
|
|
191
|
-
});
|
|
192
|
-
row.visible = false;
|
|
193
|
-
return row;
|
|
194
|
-
});
|
|
195
|
-
fileAutocompletePanel.add(fileAutocompleteTitle);
|
|
196
|
-
for (const row of fileAutocompleteRows) {
|
|
197
|
-
fileAutocompletePanel.add(row);
|
|
198
|
-
}
|
|
199
|
-
const questionPanel = new BoxRenderable(renderer, {
|
|
200
|
-
flexDirection: "column",
|
|
201
|
-
border: true,
|
|
202
|
-
borderColor: COLORS.border,
|
|
203
|
-
paddingLeft: 1,
|
|
204
|
-
paddingRight: 1,
|
|
205
|
-
flexShrink: 0,
|
|
206
|
-
});
|
|
207
|
-
questionPanel.visible = false;
|
|
208
|
-
const questionTitle = new TextRenderable(renderer, {
|
|
209
|
-
content: "question",
|
|
210
|
-
fg: COLORS.agent,
|
|
211
|
-
wrapMode: "word",
|
|
212
|
-
flexShrink: 0,
|
|
213
|
-
selectable: false,
|
|
214
|
-
});
|
|
215
|
-
const questionSpacer = new TextRenderable(renderer, {
|
|
216
|
-
content: "",
|
|
217
|
-
height: 1,
|
|
218
|
-
flexShrink: 0,
|
|
219
|
-
selectable: false,
|
|
220
|
-
});
|
|
221
|
-
const questionRows = Array.from({ length: QUESTION_OPTION_LIMIT }, () => {
|
|
222
|
-
const row = new TextRenderable(renderer, {
|
|
223
|
-
content: "",
|
|
224
|
-
fg: COLORS.hint,
|
|
225
|
-
wrapMode: "word",
|
|
226
|
-
flexShrink: 0,
|
|
227
|
-
selectable: false,
|
|
228
|
-
});
|
|
229
|
-
row.visible = false;
|
|
230
|
-
return row;
|
|
231
|
-
});
|
|
232
|
-
questionPanel.add(questionTitle);
|
|
233
|
-
questionPanel.add(questionSpacer);
|
|
234
|
-
for (const row of questionRows) {
|
|
235
|
-
questionPanel.add(row);
|
|
236
|
-
}
|
|
237
|
-
const inputBox = new BoxRenderable(renderer, {
|
|
238
|
-
flexDirection: "row",
|
|
239
|
-
border: true,
|
|
240
|
-
borderColor: COLORS.border,
|
|
241
|
-
paddingLeft: 1,
|
|
242
|
-
paddingRight: 1,
|
|
243
|
-
flexShrink: 0,
|
|
244
|
-
});
|
|
245
|
-
// The leading "> " sigil is decoration, not content; excluding it from
|
|
246
|
-
// selection means a drag that starts at the input row does not pull the
|
|
247
|
-
// sigil into the clipboard alongside the highlighted text.
|
|
248
|
-
const prompt = new TextRenderable(renderer, {
|
|
249
|
-
content: "> ",
|
|
250
|
-
fg: COLORS.user,
|
|
251
|
-
width: 2,
|
|
252
|
-
selectable: false,
|
|
253
|
-
});
|
|
254
|
-
// Textarea (rather than Input) so long messages soft-wrap visually. Enter
|
|
255
|
-
// is intercepted in onKeyDown below to submit instead of inserting a newline.
|
|
256
|
-
const inputField = new TextareaRenderable(renderer, {
|
|
257
|
-
placeholder: "Type a message and press Enter…",
|
|
258
|
-
flexGrow: 1,
|
|
259
|
-
minHeight: 1,
|
|
260
|
-
maxHeight: 10,
|
|
261
|
-
wrapMode: "word",
|
|
262
|
-
});
|
|
263
|
-
inputBox.add(prompt);
|
|
264
|
-
inputBox.add(inputField);
|
|
265
|
-
layout.add(transcript);
|
|
266
|
-
layout.add(status);
|
|
267
|
-
layout.add(hint);
|
|
268
|
-
layout.add(skillAutocompletePanel);
|
|
269
|
-
layout.add(fileAutocompletePanel);
|
|
270
|
-
layout.add(questionPanel);
|
|
271
|
-
layout.add(inputBox);
|
|
272
|
-
root.add(layout);
|
|
273
|
-
root.add(sidebar.view);
|
|
274
|
-
renderer.root.add(root);
|
|
275
|
-
inputField.focus();
|
|
276
|
-
// ---- transcript helpers ----------------------------------------------------
|
|
277
|
-
function appendLine(content, fg) {
|
|
278
|
-
if (!content)
|
|
279
|
-
return;
|
|
280
|
-
// Skip transcript writes once the renderer has been destroyed; see the
|
|
281
|
-
// matching guard in setStatus for the full rationale.
|
|
282
|
-
if (destroyed)
|
|
283
|
-
return;
|
|
284
|
-
try {
|
|
285
|
-
const line = new TextRenderable(renderer, { content, fg });
|
|
286
|
-
transcript.add(line);
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
if (isTextBufferDestroyedError(error)) {
|
|
290
|
-
destroyed = true;
|
|
291
|
-
stopWorkingTicker();
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
throw error;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
function appendBlock(label, body, fg) {
|
|
298
|
-
beginBlock();
|
|
299
|
-
const text = label ? `${label}\n${body}` : body;
|
|
300
|
-
for (const line of text.split("\n"))
|
|
301
|
-
appendLine(line, fg);
|
|
302
|
-
}
|
|
303
|
-
// Insert a blank separator before each new logical block so distinct steps
|
|
304
|
-
// (text, reasoning, tool calls, system messages) are easy to tell apart.
|
|
305
|
-
// The first block in the transcript skips the separator.
|
|
306
|
-
let hasRenderedAnyBlock = false;
|
|
307
|
-
function beginBlock() {
|
|
308
|
-
if (hasRenderedAnyBlock)
|
|
309
|
-
appendLine(" ", COLORS.hint);
|
|
310
|
-
hasRenderedAnyBlock = true;
|
|
311
|
-
}
|
|
312
|
-
function setStatus(text) {
|
|
313
|
-
// Renderer teardown destroys the underlying TextBuffer synchronously,
|
|
314
|
-
// but in-flight async work (session events, ticker callbacks, upgrade
|
|
315
|
-
// status pushes) may still drive chrome updates on the next microtask.
|
|
316
|
-
// The `destroyed` flag catches writes that arrive after our destroy
|
|
317
|
-
// handler runs; the try/catch backstops the window between OpenTUI
|
|
318
|
-
// tearing down child TextBuffers and emitting the `destroy` event,
|
|
319
|
-
// which is when the ticker callback in the stack trace lands.
|
|
320
|
-
if (destroyed)
|
|
321
|
-
return;
|
|
322
|
-
try {
|
|
323
|
-
status.content = text;
|
|
324
|
-
}
|
|
325
|
-
catch (error) {
|
|
326
|
-
if (isTextBufferDestroyedError(error)) {
|
|
327
|
-
destroyed = true;
|
|
328
|
-
stopWorkingTicker();
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
throw error;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
function setHint(running) {
|
|
335
|
-
if (destroyed)
|
|
336
|
-
return;
|
|
337
|
-
const base = running ? HINT_RUNNING : HINT_IDLE;
|
|
338
|
-
const segments = [];
|
|
339
|
-
if (pendingImages.length > 0)
|
|
340
|
-
segments.push(attachmentHint());
|
|
341
|
-
segments.push(base);
|
|
342
|
-
if (lastSelectionText.trim().length > 0)
|
|
343
|
-
segments.push(HINT_SELECTION_COPY);
|
|
344
|
-
try {
|
|
345
|
-
hint.content = segments.join(" · ");
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
348
|
-
if (isTextBufferDestroyedError(error)) {
|
|
349
|
-
destroyed = true;
|
|
350
|
-
stopWorkingTicker();
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
throw error;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
function attachmentHint() {
|
|
357
|
-
const n = pendingImages.length;
|
|
358
|
-
return n === 1 ? "📎 1 image attached" : `📎 ${n} images attached`;
|
|
359
|
-
}
|
|
360
|
-
// Single-channel hint refresh used by every input that affects what the
|
|
361
|
-
// bottom row should advertise (running state, attachments, selection).
|
|
362
|
-
function refreshHint() {
|
|
363
|
-
setHint(running);
|
|
364
|
-
}
|
|
365
|
-
// ---- runtime state ---------------------------------------------------------
|
|
366
|
-
let running = false;
|
|
367
|
-
// Image attachments collected via paste / `/image` and forwarded to the
|
|
368
|
-
// runner with the next prompt submission. Cleared after submit so each turn
|
|
369
|
-
// ships its own attachments without leaking into the next.
|
|
370
|
-
let pendingImages = [];
|
|
371
|
-
// Monotonic counter for the next `[Image #N]` placeholder. Reset alongside
|
|
372
|
-
// `pendingImages` so users see a fresh `#1` label after each submit.
|
|
373
|
-
let nextImageId = 1;
|
|
374
|
-
// Parallel record of user/agent message bodies driven by the same code
|
|
375
|
-
// paths that render them into the transcript. The `/copy` slash command
|
|
376
|
-
// and copy keystroke read from this log instead of trying to walk the
|
|
377
|
-
// ScrollBoxRenderable, which only stores presentation lines.
|
|
378
|
-
const transcriptLog = [];
|
|
379
|
-
function recordTranscriptEntry(kind, text) {
|
|
380
|
-
const trimmed = text.trim();
|
|
381
|
-
if (!trimmed)
|
|
382
|
-
return;
|
|
383
|
-
transcriptLog.push({ kind, text: trimmed });
|
|
384
|
-
}
|
|
385
|
-
let lastTerminal;
|
|
386
|
-
// Context bar + session cost are owned by `Session` (persisted beside
|
|
387
|
-
// `TurnState` in `state.json`), not `TurnRunner` / `TurnState`.
|
|
388
|
-
let activeTextStream;
|
|
389
|
-
let activeReasoningStream;
|
|
390
|
-
// Tool calls fire twice (running → completed/error). Track the rendered
|
|
391
|
-
// block by toolCallId so the second event updates the same line in place
|
|
392
|
-
// — swapping the spinner for a check/cross and appending the result —
|
|
393
|
-
// instead of pushing a separate block.
|
|
394
|
-
const activeToolBlocks = new Map();
|
|
395
|
-
// Tracks the wall-clock start of the current turn so the status line can
|
|
396
|
-
// surface a live "Ns" / "Nm Ns" elapsed counter while work is in flight.
|
|
397
|
-
let workingStartedAt;
|
|
398
|
-
let workingTicker;
|
|
399
|
-
// Flipped in the renderer `destroy` handler so post-teardown chrome
|
|
400
|
-
// mutations short-circuit instead of writing to destroyed TextBuffers.
|
|
401
|
-
let destroyed = false;
|
|
402
|
-
// Swapped out by memory events so the ticker can keep refreshing while the
|
|
403
|
-
// human-readable phase ("recalling memories…", etc.) stays accurate.
|
|
404
|
-
let workingMessage = "working…";
|
|
405
|
-
// Queued follow-up count surfaced inline on the status line so the user can
|
|
406
|
-
// see at a glance how many prompts will run after the current turn settles.
|
|
407
|
-
let queuedFollowUps = 0;
|
|
408
|
-
function formatElapsed(ms) {
|
|
409
|
-
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
410
|
-
if (totalSeconds < 60)
|
|
411
|
-
return `${totalSeconds}s`;
|
|
412
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
413
|
-
const seconds = totalSeconds % 60;
|
|
414
|
-
return `${minutes}m ${seconds}s`;
|
|
415
|
-
}
|
|
416
|
-
function refreshWorkingStatus() {
|
|
417
|
-
if (destroyed)
|
|
418
|
-
return;
|
|
419
|
-
refreshActiveToolBlocks();
|
|
420
|
-
if (workingStartedAt === undefined) {
|
|
421
|
-
setStatus(queuedFollowUps > 0 ? `queued follow-ups: ${queuedFollowUps}` : "");
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
const elapsed = formatElapsed(Date.now() - workingStartedAt);
|
|
425
|
-
const queued = queuedFollowUps > 0 ? ` · queued follow-ups: ${queuedFollowUps}` : "";
|
|
426
|
-
setStatus(`● ${workingMessage} (${elapsed})${queued}`);
|
|
427
|
-
}
|
|
428
|
-
function refreshActiveToolBlocks() {
|
|
429
|
-
if (activeToolBlocks.size === 0)
|
|
430
|
-
return;
|
|
431
|
-
const columns = toolBlockColumns();
|
|
432
|
-
for (const block of activeToolBlocks.values()) {
|
|
433
|
-
if (block.startedAt === undefined)
|
|
434
|
-
continue;
|
|
435
|
-
block.line.content = assembleToolBlock({ header: block.header, body: block.body || undefined }, runningMarker(Date.now() - block.startedAt), { columns });
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Spinner marker for an in-flight tool call. Hides the elapsed counter for
|
|
440
|
-
* sub-second runs so a transcript of fast tools is not littered with "0s".
|
|
441
|
-
*/
|
|
442
|
-
function runningMarker(elapsedMs) {
|
|
443
|
-
return elapsedMs >= 1000 ? `⏳ ${formatElapsed(elapsedMs)}` : "⏳";
|
|
444
|
-
}
|
|
445
|
-
function startWorkingTicker() {
|
|
446
|
-
if (workingTicker !== undefined)
|
|
447
|
-
return;
|
|
448
|
-
workingTicker = setInterval(refreshWorkingStatus, 1000);
|
|
449
|
-
}
|
|
450
|
-
function stopWorkingTicker() {
|
|
451
|
-
if (workingTicker !== undefined) {
|
|
452
|
-
clearInterval(workingTicker);
|
|
453
|
-
workingTicker = undefined;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
function markRunning() {
|
|
457
|
-
running = true;
|
|
458
|
-
setHint(true);
|
|
459
|
-
workingMessage = "working…";
|
|
460
|
-
workingStartedAt = Date.now();
|
|
461
|
-
refreshWorkingStatus();
|
|
462
|
-
startWorkingTicker();
|
|
463
|
-
}
|
|
464
|
-
function markIdle() {
|
|
465
|
-
running = false;
|
|
466
|
-
setHint(false);
|
|
467
|
-
stopWorkingTicker();
|
|
468
|
-
workingStartedAt = undefined;
|
|
469
|
-
workingMessage = "working…";
|
|
470
|
-
refreshWorkingStatus();
|
|
471
|
-
}
|
|
472
|
-
function reportError(error) {
|
|
30
|
+
const transcriptWriter = new TranscriptWriter(renderer, ui.transcript, {
|
|
31
|
+
getLastSelectionText: () => lastSelectionText,
|
|
32
|
+
onBufferDestroyed: () => statusController.shutdown(),
|
|
33
|
+
});
|
|
34
|
+
const appendLine = (content, fg) => transcriptWriter.appendLine(content, fg);
|
|
35
|
+
const appendBlock = (label, body, fg) => transcriptWriter.appendBlock(label, body, fg);
|
|
36
|
+
const recordTranscriptEntry = (kind, text) => transcriptWriter.recordEntry(kind, text);
|
|
37
|
+
const statusController = new StatusController({
|
|
38
|
+
renderer,
|
|
39
|
+
status: ui.status,
|
|
40
|
+
hint: ui.hint,
|
|
41
|
+
refreshActiveToolBlocks: () => stepRenderer.refreshActiveToolBlocks(),
|
|
42
|
+
});
|
|
43
|
+
const stepRenderer = new StepRenderer({
|
|
44
|
+
renderer,
|
|
45
|
+
transcript: ui.transcript,
|
|
46
|
+
transcriptWriter,
|
|
47
|
+
statusController,
|
|
48
|
+
onStepStart: () => {
|
|
49
|
+
if (questionPicker.isOpen())
|
|
50
|
+
questionPicker.hide();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const reportError = (error) => {
|
|
473
54
|
appendBlock("[error]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
474
|
-
markIdle();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const unsubscribe = input.session.subscribe((event) => {
|
|
494
|
-
refreshSidebar();
|
|
495
|
-
if (event.type === "step") {
|
|
496
|
-
renderStep(event.step);
|
|
497
|
-
}
|
|
498
|
-
else if (event.type === "follow_up_queue") {
|
|
499
|
-
// Sidebar already refreshed from session state above; mirror the count
|
|
500
|
-
// into the working-status line so the user can see queued prompts at a
|
|
501
|
-
// glance without scrolling the sidebar.
|
|
502
|
-
queuedFollowUps = event.prompts.length;
|
|
503
|
-
refreshWorkingStatus();
|
|
504
|
-
}
|
|
505
|
-
else if (event.type === "todos") {
|
|
506
|
-
// Sidebar refresh covers the visual update; nothing else to do here.
|
|
507
|
-
}
|
|
508
|
-
else if (event.type === "memory") {
|
|
509
|
-
renderMemoryStatus(event);
|
|
510
|
-
}
|
|
511
|
-
else if (event.type === "system") {
|
|
512
|
-
appendBlock("[system]", event.message, COLORS.system);
|
|
513
|
-
if (event.level === "error")
|
|
514
|
-
markIdle();
|
|
515
|
-
}
|
|
516
|
-
else if (event.type === "ask") {
|
|
517
|
-
appendBlock("[question]", event.questions.map((q) => q.question).join("\n"), COLORS.system);
|
|
518
|
-
showQuestions(event.questions);
|
|
519
|
-
renderUsage(event.usage);
|
|
520
|
-
renderTurnElapsed();
|
|
521
|
-
lastTerminal = event;
|
|
522
|
-
markIdle();
|
|
523
|
-
}
|
|
524
|
-
else if (event.type === "complete") {
|
|
525
|
-
if (event.error) {
|
|
526
|
-
appendBlock("[error]", event.error, COLORS.error);
|
|
527
|
-
}
|
|
528
|
-
renderUsage(event.usage);
|
|
529
|
-
renderTurnElapsed();
|
|
530
|
-
lastTerminal = event;
|
|
531
|
-
markIdle();
|
|
532
|
-
}
|
|
533
|
-
else if (event.type === "interrupted") {
|
|
534
|
-
appendLine("[interrupted]", COLORS.system);
|
|
535
|
-
renderUsage(event.usage);
|
|
536
|
-
renderTurnElapsed();
|
|
537
|
-
lastTerminal = event;
|
|
538
|
-
markIdle();
|
|
539
|
-
}
|
|
540
|
-
else if (event.type === "sleep") {
|
|
541
|
-
renderUsage(event.usage);
|
|
542
|
-
renderSleeping(event.wakeAt);
|
|
543
|
-
lastTerminal = event;
|
|
544
|
-
markIdle();
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
function renderSetupIntro(skills, agentFiles) {
|
|
548
|
-
// Compact 3-row wordmark. The full DUET_BANNER_LINES is ~6 rows tall
|
|
549
|
-
// and pushed the starter list off-screen on small terminals; this one
|
|
550
|
-
// keeps the brand mark visible while leaving room for the ice-break
|
|
551
|
-
// prompts to land above the fold.
|
|
552
|
-
for (const line of DUET_BANNER_LINES_COMPACT)
|
|
553
|
-
appendLine(line, COLORS.status);
|
|
554
|
-
appendLine(" ", COLORS.hint);
|
|
555
|
-
appendLine(" ", COLORS.hint);
|
|
556
|
-
// One-line header. Keeps cwd/model context visible without burning
|
|
557
|
-
// another five rows. Provenance (env/file source) is intentionally
|
|
558
|
-
// dropped here — surface it via /whoami later.
|
|
559
|
-
appendLine(formatBootHeader(input), COLORS.status);
|
|
560
|
-
if (input.upgradeStatus$) {
|
|
561
|
-
// Lazy construction on the first status that has human-readable text.
|
|
562
|
-
// Statuses without text (current/locked/skipped) skip the constructor
|
|
563
|
-
// entirely; constructing eagerly would allocate a native text buffer
|
|
564
|
-
// against the renderer that we'd never `destroy()` on the silent path.
|
|
565
|
-
//
|
|
566
|
-
// `subscribe()` replays the latest status synchronously, so the handler
|
|
567
|
-
// runs before `subscribe()` returns its unsubscribe handle. We set a
|
|
568
|
-
// `done` flag from inside the handler and tear down after `subscribe()`
|
|
569
|
-
// returns; subsequent (async) terminal statuses unsubscribe inline via
|
|
570
|
-
// the real handle.
|
|
571
|
-
let upgradeLine;
|
|
572
|
-
let done = false;
|
|
573
|
-
let unsubscribe = () => { };
|
|
574
|
-
const handle = (status) => {
|
|
575
|
-
const text = describeUpgradeStatus(input.packageName, status);
|
|
576
|
-
if (!text) {
|
|
577
|
-
if (upgradeLine) {
|
|
578
|
-
transcript.remove(upgradeLine.id);
|
|
579
|
-
upgradeLine.destroy();
|
|
580
|
-
upgradeLine = undefined;
|
|
581
|
-
}
|
|
582
|
-
// Terminal statuses with no human-readable form (current, locked,
|
|
583
|
-
// skipped) close the subscription so we stop reacting.
|
|
584
|
-
if (status.kind !== "checking") {
|
|
585
|
-
done = true;
|
|
586
|
-
unsubscribe();
|
|
587
|
-
}
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
const fg = status.kind === "failed" ? COLORS.error : COLORS.system;
|
|
591
|
-
if (!upgradeLine) {
|
|
592
|
-
upgradeLine = new TextRenderable(renderer, { content: `[update] ${text}`, fg });
|
|
593
|
-
transcript.add(upgradeLine);
|
|
594
|
-
}
|
|
595
|
-
else {
|
|
596
|
-
upgradeLine.content = `[update] ${text}`;
|
|
597
|
-
upgradeLine.fg = fg;
|
|
598
|
-
}
|
|
599
|
-
if (status.kind === "upgraded" || status.kind === "failed") {
|
|
600
|
-
done = true;
|
|
601
|
-
unsubscribe();
|
|
602
|
-
}
|
|
603
|
-
};
|
|
604
|
-
unsubscribe = input.upgradeStatus$.subscribe(handle);
|
|
605
|
-
if (done)
|
|
606
|
-
unsubscribe();
|
|
607
|
-
}
|
|
608
|
-
// Only mention agent files when one is actually loaded; "[agent file]
|
|
609
|
-
// none" is noise on every empty boot.
|
|
610
|
-
if (agentFiles.length > 0) {
|
|
611
|
-
appendLine(`[agent file] ${agentFiles.map((file) => file.name).join(", ")}`, COLORS.hint);
|
|
612
|
-
}
|
|
613
|
-
// --resume <id> launches skip the starter menu entirely: the user has
|
|
614
|
-
// explicitly asked to drop back into a known conversation, so showing
|
|
615
|
-
// "what should we work on today?" before replaying history is noise.
|
|
616
|
-
// Resumed sessions with zero prior messages still skip the menu so the
|
|
617
|
-
// contract is "any --resume" not "--resume with content".
|
|
618
|
-
if (!input.isResume) {
|
|
619
|
-
renderStarters(skills);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
function formatBootHeader(headerInput) {
|
|
623
|
-
const cwdLabel = shortenCwd(headerInput.workDir);
|
|
624
|
-
return `DUET AGENT v${headerInput.packageVersion} · ${cwdLabel} · ${headerInput.modelName} + ${headerInput.memoryModelName}`;
|
|
625
|
-
}
|
|
626
|
-
function shortenCwd(cwd) {
|
|
627
|
-
const home = homedir();
|
|
628
|
-
if (cwd === home)
|
|
629
|
-
return "~";
|
|
630
|
-
if (cwd.startsWith(`${home}/`))
|
|
631
|
-
return `~${cwd.slice(home.length)}`;
|
|
632
|
-
return cwd;
|
|
633
|
-
}
|
|
634
|
-
const starterEntries = [];
|
|
635
|
-
// Lines we render for the starter section so we can repaint highlights
|
|
636
|
-
// and tear them all down on dismissal. Includes the headline, numbered
|
|
637
|
-
// rows, the optional resume row, and the two trailing hint rows.
|
|
638
|
-
const starterRefs = [];
|
|
639
|
-
// Indexes within `starterRefs` that correspond to numbered rows; used
|
|
640
|
-
// to repaint highlight on arrow / digit navigation.
|
|
641
|
-
const starterRowIndexes = [];
|
|
642
|
-
let highlightedStarterIndex = 0;
|
|
643
|
-
let startersVisible = false;
|
|
644
|
-
// Once the user actually commits a prompt (typed Enter, picked a starter,
|
|
645
|
-
// hit a slash command), the chrome is gone for the rest of the session.
|
|
646
|
-
// Until then `dismissStarters()` is a reversible hide that
|
|
647
|
-
// `syncStarterVisibility()` can restore when the composer empties again.
|
|
648
|
-
let startersPermanentlyDismissed = false;
|
|
649
|
-
// Cached for `mountStarterChrome()` so it can re-render the trailing
|
|
650
|
-
// "✨ N skills · /help" line without re-running selectStarters.
|
|
651
|
-
let starterSkillCount = 0;
|
|
652
|
-
function startersAreVisible() {
|
|
653
|
-
return startersVisible;
|
|
654
|
-
}
|
|
655
|
-
function renderStarters(skills) {
|
|
656
|
-
// Read recent sessions off disk synchronously. The helper swallows fs
|
|
657
|
-
// errors and returns an empty list; a missing/empty ~/.duet/sessions
|
|
658
|
-
// directory is the common first-boot case.
|
|
659
|
-
const recentSessions = listRecentSessions({
|
|
660
|
-
excludeId: input.sessionId,
|
|
661
|
-
limit: 4,
|
|
662
|
-
});
|
|
663
|
-
const result = selectStarters({
|
|
664
|
-
cwd: input.workDir,
|
|
665
|
-
sessionHistory: input.history,
|
|
666
|
-
recentSessions,
|
|
667
|
-
});
|
|
668
|
-
// Selectable rows in render order. Recent sessions lead so returning
|
|
669
|
-
// users hit "pick up the thread" first; new users see the cwd starters
|
|
670
|
-
// under the original "what should we work on today?" headline.
|
|
671
|
-
const ordered = orderedSelectableStarters(result);
|
|
672
|
-
starterEntries.length = 0;
|
|
673
|
-
for (const row of ordered) {
|
|
674
|
-
if (row.kind === "recent" && row.sessionId !== undefined) {
|
|
675
|
-
starterEntries.push({
|
|
676
|
-
kind: "recent",
|
|
677
|
-
label: row.label,
|
|
678
|
-
submit: row.submit,
|
|
679
|
-
sessionId: row.sessionId,
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
else {
|
|
683
|
-
starterEntries.push({ kind: "prompt", label: row.label, submit: row.submit });
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
starterSkillCount = skills.length;
|
|
687
|
-
highlightedStarterIndex = 0;
|
|
688
|
-
mountStarterChrome();
|
|
689
|
-
}
|
|
690
|
-
// Paint the chrome (section headers, numbered rows, hint footer) from the
|
|
691
|
-
// current `starterEntries` + `highlightedStarterIndex`. Called on first
|
|
692
|
-
// boot and on every backspace-to-empty restoration. Idempotent: bails if
|
|
693
|
-
// the chrome is already mounted or no entries exist.
|
|
694
|
-
function mountStarterChrome() {
|
|
695
|
-
if (startersVisible || starterEntries.length === 0)
|
|
696
|
-
return;
|
|
697
|
-
const recentEntries = starterEntries.filter((entry) => entry.kind === "recent");
|
|
698
|
-
const promptEntries = starterEntries.filter((entry) => entry.kind === "prompt");
|
|
699
|
-
const hasRecent = recentEntries.length > 0;
|
|
700
|
-
// Every line we render here — spacers included — goes through `addLine`
|
|
701
|
-
// and gets pushed to `starterRefs`. `dismissStarters()` iterates that
|
|
702
|
-
// list to destroy refs; spacers added via fire-and-forget `appendLine`
|
|
703
|
-
// would leak and accumulate above the next mount on each type →
|
|
704
|
-
// backspace cycle, pushing the chrome lower every time.
|
|
705
|
-
const spacer = () => {
|
|
706
|
-
starterRefs.push(addLine(" ", COLORS.hint));
|
|
707
|
-
};
|
|
708
|
-
starterRowIndexes.length = 0;
|
|
709
|
-
spacer();
|
|
710
|
-
if (hasRecent) {
|
|
711
|
-
starterRefs.push(addLine("pick up the thread", COLORS.agent));
|
|
712
|
-
spacer();
|
|
713
|
-
for (let i = 0; i < starterEntries.length; i += 1) {
|
|
714
|
-
if (starterEntries[i].kind !== "recent")
|
|
715
|
-
continue;
|
|
716
|
-
const ref = addLine(formatStarterRow(i, false), COLORS.hint);
|
|
717
|
-
starterRowIndexes.push(starterRefs.length);
|
|
718
|
-
starterRefs.push(ref);
|
|
719
|
-
}
|
|
720
|
-
if (promptEntries.length > 0) {
|
|
721
|
-
spacer();
|
|
722
|
-
starterRefs.push(addLine("or start something new", COLORS.agent));
|
|
723
|
-
spacer();
|
|
724
|
-
for (let i = 0; i < starterEntries.length; i += 1) {
|
|
725
|
-
if (starterEntries[i].kind !== "prompt")
|
|
726
|
-
continue;
|
|
727
|
-
const ref = addLine(formatStarterRow(i, false), COLORS.hint);
|
|
728
|
-
starterRowIndexes.push(starterRefs.length);
|
|
729
|
-
starterRefs.push(ref);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
else {
|
|
734
|
-
starterRefs.push(addLine("what should we work on today?", COLORS.agent));
|
|
735
|
-
spacer();
|
|
736
|
-
for (let i = 0; i < starterEntries.length; i += 1) {
|
|
737
|
-
const ref = addLine(formatStarterRow(i, false), COLORS.hint);
|
|
738
|
-
starterRowIndexes.push(starterRefs.length);
|
|
739
|
-
starterRefs.push(ref);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
spacer();
|
|
743
|
-
starterRefs.push(addLine("type a number to run, ↑/↓ to highlight, or just start typing.", COLORS.hint));
|
|
744
|
-
starterRefs.push(addLine(`✦ ${starterSkillCount} skill${starterSkillCount === 1 ? "" : "s"} · /help`, COLORS.hint));
|
|
745
|
-
startersVisible = true;
|
|
746
|
-
if (highlightedStarterIndex >= starterEntries.length)
|
|
747
|
-
highlightedStarterIndex = 0;
|
|
748
|
-
paintStarterHighlight();
|
|
749
|
-
}
|
|
750
|
-
function addLine(content, fg) {
|
|
751
|
-
const line = new TextRenderable(renderer, { content: content || " ", fg });
|
|
752
|
-
transcript.add(line);
|
|
753
|
-
return line;
|
|
754
|
-
}
|
|
755
|
-
function formatStarterRow(index, highlighted) {
|
|
756
|
-
const entry = starterEntries[index];
|
|
757
|
-
const text = entry?.label ?? "";
|
|
758
|
-
const number = index + 1;
|
|
759
|
-
const arrow = highlighted ? "▶" : "→";
|
|
760
|
-
const numberCell = highlighted ? `[${number}]` : ` ${number} `;
|
|
761
|
-
return ` ${numberCell} ${arrow} ${text}`;
|
|
762
|
-
}
|
|
763
|
-
function paintStarterHighlight() {
|
|
764
|
-
for (let i = 0; i < starterRowIndexes.length; i += 1) {
|
|
765
|
-
const ref = starterRefs[starterRowIndexes[i]];
|
|
766
|
-
const isHighlighted = i === highlightedStarterIndex;
|
|
767
|
-
ref.content = formatStarterRow(i, isHighlighted);
|
|
768
|
-
ref.fg = isHighlighted ? COLORS.user : COLORS.hint;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
function moveStarterHighlight(delta) {
|
|
772
|
-
if (!startersVisible || starterEntries.length === 0)
|
|
773
|
-
return;
|
|
774
|
-
const next = (highlightedStarterIndex + delta + starterEntries.length) % starterEntries.length;
|
|
775
|
-
highlightedStarterIndex = next;
|
|
776
|
-
paintStarterHighlight();
|
|
777
|
-
}
|
|
778
|
-
function jumpStarterHighlight(targetIndex) {
|
|
779
|
-
if (!startersVisible)
|
|
780
|
-
return false;
|
|
781
|
-
if (targetIndex < 0 || targetIndex >= starterEntries.length)
|
|
782
|
-
return false;
|
|
783
|
-
highlightedStarterIndex = targetIndex;
|
|
784
|
-
paintStarterHighlight();
|
|
785
|
-
return true;
|
|
786
|
-
}
|
|
787
|
-
// Transient hide. Tears down the rendered refs but keeps `starterEntries`
|
|
788
|
-
// and `highlightedStarterIndex` so `mountStarterChrome()` can restore the
|
|
789
|
-
// exact same picker if the user backspaces the composer empty again.
|
|
790
|
-
function dismissStarters() {
|
|
791
|
-
if (!startersVisible && starterRefs.length === 0)
|
|
792
|
-
return;
|
|
793
|
-
for (const ref of starterRefs) {
|
|
794
|
-
transcript.remove(ref.id);
|
|
795
|
-
ref.destroy();
|
|
796
|
-
}
|
|
797
|
-
starterRefs.length = 0;
|
|
798
|
-
starterRowIndexes.length = 0;
|
|
799
|
-
startersVisible = false;
|
|
800
|
-
}
|
|
801
|
-
// Permanent destruction: called when the user commits a prompt. After
|
|
802
|
-
// this the chrome never comes back, even on backspace-to-empty.
|
|
803
|
-
function destroyStartersPermanently() {
|
|
804
|
-
dismissStarters();
|
|
805
|
-
starterEntries.length = 0;
|
|
806
|
-
highlightedStarterIndex = 0;
|
|
807
|
-
startersPermanentlyDismissed = true;
|
|
808
|
-
}
|
|
809
|
-
// Toggle hook called from `inputField.onContentChange`. Hides the
|
|
810
|
-
// chrome as soon as the user starts composing, brings it back if they
|
|
811
|
-
// backspace the composer empty (but only until they actually submit
|
|
812
|
-
// something — then `startersPermanentlyDismissed` latches).
|
|
813
|
-
function syncStarterVisibility() {
|
|
814
|
-
if (startersPermanentlyDismissed)
|
|
815
|
-
return;
|
|
816
|
-
if (starterEntries.length === 0)
|
|
817
|
-
return;
|
|
818
|
-
const empty = inputField.plainText.length === 0;
|
|
819
|
-
if (!empty && startersVisible) {
|
|
820
|
-
dismissStarters();
|
|
821
|
-
}
|
|
822
|
-
else if (empty && !startersVisible) {
|
|
823
|
-
mountStarterChrome();
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
function submitHighlightedStarter() {
|
|
827
|
-
if (!startersVisible)
|
|
828
|
-
return false;
|
|
829
|
-
const entry = starterEntries[highlightedStarterIndex];
|
|
830
|
-
if (!entry)
|
|
831
|
-
return false;
|
|
832
|
-
destroyStartersPermanently();
|
|
833
|
-
// Recent-session rows: when the host wires `onResumeRequest`, signal
|
|
834
|
-
// the outer dispatcher to swap sessions and tear down this renderer.
|
|
835
|
-
// The dispatcher disposes the placeholder, calls `manager.resume(id)`
|
|
836
|
-
// + `hydrate()` + `start()`, and re-enters `runTui` with the hydrated
|
|
837
|
-
// session + its full message history. End user lands on the same
|
|
838
|
-
// session id, same agent context, same transcript replayed inline.
|
|
839
|
-
//
|
|
840
|
-
// When no callback is wired (tests, playground), fall back to the
|
|
841
|
-
// legacy shortcut: re-submit the prior prompt in the current session.
|
|
842
|
-
// Agent has no context; the user lands on the same task with one
|
|
843
|
-
// keystroke instead of typing it again.
|
|
844
|
-
if (entry.kind === "recent" && input.onResumeRequest) {
|
|
845
|
-
input.onResumeRequest(entry.sessionId);
|
|
846
|
-
renderer.destroy();
|
|
847
|
-
return true;
|
|
848
|
-
}
|
|
849
|
-
submit(entry.submit);
|
|
850
|
-
return true;
|
|
851
|
-
}
|
|
852
|
-
function renderUsage(usage) {
|
|
853
|
-
if (!usage)
|
|
854
|
-
return;
|
|
855
|
-
// Cumulative cost is updated on the session when the terminal event is
|
|
856
|
-
// handled; sidebar refreshes from `getSessionCostUsd()` via `refreshSidebar`.
|
|
857
|
-
// Tokens stay terse (just in/out) since the cost breakdown below is
|
|
858
|
-
// where the cache wins actually matter. Cost is split across all four
|
|
859
|
-
// buckets (in / out / cache read / cache write) so prompt-cache hits and
|
|
860
|
-
// writes are visible at a glance; zero buckets collapse out.
|
|
861
|
-
const tokens = `Tokens: in=${usage.input} out=${usage.output}`;
|
|
862
|
-
const costParts = [
|
|
863
|
-
["in", usage.cost.input],
|
|
864
|
-
["out", usage.cost.output],
|
|
865
|
-
["cr", usage.cost.cacheRead],
|
|
866
|
-
["cw", usage.cost.cacheWrite],
|
|
867
|
-
]
|
|
868
|
-
.filter(([, value]) => value > 0)
|
|
869
|
-
.map(([label, value]) => `${label}=$${value.toFixed(4)}`);
|
|
870
|
-
const cost = usage.cost.total === 0
|
|
871
|
-
? ""
|
|
872
|
-
: ` · Cost: $${usage.cost.total.toFixed(4)}${costParts.length > 1 ? ` (${costParts.join(" ")})` : ""}`;
|
|
873
|
-
appendLine(`[usage] ${tokens}${cost}`, COLORS.hint);
|
|
874
|
-
}
|
|
875
|
-
function renderTurnElapsed() {
|
|
876
|
-
if (workingStartedAt === undefined)
|
|
877
|
-
return;
|
|
878
|
-
appendLine(`● turn finished in ${formatElapsed(Date.now() - workingStartedAt)}`, COLORS.status);
|
|
879
|
-
}
|
|
880
|
-
// Sleep terminals replace the usual "turn finished" line because the session
|
|
881
|
-
// is going back to sleep, not wrapping up. When a turn ran before the sleep
|
|
882
|
-
// (e.g. an injected prompt while waiting on a state machine), include its
|
|
883
|
-
// duration so the user can still see how long the work took.
|
|
884
|
-
function renderSleeping(wakeAt) {
|
|
885
|
-
const wakeLabel = new Date(wakeAt).toLocaleTimeString();
|
|
886
|
-
const turnDuration = workingStartedAt !== undefined
|
|
887
|
-
? ` · turn took ${formatElapsed(Date.now() - workingStartedAt)}`
|
|
888
|
-
: "";
|
|
889
|
-
appendLine(`● sleeping until ${wakeLabel}${turnDuration}`, COLORS.status);
|
|
890
|
-
}
|
|
891
|
-
function renderStep(step) {
|
|
892
|
-
if (questionPickerIsOpen())
|
|
893
|
-
hideQuestions();
|
|
894
|
-
if (step.type === "text_delta") {
|
|
895
|
-
activeTextStream = renderDelta(activeTextStream, null, step.delta, COLORS.agent);
|
|
896
|
-
}
|
|
897
|
-
else if (step.type === "reasoning_delta") {
|
|
898
|
-
activeReasoningStream = renderDelta(activeReasoningStream, "[reasoning]", step.delta, COLORS.reasoning, true);
|
|
899
|
-
}
|
|
900
|
-
else if (step.type === "text") {
|
|
901
|
-
recordTranscriptEntry("agent", step.text);
|
|
902
|
-
if (activeTextStream) {
|
|
903
|
-
finalizeDelta(activeTextStream, step.text);
|
|
904
|
-
activeTextStream = undefined;
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
appendBlock(null, step.text, COLORS.agent);
|
|
908
|
-
}
|
|
909
|
-
else if (step.type === "reasoning") {
|
|
910
|
-
const trimmed = step.text.trim();
|
|
911
|
-
if (activeReasoningStream) {
|
|
912
|
-
finalizeDelta(activeReasoningStream, trimmed);
|
|
913
|
-
activeReasoningStream = undefined;
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
if (trimmed)
|
|
917
|
-
appendBlock("[reasoning]", truncateToolText(trimmed), COLORS.reasoning);
|
|
918
|
-
}
|
|
919
|
-
else if (step.type === "tool_call") {
|
|
920
|
-
renderToolCall(step);
|
|
921
|
-
}
|
|
922
|
-
else if (step.type === "system") {
|
|
923
|
-
appendBlock("[system]", step.message, COLORS.system);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
function renderDelta(block, label, delta, fg, truncate = false) {
|
|
927
|
-
const next = block ??
|
|
928
|
-
{
|
|
929
|
-
line: new TextRenderable(renderer, { content: "", fg }),
|
|
930
|
-
label,
|
|
931
|
-
body: "",
|
|
932
|
-
truncate,
|
|
933
|
-
};
|
|
934
|
-
if (!block) {
|
|
935
|
-
beginBlock();
|
|
936
|
-
transcript.add(next.line);
|
|
937
|
-
}
|
|
938
|
-
next.body += delta;
|
|
939
|
-
updateStreamingBlock(next);
|
|
940
|
-
return next;
|
|
941
|
-
}
|
|
942
|
-
// Render a tool call as a single, self-updating block. The first event
|
|
943
|
-
// (`status: "running"`) creates the block with a spinner; the second event
|
|
944
|
-
// (`completed` or `error`) replaces the spinner with ✓/✗ and appends the
|
|
945
|
-
// truncated result inline so the call and its outcome stay visually paired.
|
|
946
|
-
// Per-tool formatters in `tool-formatters.ts` decide the header text and
|
|
947
|
-
// whether the call should appear in the transcript at all (e.g.
|
|
948
|
-
// ask_user_question hides itself live and lets the `ask` terminal event
|
|
949
|
-
// own the question display).
|
|
950
|
-
// Width budget for a tool block: terminal width minus the fixed sidebar
|
|
951
|
-
// column and a small fudge for borders/padding. Recomputed per render so a
|
|
952
|
-
// resize after a tool block lands updates new blocks; existing blocks keep
|
|
953
|
-
// the width they were rendered at, which is acceptable since the renderer
|
|
954
|
-
// would otherwise re-wrap and could exceed the row cap.
|
|
955
|
-
function toolBlockColumns() {
|
|
956
|
-
const transcriptColumnPadding = 4;
|
|
957
|
-
return Math.max(20, renderer.terminalWidth - SIDEBAR_WIDTH - transcriptColumnPadding);
|
|
958
|
-
}
|
|
959
|
-
function renderToolCall(step) {
|
|
960
|
-
const existing = activeToolBlocks.get(step.toolCallId);
|
|
961
|
-
if (existing) {
|
|
962
|
-
finalizeToolCall(step, existing);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
const isLive = step.status === "running" || step.status === "pending";
|
|
966
|
-
const formatStatus = isLive ? "running" : step.status === "error" ? "error" : "completed";
|
|
967
|
-
const formatted = formatToolBlock({
|
|
968
|
-
toolName: step.toolName,
|
|
969
|
-
status: formatStatus,
|
|
970
|
-
input: step.input,
|
|
971
|
-
output: step.output,
|
|
972
|
-
mode: "live",
|
|
973
|
-
});
|
|
974
|
-
if (formatted.hidden)
|
|
975
|
-
return;
|
|
976
|
-
const startedAt = isLive ? Date.now() : undefined;
|
|
977
|
-
const marker = "⏳";
|
|
978
|
-
const fg = step.status === "error" ? COLORS.error : COLORS.tool;
|
|
979
|
-
const columns = toolBlockColumns();
|
|
980
|
-
const line = new TextRenderable(renderer, {
|
|
981
|
-
content: assembleToolBlock(formatted, marker, { columns }),
|
|
982
|
-
fg,
|
|
983
|
-
});
|
|
984
|
-
beginBlock();
|
|
985
|
-
transcript.add(line);
|
|
986
|
-
const block = {
|
|
987
|
-
line,
|
|
988
|
-
header: formatted.header,
|
|
989
|
-
body: formatted.body ?? "",
|
|
990
|
-
input: step.input,
|
|
991
|
-
startedAt,
|
|
992
|
-
};
|
|
993
|
-
activeToolBlocks.set(step.toolCallId, block);
|
|
994
|
-
// The same event may already carry a terminal status (cached/replayed
|
|
995
|
-
// history). Fall through to finalize against the just-created block.
|
|
996
|
-
if (!isLive) {
|
|
997
|
-
finalizeToolCall(step, block);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
function finalizeToolCall(step, block) {
|
|
1001
|
-
const isError = step.status === "error";
|
|
1002
|
-
const glyph = isError ? "✗" : "✓";
|
|
1003
|
-
const elapsedMs = block.startedAt === undefined ? 0 : Date.now() - block.startedAt;
|
|
1004
|
-
// Sub-second runs drop the elapsed suffix so the transcript does not get
|
|
1005
|
-
// littered with "0s" markers from fast tools (read, ls, todo_write, …).
|
|
1006
|
-
const durationSuffix = elapsedMs >= 1000 ? ` ${formatElapsed(elapsedMs)}` : "";
|
|
1007
|
-
const formatted = formatToolBlock({
|
|
1008
|
-
toolName: step.toolName,
|
|
1009
|
-
status: isError ? "error" : "completed",
|
|
1010
|
-
input: block.input,
|
|
1011
|
-
output: step.output,
|
|
1012
|
-
mode: "live",
|
|
1013
|
-
});
|
|
1014
|
-
block.line.content = assembleToolBlock(formatted, `${glyph}${durationSuffix}`, {
|
|
1015
|
-
columns: toolBlockColumns(),
|
|
1016
|
-
});
|
|
1017
|
-
block.line.fg = isError ? COLORS.error : COLORS.tool;
|
|
1018
|
-
activeToolBlocks.delete(step.toolCallId);
|
|
1019
|
-
}
|
|
1020
|
-
function finalizeDelta(block, body) {
|
|
1021
|
-
block.body = body;
|
|
1022
|
-
updateStreamingBlock(block);
|
|
1023
|
-
}
|
|
1024
|
-
function updateStreamingBlock(block) {
|
|
1025
|
-
const body = block.truncate ? truncateToolText(block.body) : block.body;
|
|
1026
|
-
block.line.content = block.label ? `${block.label}\n${body}` : body;
|
|
1027
|
-
}
|
|
1028
|
-
function renderMemoryStatus(event) {
|
|
1029
|
-
if (event.status === "running") {
|
|
1030
|
-
workingMessage = event.message;
|
|
1031
|
-
refreshWorkingStatus();
|
|
1032
|
-
return;
|
|
1033
|
-
}
|
|
1034
|
-
const body = formatMemoryEventBody(event);
|
|
1035
|
-
if (body) {
|
|
1036
|
-
appendBlock(`[memory:${event.phase}]`, body, COLORS.memory);
|
|
1037
|
-
}
|
|
1038
|
-
if (running) {
|
|
1039
|
-
workingMessage = "working…";
|
|
1040
|
-
refreshWorkingStatus();
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
function formatMemoryEventBody(event) {
|
|
1044
|
-
const hasObservations = Boolean(event.observations && event.observations.length > 0);
|
|
1045
|
-
const hasBumps = Boolean(event.usageBumpedObservations && event.usageBumpedObservations.length > 0);
|
|
1046
|
-
if (!hasObservations && !hasBumps) {
|
|
1047
|
-
return "";
|
|
1048
|
-
}
|
|
1049
|
-
const sections = [event.message];
|
|
1050
|
-
if (hasObservations) {
|
|
1051
|
-
sections.push(event.observations.map((observation) => observation.content).join("\n\n"));
|
|
1052
|
-
}
|
|
1053
|
-
return truncateToolText(sections.join("\n"));
|
|
1054
|
-
}
|
|
1055
|
-
// ---- input handling --------------------------------------------------------
|
|
1056
|
-
let skillAutocompleteSkills = [];
|
|
1057
|
-
let skillAutocompleteToken;
|
|
1058
|
-
let skillAutocompleteItems = [];
|
|
1059
|
-
let skillAutocompleteSelectedIndex = 0;
|
|
1060
|
-
// File index loads lazily after the first @ trigger and never re-runs.
|
|
1061
|
-
// Repos large enough to matter would block the first keystroke otherwise;
|
|
1062
|
-
// a stale-by-a-few-files index is a fair trade for a snappy first paint.
|
|
1063
|
-
let fileAutocompleteAllFiles = [];
|
|
1064
|
-
let fileAutocompleteIndexPromise;
|
|
1065
|
-
let fileAutocompleteToken;
|
|
1066
|
-
let fileAutocompleteItems = [];
|
|
1067
|
-
let fileAutocompleteSelectedIndex = 0;
|
|
1068
|
-
let pendingQuestions = [];
|
|
1069
|
-
let questionActiveIndex = 0;
|
|
1070
|
-
// `NO_HIGHLIGHT` (-1) means no row is highlighted yet — the user must press
|
|
1071
|
-
// Up/Down to land on a concrete row. Single-select live-records the
|
|
1072
|
-
// highlight as the answer; multi-select uses highlight purely for
|
|
1073
|
-
// navigation and toggles the checked set on Space/Enter.
|
|
1074
|
-
let questionOptionSelectedIndex = NO_HIGHLIGHT;
|
|
1075
|
-
// Per-question checked indices for the active multi-select question. Reset
|
|
1076
|
-
// when the picker advances to the next question; single-select questions
|
|
1077
|
-
// simply ignore this set and use `questionOptionSelectedIndex` instead.
|
|
1078
|
-
let questionMultiSelectChecked = new Set();
|
|
1079
|
-
// Answers collected while walking the picker, keyed by question text. We
|
|
1080
|
-
// dispatch the full map once the user finishes the last question, or flush
|
|
1081
|
-
// it early when they decide to type a free-form prompt instead.
|
|
1082
|
-
let questionAccumulatedAnswers = {};
|
|
1083
|
-
let suppressNextEscapeExit = false;
|
|
1084
|
-
function skillAutocompleteIsOpen() {
|
|
1085
|
-
return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
|
|
1086
|
-
}
|
|
1087
|
-
function fileAutocompleteIsOpen() {
|
|
1088
|
-
return Boolean(fileAutocompleteToken && fileAutocompleteItems.length > 0);
|
|
1089
|
-
}
|
|
1090
|
-
function questionPickerIsOpen() {
|
|
1091
|
-
const question = pendingQuestions[questionActiveIndex];
|
|
1092
|
-
return Boolean(question && question.options.length > 0);
|
|
1093
|
-
}
|
|
1094
|
-
function activeQuestion() {
|
|
1095
|
-
return pendingQuestions[questionActiveIndex];
|
|
1096
|
-
}
|
|
1097
|
-
function hideSkillAutocomplete() {
|
|
1098
|
-
skillAutocompleteToken = undefined;
|
|
1099
|
-
skillAutocompleteItems = [];
|
|
1100
|
-
skillAutocompleteSelectedIndex = 0;
|
|
1101
|
-
skillAutocompletePanel.visible = false;
|
|
1102
|
-
commandHeader.visible = false;
|
|
1103
|
-
skillHeader.visible = false;
|
|
1104
|
-
for (const row of [...commandRows, ...skillRows]) {
|
|
1105
|
-
row.visible = false;
|
|
1106
|
-
row.content = "";
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
function hideFileAutocomplete() {
|
|
1110
|
-
fileAutocompleteToken = undefined;
|
|
1111
|
-
fileAutocompleteItems = [];
|
|
1112
|
-
fileAutocompleteSelectedIndex = 0;
|
|
1113
|
-
fileAutocompletePanel.visible = false;
|
|
1114
|
-
for (const row of fileAutocompleteRows) {
|
|
1115
|
-
row.visible = false;
|
|
1116
|
-
row.content = "";
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
function hideQuestions() {
|
|
1120
|
-
pendingQuestions = [];
|
|
1121
|
-
questionActiveIndex = 0;
|
|
1122
|
-
questionOptionSelectedIndex = NO_HIGHLIGHT;
|
|
1123
|
-
questionMultiSelectChecked = new Set();
|
|
1124
|
-
questionAccumulatedAnswers = {};
|
|
1125
|
-
questionPanel.visible = false;
|
|
1126
|
-
for (const row of questionRows) {
|
|
1127
|
-
row.visible = false;
|
|
1128
|
-
row.content = "";
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
function showQuestions(questions) {
|
|
1132
|
-
pendingQuestions = questions;
|
|
1133
|
-
questionActiveIndex = 0;
|
|
1134
|
-
questionOptionSelectedIndex = NO_HIGHLIGHT;
|
|
1135
|
-
questionMultiSelectChecked = new Set();
|
|
1136
|
-
questionAccumulatedAnswers = {};
|
|
1137
|
-
renderQuestions();
|
|
1138
|
-
}
|
|
1139
|
-
/**
|
|
1140
|
-
* Total navigable rows for the active question. The Up/Down handler clamps
|
|
1141
|
-
* navigation to what is actually rendered on screen so a user cannot land
|
|
1142
|
-
* the highlight on a row they cannot see.
|
|
1143
|
-
*/
|
|
1144
|
-
function activeRowCount() {
|
|
1145
|
-
const question = activeQuestion();
|
|
1146
|
-
if (!question)
|
|
1147
|
-
return 0;
|
|
1148
|
-
const optionLimit = question.multiSelect ? QUESTION_OPTION_LIMIT - 1 : QUESTION_OPTION_LIMIT;
|
|
1149
|
-
const visibleOptionCount = Math.min(question.options.length, optionLimit);
|
|
1150
|
-
return visibleOptionCount + (question.multiSelect ? 1 : 0);
|
|
1151
|
-
}
|
|
1152
|
-
/**
|
|
1153
|
-
* Row index of the synthetic Done row when the active question is
|
|
1154
|
-
* multi-select; `undefined` otherwise so callers don't compare against a
|
|
1155
|
-
* sentinel value. The Done row sits one past the last visible option,
|
|
1156
|
-
* clamped to the same limit `renderQuestions` uses.
|
|
1157
|
-
*/
|
|
1158
|
-
function activeQuestionDoneIndex() {
|
|
1159
|
-
const question = activeQuestion();
|
|
1160
|
-
if (!question?.multiSelect)
|
|
1161
|
-
return undefined;
|
|
1162
|
-
const optionLimit = QUESTION_OPTION_LIMIT - 1;
|
|
1163
|
-
return Math.min(question.options.length, optionLimit);
|
|
1164
|
-
}
|
|
1165
|
-
function renderQuestions() {
|
|
1166
|
-
const question = activeQuestion();
|
|
1167
|
-
if (!question || question.options.length === 0) {
|
|
1168
|
-
hideQuestions();
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
questionPanel.visible = true;
|
|
1172
|
-
const baseTitle = question.header
|
|
1173
|
-
? `${question.header}: ${question.question}`
|
|
1174
|
-
: question.question;
|
|
1175
|
-
const positionPrefix = pendingQuestions.length > 1 ? `(${questionActiveIndex + 1}/${pendingQuestions.length}) ` : "";
|
|
1176
|
-
const navHint = pendingQuestions.length > 1 ? " [←/→ navigate]" : "";
|
|
1177
|
-
questionTitle.content = `${positionPrefix}${baseTitle}${navHint}`;
|
|
1178
|
-
const optionLimit = question.multiSelect ? QUESTION_OPTION_LIMIT - 1 : QUESTION_OPTION_LIMIT;
|
|
1179
|
-
const visibleOptions = question.options.slice(0, optionLimit);
|
|
1180
|
-
const doneIndex = activeQuestionDoneIndex();
|
|
1181
|
-
for (const [index, row] of questionRows.entries()) {
|
|
1182
|
-
if (index < visibleOptions.length) {
|
|
1183
|
-
const option = visibleOptions[index];
|
|
1184
|
-
const highlighted = index === questionOptionSelectedIndex;
|
|
1185
|
-
const checkbox = question.multiSelect
|
|
1186
|
-
? questionMultiSelectChecked.has(index)
|
|
1187
|
-
? "[x] "
|
|
1188
|
-
: "[ ] "
|
|
1189
|
-
: "";
|
|
1190
|
-
const labelColor = highlighted ? COLORS.status : COLORS.user;
|
|
1191
|
-
const description = formatQuestionOptionDescription(option.description);
|
|
1192
|
-
const labelLine = `${checkbox}${option.label}`;
|
|
1193
|
-
row.content = description
|
|
1194
|
-
? t `${fg(labelColor)(labelLine)}\n${description}`
|
|
1195
|
-
: t `${fg(labelColor)(labelLine)}`;
|
|
1196
|
-
row.fg = highlighted ? COLORS.agent : COLORS.hint;
|
|
1197
|
-
row.visible = true;
|
|
1198
|
-
continue;
|
|
1199
|
-
}
|
|
1200
|
-
if (question.multiSelect && index === visibleOptions.length) {
|
|
1201
|
-
// Synthetic Done row carries no checkbox prefix and self-documents
|
|
1202
|
-
// its purpose so users discover how to advance from a multi-select.
|
|
1203
|
-
const highlighted = doneIndex === questionOptionSelectedIndex;
|
|
1204
|
-
const labelColor = highlighted ? COLORS.status : COLORS.user;
|
|
1205
|
-
const description = formatQuestionOptionDescription("Advance to next question");
|
|
1206
|
-
row.content = t `${fg(labelColor)("Done")}\n${description}`;
|
|
1207
|
-
row.fg = highlighted ? COLORS.agent : COLORS.hint;
|
|
1208
|
-
row.visible = true;
|
|
1209
|
-
continue;
|
|
1210
|
-
}
|
|
1211
|
-
row.visible = false;
|
|
1212
|
-
row.content = "";
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
function moveActiveQuestionHighlight(direction) {
|
|
1216
|
-
const question = activeQuestion();
|
|
1217
|
-
if (!question)
|
|
1218
|
-
return;
|
|
1219
|
-
questionOptionSelectedIndex = moveQuestionHighlight(questionOptionSelectedIndex, activeRowCount(), direction);
|
|
1220
|
-
// Single-select live-records the highlight as the answer so a
|
|
1221
|
-
// prompt-flush or arrow-nav captures it without requiring Space/Enter.
|
|
1222
|
-
// Multi-select keeps highlight separate from the toggled set.
|
|
1223
|
-
if (!question.multiSelect) {
|
|
1224
|
-
questionAccumulatedAnswers = commitActiveAnswer(question, questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
|
|
1225
|
-
}
|
|
1226
|
-
renderQuestions();
|
|
1227
|
-
}
|
|
1228
|
-
function toggleActiveMultiSelectOption() {
|
|
1229
|
-
const question = activeQuestion();
|
|
1230
|
-
if (!question?.multiSelect)
|
|
1231
|
-
return;
|
|
1232
|
-
if (questionMultiSelectChecked.has(questionOptionSelectedIndex)) {
|
|
1233
|
-
questionMultiSelectChecked.delete(questionOptionSelectedIndex);
|
|
1234
|
-
}
|
|
1235
|
-
else {
|
|
1236
|
-
questionMultiSelectChecked.add(questionOptionSelectedIndex);
|
|
1237
|
-
}
|
|
1238
|
-
questionAccumulatedAnswers = commitActiveAnswer(question, questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
|
|
1239
|
-
renderQuestions();
|
|
1240
|
-
}
|
|
1241
|
-
function navigateActiveQuestion(direction) {
|
|
1242
|
-
if (pendingQuestions.length <= 1)
|
|
1243
|
-
return false;
|
|
1244
|
-
const nextIndex = questionActiveIndex + direction;
|
|
1245
|
-
if (nextIndex < 0 || nextIndex >= pendingQuestions.length)
|
|
1246
|
-
return false;
|
|
1247
|
-
questionAccumulatedAnswers = commitActiveAnswer(activeQuestion(), questionOptionSelectedIndex, questionMultiSelectChecked, questionAccumulatedAnswers);
|
|
1248
|
-
questionActiveIndex = nextIndex;
|
|
1249
|
-
const restored = restoreSavedAnswer(activeQuestion(), questionAccumulatedAnswers);
|
|
1250
|
-
questionOptionSelectedIndex = restored.selectedIndex;
|
|
1251
|
-
questionMultiSelectChecked = restored.checked;
|
|
1252
|
-
renderQuestions();
|
|
1253
|
-
return true;
|
|
1254
|
-
}
|
|
1255
|
-
function describeAnswerLabels(question, labels) {
|
|
1256
|
-
if (labels.length === 0)
|
|
1257
|
-
return question.multiSelect ? "(no selection)" : "";
|
|
1258
|
-
return labels.join(", ");
|
|
1259
|
-
}
|
|
1260
|
-
/**
|
|
1261
|
-
* Handle Space/Enter when the picker is open and the composer is empty.
|
|
1262
|
-
* Multi-select on a regular row toggles; multi-select on the Done row
|
|
1263
|
-
* advances. Single-select always advances (highlight = answer is already
|
|
1264
|
-
* live-recorded by Up/Down). No-op when nothing is highlighted yet so the
|
|
1265
|
-
* user is forced to make an explicit choice (or skip via Right-arrow).
|
|
1266
|
-
*/
|
|
1267
|
-
function confirmActiveSelection() {
|
|
1268
|
-
const question = activeQuestion();
|
|
1269
|
-
if (!question)
|
|
1270
|
-
return false;
|
|
1271
|
-
if (questionOptionSelectedIndex === NO_HIGHLIGHT)
|
|
1272
|
-
return false;
|
|
1273
|
-
if (question.multiSelect && questionOptionSelectedIndex !== activeQuestionDoneIndex()) {
|
|
1274
|
-
toggleActiveMultiSelectOption();
|
|
1275
|
-
return true;
|
|
1276
|
-
}
|
|
1277
|
-
return advanceOrSubmit();
|
|
1278
|
-
}
|
|
1279
|
-
function advanceOrSubmit() {
|
|
1280
|
-
const question = activeQuestion();
|
|
1281
|
-
if (!question)
|
|
1282
|
-
return false;
|
|
1283
|
-
const accumulatedForActive = questionAccumulatedAnswers[question.question] ?? [];
|
|
1284
|
-
const transcriptText = describeAnswerLabels(question, accumulatedForActive);
|
|
1285
|
-
if (transcriptText) {
|
|
1286
|
-
recordTranscriptEntry("user", transcriptText);
|
|
1287
|
-
appendBlock("you:", transcriptText, COLORS.user);
|
|
1288
|
-
}
|
|
1289
|
-
if (questionActiveIndex < pendingQuestions.length - 1) {
|
|
1290
|
-
questionActiveIndex += 1;
|
|
1291
|
-
const restored = restoreSavedAnswer(activeQuestion(), questionAccumulatedAnswers);
|
|
1292
|
-
questionOptionSelectedIndex = restored.selectedIndex;
|
|
1293
|
-
questionMultiSelectChecked = restored.checked;
|
|
1294
|
-
renderQuestions();
|
|
1295
|
-
return true;
|
|
1296
|
-
}
|
|
1297
|
-
void input.session
|
|
1298
|
-
.answer({
|
|
1299
|
-
questions: pendingQuestions,
|
|
1300
|
-
answers: questionAccumulatedAnswers,
|
|
1301
|
-
behavior: "follow_up",
|
|
1302
|
-
})
|
|
1303
|
-
.catch(reportError);
|
|
1304
|
-
hideQuestions();
|
|
1305
|
-
markRunning();
|
|
1306
|
-
return true;
|
|
1307
|
-
}
|
|
1308
|
-
async function ensureFileIndex() {
|
|
1309
|
-
if (fileAutocompleteAllFiles.length > 0)
|
|
1310
|
-
return fileAutocompleteAllFiles;
|
|
1311
|
-
if (!fileAutocompleteIndexPromise) {
|
|
1312
|
-
fileAutocompleteIndexPromise = buildFileIndex(input.workDir).catch(() => []);
|
|
1313
|
-
}
|
|
1314
|
-
fileAutocompleteAllFiles = await fileAutocompleteIndexPromise;
|
|
1315
|
-
return fileAutocompleteAllFiles;
|
|
1316
|
-
}
|
|
1317
|
-
function refreshAutocomplete() {
|
|
1318
|
-
refreshSkillAutocomplete();
|
|
1319
|
-
refreshFileAutocomplete();
|
|
1320
|
-
}
|
|
1321
|
-
function refreshSkillAutocomplete() {
|
|
1322
|
-
const token = activeSkillAutocompleteToken(inputField.plainText, inputField.cursorOffset);
|
|
1323
|
-
if (!token) {
|
|
1324
|
-
hideSkillAutocomplete();
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1327
|
-
const items = skillAutocompleteMatches(skillAutocompleteSkills, token.query);
|
|
1328
|
-
if (items.length === 0) {
|
|
1329
|
-
hideSkillAutocomplete();
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
const previousToken = skillAutocompleteToken;
|
|
1333
|
-
skillAutocompleteToken = token;
|
|
1334
|
-
skillAutocompleteItems = items;
|
|
1335
|
-
const queryChanged = !previousToken ||
|
|
1336
|
-
previousToken.start !== token.start ||
|
|
1337
|
-
previousToken.end !== token.end ||
|
|
1338
|
-
previousToken.query !== token.query;
|
|
1339
|
-
if (queryChanged || skillAutocompleteSelectedIndex >= items.length) {
|
|
1340
|
-
skillAutocompleteSelectedIndex = 0;
|
|
1341
|
-
}
|
|
1342
|
-
renderSkillAutocomplete();
|
|
1343
|
-
}
|
|
1344
|
-
function refreshFileAutocomplete() {
|
|
1345
|
-
const token = activeFileAutocompleteToken(inputField.plainText, inputField.cursorOffset);
|
|
1346
|
-
if (!token) {
|
|
1347
|
-
hideFileAutocomplete();
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
// Capture the token id we're looking up so a slow index resolution can
|
|
1351
|
-
// tell whether the user has typed past the original query and bail out.
|
|
1352
|
-
const targetStart = token.start;
|
|
1353
|
-
const targetEnd = token.end;
|
|
1354
|
-
const targetQuery = token.query;
|
|
1355
|
-
void ensureFileIndex().then((files) => {
|
|
1356
|
-
const stillCurrent = fileAutocompleteToken !== undefined
|
|
1357
|
-
? fileAutocompleteToken.start === targetStart &&
|
|
1358
|
-
fileAutocompleteToken.end === targetEnd &&
|
|
1359
|
-
fileAutocompleteToken.query === targetQuery
|
|
1360
|
-
: activeFileAutocompleteToken(inputField.plainText, inputField.cursorOffset)?.query ===
|
|
1361
|
-
targetQuery;
|
|
1362
|
-
if (!stillCurrent && fileAutocompleteToken === undefined)
|
|
1363
|
-
return;
|
|
1364
|
-
const items = fileAutocompleteMatches(files, targetQuery);
|
|
1365
|
-
if (items.length === 0) {
|
|
1366
|
-
hideFileAutocomplete();
|
|
1367
|
-
return;
|
|
1368
|
-
}
|
|
1369
|
-
const previousToken = fileAutocompleteToken;
|
|
1370
|
-
fileAutocompleteToken = { start: targetStart, end: targetEnd, query: targetQuery };
|
|
1371
|
-
fileAutocompleteItems = items;
|
|
1372
|
-
const queryChanged = !previousToken ||
|
|
1373
|
-
previousToken.start !== targetStart ||
|
|
1374
|
-
previousToken.end !== targetEnd ||
|
|
1375
|
-
previousToken.query !== targetQuery;
|
|
1376
|
-
if (queryChanged || fileAutocompleteSelectedIndex >= items.length) {
|
|
1377
|
-
fileAutocompleteSelectedIndex = 0;
|
|
1378
|
-
}
|
|
1379
|
-
renderFileAutocomplete();
|
|
55
|
+
statusController.markIdle();
|
|
56
|
+
};
|
|
57
|
+
const escapeState = { suppress: false };
|
|
58
|
+
const setEscapeSuppress = () => {
|
|
59
|
+
escapeState.suppress = true;
|
|
60
|
+
};
|
|
61
|
+
// Boot starter prompts; skipped on --resume so call sites null-check.
|
|
62
|
+
const starters = input.isResume
|
|
63
|
+
? undefined
|
|
64
|
+
: new StarterSection({
|
|
65
|
+
workDir: input.workDir,
|
|
66
|
+
sessionId: input.sessionId,
|
|
67
|
+
history: input.history,
|
|
68
|
+
inputField: ui.inputField,
|
|
69
|
+
transcript: ui.transcript,
|
|
70
|
+
transcriptWriter,
|
|
71
|
+
renderer,
|
|
72
|
+
submit: (text) => submit(text),
|
|
73
|
+
onResumeRequest: input.onResumeRequest,
|
|
1380
74
|
});
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
const slot = groups[groupKey];
|
|
1398
|
-
const row = slot.rows[slot.cursor];
|
|
1399
|
-
if (!row)
|
|
1400
|
-
continue;
|
|
1401
|
-
slot.cursor += 1;
|
|
1402
|
-
const selected = flatIndex === skillAutocompleteSelectedIndex;
|
|
1403
|
-
const nameColor = selected ? COLORS.status : COLORS.user;
|
|
1404
|
-
const pathColor = selected ? COLORS.agent : COLORS.hint;
|
|
1405
|
-
const description = formatSkillAutocompleteDescription(item.description);
|
|
1406
|
-
const tail = description ? `\n${description}` : "";
|
|
1407
|
-
row.content = item.path
|
|
1408
|
-
? t `${fg(nameColor)(`/${item.name}`)} ${fg(pathColor)(`(${item.path})`)}${tail}`
|
|
1409
|
-
: t `${fg(nameColor)(`/${item.name}`)}${tail}`;
|
|
1410
|
-
// Height = name line + each wrapped description line. Without this the
|
|
1411
|
-
// box defaults to a single line and clips multi-line descriptions.
|
|
1412
|
-
row.height = description ? 1 + description.split("\n").length : 1;
|
|
1413
|
-
row.fg = selected ? COLORS.agent : COLORS.hint;
|
|
1414
|
-
row.visible = true;
|
|
1415
|
-
}
|
|
1416
|
-
commandHeader.visible = groups.commands.cursor > 0;
|
|
1417
|
-
skillHeader.visible = groups.skills.cursor > 0;
|
|
1418
|
-
}
|
|
1419
|
-
function renderFileAutocomplete() {
|
|
1420
|
-
fileAutocompletePanel.visible = fileAutocompleteItems.length > 0;
|
|
1421
|
-
for (const [index, row] of fileAutocompleteRows.entries()) {
|
|
1422
|
-
const item = fileAutocompleteItems[index];
|
|
1423
|
-
if (!item) {
|
|
1424
|
-
row.visible = false;
|
|
1425
|
-
row.content = "";
|
|
1426
|
-
continue;
|
|
1427
|
-
}
|
|
1428
|
-
const selected = index === fileAutocompleteSelectedIndex;
|
|
1429
|
-
const nameColor = selected ? COLORS.status : COLORS.user;
|
|
1430
|
-
const pathColor = selected ? COLORS.agent : COLORS.hint;
|
|
1431
|
-
// Show basename + relative directory side-by-side. The directory
|
|
1432
|
-
// portion is the path with the trailing basename removed; for files at
|
|
1433
|
-
// the repo root this collapses to "./" so each row has a consistent
|
|
1434
|
-
// shape.
|
|
1435
|
-
const directory = item.relativePath.includes("/")
|
|
1436
|
-
? item.relativePath.slice(0, item.relativePath.lastIndexOf("/") + 1)
|
|
1437
|
-
: "./";
|
|
1438
|
-
row.content = t `${fg(nameColor)(item.name)} ${fg(pathColor)(directory)}`;
|
|
1439
|
-
row.fg = selected ? COLORS.agent : COLORS.hint;
|
|
1440
|
-
row.visible = true;
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
function completeSelectedSkillAutocomplete() {
|
|
1444
|
-
const token = skillAutocompleteToken;
|
|
1445
|
-
const item = skillAutocompleteItems[skillAutocompleteSelectedIndex];
|
|
1446
|
-
if (!token || !item)
|
|
1447
|
-
return false;
|
|
1448
|
-
const insertion = inputField.plainText[token.end]?.match(/\s/)
|
|
1449
|
-
? `/${item.name}`
|
|
1450
|
-
: `/${item.name} `;
|
|
1451
|
-
inputField.setSelection(token.start, token.end);
|
|
1452
|
-
inputField.deleteSelection();
|
|
1453
|
-
inputField.insertText(insertion);
|
|
1454
|
-
hideSkillAutocomplete();
|
|
1455
|
-
return true;
|
|
1456
|
-
}
|
|
1457
|
-
function completeSelectedFileAutocomplete() {
|
|
1458
|
-
const token = fileAutocompleteToken;
|
|
1459
|
-
const item = fileAutocompleteItems[fileAutocompleteSelectedIndex];
|
|
1460
|
-
if (!token || !item)
|
|
1461
|
-
return false;
|
|
1462
|
-
// Insert a markdown link `[@<basename>](./<relative-path>)` so the
|
|
1463
|
-
// visible token still reads as an `@`-mention while the link target is
|
|
1464
|
-
// a path the agent can hand straight to its `read` tool. The format
|
|
1465
|
-
// contract lives in `replaceFileAutocompleteToken`; this call site
|
|
1466
|
-
// mutates the inputField in place via setSelection/insertText so it
|
|
1467
|
-
// composes cleanly with attachment placeholders and the cursor model.
|
|
1468
|
-
const replacement = replaceFileAutocompleteToken(inputField.plainText, token, item.relativePath);
|
|
1469
|
-
const insertion = replacement.text.slice(token.start, replacement.cursorOffset);
|
|
1470
|
-
inputField.setSelection(token.start, token.end);
|
|
1471
|
-
inputField.deleteSelection();
|
|
1472
|
-
inputField.insertText(insertion);
|
|
1473
|
-
hideFileAutocomplete();
|
|
1474
|
-
return true;
|
|
1475
|
-
}
|
|
1476
|
-
const keyHandler = renderer._keyHandler;
|
|
1477
|
-
keyHandler.onInternal("keypress", (key) => {
|
|
1478
|
-
logKeyDiag("global", key);
|
|
1479
|
-
// Copy keystroke. Lives on the global handler (not
|
|
1480
|
-
// inputField.onKeyDown) because the mousedown that starts a
|
|
1481
|
-
// drag-select moves focus off the textarea — the focused-renderable
|
|
1482
|
-
// path stops firing right when the user has something to copy. The
|
|
1483
|
-
// global handler always fires regardless of focus.
|
|
1484
|
-
if (handleCopyKeystroke(key))
|
|
1485
|
-
return;
|
|
1486
|
-
if (key.name !== "escape")
|
|
1487
|
-
return;
|
|
1488
|
-
if (suppressNextEscapeExit) {
|
|
1489
|
-
suppressNextEscapeExit = false;
|
|
1490
|
-
key.preventDefault();
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
if (skillAutocompleteIsOpen()) {
|
|
1494
|
-
key.preventDefault();
|
|
1495
|
-
hideSkillAutocomplete();
|
|
1496
|
-
return;
|
|
1497
|
-
}
|
|
1498
|
-
if (fileAutocompleteIsOpen()) {
|
|
1499
|
-
key.preventDefault();
|
|
1500
|
-
hideFileAutocomplete();
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
if (questionPickerIsOpen()) {
|
|
1504
|
-
key.preventDefault();
|
|
1505
|
-
hideQuestions();
|
|
1506
|
-
return;
|
|
1507
|
-
}
|
|
1508
|
-
key.preventDefault();
|
|
1509
|
-
handleEscape();
|
|
75
|
+
const { autocomplete, questionPicker, pasteController, copyController } = createTuiControllers({
|
|
76
|
+
renderer,
|
|
77
|
+
ui,
|
|
78
|
+
session: input.session,
|
|
79
|
+
sessionId: input.sessionId,
|
|
80
|
+
workDir: input.workDir,
|
|
81
|
+
transcriptWriter,
|
|
82
|
+
statusController,
|
|
83
|
+
appendBlock,
|
|
84
|
+
recordTranscriptEntry,
|
|
85
|
+
reportError,
|
|
86
|
+
onPickerEscapeClose: setEscapeSuppress,
|
|
87
|
+
getLastSelectionText: () => lastSelectionText,
|
|
88
|
+
clearLastSelectionText: () => {
|
|
89
|
+
lastSelectionText = "";
|
|
90
|
+
},
|
|
1510
91
|
});
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
//
|
|
1526
|
-
// fires, so we intercept at the Renderable's onKeyDown hook which runs first.
|
|
1527
|
-
inputField.onKeyDown = (key) => {
|
|
1528
|
-
logKeyDiag("keydown", key);
|
|
1529
|
-
if (key.name === "pageup") {
|
|
1530
|
-
scrollTranscriptByPage(-1);
|
|
1531
|
-
key.preventDefault();
|
|
1532
|
-
return;
|
|
1533
|
-
}
|
|
1534
|
-
if (key.name === "pagedown") {
|
|
1535
|
-
scrollTranscriptByPage(1);
|
|
1536
|
-
key.preventDefault();
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
if (key.shift && key.name === "up" && !key.ctrl && !key.meta && !key.super) {
|
|
1540
|
-
scrollTranscriptByLines(-3);
|
|
1541
|
-
key.preventDefault();
|
|
1542
|
-
return;
|
|
1543
|
-
}
|
|
1544
|
-
if (key.shift && key.name === "down" && !key.ctrl && !key.meta && !key.super) {
|
|
1545
|
-
scrollTranscriptByLines(3);
|
|
1546
|
-
key.preventDefault();
|
|
1547
|
-
return;
|
|
1548
|
-
}
|
|
1549
|
-
// Boot starter navigation. Only intercepts when the starter section is
|
|
1550
|
-
// still on screen; once dismissed (by composition or first submit) all
|
|
1551
|
-
// of these branches no-op and keys flow normally to the input.
|
|
1552
|
-
if (startersAreVisible() && inputField.plainText.length === 0) {
|
|
1553
|
-
if (key.name === "up") {
|
|
1554
|
-
moveStarterHighlight(-1);
|
|
1555
|
-
key.preventDefault();
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
if (key.name === "down") {
|
|
1559
|
-
moveStarterHighlight(1);
|
|
1560
|
-
key.preventDefault();
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
if (key.name &&
|
|
1564
|
-
key.name.length === 1 &&
|
|
1565
|
-
key.name >= "1" &&
|
|
1566
|
-
key.name <= "9" &&
|
|
1567
|
-
!key.ctrl &&
|
|
1568
|
-
!key.meta &&
|
|
1569
|
-
!key.super) {
|
|
1570
|
-
const target = Number.parseInt(key.name, 10) - 1;
|
|
1571
|
-
if (jumpStarterHighlight(target)) {
|
|
1572
|
-
key.preventDefault();
|
|
1573
|
-
return;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
// Cmd+V / Ctrl+V keystroke trigger. Many terminals (Warp in particular,
|
|
1578
|
-
// and macOS Terminal.app for binary clipboards) do not forward a paste
|
|
1579
|
-
// event to TUI programs on Cmd+V — they handle the clipboard at the app
|
|
1580
|
-
// level and only deliver the resulting text. We catch the keystroke here
|
|
1581
|
-
// and probe the OS clipboard directly so image attach works regardless of
|
|
1582
|
-
// whether the terminal cooperates with bracketed paste for binary data.
|
|
1583
|
-
//
|
|
1584
|
-
// The keystroke handler only fires on terminals that actually deliver
|
|
1585
|
-
// Cmd+V as a keypress (kitty-keyboard-aware terminals: kitty, Ghostty,
|
|
1586
|
-
// recent iTerm2, WezTerm). For terminals that swallow Cmd+V entirely,
|
|
1587
|
-
// the `/paste` slash command below provides a guaranteed fallback.
|
|
1588
|
-
if (key.name === "v" && (key.super || key.meta || key.ctrl) && !key.shift) {
|
|
1589
|
-
key.preventDefault();
|
|
1590
|
-
void triggerClipboardProbe("keystroke");
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
if (skillAutocompleteIsOpen()) {
|
|
1594
|
-
if (key.name === "up") {
|
|
1595
|
-
skillAutocompleteSelectedIndex = moveSkillAutocompleteSelection(skillAutocompleteSelectedIndex, skillAutocompleteItems.length, -1);
|
|
1596
|
-
renderSkillAutocomplete();
|
|
1597
|
-
key.preventDefault();
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
if (key.name === "down") {
|
|
1601
|
-
skillAutocompleteSelectedIndex = moveSkillAutocompleteSelection(skillAutocompleteSelectedIndex, skillAutocompleteItems.length, 1);
|
|
1602
|
-
renderSkillAutocomplete();
|
|
1603
|
-
key.preventDefault();
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
|
|
1607
|
-
key.preventDefault();
|
|
1608
|
-
completeSelectedSkillAutocomplete();
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
if (key.name === "escape") {
|
|
1612
|
-
key.preventDefault();
|
|
1613
|
-
suppressNextEscapeExit = true;
|
|
1614
|
-
hideSkillAutocomplete();
|
|
1615
|
-
return;
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
if (fileAutocompleteIsOpen()) {
|
|
1619
|
-
if (key.name === "up") {
|
|
1620
|
-
fileAutocompleteSelectedIndex = moveSkillAutocompleteSelection(fileAutocompleteSelectedIndex, fileAutocompleteItems.length, -1);
|
|
1621
|
-
renderFileAutocomplete();
|
|
1622
|
-
key.preventDefault();
|
|
1623
|
-
return;
|
|
1624
|
-
}
|
|
1625
|
-
if (key.name === "down") {
|
|
1626
|
-
fileAutocompleteSelectedIndex = moveSkillAutocompleteSelection(fileAutocompleteSelectedIndex, fileAutocompleteItems.length, 1);
|
|
1627
|
-
renderFileAutocomplete();
|
|
1628
|
-
key.preventDefault();
|
|
1629
|
-
return;
|
|
1630
|
-
}
|
|
1631
|
-
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
|
|
1632
|
-
key.preventDefault();
|
|
1633
|
-
completeSelectedFileAutocomplete();
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
if (key.name === "escape") {
|
|
1637
|
-
key.preventDefault();
|
|
1638
|
-
suppressNextEscapeExit = true;
|
|
1639
|
-
hideFileAutocomplete();
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
if (questionPickerIsOpen()) {
|
|
1644
|
-
if (key.name === "up") {
|
|
1645
|
-
moveActiveQuestionHighlight(-1);
|
|
1646
|
-
key.preventDefault();
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
if (key.name === "down") {
|
|
1650
|
-
moveActiveQuestionHighlight(1);
|
|
1651
|
-
key.preventDefault();
|
|
1652
|
-
return;
|
|
1653
|
-
}
|
|
1654
|
-
// Space confirms the active selection only when the composer is empty
|
|
1655
|
-
// so users can still type a free-form prompt that includes spaces.
|
|
1656
|
-
// Match either the named form (most terminals) or the literal-char
|
|
1657
|
-
// form some kitty-keyboard parsers emit so the binding is robust
|
|
1658
|
-
// regardless of how the host reports an unmodified Space.
|
|
1659
|
-
if ((key.name === "space" || key.name === " ") && inputField.plainText.length === 0) {
|
|
1660
|
-
key.preventDefault();
|
|
1661
|
-
confirmActiveSelection();
|
|
1662
|
-
return;
|
|
1663
|
-
}
|
|
1664
|
-
// Left/Right navigate between questions, but only when the composer is
|
|
1665
|
-
// empty so editing a typed prompt with arrow keys still works.
|
|
1666
|
-
if ((key.name === "left" || key.name === "right") &&
|
|
1667
|
-
inputField.plainText.length === 0 &&
|
|
1668
|
-
pendingQuestions.length > 1) {
|
|
1669
|
-
const direction = key.name === "left" ? -1 : 1;
|
|
1670
|
-
if (navigateActiveQuestion(direction)) {
|
|
1671
|
-
key.preventDefault();
|
|
1672
|
-
return;
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
if (key.name === "escape") {
|
|
1676
|
-
key.preventDefault();
|
|
1677
|
-
suppressNextEscapeExit = true;
|
|
1678
|
-
hideQuestions();
|
|
1679
|
-
return;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
if (key.name === "return" || key.name === "enter") {
|
|
1683
|
-
// Three modifier flavors on the Enter key. The modifier is only
|
|
1684
|
-
// distinguishable when the terminal speaks the kitty-keyboard
|
|
1685
|
-
// protocol (or modifyOtherKeys); in a legacy terminal Ctrl+Enter
|
|
1686
|
-
// and Shift+Enter collapse to plain Enter. We accept that
|
|
1687
|
-
// tradeoff for the same reason Shift+Enter accepts it: modern
|
|
1688
|
-
// terminals are the target.
|
|
1689
|
-
//
|
|
1690
|
-
// Plain Enter → submit (idle = fresh turn, running = soft queue
|
|
1691
|
-
// via follow_up).
|
|
1692
|
-
// Shift+Enter → insert a literal newline at the cursor, matching
|
|
1693
|
-
// every modern chat composer (Slack, ChatGPT,
|
|
1694
|
-
// Discord, Cursor, Claude Code).
|
|
1695
|
-
// Ctrl+Enter → steer: dispatch with behavior:"steer" so the
|
|
1696
|
-
// runner hands it to agent.steer() at the next
|
|
1697
|
-
// inference boundary instead of waiting for the
|
|
1698
|
-
// full turn to wrap up.
|
|
1699
|
-
key.preventDefault();
|
|
1700
|
-
if (key.shift) {
|
|
1701
|
-
inputField.insertText("\n");
|
|
1702
|
-
return;
|
|
1703
|
-
}
|
|
1704
|
-
if (key.ctrl) {
|
|
1705
|
-
handleSteerKeystroke();
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
const value = inputField.plainText.trim();
|
|
1709
|
-
// Pre-latch the starter chrome before clearing the composer so the
|
|
1710
|
-
// clear-induced `onContentChange` doesn't briefly re-mount the chrome
|
|
1711
|
-
// (it would see empty composer + starters hidden and call
|
|
1712
|
-
// mountStarterChrome before submit got a chance to destroy them).
|
|
1713
|
-
// Only pre-latch when we know a typed-submit is about to fire —
|
|
1714
|
-
// empty-Enter into the picker / starter-row branches still need the
|
|
1715
|
-
// chrome state intact so they can dispatch.
|
|
1716
|
-
if (value && !startersPermanentlyDismissed)
|
|
1717
|
-
destroyStartersPermanently();
|
|
1718
|
-
inputField.clear();
|
|
1719
|
-
if (value) {
|
|
1720
|
-
submit(value);
|
|
1721
|
-
}
|
|
1722
|
-
else if (questionPickerIsOpen()) {
|
|
1723
|
-
confirmActiveSelection();
|
|
1724
|
-
}
|
|
1725
|
-
else if (startersAreVisible()) {
|
|
1726
|
-
submitHighlightedStarter();
|
|
1727
|
-
}
|
|
1728
|
-
return;
|
|
1729
|
-
}
|
|
1730
|
-
if (key.name === "escape") {
|
|
1731
|
-
handleEscape();
|
|
1732
|
-
return;
|
|
1733
|
-
}
|
|
1734
|
-
};
|
|
1735
|
-
// Esc interrupts the in-flight turn; when nothing is running it is a
|
|
1736
|
-
// no-op so muscle memory does not eject the user out of the session.
|
|
1737
|
-
// Quitting goes through Ctrl+C (renderer's exitOnCtrlC) or closing the
|
|
1738
|
-
// terminal — both paths drain through the `finally` block in
|
|
1739
|
-
// cli/run.ts that disposes the SessionManager and flushes PGlite.
|
|
92
|
+
renderer.on("selection", (selection) => {
|
|
93
|
+
lastSelectionText = selection.getSelectedText();
|
|
94
|
+
transcriptWriter.logSelection(lastSelectionText);
|
|
95
|
+
statusController.setSelectionText(lastSelectionText);
|
|
96
|
+
});
|
|
97
|
+
const unsubscribe = bindSessionToUi({
|
|
98
|
+
session: input.session,
|
|
99
|
+
sidebar: ui.sidebar,
|
|
100
|
+
stepRenderer,
|
|
101
|
+
statusController,
|
|
102
|
+
questionPicker,
|
|
103
|
+
appendLine,
|
|
104
|
+
appendBlock,
|
|
105
|
+
});
|
|
106
|
+
// Esc cancels the in-flight turn; idle Esc is a no-op (Ctrl+C quits).
|
|
1740
107
|
function handleEscape() {
|
|
1741
|
-
if (!
|
|
108
|
+
if (!statusController.isRunning())
|
|
1742
109
|
return;
|
|
1743
110
|
void input.session.interrupt().catch(reportError);
|
|
1744
111
|
}
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
// command enqueues and gets picked up at the next agent boundary.
|
|
1751
|
-
// Either way the user gets "at next sensible task boundary" pickup,
|
|
1752
|
-
// which is sooner than the soft-queue follow_up (which only fires at
|
|
1753
|
-
// end of the full turn).
|
|
1754
|
-
//
|
|
1755
|
-
// Empty composer: no-op. The keybind is dedicated to send-with-steer,
|
|
1756
|
-
// and a bare Ctrl+Enter should not interrupt or otherwise affect state.
|
|
1757
|
-
function handleSteerKeystroke() {
|
|
1758
|
-
const message = inputField.plainText.trim();
|
|
1759
|
-
if (message.length === 0)
|
|
1760
|
-
return;
|
|
1761
|
-
// Snapshot before clearing so attachments ride with the steer
|
|
1762
|
-
// dispatch and the user-facing labels render correctly.
|
|
1763
|
-
const submittedImages = pendingImages;
|
|
1764
|
-
inputField.clear();
|
|
1765
|
-
clearPendingImages();
|
|
1766
|
-
refreshHint();
|
|
112
|
+
// Shared dispatch for submit (follow_up) and Ctrl+Enter (steer): log the
|
|
113
|
+
// user message + attachments, hand the prompt to the session, flip the
|
|
114
|
+
// chrome to "running".
|
|
115
|
+
function dispatchTurn(message, behavior) {
|
|
116
|
+
const pending = pasteController.consume();
|
|
1767
117
|
recordTranscriptEntry("user", message);
|
|
1768
118
|
appendBlock("you:", message, COLORS.user);
|
|
1769
|
-
if (
|
|
1770
|
-
const lines =
|
|
119
|
+
if (pending.length > 0) {
|
|
120
|
+
const lines = pending.map((p) => `📎 ${p.label}: ${p.path}`).join("\n");
|
|
1771
121
|
appendBlock(null, lines, COLORS.hint);
|
|
1772
122
|
}
|
|
1773
|
-
const images =
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
}
|
|
1779
|
-
// ---- /diag diagnostics -----------------------------------------------------
|
|
1780
|
-
// `/diag` toggles a key+selection event log so the user can show us
|
|
1781
|
-
// exactly what their terminal forwards when something silently fails
|
|
1782
|
-
// (e.g. a keystroke not reaching the handler, a selection event firing
|
|
1783
|
-
// with empty text). Kept as a flag rather than a one-shot capture so
|
|
1784
|
-
// we can layer additional diagnostic facets on the same surface
|
|
1785
|
-
// without inventing new commands every time.
|
|
1786
|
-
let keyDiagnostics = false;
|
|
1787
|
-
function handleDiagSlashCommand(raw) {
|
|
1788
|
-
const argument = raw === "/diag" ? "" : raw.slice("/diag ".length).trim();
|
|
1789
|
-
if (argument === "" || argument === "keys") {
|
|
1790
|
-
keyDiagnostics = !keyDiagnostics;
|
|
1791
|
-
appendBlock("[diag]", keyDiagnostics
|
|
1792
|
-
? "key + selection event logging ON. Run /diag again to stop."
|
|
1793
|
-
: "key + selection event logging OFF.", COLORS.system);
|
|
123
|
+
const images = pending.map((p) => p.attachment);
|
|
124
|
+
// Treat typed message as a flush so collected partial answers are not
|
|
125
|
+
// dropped on the floor.
|
|
126
|
+
if (behavior === "follow_up" && questionPicker.isOpen()) {
|
|
127
|
+
questionPicker.flushWithMessage(message, images);
|
|
1794
128
|
return;
|
|
1795
129
|
}
|
|
1796
|
-
|
|
130
|
+
void input.session.prompt({ message, behavior, images }).catch(reportError);
|
|
131
|
+
if (!statusController.isRunning())
|
|
132
|
+
statusController.markRunning();
|
|
1797
133
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
const
|
|
1802
|
-
if (
|
|
1803
|
-
flags.push("ctrl");
|
|
1804
|
-
if (key.shift)
|
|
1805
|
-
flags.push("shift");
|
|
1806
|
-
if (key.meta)
|
|
1807
|
-
flags.push("meta");
|
|
1808
|
-
if (key.super)
|
|
1809
|
-
flags.push("super");
|
|
1810
|
-
if (key.option)
|
|
1811
|
-
flags.push("option");
|
|
1812
|
-
appendBlock("[diag]", `${label} name=${JSON.stringify(key.name)} flags=[${flags.join(",")}] sequence=${JSON.stringify(key.sequence)} source=${key.source} | lastSelection=${lastSelectionText.length}c rendererSel=${renderer.hasSelection ? "yes" : "no"}`, COLORS.hint);
|
|
1813
|
-
}
|
|
1814
|
-
function logSelectionDiag(text) {
|
|
1815
|
-
if (!keyDiagnostics)
|
|
134
|
+
// Ctrl+Enter: behavior:"steer" so the runner calls agent.steer() at
|
|
135
|
+
// the next inference boundary instead of queueing behind the full turn.
|
|
136
|
+
function handleSteer() {
|
|
137
|
+
const message = ui.inputField.plainText.trim();
|
|
138
|
+
if (message.length === 0)
|
|
1816
139
|
return;
|
|
1817
|
-
|
|
140
|
+
ui.inputField.clear();
|
|
141
|
+
dispatchTurn(message, "steer");
|
|
1818
142
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
143
|
+
// Plain Enter: slash-style local commands → shared dispatch (which
|
|
144
|
+
// handles question-picker flush → follow_up turn). The boot starter
|
|
145
|
+
// chrome is destroyed on the first user-driven submit.
|
|
146
|
+
function submit(message) {
|
|
147
|
+
if (starters && !starters.isPermanentlyDismissed()) {
|
|
148
|
+
starters.destroyPermanently();
|
|
149
|
+
}
|
|
150
|
+
if (tryDispatchSlashCommand(message, {
|
|
151
|
+
pasteController,
|
|
152
|
+
copyController,
|
|
153
|
+
transcriptWriter,
|
|
154
|
+
appendBlock,
|
|
155
|
+
})) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
dispatchTurn(message, "follow_up");
|
|
159
|
+
}
|
|
160
|
+
installKeyHandlers({
|
|
161
|
+
renderer,
|
|
162
|
+
inputField: ui.inputField,
|
|
163
|
+
transcript: ui.transcript,
|
|
164
|
+
autocomplete,
|
|
165
|
+
questionPicker,
|
|
166
|
+
pasteController,
|
|
167
|
+
copyController,
|
|
168
|
+
starters,
|
|
169
|
+
transcriptWriter,
|
|
170
|
+
escapeState,
|
|
171
|
+
onSubmit: submit,
|
|
172
|
+
onEscape: handleEscape,
|
|
173
|
+
onSteer: handleSteer,
|
|
174
|
+
});
|
|
175
|
+
// Typing hides starter chrome; backspacing empty brings it back until
|
|
176
|
+
// the user submits.
|
|
177
|
+
ui.inputField.onContentChange = () => {
|
|
178
|
+
starters?.syncVisibility();
|
|
179
|
+
autocomplete.refresh();
|
|
1853
180
|
};
|
|
1854
|
-
inputField.onCursorChange = () =>
|
|
1855
|
-
//
|
|
1856
|
-
// ghostty, recent iTerm2
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
// is unchanged for non-image clipboards.
|
|
1861
|
-
inputField.onPaste = (event) => {
|
|
1862
|
-
void handlePasteEvent(event).catch((error) => {
|
|
181
|
+
ui.inputField.onCursorChange = () => autocomplete.refresh();
|
|
182
|
+
// Binary-clipboard paste: terminals that forward image bytes (kitty,
|
|
183
|
+
// ghostty, recent iTerm2) land here; text pastes fall through to the
|
|
184
|
+
// Textarea's default insert path.
|
|
185
|
+
ui.inputField.onPaste = (event) => {
|
|
186
|
+
void pasteController.handlePasteEvent(event).catch((error) => {
|
|
1863
187
|
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
1864
188
|
});
|
|
1865
189
|
};
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
const candidate = looksLikeImageFilePath(originalText);
|
|
1898
|
-
if (candidate) {
|
|
1899
|
-
// Path-shaped paste: suppress the default insert so we can swap in
|
|
1900
|
-
// the [Image #N] placeholder once load resolves.
|
|
1901
|
-
event.preventDefault();
|
|
1902
|
-
try {
|
|
1903
|
-
const pending = await loadImageFromPath({
|
|
1904
|
-
cwd: input.workDir,
|
|
1905
|
-
rawPath: candidate,
|
|
1906
|
-
id: nextImageId,
|
|
1907
|
-
});
|
|
1908
|
-
nextImageId += 1;
|
|
1909
|
-
pendingImages.push(pending);
|
|
1910
|
-
inputField.insertText(pending.label);
|
|
1911
|
-
appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
|
|
1912
|
-
refreshHint();
|
|
1913
|
-
}
|
|
1914
|
-
catch (error) {
|
|
1915
|
-
// The clipboard looked like an image path but we could not load
|
|
1916
|
-
// it — surface why and restore the original text so the user can
|
|
1917
|
-
// edit it manually instead of losing what they pasted.
|
|
1918
|
-
appendBlock("[paste]", `looked like an image path but could not attach ${candidate}: ${error instanceof Error ? error.message : String(error)}`, COLORS.system);
|
|
1919
|
-
if (originalText.length > 0)
|
|
1920
|
-
inputField.insertText(originalText);
|
|
1921
|
-
}
|
|
1922
|
-
return;
|
|
1923
|
-
}
|
|
1924
|
-
if (originalText.length === 0) {
|
|
1925
|
-
// No text payload — the terminal had nothing to forward but the OS
|
|
1926
|
-
// clipboard may still carry an image promise. Suppress the default
|
|
1927
|
-
// (which would do nothing anyway) and run the slow probe.
|
|
1928
|
-
event.preventDefault();
|
|
1929
|
-
const clipboardImage = await tryReadClipboardImage();
|
|
1930
|
-
if (clipboardImage) {
|
|
1931
|
-
await attachPastedImageBytes(clipboardImage.bytes, clipboardImage.mimeType);
|
|
1932
|
-
}
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
// Plain text paste — fall through to the InputRenderable's default
|
|
1936
|
-
// insert path, which paints synchronously. Users whose intended image
|
|
1937
|
-
// arrived as text-shaped bytes (e.g. Figma) can still trigger an
|
|
1938
|
-
// explicit clipboard probe via the `/paste` slash command.
|
|
1939
|
-
}
|
|
1940
|
-
async function attachPastedImageBytes(bytes, mimeType) {
|
|
1941
|
-
try {
|
|
1942
|
-
const pending = await persistPastedImage({
|
|
1943
|
-
sessionId: input.sessionId,
|
|
1944
|
-
id: nextImageId,
|
|
1945
|
-
bytes,
|
|
1946
|
-
mimeType,
|
|
1947
|
-
});
|
|
1948
|
-
nextImageId += 1;
|
|
1949
|
-
pendingImages.push(pending);
|
|
1950
|
-
inputField.insertText(pending.label);
|
|
1951
|
-
appendBlock("[paste]", `attached ${pending.label} (${mimeType}, ${formatBytes(bytes.length)})`, COLORS.system);
|
|
1952
|
-
refreshHint();
|
|
1953
|
-
}
|
|
1954
|
-
catch (error) {
|
|
1955
|
-
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
function clearPendingImages() {
|
|
1959
|
-
if (pendingImages.length === 0)
|
|
1960
|
-
return;
|
|
1961
|
-
pendingImages = [];
|
|
1962
|
-
nextImageId = 1;
|
|
1963
|
-
refreshHint();
|
|
1964
|
-
}
|
|
1965
|
-
// Manual clipboard probe. Read the OS clipboard for an image right now and
|
|
1966
|
-
// attach it if found; otherwise emit a useful diagnostic line. Used both by
|
|
1967
|
-
// the Cmd+V/Ctrl+V keystroke handler above and the `/paste` slash command.
|
|
1968
|
-
async function triggerClipboardProbe(source) {
|
|
1969
|
-
try {
|
|
1970
|
-
const clipboardImage = await tryReadClipboardImage();
|
|
1971
|
-
if (clipboardImage) {
|
|
1972
|
-
await attachPastedImageBytes(clipboardImage.bytes, clipboardImage.mimeType);
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
// No image on the clipboard. The keystroke path may have eaten a
|
|
1976
|
-
// legitimate text paste, so fall back to a text probe so users do not
|
|
1977
|
-
// lose what they were trying to paste.
|
|
1978
|
-
const text = await tryReadClipboardText();
|
|
1979
|
-
if (text) {
|
|
1980
|
-
inputField.insertText(text);
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
if (source === "slash") {
|
|
1984
|
-
// Surface the actual clipboard UTI list when a /paste probe comes
|
|
1985
|
-
// up empty — lets users see what their source app actually put
|
|
1986
|
-
// there so the failure stops being mysterious.
|
|
1987
|
-
const types = await describeMacClipboardTypes();
|
|
1988
|
-
const detail = types
|
|
1989
|
-
? ` — clipboard types: ${types}`
|
|
1990
|
-
: " — (could not query clipboard types; clipboard may be empty)";
|
|
1991
|
-
appendBlock("[paste]", `clipboard had no readable image or text${detail}`, COLORS.system);
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
catch (error) {
|
|
1995
|
-
appendBlock("[paste]", `clipboard probe failed: ${error instanceof Error ? error.message : String(error)}`, COLORS.error);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
function submit(message) {
|
|
1999
|
-
// First user submit collapses the boot starter section permanently for
|
|
2000
|
-
// this session, even when the prompt came from autocomplete or paste.
|
|
2001
|
-
if (!startersPermanentlyDismissed)
|
|
2002
|
-
destroyStartersPermanently();
|
|
2003
|
-
// Slash-style attach commands run locally and never reach the runner so
|
|
2004
|
-
// users on terminals that do not forward image bytes still have a way to
|
|
2005
|
-
// attach images by path.
|
|
2006
|
-
if (message.startsWith("/image ") || message === "/image") {
|
|
2007
|
-
void handleImageSlashCommand(message);
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
if (message === "/paste") {
|
|
2011
|
-
void triggerClipboardProbe("slash");
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
|
-
if (message === "/clear-images") {
|
|
2015
|
-
clearPendingImages();
|
|
2016
|
-
appendBlock("[paste]", "cleared pending image attachments", COLORS.system);
|
|
2017
|
-
return;
|
|
2018
|
-
}
|
|
2019
|
-
if (message === "/copy" || message.startsWith("/copy ")) {
|
|
2020
|
-
void handleCopySlashCommand(message);
|
|
2021
|
-
return;
|
|
2022
|
-
}
|
|
2023
|
-
if (message === "/diag" || message.startsWith("/diag ")) {
|
|
2024
|
-
handleDiagSlashCommand(message);
|
|
2025
|
-
return;
|
|
2026
|
-
}
|
|
2027
|
-
if (message === "/feedback" || message.startsWith("/feedback ")) {
|
|
2028
|
-
void handleFeedbackSlashCommand(message);
|
|
2029
|
-
return;
|
|
2030
|
-
}
|
|
2031
|
-
const submittedImages = pendingImages;
|
|
2032
|
-
recordTranscriptEntry("user", message);
|
|
2033
|
-
appendBlock("you:", message, COLORS.user);
|
|
2034
|
-
// Render attachments as a separate hint-colored footnote rather than
|
|
2035
|
-
// inlining them into the user-message block. Keeps the transcript
|
|
2036
|
-
// structure honest — the user message persists exactly as the agent
|
|
2037
|
-
// sees it, and resumed sessions render identically because no extra
|
|
2038
|
-
// text was concatenated.
|
|
2039
|
-
if (submittedImages.length > 0) {
|
|
2040
|
-
const lines = submittedImages.map((p) => `📎 ${p.label}: ${p.path}`).join("\n");
|
|
2041
|
-
appendBlock(null, lines, COLORS.hint);
|
|
2042
|
-
}
|
|
2043
|
-
const images = submittedImages.map((p) => p.attachment);
|
|
2044
|
-
// Reset before dispatch so an in-flight error does not leave the user
|
|
2045
|
-
// double-charged with the same attachments on retry.
|
|
2046
|
-
clearPendingImages();
|
|
2047
|
-
// If the question picker is open, treat the typed message as a flush:
|
|
2048
|
-
// dispatch whatever answers were already collected together with the new
|
|
2049
|
-
// prompt text so the model sees one combined turn instead of dropping
|
|
2050
|
-
// the partial answers on the floor.
|
|
2051
|
-
if (pendingQuestions.length > 0) {
|
|
2052
|
-
void input.session
|
|
2053
|
-
.answer({
|
|
2054
|
-
questions: pendingQuestions,
|
|
2055
|
-
answers: questionAccumulatedAnswers,
|
|
2056
|
-
behavior: "follow_up",
|
|
2057
|
-
message,
|
|
2058
|
-
images,
|
|
2059
|
-
})
|
|
2060
|
-
.catch(reportError);
|
|
2061
|
-
hideQuestions();
|
|
2062
|
-
if (!running)
|
|
2063
|
-
markRunning();
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
// Every submit — running or idle — is a follow_up. While the agent is
|
|
2067
|
-
// running this queues; while idle it kicks off a fresh turn. Single
|
|
2068
|
-
// mental model: type, press Enter, your message lands.
|
|
2069
|
-
void input.session.prompt({ message, behavior: "follow_up", images }).catch(reportError);
|
|
2070
|
-
if (!running) {
|
|
2071
|
-
markRunning();
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
/**
|
|
2075
|
-
* Copy the active drag-selection to the clipboard and clear the highlight
|
|
2076
|
-
* so the user gets visual confirmation the action happened. Used by the
|
|
2077
|
-
* platform copy keystroke (Cmd+C on macOS, Ctrl+Shift+C elsewhere); the
|
|
2078
|
-
* slash command path goes through `handleCopySlashCommand` so it can
|
|
2079
|
-
* also serve `/copy last|all|<N>`.
|
|
2080
|
-
*/
|
|
2081
|
-
async function copyActiveSelection() {
|
|
2082
|
-
const text = lastSelectionText;
|
|
2083
|
-
if (text.trim().length === 0)
|
|
2084
|
-
return;
|
|
2085
|
-
const result = await copyTextToClipboard(text);
|
|
2086
|
-
renderer.clearSelection();
|
|
2087
|
-
lastSelectionText = "";
|
|
2088
|
-
refreshHint();
|
|
2089
|
-
if (result.ok) {
|
|
2090
|
-
appendBlock("[copy]", `copied selection (${text.length} char${text.length === 1 ? "" : "s"}) to clipboard via ${result.via}`, COLORS.system);
|
|
2091
|
-
}
|
|
2092
|
-
else {
|
|
2093
|
-
appendBlock("[copy]", `clipboard write failed: ${result.error ?? "unknown error"}` +
|
|
2094
|
-
(process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
/**
|
|
2098
|
-
* Resolve a `/copy ...` invocation to clipboard text and pipe it to the
|
|
2099
|
-
* OS clipboard. When the user has an active drag-selection and ran a bare
|
|
2100
|
-
* `/copy`, copy that highlight verbatim — it matches what they actually
|
|
2101
|
-
* have on screen. Otherwise fall back to the transcript-log heuristic
|
|
2102
|
-
* (`last` / `all` / `<N>`).
|
|
2103
|
-
*
|
|
2104
|
-
* Failures are surfaced in the transcript so users on minimal Linux
|
|
2105
|
-
* installs see exactly which writer is missing.
|
|
2106
|
-
*/
|
|
2107
|
-
async function handleCopySlashCommand(raw) {
|
|
2108
|
-
const argumentRaw = raw === "/copy" ? "" : raw.slice("/copy ".length);
|
|
2109
|
-
const argument = parseCopyArgument(argumentRaw);
|
|
2110
|
-
if (argument === undefined) {
|
|
2111
|
-
appendBlock("[copy]", "Usage: /copy [last|all|<N>] — last (default) copies the most recent agent reply, " +
|
|
2112
|
-
"or copies the active drag-selection when one is present", COLORS.system);
|
|
2113
|
-
return;
|
|
2114
|
-
}
|
|
2115
|
-
// A bare `/copy` (or the copy keystroke while a selection is active)
|
|
2116
|
-
// prefers the drag-selection so the clipboard matches what the user
|
|
2117
|
-
// has highlighted on screen; an explicit `/copy last|all|<N>` always
|
|
2118
|
-
// uses the transcript log instead.
|
|
2119
|
-
const explicitArgument = argumentRaw.trim().length > 0;
|
|
2120
|
-
const useSelection = !explicitArgument && lastSelectionText.trim().length > 0;
|
|
2121
|
-
const text = useSelection ? lastSelectionText : selectCopyText(transcriptLog, argument);
|
|
2122
|
-
if (!text) {
|
|
2123
|
-
appendBlock("[copy]", "nothing to copy yet", COLORS.system);
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
const result = await copyTextToClipboard(text);
|
|
2127
|
-
if (result.ok) {
|
|
2128
|
-
const summary = useSelection
|
|
2129
|
-
? `selection (${text.length} char${text.length === 1 ? "" : "s"})`
|
|
2130
|
-
: describeCopySelection(argument, text.length);
|
|
2131
|
-
appendBlock("[copy]", `copied ${summary} to clipboard via ${result.via}`, COLORS.system);
|
|
2132
|
-
}
|
|
2133
|
-
else {
|
|
2134
|
-
appendBlock("[copy]", `clipboard write failed: ${result.error ?? "unknown error"}` +
|
|
2135
|
-
(process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
/**
|
|
2139
|
-
* Two-stage clipboard write. The platform-native CLI (pbcopy / wl-copy /
|
|
2140
|
-
* xclip / xsel / clip.exe) goes first because it actually writes to the
|
|
2141
|
-
* OS clipboard and — critically — `writeClipboardText` reads the
|
|
2142
|
-
* clipboard back through pbpaste / wl-paste / xclip -o to confirm the
|
|
2143
|
-
* bytes landed. Exit-code-only success is not enough: pbcopy from inside
|
|
2144
|
-
* a raw-mode TUI on Warp/macOS exits 0 without actually updating
|
|
2145
|
-
* NSPasteboard, and OSC 52 has the same silent-drop problem on Warp.
|
|
2146
|
-
*
|
|
2147
|
-
* OSC 52 is only the fallback when no local CLI is available at all
|
|
2148
|
-
* (e.g. an SSH session with no clipboard tool installed remotely). When
|
|
2149
|
-
* a local CLI ran but failed verification we surface that error
|
|
2150
|
-
* directly instead of falling through to OSC 52, because OSC 52 would
|
|
2151
|
-
* also silently "succeed" on the same broken terminals and hide the
|
|
2152
|
-
* real failure behind a fake "copied via OSC 52" line.
|
|
2153
|
-
*/
|
|
2154
|
-
async function copyTextToClipboard(text) {
|
|
2155
|
-
const cli = await writeClipboardText(text);
|
|
2156
|
-
if (cli.ok)
|
|
2157
|
-
return cli;
|
|
2158
|
-
// Only fall back to OSC 52 when the CLI was simply unavailable. If a
|
|
2159
|
-
// CLI ran but the readback did not match (cli.kind ===
|
|
2160
|
-
// "verification-failed"), OSC 52 is on the same broken pipe and
|
|
2161
|
-
// would silently "succeed" the same way — surface the real error.
|
|
2162
|
-
if (cli.kind === "no-writer" &&
|
|
2163
|
-
renderer.isOsc52Supported() &&
|
|
2164
|
-
renderer.copyToClipboardOSC52(text)) {
|
|
2165
|
-
return { ok: true, via: "OSC 52" };
|
|
2166
|
-
}
|
|
2167
|
-
return cli;
|
|
2168
|
-
}
|
|
2169
|
-
function describeCopySelection(argument, length) {
|
|
2170
|
-
const chars = `${length} char${length === 1 ? "" : "s"}`;
|
|
2171
|
-
if (argument === "last")
|
|
2172
|
-
return `last message (${chars})`;
|
|
2173
|
-
if (argument === "all")
|
|
2174
|
-
return `full transcript (${chars})`;
|
|
2175
|
-
return `last ${argument} messages (${chars})`;
|
|
2176
|
-
}
|
|
2177
|
-
async function handleFeedbackSlashCommand(raw) {
|
|
2178
|
-
const content = raw.slice("/feedback".length).trim();
|
|
2179
|
-
if (!content) {
|
|
2180
|
-
appendBlock("[feedback]", "Usage: /feedback <message> — send free-form feedback to the Duet team", COLORS.system);
|
|
2181
|
-
return;
|
|
2182
|
-
}
|
|
2183
|
-
appendBlock("[feedback]", "sending…", COLORS.system);
|
|
2184
|
-
try {
|
|
2185
|
-
const { baseUrl } = await submitDuetFeedback({ content });
|
|
2186
|
-
appendBlock("[feedback]", `Thanks! Feedback sent to ${baseUrl}.`, COLORS.system);
|
|
2187
|
-
}
|
|
2188
|
-
catch (error) {
|
|
2189
|
-
appendBlock("[feedback]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
2190
|
-
}
|
|
2191
|
-
}
|
|
2192
|
-
async function handleImageSlashCommand(raw) {
|
|
2193
|
-
const rest = raw.slice("/image".length).trim();
|
|
2194
|
-
if (!rest) {
|
|
2195
|
-
appendBlock("[paste]", "Usage: /image <path> — attach a PNG/JPEG/GIF/WebP from disk", COLORS.system);
|
|
2196
|
-
return;
|
|
2197
|
-
}
|
|
2198
|
-
try {
|
|
2199
|
-
const pending = await loadImageFromPath({
|
|
2200
|
-
cwd: input.workDir,
|
|
2201
|
-
rawPath: rest,
|
|
2202
|
-
id: nextImageId,
|
|
2203
|
-
});
|
|
2204
|
-
nextImageId += 1;
|
|
2205
|
-
pendingImages.push(pending);
|
|
2206
|
-
// Insert the placeholder back into the (now-empty) input so the user
|
|
2207
|
-
// can keep typing their prompt with the image already attached.
|
|
2208
|
-
inputField.insertText(pending.label);
|
|
2209
|
-
appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
|
|
2210
|
-
refreshHint();
|
|
2211
|
-
}
|
|
2212
|
-
catch (error) {
|
|
2213
|
-
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
function formatBytes(n) {
|
|
2217
|
-
if (n < 1024)
|
|
2218
|
-
return `${n} B`;
|
|
2219
|
-
if (n < 1024 * 1024)
|
|
2220
|
-
return `${(n / 1024).toFixed(1)} KB`;
|
|
2221
|
-
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
2222
|
-
}
|
|
2223
|
-
// ---- replay history on resume ---------------------------------------------
|
|
2224
|
-
// Setup already ran before the TUI launched, so we can read the resolved
|
|
2225
|
-
// skills/agent-files synchronously through the session getters.
|
|
2226
|
-
const [skills, agentFiles] = await Promise.all([
|
|
2227
|
-
input.session.getSkills(),
|
|
2228
|
-
input.session.getResolvedAgentFiles(),
|
|
2229
|
-
]);
|
|
2230
|
-
skillAutocompleteSkills = [
|
|
2231
|
-
...BUILT_IN_SLASH_COMMANDS,
|
|
2232
|
-
...skills.map((skill) => ({
|
|
2233
|
-
name: skill.name,
|
|
2234
|
-
description: skill.description,
|
|
2235
|
-
path: skill.baseDir,
|
|
2236
|
-
group: "skills",
|
|
2237
|
-
})),
|
|
2238
|
-
];
|
|
2239
|
-
refreshAutocomplete();
|
|
2240
|
-
renderSetupIntro(skills, agentFiles);
|
|
2241
|
-
refreshSidebar();
|
|
2242
|
-
const resumeHistoryMessages = input.resumeHistoryMessages ?? Number.POSITIVE_INFINITY;
|
|
2243
|
-
if (resumeHistoryMessages > 0 && input.history && input.history.length > 0) {
|
|
2244
|
-
const limited = limitHistoryDisplayMessages(historyDisplayBlocks(input.history), resumeHistoryMessages);
|
|
2245
|
-
if (limited.omittedBlocks > 0) {
|
|
2246
|
-
appendLine(`[resume] showing last ${resumeHistoryMessages} message${resumeHistoryMessages === 1 ? "" : "s"} of prior session history`, COLORS.hint);
|
|
2247
|
-
}
|
|
2248
|
-
for (const block of limited.blocks) {
|
|
2249
|
-
appendDisplayBlock(block);
|
|
2250
|
-
}
|
|
2251
|
-
}
|
|
2252
|
-
// Seed the copy-out log from full resumed history (not the trimmed display
|
|
2253
|
-
// slice) so `/copy all` and `/copy <N>` can reach back further than what is
|
|
2254
|
-
// actually rendered in the transcript on resume.
|
|
2255
|
-
if (input.history && input.history.length > 0) {
|
|
2256
|
-
for (const block of historyDisplayBlocks(input.history)) {
|
|
2257
|
-
if (block.kind === "user") {
|
|
2258
|
-
// History blocks for users are formatted as `you:\n<text>`; strip the
|
|
2259
|
-
// label so the clipboard text matches what the user originally typed.
|
|
2260
|
-
const stripped = block.content.replace(/^you:\n?/, "");
|
|
2261
|
-
recordTranscriptEntry("user", stripped);
|
|
2262
|
-
}
|
|
2263
|
-
else if (block.kind === "agent") {
|
|
2264
|
-
recordTranscriptEntry("agent", block.content);
|
|
2265
|
-
}
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
// ---- bootstrap initial prompt ----------------------------------------------
|
|
2269
|
-
if (input.initialPrompt) {
|
|
2270
|
-
recordTranscriptEntry("user", input.initialPrompt);
|
|
2271
|
-
appendBlock("you:", input.initialPrompt, COLORS.user);
|
|
2272
|
-
void input.session
|
|
2273
|
-
.prompt({ message: input.initialPrompt, behavior: "follow_up" })
|
|
2274
|
-
.catch(reportError);
|
|
2275
|
-
markRunning();
|
|
2276
|
-
}
|
|
2277
|
-
else {
|
|
2278
|
-
// A resumed sleeping session emitted its `sleep` terminal during
|
|
2279
|
-
// hydrate(), before this subscriber attached. Surface the banner now so
|
|
2280
|
-
// the user can see when the next wake will fire.
|
|
2281
|
-
const pending = input.session.getLastTerminal();
|
|
2282
|
-
if (pending?.type === "sleep") {
|
|
2283
|
-
lastTerminal = pending;
|
|
2284
|
-
renderSleeping(pending.wakeAt);
|
|
2285
|
-
}
|
|
2286
|
-
markIdle();
|
|
2287
|
-
}
|
|
2288
|
-
// ---- run renderer until the user quits -------------------------------------
|
|
2289
|
-
await new Promise((resolve) => {
|
|
2290
|
-
const onDestroy = () => {
|
|
2291
|
-
// Ctrl+C (exitOnCtrlC) destroys text buffers synchronously. Any
|
|
2292
|
-
// setInterval that survives into the next tick will call setStatus on
|
|
2293
|
-
// a destroyed TextBuffer and throw, so tear down timers and stop
|
|
2294
|
-
// accepting chrome writes here before resolving. Session events that
|
|
2295
|
-
// race the teardown are caught by the `destroyed` guard in setStatus
|
|
2296
|
-
// and the other chrome mutators.
|
|
2297
|
-
destroyed = true;
|
|
2298
|
-
stopWorkingTicker();
|
|
2299
|
-
resolve();
|
|
2300
|
-
};
|
|
2301
|
-
renderer.once("destroy", onDestroy);
|
|
190
|
+
await renderBootScreen({
|
|
191
|
+
renderer,
|
|
192
|
+
transcript: ui.transcript,
|
|
193
|
+
appendLine,
|
|
194
|
+
session: input.session,
|
|
195
|
+
sidebar: ui.sidebar,
|
|
196
|
+
autocomplete,
|
|
197
|
+
starters,
|
|
198
|
+
packageName: input.packageName,
|
|
199
|
+
packageVersion: input.packageVersion,
|
|
200
|
+
workDir: input.workDir,
|
|
201
|
+
modelName: input.modelName,
|
|
202
|
+
memoryModelName: input.memoryModelName,
|
|
203
|
+
upgradeStatus$: input.upgradeStatus$,
|
|
204
|
+
});
|
|
205
|
+
replayResumeHistory({ appendLine, appendBlock, recordTranscriptEntry }, { history: input.history, resumeHistoryMessages: input.resumeHistoryMessages });
|
|
206
|
+
bootstrapInitialPrompt({
|
|
207
|
+
session: input.session,
|
|
208
|
+
initialPrompt: input.initialPrompt,
|
|
209
|
+
statusController,
|
|
210
|
+
stepRenderer,
|
|
211
|
+
appendBlock,
|
|
212
|
+
recordTranscriptEntry,
|
|
213
|
+
reportError,
|
|
214
|
+
});
|
|
215
|
+
await waitForRendererDestroy(renderer, () => {
|
|
216
|
+
// Ctrl+C destroys text buffers synchronously. Tear down timers and
|
|
217
|
+
// stop accepting chrome writes before resolving so in-flight session
|
|
218
|
+
// events do not land on a dead TextBuffer.
|
|
219
|
+
transcriptWriter.markDestroyed();
|
|
220
|
+
statusController.shutdown();
|
|
2302
221
|
});
|
|
2303
222
|
unsubscribe();
|
|
2304
|
-
return lastTerminal;
|
|
2305
|
-
// --------------------------------------------------------------------------
|
|
2306
|
-
function appendDisplayBlock(block) {
|
|
2307
|
-
appendBlock(null, block.content, colorForHistoryBlock(block.kind));
|
|
2308
|
-
}
|
|
2309
|
-
function colorForHistoryBlock(kind) {
|
|
2310
|
-
if (kind === "user")
|
|
2311
|
-
return COLORS.user;
|
|
2312
|
-
if (kind === "reasoning")
|
|
2313
|
-
return COLORS.reasoning;
|
|
2314
|
-
if (kind === "tool")
|
|
2315
|
-
return COLORS.tool;
|
|
2316
|
-
if (kind === "error")
|
|
2317
|
-
return COLORS.error;
|
|
2318
|
-
return COLORS.agent;
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
// OpenTUI's TextBuffer throws a plain Error with this exact message from its
|
|
2322
|
-
// `guard()` method when any setter is touched after destroy. We sniff the
|
|
2323
|
-
// message to distinguish post-teardown races (swallow) from real bugs (rethrow).
|
|
2324
|
-
function isTextBufferDestroyedError(error) {
|
|
2325
|
-
return error instanceof Error && error.message === "TextBuffer is destroyed";
|
|
2326
|
-
}
|
|
2327
|
-
function restoreWindowGlobal(previousWindow) {
|
|
2328
|
-
// OpenTUI installs `window.requestAnimationFrame` for browser-style
|
|
2329
|
-
// animation compatibility. In Bun, the presence of `window` can send fetch
|
|
2330
|
-
// internals down browser-only paths, while `global.requestAnimationFrame`
|
|
2331
|
-
// remains enough for OpenTUI after initialization.
|
|
2332
|
-
if (previousWindow) {
|
|
2333
|
-
Object.defineProperty(globalThis, "window", previousWindow);
|
|
2334
|
-
return;
|
|
2335
|
-
}
|
|
2336
|
-
delete globalThis.window;
|
|
223
|
+
return statusController.lastTerminal();
|
|
2337
224
|
}
|
|
2338
225
|
//# sourceMappingURL=app.js.map
|