@dotdrelle/wiki-manager 0.14.12 → 0.14.16
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 +11 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +43 -13
- package/src/activity/activityAggregator.test.js +51 -2
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +44 -3
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +31 -0
- package/src/core/agentEvents.js +11 -2
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.js +16 -4
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +43 -1
- package/src/core/wikiWorkspace.test.js +20 -0
- package/src/core/workspaces.js +10 -2
- package/src/orchestrator/dependencyResolver.js +19 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +23 -1
- package/src/orchestrator/scheduler.test.js +22 -1
- package/src/runtime/auth.test.js +65 -1
- package/src/runtime/client.js +4 -0
- package/src/runtime/donna-contract.test.js +2 -0
- package/src/runtime/lifecycle.js +21 -12
- package/src/runtime/runner.js +130 -17
- package/src/runtime/runner.test.js +62 -1
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +147 -54
- package/src/shell/StartupScreen.tsx +3 -7
- package/src/shell/renderer.ts +1 -0
- package/src/shell/repl.js +7 -81
- package/src/shell/repl.test.js +128 -38
- package/src/shell/tui.tsx +59 -64
- package/src/shell/useSession.ts +45 -6
- package/wiki-workspace +36 -3
package/src/shell/repl.test.js
CHANGED
|
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
|
-
appendRuntimePlanCompletionMessages,
|
|
9
8
|
chatReadTools,
|
|
10
9
|
createSession,
|
|
11
10
|
runHeadlessChatTurn,
|
|
@@ -20,8 +19,109 @@ import {
|
|
|
20
19
|
shouldHandleFreeTextLocally,
|
|
21
20
|
submitRuntimeRun,
|
|
22
21
|
} from './repl.js';
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
23
|
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
24
24
|
|
|
25
|
+
test('ShellUI inserts StyledText as a child instead of stringifying it through content', async () => {
|
|
26
|
+
const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
|
|
27
|
+
assert.doesNotMatch(source, /content=\{styledSegments\(/);
|
|
28
|
+
assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('ShellUI renders the user header full-width before constraining only its body', async () => {
|
|
32
|
+
const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
|
|
33
|
+
const headerBranch = source.indexOf("line.copyContent !== undefined ? (");
|
|
34
|
+
const userBodyBranch = source.indexOf("line.role === 'user' && line.userBubbleWidth ? (", headerBranch);
|
|
35
|
+
assert.ok(headerBranch >= 0 && userBodyBranch > headerBranch);
|
|
36
|
+
assert.match(source, /const userBubbleWidth = message\.role === 'user'/);
|
|
37
|
+
assert.match(source, /Math\.floor\(columns \* 0\.6\)/);
|
|
38
|
+
assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.5\)/);
|
|
39
|
+
assert.match(source.slice(userBodyBranch), /width=\{line\.userBubbleWidth\}/);
|
|
40
|
+
assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
|
|
41
|
+
assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
|
|
42
|
+
assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
|
|
43
|
+
assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
|
|
47
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
48
|
+
const filteredLogs = source.slice(
|
|
49
|
+
source.indexOf('const filteredLogs ='),
|
|
50
|
+
source.indexOf('const allLines ='),
|
|
51
|
+
);
|
|
52
|
+
assert.match(filteredLogs, /activeLogTab\(\) === 'agent-status'/);
|
|
53
|
+
assert.match(filteredLogs, /isAgentStatus\(line\)/);
|
|
54
|
+
assert.doesNotMatch(filteredLogs, /\.reverse\(\)/);
|
|
55
|
+
const renderedLogs = source.slice(
|
|
56
|
+
source.indexOf('function logRenderLines'),
|
|
57
|
+
source.indexOf('function logEntryLines'),
|
|
58
|
+
);
|
|
59
|
+
assert.match(renderedLogs, /return blocks\.reverse\(\)\.flat\(\)/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Activity uses only visible jobs and leaves remaining height to Flow/Trace', async () => {
|
|
63
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
64
|
+
const activityPanel = source.slice(
|
|
65
|
+
source.indexOf('export function ActivityPanel'),
|
|
66
|
+
source.indexOf('type LogSegment'),
|
|
67
|
+
);
|
|
68
|
+
assert.match(activityPanel, /<Index each=\{visibleSlots\(\)\}>/);
|
|
69
|
+
assert.doesNotMatch(activityPanel, /<Index each=\{ACTIVITY_SLOTS\}>/);
|
|
70
|
+
assert.match(activityPanel, /paddingX=\{1\}/);
|
|
71
|
+
assert.doesNotMatch(activityPanel, /updatedLine\(activity\(\)\)/);
|
|
72
|
+
assert.doesNotMatch(activityPanel, /marginTop=/);
|
|
73
|
+
const logPanel = source.slice(
|
|
74
|
+
source.indexOf('export function LogPanel'),
|
|
75
|
+
source.indexOf('export function QueuePanel'),
|
|
76
|
+
);
|
|
77
|
+
assert.doesNotMatch(logPanel, /marginTop=\{1\}/);
|
|
78
|
+
assert.match(activityPanel, /wrapLine\(activityDetailText\(item\), lineWidth\(\)\)\.slice\(0, 2\)/);
|
|
79
|
+
assert.match(source, /`Task \$\{Math\.max\(1, taskIndex\)\}\/\$\{taskTotal\}`/);
|
|
80
|
+
assert.match(source, /`Step \$\{Math\.max\(1, stepIndex\)\}\/\$\{stepTotal\}`/);
|
|
81
|
+
assert.match(source, /`Batch \$\{Math\.min\(batchCount, Math\.max\(1, batchIndex \+ 1\)\)\}\/\$\{batchCount\}`/);
|
|
82
|
+
assert.match(source, /instructionCount/);
|
|
83
|
+
assert.match(source, /stabilizeKept/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ShellUI launcher never starts agents or workspace services implicitly', async () => {
|
|
87
|
+
const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
|
|
88
|
+
const startup = await readFile(new URL('./StartupScreen.tsx', import.meta.url), 'utf8');
|
|
89
|
+
assert.doesNotMatch(tui, /submitInput\('\/start (?:agents|agent|all)'\)/);
|
|
90
|
+
assert.doesNotMatch(startup, /start-services|Start services|start agents and services/i);
|
|
91
|
+
assert.doesNotMatch(startup, /open-logs|Open diagnostics/i);
|
|
92
|
+
assert.match(startup, /Select and load a workspace/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('ShellUI lets the right pane extend to the terminal edge', async () => {
|
|
96
|
+
const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
|
|
97
|
+
const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
98
|
+
assert.match(tui, /Math\.min\(58, Math\.floor\(width \* 0\.38\) \+ 2\)/);
|
|
99
|
+
assert.match(pane, /paddingLeft=\{1\}/);
|
|
100
|
+
assert.doesNotMatch(pane, /height="100%" flexDirection="column" padding=\{1\}/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
|
|
104
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
105
|
+
const entryRenderer = source.slice(
|
|
106
|
+
source.indexOf('function logEntryLines'),
|
|
107
|
+
source.indexOf('export function LogPanel'),
|
|
108
|
+
);
|
|
109
|
+
assert.match(entryRenderer, /sourceMatch/);
|
|
110
|
+
assert.doesNotMatch(entryRenderer, /prefix\.push\(\{ text: `\$\{source\}/);
|
|
111
|
+
assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('runtime logs have a separator and a concise Runtime tab label', async () => {
|
|
115
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
116
|
+
const logPanel = source.slice(
|
|
117
|
+
source.indexOf('export function LogPanel'),
|
|
118
|
+
source.indexOf('export function QueuePanel'),
|
|
119
|
+
);
|
|
120
|
+
assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
|
|
121
|
+
assert.match(logPanel, /content=" Runtime "/);
|
|
122
|
+
assert.doesNotMatch(logPanel, /Flow \/ Trace/);
|
|
123
|
+
});
|
|
124
|
+
|
|
25
125
|
test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
|
|
26
126
|
assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
|
|
27
127
|
{ text: 'Voir ' },
|
|
@@ -47,6 +147,16 @@ test('ShellUI leaves malformed URLs as plain text', () => {
|
|
|
47
147
|
assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
|
|
48
148
|
});
|
|
49
149
|
|
|
150
|
+
test('ShellUI preserves every URL when several links share one line', () => {
|
|
151
|
+
const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
|
|
152
|
+
.flat()
|
|
153
|
+
.filter((part) => part.url);
|
|
154
|
+
assert.deepEqual(links, [
|
|
155
|
+
{ text: '[link: one.example]', url: 'https://one.example/a' },
|
|
156
|
+
{ text: '[link: two.example]', url: 'https://two.example/b' },
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
50
160
|
test('runtime display preserves a failed plan and its diagnostic evidence', () => {
|
|
51
161
|
const state = {
|
|
52
162
|
status: 'error',
|
|
@@ -59,7 +169,7 @@ test('runtime display preserves a failed plan and its diagnostic evidence', () =
|
|
|
59
169
|
assert.equal(sanitizeRuntimeStateForDisplay(state), state);
|
|
60
170
|
});
|
|
61
171
|
|
|
62
|
-
test('runtime display
|
|
172
|
+
test('runtime display preserves completed plan and logs for post-run inspection', () => {
|
|
63
173
|
const display = sanitizeRuntimeStateForDisplay({
|
|
64
174
|
status: 'done',
|
|
65
175
|
plan: [{ id: 'old', status: 'done' }],
|
|
@@ -68,10 +178,10 @@ test('runtime display still clears completed historical execution state', () =>
|
|
|
68
178
|
conversation: [{ role: 'assistant', content: 'Old run.' }],
|
|
69
179
|
});
|
|
70
180
|
|
|
71
|
-
assert.deepEqual(display.plan, []);
|
|
72
|
-
assert.deepEqual(display.activities, []);
|
|
73
|
-
assert.deepEqual(display.logs, []);
|
|
74
|
-
assert.deepEqual(display.conversation, []);
|
|
181
|
+
assert.deepEqual(display.plan, [{ id: 'old', status: 'done' }]);
|
|
182
|
+
assert.deepEqual(display.activities, [{ id: 'old-job', status: 'done' }]);
|
|
183
|
+
assert.deepEqual(display.logs, ['old log']);
|
|
184
|
+
assert.deepEqual(display.conversation, [{ role: 'assistant', content: 'Old run.' }]);
|
|
75
185
|
});
|
|
76
186
|
|
|
77
187
|
function stubFetch(handler) {
|
|
@@ -133,7 +243,7 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
|
|
|
133
243
|
assert.deepEqual(conversationMessages(session), []);
|
|
134
244
|
});
|
|
135
245
|
|
|
136
|
-
test('applyRuntimeStateToShellSession
|
|
246
|
+
test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtime is idle', () => {
|
|
137
247
|
const session = createSession();
|
|
138
248
|
session.headlessPlan = [{ step: 1, description: 'Old read', status: 'failed' }];
|
|
139
249
|
session.activities = { old: { key: 'old', status: 'failed', terminal: true } };
|
|
@@ -150,17 +260,17 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
|
|
|
150
260
|
planPatches: [{ id: 'old-patch' }],
|
|
151
261
|
});
|
|
152
262
|
|
|
153
|
-
assert.
|
|
154
|
-
assert.deepEqual(session.activities, {});
|
|
155
|
-
assert.
|
|
156
|
-
assert.deepEqual(session.agentProjection.logs, []);
|
|
157
|
-
assert.equal(session.agentProjection.summary,
|
|
158
|
-
assert.deepEqual(session.agentProjection.conversation, []);
|
|
159
|
-
assert.deepEqual(session.agentProjection.chain, []);
|
|
160
|
-
assert.deepEqual(session.agentProjection.planPatches, []);
|
|
263
|
+
assert.deepEqual(session.headlessPlan, [{ step: 1, description: 'Old read', status: 'failed' }]);
|
|
264
|
+
assert.deepEqual(session.activities, { old: { key: 'old', status: 'failed', terminal: true } });
|
|
265
|
+
assert.deepEqual(session.workflow.nodes, [{ id: 'task:old' }]);
|
|
266
|
+
assert.deepEqual(session.agentProjection.logs, ['Runtime evaluator rejected the old run']);
|
|
267
|
+
assert.equal(session.agentProjection.summary, 'Old run failed');
|
|
268
|
+
assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failed answer' }]);
|
|
269
|
+
assert.deepEqual(session.agentProjection.chain, [{ id: 'old-step' }]);
|
|
270
|
+
assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
|
|
161
271
|
});
|
|
162
272
|
|
|
163
|
-
test('runtime
|
|
273
|
+
test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
|
|
164
274
|
const session = createSession();
|
|
165
275
|
applyRuntimeStateToShellSession(session, {
|
|
166
276
|
runId: 'run-1',
|
|
@@ -178,27 +288,7 @@ test('runtime plan completion is appended once to the ShellUI conversation', ()
|
|
|
178
288
|
plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
|
|
179
289
|
});
|
|
180
290
|
|
|
181
|
-
assert.deepEqual(conversationMessages(session), [
|
|
182
|
-
role: 'command',
|
|
183
|
-
content: '✓ Job terminé : Build documentation\nStatus: done',
|
|
184
|
-
}]);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test('runtime plan failure includes its available error description', () => {
|
|
188
|
-
const session = createSession();
|
|
189
|
-
appendRuntimePlanCompletionMessages(session, {
|
|
190
|
-
runId: 'run-2',
|
|
191
|
-
plan: [{ id: 'ingest', description: 'Ingest pages', status: 'running' }],
|
|
192
|
-
});
|
|
193
|
-
appendRuntimePlanCompletionMessages(session, {
|
|
194
|
-
runId: 'run-2',
|
|
195
|
-
plan: [{ id: 'ingest', description: 'Ingest pages', status: 'failed', result: { error: { message: 'Index unavailable' } } }],
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
assert.deepEqual(conversationMessages(session), [{
|
|
199
|
-
role: 'command',
|
|
200
|
-
content: '✗ Job terminé en erreur : Ingest pages\nStatus: failed\nErreur: Index unavailable',
|
|
201
|
-
}]);
|
|
291
|
+
assert.deepEqual(conversationMessages(session), []);
|
|
202
292
|
});
|
|
203
293
|
|
|
204
294
|
test('direct chat system prompt forbids unsolicited next steps', async () => {
|
|
@@ -312,7 +402,7 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
|
|
|
312
402
|
assert.equal(session.chatMode, true);
|
|
313
403
|
assert.equal(result.oneShotAgent, true);
|
|
314
404
|
assert.equal(result.runtimeOutcome.kind, 'accepted');
|
|
315
|
-
assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
|
|
405
|
+
assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
|
|
316
406
|
} finally {
|
|
317
407
|
restore();
|
|
318
408
|
}
|
package/src/shell/tui.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import { useSession } from './useSession';
|
|
|
12
12
|
import { buildMcpStatus } from '../core/mcp.js';
|
|
13
13
|
import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
|
|
14
14
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
15
|
-
import { postRuntimeShutdown } from '../runtime/client.js';
|
|
16
15
|
import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
|
|
17
16
|
|
|
18
17
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
@@ -30,11 +29,13 @@ function emptyStartupInfo(version: string, workspace: { name: string } | null, w
|
|
|
30
29
|
};
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
function startupInfo(packageJson: Record<string, unknown
|
|
32
|
+
function startupInfo(packageJson: Record<string, unknown>, preferredWorkspaceName?: string | null) {
|
|
34
33
|
// listWorkspaces() order is filesystem-dependent (readdirSync), not stable —
|
|
35
34
|
// sort so "the default workspace" is deterministic across runs/platforms.
|
|
36
35
|
const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
|
|
37
|
-
const workspace =
|
|
36
|
+
const workspace = preferredWorkspaceName
|
|
37
|
+
? workspaces.find((item) => item.name === preferredWorkspaceName) ?? null
|
|
38
|
+
: workspaces[0] ?? null;
|
|
38
39
|
const version = String(packageJson.version ?? '');
|
|
39
40
|
if (!workspace) return emptyStartupInfo(version, null, []);
|
|
40
41
|
|
|
@@ -75,7 +76,11 @@ function startupInfo(packageJson: Record<string, unknown>) {
|
|
|
75
76
|
|
|
76
77
|
function copyToClipboard(text: string, renderer: unknown) {
|
|
77
78
|
try {
|
|
78
|
-
|
|
79
|
+
const osc52 = (renderer as any).copyToClipboardOSC52;
|
|
80
|
+
if (typeof osc52 === 'function') {
|
|
81
|
+
osc52.call(renderer, text);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
79
84
|
} catch {
|
|
80
85
|
// Fall through to platform clipboard tools.
|
|
81
86
|
}
|
|
@@ -105,11 +110,16 @@ function App(props: {
|
|
|
105
110
|
packageJson: Record<string, unknown>;
|
|
106
111
|
runtime?: any;
|
|
107
112
|
preflight?: any;
|
|
113
|
+
initialWorkspaceName?: string | null;
|
|
114
|
+
// Shared handle the shell awaits after onDestroy resolves. One ref instead of
|
|
115
|
+
// two same-named `exitTask` bindings wired through a setter callback.
|
|
116
|
+
exitRef?: { current: Promise<void> };
|
|
108
117
|
}) {
|
|
109
118
|
const renderer = useRenderer();
|
|
110
119
|
const dimensions = useTerminalDimensions();
|
|
111
120
|
const [spinnerIndex, setSpinnerIndex] = createSignal(0);
|
|
112
121
|
const [exitHint, setExitHint] = createSignal(false);
|
|
122
|
+
const [exitStatus, setExitStatus] = createSignal<string | null>(null);
|
|
113
123
|
const [copyHint, setCopyHint] = createSignal<string | null>(null);
|
|
114
124
|
const [chatInputHeight, setChatInputHeight] = createSignal(3);
|
|
115
125
|
const [startupKeyboardEvent, setStartupKeyboardEvent] = createSignal<{ id: number; key: any } | null>(null);
|
|
@@ -128,7 +138,12 @@ function App(props: {
|
|
|
128
138
|
const exitShell = () => {
|
|
129
139
|
if (exiting) return;
|
|
130
140
|
exiting = true;
|
|
131
|
-
|
|
141
|
+
setExitStatus('Fermeture enclenchée…');
|
|
142
|
+
// Register the complete shutdown before destroying the renderer: onDestroy
|
|
143
|
+
// resolves runOpenTuiShell, which must still await this cleanup task.
|
|
144
|
+
const task = Promise.resolve().then(async () => {
|
|
145
|
+
renderer.destroy();
|
|
146
|
+
console.log('[wiki-manager] fermeture enclenchée — nettoyage en cours…');
|
|
132
147
|
const messages: string[] = [];
|
|
133
148
|
try {
|
|
134
149
|
if (props.runtime?.url) {
|
|
@@ -138,25 +153,25 @@ function App(props: {
|
|
|
138
153
|
} catch {
|
|
139
154
|
// Best effort: never block the exit on runtime cleanup.
|
|
140
155
|
}
|
|
141
|
-
renderer.destroy();
|
|
142
|
-
// Print AFTER destroy so the note survives on the restored terminal.
|
|
143
156
|
for (const message of messages) console.log(`[wiki-manager] ${message}`);
|
|
144
|
-
|
|
157
|
+
console.log('[wiki-manager] fermeture terminée — shell clos proprement.');
|
|
158
|
+
});
|
|
159
|
+
if (props.exitRef) props.exitRef.current = task;
|
|
145
160
|
};
|
|
146
161
|
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
147
162
|
let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
148
163
|
let startupKeyboardEventId = 0;
|
|
149
164
|
let lastCopiedSelection = '';
|
|
150
|
-
let runtimeShutdownRequested = false;
|
|
151
165
|
const state = useSession(props);
|
|
152
|
-
const startup = createMemo(() => startupInfo(props.packageJson));
|
|
166
|
+
const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
|
|
153
167
|
const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
|
|
154
168
|
const rightColumns = createMemo(() => {
|
|
155
169
|
const width = dimensions().width;
|
|
156
|
-
// 38%
|
|
170
|
+
// 38% + 2 columns / cap 58: the Plan/Activity/Logs panes carry job
|
|
157
171
|
// labels, file names and error messages — 40 columns truncated everything
|
|
158
|
-
// into unreadable stubs.
|
|
159
|
-
|
|
172
|
+
// into unreadable stubs. The small addition uses the terminal's right-side
|
|
173
|
+
// slack without making the conversation pane noticeably narrower.
|
|
174
|
+
return Math.max(32, Math.min(58, Math.floor(width * 0.38) + 2));
|
|
160
175
|
});
|
|
161
176
|
const leftColumns = createMemo(() => Math.max(32, dimensions().width - rightColumns() - 1));
|
|
162
177
|
const conversationColumns = createMemo(() => {
|
|
@@ -187,7 +202,7 @@ function App(props: {
|
|
|
187
202
|
// decide *which* workspace to load must re-read current state directly
|
|
188
203
|
// (startupInfo(...)) rather than trust the frozen memo, the same way
|
|
189
204
|
// closeSetup() already does.
|
|
190
|
-
const loadDefaultWorkspace = async () => loadWorkspace(startupInfo(props.packageJson).workspaceName);
|
|
205
|
+
const loadDefaultWorkspace = async () => loadWorkspace(startupInfo(props.packageJson, props.initialWorkspaceName).workspaceName);
|
|
191
206
|
|
|
192
207
|
// The canonical status is the first useful view after entering ShellUI.
|
|
193
208
|
// Run it only after /use has established the workspace so config, services,
|
|
@@ -196,18 +211,6 @@ function App(props: {
|
|
|
196
211
|
if (workspaceLoaded) await state.submitInput('/status');
|
|
197
212
|
};
|
|
198
213
|
|
|
199
|
-
// "Open a workspace" is really one composite action (load it, then bring
|
|
200
|
-
// its services up) — named here so it has one definition instead of being
|
|
201
|
-
// inlined as three sequential submitInput calls in openAction.
|
|
202
|
-
const openWorkspaceAndStartServices = async (workspaceName?: string | null) => {
|
|
203
|
-
const loaded = await loadWorkspace(workspaceName);
|
|
204
|
-
if (loaded) {
|
|
205
|
-
await state.submitInput('/start agents');
|
|
206
|
-
await state.submitInput('/start all');
|
|
207
|
-
}
|
|
208
|
-
return loaded;
|
|
209
|
-
};
|
|
210
|
-
|
|
211
214
|
const refreshPreflight = async () => {
|
|
212
215
|
setPreflightBusy(true);
|
|
213
216
|
try {
|
|
@@ -227,37 +230,11 @@ function App(props: {
|
|
|
227
230
|
void refreshPreflight();
|
|
228
231
|
return;
|
|
229
232
|
}
|
|
230
|
-
if (action === 'start-services') {
|
|
231
|
-
void (async () => {
|
|
232
|
-
setPreflightBusy(true);
|
|
233
|
-
try {
|
|
234
|
-
await openWorkspaceAndStartServices(workspaceName ?? startupInfo(props.packageJson).workspaceName);
|
|
235
|
-
const next = await runPreflightChecks();
|
|
236
|
-
setPreflight(withRuntimePreflight(next, props.runtime));
|
|
237
|
-
} finally {
|
|
238
|
-
setPreflightBusy(false);
|
|
239
|
-
}
|
|
240
|
-
})();
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (action === 'open-logs') {
|
|
244
|
-
setScreen('main');
|
|
245
|
-
void (async () => {
|
|
246
|
-
const loaded = await loadDefaultWorkspace();
|
|
247
|
-
if (!loaded) return;
|
|
248
|
-
const containerCheck = preflight().checks?.find((check: any) => check.kind === 'containers');
|
|
249
|
-
const service = containerCheck?.context?.unavailable?.[0];
|
|
250
|
-
if (service) await state.submitInput(`/logs ${service} 120`);
|
|
251
|
-
else await state.submitInput('/services');
|
|
252
|
-
await state.submitInput('/mcp status');
|
|
253
|
-
})();
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
233
|
setScreen('main');
|
|
257
234
|
void (async () => {
|
|
258
235
|
try {
|
|
259
236
|
if (action === 'open-workspace') {
|
|
260
|
-
const loaded = await
|
|
237
|
+
const loaded = await loadWorkspace(workspaceName ?? startupInfo(props.packageJson, props.initialWorkspaceName).workspaceName);
|
|
261
238
|
await showDefaultStatus(loaded);
|
|
262
239
|
} else if (action === 'new-conversation' || action === 'run-workflow') {
|
|
263
240
|
const loaded = await loadDefaultWorkspace();
|
|
@@ -275,7 +252,7 @@ function App(props: {
|
|
|
275
252
|
};
|
|
276
253
|
|
|
277
254
|
const closeSetup = () => {
|
|
278
|
-
const info = startupInfo(props.packageJson);
|
|
255
|
+
const info = startupInfo(props.packageJson, props.initialWorkspaceName);
|
|
279
256
|
if (!info.hasWorkspace) {
|
|
280
257
|
setScreen('startup');
|
|
281
258
|
return;
|
|
@@ -307,10 +284,6 @@ function App(props: {
|
|
|
307
284
|
onCleanup(() => {
|
|
308
285
|
if (copyHintTimer) clearTimeout(copyHintTimer);
|
|
309
286
|
if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
|
|
310
|
-
if (props.runtime?.url && !runtimeShutdownRequested) {
|
|
311
|
-
runtimeShutdownRequested = true;
|
|
312
|
-
void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
|
|
313
|
-
}
|
|
314
287
|
});
|
|
315
288
|
|
|
316
289
|
const spinnerTimer = setInterval(() => {
|
|
@@ -358,17 +331,24 @@ function App(props: {
|
|
|
358
331
|
if (keyName === 'tab') state.completeSelected();
|
|
359
332
|
if (keyName === 'pageup') state.scrollConversation(conversationRows());
|
|
360
333
|
else if (keyName === 'pagedown') state.scrollConversation(-conversationRows());
|
|
361
|
-
if (keyName === 'up' && state.slash()
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
334
|
+
if ((keyName === 'up' || keyName === 'down') && (state.slash() || !state.input().includes('\n'))) {
|
|
335
|
+
// The Plan scrollbox also listens to global arrow keys. Consume arrows
|
|
336
|
+
// handled by the chat input so one key press cannot move both widgets.
|
|
337
|
+
key.preventDefault?.();
|
|
338
|
+
key.stopPropagation?.();
|
|
339
|
+
if (state.slash()) state.moveCompletion(keyName === 'up' ? -1 : 1);
|
|
340
|
+
else if (keyName === 'up') state.historyUp();
|
|
341
|
+
else state.historyDown();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (keyName === 'escape') {
|
|
366
345
|
if (state.slash()) state.dismissSlash();
|
|
367
346
|
else state.setInput('');
|
|
368
347
|
}
|
|
369
348
|
});
|
|
370
349
|
|
|
371
350
|
const hintLine = () => {
|
|
351
|
+
if (exitStatus()) return exitStatus();
|
|
372
352
|
if (copyHint()) return copyHint();
|
|
373
353
|
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
374
354
|
if (state.runtimeHint()) return state.runtimeHint();
|
|
@@ -416,6 +396,8 @@ function App(props: {
|
|
|
416
396
|
queueInfo={state.queueInfo()}
|
|
417
397
|
activeTab={state.rightTab()}
|
|
418
398
|
logFilter={state.runtimeLogFilter()}
|
|
399
|
+
pendingApprovals={state.pendingApprovals()}
|
|
400
|
+
onApprove={() => { void state.submitInput('/approve'); }}
|
|
419
401
|
onTabClick={state.selectRightTab}
|
|
420
402
|
/>
|
|
421
403
|
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
@@ -467,20 +449,33 @@ export async function runOpenTuiShell({
|
|
|
467
449
|
packageJson,
|
|
468
450
|
runtime = null,
|
|
469
451
|
preflight = null,
|
|
452
|
+
initialWorkspaceName = null,
|
|
470
453
|
}: {
|
|
471
454
|
agent: unknown;
|
|
472
455
|
packageJson: Record<string, unknown>;
|
|
473
456
|
runtime?: any;
|
|
474
457
|
preflight?: any;
|
|
458
|
+
initialWorkspaceName?: string | null;
|
|
475
459
|
}) {
|
|
460
|
+
const exitRef = { current: Promise.resolve() as Promise<void> };
|
|
476
461
|
await new Promise<void>((resolve, reject) => {
|
|
477
|
-
render(() =>
|
|
462
|
+
render(() => (
|
|
463
|
+
<App
|
|
464
|
+
agent={agent}
|
|
465
|
+
packageJson={packageJson}
|
|
466
|
+
runtime={runtime}
|
|
467
|
+
preflight={preflight}
|
|
468
|
+
initialWorkspaceName={initialWorkspaceName}
|
|
469
|
+
exitRef={exitRef}
|
|
470
|
+
/>
|
|
471
|
+
), {
|
|
478
472
|
exitOnCtrlC: false,
|
|
479
473
|
useMouse: true,
|
|
480
474
|
targetFps: 30,
|
|
481
475
|
onDestroy: resolve,
|
|
482
476
|
}).catch(reject);
|
|
483
477
|
});
|
|
478
|
+
await exitRef.current;
|
|
484
479
|
return {};
|
|
485
480
|
}
|
|
486
481
|
|
package/src/shell/useSession.ts
CHANGED
|
@@ -329,6 +329,11 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
329
329
|
const tagged = filterRuntimeLogs(runtimeLogs.map((line: any) => `runtime ${String(line)}`), filter);
|
|
330
330
|
return [...logs(), ...tagged];
|
|
331
331
|
});
|
|
332
|
+
const pendingApprovals = createMemo(() => {
|
|
333
|
+
version();
|
|
334
|
+
return (Array.isArray(runtimeState()?.approvals) ? runtimeState().approvals : [])
|
|
335
|
+
.filter((approval: any) => approval.status === 'pending_approval');
|
|
336
|
+
});
|
|
332
337
|
|
|
333
338
|
// Index-aligned with each workspace's runtimeConversation array — not just
|
|
334
339
|
// a length count. The server sometimes finalizes a streaming placeholder
|
|
@@ -379,16 +384,23 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
379
384
|
continue;
|
|
380
385
|
}
|
|
381
386
|
if (role === 'user') {
|
|
382
|
-
// The user's own message was already pushed optimistically
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
387
|
+
// The user's own message was already pushed optimistically — either
|
|
388
|
+
// marked _pending (web / runtime-submit path, useAgent.ts) OR as a plain
|
|
389
|
+
// entry by the interactive agent turn (repl.js runAgentTurn) that then
|
|
390
|
+
// delegated. Adopt whichever already exists instead of appending a
|
|
391
|
+
// second identical copy (the delegation seeds the run with the same
|
|
392
|
+
// objective, which comes back through this merge).
|
|
386
393
|
const pendingIndex = target.findIndex((entry: any) => entry._pending && entry.role === 'user' && entry.content === content);
|
|
387
394
|
if (pendingIndex !== -1) {
|
|
388
395
|
delete target[pendingIndex]._pending;
|
|
389
396
|
backed.push(target[pendingIndex]);
|
|
390
397
|
continue;
|
|
391
398
|
}
|
|
399
|
+
const localIndex = target.findIndex((entry: any) => entry.role === 'user' && entry.content === content && !backed.includes(entry));
|
|
400
|
+
if (localIndex !== -1) {
|
|
401
|
+
backed.push(target[localIndex]);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
392
404
|
}
|
|
393
405
|
const entry = { role, content };
|
|
394
406
|
target.push(entry);
|
|
@@ -397,19 +409,37 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
397
409
|
}
|
|
398
410
|
|
|
399
411
|
function syncRuntimeState() {
|
|
400
|
-
|
|
412
|
+
const workspace = (session as any).workspace ?? null;
|
|
413
|
+
const workspaceKey = workspace ?? '__global__';
|
|
414
|
+
if (runtimeSyncInFlight.has(workspaceKey)) return;
|
|
415
|
+
runtimeSyncInFlight.add(workspaceKey);
|
|
416
|
+
void fetchRuntimeState({ url: props.runtime.url, workspace })
|
|
401
417
|
.then((state) => {
|
|
418
|
+
if (((session as any).workspace ?? null) !== workspace) return;
|
|
402
419
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
403
420
|
setRuntimeState(displayState);
|
|
421
|
+
// The per-job "Job terminé" lines and the canned plan summary are gone:
|
|
422
|
+
// the runtime now emits ONE natural-language Donna message at completion
|
|
423
|
+
// (see announceRunOutcome in runner.js), which arrives through the
|
|
424
|
+
// conversation merge below. Detect that the chat grew to keep the
|
|
425
|
+
// welcome-hidden / scroll-to-latest behavior.
|
|
426
|
+
const beforeLength = conversationMessages(session).length;
|
|
404
427
|
mergeRuntimeConversation(displayState);
|
|
428
|
+
if (conversationMessages(session).length > beforeLength) {
|
|
429
|
+
setShowWelcome(false);
|
|
430
|
+
setConversationScroll(0);
|
|
431
|
+
}
|
|
405
432
|
setRuntimeStatus('connected');
|
|
406
433
|
refresh();
|
|
407
434
|
})
|
|
408
435
|
.catch(() => {
|
|
436
|
+
if (((session as any).workspace ?? null) !== workspace) return;
|
|
409
437
|
setRuntimeStatus('disconnected');
|
|
410
438
|
refresh();
|
|
411
|
-
})
|
|
439
|
+
})
|
|
440
|
+
.finally(() => { runtimeSyncInFlight.delete(workspaceKey); });
|
|
412
441
|
}
|
|
442
|
+
const runtimeSyncInFlight = new Set<string>();
|
|
413
443
|
let runtimeStreamAbort: AbortController | null = null;
|
|
414
444
|
let runtimeSyncDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
415
445
|
let runtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -446,6 +476,13 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
446
476
|
void subscribeRuntimeEvents();
|
|
447
477
|
}
|
|
448
478
|
|
|
479
|
+
// Safety net only — the SSE stream (subscribeRuntimeEvents) is the primary
|
|
480
|
+
// sync path and already debounces on every agent_event. This just catches
|
|
481
|
+
// a missed/dropped event, so it can poll far less often than that.
|
|
482
|
+
const runtimeCompletionWatchdog = setInterval(() => {
|
|
483
|
+
if (props.runtime?.url && runtimeRunStatus() === 'running') syncRuntimeState();
|
|
484
|
+
}, 10000);
|
|
485
|
+
|
|
449
486
|
let currentRuntimeWorkspace = (session as any).workspace ?? null;
|
|
450
487
|
function resyncRuntimeWorkspaceIfChanged() {
|
|
451
488
|
const workspace = (session as any).workspace ?? null;
|
|
@@ -466,6 +503,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
466
503
|
runtimeStreamAbort?.abort();
|
|
467
504
|
if (runtimeSyncDebounce) clearTimeout(runtimeSyncDebounce);
|
|
468
505
|
if (runtimeReconnectTimer) clearTimeout(runtimeReconnectTimer);
|
|
506
|
+
clearInterval(runtimeCompletionWatchdog);
|
|
469
507
|
});
|
|
470
508
|
|
|
471
509
|
const activityPollTimer = setInterval(() => {
|
|
@@ -690,6 +728,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
690
728
|
toggleRightTab,
|
|
691
729
|
selectRightTab,
|
|
692
730
|
plan,
|
|
731
|
+
pendingApprovals,
|
|
693
732
|
conversationScroll,
|
|
694
733
|
scrollConversation,
|
|
695
734
|
conversationBusy,
|