@duetso/agent 0.1.57 → 0.1.59

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 (56) hide show
  1. package/README.md +41 -2
  2. package/dist/package.json +1 -1
  3. package/dist/src/cli/memory-db.d.ts +1 -1
  4. package/dist/src/cli/memory-db.js +3 -2
  5. package/dist/src/cli/memory-db.js.map +1 -1
  6. package/dist/src/memory/context-pack.d.ts +3 -3
  7. package/dist/src/memory/context-pack.d.ts.map +1 -1
  8. package/dist/src/memory/context-pack.js +2 -2
  9. package/dist/src/memory/loader.d.ts +12 -45
  10. package/dist/src/memory/loader.d.ts.map +1 -1
  11. package/dist/src/memory/loader.js +61 -77
  12. package/dist/src/memory/loader.js.map +1 -1
  13. package/dist/src/memory/migrations.d.ts.map +1 -1
  14. package/dist/src/memory/migrations.js +28 -0
  15. package/dist/src/memory/migrations.js.map +1 -1
  16. package/dist/src/memory/observational-prompts.d.ts.map +1 -1
  17. package/dist/src/memory/observational-prompts.js +3 -0
  18. package/dist/src/memory/observational-prompts.js.map +1 -1
  19. package/dist/src/memory/observational.d.ts +15 -3
  20. package/dist/src/memory/observational.d.ts.map +1 -1
  21. package/dist/src/memory/observational.js +117 -40
  22. package/dist/src/memory/observational.js.map +1 -1
  23. package/dist/src/memory/recall.js +2 -1
  24. package/dist/src/memory/recall.js.map +1 -1
  25. package/dist/src/memory/storage.d.ts +48 -27
  26. package/dist/src/memory/storage.d.ts.map +1 -1
  27. package/dist/src/memory/storage.js +65 -42
  28. package/dist/src/memory/storage.js.map +1 -1
  29. package/dist/src/memory/store.d.ts +20 -21
  30. package/dist/src/memory/store.d.ts.map +1 -1
  31. package/dist/src/memory/store.js +15 -99
  32. package/dist/src/memory/store.js.map +1 -1
  33. package/dist/src/tui/app.d.ts.map +1 -1
  34. package/dist/src/tui/app.js +224 -54
  35. package/dist/src/tui/app.js.map +1 -1
  36. package/dist/src/tui/autocomplete.d.ts.map +1 -1
  37. package/dist/src/tui/autocomplete.js +5 -0
  38. package/dist/src/tui/autocomplete.js.map +1 -1
  39. package/dist/src/tui/clipboard.d.ts +30 -14
  40. package/dist/src/tui/clipboard.d.ts.map +1 -1
  41. package/dist/src/tui/clipboard.js +88 -11
  42. package/dist/src/tui/clipboard.js.map +1 -1
  43. package/dist/src/tui/theme.d.ts +18 -1
  44. package/dist/src/tui/theme.d.ts.map +1 -1
  45. package/dist/src/tui/theme.js +30 -1
  46. package/dist/src/tui/theme.js.map +1 -1
  47. package/dist/src/tui/transcript-log.d.ts +1 -1
  48. package/dist/src/tui/transcript-log.js +1 -1
  49. package/dist/src/turn-runner/tools.d.ts +1 -1
  50. package/dist/src/turn-runner/turn-runner.d.ts +2 -2
  51. package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
  52. package/dist/src/turn-runner/turn-runner.js +9 -5
  53. package/dist/src/turn-runner/turn-runner.js.map +1 -1
  54. package/dist/src/types/memory.d.ts +20 -36
  55. package/dist/src/types/memory.d.ts.map +1 -1
  56. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { DUET_BANNER_LINES_COMPACT, historyDisplayBlocks, limitHistoryDisplayMes
9
9
  import { listRecentSessions } from "./recent-sessions.js";
10
10
  import { createSidebar, SIDEBAR_WIDTH } from "./sidebar.js";
11
11
  import { orderedSelectableStarters, selectStarters } from "./starters.js";
12
- import { COLORS, HINT_IDLE, HINT_RUNNING } from "./theme.js";
12
+ import { COLORS, HINT_IDLE, HINT_RUNNING, HINT_SELECTION_COPY } from "./theme.js";
13
13
  // Re-exports preserve the historical `tui/app.js` entry point used by tests
14
14
  // and external callers; the implementations live in focused leaf modules.
15
15
  export { activeFileAutocompleteToken, activeSkillAutocompleteToken, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, skillAutocompleteMatches, } from "./autocomplete.js";
@@ -30,11 +30,17 @@ const QUESTION_OPTION_LIMIT = AUTOCOMPLETE_LIMITS.questionOption;
30
30
  export async function runTui(input) {
31
31
  const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
32
32
  // useMouse: true so the scroll wheel reaches the transcript
33
- // ScrollBoxRenderable. Drag-select for native copy still works in every
34
- // mainstream terminal by holding Option (macOS) or Shift (most Linux
35
- // terminals) while dragging, which bypasses the terminal's mouse
36
- // capture. PageUp/PageDown and Shift+Up/Down keyboard bindings below are
37
- // the no-mouse fallback.
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.
38
44
  const renderer = await createCliRenderer({
39
45
  exitOnCtrlC: true,
40
46
  useMouse: true,
@@ -42,6 +48,17 @@ export async function runTui(input) {
42
48
  targetFps: 60,
43
49
  });
44
50
  restoreWindowGlobal(previousWindow);
51
+ // Most recent drag-selected text. OpenTUI emits the `selection` event
52
+ // whenever a drag finishes; we cache the resulting string so /copy and
53
+ // the copy keystroke can prefer the user's actual highlight over the
54
+ // last-message heuristic, and so the bottom hint can advertise the
55
+ // copy keystroke only while it actually does something.
56
+ let lastSelectionText = "";
57
+ renderer.on("selection", (selection) => {
58
+ lastSelectionText = selection.getSelectedText();
59
+ logSelectionDiag(lastSelectionText);
60
+ refreshHint();
61
+ });
45
62
  // Outer row wraps the main column and a right-side sidebar that surfaces
46
63
  // the runner's current todo list and state-machine progress.
47
64
  const root = new BoxRenderable(renderer, {
@@ -71,17 +88,22 @@ export async function runTui(input) {
71
88
  borderColor: COLORS.border,
72
89
  padding: 1,
73
90
  });
91
+ // Status and hint chrome are excluded from drag-select so a highlight that
92
+ // sweeps the bottom of the screen does not pull the spinner / hint text
93
+ // into the clipboard alongside the transcript content the user wanted.
74
94
  const status = new TextRenderable(renderer, {
75
95
  content: "",
76
96
  fg: COLORS.status,
77
97
  height: 1,
78
98
  flexShrink: 0,
99
+ selectable: false,
79
100
  });
80
101
  const hint = new TextRenderable(renderer, {
81
102
  content: HINT_IDLE,
82
103
  fg: COLORS.hint,
83
104
  height: 1,
84
105
  flexShrink: 0,
106
+ selectable: false,
85
107
  });
86
108
  const skillAutocompletePanel = new BoxRenderable(renderer, {
87
109
  flexDirection: "column",
@@ -99,11 +121,15 @@ export async function runTui(input) {
99
121
  // length so a one-line description doesn't leave an empty trailing line
100
122
  // beneath the name. The renderer sets `height` whenever it writes
101
123
  // `content`.
124
+ // Autocomplete and panel chrome are not part of the transcript content,
125
+ // so exclude them from drag-select to keep the clipboard focused on
126
+ // assistant/user messages.
102
127
  const makeItemRow = () => {
103
128
  const row = new TextRenderable(renderer, {
104
129
  content: "",
105
130
  fg: COLORS.hint,
106
131
  flexShrink: 0,
132
+ selectable: false,
107
133
  });
108
134
  row.visible = false;
109
135
  return row;
@@ -113,6 +139,7 @@ export async function runTui(input) {
113
139
  fg: COLORS.status,
114
140
  height: 1,
115
141
  flexShrink: 0,
142
+ selectable: false,
116
143
  });
117
144
  const commandHeader = makeHeaderRow("commands");
118
145
  const commandRows = Array.from({ length: BUILT_IN_SLASH_COMMANDS.length }, makeItemRow);
@@ -140,6 +167,7 @@ export async function runTui(input) {
140
167
  fg: COLORS.status,
141
168
  height: 1,
142
169
  flexShrink: 0,
170
+ selectable: false,
143
171
  });
144
172
  const fileAutocompleteRows = Array.from({ length: FILE_AUTOCOMPLETE_LIMIT }, () => {
145
173
  const row = new TextRenderable(renderer, {
@@ -147,6 +175,7 @@ export async function runTui(input) {
147
175
  fg: COLORS.hint,
148
176
  height: 1,
149
177
  flexShrink: 0,
178
+ selectable: false,
150
179
  });
151
180
  row.visible = false;
152
181
  return row;
@@ -169,11 +198,13 @@ export async function runTui(input) {
169
198
  fg: COLORS.agent,
170
199
  wrapMode: "word",
171
200
  flexShrink: 0,
201
+ selectable: false,
172
202
  });
173
203
  const questionSpacer = new TextRenderable(renderer, {
174
204
  content: "",
175
205
  height: 1,
176
206
  flexShrink: 0,
207
+ selectable: false,
177
208
  });
178
209
  const questionRows = Array.from({ length: QUESTION_OPTION_LIMIT }, () => {
179
210
  const row = new TextRenderable(renderer, {
@@ -181,6 +212,7 @@ export async function runTui(input) {
181
212
  fg: COLORS.hint,
182
213
  wrapMode: "word",
183
214
  flexShrink: 0,
215
+ selectable: false,
184
216
  });
185
217
  row.visible = false;
186
218
  return row;
@@ -198,10 +230,14 @@ export async function runTui(input) {
198
230
  paddingRight: 1,
199
231
  flexShrink: 0,
200
232
  });
233
+ // The leading "> " sigil is decoration, not content; excluding it from
234
+ // selection means a drag that starts at the input row does not pull the
235
+ // sigil into the clipboard alongside the highlighted text.
201
236
  const prompt = new TextRenderable(renderer, {
202
237
  content: "> ",
203
238
  fg: COLORS.user,
204
239
  width: 2,
240
+ selectable: false,
205
241
  });
206
242
  // Textarea (rather than Input) so long messages soft-wrap visually. Enter
207
243
  // is intercepted in onKeyDown below to submit instead of inserting a newline.
@@ -252,13 +288,21 @@ export async function runTui(input) {
252
288
  }
253
289
  function setHint(running) {
254
290
  const base = running ? HINT_RUNNING : HINT_IDLE;
255
- hint.content = pendingImages.length > 0 ? `${attachmentHint()} · ${base}` : base;
291
+ const segments = [];
292
+ if (pendingImages.length > 0)
293
+ segments.push(attachmentHint());
294
+ segments.push(base);
295
+ if (lastSelectionText.trim().length > 0)
296
+ segments.push(HINT_SELECTION_COPY);
297
+ hint.content = segments.join(" · ");
256
298
  }
257
299
  function attachmentHint() {
258
300
  const n = pendingImages.length;
259
301
  return n === 1 ? "📎 1 image attached" : `📎 ${n} images attached`;
260
302
  }
261
- function refreshAttachmentHint() {
303
+ // Single-channel hint refresh used by every input that affects what the
304
+ // bottom row should advertise (running state, attachments, selection).
305
+ function refreshHint() {
262
306
  setHint(running);
263
307
  }
264
308
  // ---- runtime state ---------------------------------------------------------
@@ -272,7 +316,7 @@ export async function runTui(input) {
272
316
  let nextImageId = 1;
273
317
  // Parallel record of user/agent message bodies driven by the same code
274
318
  // paths that render them into the transcript. The `/copy` slash command
275
- // and Ctrl+Y keystroke read from this log instead of trying to walk the
319
+ // and copy keystroke read from this log instead of trying to walk the
276
320
  // ScrollBoxRenderable, which only stores presentation lines.
277
321
  const transcriptLog = [];
278
322
  function recordTranscriptEntry(kind, text) {
@@ -850,29 +894,6 @@ export async function runTui(input) {
850
894
  let pendingQuestions = [];
851
895
  let questionOptionSelectedIndex = 0;
852
896
  let suppressNextEscapeExit = false;
853
- let closingAfterInterrupt = false;
854
- const requestExit = async () => {
855
- if (running) {
856
- if (closingAfterInterrupt)
857
- return;
858
- closingAfterInterrupt = true;
859
- stopWorkingTicker();
860
- setStatus("● interrupting…");
861
- try {
862
- await input.session.interrupt();
863
- await input.session.waitForTerminal();
864
- }
865
- catch (error) {
866
- reportError(error);
867
- }
868
- finally {
869
- renderer.destroy();
870
- }
871
- }
872
- else {
873
- renderer.destroy();
874
- }
875
- };
876
897
  function skillAutocompleteIsOpen() {
877
898
  return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
878
899
  }
@@ -1126,6 +1147,14 @@ export async function runTui(input) {
1126
1147
  }
1127
1148
  const keyHandler = renderer._keyHandler;
1128
1149
  keyHandler.onInternal("keypress", (key) => {
1150
+ logKeyDiag("global", key);
1151
+ // Copy keystroke. Lives on the global handler (not
1152
+ // inputField.onKeyDown) because the mousedown that starts a
1153
+ // drag-select moves focus off the textarea — the focused-renderable
1154
+ // path stops firing right when the user has something to copy. The
1155
+ // global handler always fires regardless of focus.
1156
+ if (handleCopyKeystroke(key))
1157
+ return;
1129
1158
  if (key.name !== "escape")
1130
1159
  return;
1131
1160
  if (suppressNextEscapeExit) {
@@ -1149,7 +1178,7 @@ export async function runTui(input) {
1149
1178
  return;
1150
1179
  }
1151
1180
  key.preventDefault();
1152
- void requestExit();
1181
+ handleEscape();
1153
1182
  });
1154
1183
  // Keyboard scroll bindings for the transcript. Mirrors the mouse wheel
1155
1184
  // for terminals that swallow mouse events (tmux without mouse mode, ssh
@@ -1168,6 +1197,7 @@ export async function runTui(input) {
1168
1197
  // consumes escape via its own keybindings before any global keypress handler
1169
1198
  // fires, so we intercept at the Renderable's onKeyDown hook which runs first.
1170
1199
  inputField.onKeyDown = (key) => {
1200
+ logKeyDiag("keydown", key);
1171
1201
  if (key.name === "pageup") {
1172
1202
  scrollTranscriptByPage(-1);
1173
1203
  key.preventDefault();
@@ -1232,15 +1262,6 @@ export async function runTui(input) {
1232
1262
  void triggerClipboardProbe("keystroke");
1233
1263
  return;
1234
1264
  }
1235
- // Ctrl+Y — emacs-style "yank," repurposed here as the keyboard hotkey
1236
- // for the `/copy` slash command. macOS Cmd+C is owned by the terminal
1237
- // emulator and never reaches the TUI, so Ctrl+Y is the closest thing
1238
- // to a real copy keystroke we can deliver across all terminals.
1239
- if (key.name === "y" && key.ctrl && !key.shift && !key.super && !key.meta) {
1240
- key.preventDefault();
1241
- void handleCopySlashCommand("/copy");
1242
- return;
1243
- }
1244
1265
  if (skillAutocompleteIsOpen()) {
1245
1266
  if (key.name === "up") {
1246
1267
  skillAutocompleteSelectedIndex = moveSkillAutocompleteSelection(skillAutocompleteSelectedIndex, skillAutocompleteItems.length, -1);
@@ -1335,10 +1356,88 @@ export async function runTui(input) {
1335
1356
  return;
1336
1357
  }
1337
1358
  if (key.name === "escape") {
1338
- void requestExit();
1359
+ handleEscape();
1339
1360
  return;
1340
1361
  }
1341
1362
  };
1363
+ // Esc interrupts the in-flight turn; when nothing is running it is a
1364
+ // no-op so muscle memory does not eject the user out of the session.
1365
+ // Quitting goes through Ctrl+C (renderer's exitOnCtrlC) or closing the
1366
+ // terminal — both paths drain through the `finally` block in
1367
+ // cli/run.ts that disposes the SessionManager and flushes PGlite.
1368
+ function handleEscape() {
1369
+ if (!running)
1370
+ return;
1371
+ void input.session.interrupt().catch(reportError);
1372
+ }
1373
+ // ---- /diag diagnostics -----------------------------------------------------
1374
+ // `/diag` toggles a key+selection event log so the user can show us
1375
+ // exactly what their terminal forwards when something silently fails
1376
+ // (e.g. a keystroke not reaching the handler, a selection event firing
1377
+ // with empty text). Kept as a flag rather than a one-shot capture so
1378
+ // we can layer additional diagnostic facets on the same surface
1379
+ // without inventing new commands every time.
1380
+ let keyDiagnostics = false;
1381
+ function handleDiagSlashCommand(raw) {
1382
+ const argument = raw === "/diag" ? "" : raw.slice("/diag ".length).trim();
1383
+ if (argument === "" || argument === "keys") {
1384
+ keyDiagnostics = !keyDiagnostics;
1385
+ appendBlock("[diag]", keyDiagnostics
1386
+ ? "key + selection event logging ON. Run /diag again to stop."
1387
+ : "key + selection event logging OFF.", COLORS.system);
1388
+ return;
1389
+ }
1390
+ appendBlock("[diag]", "Usage: /diag (or /diag keys) — toggles key + selection event logging", COLORS.system);
1391
+ }
1392
+ function logKeyDiag(label, key) {
1393
+ if (!keyDiagnostics)
1394
+ return;
1395
+ const flags = [];
1396
+ if (key.ctrl)
1397
+ flags.push("ctrl");
1398
+ if (key.shift)
1399
+ flags.push("shift");
1400
+ if (key.meta)
1401
+ flags.push("meta");
1402
+ if (key.super)
1403
+ flags.push("super");
1404
+ if (key.option)
1405
+ flags.push("option");
1406
+ 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);
1407
+ }
1408
+ function logSelectionDiag(text) {
1409
+ if (!keyDiagnostics)
1410
+ return;
1411
+ appendBlock("[diag]", `selection event: ${text.length} chars — ${JSON.stringify(text.slice(0, 80))}`, COLORS.hint);
1412
+ }
1413
+ /**
1414
+ * Copy keystroke detection. Accepts Cmd+C, Cmd+Shift+C, and Ctrl+Shift+C
1415
+ * because each mainstream terminal forwards a different subset — see
1416
+ * `theme.ts` for which one ends up in the hint label per terminal.
1417
+ * Returns true (and prevents default) when the keystroke matched and a
1418
+ * non-empty selection was on the clipboard path; false otherwise so the
1419
+ * caller can fall through to other handlers (Esc, etc.).
1420
+ *
1421
+ * Accepts both "c" and "C" as the key name because some kitty parsers
1422
+ * report the shifted letter while others report the base letter with
1423
+ * `shift: true`.
1424
+ */
1425
+ function handleCopyKeystroke(key) {
1426
+ const isCopyLetter = key.name === "c" || key.name === "C";
1427
+ if (!isCopyLetter)
1428
+ return false;
1429
+ const cmdHeld = key.super || key.meta;
1430
+ const isCmdC = cmdHeld && !key.shift && !key.ctrl;
1431
+ const isCmdShiftC = cmdHeld && key.shift && !key.ctrl;
1432
+ const isCtrlShiftC = key.ctrl && key.shift && !cmdHeld;
1433
+ if (!(isCmdC || isCmdShiftC || isCtrlShiftC))
1434
+ return false;
1435
+ if (lastSelectionText.trim().length === 0)
1436
+ return false;
1437
+ key.preventDefault();
1438
+ void copyActiveSelection();
1439
+ return true;
1440
+ }
1342
1441
  inputField.onContentChange = () => {
1343
1442
  // First real keystroke into the input collapses the starter section
1344
1443
  // — the user is composing their own prompt, so the suggestions get
@@ -1405,7 +1504,7 @@ export async function runTui(input) {
1405
1504
  pendingImages.push(pending);
1406
1505
  inputField.insertText(pending.label);
1407
1506
  appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
1408
- refreshAttachmentHint();
1507
+ refreshHint();
1409
1508
  }
1410
1509
  catch (error) {
1411
1510
  // The clipboard looked like an image path but we could not load
@@ -1445,7 +1544,7 @@ export async function runTui(input) {
1445
1544
  pendingImages.push(pending);
1446
1545
  inputField.insertText(pending.label);
1447
1546
  appendBlock("[paste]", `attached ${pending.label} (${mimeType}, ${formatBytes(bytes.length)})`, COLORS.system);
1448
- refreshAttachmentHint();
1547
+ refreshHint();
1449
1548
  }
1450
1549
  catch (error) {
1451
1550
  appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
@@ -1456,7 +1555,7 @@ export async function runTui(input) {
1456
1555
  return;
1457
1556
  pendingImages = [];
1458
1557
  nextImageId = 1;
1459
- refreshAttachmentHint();
1558
+ refreshHint();
1460
1559
  }
1461
1560
  // Manual clipboard probe. Read the OS clipboard for an image right now and
1462
1561
  // attach it if found; otherwise emit a useful diagnostic line. Used both by
@@ -1516,6 +1615,10 @@ export async function runTui(input) {
1516
1615
  void handleCopySlashCommand(message);
1517
1616
  return;
1518
1617
  }
1618
+ if (message === "/diag" || message.startsWith("/diag ")) {
1619
+ handleDiagSlashCommand(message);
1620
+ return;
1621
+ }
1519
1622
  const submittedImages = pendingImages;
1520
1623
  recordTranscriptEntry("user", message);
1521
1624
  appendBlock("you:", message, COLORS.user);
@@ -1541,27 +1644,63 @@ export async function runTui(input) {
1541
1644
  markRunning();
1542
1645
  }
1543
1646
  }
1647
+ /**
1648
+ * Copy the active drag-selection to the clipboard and clear the highlight
1649
+ * so the user gets visual confirmation the action happened. Used by the
1650
+ * platform copy keystroke (Cmd+C on macOS, Ctrl+Shift+C elsewhere); the
1651
+ * slash command path goes through `handleCopySlashCommand` so it can
1652
+ * also serve `/copy last|all|<N>`.
1653
+ */
1654
+ async function copyActiveSelection() {
1655
+ const text = lastSelectionText;
1656
+ if (text.trim().length === 0)
1657
+ return;
1658
+ const result = await copyTextToClipboard(text);
1659
+ renderer.clearSelection();
1660
+ lastSelectionText = "";
1661
+ refreshHint();
1662
+ if (result.ok) {
1663
+ appendBlock("[copy]", `copied selection (${text.length} char${text.length === 1 ? "" : "s"}) to clipboard via ${result.via}`, COLORS.system);
1664
+ }
1665
+ else {
1666
+ appendBlock("[copy]", `clipboard write failed: ${result.error ?? "unknown error"}` +
1667
+ (process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
1668
+ }
1669
+ }
1544
1670
  /**
1545
1671
  * Resolve a `/copy ...` invocation to clipboard text and pipe it to the
1546
- * OS clipboard. Surfaces every failure mode in the transcript so users on
1547
- * minimal Linux installs see exactly which writer is missing (e.g.
1548
- * "install xclip or wl-clipboard").
1672
+ * OS clipboard. When the user has an active drag-selection and ran a bare
1673
+ * `/copy`, copy that highlight verbatim it matches what they actually
1674
+ * have on screen. Otherwise fall back to the transcript-log heuristic
1675
+ * (`last` / `all` / `<N>`).
1676
+ *
1677
+ * Failures are surfaced in the transcript so users on minimal Linux
1678
+ * installs see exactly which writer is missing.
1549
1679
  */
1550
1680
  async function handleCopySlashCommand(raw) {
1551
1681
  const argumentRaw = raw === "/copy" ? "" : raw.slice("/copy ".length);
1552
1682
  const argument = parseCopyArgument(argumentRaw);
1553
1683
  if (argument === undefined) {
1554
- appendBlock("[copy]", "Usage: /copy [last|all|<N>] — last (default) copies the most recent agent reply", COLORS.system);
1684
+ appendBlock("[copy]", "Usage: /copy [last|all|<N>] — last (default) copies the most recent agent reply, " +
1685
+ "or copies the active drag-selection when one is present", COLORS.system);
1555
1686
  return;
1556
1687
  }
1557
- const text = selectCopyText(transcriptLog, argument);
1688
+ // A bare `/copy` (or the copy keystroke while a selection is active)
1689
+ // prefers the drag-selection so the clipboard matches what the user
1690
+ // has highlighted on screen; an explicit `/copy last|all|<N>` always
1691
+ // uses the transcript log instead.
1692
+ const explicitArgument = argumentRaw.trim().length > 0;
1693
+ const useSelection = !explicitArgument && lastSelectionText.trim().length > 0;
1694
+ const text = useSelection ? lastSelectionText : selectCopyText(transcriptLog, argument);
1558
1695
  if (!text) {
1559
1696
  appendBlock("[copy]", "nothing to copy yet", COLORS.system);
1560
1697
  return;
1561
1698
  }
1562
- const result = await writeClipboardText(text);
1699
+ const result = await copyTextToClipboard(text);
1563
1700
  if (result.ok) {
1564
- const summary = describeCopySelection(argument, text.length);
1701
+ const summary = useSelection
1702
+ ? `selection (${text.length} char${text.length === 1 ? "" : "s"})`
1703
+ : describeCopySelection(argument, text.length);
1565
1704
  appendBlock("[copy]", `copied ${summary} to clipboard via ${result.via}`, COLORS.system);
1566
1705
  }
1567
1706
  else {
@@ -1569,6 +1708,37 @@ export async function runTui(input) {
1569
1708
  (process.platform === "linux" ? "\nInstall one of: wl-clipboard, xclip, xsel" : ""), COLORS.error);
1570
1709
  }
1571
1710
  }
1711
+ /**
1712
+ * Two-stage clipboard write. The platform-native CLI (pbcopy / wl-copy /
1713
+ * xclip / xsel / clip.exe) goes first because it actually writes to the
1714
+ * OS clipboard and — critically — `writeClipboardText` reads the
1715
+ * clipboard back through pbpaste / wl-paste / xclip -o to confirm the
1716
+ * bytes landed. Exit-code-only success is not enough: pbcopy from inside
1717
+ * a raw-mode TUI on Warp/macOS exits 0 without actually updating
1718
+ * NSPasteboard, and OSC 52 has the same silent-drop problem on Warp.
1719
+ *
1720
+ * OSC 52 is only the fallback when no local CLI is available at all
1721
+ * (e.g. an SSH session with no clipboard tool installed remotely). When
1722
+ * a local CLI ran but failed verification we surface that error
1723
+ * directly instead of falling through to OSC 52, because OSC 52 would
1724
+ * also silently "succeed" on the same broken terminals and hide the
1725
+ * real failure behind a fake "copied via OSC 52" line.
1726
+ */
1727
+ async function copyTextToClipboard(text) {
1728
+ const cli = await writeClipboardText(text);
1729
+ if (cli.ok)
1730
+ return cli;
1731
+ // Only fall back to OSC 52 when the CLI was simply unavailable. If a
1732
+ // CLI ran but the readback did not match (cli.kind ===
1733
+ // "verification-failed"), OSC 52 is on the same broken pipe and
1734
+ // would silently "succeed" the same way — surface the real error.
1735
+ if (cli.kind === "no-writer" &&
1736
+ renderer.isOsc52Supported() &&
1737
+ renderer.copyToClipboardOSC52(text)) {
1738
+ return { ok: true, via: "OSC 52" };
1739
+ }
1740
+ return cli;
1741
+ }
1572
1742
  function describeCopySelection(argument, length) {
1573
1743
  const chars = `${length} char${length === 1 ? "" : "s"}`;
1574
1744
  if (argument === "last")
@@ -1595,7 +1765,7 @@ export async function runTui(input) {
1595
1765
  // can keep typing their prompt with the image already attached.
1596
1766
  inputField.insertText(pending.label);
1597
1767
  appendBlock("[paste]", `attached ${pending.label} from ${pending.path}`, COLORS.system);
1598
- refreshAttachmentHint();
1768
+ refreshHint();
1599
1769
  }
1600
1770
  catch (error) {
1601
1771
  appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);