@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.
- package/.env.example +18 -5
- package/README.md +35 -13
- package/agents.docker-compose.override.example.yml +82 -0
- package/agents.docker-compose.yml +8 -2
- package/docker-compose.override.example.yml +81 -0
- package/docker-compose.yml +23 -17
- package/package.json +4 -2
- package/src/cli/wiki-manager.js +13 -4
- package/src/commands/slash.js +53 -39
- package/src/core/agentEvents.js +20 -0
- package/src/core/agentEvents.test.js +38 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.js +81 -0
- package/src/core/commandFailure.test.js +67 -0
- package/src/core/compose.js +20 -3
- package/src/core/composeOverrides.test.js +74 -0
- package/src/core/dockerCompose.test.js +69 -2
- package/src/core/env.js +59 -0
- package/src/core/env.test.js +33 -1
- package/src/core/mcp.js +1 -1
- package/src/core/setEnvValue.test.js +96 -0
- package/src/runtime/client.js +18 -0
- package/src/runtime/lifecycle.js +5 -1
- package/src/runtime/server.js +43 -1
- package/src/runtime/server.test.js +117 -0
- package/src/runtime/store.js +47 -0
- package/src/runtime/store.test.js +89 -1
- package/src/shell/LeftPane.tsx +46 -16
- package/src/shell/RightPane.tsx +2 -4
- package/src/shell/SetupWizard.tsx +19 -7
- package/src/shell/externalLinks.js +24 -2
- package/src/shell/openExternal.js +53 -0
- package/src/shell/repl.js +33 -10
- package/src/shell/repl.test.js +107 -9
- package/src/shell/setupWizardPlaceholders.test.js +66 -0
- package/src/shell/tui.tsx +54 -25
- package/src/shell/useSession.ts +59 -6
- 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
|
-
|
|
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
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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);
|
package/src/shell/repl.test.js
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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
|
-
|
|
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, /
|
|
156
|
-
assert.match(source, /
|
|
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: '
|
|
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: '
|
|
176
|
-
{ text: '
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }, () => (
|
package/src/shell/useSession.ts
CHANGED
|
@@ -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,
|