@dotdrelle/wiki-manager 0.14.13 → 0.14.20
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 +29 -4
- package/README.md +19 -0
- package/docker-compose.yml +6 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +50 -16
- package/src/activity/activityAggregator.test.js +67 -4
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +74 -11
- package/src/cli/wiki-manager.test.js +40 -1
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +35 -0
- package/src/core/wikiWorkspace.test.js +20 -0
- package/src/core/workflow.js +72 -0
- package/src/core/workflow.test.js +57 -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.js +27 -7
- package/src/orchestrator/scheduler.test.js +45 -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 +141 -18
- package/src/runtime/runner.test.js +30 -0
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/runtime/store.js +54 -0
- package/src/runtime/store.test.js +42 -0
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +168 -56
- 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 +141 -38
- package/src/shell/tui.tsx +65 -65
- package/src/shell/useSession.ts +92 -6
- package/wiki-workspace +28 -0
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,122 @@ 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('ShellUI shows the canonical run summary above the plan', async () => {
|
|
104
|
+
const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
|
|
105
|
+
const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
106
|
+
const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
|
|
107
|
+
assert.match(session, /const runSummary = createMemo/);
|
|
108
|
+
assert.match(session, /agent\$\{agents\.size === 1/);
|
|
109
|
+
assert.match(session, /parallel \$\{activeParallel\}\/×\$\{maxParallel\}/);
|
|
110
|
+
assert.match(session, /usage\.inputKnown/);
|
|
111
|
+
assert.match(session, /usage\.outputKnown/);
|
|
112
|
+
assert.match(pane, /summary=\{props\.runSummary\}/);
|
|
113
|
+
assert.match(tui, /runSummary=\{state\.runSummary\(\)\}/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
|
|
117
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
118
|
+
const entryRenderer = source.slice(
|
|
119
|
+
source.indexOf('function logEntryLines'),
|
|
120
|
+
source.indexOf('export function LogPanel'),
|
|
121
|
+
);
|
|
122
|
+
assert.match(entryRenderer, /sourceMatch/);
|
|
123
|
+
assert.doesNotMatch(entryRenderer, /prefix\.push\(\{ text: `\$\{source\}/);
|
|
124
|
+
assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('runtime logs have a separator and a concise Runtime tab label', async () => {
|
|
128
|
+
const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
|
|
129
|
+
const logPanel = source.slice(
|
|
130
|
+
source.indexOf('export function LogPanel'),
|
|
131
|
+
source.indexOf('export function QueuePanel'),
|
|
132
|
+
);
|
|
133
|
+
assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
|
|
134
|
+
assert.match(logPanel, /content=" Runtime "/);
|
|
135
|
+
assert.doesNotMatch(logPanel, /Flow \/ Trace/);
|
|
136
|
+
});
|
|
137
|
+
|
|
25
138
|
test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
|
|
26
139
|
assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
|
|
27
140
|
{ text: 'Voir ' },
|
|
@@ -47,6 +160,16 @@ test('ShellUI leaves malformed URLs as plain text', () => {
|
|
|
47
160
|
assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
|
|
48
161
|
});
|
|
49
162
|
|
|
163
|
+
test('ShellUI preserves every URL when several links share one line', () => {
|
|
164
|
+
const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
|
|
165
|
+
.flat()
|
|
166
|
+
.filter((part) => part.url);
|
|
167
|
+
assert.deepEqual(links, [
|
|
168
|
+
{ text: '[link: one.example]', url: 'https://one.example/a' },
|
|
169
|
+
{ text: '[link: two.example]', url: 'https://two.example/b' },
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
50
173
|
test('runtime display preserves a failed plan and its diagnostic evidence', () => {
|
|
51
174
|
const state = {
|
|
52
175
|
status: 'error',
|
|
@@ -59,7 +182,7 @@ test('runtime display preserves a failed plan and its diagnostic evidence', () =
|
|
|
59
182
|
assert.equal(sanitizeRuntimeStateForDisplay(state), state);
|
|
60
183
|
});
|
|
61
184
|
|
|
62
|
-
test('runtime display
|
|
185
|
+
test('runtime display preserves completed plan and logs for post-run inspection', () => {
|
|
63
186
|
const display = sanitizeRuntimeStateForDisplay({
|
|
64
187
|
status: 'done',
|
|
65
188
|
plan: [{ id: 'old', status: 'done' }],
|
|
@@ -68,10 +191,10 @@ test('runtime display still clears completed historical execution state', () =>
|
|
|
68
191
|
conversation: [{ role: 'assistant', content: 'Old run.' }],
|
|
69
192
|
});
|
|
70
193
|
|
|
71
|
-
assert.deepEqual(display.plan, []);
|
|
72
|
-
assert.deepEqual(display.activities, []);
|
|
73
|
-
assert.deepEqual(display.logs, []);
|
|
74
|
-
assert.deepEqual(display.conversation, []);
|
|
194
|
+
assert.deepEqual(display.plan, [{ id: 'old', status: 'done' }]);
|
|
195
|
+
assert.deepEqual(display.activities, [{ id: 'old-job', status: 'done' }]);
|
|
196
|
+
assert.deepEqual(display.logs, ['old log']);
|
|
197
|
+
assert.deepEqual(display.conversation, [{ role: 'assistant', content: 'Old run.' }]);
|
|
75
198
|
});
|
|
76
199
|
|
|
77
200
|
function stubFetch(handler) {
|
|
@@ -133,7 +256,7 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
|
|
|
133
256
|
assert.deepEqual(conversationMessages(session), []);
|
|
134
257
|
});
|
|
135
258
|
|
|
136
|
-
test('applyRuntimeStateToShellSession
|
|
259
|
+
test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtime is idle', () => {
|
|
137
260
|
const session = createSession();
|
|
138
261
|
session.headlessPlan = [{ step: 1, description: 'Old read', status: 'failed' }];
|
|
139
262
|
session.activities = { old: { key: 'old', status: 'failed', terminal: true } };
|
|
@@ -150,17 +273,17 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
|
|
|
150
273
|
planPatches: [{ id: 'old-patch' }],
|
|
151
274
|
});
|
|
152
275
|
|
|
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, []);
|
|
276
|
+
assert.deepEqual(session.headlessPlan, [{ step: 1, description: 'Old read', status: 'failed' }]);
|
|
277
|
+
assert.deepEqual(session.activities, { old: { key: 'old', status: 'failed', terminal: true } });
|
|
278
|
+
assert.deepEqual(session.workflow.nodes, [{ id: 'task:old' }]);
|
|
279
|
+
assert.deepEqual(session.agentProjection.logs, ['Runtime evaluator rejected the old run']);
|
|
280
|
+
assert.equal(session.agentProjection.summary, 'Old run failed');
|
|
281
|
+
assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failed answer' }]);
|
|
282
|
+
assert.deepEqual(session.agentProjection.chain, [{ id: 'old-step' }]);
|
|
283
|
+
assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
|
|
161
284
|
});
|
|
162
285
|
|
|
163
|
-
test('runtime
|
|
286
|
+
test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
|
|
164
287
|
const session = createSession();
|
|
165
288
|
applyRuntimeStateToShellSession(session, {
|
|
166
289
|
runId: 'run-1',
|
|
@@ -178,27 +301,7 @@ test('runtime plan completion is appended once to the ShellUI conversation', ()
|
|
|
178
301
|
plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
|
|
179
302
|
});
|
|
180
303
|
|
|
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
|
-
}]);
|
|
304
|
+
assert.deepEqual(conversationMessages(session), []);
|
|
202
305
|
});
|
|
203
306
|
|
|
204
307
|
test('direct chat system prompt forbids unsolicited next steps', async () => {
|
|
@@ -312,7 +415,7 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
|
|
|
312
415
|
assert.equal(session.chatMode, true);
|
|
313
416
|
assert.equal(result.oneShotAgent, true);
|
|
314
417
|
assert.equal(result.runtimeOutcome.kind, 'accepted');
|
|
315
|
-
assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
|
|
418
|
+
assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
|
|
316
419
|
} finally {
|
|
317
420
|
restore();
|
|
318
421
|
}
|
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,14 +284,13 @@ 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(() => {
|
|
317
|
-
|
|
290
|
+
// Advance while the chat is streaming OR a runtime run is executing. Without
|
|
291
|
+
// executionActive(), the plan spinner froze on frame 0 during a scheduler-
|
|
292
|
+
// driven run (no conversation turn), so it looked static.
|
|
293
|
+
if (state.busy() || state.executionActive()) setSpinnerIndex((value) => (value + 1) % 10);
|
|
318
294
|
}, 90);
|
|
319
295
|
onCleanup(() => {
|
|
320
296
|
clearInterval(spinnerTimer);
|
|
@@ -358,17 +334,24 @@ function App(props: {
|
|
|
358
334
|
if (keyName === 'tab') state.completeSelected();
|
|
359
335
|
if (keyName === 'pageup') state.scrollConversation(conversationRows());
|
|
360
336
|
else if (keyName === 'pagedown') state.scrollConversation(-conversationRows());
|
|
361
|
-
if (keyName === 'up' && state.slash()
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
337
|
+
if ((keyName === 'up' || keyName === 'down') && (state.slash() || !state.input().includes('\n'))) {
|
|
338
|
+
// The Plan scrollbox also listens to global arrow keys. Consume arrows
|
|
339
|
+
// handled by the chat input so one key press cannot move both widgets.
|
|
340
|
+
key.preventDefault?.();
|
|
341
|
+
key.stopPropagation?.();
|
|
342
|
+
if (state.slash()) state.moveCompletion(keyName === 'up' ? -1 : 1);
|
|
343
|
+
else if (keyName === 'up') state.historyUp();
|
|
344
|
+
else state.historyDown();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (keyName === 'escape') {
|
|
366
348
|
if (state.slash()) state.dismissSlash();
|
|
367
349
|
else state.setInput('');
|
|
368
350
|
}
|
|
369
351
|
});
|
|
370
352
|
|
|
371
353
|
const hintLine = () => {
|
|
354
|
+
if (exitStatus()) return exitStatus();
|
|
372
355
|
if (copyHint()) return copyHint();
|
|
373
356
|
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
374
357
|
if (state.runtimeHint()) return state.runtimeHint();
|
|
@@ -412,11 +395,15 @@ function App(props: {
|
|
|
412
395
|
activities={state.activities()}
|
|
413
396
|
logs={state.logs()}
|
|
414
397
|
plan={state.plan()}
|
|
398
|
+
runSummary={state.runSummary()}
|
|
415
399
|
queueItems={state.queueItems()}
|
|
416
400
|
queueInfo={state.queueInfo()}
|
|
417
401
|
activeTab={state.rightTab()}
|
|
418
402
|
logFilter={state.runtimeLogFilter()}
|
|
403
|
+
pendingApprovals={state.pendingApprovals()}
|
|
404
|
+
onApprove={() => { void state.submitInput('/approve'); }}
|
|
419
405
|
onTabClick={state.selectRightTab}
|
|
406
|
+
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
420
407
|
/>
|
|
421
408
|
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
422
409
|
<FileEditorDialog
|
|
@@ -467,20 +454,33 @@ export async function runOpenTuiShell({
|
|
|
467
454
|
packageJson,
|
|
468
455
|
runtime = null,
|
|
469
456
|
preflight = null,
|
|
457
|
+
initialWorkspaceName = null,
|
|
470
458
|
}: {
|
|
471
459
|
agent: unknown;
|
|
472
460
|
packageJson: Record<string, unknown>;
|
|
473
461
|
runtime?: any;
|
|
474
462
|
preflight?: any;
|
|
463
|
+
initialWorkspaceName?: string | null;
|
|
475
464
|
}) {
|
|
465
|
+
const exitRef = { current: Promise.resolve() as Promise<void> };
|
|
476
466
|
await new Promise<void>((resolve, reject) => {
|
|
477
|
-
render(() =>
|
|
467
|
+
render(() => (
|
|
468
|
+
<App
|
|
469
|
+
agent={agent}
|
|
470
|
+
packageJson={packageJson}
|
|
471
|
+
runtime={runtime}
|
|
472
|
+
preflight={preflight}
|
|
473
|
+
initialWorkspaceName={initialWorkspaceName}
|
|
474
|
+
exitRef={exitRef}
|
|
475
|
+
/>
|
|
476
|
+
), {
|
|
478
477
|
exitOnCtrlC: false,
|
|
479
478
|
useMouse: true,
|
|
480
479
|
targetFps: 30,
|
|
481
480
|
onDestroy: resolve,
|
|
482
481
|
}).catch(reject);
|
|
483
482
|
});
|
|
483
|
+
await exitRef.current;
|
|
484
484
|
return {};
|
|
485
485
|
}
|
|
486
486
|
|