@dotdrelle/wiki-manager 0.11.4 → 0.11.7
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/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +118 -5
- package/src/agent/graph.test.js +158 -1
- package/src/cli/wiki-manager.js +9 -2
- package/src/commands/slash.js +36 -11
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/core/profile.js +111 -0
- package/src/core/skills.js +1 -1
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/runner.js +72 -3
- package/src/runtime/runner.test.js +113 -3
- package/src/runtime/server.js +27 -1
- package/src/runtime/server.test.js +38 -0
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +118 -23
- package/src/shell/repl.js +95 -42
- package/src/shell/repl.test.js +59 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
package/src/shell/repl.js
CHANGED
|
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
6
6
|
import { marked } from 'marked';
|
|
7
7
|
import { markedTerminal } from 'marked-terminal';
|
|
8
|
-
import { buildAgentSystemPrompt,
|
|
8
|
+
import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
|
|
9
9
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
10
10
|
import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
|
|
11
11
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
@@ -15,7 +15,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
15
15
|
import { listSkills } from '../core/skills.js';
|
|
16
16
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
17
17
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
18
|
-
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
|
|
18
|
+
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, postRuntimeShutdown, streamRuntimeEvents } from '../runtime/client.js';
|
|
19
19
|
|
|
20
20
|
marked.use(markedTerminal());
|
|
21
21
|
// marked-terminal's text renderer extracts token.text (raw string) instead of
|
|
@@ -102,6 +102,31 @@ const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
102
102
|
'/skills:show': 'Show one workspace skill.',
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
+
export function runtimeUnavailableReason(runtime) {
|
|
106
|
+
if (runtime?.url) return null;
|
|
107
|
+
const reason = runtime?.error ?? runtime?.unavailableReason ?? runtime?.reason ?? null;
|
|
108
|
+
return reason ? String(reason) : 'runtime introuvable';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function runtimeUnavailableAgentMessage(runtime) {
|
|
112
|
+
const reason = runtimeUnavailableReason(runtime);
|
|
113
|
+
return reason ? `⚠ Runtime indisponible : ${reason} — /agent désactivé, /chat reste possible` : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function runtimeStatusLine(runtime, session) {
|
|
117
|
+
if (runtime?.url) return `runtime: connected (${session?.workspace ?? 'no workspace'})`;
|
|
118
|
+
const reason = runtimeUnavailableReason(runtime);
|
|
119
|
+
if (reason) return `runtime: disconnected: ${reason}`;
|
|
120
|
+
return 'runtime: off';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function recordRuntimeUnavailableAgentInput(session, line, runtime) {
|
|
124
|
+
const message = runtimeUnavailableAgentMessage(runtime);
|
|
125
|
+
conversationMessages(session).push({ role: 'user', content: line });
|
|
126
|
+
conversationMessages(session).push({ role: 'command', content: message ?? 'Runtime indisponible.' });
|
|
127
|
+
return message;
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
export function createSession() {
|
|
106
131
|
return {
|
|
107
132
|
workspace: null,
|
|
@@ -839,17 +864,18 @@ function isDurableActivityLine(label) {
|
|
|
839
864
|
return /^[a-z0-9_-]+\.[a-z0-9_-]+:/i.test(String(label).trim());
|
|
840
865
|
}
|
|
841
866
|
|
|
842
|
-
function renderScreen({ packageJson, session, messages, inputBuffer, busy = false, spinnerFrame = 0, scrollOffset = 0, spinnerLabel = 'Thinking…', activityLines = [] }) {
|
|
867
|
+
function renderScreen({ packageJson, session, messages, inputBuffer, busy = false, spinnerFrame = 0, scrollOffset = 0, spinnerLabel = 'Thinking…', activityLines = [], runtime = null }) {
|
|
843
868
|
const columns = output.columns || 100;
|
|
844
869
|
const rows = output.rows || 30;
|
|
845
870
|
const banner = renderBannerWithMcpPanel(columns, session);
|
|
871
|
+
const runtimeBanner = runtimeUnavailableAgentMessage(runtime);
|
|
846
872
|
const completions = busy ? [] : completionLines(inputBuffer, session, columns);
|
|
847
873
|
const visibleCompletions = completions.slice(0, COMPLETION_PANEL_ROWS);
|
|
848
874
|
const completionRows = visibleCompletions.length > 0 ? COMPLETION_PANEL_ROWS : 0;
|
|
849
875
|
const activityRows = LOWER_DETAIL_ROWS - completionRows;
|
|
850
876
|
const activity = renderActivityLines(activityLines, columns, activityRows);
|
|
851
877
|
const productionActivityRows = 1;
|
|
852
|
-
const fixedRows = 4 + banner.length + productionActivityRows + 1 + LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS;
|
|
878
|
+
const fixedRows = 4 + banner.length + (runtimeBanner ? 1 : 0) + productionActivityRows + 1 + LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS;
|
|
853
879
|
const middleHeight = Math.max(5, rows - fixedRows);
|
|
854
880
|
lastMiddleHeight = middleHeight;
|
|
855
881
|
const prompt = promptFor(session);
|
|
@@ -860,6 +886,7 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
860
886
|
session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
|
|
861
887
|
session.language ? session.language : 'no language',
|
|
862
888
|
session.llm ? 'llm ready' : 'llm limited',
|
|
889
|
+
runtimeStatusLine(runtime, session),
|
|
863
890
|
].join(' ');
|
|
864
891
|
const header = `${title}${' '.repeat(Math.max(0, columns - stripAnsi(title).length - context.length))}${context}`;
|
|
865
892
|
const divider = '─'.repeat(columns);
|
|
@@ -910,6 +937,9 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
910
937
|
if (banner.length > 0) {
|
|
911
938
|
buf += `${banner.map((line) => line.padEnd(columns, ' ')).join('\n')}\n`;
|
|
912
939
|
}
|
|
940
|
+
if (runtimeBanner) {
|
|
941
|
+
buf += `${padVisible(`${styles.yellow}${truncateAnsi(runtimeBanner, columns)}${styles.reset}`, columns)}\n`;
|
|
942
|
+
}
|
|
913
943
|
buf += `${topDivider}\n`;
|
|
914
944
|
buf += `${visibleBody.map((line) => padVisible(line, columns)).join('\n')}\n`;
|
|
915
945
|
// Keep this line reserved: production jobs publish their progress here.
|
|
@@ -931,22 +961,22 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
931
961
|
output.write(buf);
|
|
932
962
|
}
|
|
933
963
|
|
|
934
|
-
async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
964
|
+
async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayInput = input }) {
|
|
935
965
|
const messages = conversationMessages(session);
|
|
936
966
|
const history = toConversationHistory(messages);
|
|
937
967
|
session._onStep = onStep ?? null;
|
|
938
968
|
session.packageJson = session.packageJson ?? {};
|
|
939
969
|
dispatchAgentEvent(session, createAgentEvent('run_started', {
|
|
940
970
|
origin: 'user',
|
|
941
|
-
payload: { input },
|
|
971
|
+
payload: { input: displayInput },
|
|
942
972
|
}));
|
|
943
973
|
dispatchAgentEvent(session, createAgentEvent('user_message', {
|
|
944
974
|
origin: 'user',
|
|
945
|
-
payload: { content:
|
|
975
|
+
payload: { content: displayInput },
|
|
946
976
|
}));
|
|
947
977
|
|
|
948
978
|
// Show the user's input in the conversation (mirrors runDirectChatTurn).
|
|
949
|
-
messages.push({ role: 'user', content:
|
|
979
|
+
messages.push({ role: 'user', content: displayInput });
|
|
950
980
|
onUpdate?.();
|
|
951
981
|
|
|
952
982
|
// Create the donna bubble immediately so "Thinking…" is visible during TTFT.
|
|
@@ -994,7 +1024,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
|
994
1024
|
// Non-abort error: surface it in the bubble rather than leaving "Thinking…" stuck.
|
|
995
1025
|
if (donnaMessage) {
|
|
996
1026
|
const msg = err instanceof Error ? err.message : String(err);
|
|
997
|
-
donnaMessage.content =
|
|
1027
|
+
donnaMessage.content = formatLlmUnavailableMessage(msg);
|
|
998
1028
|
onUpdate?.();
|
|
999
1029
|
}
|
|
1000
1030
|
throw err;
|
|
@@ -1008,10 +1038,10 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
|
1008
1038
|
if (donnaMessage) {
|
|
1009
1039
|
donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
|
|
1010
1040
|
if (!donnaMessage.content.trim()) {
|
|
1011
|
-
donnaMessage.content =
|
|
1041
|
+
donnaMessage.content = formatLlmUnavailableMessage('flux vide');
|
|
1012
1042
|
}
|
|
1013
1043
|
} else {
|
|
1014
|
-
messages.push({ role: 'donna', content:
|
|
1044
|
+
messages.push({ role: 'donna', content: formatLlmUnavailableMessage('flux vide') });
|
|
1015
1045
|
}
|
|
1016
1046
|
onUpdate?.();
|
|
1017
1047
|
return {};
|
|
@@ -1050,7 +1080,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
|
1050
1080
|
}
|
|
1051
1081
|
donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
|
|
1052
1082
|
if (!donnaMessage.content.trim()) {
|
|
1053
|
-
donnaMessage.content =
|
|
1083
|
+
donnaMessage.content = formatLlmUnavailableMessage('flux vide');
|
|
1054
1084
|
onUpdate?.();
|
|
1055
1085
|
}
|
|
1056
1086
|
} catch (err) {
|
|
@@ -1060,16 +1090,16 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
|
1060
1090
|
return { aborted: true };
|
|
1061
1091
|
}
|
|
1062
1092
|
const message = err instanceof Error ? err.message : String(err);
|
|
1063
|
-
donnaMessage.content =
|
|
1093
|
+
donnaMessage.content = formatLlmUnavailableMessage(message);
|
|
1064
1094
|
onUpdate?.();
|
|
1065
1095
|
}
|
|
1066
1096
|
return {};
|
|
1067
1097
|
}
|
|
1068
1098
|
|
|
1069
1099
|
if (donnaMessage) {
|
|
1070
|
-
donnaMessage.content =
|
|
1100
|
+
donnaMessage.content = formatLlmUnavailableMessage('reponse vide');
|
|
1071
1101
|
} else {
|
|
1072
|
-
messages.push({ role: 'donna', content:
|
|
1102
|
+
messages.push({ role: 'donna', content: formatLlmUnavailableMessage('reponse vide') });
|
|
1073
1103
|
}
|
|
1074
1104
|
onUpdate?.();
|
|
1075
1105
|
return {};
|
|
@@ -1102,7 +1132,7 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1102
1132
|
}
|
|
1103
1133
|
donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
|
|
1104
1134
|
if (!donnaMessage.content.trim()) {
|
|
1105
|
-
donnaMessage.content =
|
|
1135
|
+
donnaMessage.content = formatLlmUnavailableMessage('flux vide');
|
|
1106
1136
|
onUpdate?.();
|
|
1107
1137
|
}
|
|
1108
1138
|
} catch (err) {
|
|
@@ -1111,7 +1141,7 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1111
1141
|
return { exit: false, aborted: true };
|
|
1112
1142
|
}
|
|
1113
1143
|
const message = err instanceof Error ? err.message : String(err);
|
|
1114
|
-
donnaMessage.content =
|
|
1144
|
+
donnaMessage.content = formatLlmUnavailableMessage(message);
|
|
1115
1145
|
onUpdate?.();
|
|
1116
1146
|
}
|
|
1117
1147
|
return { exit: false };
|
|
@@ -1152,6 +1182,7 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1152
1182
|
onStep?.(`Shell: ${trimmed}`);
|
|
1153
1183
|
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep });
|
|
1154
1184
|
const messages = conversationMessages(session);
|
|
1185
|
+
const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
|
|
1155
1186
|
if (result.output) {
|
|
1156
1187
|
const parts = trimmed.split(/\s+/);
|
|
1157
1188
|
if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
|
|
@@ -1166,10 +1197,15 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1166
1197
|
rememberProductionActivity(session, payload);
|
|
1167
1198
|
}
|
|
1168
1199
|
}
|
|
1200
|
+
}
|
|
1201
|
+
if (result.output && !handoffRawToAgent) {
|
|
1169
1202
|
messages.push({ role: 'command', content: result.output });
|
|
1170
1203
|
onUpdate?.();
|
|
1171
1204
|
}
|
|
1172
|
-
if (
|
|
1205
|
+
if (handoffRawToAgent) {
|
|
1206
|
+
const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep, displayInput: trimmed });
|
|
1207
|
+
if (agentResult.aborted) return { exit: false, aborted: true };
|
|
1208
|
+
} else if (result.agentTrigger && agent) {
|
|
1173
1209
|
const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep });
|
|
1174
1210
|
if (agentResult.aborted) return { exit: false, aborted: true };
|
|
1175
1211
|
}
|
|
@@ -1243,16 +1279,28 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1243
1279
|
let runtimeReconnectTimer = null;
|
|
1244
1280
|
let runtimeSyncTimer = null;
|
|
1245
1281
|
let runtimeStreamStopped = false;
|
|
1282
|
+
let runtimeShutdownRequested = false;
|
|
1246
1283
|
let finish;
|
|
1247
1284
|
const finished = new Promise((resolve) => {
|
|
1248
1285
|
finish = resolve;
|
|
1249
1286
|
});
|
|
1250
1287
|
|
|
1288
|
+
async function shutdownRuntimeOnExit() {
|
|
1289
|
+
if (!runtime?.url || runtimeShutdownRequested) return;
|
|
1290
|
+
runtimeShutdownRequested = true;
|
|
1291
|
+
try {
|
|
1292
|
+
await postRuntimeShutdown({ url: runtime.url });
|
|
1293
|
+
} catch {
|
|
1294
|
+
// The shell is already exiting; an unavailable runtime should not block
|
|
1295
|
+
// terminal restoration.
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1251
1299
|
input.setRawMode(true);
|
|
1252
1300
|
input.resume();
|
|
1253
1301
|
output.write('\u001b[?1049h');
|
|
1254
1302
|
|
|
1255
|
-
const rerender = () => renderScreen({ packageJson, session, messages: conversationMessages(session), inputBuffer, busy, spinnerFrame, scrollOffset, spinnerLabel, activityLines });
|
|
1303
|
+
const rerender = () => renderScreen({ packageJson, session, messages: conversationMessages(session), inputBuffer, busy, spinnerFrame, scrollOffset, spinnerLabel, activityLines, runtime });
|
|
1256
1304
|
busy = true;
|
|
1257
1305
|
spinnerLabel = 'Loading wiki-manager shell...';
|
|
1258
1306
|
spinnerInterval = setInterval(() => {
|
|
@@ -1530,31 +1578,35 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1530
1578
|
conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
|
|
1531
1579
|
syncRuntimeState();
|
|
1532
1580
|
}
|
|
1533
|
-
} else
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1581
|
+
} else {
|
|
1582
|
+
if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
|
|
1583
|
+
conversationMessages(session).push({ role: 'user', content: line });
|
|
1584
|
+
const outcome = await submitRuntimeRun(line, { runtime, session });
|
|
1585
|
+
if (outcome.kind === 'accepted') {
|
|
1586
|
+
activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
|
|
1587
|
+
} else if (outcome.kind === 'queued') {
|
|
1588
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
|
|
1589
|
+
activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
|
|
1590
|
+
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1591
|
+
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1592
|
+
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1593
|
+
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1594
|
+
} else if (outcome.kind === 'ambiguous') {
|
|
1595
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
|
|
1596
|
+
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1597
|
+
} else {
|
|
1598
|
+
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
1599
|
+
activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
|
|
1600
|
+
}
|
|
1601
|
+
syncRuntimeState();
|
|
1602
|
+
} else if (!session.chatMode && !line.trim().startsWith('/')) {
|
|
1603
|
+
const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
|
|
1604
|
+
activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
|
|
1549
1605
|
} else {
|
|
1550
|
-
|
|
1551
|
-
|
|
1606
|
+
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
|
1607
|
+
done = result.exit;
|
|
1608
|
+
aborted = result.aborted ?? false;
|
|
1552
1609
|
}
|
|
1553
|
-
syncRuntimeState();
|
|
1554
|
-
} else {
|
|
1555
|
-
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
|
1556
|
-
done = result.exit;
|
|
1557
|
-
aborted = result.aborted ?? false;
|
|
1558
1610
|
}
|
|
1559
1611
|
} catch (err) {
|
|
1560
1612
|
if (err.name !== 'AbortError') throw err;
|
|
@@ -1664,6 +1716,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1664
1716
|
input.setRawMode(false);
|
|
1665
1717
|
input.pause();
|
|
1666
1718
|
output.write('\u001b[?25h\u001b[H\u001b[2J\u001b[?1049l');
|
|
1719
|
+
await shutdownRuntimeOnExit();
|
|
1667
1720
|
}
|
|
1668
1721
|
}
|
|
1669
1722
|
|
package/src/shell/repl.test.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
applyRuntimeStateToShellSession,
|
|
8
|
+
createSession,
|
|
9
|
+
conversationMessages,
|
|
10
|
+
recordRuntimeUnavailableAgentInput,
|
|
11
|
+
runLine,
|
|
12
|
+
runtimeStatusLine,
|
|
13
|
+
runtimeUnavailableAgentMessage,
|
|
14
|
+
submitRuntimeRun,
|
|
15
|
+
} from './repl.js';
|
|
4
16
|
|
|
5
17
|
function stubFetch(handler) {
|
|
6
18
|
const original = globalThis.fetch;
|
|
@@ -112,3 +124,49 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
|
|
|
112
124
|
restore();
|
|
113
125
|
}
|
|
114
126
|
});
|
|
127
|
+
|
|
128
|
+
test('runLine does not update workspace profile before Donna handles the request', async () => {
|
|
129
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
|
|
130
|
+
mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
|
|
131
|
+
try {
|
|
132
|
+
const session = createSession();
|
|
133
|
+
session.workspace = 'docs';
|
|
134
|
+
session.workspacePath = workspacePath;
|
|
135
|
+
const result = await runLine('ajoute a mon profil que les statuts Docker sont rendus en tableau', {
|
|
136
|
+
agent: null,
|
|
137
|
+
packageJson: { version: 'test' },
|
|
138
|
+
session,
|
|
139
|
+
chatMode: true,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assert.equal(result.exit, false);
|
|
143
|
+
assert.equal(existsSync(join(workspacePath, '.wiki', 'profile.md')), false);
|
|
144
|
+
assert.notDeepEqual(conversationMessages(session).map((message) => message.role), ['user', 'donna']);
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('agent mode without runtime records a visible error instead of falling back locally', () => {
|
|
151
|
+
const session = createSession();
|
|
152
|
+
session.chatMode = false;
|
|
153
|
+
|
|
154
|
+
const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
|
|
155
|
+
|
|
156
|
+
assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
|
|
157
|
+
assert.deepEqual(conversationMessages(session), [
|
|
158
|
+
{ role: 'user', content: 'salut' },
|
|
159
|
+
{ role: 'command', content: message },
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('runtime status exposes the disconnected reason', () => {
|
|
164
|
+
assert.equal(
|
|
165
|
+
runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'juno' }),
|
|
166
|
+
'runtime: disconnected: token mismatch',
|
|
167
|
+
);
|
|
168
|
+
assert.equal(
|
|
169
|
+
runtimeUnavailableAgentMessage({ error: 'token mismatch' }),
|
|
170
|
+
'⚠ Runtime indisponible : token mismatch — /agent désactivé, /chat reste possible',
|
|
171
|
+
);
|
|
172
|
+
});
|
package/src/shell/tui.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { render, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions } from '@opentui/solid';
|
|
4
|
-
import { createMemo, createSignal, onCleanup } from 'solid-js';
|
|
4
|
+
import { createMemo, createSignal, onCleanup, Show } from 'solid-js';
|
|
5
5
|
import { FileEditorDialog } from './FileEditorDialog';
|
|
6
6
|
import { LeftPane } from './LeftPane';
|
|
7
7
|
import { RightPane } from './RightPane';
|
|
@@ -12,6 +12,7 @@ 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';
|
|
15
16
|
|
|
16
17
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
17
18
|
|
|
@@ -109,6 +110,7 @@ function App(props: {
|
|
|
109
110
|
const [exitHint, setExitHint] = createSignal(false);
|
|
110
111
|
const [copyHint, setCopyHint] = createSignal<string | null>(null);
|
|
111
112
|
const [chatInputHeight, setChatInputHeight] = createSignal(3);
|
|
113
|
+
const [startupKeyboardEvent, setStartupKeyboardEvent] = createSignal<{ id: number; key: any } | null>(null);
|
|
112
114
|
// The app has exactly three mutually exclusive screens; one signal makes
|
|
113
115
|
// that invariant structural instead of relying on two booleans staying in
|
|
114
116
|
// sync at every call site.
|
|
@@ -116,7 +118,9 @@ function App(props: {
|
|
|
116
118
|
let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
|
|
117
119
|
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
120
|
let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
let startupKeyboardEventId = 0;
|
|
119
122
|
let lastCopiedSelection = '';
|
|
123
|
+
let runtimeShutdownRequested = false;
|
|
120
124
|
const state = useSession(props);
|
|
121
125
|
const startup = createMemo(() => startupInfo(props.packageJson));
|
|
122
126
|
const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
|
|
@@ -224,6 +228,10 @@ function App(props: {
|
|
|
224
228
|
onCleanup(() => {
|
|
225
229
|
if (copyHintTimer) clearTimeout(copyHintTimer);
|
|
226
230
|
if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
|
|
231
|
+
if (props.runtime?.url && !runtimeShutdownRequested) {
|
|
232
|
+
runtimeShutdownRequested = true;
|
|
233
|
+
void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
|
|
234
|
+
}
|
|
227
235
|
});
|
|
228
236
|
|
|
229
237
|
const spinnerTimer = setInterval(() => {
|
|
@@ -236,6 +244,11 @@ function App(props: {
|
|
|
236
244
|
|
|
237
245
|
useKeyboard((key) => {
|
|
238
246
|
const keyName = String(key.name ?? '').toLowerCase();
|
|
247
|
+
if (screen() === 'startup') {
|
|
248
|
+
startupKeyboardEventId += 1;
|
|
249
|
+
setStartupKeyboardEvent({ id: startupKeyboardEventId, key });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
239
252
|
if (screen() !== 'main') return;
|
|
240
253
|
if (state.activeEditor()) {
|
|
241
254
|
if (keyName === 'escape') state.closeEditor();
|
|
@@ -279,89 +292,91 @@ function App(props: {
|
|
|
279
292
|
const hintLine = () => {
|
|
280
293
|
if (copyHint()) return copyHint();
|
|
281
294
|
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
295
|
+
if (state.runtimeHint()) return state.runtimeHint();
|
|
282
296
|
return null;
|
|
283
297
|
};
|
|
284
298
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
299
|
+
return (
|
|
300
|
+
<Show
|
|
301
|
+
when={screen() === 'startup'}
|
|
302
|
+
fallback={
|
|
303
|
+
<box width="100%" height="100%" flexDirection="row">
|
|
304
|
+
<LeftPane
|
|
305
|
+
width={leftColumns()}
|
|
306
|
+
title={state.title()}
|
|
307
|
+
statusLine={state.statusLine()}
|
|
308
|
+
hintLine={hintLine()}
|
|
309
|
+
showWelcome={state.showWelcome()}
|
|
310
|
+
messages={state.messages()}
|
|
311
|
+
prompt={state.prompt()}
|
|
312
|
+
input={state.input()}
|
|
313
|
+
busy={state.busy()}
|
|
314
|
+
chatMode={state.chatMode()}
|
|
315
|
+
chatFocused={!state.activeEditor()}
|
|
316
|
+
setInput={state.setInput}
|
|
317
|
+
submit={submit}
|
|
318
|
+
conversationRows={conversationRows()}
|
|
319
|
+
conversationColumns={conversationColumns()}
|
|
320
|
+
conversationScroll={state.conversationScroll()}
|
|
321
|
+
scrollConversation={state.scrollConversation}
|
|
322
|
+
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
323
|
+
onInputHeightChange={setChatInputHeight}
|
|
324
|
+
onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
|
|
325
|
+
/>
|
|
326
|
+
<box width={1} height="100%" flexDirection="column">
|
|
327
|
+
{Array.from({ length: dimensions().height }, () => (
|
|
328
|
+
<text fg="#4B5563">│</text>
|
|
329
|
+
))}
|
|
330
|
+
</box>
|
|
331
|
+
<RightPane
|
|
332
|
+
width={rightColumns()}
|
|
333
|
+
activities={state.activities()}
|
|
334
|
+
logs={state.logs()}
|
|
335
|
+
plan={state.plan()}
|
|
336
|
+
queueItems={state.queueItems()}
|
|
337
|
+
queueInfo={state.queueInfo()}
|
|
338
|
+
activeTab={state.rightTab()}
|
|
339
|
+
onTabClick={state.selectRightTab}
|
|
340
|
+
/>
|
|
341
|
+
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
342
|
+
<FileEditorDialog
|
|
343
|
+
editor={state.activeEditor()}
|
|
344
|
+
width={dimensions().width}
|
|
345
|
+
height={dimensions().height}
|
|
346
|
+
onSave={state.saveEditor}
|
|
347
|
+
onCancel={state.closeEditor}
|
|
348
|
+
/>
|
|
349
|
+
{screen() === 'setup' ? (
|
|
350
|
+
<SetupWizard
|
|
351
|
+
mode="setup"
|
|
352
|
+
session={state.session}
|
|
353
|
+
width={dimensions().width}
|
|
354
|
+
height={dimensions().height}
|
|
355
|
+
initialRoute="workspace-name"
|
|
356
|
+
closeOnDone
|
|
357
|
+
onComplete={closeSetup}
|
|
358
|
+
onClose={closeSetup}
|
|
359
|
+
/>
|
|
360
|
+
) : null}
|
|
361
|
+
</box>
|
|
362
|
+
}
|
|
363
|
+
>
|
|
288
364
|
<StartupScreen
|
|
289
|
-
version={
|
|
290
|
-
model={
|
|
291
|
-
connectedMcpServers={
|
|
292
|
-
wikiReady={
|
|
293
|
-
workspaceName={
|
|
294
|
-
profileName={
|
|
295
|
-
workspaces={
|
|
296
|
-
hasWorkspace={
|
|
365
|
+
version={startup().version}
|
|
366
|
+
model={startup().model}
|
|
367
|
+
connectedMcpServers={startup().connectedMcpServers}
|
|
368
|
+
wikiReady={startup().wikiReady}
|
|
369
|
+
workspaceName={startup().workspaceName}
|
|
370
|
+
profileName={startup().profileName}
|
|
371
|
+
workspaces={startup().workspaces}
|
|
372
|
+
hasWorkspace={startup().hasWorkspace}
|
|
297
373
|
width={dimensions().width}
|
|
298
374
|
height={dimensions().height}
|
|
375
|
+
keyboardEvent={startupKeyboardEvent()}
|
|
299
376
|
onSelect={openAction}
|
|
300
377
|
onQuit={() => renderer.destroy()}
|
|
301
378
|
/>
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return (
|
|
306
|
-
<box width="100%" height="100%" flexDirection="row">
|
|
307
|
-
<LeftPane
|
|
308
|
-
width={leftColumns()}
|
|
309
|
-
title={state.title()}
|
|
310
|
-
statusLine={state.statusLine()}
|
|
311
|
-
hintLine={hintLine()}
|
|
312
|
-
showWelcome={state.showWelcome()}
|
|
313
|
-
messages={state.messages()}
|
|
314
|
-
prompt={state.prompt()}
|
|
315
|
-
input={state.input()}
|
|
316
|
-
busy={state.busy()}
|
|
317
|
-
chatMode={state.chatMode()}
|
|
318
|
-
chatFocused={!state.activeEditor()}
|
|
319
|
-
setInput={state.setInput}
|
|
320
|
-
submit={submit}
|
|
321
|
-
conversationRows={conversationRows()}
|
|
322
|
-
conversationColumns={conversationColumns()}
|
|
323
|
-
conversationScroll={state.conversationScroll()}
|
|
324
|
-
scrollConversation={state.scrollConversation}
|
|
325
|
-
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
326
|
-
onInputHeightChange={setChatInputHeight}
|
|
327
|
-
onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
|
|
328
|
-
/>
|
|
329
|
-
<box width={1} height="100%" flexDirection="column">
|
|
330
|
-
{Array.from({ length: dimensions().height }, () => (
|
|
331
|
-
<text fg="#4B5563">│</text>
|
|
332
|
-
))}
|
|
333
|
-
</box>
|
|
334
|
-
<RightPane
|
|
335
|
-
width={rightColumns()}
|
|
336
|
-
activities={state.activities()}
|
|
337
|
-
logs={state.logs()}
|
|
338
|
-
plan={state.plan()}
|
|
339
|
-
queueItems={state.queueItems()}
|
|
340
|
-
queueInfo={state.queueInfo()}
|
|
341
|
-
activeTab={state.rightTab()}
|
|
342
|
-
onTabClick={state.selectRightTab}
|
|
343
|
-
/>
|
|
344
|
-
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
345
|
-
<FileEditorDialog
|
|
346
|
-
editor={state.activeEditor()}
|
|
347
|
-
width={dimensions().width}
|
|
348
|
-
height={dimensions().height}
|
|
349
|
-
onSave={state.saveEditor}
|
|
350
|
-
onCancel={state.closeEditor}
|
|
351
|
-
/>
|
|
352
|
-
{screen() === 'setup' ? (
|
|
353
|
-
<SetupWizard
|
|
354
|
-
mode="setup"
|
|
355
|
-
session={state.session}
|
|
356
|
-
width={dimensions().width}
|
|
357
|
-
height={dimensions().height}
|
|
358
|
-
initialRoute="workspace-name"
|
|
359
|
-
closeOnDone
|
|
360
|
-
onComplete={closeSetup}
|
|
361
|
-
onClose={closeSetup}
|
|
362
|
-
/>
|
|
363
|
-
) : null}
|
|
364
|
-
</box>
|
|
379
|
+
</Show>
|
|
365
380
|
);
|
|
366
381
|
}
|
|
367
382
|
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
2
|
import { postRuntimeCancel } from '../runtime/client.js';
|
|
3
|
-
import { conversationMessages, runLine, submitRuntimeRun } from './repl.js';
|
|
3
|
+
import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, submitRuntimeRun } from './repl.js';
|
|
4
4
|
|
|
5
|
-
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
5
|
+
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
6
6
|
const [busy, setBusy] = createSignal(false);
|
|
7
7
|
const [abortController, setAbortController] = createSignal<AbortController | null>(null);
|
|
8
8
|
|
|
@@ -19,13 +19,15 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
21
|
if (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/')) {
|
|
22
|
-
|
|
22
|
+
// Marked _pending so mergeRuntimeConversation (useSession.ts) can
|
|
23
|
+
// confirm this exact entry instead of pushing a second copy once the
|
|
24
|
+
// same user message comes back from the runtime's own /state.
|
|
25
|
+
conversationMessages(props.session).push({ role: 'user', content: trimmed, _pending: true });
|
|
23
26
|
const outcome = await submitRuntimeRun(trimmed, {
|
|
24
27
|
runtime: { url: props.runtimeUrl },
|
|
25
28
|
session: props.session,
|
|
26
29
|
});
|
|
27
30
|
if (outcome.kind === 'accepted') {
|
|
28
|
-
conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
|
|
29
31
|
props.onRuntimeAccepted?.();
|
|
30
32
|
props.addLog('runtime: run accepted');
|
|
31
33
|
} else if (outcome.kind === 'queued') {
|
|
@@ -38,6 +40,14 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
38
40
|
props.refresh();
|
|
39
41
|
return { exit: false, runtime: true };
|
|
40
42
|
}
|
|
43
|
+
if (!props.chatMode() && !trimmed.startsWith('/')) {
|
|
44
|
+
const message = recordRuntimeUnavailableAgentInput(props.session, trimmed, {
|
|
45
|
+
error: props.runtimeUnavailableReason ?? 'runtime introuvable',
|
|
46
|
+
});
|
|
47
|
+
props.addLog(message ?? 'runtime: disconnected');
|
|
48
|
+
props.refresh();
|
|
49
|
+
return { exit: false, runtimeUnavailable: true };
|
|
50
|
+
}
|
|
41
51
|
const result = await runLine(trimmed, {
|
|
42
52
|
agent: props.agent,
|
|
43
53
|
packageJson: props.packageJson,
|