@dotdrelle/wiki-manager 0.14.13 → 0.14.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +29 -4
  2. package/README.md +19 -0
  3. package/docker-compose.yml +6 -1
  4. package/package.json +1 -1
  5. package/src/activity/activityAggregator.js +50 -16
  6. package/src/activity/activityAggregator.test.js +67 -4
  7. package/src/agent/graph.js +79 -11
  8. package/src/agent/graph.test.js +43 -3
  9. package/src/cli/wiki-manager.js +74 -11
  10. package/src/cli/wiki-manager.test.js +40 -1
  11. package/src/commands/slash.js +10 -3
  12. package/src/commands/slash.test.js +24 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +4 -0
  15. package/src/core/env.test.js +3 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/wikiSetup.js +35 -0
  18. package/src/core/wikiWorkspace.test.js +20 -0
  19. package/src/core/workflow.js +72 -0
  20. package/src/core/workflow.test.js +57 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.js +27 -7
  26. package/src/orchestrator/scheduler.test.js +45 -1
  27. package/src/runtime/auth.test.js +65 -1
  28. package/src/runtime/client.js +4 -0
  29. package/src/runtime/donna-contract.test.js +2 -0
  30. package/src/runtime/lifecycle.js +21 -12
  31. package/src/runtime/runner.js +141 -18
  32. package/src/runtime/runner.test.js +30 -0
  33. package/src/runtime/server.js +13 -2
  34. package/src/runtime/server.test.js +30 -1
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/FileEditorDialog.tsx +2 -2
  38. package/src/shell/LeftPane.tsx +60 -15
  39. package/src/shell/RightPane.tsx +168 -56
  40. package/src/shell/StartupScreen.tsx +3 -7
  41. package/src/shell/renderer.ts +1 -0
  42. package/src/shell/repl.js +7 -81
  43. package/src/shell/repl.test.js +141 -38
  44. package/src/shell/tui.tsx +65 -65
  45. package/src/shell/useSession.ts +92 -6
  46. package/wiki-workspace +28 -0
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  applyRuntimeStateToShellSession,
8
- appendRuntimePlanCompletionMessages,
9
8
  chatReadTools,
10
9
  createSession,
11
10
  runHeadlessChatTurn,
@@ -20,8 +19,122 @@ import {
20
19
  shouldHandleFreeTextLocally,
21
20
  submitRuntimeRun,
22
21
  } from './repl.js';
22
+ import { readFile } from 'node:fs/promises';
23
23
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
24
24
 
25
+ test('ShellUI inserts StyledText as a child instead of stringifying it through content', async () => {
26
+ const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
27
+ assert.doesNotMatch(source, /content=\{styledSegments\(/);
28
+ assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
29
+ });
30
+
31
+ test('ShellUI renders the user header full-width before constraining only its body', async () => {
32
+ const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
33
+ const headerBranch = source.indexOf("line.copyContent !== undefined ? (");
34
+ const userBodyBranch = source.indexOf("line.role === 'user' && line.userBubbleWidth ? (", headerBranch);
35
+ assert.ok(headerBranch >= 0 && userBodyBranch > headerBranch);
36
+ assert.match(source, /const userBubbleWidth = message\.role === 'user'/);
37
+ assert.match(source, /Math\.floor\(columns \* 0\.6\)/);
38
+ assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.5\)/);
39
+ assert.match(source.slice(userBodyBranch), /width=\{line\.userBubbleWidth\}/);
40
+ assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
41
+ assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
42
+ assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
43
+ assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
44
+ });
45
+
46
+ test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
47
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
48
+ const filteredLogs = source.slice(
49
+ source.indexOf('const filteredLogs ='),
50
+ source.indexOf('const allLines ='),
51
+ );
52
+ assert.match(filteredLogs, /activeLogTab\(\) === 'agent-status'/);
53
+ assert.match(filteredLogs, /isAgentStatus\(line\)/);
54
+ assert.doesNotMatch(filteredLogs, /\.reverse\(\)/);
55
+ const renderedLogs = source.slice(
56
+ source.indexOf('function logRenderLines'),
57
+ source.indexOf('function logEntryLines'),
58
+ );
59
+ assert.match(renderedLogs, /return blocks\.reverse\(\)\.flat\(\)/);
60
+ });
61
+
62
+ test('Activity uses only visible jobs and leaves remaining height to Flow/Trace', async () => {
63
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
64
+ const activityPanel = source.slice(
65
+ source.indexOf('export function ActivityPanel'),
66
+ source.indexOf('type LogSegment'),
67
+ );
68
+ assert.match(activityPanel, /<Index each=\{visibleSlots\(\)\}>/);
69
+ assert.doesNotMatch(activityPanel, /<Index each=\{ACTIVITY_SLOTS\}>/);
70
+ assert.match(activityPanel, /paddingX=\{1\}/);
71
+ assert.doesNotMatch(activityPanel, /updatedLine\(activity\(\)\)/);
72
+ assert.doesNotMatch(activityPanel, /marginTop=/);
73
+ const logPanel = source.slice(
74
+ source.indexOf('export function LogPanel'),
75
+ source.indexOf('export function QueuePanel'),
76
+ );
77
+ assert.doesNotMatch(logPanel, /marginTop=\{1\}/);
78
+ assert.match(activityPanel, /wrapLine\(activityDetailText\(item\), lineWidth\(\)\)\.slice\(0, 2\)/);
79
+ assert.match(source, /`Task \$\{Math\.max\(1, taskIndex\)\}\/\$\{taskTotal\}`/);
80
+ assert.match(source, /`Step \$\{Math\.max\(1, stepIndex\)\}\/\$\{stepTotal\}`/);
81
+ assert.match(source, /`Batch \$\{Math\.min\(batchCount, Math\.max\(1, batchIndex \+ 1\)\)\}\/\$\{batchCount\}`/);
82
+ assert.match(source, /instructionCount/);
83
+ assert.match(source, /stabilizeKept/);
84
+ });
85
+
86
+ test('ShellUI launcher never starts agents or workspace services implicitly', async () => {
87
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
88
+ const startup = await readFile(new URL('./StartupScreen.tsx', import.meta.url), 'utf8');
89
+ assert.doesNotMatch(tui, /submitInput\('\/start (?:agents|agent|all)'\)/);
90
+ assert.doesNotMatch(startup, /start-services|Start services|start agents and services/i);
91
+ assert.doesNotMatch(startup, /open-logs|Open diagnostics/i);
92
+ assert.match(startup, /Select and load a workspace/);
93
+ });
94
+
95
+ test('ShellUI lets the right pane extend to the terminal edge', async () => {
96
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
97
+ const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
98
+ assert.match(tui, /Math\.min\(58, Math\.floor\(width \* 0\.38\) \+ 2\)/);
99
+ assert.match(pane, /paddingLeft=\{1\}/);
100
+ assert.doesNotMatch(pane, /height="100%" flexDirection="column" padding=\{1\}/);
101
+ });
102
+
103
+ test('ShellUI shows the canonical run summary above the plan', async () => {
104
+ const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
105
+ const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
106
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
107
+ assert.match(session, /const runSummary = createMemo/);
108
+ assert.match(session, /agent\$\{agents\.size === 1/);
109
+ assert.match(session, /parallel \$\{activeParallel\}\/×\$\{maxParallel\}/);
110
+ assert.match(session, /usage\.inputKnown/);
111
+ assert.match(session, /usage\.outputKnown/);
112
+ assert.match(pane, /summary=\{props\.runSummary\}/);
113
+ assert.match(tui, /runSummary=\{state\.runSummary\(\)\}/);
114
+ });
115
+
116
+ test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
117
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
118
+ const entryRenderer = source.slice(
119
+ source.indexOf('function logEntryLines'),
120
+ source.indexOf('export function LogPanel'),
121
+ );
122
+ assert.match(entryRenderer, /sourceMatch/);
123
+ assert.doesNotMatch(entryRenderer, /prefix\.push\(\{ text: `\$\{source\}/);
124
+ assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
125
+ });
126
+
127
+ test('runtime logs have a separator and a concise Runtime tab label', async () => {
128
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
129
+ const logPanel = source.slice(
130
+ source.indexOf('export function LogPanel'),
131
+ source.indexOf('export function QueuePanel'),
132
+ );
133
+ assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
134
+ assert.match(logPanel, /content=" Runtime "/);
135
+ assert.doesNotMatch(logPanel, /Flow \/ Trace/);
136
+ });
137
+
25
138
  test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
26
139
  assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
27
140
  { text: 'Voir ' },
@@ -47,6 +160,16 @@ test('ShellUI leaves malformed URLs as plain text', () => {
47
160
  assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
48
161
  });
49
162
 
163
+ test('ShellUI preserves every URL when several links share one line', () => {
164
+ const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
165
+ .flat()
166
+ .filter((part) => part.url);
167
+ assert.deepEqual(links, [
168
+ { text: '[link: one.example]', url: 'https://one.example/a' },
169
+ { text: '[link: two.example]', url: 'https://two.example/b' },
170
+ ]);
171
+ });
172
+
50
173
  test('runtime display preserves a failed plan and its diagnostic evidence', () => {
51
174
  const state = {
52
175
  status: 'error',
@@ -59,7 +182,7 @@ test('runtime display preserves a failed plan and its diagnostic evidence', () =
59
182
  assert.equal(sanitizeRuntimeStateForDisplay(state), state);
60
183
  });
61
184
 
62
- test('runtime display still clears completed historical execution state', () => {
185
+ test('runtime display preserves completed plan and logs for post-run inspection', () => {
63
186
  const display = sanitizeRuntimeStateForDisplay({
64
187
  status: 'done',
65
188
  plan: [{ id: 'old', status: 'done' }],
@@ -68,10 +191,10 @@ test('runtime display still clears completed historical execution state', () =>
68
191
  conversation: [{ role: 'assistant', content: 'Old run.' }],
69
192
  });
70
193
 
71
- assert.deepEqual(display.plan, []);
72
- assert.deepEqual(display.activities, []);
73
- assert.deepEqual(display.logs, []);
74
- assert.deepEqual(display.conversation, []);
194
+ assert.deepEqual(display.plan, [{ id: 'old', status: 'done' }]);
195
+ assert.deepEqual(display.activities, [{ id: 'old-job', status: 'done' }]);
196
+ assert.deepEqual(display.logs, ['old log']);
197
+ assert.deepEqual(display.conversation, [{ role: 'assistant', content: 'Old run.' }]);
75
198
  });
76
199
 
77
200
  function stubFetch(handler) {
@@ -133,7 +256,7 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
133
256
  assert.deepEqual(conversationMessages(session), []);
134
257
  });
135
258
 
136
- test('applyRuntimeStateToShellSession clears terminal plan and activities when runtime is idle', () => {
259
+ test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtime is idle', () => {
137
260
  const session = createSession();
138
261
  session.headlessPlan = [{ step: 1, description: 'Old read', status: 'failed' }];
139
262
  session.activities = { old: { key: 'old', status: 'failed', terminal: true } };
@@ -150,17 +273,17 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
150
273
  planPatches: [{ id: 'old-patch' }],
151
274
  });
152
275
 
153
- assert.equal(session.headlessPlan, null);
154
- assert.deepEqual(session.activities, {});
155
- assert.equal(session.workflow, null);
156
- assert.deepEqual(session.agentProjection.logs, []);
157
- assert.equal(session.agentProjection.summary, null);
158
- assert.deepEqual(session.agentProjection.conversation, []);
159
- assert.deepEqual(session.agentProjection.chain, []);
160
- assert.deepEqual(session.agentProjection.planPatches, []);
276
+ assert.deepEqual(session.headlessPlan, [{ step: 1, description: 'Old read', status: 'failed' }]);
277
+ assert.deepEqual(session.activities, { old: { key: 'old', status: 'failed', terminal: true } });
278
+ assert.deepEqual(session.workflow.nodes, [{ id: 'task:old' }]);
279
+ assert.deepEqual(session.agentProjection.logs, ['Runtime evaluator rejected the old run']);
280
+ assert.equal(session.agentProjection.summary, 'Old run failed');
281
+ assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failed answer' }]);
282
+ assert.deepEqual(session.agentProjection.chain, [{ id: 'old-step' }]);
283
+ assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
161
284
  });
162
285
 
163
- test('runtime plan completion is appended once to the ShellUI conversation', () => {
286
+ test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
164
287
  const session = createSession();
165
288
  applyRuntimeStateToShellSession(session, {
166
289
  runId: 'run-1',
@@ -178,27 +301,7 @@ test('runtime plan completion is appended once to the ShellUI conversation', ()
178
301
  plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
179
302
  });
180
303
 
181
- assert.deepEqual(conversationMessages(session), [{
182
- role: 'command',
183
- content: '✓ Job terminé : Build documentation\nStatus: done',
184
- }]);
185
- });
186
-
187
- test('runtime plan failure includes its available error description', () => {
188
- const session = createSession();
189
- appendRuntimePlanCompletionMessages(session, {
190
- runId: 'run-2',
191
- plan: [{ id: 'ingest', description: 'Ingest pages', status: 'running' }],
192
- });
193
- appendRuntimePlanCompletionMessages(session, {
194
- runId: 'run-2',
195
- plan: [{ id: 'ingest', description: 'Ingest pages', status: 'failed', result: { error: { message: 'Index unavailable' } } }],
196
- });
197
-
198
- assert.deepEqual(conversationMessages(session), [{
199
- role: 'command',
200
- content: '✗ Job terminé en erreur : Ingest pages\nStatus: failed\nErreur: Index unavailable',
201
- }]);
304
+ assert.deepEqual(conversationMessages(session), []);
202
305
  });
203
306
 
204
307
  test('direct chat system prompt forbids unsolicited next steps', async () => {
@@ -312,7 +415,7 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
312
415
  assert.equal(session.chatMode, true);
313
416
  assert.equal(result.oneShotAgent, true);
314
417
  assert.equal(result.runtimeOutcome.kind, 'accepted');
315
- assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
418
+ assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
316
419
  } finally {
317
420
  restore();
318
421
  }
package/src/shell/tui.tsx CHANGED
@@ -12,7 +12,6 @@ import { useSession } from './useSession';
12
12
  import { buildMcpStatus } from '../core/mcp.js';
13
13
  import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
14
14
  import { listWorkspaces } from '../core/workspaces.js';
15
- import { postRuntimeShutdown } from '../runtime/client.js';
16
15
  import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
17
16
 
18
17
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -30,11 +29,13 @@ function emptyStartupInfo(version: string, workspace: { name: string } | null, w
30
29
  };
31
30
  }
32
31
 
33
- function startupInfo(packageJson: Record<string, unknown>) {
32
+ function startupInfo(packageJson: Record<string, unknown>, preferredWorkspaceName?: string | null) {
34
33
  // listWorkspaces() order is filesystem-dependent (readdirSync), not stable —
35
34
  // sort so "the default workspace" is deterministic across runs/platforms.
36
35
  const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
37
- const workspace = workspaces[0] ?? null;
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
- if ((renderer as any).copyToClipboardOSC52?.(text)) return true;
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
- void (async () => {
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% / cap 56 (was 32% / cap 44): the Plan/Activity/Logs panes carry job
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
- return Math.max(30, Math.min(56, Math.floor(width * 0.38)));
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 openWorkspaceAndStartServices(workspaceName ?? startupInfo(props.packageJson).workspaceName);
237
+ const loaded = await loadWorkspace(workspaceName ?? startupInfo(props.packageJson, props.initialWorkspaceName).workspaceName);
261
238
  await showDefaultStatus(loaded);
262
239
  } else if (action === 'new-conversation' || action === 'run-workflow') {
263
240
  const loaded = await loadDefaultWorkspace();
@@ -275,7 +252,7 @@ function App(props: {
275
252
  };
276
253
 
277
254
  const closeSetup = () => {
278
- const info = startupInfo(props.packageJson);
255
+ const info = startupInfo(props.packageJson, props.initialWorkspaceName);
279
256
  if (!info.hasWorkspace) {
280
257
  setScreen('startup');
281
258
  return;
@@ -307,14 +284,13 @@ function App(props: {
307
284
  onCleanup(() => {
308
285
  if (copyHintTimer) clearTimeout(copyHintTimer);
309
286
  if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
310
- if (props.runtime?.url && !runtimeShutdownRequested) {
311
- runtimeShutdownRequested = true;
312
- void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
313
- }
314
287
  });
315
288
 
316
289
  const spinnerTimer = setInterval(() => {
317
- if (state.busy()) setSpinnerIndex((value) => (value + 1) % 10);
290
+ // Advance while the chat is streaming OR a runtime run is executing. Without
291
+ // executionActive(), the plan spinner froze on frame 0 during a scheduler-
292
+ // driven run (no conversation turn), so it looked static.
293
+ if (state.busy() || state.executionActive()) setSpinnerIndex((value) => (value + 1) % 10);
318
294
  }, 90);
319
295
  onCleanup(() => {
320
296
  clearInterval(spinnerTimer);
@@ -358,17 +334,24 @@ function App(props: {
358
334
  if (keyName === 'tab') state.completeSelected();
359
335
  if (keyName === 'pageup') state.scrollConversation(conversationRows());
360
336
  else if (keyName === 'pagedown') state.scrollConversation(-conversationRows());
361
- if (keyName === 'up' && state.slash()) state.moveCompletion(-1);
362
- else if (keyName === 'down' && state.slash()) state.moveCompletion(1);
363
- else if (keyName === 'up' && !state.input().includes('\n')) state.historyUp();
364
- else if (keyName === 'down' && !state.input().includes('\n')) state.historyDown();
365
- else if (keyName === 'escape') {
337
+ if ((keyName === 'up' || keyName === 'down') && (state.slash() || !state.input().includes('\n'))) {
338
+ // The Plan scrollbox also listens to global arrow keys. Consume arrows
339
+ // handled by the chat input so one key press cannot move both widgets.
340
+ key.preventDefault?.();
341
+ key.stopPropagation?.();
342
+ if (state.slash()) state.moveCompletion(keyName === 'up' ? -1 : 1);
343
+ else if (keyName === 'up') state.historyUp();
344
+ else state.historyDown();
345
+ return;
346
+ }
347
+ if (keyName === 'escape') {
366
348
  if (state.slash()) state.dismissSlash();
367
349
  else state.setInput('');
368
350
  }
369
351
  });
370
352
 
371
353
  const hintLine = () => {
354
+ if (exitStatus()) return exitStatus();
372
355
  if (copyHint()) return copyHint();
373
356
  if (exitHint()) return 'Press Ctrl+C again to exit.';
374
357
  if (state.runtimeHint()) return state.runtimeHint();
@@ -412,11 +395,15 @@ function App(props: {
412
395
  activities={state.activities()}
413
396
  logs={state.logs()}
414
397
  plan={state.plan()}
398
+ runSummary={state.runSummary()}
415
399
  queueItems={state.queueItems()}
416
400
  queueInfo={state.queueInfo()}
417
401
  activeTab={state.rightTab()}
418
402
  logFilter={state.runtimeLogFilter()}
403
+ pendingApprovals={state.pendingApprovals()}
404
+ onApprove={() => { void state.submitInput('/approve'); }}
419
405
  onTabClick={state.selectRightTab}
406
+ spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
420
407
  />
421
408
  <SlashDialog context={state.activeEditor() ? null : state.slash()} />
422
409
  <FileEditorDialog
@@ -467,20 +454,33 @@ export async function runOpenTuiShell({
467
454
  packageJson,
468
455
  runtime = null,
469
456
  preflight = null,
457
+ initialWorkspaceName = null,
470
458
  }: {
471
459
  agent: unknown;
472
460
  packageJson: Record<string, unknown>;
473
461
  runtime?: any;
474
462
  preflight?: any;
463
+ initialWorkspaceName?: string | null;
475
464
  }) {
465
+ const exitRef = { current: Promise.resolve() as Promise<void> };
476
466
  await new Promise<void>((resolve, reject) => {
477
- render(() => <App agent={agent} packageJson={packageJson} runtime={runtime} preflight={preflight} />, {
467
+ render(() => (
468
+ <App
469
+ agent={agent}
470
+ packageJson={packageJson}
471
+ runtime={runtime}
472
+ preflight={preflight}
473
+ initialWorkspaceName={initialWorkspaceName}
474
+ exitRef={exitRef}
475
+ />
476
+ ), {
478
477
  exitOnCtrlC: false,
479
478
  useMouse: true,
480
479
  targetFps: 30,
481
480
  onDestroy: resolve,
482
481
  }).catch(reject);
483
482
  });
483
+ await exitRef.current;
484
484
  return {};
485
485
  }
486
486