@dotdrelle/wiki-manager 0.15.25 → 0.15.27

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 +14 -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 +3 -1
  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 +91 -9
  35. package/src/shell/setupWizardPlaceholders.test.js +66 -0
  36. package/src/shell/tui.tsx +54 -25
  37. package/src/shell/useSession.ts +42 -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,23 @@ 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(index');
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\);/);
45
62
  });
46
63
 
47
64
  test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
@@ -144,36 +161,101 @@ test('ShellUI turns HTTP URLs into valid links without trailing punctuation', ()
144
161
  ]);
145
162
  });
146
163
 
147
- test('long ShellUI URLs use one short label with the complete link target', () => {
164
+ test('a link that fits is shown as the URL itself, never as a [link: host] stub', () => {
165
+ // `/openui` prints `Web UI: http://localhost:3100`. The old `[link: host]`
166
+ // label dropped the port, so a failed opener left nothing to type by hand.
167
+ const url = 'http://localhost:3100';
168
+ assert.equal(linkLabel(url, 60), url);
169
+ const links = wrapHttpLinks(`Web UI: ${url}`, 60).flat().filter((part) => part.url);
170
+ assert.deepEqual(links, [{ text: url, url }]);
171
+ });
172
+
173
+ test('long ShellUI URLs shorten to host, keeping the full link target', () => {
148
174
  const url = 'https://example.test/a/very/long/document';
149
175
  const links = wrapHttpLinks(url, 12).flat().filter((part) => part.url);
150
- assert.deepEqual(links, [{ text: '[link: example.test]', url }]);
176
+ // Too narrow even for `host/…`: the host stays whole rather than being
177
+ // truncated into an unusable fragment.
178
+ assert.deepEqual(links, [{ text: 'example.test', url }]);
179
+ assert.equal(linkLabel('https://example.test:8443/a/very/long/document', 20), 'example.test:8443/…');
151
180
  });
152
181
 
153
182
  test('ShellUI makes rendered link lines openable with the system browser', async () => {
154
183
  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\(\)\]/);
184
+ assert.match(source, /const url = singleLineUrl\(line\.segments\);/);
185
+ assert.match(source, /props\.onOpenLink\?\.\(url\)/);
186
+ // Hover feedback: nothing else on screen says a line is clickable.
187
+ assert.match(source, /onMouseOver=\{\(\) => hoverLink\(singleLineUrl\(line\.segments\)\)\}/);
188
+ assert.match(source, /onMouseOut=\{\(\) => hoverLink\(null\)\}/);
189
+ assert.match(source, /setMousePointer\?\.\(url \? 'pointer' : 'default'\)/);
157
190
  });
158
191
 
159
192
  test('ShellUI never splits a link label at the end of a line', () => {
160
193
  const url = 'https://example.test/a/very/long/document';
161
194
  const rows = wrapHttpLinks(`Voir maintenant ${url}`, 20);
162
195
  assert.equal(rows.length, 2);
163
- assert.deepEqual(rows[1], [{ text: '[link: example.test]', url }]);
196
+ assert.deepEqual(rows[1], [{ text: 'example.test/…', url }]);
197
+ });
198
+
199
+ test('a slash command produces one activity row, not a duplicated echo', async () => {
200
+ const source = await readFile(new URL('./repl.js', import.meta.url), 'utf8');
201
+ // useAgent already logs `input: <line>` for every submission; repl.js used to
202
+ // add `Shell: <line>` on top, but only for slash commands.
203
+ assert.doesNotMatch(source, /onStep\?\.\(`Shell: \$\{trimmed\}`\)/);
204
+ const useAgent = await readFile(new URL('./useAgent.ts', import.meta.url), 'utf8');
205
+ assert.match(useAgent, /props\.addLog\(`input: \$\{trimmed\}`\)/);
206
+ });
207
+
208
+ test('clipboard copy prefers a verifiable local tool over fire-and-forget OSC 52', async () => {
209
+ const source = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
210
+ const localIndex = source.indexOf('for (const [command, args] of clipboardCommands())');
211
+ const osc52Index = source.indexOf('copyToClipboardOSC52');
212
+ const osc52Call = source.indexOf('osc52.call(renderer, text)');
213
+ assert.ok(localIndex > 0 && osc52Call > localIndex, 'OSC 52 must be the last resort');
214
+ assert.ok(osc52Index > 0);
215
+ // xsel before xclip: xclip holds the X selection and never closes the
216
+ // inherited pipes, which froze the ShellUI under execFileSync.
217
+ assert.ok(source.indexOf("'xsel'") < source.indexOf("'xclip'"));
218
+ assert.match(source, /stdio: \['pipe', 'ignore', 'ignore'\]/);
219
+ assert.match(source, /timeout: 2_000/);
164
220
  });
165
221
 
166
222
  test('ShellUI leaves malformed URLs as plain text', () => {
167
223
  assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
168
224
  });
169
225
 
226
+ test('opening a link tries every opener the platform offers before giving up', () => {
227
+ const attempts = [];
228
+ const failing = (command, args) => {
229
+ attempts.push([command, args.at(-1)]);
230
+ throw new Error('ENOENT');
231
+ };
232
+
233
+ // A single missing `xdg-open` used to swallow the click silently; the caller
234
+ // now learns that nothing worked and can fall back to copying the URL.
235
+ assert.equal(openExternalUrl('http://localhost:3100', { run: failing }), null);
236
+ assert.ok(attempts.length >= 1, 'expected at least one opener candidate');
237
+ assert.ok(attempts.every(([, url]) => url === 'http://localhost:3100'));
238
+
239
+ const calls = [];
240
+ const working = (command, args) => { calls.push(command); };
241
+ assert.equal(openExternalUrl('http://localhost:3100', { run: working }), 'http://localhost:3100');
242
+ assert.equal(calls.length, 1, 'must stop at the first opener that succeeds');
243
+ });
244
+
245
+ test('only http(s) targets ever reach the system opener', () => {
246
+ for (const value of ['file:///etc/passwd', 'javascript:alert(1)', 'not a url', '', null]) {
247
+ assert.equal(normalizeExternalUrl(value), null, `${value} must not be openable`);
248
+ assert.equal(openExternalUrl(value, { run: () => { throw new Error('must not run'); } }), null);
249
+ }
250
+ });
251
+
170
252
  test('ShellUI preserves every URL when several links share one line', () => {
171
253
  const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
172
254
  .flat()
173
255
  .filter((part) => part.url);
174
256
  assert.deepEqual(links, [
175
- { text: '[link: one.example]', url: 'https://one.example/a' },
176
- { text: '[link: two.example]', url: 'https://two.example/b' },
257
+ { text: 'https://one.example/a', url: 'https://one.example/a' },
258
+ { text: 'https://two.example/b', url: 'https://two.example/b' },
177
259
  ]);
178
260
  });
179
261
 
@@ -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,46 @@ 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
+ if (props.runtime?.url) {
671
+ // The runtime derives the conversation from its event log: deleting
672
+ // locally alone would be undone by the next /state merge.
673
+ const result = await postRuntimeConversationTruncate(index, {
674
+ url: props.runtime.url,
675
+ workspace: (session as any).workspace ?? null,
676
+ }) as { truncated?: boolean; reason?: string };
677
+ if (result?.truncated !== true) {
678
+ addLog(result?.reason === 'run_active'
679
+ ? 'redo: a run is still active — cancel it first'
680
+ : result?.reason === 'workspace_required'
681
+ ? 'redo: select a workspace first'
682
+ : `redo: runtime refused to truncate (${result?.reason ?? 'unknown'})`);
683
+ return { exit: false };
684
+ }
685
+ }
686
+ // Drop the question too: resubmitting re-appends it, and keeping it here
687
+ // would leave the same message twice in the thread.
688
+ full.splice(index);
689
+ // Only the current workspace's merge state was invalidated by the
690
+ // truncate above — clearing every workspace's refs here would desync
691
+ // other workspaces' `backed` arrays from their untouched `target`
692
+ // arrays and duplicate their history on the next merge.
693
+ const currentWorkspace = (session as any).workspace || '__global__';
694
+ runtimeConversationRefsByWorkspace.get(currentWorkspace)?.splice(0);
695
+ refresh();
696
+ return submitInput(content);
697
+ }
698
+
659
699
  function updateInput(value: string) {
660
700
  setInput(value);
661
701
  if (value !== dismissedSlashInput()) setDismissedSlashInput(null);
@@ -708,10 +748,6 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
708
748
  setConversationScroll((value) => Math.max(0, value + delta));
709
749
  }
710
750
 
711
- function toggleRightTab() {
712
- setRightTab((value) => value === 'plan' ? 'queue' : 'plan');
713
- }
714
-
715
751
  function selectRightTab(tab: 'plan' | 'queue') {
716
752
  setRightTab(tab);
717
753
  }
@@ -771,7 +807,6 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
771
807
  queueItems,
772
808
  queueInfo,
773
809
  rightTab,
774
- toggleRightTab,
775
810
  selectRightTab,
776
811
  plan,
777
812
  runSummary,
@@ -783,6 +818,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
783
818
  busy: agentBusy,
784
819
  abort,
785
820
  submitInput,
821
+ redoMessage,
786
822
  completeSelected,
787
823
  dismissSlash,
788
824
  moveCompletion,