@duetso/agent 0.1.45 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/package.json +2 -1
- package/dist/src/session/session.d.ts +7 -2
- package/dist/src/session/session.d.ts.map +1 -1
- package/dist/src/session/session.js +1 -0
- package/dist/src/session/session.js.map +1 -1
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +320 -43
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/autocomplete.d.ts +18 -3
- package/dist/src/tui/autocomplete.d.ts.map +1 -1
- package/dist/src/tui/autocomplete.js +36 -1
- package/dist/src/tui/autocomplete.js.map +1 -1
- package/dist/src/tui/history.js +3 -3
- package/dist/src/tui/history.js.map +1 -1
- package/dist/src/tui/paste.d.ts +113 -0
- package/dist/src/tui/paste.d.ts.map +1 -0
- package/dist/src/tui/paste.js +505 -0
- package/dist/src/tui/paste.js.map +1 -0
- package/dist/src/tui/sidebar.d.ts +11 -3
- package/dist/src/tui/sidebar.d.ts.map +1 -1
- package/dist/src/tui/sidebar.js +28 -5
- package/dist/src/tui/sidebar.js.map +1 -1
- package/dist/src/tui/tool-formatters.d.ts +29 -4
- package/dist/src/tui/tool-formatters.d.ts.map +1 -1
- package/dist/src/tui/tool-formatters.js +65 -16
- package/dist/src/tui/tool-formatters.js.map +1 -1
- package/dist/src/turn-runner/skills.d.ts.map +1 -1
- package/dist/src/turn-runner/skills.js +22 -8
- package/dist/src/turn-runner/skills.js.map +1 -1
- package/dist/src/turn-runner/turn-runner.d.ts +15 -2
- package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
- package/dist/src/turn-runner/turn-runner.js +59 -32
- package/dist/src/turn-runner/turn-runner.js.map +1 -1
- package/dist/src/types/protocol.d.ts +36 -4
- package/dist/src/types/protocol.d.ts.map +1 -1
- package/package.json +2 -1
package/dist/src/tui/app.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { BoxRenderable, createCliRenderer, fg, ScrollBoxRenderable, t, TextRenderable, TextareaRenderable, } from "@opentui/core";
|
|
2
|
-
import {
|
|
1
|
+
import { BoxRenderable, createCliRenderer, decodePasteBytes, fg, ScrollBoxRenderable, t, TextRenderable, TextareaRenderable, } from "@opentui/core";
|
|
2
|
+
import { describeMacClipboardTypes, loadImageFromPath, looksLikeImageFilePath, persistPastedImage, sniffImageMimeType, tryReadClipboardImage, tryReadClipboardText, } from "./paste.js";
|
|
3
|
+
import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, BUILT_IN_SLASH_COMMANDS, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, skillAutocompleteMatches, } from "./autocomplete.js";
|
|
3
4
|
import { buildFileIndex } from "./file-index.js";
|
|
4
5
|
import { DUET_BANNER_LINES, historyDisplayBlocks, limitHistoryDisplayBlocks, startupHeaderLines, } from "./history.js";
|
|
5
|
-
import { createSidebar } from "./sidebar.js";
|
|
6
|
+
import { createSidebar, SIDEBAR_WIDTH } from "./sidebar.js";
|
|
6
7
|
import { COLORS, HINT_IDLE, HINT_RUNNING } from "./theme.js";
|
|
7
8
|
// Re-exports preserve the historical `tui/app.js` entry point used by tests
|
|
8
9
|
// and external callers; the implementations live in focused leaf modules.
|
|
9
10
|
export { activeFileAutocompleteToken, activeSkillAutocompleteToken, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, skillAutocompleteMatches, } from "./autocomplete.js";
|
|
10
11
|
export { formatSkillAutocompleteItem } from "./autocomplete.js";
|
|
11
12
|
export { DUET_BANNER_LINES, historyDisplayBlocks, limitHistoryDisplayBlocks, startupHeaderLines, } from "./history.js";
|
|
12
|
-
import { formatToolBlock, truncateToolText } from "./tool-formatters.js";
|
|
13
|
+
import { assembleToolBlock, formatToolBlock, truncateToolText } from "./tool-formatters.js";
|
|
13
14
|
const SKILL_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.skill;
|
|
14
15
|
const FILE_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.file;
|
|
15
16
|
const QUESTION_OPTION_LIMIT = AUTOCOMPLETE_LIMITS.questionOption;
|
|
@@ -79,13 +80,10 @@ export async function runTui(input) {
|
|
|
79
80
|
flexShrink: 0,
|
|
80
81
|
});
|
|
81
82
|
skillAutocompletePanel.visible = false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
flexShrink: 0,
|
|
87
|
-
});
|
|
88
|
-
const skillAutocompleteRows = Array.from({ length: SKILL_AUTOCOMPLETE_LIMIT }, () => {
|
|
83
|
+
// Two ordered sections: built-in commands first, skills second. Each
|
|
84
|
+
// section has its own header row plus a fixed pool of item rows. Selection
|
|
85
|
+
// navigates the flat ordered list of visible items across both sections.
|
|
86
|
+
const makeItemRow = () => {
|
|
89
87
|
const row = new TextRenderable(renderer, {
|
|
90
88
|
content: "",
|
|
91
89
|
fg: COLORS.hint,
|
|
@@ -94,11 +92,23 @@ export async function runTui(input) {
|
|
|
94
92
|
});
|
|
95
93
|
row.visible = false;
|
|
96
94
|
return row;
|
|
95
|
+
};
|
|
96
|
+
const makeHeaderRow = (label) => new TextRenderable(renderer, {
|
|
97
|
+
content: label,
|
|
98
|
+
fg: COLORS.status,
|
|
99
|
+
height: 1,
|
|
100
|
+
flexShrink: 0,
|
|
97
101
|
});
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
const commandHeader = makeHeaderRow("commands");
|
|
103
|
+
const commandRows = Array.from({ length: BUILT_IN_SLASH_COMMANDS.length }, makeItemRow);
|
|
104
|
+
const skillHeader = makeHeaderRow("skills");
|
|
105
|
+
const skillRows = Array.from({ length: SKILL_AUTOCOMPLETE_LIMIT }, makeItemRow);
|
|
106
|
+
skillAutocompletePanel.add(commandHeader);
|
|
107
|
+
for (const row of commandRows)
|
|
108
|
+
skillAutocompletePanel.add(row);
|
|
109
|
+
skillAutocompletePanel.add(skillHeader);
|
|
110
|
+
for (const row of skillRows)
|
|
100
111
|
skillAutocompletePanel.add(row);
|
|
101
|
-
}
|
|
102
112
|
// The @-file picker mirrors the slash picker's structure so the renderer
|
|
103
113
|
// logic and key handling can stay parallel between the two pickers.
|
|
104
114
|
const fileAutocompletePanel = new BoxRenderable(renderer, {
|
|
@@ -226,12 +236,31 @@ export async function runTui(input) {
|
|
|
226
236
|
status.content = text;
|
|
227
237
|
}
|
|
228
238
|
function setHint(running) {
|
|
229
|
-
|
|
239
|
+
const base = running ? HINT_RUNNING : HINT_IDLE;
|
|
240
|
+
hint.content = pendingImages.length > 0 ? `${attachmentHint()} · ${base}` : base;
|
|
241
|
+
}
|
|
242
|
+
function attachmentHint() {
|
|
243
|
+
const n = pendingImages.length;
|
|
244
|
+
return n === 1 ? "📎 1 image attached" : `📎 ${n} images attached`;
|
|
245
|
+
}
|
|
246
|
+
function refreshAttachmentHint() {
|
|
247
|
+
setHint(running);
|
|
230
248
|
}
|
|
231
249
|
// ---- runtime state ---------------------------------------------------------
|
|
232
250
|
let running = false;
|
|
251
|
+
// Image attachments collected via paste / `/image` and forwarded to the
|
|
252
|
+
// runner with the next prompt submission. Cleared after submit so each turn
|
|
253
|
+
// ships its own attachments without leaking into the next.
|
|
254
|
+
let pendingImages = [];
|
|
255
|
+
// Monotonic counter for the next `[Image #N]` placeholder. Reset alongside
|
|
256
|
+
// `pendingImages` so users see a fresh `#1` label after each submit.
|
|
257
|
+
let nextImageId = 1;
|
|
233
258
|
let lastTerminal;
|
|
234
259
|
let latestContextUsage;
|
|
260
|
+
// Running USD total across every settled turn in this session. Reset only
|
|
261
|
+
// by exiting the TUI; resumed sessions start fresh because per-turn usage
|
|
262
|
+
// events are not replayed from persisted state.
|
|
263
|
+
let sessionCost = 0;
|
|
235
264
|
let activeTextStream;
|
|
236
265
|
let activeReasoningStream;
|
|
237
266
|
// Tool calls fire twice (running → completed/error). Track the rendered
|
|
@@ -270,14 +299,20 @@ export async function runTui(input) {
|
|
|
270
299
|
function refreshActiveToolBlocks() {
|
|
271
300
|
if (activeToolBlocks.size === 0)
|
|
272
301
|
return;
|
|
302
|
+
const columns = toolBlockColumns();
|
|
273
303
|
for (const block of activeToolBlocks.values()) {
|
|
274
304
|
if (block.startedAt === undefined)
|
|
275
305
|
continue;
|
|
276
|
-
|
|
277
|
-
const headerLine = `${block.header} ⏳ ${elapsed}`;
|
|
278
|
-
block.line.content = block.body ? `${headerLine}\n${block.body}` : headerLine;
|
|
306
|
+
block.line.content = assembleToolBlock({ header: block.header, body: block.body || undefined }, runningMarker(Date.now() - block.startedAt), { columns });
|
|
279
307
|
}
|
|
280
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Spinner marker for an in-flight tool call. Hides the elapsed counter for
|
|
311
|
+
* sub-second runs so a transcript of fast tools is not littered with "0s".
|
|
312
|
+
*/
|
|
313
|
+
function runningMarker(elapsedMs) {
|
|
314
|
+
return elapsedMs >= 1000 ? `⏳ ${formatElapsed(elapsedMs)}` : "⏳";
|
|
315
|
+
}
|
|
281
316
|
function startWorkingTicker() {
|
|
282
317
|
if (workingTicker !== undefined)
|
|
283
318
|
return;
|
|
@@ -316,6 +351,7 @@ export async function runTui(input) {
|
|
|
316
351
|
sidebar.setFollowUpQueue(state?.followUpQueue ?? []);
|
|
317
352
|
sidebar.setStateMachine(state?.stateMachine);
|
|
318
353
|
sidebar.setContextUsage(latestContextUsage);
|
|
354
|
+
sidebar.setSessionCost(sessionCost);
|
|
319
355
|
}
|
|
320
356
|
const unsubscribe = input.session.subscribe((event) => {
|
|
321
357
|
refreshSidebar();
|
|
@@ -400,6 +436,8 @@ export async function runTui(input) {
|
|
|
400
436
|
function renderUsage(usage) {
|
|
401
437
|
if (!usage)
|
|
402
438
|
return;
|
|
439
|
+
sessionCost += usage.cost.total;
|
|
440
|
+
sidebar.setSessionCost(sessionCost);
|
|
403
441
|
const parts = [`in=${usage.input}`, `out=${usage.output}`];
|
|
404
442
|
if (usage.cacheRead > 0)
|
|
405
443
|
parts.push(`cached=${usage.cacheRead}`);
|
|
@@ -480,6 +518,15 @@ export async function runTui(input) {
|
|
|
480
518
|
// whether the call should appear in the transcript at all (e.g.
|
|
481
519
|
// ask_user_question hides itself live and lets the `ask` terminal event
|
|
482
520
|
// own the question display).
|
|
521
|
+
// Width budget for a tool block: terminal width minus the fixed sidebar
|
|
522
|
+
// column and a small fudge for borders/padding. Recomputed per render so a
|
|
523
|
+
// resize after a tool block lands updates new blocks; existing blocks keep
|
|
524
|
+
// the width they were rendered at, which is acceptable since the renderer
|
|
525
|
+
// would otherwise re-wrap and could exceed the row cap.
|
|
526
|
+
function toolBlockColumns() {
|
|
527
|
+
const transcriptColumnPadding = 4;
|
|
528
|
+
return Math.max(20, renderer.terminalWidth - SIDEBAR_WIDTH - transcriptColumnPadding);
|
|
529
|
+
}
|
|
483
530
|
function renderToolCall(step) {
|
|
484
531
|
const existing = activeToolBlocks.get(step.toolCallId);
|
|
485
532
|
if (existing) {
|
|
@@ -498,10 +545,11 @@ export async function runTui(input) {
|
|
|
498
545
|
if (formatted.hidden)
|
|
499
546
|
return;
|
|
500
547
|
const startedAt = isLive ? Date.now() : undefined;
|
|
501
|
-
const
|
|
548
|
+
const marker = "⏳";
|
|
502
549
|
const fg = step.status === "error" ? COLORS.error : COLORS.tool;
|
|
550
|
+
const columns = toolBlockColumns();
|
|
503
551
|
const line = new TextRenderable(renderer, {
|
|
504
|
-
content: formatted
|
|
552
|
+
content: assembleToolBlock(formatted, marker, { columns }),
|
|
505
553
|
fg,
|
|
506
554
|
});
|
|
507
555
|
beginBlock();
|
|
@@ -522,9 +570,11 @@ export async function runTui(input) {
|
|
|
522
570
|
}
|
|
523
571
|
function finalizeToolCall(step, block) {
|
|
524
572
|
const isError = step.status === "error";
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
|
|
573
|
+
const glyph = isError ? "✗" : "✓";
|
|
574
|
+
const elapsedMs = block.startedAt === undefined ? 0 : Date.now() - block.startedAt;
|
|
575
|
+
// Sub-second runs drop the elapsed suffix so the transcript does not get
|
|
576
|
+
// littered with "0s" markers from fast tools (read, ls, todo_write, …).
|
|
577
|
+
const durationSuffix = elapsedMs >= 1000 ? ` ${formatElapsed(elapsedMs)}` : "";
|
|
528
578
|
const formatted = formatToolBlock({
|
|
529
579
|
toolName: step.toolName,
|
|
530
580
|
status: isError ? "error" : "completed",
|
|
@@ -532,11 +582,9 @@ export async function runTui(input) {
|
|
|
532
582
|
output: step.output,
|
|
533
583
|
mode: "live",
|
|
534
584
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
block.line.content = sections.join("\n");
|
|
585
|
+
block.line.content = assembleToolBlock(formatted, `${glyph}${durationSuffix}`, {
|
|
586
|
+
columns: toolBlockColumns(),
|
|
587
|
+
});
|
|
540
588
|
block.line.fg = isError ? COLORS.error : COLORS.tool;
|
|
541
589
|
activeToolBlocks.delete(step.toolCallId);
|
|
542
590
|
}
|
|
@@ -628,7 +676,9 @@ export async function runTui(input) {
|
|
|
628
676
|
skillAutocompleteItems = [];
|
|
629
677
|
skillAutocompleteSelectedIndex = 0;
|
|
630
678
|
skillAutocompletePanel.visible = false;
|
|
631
|
-
|
|
679
|
+
commandHeader.visible = false;
|
|
680
|
+
skillHeader.visible = false;
|
|
681
|
+
for (const row of [...commandRows, ...skillRows]) {
|
|
632
682
|
row.visible = false;
|
|
633
683
|
row.content = "";
|
|
634
684
|
}
|
|
@@ -773,14 +823,25 @@ export async function runTui(input) {
|
|
|
773
823
|
}
|
|
774
824
|
function renderSkillAutocomplete() {
|
|
775
825
|
skillAutocompletePanel.visible = skillAutocompleteItems.length > 0;
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
826
|
+
// Distribute matched items into the two section row pools by group. The
|
|
827
|
+
// selection index navigates the flat list, so we track each item's flat
|
|
828
|
+
// position to highlight the correct row regardless of section.
|
|
829
|
+
const groups = {
|
|
830
|
+
commands: { rows: commandRows, cursor: 0 },
|
|
831
|
+
skills: { rows: skillRows, cursor: 0 },
|
|
832
|
+
};
|
|
833
|
+
for (const row of [...commandRows, ...skillRows]) {
|
|
834
|
+
row.visible = false;
|
|
835
|
+
row.content = "";
|
|
836
|
+
}
|
|
837
|
+
for (const [flatIndex, item] of skillAutocompleteItems.entries()) {
|
|
838
|
+
const groupKey = item.group ?? "skills";
|
|
839
|
+
const slot = groups[groupKey];
|
|
840
|
+
const row = slot.rows[slot.cursor];
|
|
841
|
+
if (!row)
|
|
781
842
|
continue;
|
|
782
|
-
|
|
783
|
-
const selected =
|
|
843
|
+
slot.cursor += 1;
|
|
844
|
+
const selected = flatIndex === skillAutocompleteSelectedIndex;
|
|
784
845
|
const nameColor = selected ? COLORS.status : COLORS.user;
|
|
785
846
|
const pathColor = selected ? COLORS.agent : COLORS.hint;
|
|
786
847
|
const description = formatSkillAutocompleteDescription(item.description);
|
|
@@ -790,6 +851,8 @@ export async function runTui(input) {
|
|
|
790
851
|
row.fg = selected ? COLORS.agent : COLORS.hint;
|
|
791
852
|
row.visible = true;
|
|
792
853
|
}
|
|
854
|
+
commandHeader.visible = groups.commands.cursor > 0;
|
|
855
|
+
skillHeader.visible = groups.skills.cursor > 0;
|
|
793
856
|
}
|
|
794
857
|
function renderFileAutocomplete() {
|
|
795
858
|
fileAutocompletePanel.visible = fileAutocompleteItems.length > 0;
|
|
@@ -874,6 +937,22 @@ export async function runTui(input) {
|
|
|
874
937
|
// consumes escape via its own keybindings before any global keypress handler
|
|
875
938
|
// fires, so we intercept at the Renderable's onKeyDown hook which runs first.
|
|
876
939
|
inputField.onKeyDown = (key) => {
|
|
940
|
+
// Cmd+V / Ctrl+V keystroke trigger. Many terminals (Warp in particular,
|
|
941
|
+
// and macOS Terminal.app for binary clipboards) do not forward a paste
|
|
942
|
+
// event to TUI programs on Cmd+V — they handle the clipboard at the app
|
|
943
|
+
// level and only deliver the resulting text. We catch the keystroke here
|
|
944
|
+
// and probe the OS clipboard directly so image attach works regardless of
|
|
945
|
+
// whether the terminal cooperates with bracketed paste for binary data.
|
|
946
|
+
//
|
|
947
|
+
// The keystroke handler only fires on terminals that actually deliver
|
|
948
|
+
// Cmd+V as a keypress (kitty-keyboard-aware terminals: kitty, Ghostty,
|
|
949
|
+
// recent iTerm2, WezTerm). For terminals that swallow Cmd+V entirely,
|
|
950
|
+
// the `/paste` slash command below provides a guaranteed fallback.
|
|
951
|
+
if (key.name === "v" && (key.super || key.meta || key.ctrl) && !key.shift) {
|
|
952
|
+
key.preventDefault();
|
|
953
|
+
void triggerClipboardProbe("keystroke");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
877
956
|
if (skillAutocompleteIsOpen()) {
|
|
878
957
|
if (key.name === "up") {
|
|
879
958
|
skillAutocompleteSelectedIndex = moveSkillAutocompleteSelection(skillAutocompleteSelectedIndex, skillAutocompleteItems.length, -1);
|
|
@@ -965,17 +1044,211 @@ export async function runTui(input) {
|
|
|
965
1044
|
};
|
|
966
1045
|
inputField.onContentChange = () => refreshAutocomplete();
|
|
967
1046
|
inputField.onCursorChange = () => refreshAutocomplete();
|
|
1047
|
+
// Paste handling. Terminals that forward binary clipboard contents (kitty,
|
|
1048
|
+
// ghostty, recent iTerm2 builds) deliver image bytes directly via the paste
|
|
1049
|
+
// event — we intercept those, persist them under the session cache, and
|
|
1050
|
+
// surface a `[Image #N]` placeholder in the prompt buffer. Plain text pastes
|
|
1051
|
+
// fall through to the Textarea's default insert path so existing behavior
|
|
1052
|
+
// is unchanged for non-image clipboards.
|
|
1053
|
+
inputField.onPaste = (event) => {
|
|
1054
|
+
void handlePasteEvent(event).catch((error) => {
|
|
1055
|
+
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
async function handlePasteEvent(event) {
|
|
1059
|
+
const metadata = event.metadata;
|
|
1060
|
+
const sniffed = sniffImageMimeType(event.bytes);
|
|
1061
|
+
const inferredMime = metadata?.mimeType && metadata.mimeType.startsWith("image/") ? metadata.mimeType : sniffed;
|
|
1062
|
+
// Synchronous fast paths — the paste payload itself is enough to decide.
|
|
1063
|
+
if (inferredMime) {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
await attachPastedImageBytes(event.bytes, inferredMime);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (metadata?.kind === "binary") {
|
|
1069
|
+
// Non-image binary paste — we cannot meaningfully forward it, but the
|
|
1070
|
+
// terminal already swallowed the keystroke, so suppress the default
|
|
1071
|
+
// text-insert path that would otherwise garble the prompt.
|
|
1072
|
+
event.preventDefault();
|
|
1073
|
+
appendBlock("[paste]", "Unsupported binary clipboard contents (only PNG/JPEG/GIF/WebP).", COLORS.system);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
// Async path — the paste was text-shaped, but the OS clipboard may hold
|
|
1077
|
+
// an image (e.g. Figma "Copy as PNG", screenshot, browser image copy)
|
|
1078
|
+
// that the terminal could not forward as bytes. Suppress the default
|
|
1079
|
+
// text-insert NOW, synchronously, before awaiting the clipboard probe;
|
|
1080
|
+
// otherwise the InputRenderable inserts the placeholder text first and
|
|
1081
|
+
// we end up with both the path and the [Image #N] in the buffer.
|
|
1082
|
+
event.preventDefault();
|
|
1083
|
+
const originalText = decodePasteBytes(event.bytes);
|
|
1084
|
+
const clipboardImage = await tryReadClipboardImage();
|
|
1085
|
+
if (clipboardImage) {
|
|
1086
|
+
await attachPastedImageBytes(clipboardImage.bytes, clipboardImage.mimeType);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
// No image on the clipboard. Opportunistically auto-attach if the paste
|
|
1090
|
+
// text resolves to a single existing image file path (Finder / Files
|
|
1091
|
+
// drag-paste pattern).
|
|
1092
|
+
const candidate = looksLikeImageFilePath(originalText);
|
|
1093
|
+
if (candidate) {
|
|
1094
|
+
try {
|
|
1095
|
+
const pending = await loadImageFromPath({
|
|
1096
|
+
cwd: input.workDir,
|
|
1097
|
+
rawPath: candidate,
|
|
1098
|
+
id: nextImageId,
|
|
1099
|
+
});
|
|
1100
|
+
nextImageId += 1;
|
|
1101
|
+
pendingImages.push(pending);
|
|
1102
|
+
inputField.insertText(pending.label);
|
|
1103
|
+
appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
|
|
1104
|
+
refreshAttachmentHint();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
// The clipboard looked like an image path but we could not load it.
|
|
1109
|
+
// Surface the reason so users do not see silent fallthrough — most
|
|
1110
|
+
// commonly a path that no longer exists or whose bytes do not match
|
|
1111
|
+
// an image MIME header. Restore the original text below so the user
|
|
1112
|
+
// can edit it manually instead of losing what they pasted.
|
|
1113
|
+
appendBlock("[paste]", `looked like an image path but could not attach ${candidate}: ${error instanceof Error ? error.message : String(error)}`, COLORS.system);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Plain text paste — we suppressed the default insert above, so put the
|
|
1117
|
+
// text back into the prompt manually.
|
|
1118
|
+
if (originalText.length > 0) {
|
|
1119
|
+
inputField.insertText(originalText);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
async function attachPastedImageBytes(bytes, mimeType) {
|
|
1123
|
+
try {
|
|
1124
|
+
const pending = await persistPastedImage({
|
|
1125
|
+
sessionId: input.sessionId,
|
|
1126
|
+
id: nextImageId,
|
|
1127
|
+
bytes,
|
|
1128
|
+
mimeType,
|
|
1129
|
+
});
|
|
1130
|
+
nextImageId += 1;
|
|
1131
|
+
pendingImages.push(pending);
|
|
1132
|
+
inputField.insertText(pending.label);
|
|
1133
|
+
appendBlock("[paste]", `attached ${pending.label} (${mimeType}, ${formatBytes(bytes.length)})`, COLORS.system);
|
|
1134
|
+
refreshAttachmentHint();
|
|
1135
|
+
}
|
|
1136
|
+
catch (error) {
|
|
1137
|
+
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function clearPendingImages() {
|
|
1141
|
+
if (pendingImages.length === 0)
|
|
1142
|
+
return;
|
|
1143
|
+
pendingImages = [];
|
|
1144
|
+
nextImageId = 1;
|
|
1145
|
+
refreshAttachmentHint();
|
|
1146
|
+
}
|
|
1147
|
+
// Manual clipboard probe. Read the OS clipboard for an image right now and
|
|
1148
|
+
// attach it if found; otherwise emit a useful diagnostic line. Used both by
|
|
1149
|
+
// the Cmd+V/Ctrl+V keystroke handler above and the `/paste` slash command.
|
|
1150
|
+
async function triggerClipboardProbe(source) {
|
|
1151
|
+
try {
|
|
1152
|
+
const clipboardImage = await tryReadClipboardImage();
|
|
1153
|
+
if (clipboardImage) {
|
|
1154
|
+
await attachPastedImageBytes(clipboardImage.bytes, clipboardImage.mimeType);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
// No image on the clipboard. The keystroke path may have eaten a
|
|
1158
|
+
// legitimate text paste, so fall back to a text probe so users do not
|
|
1159
|
+
// lose what they were trying to paste.
|
|
1160
|
+
const text = await tryReadClipboardText();
|
|
1161
|
+
if (text) {
|
|
1162
|
+
inputField.insertText(text);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (source === "slash") {
|
|
1166
|
+
// Surface the actual clipboard UTI list when a /paste probe comes
|
|
1167
|
+
// up empty — lets users see what their source app actually put
|
|
1168
|
+
// there so the failure stops being mysterious.
|
|
1169
|
+
const types = await describeMacClipboardTypes();
|
|
1170
|
+
const detail = types
|
|
1171
|
+
? ` — clipboard types: ${types}`
|
|
1172
|
+
: " — (could not query clipboard types; clipboard may be empty)";
|
|
1173
|
+
appendBlock("[paste]", `clipboard had no readable image or text${detail}`, COLORS.system);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
appendBlock("[paste]", `clipboard probe failed: ${error instanceof Error ? error.message : String(error)}`, COLORS.error);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
968
1180
|
function submit(message, shiftEnter) {
|
|
1181
|
+
// Slash-style attach commands run locally and never reach the runner so
|
|
1182
|
+
// users on terminals that do not forward image bytes still have a way to
|
|
1183
|
+
// attach images by path.
|
|
1184
|
+
if (message.startsWith("/image ") || message === "/image") {
|
|
1185
|
+
void handleImageSlashCommand(message);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (message === "/paste") {
|
|
1189
|
+
void triggerClipboardProbe("slash");
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (message === "/clear-images") {
|
|
1193
|
+
clearPendingImages();
|
|
1194
|
+
appendBlock("[paste]", "cleared pending image attachments", COLORS.system);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
const submittedImages = pendingImages;
|
|
969
1198
|
appendBlock("you:", message, COLORS.user);
|
|
1199
|
+
// Render attachments as a separate hint-colored footnote rather than
|
|
1200
|
+
// inlining them into the user-message block. Keeps the transcript
|
|
1201
|
+
// structure honest — the user message persists exactly as the agent
|
|
1202
|
+
// sees it, and resumed sessions render identically because no extra
|
|
1203
|
+
// text was concatenated.
|
|
1204
|
+
if (submittedImages.length > 0) {
|
|
1205
|
+
const lines = submittedImages.map((p) => `📎 ${p.label}: ${p.path}`).join("\n");
|
|
1206
|
+
appendBlock(null, lines, COLORS.hint);
|
|
1207
|
+
}
|
|
970
1208
|
hideQuestions();
|
|
1209
|
+
const images = submittedImages.map((p) => p.attachment);
|
|
1210
|
+
// Reset before dispatch so an in-flight error does not leave the user
|
|
1211
|
+
// double-charged with the same attachments on retry.
|
|
1212
|
+
clearPendingImages();
|
|
971
1213
|
if (running) {
|
|
972
1214
|
const behavior = shiftEnter ? "follow_up" : "steer";
|
|
973
|
-
void input.session.prompt({ message, behavior }).catch(reportError);
|
|
1215
|
+
void input.session.prompt({ message, behavior, images }).catch(reportError);
|
|
974
1216
|
return;
|
|
975
1217
|
}
|
|
976
|
-
void input.session.prompt({ message, behavior: "follow_up" }).catch(reportError);
|
|
1218
|
+
void input.session.prompt({ message, behavior: "follow_up", images }).catch(reportError);
|
|
977
1219
|
markRunning();
|
|
978
1220
|
}
|
|
1221
|
+
async function handleImageSlashCommand(raw) {
|
|
1222
|
+
const rest = raw.slice("/image".length).trim();
|
|
1223
|
+
if (!rest) {
|
|
1224
|
+
appendBlock("[paste]", "Usage: /image <path> — attach a PNG/JPEG/GIF/WebP from disk", COLORS.system);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
try {
|
|
1228
|
+
const pending = await loadImageFromPath({
|
|
1229
|
+
cwd: input.workDir,
|
|
1230
|
+
rawPath: rest,
|
|
1231
|
+
id: nextImageId,
|
|
1232
|
+
});
|
|
1233
|
+
nextImageId += 1;
|
|
1234
|
+
pendingImages.push(pending);
|
|
1235
|
+
// Insert the placeholder back into the (now-empty) input so the user
|
|
1236
|
+
// can keep typing their prompt with the image already attached.
|
|
1237
|
+
inputField.insertText(pending.label);
|
|
1238
|
+
appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
|
|
1239
|
+
refreshAttachmentHint();
|
|
1240
|
+
}
|
|
1241
|
+
catch (error) {
|
|
1242
|
+
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function formatBytes(n) {
|
|
1246
|
+
if (n < 1024)
|
|
1247
|
+
return `${n} B`;
|
|
1248
|
+
if (n < 1024 * 1024)
|
|
1249
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1250
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
1251
|
+
}
|
|
979
1252
|
// ---- replay history on resume ---------------------------------------------
|
|
980
1253
|
// Setup already ran before the TUI launched, so we can read the resolved
|
|
981
1254
|
// skills/agent-files synchronously through the session getters.
|
|
@@ -983,11 +1256,15 @@ export async function runTui(input) {
|
|
|
983
1256
|
input.session.getSkills(),
|
|
984
1257
|
input.session.getResolvedAgentFiles(),
|
|
985
1258
|
]);
|
|
986
|
-
skillAutocompleteSkills =
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1259
|
+
skillAutocompleteSkills = [
|
|
1260
|
+
...BUILT_IN_SLASH_COMMANDS,
|
|
1261
|
+
...skills.map((skill) => ({
|
|
1262
|
+
name: skill.name,
|
|
1263
|
+
description: skill.description,
|
|
1264
|
+
path: skill.baseDir,
|
|
1265
|
+
group: "skills",
|
|
1266
|
+
})),
|
|
1267
|
+
];
|
|
991
1268
|
refreshAutocomplete();
|
|
992
1269
|
renderSetupIntro(skills, agentFiles);
|
|
993
1270
|
refreshSidebar();
|