@duetso/agent 0.1.81 → 0.1.83

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.
Files changed (82) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/memory/observational.d.ts +7 -0
  3. package/dist/src/memory/observational.d.ts.map +1 -1
  4. package/dist/src/memory/observational.js +7 -1
  5. package/dist/src/memory/observational.js.map +1 -1
  6. package/dist/src/tui/app.d.ts +25 -35
  7. package/dist/src/tui/app.d.ts.map +1 -1
  8. package/dist/src/tui/app.js +195 -2308
  9. package/dist/src/tui/app.js.map +1 -1
  10. package/dist/src/tui/autocomplete-controller.d.ts +83 -0
  11. package/dist/src/tui/autocomplete-controller.d.ts.map +1 -0
  12. package/dist/src/tui/autocomplete-controller.js +308 -0
  13. package/dist/src/tui/autocomplete-controller.js.map +1 -0
  14. package/dist/src/tui/boot-screen.d.ts +64 -0
  15. package/dist/src/tui/boot-screen.d.ts.map +1 -0
  16. package/dist/src/tui/boot-screen.js +137 -0
  17. package/dist/src/tui/boot-screen.js.map +1 -0
  18. package/dist/src/tui/controllers.d.ts +39 -0
  19. package/dist/src/tui/controllers.d.ts.map +1 -0
  20. package/dist/src/tui/controllers.js +53 -0
  21. package/dist/src/tui/controllers.js.map +1 -0
  22. package/dist/src/tui/copy-controller.d.ts +75 -0
  23. package/dist/src/tui/copy-controller.d.ts.map +1 -0
  24. package/dist/src/tui/copy-controller.js +140 -0
  25. package/dist/src/tui/copy-controller.js.map +1 -0
  26. package/dist/src/tui/history-replay.d.ts +33 -0
  27. package/dist/src/tui/history-replay.d.ts.map +1 -0
  28. package/dist/src/tui/history-replay.js +47 -0
  29. package/dist/src/tui/history-replay.js.map +1 -0
  30. package/dist/src/tui/initial-prompt.d.ts +24 -0
  31. package/dist/src/tui/initial-prompt.d.ts.map +1 -0
  32. package/dist/src/tui/initial-prompt.js +29 -0
  33. package/dist/src/tui/initial-prompt.js.map +1 -0
  34. package/dist/src/tui/key-handlers.d.ts +49 -0
  35. package/dist/src/tui/key-handlers.d.ts.map +1 -0
  36. package/dist/src/tui/key-handlers.js +192 -0
  37. package/dist/src/tui/key-handlers.js.map +1 -0
  38. package/dist/src/tui/layout.d.ts +71 -0
  39. package/dist/src/tui/layout.d.ts.map +1 -0
  40. package/dist/src/tui/layout.js +245 -0
  41. package/dist/src/tui/layout.js.map +1 -0
  42. package/dist/src/tui/paste-controller.d.ts +90 -0
  43. package/dist/src/tui/paste-controller.d.ts.map +1 -0
  44. package/dist/src/tui/paste-controller.js +234 -0
  45. package/dist/src/tui/paste-controller.js.map +1 -0
  46. package/dist/src/tui/question-picker.d.ts +111 -0
  47. package/dist/src/tui/question-picker.d.ts.map +1 -0
  48. package/dist/src/tui/question-picker.js +308 -0
  49. package/dist/src/tui/question-picker.js.map +1 -0
  50. package/dist/src/tui/renderer-lifecycle.d.ts +20 -0
  51. package/dist/src/tui/renderer-lifecycle.d.ts.map +1 -0
  52. package/dist/src/tui/renderer-lifecycle.js +50 -0
  53. package/dist/src/tui/renderer-lifecycle.js.map +1 -0
  54. package/dist/src/tui/session-subscription.d.ts +30 -0
  55. package/dist/src/tui/session-subscription.d.ts.map +1 -0
  56. package/dist/src/tui/session-subscription.js +78 -0
  57. package/dist/src/tui/session-subscription.js.map +1 -0
  58. package/dist/src/tui/slash-commands.d.ts +23 -0
  59. package/dist/src/tui/slash-commands.d.ts.map +1 -0
  60. package/dist/src/tui/slash-commands.js +79 -0
  61. package/dist/src/tui/slash-commands.js.map +1 -0
  62. package/dist/src/tui/starter-section.d.ts +99 -0
  63. package/dist/src/tui/starter-section.d.ts.map +1 -0
  64. package/dist/src/tui/starter-section.js +254 -0
  65. package/dist/src/tui/starter-section.js.map +1 -0
  66. package/dist/src/tui/status-controller.d.ts +94 -0
  67. package/dist/src/tui/status-controller.d.ts.map +1 -0
  68. package/dist/src/tui/status-controller.js +186 -0
  69. package/dist/src/tui/status-controller.js.map +1 -0
  70. package/dist/src/tui/step-renderer.d.ts +55 -0
  71. package/dist/src/tui/step-renderer.d.ts.map +1 -0
  72. package/dist/src/tui/step-renderer.js +246 -0
  73. package/dist/src/tui/step-renderer.js.map +1 -0
  74. package/dist/src/tui/transcript-writer.d.ts +63 -0
  75. package/dist/src/tui/transcript-writer.d.ts.map +1 -0
  76. package/dist/src/tui/transcript-writer.js +130 -0
  77. package/dist/src/tui/transcript-writer.js.map +1 -0
  78. package/dist/src/turn-runner/turn-runner.d.ts +69 -3
  79. package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
  80. package/dist/src/turn-runner/turn-runner.js +123 -20
  81. package/dist/src/turn-runner/turn-runner.js.map +1 -1
  82. package/package.json +1 -1
@@ -1,2338 +1,225 @@
1
- import { BoxRenderable, createCliRenderer, decodePasteBytes, fg, ScrollBoxRenderable, t, TextRenderable, TextareaRenderable, } from "@opentui/core";
2
- import { writeClipboardText } from "./clipboard.js";
3
- import { describeMacClipboardTypes, loadImageFromPath, looksLikeImageFilePath, persistPastedImage, sniffImageMimeType, tryReadClipboardImage, tryReadClipboardText, } from "./paste.js";
4
- import { parseCopyArgument, selectCopyText } from "./transcript-log.js";
5
- import { describeUpgradeStatus, } from "../cli/auto-upgrade.js";
6
- import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, BUILT_IN_SLASH_COMMANDS, commitActiveAnswer, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionHighlight, moveSkillAutocompleteSelection, NO_HIGHLIGHT, replaceFileAutocompleteToken, restoreSavedAnswer, skillAutocompleteMatches, } from "./autocomplete.js";
7
- import { homedir } from "node:os";
8
- import { submitDuetFeedback } from "../lib/feedback.js";
9
- import { buildFileIndex } from "./file-index.js";
10
- import { DUET_BANNER_LINES_COMPACT, historyDisplayBlocks, limitHistoryDisplayMessages, } from "./history.js";
11
- import { listRecentSessions } from "./recent-sessions.js";
12
- import { createSidebar, SIDEBAR_WIDTH } from "./sidebar.js";
13
- import { orderedSelectableStarters, selectStarters } from "./starters.js";
14
- import { COLORS, HINT_IDLE, HINT_RUNNING, HINT_SELECTION_COPY } from "./theme.js";
15
- // Re-exports preserve the historical `tui/app.js` entry point used by tests
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. Resolves with the most recent
25
- * terminal event (if any) when the user exits the UI.
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
- // useMouse: true so the scroll wheel reaches the transcript
33
- // ScrollBoxRenderable and so OpenTUI receives drag events for in-app
34
- // text selection. Selected text is captured via the renderer's
35
- // `selection` event below and copied to the clipboard via OSC 52 (or
36
- // CLI fallback) on the platform-appropriate copy keystroke (Cmd+C on
37
- // macOS, Ctrl+Shift+C elsewhere) or `/copy`. PageUp/PageDown and
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.on("selection", (selection) => {
70
- lastSelectionText = selection.getSelectedText();
71
- logSelectionDiag(lastSelectionText);
72
- refreshHint();
73
- });
74
- // Outer row wraps the main column and a right-side sidebar that surfaces
75
- // the runner's current todo list and state-machine progress.
76
- const root = new BoxRenderable(renderer, {
77
- flexDirection: "row",
78
- width: "100%",
79
- height: "100%",
80
- });
81
- const layout = new BoxRenderable(renderer, {
82
- flexDirection: "column",
83
- flexGrow: 1,
84
- flexShrink: 1,
85
- height: "100%",
86
- });
87
- const sidebar = createSidebar(renderer);
88
- const transcript = new ScrollBoxRenderable(renderer, {
89
- flexGrow: 1,
90
- flexShrink: 1,
91
- scrollY: true,
92
- // Pin to bottom as new lines arrive, but only while the user has not
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
- // ---- session subscription --------------------------------------------------
477
- function refreshSidebar() {
478
- const state = input.session.getState();
479
- sidebar.setTodos(state?.todos ?? []);
480
- sidebar.setFollowUpQueue(state?.followUpQueue ?? []);
481
- sidebar.setStateMachine(state?.stateMachine);
482
- const snap = input.session.getLastUsage();
483
- sidebar.setUsage(snap
484
- ? {
485
- type: "usage",
486
- usage: snap.usage,
487
- effectiveContextWindow: snap.effectiveContextWindow,
488
- contextWindowUsage: snap.contextWindowUsage,
489
- }
490
- : undefined);
491
- sidebar.setSessionCost(input.session.getSessionCostUsd());
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
- function renderSkillAutocomplete() {
1383
- skillAutocompletePanel.visible = skillAutocompleteItems.length > 0;
1384
- // Distribute matched items into the two section row pools by group. The
1385
- // selection index navigates the flat list, so we track each item's flat
1386
- // position to highlight the correct row regardless of section.
1387
- const groups = {
1388
- commands: { rows: commandRows, cursor: 0 },
1389
- skills: { rows: skillRows, cursor: 0 },
1390
- };
1391
- for (const row of [...commandRows, ...skillRows]) {
1392
- row.visible = false;
1393
- row.content = "";
1394
- }
1395
- for (const [flatIndex, item] of skillAutocompleteItems.entries()) {
1396
- const groupKey = item.group ?? "skills";
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
- // Keyboard scroll bindings for the transcript. Mirrors the mouse wheel
1512
- // for terminals that swallow mouse events (tmux without mouse mode, ssh
1513
- // sessions where the local terminal owns the wheel, screen readers).
1514
- // Page = one viewport; Shift+arrow = three lines, matching wheel cadence.
1515
- function scrollTranscriptByLines(delta) {
1516
- transcript.scrollBy({ x: 0, y: delta });
1517
- }
1518
- function scrollTranscriptByPage(direction) {
1519
- // Subtract 2 to account for the top+bottom border rows; padding lives
1520
- // inside the scroll viewport and does not need to be deducted.
1521
- const viewport = Math.max(1, transcript.height - 2);
1522
- transcript.scrollBy({ x: 0, y: direction * viewport });
1523
- }
1524
- // Attach directly to the focused InputRenderable. The Textarea-based input
1525
- // consumes escape via its own keybindings before any global keypress handler
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 (!running)
108
+ if (!statusController.isRunning())
1742
109
  return;
1743
110
  void input.session.interrupt().catch(reportError);
1744
111
  }
1745
- // Ctrl+Enter sends the composer text with behavior:"steer" the
1746
- // runner routes this to agent.steer() when the agent is mid-inference
1747
- // (most of an active turn), which preempts the current LLM call and
1748
- // injects the message into the next iteration. If the runner is
1749
- // between inference calls (e.g. mid-tool-call) the steer-flagged
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 (submittedImages.length > 0) {
1770
- const lines = submittedImages.map((p) => `📎 ${p.label}: ${p.path}`).join("\n");
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 = submittedImages.map((p) => p.attachment);
1774
- void input.session.prompt({ message, behavior: "steer", images }).catch(reportError);
1775
- if (!running) {
1776
- markRunning();
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
- appendBlock("[diag]", "Usage: /diag (or /diag keys) — toggles key + selection event logging", COLORS.system);
130
+ void input.session.prompt({ message, behavior, images }).catch(reportError);
131
+ if (!statusController.isRunning())
132
+ statusController.markRunning();
1797
133
  }
1798
- function logKeyDiag(label, key) {
1799
- if (!keyDiagnostics)
1800
- return;
1801
- const flags = [];
1802
- if (key.ctrl)
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
- appendBlock("[diag]", `selection event: ${text.length} chars — ${JSON.stringify(text.slice(0, 80))}`, COLORS.hint);
140
+ ui.inputField.clear();
141
+ dispatchTurn(message, "steer");
1818
142
  }
1819
- /**
1820
- * Copy keystroke detection. Accepts Cmd+C, Cmd+Shift+C, and Ctrl+Shift+C
1821
- * because each mainstream terminal forwards a different subset — see
1822
- * `theme.ts` for which one ends up in the hint label per terminal.
1823
- * Returns true (and prevents default) when the keystroke matched and a
1824
- * non-empty selection was on the clipboard path; false otherwise so the
1825
- * caller can fall through to other handlers (Esc, etc.).
1826
- *
1827
- * Accepts both "c" and "C" as the key name because some kitty parsers
1828
- * report the shifted letter while others report the base letter with
1829
- * `shift: true`.
1830
- */
1831
- function handleCopyKeystroke(key) {
1832
- const isCopyLetter = key.name === "c" || key.name === "C";
1833
- if (!isCopyLetter)
1834
- return false;
1835
- const cmdHeld = key.super || key.meta;
1836
- const isCmdC = cmdHeld && !key.shift && !key.ctrl;
1837
- const isCmdShiftC = cmdHeld && key.shift && !key.ctrl;
1838
- const isCtrlShiftC = key.ctrl && key.shift && !cmdHeld;
1839
- if (!(isCmdC || isCmdShiftC || isCtrlShiftC))
1840
- return false;
1841
- if (lastSelectionText.trim().length === 0)
1842
- return false;
1843
- key.preventDefault();
1844
- void copyActiveSelection();
1845
- return true;
1846
- }
1847
- inputField.onContentChange = () => {
1848
- // Typing hides the starter chrome; backspacing the composer empty
1849
- // brings it back (until the user actually submits a prompt, at which
1850
- // point it's gone for good).
1851
- syncStarterVisibility();
1852
- refreshAutocomplete();
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 = () => refreshAutocomplete();
1855
- // Paste handling. Terminals that forward binary clipboard contents (kitty,
1856
- // ghostty, recent iTerm2 builds) deliver image bytes directly via the paste
1857
- // event we intercept those, persist them under the session cache, and
1858
- // surface a `[Image #N]` placeholder in the prompt buffer. Plain text pastes
1859
- // fall through to the Textarea's default insert path so existing behavior
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
- async function handlePasteEvent(event) {
1867
- const metadata = event.metadata;
1868
- const sniffed = sniffImageMimeType(event.bytes);
1869
- const inferredMime = metadata?.mimeType && metadata.mimeType.startsWith("image/") ? metadata.mimeType : sniffed;
1870
- // Synchronous fast paths — the paste payload itself is enough to decide.
1871
- if (inferredMime) {
1872
- event.preventDefault();
1873
- await attachPastedImageBytes(event.bytes, inferredMime);
1874
- return;
1875
- }
1876
- if (metadata?.kind === "binary") {
1877
- // Non-image binary paste — we cannot meaningfully forward it, but the
1878
- // terminal already swallowed the keystroke, so suppress the default
1879
- // text-insert path that would otherwise garble the prompt.
1880
- event.preventDefault();
1881
- appendBlock("[paste]", "Unsupported binary clipboard contents (only PNG/JPEG/GIF/WebP).", COLORS.system);
1882
- return;
1883
- }
1884
- // Text-shaped paste. Three sub-cases, ordered cheapest first so common
1885
- // text pastes never wait on the macOS Swift clipboard probe:
1886
- //
1887
- // 1. The text resolves to an image file path (Finder/Files drag-paste).
1888
- // 2. The terminal forwarded an empty payload but the OS clipboard
1889
- // may carry an image promise (e.g. Figma "Copy as PNG", screenshot,
1890
- // browser image copy that bracketed-paste cannot represent).
1891
- // 3. Plain text just insert it.
1892
- //
1893
- // Sub-cases 1 and 3 are fully synchronous after the path heuristic, so
1894
- // the buffer paints immediately. Only sub-case 2 spawns the Swift
1895
- // probe, and only when there is literally no text to insert anyway.
1896
- const originalText = decodePasteBytes(event.bytes);
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