@dotdrelle/wiki-manager 0.15.26 → 0.15.28

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 (38) hide show
  1. package/.env.example +18 -5
  2. package/README.md +35 -13
  3. package/agents.docker-compose.override.example.yml +82 -0
  4. package/agents.docker-compose.yml +8 -2
  5. package/docker-compose.override.example.yml +81 -0
  6. package/docker-compose.yml +23 -17
  7. package/package.json +4 -2
  8. package/src/cli/wiki-manager.js +13 -4
  9. package/src/commands/slash.js +53 -39
  10. package/src/core/agentEvents.js +20 -0
  11. package/src/core/agentEvents.test.js +38 -1
  12. package/src/core/buildInfo.json +2 -2
  13. package/src/core/commandFailure.js +81 -0
  14. package/src/core/commandFailure.test.js +67 -0
  15. package/src/core/compose.js +20 -3
  16. package/src/core/composeOverrides.test.js +74 -0
  17. package/src/core/dockerCompose.test.js +69 -2
  18. package/src/core/env.js +59 -0
  19. package/src/core/env.test.js +33 -1
  20. package/src/core/mcp.js +1 -1
  21. package/src/core/setEnvValue.test.js +96 -0
  22. package/src/runtime/client.js +18 -0
  23. package/src/runtime/lifecycle.js +5 -1
  24. package/src/runtime/server.js +43 -1
  25. package/src/runtime/server.test.js +117 -0
  26. package/src/runtime/store.js +47 -0
  27. package/src/runtime/store.test.js +89 -1
  28. package/src/shell/LeftPane.tsx +46 -16
  29. package/src/shell/RightPane.tsx +2 -4
  30. package/src/shell/SetupWizard.tsx +19 -7
  31. package/src/shell/externalLinks.js +24 -2
  32. package/src/shell/openExternal.js +53 -0
  33. package/src/shell/repl.js +33 -10
  34. package/src/shell/repl.test.js +107 -9
  35. package/src/shell/setupWizardPlaceholders.test.js +66 -0
  36. package/src/shell/tui.tsx +54 -25
  37. package/src/shell/useSession.ts +59 -6
  38. package/wiki-workspace +84 -6
@@ -0,0 +1,53 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+
4
+ // `xdg-open` alone is not an opener strategy. It is absent from most container
5
+ // and headless installs, and under WSL it either does not exist or opens a
6
+ // Linux browser that nobody can see. Every failure used to be swallowed by an
7
+ // empty callback, so clicking a link in the ShellUI did strictly nothing and
8
+ // gave no reason why. Try the real candidates for the platform, in order.
9
+ function openerCandidates() {
10
+ if (process.platform === 'darwin') return [['open', []]];
11
+ if (process.platform === 'win32') return [['cmd', ['/c', 'start', '']]];
12
+ const candidates = [];
13
+ // WSL: reach the Windows browser first, otherwise the URL opens in a Linux
14
+ // session the operator is not looking at.
15
+ if (process.env.WSL_DISTRO_NAME || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) {
16
+ candidates.push(['wslview', []], ['/mnt/c/Windows/System32/cmd.exe', ['/c', 'start', '']]);
17
+ }
18
+ candidates.push(['xdg-open', []], ['gio', ['open']], ['sensible-browser', []]);
19
+ return candidates;
20
+ }
21
+
22
+ export function normalizeExternalUrl(value) {
23
+ const text = String(value ?? '').trim();
24
+ if (!text) return null;
25
+ try {
26
+ const parsed = new URL(text);
27
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
28
+ // Validate, but hand back the operator's own string: `toString()` appends a
29
+ // root slash, so the hint would show `http://localhost:3100/` while the
30
+ // rendered line said `http://localhost:3100`. Safe because the URL is
31
+ // passed as an argv element, never through a shell.
32
+ return text;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // Returns the opened URL, or null when no opener on this machine worked. The
39
+ // caller is expected to fall back to something the operator can act on (copy
40
+ // the URL, show it) rather than failing silently.
41
+ export function openExternalUrl(value, { run = execFileSync } = {}) {
42
+ const url = normalizeExternalUrl(value);
43
+ if (!url) return null;
44
+ for (const [command, args] of openerCandidates()) {
45
+ try {
46
+ run(command, [...args, url], { stdio: 'ignore', timeout: 5_000 });
47
+ return url;
48
+ } catch {
49
+ // Missing binary or non-zero exit: try the next candidate.
50
+ }
51
+ }
52
+ return null;
53
+ }
package/src/shell/repl.js CHANGED
@@ -175,6 +175,32 @@ export function conversationMessages(session) {
175
175
  return session.conversations[key];
176
176
  }
177
177
 
178
+ // Ordered from verifiable to hopeful. Mirrors tui.tsx's clipboardCommands():
179
+ // xsel before xclip because xclip must keep running to own the X selection
180
+ // and inherits our stdio, so execFileSync waits on pipes that never close
181
+ // and hangs the whole shell; xsel forks and releases them.
182
+ function clipboardCommands() {
183
+ if (process.platform === 'darwin') return [['pbcopy', []]];
184
+ if (process.platform === 'win32') return [['clip', []]];
185
+ return [
186
+ ['wl-copy', []],
187
+ ['xsel', ['--clipboard', '--input']],
188
+ ['xclip', ['-selection', 'clipboard']],
189
+ ];
190
+ }
191
+
192
+ function copyTextToClipboard(text) {
193
+ for (const [command, args] of clipboardCommands()) {
194
+ try {
195
+ execFileSync(command, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 2_000 });
196
+ return true;
197
+ } catch {
198
+ // Missing binary, wrong display server, or timeout: try the next one.
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+
178
204
  function initialLegacyWelcomeMessage() {
179
205
  return [
180
206
  'Orchestrator agent ready.',
@@ -1500,7 +1526,10 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1500
1526
  }
1501
1527
 
1502
1528
  if (trimmed.startsWith('/')) {
1503
- onStep?.(`Shell: ${trimmed}`);
1529
+ // No command echo here: every caller already records the submitted line
1530
+ // (`input: <line>` in useAgent, readline's own echo in the terminal REPL).
1531
+ // Emitting `Shell: /status` on top produced two log rows for one keystroke,
1532
+ // and only for slash commands — free text got a single row.
1504
1533
  const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
1505
1534
  const messages = conversationMessages(session);
1506
1535
  const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
@@ -1801,17 +1830,11 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1801
1830
  const lastDonna = [...messages].reverse().find((m) => isDonnaRole(m.role));
1802
1831
  if (lastDonna) {
1803
1832
  const text = stripAnsi(colorizeStatus(lastDonna.content)).replace(/\[[0-9;]*m/g, '');
1804
- const clipCmd = process.platform === 'darwin'
1805
- ? { command: 'pbcopy', args: [] }
1806
- : { command: 'xclip', args: ['-selection', 'clipboard'] };
1807
1833
  const prevLabel = spinnerLabel;
1808
1834
  const prevBusy = busy;
1809
- try {
1810
- execFileSync(clipCmd.command, clipCmd.args, { input: text });
1811
- spinnerLabel = 'Copied to clipboard ✓';
1812
- } catch {
1813
- spinnerLabel = 'Copy failed — pbcopy / xclip not available';
1814
- }
1835
+ spinnerLabel = copyTextToClipboard(text)
1836
+ ? 'Copied to clipboard ✓'
1837
+ : 'Copy failed — install wl-copy, xsel or xclip.';
1815
1838
  busy = true;
1816
1839
  rerender();
1817
1840
  setTimeout(() => { spinnerLabel = prevLabel; busy = prevBusy; rerender(); }, 1800);
@@ -21,7 +21,8 @@ import {
21
21
  submitRuntimeTurn,
22
22
  } from './repl.js';
23
23
  import { readFile } from 'node:fs/promises';
24
- import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
24
+ import { httpLinkParts, linkLabel, wrapHttpLinks } from './externalLinks.js';
25
+ import { normalizeExternalUrl, openExternalUrl } from './openExternal.js';
25
26
 
26
27
  test('ShellUI inserts StyledText as a child instead of stringifying it through content', async () => {
27
28
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
@@ -41,7 +42,39 @@ test('ShellUI renders the user header full-width before constraining only its bo
41
42
  assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
42
43
  assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
43
44
  assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
44
- assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
45
+ // The third argument reserves room for [ redo ]; the width stays `columns`.
46
+ assert.match(source, /messageHeaderSegments\(message\.role, columns(, canRedo)?\)/);
47
+ });
48
+
49
+ test('redo is offered on user questions only, next to copy', async () => {
50
+ const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
51
+ assert.match(source, /const canRedo = message\.role === 'user' && raw\.trim\(\)\.length > 0;/);
52
+ assert.match(source, /props\.onRedo\?\.\(line\.redoIndex!, line\.redoContent!\)/);
53
+
54
+ const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
55
+ // The runtime must be truncated first: its conversation is derived from the
56
+ // event log and would be merged straight back in otherwise.
57
+ const truncateIndex = session.indexOf('postRuntimeConversationTruncate(runtimeIndex');
58
+ const spliceIndex = session.indexOf('full.splice(index)');
59
+ const submitIndex = session.indexOf('return submitInput(content)');
60
+ assert.ok(truncateIndex > 0 && spliceIndex > truncateIndex && submitIndex > spliceIndex);
61
+ assert.match(session, /const windowOffset = Math\.max\(0, full\.length - messages\(\)\.length\);/);
62
+ });
63
+
64
+ test('redo translates the local thread index into a runtime conversation index', async () => {
65
+ const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
66
+ const redo = session.slice(
67
+ session.indexOf('async function redoMessage('),
68
+ session.indexOf('function updateInput('),
69
+ );
70
+ // The local thread holds entries the runtime never recorded (slash-command
71
+ // output, help panels). Passing the raw local index to /conversation/truncate
72
+ // is what produced `index_out_of_range`; `backed` is the translation table.
73
+ assert.match(redo, /const backed = runtimeConversationRefsByWorkspace\.get\(workspaceKey\)/);
74
+ assert.match(redo, /let runtimeIndex = backed\.indexOf\(full\[index\]\)/);
75
+ assert.match(redo, /if \(full\.indexOf\(backed\[i\]\) < index\)/);
76
+ assert.match(redo, /props\.runtime\?\.url && runtimeIndex >= 0/);
77
+ assert.doesNotMatch(redo, /postRuntimeConversationTruncate\(index/);
45
78
  });
46
79
 
47
80
  test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
@@ -144,36 +177,101 @@ test('ShellUI turns HTTP URLs into valid links without trailing punctuation', ()
144
177
  ]);
145
178
  });
146
179
 
147
- test('long ShellUI URLs use one short label with the complete link target', () => {
180
+ test('a link that fits is shown as the URL itself, never as a [link: host] stub', () => {
181
+ // `/openui` prints `Web UI: http://localhost:3100`. The old `[link: host]`
182
+ // label dropped the port, so a failed opener left nothing to type by hand.
183
+ const url = 'http://localhost:3100';
184
+ assert.equal(linkLabel(url, 60), url);
185
+ const links = wrapHttpLinks(`Web UI: ${url}`, 60).flat().filter((part) => part.url);
186
+ assert.deepEqual(links, [{ text: url, url }]);
187
+ });
188
+
189
+ test('long ShellUI URLs shorten to host, keeping the full link target', () => {
148
190
  const url = 'https://example.test/a/very/long/document';
149
191
  const links = wrapHttpLinks(url, 12).flat().filter((part) => part.url);
150
- assert.deepEqual(links, [{ text: '[link: example.test]', url }]);
192
+ // Too narrow even for `host/…`: the host stays whole rather than being
193
+ // truncated into an unusable fragment.
194
+ assert.deepEqual(links, [{ text: 'example.test', url }]);
195
+ assert.equal(linkLabel('https://example.test:8443/a/very/long/document', 20), 'example.test:8443/…');
151
196
  });
152
197
 
153
198
  test('ShellUI makes rendered link lines openable with the system browser', async () => {
154
199
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
155
- assert.match(source, /onMouseUp=\{\(\) => openSingleLineLink\(line\.segments\)\}/);
156
- assert.match(source, /execFile\(opener, \[parsed\.toString\(\)\]/);
200
+ assert.match(source, /const url = singleLineUrl\(line\.segments\);/);
201
+ assert.match(source, /props\.onOpenLink\?\.\(url\)/);
202
+ // Hover feedback: nothing else on screen says a line is clickable.
203
+ assert.match(source, /onMouseOver=\{\(\) => hoverLink\(singleLineUrl\(line\.segments\)\)\}/);
204
+ assert.match(source, /onMouseOut=\{\(\) => hoverLink\(null\)\}/);
205
+ assert.match(source, /setMousePointer\?\.\(url \? 'pointer' : 'default'\)/);
157
206
  });
158
207
 
159
208
  test('ShellUI never splits a link label at the end of a line', () => {
160
209
  const url = 'https://example.test/a/very/long/document';
161
210
  const rows = wrapHttpLinks(`Voir maintenant ${url}`, 20);
162
211
  assert.equal(rows.length, 2);
163
- assert.deepEqual(rows[1], [{ text: '[link: example.test]', url }]);
212
+ assert.deepEqual(rows[1], [{ text: 'example.test/…', url }]);
213
+ });
214
+
215
+ test('a slash command produces one activity row, not a duplicated echo', async () => {
216
+ const source = await readFile(new URL('./repl.js', import.meta.url), 'utf8');
217
+ // useAgent already logs `input: <line>` for every submission; repl.js used to
218
+ // add `Shell: <line>` on top, but only for slash commands.
219
+ assert.doesNotMatch(source, /onStep\?\.\(`Shell: \$\{trimmed\}`\)/);
220
+ const useAgent = await readFile(new URL('./useAgent.ts', import.meta.url), 'utf8');
221
+ assert.match(useAgent, /props\.addLog\(`input: \$\{trimmed\}`\)/);
222
+ });
223
+
224
+ test('clipboard copy prefers a verifiable local tool over fire-and-forget OSC 52', async () => {
225
+ const source = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
226
+ const localIndex = source.indexOf('for (const [command, args] of clipboardCommands())');
227
+ const osc52Index = source.indexOf('copyToClipboardOSC52');
228
+ const osc52Call = source.indexOf('osc52.call(renderer, text)');
229
+ assert.ok(localIndex > 0 && osc52Call > localIndex, 'OSC 52 must be the last resort');
230
+ assert.ok(osc52Index > 0);
231
+ // xsel before xclip: xclip holds the X selection and never closes the
232
+ // inherited pipes, which froze the ShellUI under execFileSync.
233
+ assert.ok(source.indexOf("'xsel'") < source.indexOf("'xclip'"));
234
+ assert.match(source, /stdio: \['pipe', 'ignore', 'ignore'\]/);
235
+ assert.match(source, /timeout: 2_000/);
164
236
  });
165
237
 
166
238
  test('ShellUI leaves malformed URLs as plain text', () => {
167
239
  assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
168
240
  });
169
241
 
242
+ test('opening a link tries every opener the platform offers before giving up', () => {
243
+ const attempts = [];
244
+ const failing = (command, args) => {
245
+ attempts.push([command, args.at(-1)]);
246
+ throw new Error('ENOENT');
247
+ };
248
+
249
+ // A single missing `xdg-open` used to swallow the click silently; the caller
250
+ // now learns that nothing worked and can fall back to copying the URL.
251
+ assert.equal(openExternalUrl('http://localhost:3100', { run: failing }), null);
252
+ assert.ok(attempts.length >= 1, 'expected at least one opener candidate');
253
+ assert.ok(attempts.every(([, url]) => url === 'http://localhost:3100'));
254
+
255
+ const calls = [];
256
+ const working = (command, args) => { calls.push(command); };
257
+ assert.equal(openExternalUrl('http://localhost:3100', { run: working }), 'http://localhost:3100');
258
+ assert.equal(calls.length, 1, 'must stop at the first opener that succeeds');
259
+ });
260
+
261
+ test('only http(s) targets ever reach the system opener', () => {
262
+ for (const value of ['file:///etc/passwd', 'javascript:alert(1)', 'not a url', '', null]) {
263
+ assert.equal(normalizeExternalUrl(value), null, `${value} must not be openable`);
264
+ assert.equal(openExternalUrl(value, { run: () => { throw new Error('must not run'); } }), null);
265
+ }
266
+ });
267
+
170
268
  test('ShellUI preserves every URL when several links share one line', () => {
171
269
  const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
172
270
  .flat()
173
271
  .filter((part) => part.url);
174
272
  assert.deepEqual(links, [
175
- { text: '[link: one.example]', url: 'https://one.example/a' },
176
- { text: '[link: two.example]', url: 'https://two.example/b' },
273
+ { text: 'https://one.example/a', url: 'https://one.example/a' },
274
+ { text: 'https://two.example/b', url: 'https://two.example/b' },
177
275
  ]);
178
276
  });
179
277
 
@@ -0,0 +1,66 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const wizardPath = fileURLToPath(new URL('./SetupWizard.tsx', import.meta.url));
7
+ const scaffoldPath = fileURLToPath(
8
+ new URL('../../../llm-wiki/scaffold/workspace/.wikirc.yaml', import.meta.url),
9
+ );
10
+
11
+ function placeholderRegex() {
12
+ const source = readFileSync(wizardPath, 'utf8');
13
+ const match = source.match(/^const PLACEHOLDER_VALUE_RE = \/(.+)\/i;$/m);
14
+ assert.ok(match, 'PLACEHOLDER_VALUE_RE must stay a single-line literal the tests can read');
15
+ return new RegExp(match[1], 'i');
16
+ }
17
+
18
+ test('the wizard treats scaffolded config values as unset', { skip: !existsSync(scaffoldPath) && 'llm-wiki sibling checkout not available' }, () => {
19
+ const regex = placeholderRegex();
20
+ const scaffold = readFileSync(scaffoldPath, 'utf8');
21
+
22
+ // Cross-repo guard: if the scaffold ever ships a different fake endpoint or
23
+ // secret, the wizard would prefill it as a real answer and a skipped step
24
+ // would write it to the operator's config.
25
+ const placeholders = [
26
+ ...scaffold.matchAll(/^\s*(baseUrl|apiKey|model):\s*(\S+)\s*$/gm),
27
+ ].map(([, key, value]) => [key, value]);
28
+
29
+ assert.ok(placeholders.length >= 4, 'expected the scaffold to declare endpoints and secrets');
30
+ for (const [key, value] of placeholders) {
31
+ assert.match(value, regex, `scaffolded ${key}=${value} must be recognised as a placeholder`);
32
+ }
33
+ });
34
+
35
+ test('preloading a profile filters placeholders out of every prefilled field', () => {
36
+ const source = readFileSync(wizardPath, 'utf8');
37
+
38
+ for (const field of [
39
+ 'baseUrl: configuredValue(config.llm.baseUrl)',
40
+ 'apiKey: configuredValue(config.llm.apiKey)',
41
+ 'model: configuredValue(config.llm.model)',
42
+ 'baseUrl: configuredValue(config.retrieval.vector.baseUrl)',
43
+ 'apiKey: configuredValue(config.retrieval.vector.apiKey)',
44
+ ]) {
45
+ assert.ok(source.includes(field), `preloadWikirc must filter ${field}`);
46
+ }
47
+
48
+ // With the vector URL filtered to null, this is what makes the embeddings
49
+ // step offer the base URL the operator just typed.
50
+ assert.match(source, /const baseUrl = vector\(\)\.baseUrl \|\| llm\(\)\.baseUrl;/);
51
+ assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider\)/);
52
+ });
53
+
54
+ test('a real configuration is never mistaken for a placeholder', () => {
55
+ const regex = placeholderRegex();
56
+
57
+ for (const value of [
58
+ 'https://albert.api.etalab.gouv.fr/v1',
59
+ 'http://localhost:11434',
60
+ 'https://api.openai.com',
61
+ 'BAAI/bge-m3',
62
+ 'gpt-oss-120b',
63
+ ]) {
64
+ assert.doesNotMatch(value, regex, `${value} must be kept as a configured value`);
65
+ }
66
+ });
package/src/shell/tui.tsx CHANGED
@@ -4,6 +4,7 @@ import { render, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimen
4
4
  import { createMemo, createSignal, onCleanup, Show } from 'solid-js';
5
5
  import { FileEditorDialog } from './FileEditorDialog';
6
6
  import { LeftPane } from './LeftPane';
7
+ import { openExternalUrl } from './openExternal.js';
7
8
  import { RightPane } from './RightPane';
8
9
  import { SlashDialog } from './SlashDialog';
9
10
  import { SetupWizard } from './SetupWizard';
@@ -74,35 +75,53 @@ function startupInfo(packageJson: Record<string, unknown>, preferredWorkspaceNam
74
75
  }
75
76
  }
76
77
 
78
+ // Ordered from verifiable to hopeful. Each entry is [command, args].
79
+ function clipboardCommands(): Array<[string, string[]]> {
80
+ if (process.platform === 'darwin') return [['pbcopy', []]];
81
+ if (process.platform === 'win32') return [['clip', []]];
82
+ return [
83
+ ['wl-copy', []],
84
+ // `xsel` before `xclip`: xclip must keep running to own the X selection and
85
+ // inherits our stdio, so execFileSync waits on pipes that never close and
86
+ // the whole ShellUI freezes. xsel forks and releases them.
87
+ ['xsel', ['--clipboard', '--input']],
88
+ ['xclip', ['-selection', 'clipboard']],
89
+ ];
90
+ }
91
+
77
92
  function copyToClipboard(text: string, renderer: unknown) {
78
- try {
79
- const osc52 = (renderer as any).copyToClipboardOSC52;
80
- if (typeof osc52 === 'function') {
81
- osc52.call(renderer, text);
93
+ // Local tools first, OSC 52 last. OSC 52 used to win by default and always
94
+ // reported success, because writing an escape sequence cannot fail: on
95
+ // GNOME Terminal / VTE, which does not implement it, the sequence was
96
+ // swallowed and the ShellUI cheerfully announced "Copied." with an unchanged
97
+ // clipboard. Selection-copy appeared to work only because the terminal
98
+ // itself copies selections, without going through this code at all.
99
+ for (const [command, args] of clipboardCommands()) {
100
+ try {
101
+ execFileSync(command, args, {
102
+ input: text,
103
+ // Never inherit stdout/stderr: see the xclip note above.
104
+ stdio: ['pipe', 'ignore', 'ignore'],
105
+ timeout: 2_000,
106
+ });
82
107
  return true;
108
+ } catch {
109
+ // Missing binary, wrong display server, or timeout: try the next one.
83
110
  }
84
- } catch {
85
- // Fall through to platform clipboard tools.
86
111
  }
112
+ // Nothing local worked. Over SSH or in a terminal that supports it, OSC 52
113
+ // is the only remaining option — but it is fire-and-forget, so it is also
114
+ // the only case where we cannot promise the clipboard was actually written.
87
115
  try {
88
- if (process.platform === 'darwin') {
89
- execFileSync('pbcopy', [], { input: text });
90
- return true;
91
- }
92
- if (process.platform === 'win32') {
93
- execFileSync('clip', [], { input: text });
94
- return true;
95
- }
96
- try {
97
- execFileSync('wl-copy', [], { input: text });
98
- return true;
99
- } catch {
100
- execFileSync('xclip', ['-selection', 'clipboard'], { input: text });
116
+ const osc52 = (renderer as any).copyToClipboardOSC52;
117
+ if (typeof osc52 === 'function') {
118
+ osc52.call(renderer, text);
101
119
  return true;
102
120
  }
103
121
  } catch {
104
- return false;
122
+ // Renderer without OSC 52 support.
105
123
  }
124
+ return false;
106
125
  }
107
126
 
108
127
  function App(props: {
@@ -326,10 +345,6 @@ function App(props: {
326
345
  }, 1600);
327
346
  return;
328
347
  }
329
- if (key.ctrl && keyName === 'q') {
330
- state.toggleRightTab();
331
- return;
332
- }
333
348
  if (state.busy()) return;
334
349
  if (keyName === 'tab') state.completeSelected();
335
350
  if (keyName === 'pageup') state.scrollConversation(conversationRows());
@@ -383,7 +398,21 @@ function App(props: {
383
398
  scrollConversation={state.scrollConversation}
384
399
  spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
385
400
  onInputHeightChange={setChatInputHeight}
386
- onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
401
+ onRedo={(index, content) => { void state.redoMessage(index, content); }}
402
+ onCopy={(content) => showCopyHint(
403
+ copyToClipboard(content, renderer)
404
+ ? 'Copied.'
405
+ // Actionable, because the fix is one package away: on Linux the
406
+ // clipboard needs wl-copy (Wayland) or xsel/xclip (X11).
407
+ : 'Copy failed — install wl-copy, xsel or xclip.',
408
+ )}
409
+ onOpenLink={(url) => {
410
+ if (openExternalUrl(url)) return showCopyHint(`Opening ${url}`);
411
+ // No usable opener on this machine (headless, container, no
412
+ // xdg-utils). Leave the operator something actionable instead of
413
+ // the silent no-op that made links look broken.
414
+ showCopyHint(copyToClipboard(url, renderer) ? `No browser opener — copied ${url}` : `No browser opener — open ${url}`);
415
+ }}
387
416
  />
388
417
  <box width={1} height="100%" flexDirection="column">
389
418
  {Array.from({ length: dimensions().height }, () => (
@@ -9,7 +9,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
9
9
  import { projectQueue, queueCounts, startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
10
10
  import { queueStoreFor } from '../core/queueStore.js';
11
11
  import { filterRuntimeLogs } from '../core/runtimeLog.js';
12
- import { fetchRuntimeState, streamRuntimeEvents } from '../runtime/client.js';
12
+ import { fetchRuntimeState, postRuntimeConversationTruncate, streamRuntimeEvents } from '../runtime/client.js';
13
13
  import type { ActiveFileEditor } from './FileEditorDialog';
14
14
  import {
15
15
  completionContext,
@@ -656,6 +656,63 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
656
656
  return result;
657
657
  }
658
658
 
659
+ // Redo: re-ask one question after discarding everything it produced.
660
+ // `displayIndex` indexes the rendered (windowed) list, so it is translated
661
+ // back to the full conversation before anything is deleted — otherwise a
662
+ // scrolled-back redo would truncate at the wrong message.
663
+ async function redoMessage(displayIndex: number, content: string) {
664
+ if (conversationBusy()) return { exit: false, busy: true };
665
+ const full = conversationMessages(session);
666
+ const windowOffset = Math.max(0, full.length - messages().length);
667
+ const index = windowOffset + displayIndex;
668
+ if (index < 0 || index >= full.length) return { exit: false };
669
+
670
+ const workspaceKey = (session as any).workspace || '__global__';
671
+ // The local thread and the runtime conversation are NOT the same list:
672
+ // slash-command output, help panels and any other local-only entry live in
673
+ // `full` but were never recorded as events. Sending a `full` index straight
674
+ // to /conversation/truncate therefore points at the wrong entry — or past
675
+ // the end (`index_out_of_range`) — as soon as one local entry precedes the
676
+ // redone question. `backed` is index-aligned with the runtime conversation,
677
+ // so it is the only correct translation table.
678
+ const backed = runtimeConversationRefsByWorkspace.get(workspaceKey) ?? [];
679
+ let runtimeIndex = backed.indexOf(full[index]);
680
+ if (runtimeIndex === -1) {
681
+ // Local-only entry: truncate after the last runtime-backed entry that
682
+ // still precedes it, so nothing recorded before the question is lost.
683
+ for (let i = backed.length - 1; i >= 0; i -= 1) {
684
+ if (full.indexOf(backed[i]) < index) { runtimeIndex = i; break; }
685
+ }
686
+ }
687
+
688
+ if (props.runtime?.url && runtimeIndex >= 0) {
689
+ // The runtime derives the conversation from its event log: deleting
690
+ // locally alone would be undone by the next /state merge.
691
+ const result = await postRuntimeConversationTruncate(runtimeIndex, {
692
+ url: props.runtime.url,
693
+ workspace: (session as any).workspace ?? null,
694
+ }) as { truncated?: boolean; reason?: string };
695
+ if (result?.truncated !== true) {
696
+ addLog(result?.reason === 'run_active'
697
+ ? 'redo: a run is still active — cancel it first'
698
+ : result?.reason === 'workspace_required'
699
+ ? 'redo: select a workspace first'
700
+ : `redo: runtime refused to truncate (${result?.reason ?? 'unknown'})`);
701
+ return { exit: false };
702
+ }
703
+ }
704
+ // Drop the question too: resubmitting re-appends it, and keeping it here
705
+ // would leave the same message twice in the thread.
706
+ full.splice(index);
707
+ // Only the current workspace's merge state was invalidated by the
708
+ // truncate above — clearing every workspace's refs here would desync
709
+ // other workspaces' `backed` arrays from their untouched `target`
710
+ // arrays and duplicate their history on the next merge.
711
+ runtimeConversationRefsByWorkspace.get(workspaceKey)?.splice(0);
712
+ refresh();
713
+ return submitInput(content);
714
+ }
715
+
659
716
  function updateInput(value: string) {
660
717
  setInput(value);
661
718
  if (value !== dismissedSlashInput()) setDismissedSlashInput(null);
@@ -708,10 +765,6 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
708
765
  setConversationScroll((value) => Math.max(0, value + delta));
709
766
  }
710
767
 
711
- function toggleRightTab() {
712
- setRightTab((value) => value === 'plan' ? 'queue' : 'plan');
713
- }
714
-
715
768
  function selectRightTab(tab: 'plan' | 'queue') {
716
769
  setRightTab(tab);
717
770
  }
@@ -771,7 +824,6 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
771
824
  queueItems,
772
825
  queueInfo,
773
826
  rightTab,
774
- toggleRightTab,
775
827
  selectRightTab,
776
828
  plan,
777
829
  runSummary,
@@ -783,6 +835,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
783
835
  busy: agentBusy,
784
836
  abort,
785
837
  submitInput,
838
+ redoMessage,
786
839
  completeSelected,
787
840
  dismissSlash,
788
841
  moveCompletion,