@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/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, buildLimitedAgentResponse } from '../agent/graph.js';
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: input },
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: input });
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 = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${msg}`);
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 = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
1041
+ donnaMessage.content = formatLlmUnavailableMessage('flux vide');
1012
1042
  }
1013
1043
  } else {
1014
- messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }, 'LLM stream ended without 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 = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without 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 = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
1093
+ donnaMessage.content = formatLlmUnavailableMessage(message);
1064
1094
  onUpdate?.();
1065
1095
  }
1066
1096
  return {};
1067
1097
  }
1068
1098
 
1069
1099
  if (donnaMessage) {
1070
- donnaMessage.content = buildLimitedAgentResponse({ input, session });
1100
+ donnaMessage.content = formatLlmUnavailableMessage('reponse vide');
1071
1101
  } else {
1072
- messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }) });
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 = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without 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 = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
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 (result.agentTrigger && agent) {
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 if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1534
- conversationMessages(session).push({ role: 'user', content: line });
1535
- const outcome = await submitRuntimeRun(line, { runtime, session });
1536
- if (outcome.kind === 'accepted') {
1537
- conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1538
- activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1539
- } else if (outcome.kind === 'queued') {
1540
- conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
1541
- activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
1542
- } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1543
- const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
1544
- conversationMessages(session).push({ role: 'command', content: explanation });
1545
- activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1546
- } else if (outcome.kind === 'ambiguous') {
1547
- conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
1548
- activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
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
- conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1551
- activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
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
 
@@ -1,6 +1,18 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyRuntimeStateToShellSession, createSession, conversationMessages, submitRuntimeRun } from './repl.js';
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
- if (screen() === 'startup') {
286
- const info = startup();
287
- return (
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={info.version}
290
- model={info.model}
291
- connectedMcpServers={info.connectedMcpServers}
292
- wikiReady={info.wikiReady}
293
- workspaceName={info.workspaceName}
294
- profileName={info.profileName}
295
- workspaces={info.workspaces}
296
- hasWorkspace={info.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
 
@@ -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
- conversationMessages(props.session).push({ role: 'user', content: trimmed });
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,