@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.
- package/README.md +41 -2
- package/dist/package.json +1 -1
- package/dist/src/cli/memory-db.d.ts +1 -1
- package/dist/src/cli/memory-db.js +3 -2
- package/dist/src/cli/memory-db.js.map +1 -1
- package/dist/src/memory/context-pack.d.ts +3 -3
- package/dist/src/memory/context-pack.d.ts.map +1 -1
- package/dist/src/memory/context-pack.js +2 -2
- package/dist/src/memory/loader.d.ts +12 -45
- package/dist/src/memory/loader.d.ts.map +1 -1
- package/dist/src/memory/loader.js +61 -77
- package/dist/src/memory/loader.js.map +1 -1
- package/dist/src/memory/migrations.d.ts.map +1 -1
- package/dist/src/memory/migrations.js +28 -0
- package/dist/src/memory/migrations.js.map +1 -1
- package/dist/src/memory/observational-prompts.d.ts.map +1 -1
- package/dist/src/memory/observational-prompts.js +3 -0
- package/dist/src/memory/observational-prompts.js.map +1 -1
- package/dist/src/memory/observational.d.ts +15 -3
- package/dist/src/memory/observational.d.ts.map +1 -1
- package/dist/src/memory/observational.js +117 -40
- package/dist/src/memory/observational.js.map +1 -1
- package/dist/src/memory/recall.js +2 -1
- package/dist/src/memory/recall.js.map +1 -1
- package/dist/src/memory/storage.d.ts +48 -27
- package/dist/src/memory/storage.d.ts.map +1 -1
- package/dist/src/memory/storage.js +65 -42
- package/dist/src/memory/storage.js.map +1 -1
- package/dist/src/memory/store.d.ts +20 -21
- package/dist/src/memory/store.d.ts.map +1 -1
- package/dist/src/memory/store.js +15 -99
- package/dist/src/memory/store.js.map +1 -1
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +224 -54
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/autocomplete.d.ts.map +1 -1
- package/dist/src/tui/autocomplete.js +5 -0
- package/dist/src/tui/autocomplete.js.map +1 -1
- package/dist/src/tui/clipboard.d.ts +30 -14
- package/dist/src/tui/clipboard.d.ts.map +1 -1
- package/dist/src/tui/clipboard.js +88 -11
- package/dist/src/tui/clipboard.js.map +1 -1
- package/dist/src/tui/theme.d.ts +18 -1
- package/dist/src/tui/theme.d.ts.map +1 -1
- package/dist/src/tui/theme.js +30 -1
- package/dist/src/tui/theme.js.map +1 -1
- package/dist/src/tui/transcript-log.d.ts +1 -1
- package/dist/src/tui/transcript-log.js +1 -1
- package/dist/src/turn-runner/tools.d.ts +1 -1
- package/dist/src/turn-runner/turn-runner.d.ts +2 -2
- package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
- package/dist/src/turn-runner/turn-runner.js +9 -5
- package/dist/src/turn-runner/turn-runner.js.map +1 -1
- package/dist/src/types/memory.d.ts +20 -36
- package/dist/src/types/memory.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/src/tui/app.js
CHANGED
|
@@ -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
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
1699
|
+
const result = await copyTextToClipboard(text);
|
|
1563
1700
|
if (result.ok) {
|
|
1564
|
-
const summary =
|
|
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
|
-
|
|
1768
|
+
refreshHint();
|
|
1599
1769
|
}
|
|
1600
1770
|
catch (error) {
|
|
1601
1771
|
appendBlock("[paste]", error instanceof Error ? error.message : String(error), COLORS.error);
|