@duetso/agent 0.1.46 โ†’ 0.1.48

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 (34) hide show
  1. package/README.md +23 -0
  2. package/dist/package.json +2 -1
  3. package/dist/src/session/session.d.ts +7 -2
  4. package/dist/src/session/session.d.ts.map +1 -1
  5. package/dist/src/session/session.js +1 -0
  6. package/dist/src/session/session.js.map +1 -1
  7. package/dist/src/tui/app.d.ts.map +1 -1
  8. package/dist/src/tui/app.js +320 -43
  9. package/dist/src/tui/app.js.map +1 -1
  10. package/dist/src/tui/autocomplete.d.ts +18 -3
  11. package/dist/src/tui/autocomplete.d.ts.map +1 -1
  12. package/dist/src/tui/autocomplete.js +36 -1
  13. package/dist/src/tui/autocomplete.js.map +1 -1
  14. package/dist/src/tui/history.js +3 -3
  15. package/dist/src/tui/history.js.map +1 -1
  16. package/dist/src/tui/paste.d.ts +113 -0
  17. package/dist/src/tui/paste.d.ts.map +1 -0
  18. package/dist/src/tui/paste.js +505 -0
  19. package/dist/src/tui/paste.js.map +1 -0
  20. package/dist/src/tui/sidebar.d.ts +11 -3
  21. package/dist/src/tui/sidebar.d.ts.map +1 -1
  22. package/dist/src/tui/sidebar.js +46 -9
  23. package/dist/src/tui/sidebar.js.map +1 -1
  24. package/dist/src/tui/tool-formatters.d.ts +30 -4
  25. package/dist/src/tui/tool-formatters.d.ts.map +1 -1
  26. package/dist/src/tui/tool-formatters.js +70 -20
  27. package/dist/src/tui/tool-formatters.js.map +1 -1
  28. package/dist/src/turn-runner/turn-runner.d.ts +15 -2
  29. package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
  30. package/dist/src/turn-runner/turn-runner.js +59 -32
  31. package/dist/src/turn-runner/turn-runner.js.map +1 -1
  32. package/dist/src/types/protocol.d.ts +36 -4
  33. package/dist/src/types/protocol.d.ts.map +1 -1
  34. package/package.json +2 -1
@@ -1,15 +1,16 @@
1
- import { BoxRenderable, createCliRenderer, fg, ScrollBoxRenderable, t, TextRenderable, TextareaRenderable, } from "@opentui/core";
2
- import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, skillAutocompleteMatches, } from "./autocomplete.js";
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
- const skillAutocompleteTitle = new TextRenderable(renderer, {
83
- content: "skills",
84
- fg: COLORS.status,
85
- height: 1,
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
- skillAutocompletePanel.add(skillAutocompleteTitle);
99
- for (const row of skillAutocompleteRows) {
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
- hint.content = running ? HINT_RUNNING : HINT_IDLE;
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
- const elapsed = formatElapsed(Date.now() - block.startedAt);
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 headerLine = isLive ? `${formatted.header} โณ 0s` : `${formatted.header} โณ`;
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.body ? `${headerLine}\n${formatted.body}` : headerLine,
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 marker = isError ? "โœ—" : "โœ“";
526
- const durationSuffix = block.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - block.startedAt)}`;
527
- const headerLine = `${block.header} ${marker}${durationSuffix}`;
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
- const sections = [formatted.body ? `${headerLine}\n${formatted.body}` : headerLine];
536
- if (formatted.result && formatted.result.body) {
537
- sections.push(`${formatted.result.label}\n${formatted.result.body}`);
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
- for (const row of skillAutocompleteRows) {
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
- for (const [index, row] of skillAutocompleteRows.entries()) {
777
- const item = skillAutocompleteItems[index];
778
- if (!item) {
779
- row.visible = false;
780
- row.content = "";
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 = index === skillAutocompleteSelectedIndex;
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 = skills.map((skill) => ({
987
- name: skill.name,
988
- description: skill.description,
989
- path: skill.baseDir,
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();