@dotdrelle/wiki-manager 0.14.13 → 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/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/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 +111 -15
- 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/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 +28 -0
package/src/shell/repl.js
CHANGED
|
@@ -822,73 +822,8 @@ function rememberProductionActivity(session, payload) {
|
|
|
822
822
|
return true;
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
-
const TERMINAL_PLAN_STATUSES = new Set(['done', 'completed', 'succeeded', 'success', 'failed', 'error', 'cancelled', 'canceled']);
|
|
826
|
-
|
|
827
|
-
function planTaskKey(task, index) {
|
|
828
|
-
return String(task?.id ?? task?.step ?? `task-${index + 1}`);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function errorDescription(value) {
|
|
832
|
-
if (!value) return null;
|
|
833
|
-
if (typeof value === 'string') return value.trim() || null;
|
|
834
|
-
if (value instanceof Error) return value.message;
|
|
835
|
-
if (typeof value === 'object') {
|
|
836
|
-
return errorDescription(value.message ?? value.reason ?? value.detail ?? value.description ?? value.error)
|
|
837
|
-
?? (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
|
|
838
|
-
}
|
|
839
|
-
return String(value);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
function planTaskError(task, state) {
|
|
843
|
-
const activityKey = task?.activityKey ?? task?._activityKey ?? task?.ownerActivityKey;
|
|
844
|
-
const activity = (state?.activities ?? []).find((item) =>
|
|
845
|
-
(activityKey && String(item?.key) === String(activityKey))
|
|
846
|
-
|| (task?.id != null && String(item?.taskId ?? '') === String(task.id)));
|
|
847
|
-
return errorDescription(
|
|
848
|
-
task?.error
|
|
849
|
-
?? task?.result?.error
|
|
850
|
-
?? task?.result?.reason
|
|
851
|
-
?? activity?.error
|
|
852
|
-
?? activity?.detail
|
|
853
|
-
?? (['failed', 'error'].includes(String(state?.status ?? '').toLowerCase()) ? state?.evaluation?.reason : null),
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
export function appendRuntimePlanCompletionMessages(session, state) {
|
|
858
|
-
const plan = Array.isArray(state?.plan) ? state.plan : [];
|
|
859
|
-
const runId = String(state?.runId ?? 'runtime');
|
|
860
|
-
const current = new Map(plan.map((task, index) => [planTaskKey(task, index), String(task?.status ?? 'pending').toLowerCase()]));
|
|
861
|
-
if (session._runtimePlanRunId !== runId || !(session._runtimePlanStatuses instanceof Map)) {
|
|
862
|
-
session._runtimePlanRunId = runId;
|
|
863
|
-
session._runtimePlanStatuses = current;
|
|
864
|
-
return 0;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
let appended = 0;
|
|
868
|
-
plan.forEach((task, index) => {
|
|
869
|
-
const key = planTaskKey(task, index);
|
|
870
|
-
const status = current.get(key);
|
|
871
|
-
const previous = session._runtimePlanStatuses.get(key);
|
|
872
|
-
if (!TERMINAL_PLAN_STATUSES.has(status) || TERMINAL_PLAN_STATUSES.has(previous)) return;
|
|
873
|
-
const label = String(task?.label ?? task?.description ?? task?.name ?? `Job ${key}`);
|
|
874
|
-
const failed = status === 'failed' || status === 'error';
|
|
875
|
-
const cancelled = status === 'cancelled' || status === 'canceled';
|
|
876
|
-
const error = failed ? planTaskError(task, state) : null;
|
|
877
|
-
const content = failed
|
|
878
|
-
? `✗ Job terminé en erreur : ${label}\nStatus: ${status}${error ? `\nErreur: ${error}` : ''}`
|
|
879
|
-
: cancelled
|
|
880
|
-
? `○ Job terminé : ${label}\nStatus: ${status}`
|
|
881
|
-
: `✓ Job terminé : ${label}\nStatus: ${status}`;
|
|
882
|
-
conversationMessages(session).push({ role: 'command', content });
|
|
883
|
-
appended += 1;
|
|
884
|
-
});
|
|
885
|
-
session._runtimePlanStatuses = current;
|
|
886
|
-
return appended;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
825
|
export function applyRuntimeStateToShellSession(session, state) {
|
|
890
826
|
if (!state || typeof state !== 'object') return false;
|
|
891
|
-
appendRuntimePlanCompletionMessages(session, state);
|
|
892
827
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
893
828
|
session.agentProjection = {
|
|
894
829
|
conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
|
|
@@ -937,21 +872,9 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
937
872
|
|
|
938
873
|
export function sanitizeRuntimeStateForDisplay(state) {
|
|
939
874
|
if (!state || typeof state !== 'object') return state;
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
if (visible) return state;
|
|
944
|
-
return {
|
|
945
|
-
...state,
|
|
946
|
-
conversation: [],
|
|
947
|
-
chain: [],
|
|
948
|
-
plan: [],
|
|
949
|
-
activities: [],
|
|
950
|
-
workflow: null,
|
|
951
|
-
logs: [],
|
|
952
|
-
summary: null,
|
|
953
|
-
planPatches: [],
|
|
954
|
-
};
|
|
875
|
+
// Keep the last terminal run visible for post-run inspection. A new
|
|
876
|
+
// run_started event replaces these fields when the next run begins.
|
|
877
|
+
return state;
|
|
955
878
|
}
|
|
956
879
|
|
|
957
880
|
// In agent mode, Donna receives every free-text turn and decides whether to
|
|
@@ -1467,7 +1390,10 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1467
1390
|
onUpdate?.();
|
|
1468
1391
|
return { exit: false, oneShotAgent: true };
|
|
1469
1392
|
}
|
|
1470
|
-
|
|
1393
|
+
// The runtime persists and returns this same user message through /state.
|
|
1394
|
+
// Mark the optimistic local copy so useSession can bind that runtime entry
|
|
1395
|
+
// to it instead of appending a duplicate after `/agent <instruction>`.
|
|
1396
|
+
conversationMessages(session).push({ role: 'user', content: oneShotAgentInput, _pending: true });
|
|
1471
1397
|
onUpdate?.();
|
|
1472
1398
|
const outcome = await submitRuntimeRun(oneShotAgentInput, { runtime, session });
|
|
1473
1399
|
applyRuntimeOutcome(session, outcome, (msg) => onStep?.(msg));
|
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
|
|