@dotdrelle/wiki-manager 0.9.3 → 0.10.4

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.
@@ -0,0 +1,212 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { useKeyboard } from '@opentui/solid';
3
+ import { createMemo, createSignal, For } from 'solid-js';
4
+ import { fit } from './textFit';
5
+
6
+ export type StartupAction =
7
+ | 'new-conversation'
8
+ | 'open-workspace'
9
+ | 'run-workflow'
10
+ | 'init-workspace';
11
+
12
+ type StartupItem = {
13
+ action: StartupAction;
14
+ label: string;
15
+ detail: string;
16
+ };
17
+
18
+ function statusDot(ready: boolean) {
19
+ return ready ? '●' : '○';
20
+ }
21
+
22
+ function initWorkspaceItem(detail: string): StartupItem {
23
+ return { action: 'init-workspace', label: 'Initialize workspace', detail };
24
+ }
25
+
26
+ export function StartupScreen(props: {
27
+ version: string;
28
+ model: string;
29
+ connectedMcpServers: number;
30
+ wikiReady: boolean;
31
+ workspaceName?: string | null;
32
+ profileName?: string | null;
33
+ workspaces: string[];
34
+ hasWorkspace: boolean;
35
+ width: number;
36
+ height: number;
37
+ onSelect: (action: StartupAction, workspaceName?: string) => void;
38
+ onQuit: () => void;
39
+ }) {
40
+ const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
41
+ const [selected, setSelected] = createSignal(0);
42
+ const panelWidth = createMemo(() => Math.max(54, Math.min(86, props.width - 4)));
43
+ const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
44
+ const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
45
+ const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
46
+
47
+ const items = createMemo<StartupItem[]>(() => {
48
+ if (!props.hasWorkspace) {
49
+ return [initWorkspaceItem('Create and configure the default workspace')];
50
+ }
51
+ return [
52
+ {
53
+ action: 'new-conversation',
54
+ label: 'Start a new conversation',
55
+ detail: props.workspaceName ? `Load ${props.workspaceName} with default .wikirc` : 'Load default .wikirc',
56
+ },
57
+ {
58
+ action: 'open-workspace',
59
+ label: 'Open workspace',
60
+ detail: 'Select, load, then start agents and services',
61
+ },
62
+ initWorkspaceItem('Create and configure a new workspace'),
63
+ {
64
+ action: 'run-workflow',
65
+ label: 'Run a workflow',
66
+ detail: 'Switch to Agent mode after loading the workspace',
67
+ },
68
+ ];
69
+ });
70
+ const workspaceItems = createMemo(() => props.workspaces.map((name) => ({
71
+ label: name,
72
+ detail: name === props.workspaceName ? 'default workspace' : 'available workspace',
73
+ })));
74
+ const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
75
+
76
+ const move = (delta: number) => {
77
+ setSelected((value) => (value + delta + currentLength()) % currentLength());
78
+ };
79
+
80
+ const choose = (index = selected()) => {
81
+ if (mode() === 'workspace-select') {
82
+ const item = workspaceItems()[index];
83
+ if (item) props.onSelect('open-workspace', item.label);
84
+ return;
85
+ }
86
+ const item = items()[index];
87
+ if (!item) return;
88
+ if (item.action === 'open-workspace') {
89
+ setMode('workspace-select');
90
+ setSelected(0);
91
+ return;
92
+ }
93
+ props.onSelect(item.action);
94
+ };
95
+
96
+ useKeyboard((key: any) => {
97
+ const keyName = String(key.name ?? '').toLowerCase();
98
+ const sequence = String(key.sequence ?? '');
99
+ if ((key.ctrl || key.meta) && keyName === 'c') {
100
+ props.onQuit();
101
+ return;
102
+ }
103
+ if (keyName === 'escape' && mode() === 'workspace-select') {
104
+ setMode('home');
105
+ setSelected(0);
106
+ return;
107
+ }
108
+ if (keyName === 'escape') {
109
+ props.onQuit();
110
+ return;
111
+ }
112
+ if (keyName === 'up') {
113
+ move(-1);
114
+ return;
115
+ }
116
+ if (keyName === 'down') {
117
+ move(1);
118
+ return;
119
+ }
120
+ if (keyName === 'return' || keyName === 'enter') {
121
+ choose();
122
+ return;
123
+ }
124
+ const numeric = Number.parseInt(sequence, 10);
125
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
126
+ choose(numeric - 1);
127
+ }
128
+ });
129
+
130
+ const status = createMemo(() => {
131
+ if (!props.hasWorkspace) return 'Setup needed';
132
+ return props.wikiReady ? 'Ready' : 'Default .wikirc missing';
133
+ });
134
+
135
+ const subtitle = createMemo(() => {
136
+ const workspace = props.workspaceName ?? 'no workspace';
137
+ const profile = props.profileName ?? 'default';
138
+ return `${workspace} / ${profile}`;
139
+ });
140
+
141
+ const innerWidth = createMemo(() => Math.max(40, panelWidth() - 6));
142
+ const shortcutHint = createMemo(() => {
143
+ const count = currentLength();
144
+ const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
145
+ return mode() === 'workspace-select'
146
+ ? `↑/↓ select Enter open ${quick} Esc back`
147
+ : `↑/↓ select Enter open ${quick} Esc quit`;
148
+ });
149
+ const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
150
+
151
+ return (
152
+ <box width="100%" height="100%" backgroundColor="#0B0D12">
153
+ <box
154
+ position="absolute"
155
+ left={left()}
156
+ top={top()}
157
+ width={panelWidth()}
158
+ height={panelHeight()}
159
+ border
160
+ borderStyle="rounded"
161
+ borderColor="#8BD5CA"
162
+ backgroundColor="#111318"
163
+ padding={2}
164
+ flexDirection="column"
165
+ overflow="hidden"
166
+ >
167
+ <box height={1} flexDirection="row">
168
+ <text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.5))} />
169
+ <text fg={props.wikiReady ? '#8BD5CA' : '#FBBF24'} content={fit(` ${statusDot(props.wikiReady)} ${status()}`, Math.floor(innerWidth() * 0.45))} />
170
+ </box>
171
+ <text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
172
+ <text height={1}>{''}</text>
173
+ <text height={1} fg="#D6DEE8">██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗</text>
174
+ <text height={1} fg="#D6DEE8">██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗</text>
175
+ <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║</text>
176
+ <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║</text>
177
+ <text height={1} fg="#D6DEE8">██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║</text>
178
+ <text height={1} fg="#D6DEE8">╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝</text>
179
+ <text height={1}>{''}</text>
180
+ <text height={1} fg="#7F8C8D" content={menuTitle()} />
181
+ <box height={Math.max(1, currentLength())} flexDirection="column">
182
+ <For each={mode() === 'workspace-select' ? workspaceItems() : items()}>
183
+ {(item, index) => {
184
+ const active = () => index() === selected();
185
+ return (
186
+ <box
187
+ height={1}
188
+ flexDirection="row"
189
+ onMouseUp={() => choose(index())}
190
+ >
191
+ <text width={4} fg={active() ? '#111318' : '#7F8C8D'} bg={active() ? '#8BD5CA' : undefined}>
192
+ {active() ? '›' : ' '} {index() + 1}
193
+ </text>
194
+ <text width={28} fg={active() ? '#8BD5CA' : '#D6DEE8'} content={fit(item.label, 27)} />
195
+ <text fg="#7F8C8D" content={fit(item.detail, Math.max(8, innerWidth() - 34))} />
196
+ </box>
197
+ );
198
+ }}
199
+ </For>
200
+ </box>
201
+ <text height={1}>{''}</text>
202
+ <box height={3} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1}>
203
+ <text height={1} fg="#D6DEE8" content={fit(`LLM ${statusDot(Boolean(props.model))} ${props.model || 'not configured'}`, innerWidth() - 2)} />
204
+ <text height={1} fg="#D6DEE8" content={fit(`MCP ${statusDot(props.connectedMcpServers > 0)} ${props.connectedMcpServers} server(s) configured`, innerWidth() - 2)} />
205
+ <text height={1} fg="#D6DEE8" content={fit(`Wiki ${statusDot(props.wikiReady)} ${props.wikiReady ? 'default profile ready' : 'init required'}`, innerWidth() - 2)} />
206
+ </box>
207
+ <text height={1}>{''}</text>
208
+ <text height={1} fg="#7F8C8D" content={shortcutHint()} />
209
+ </box>
210
+ </box>
211
+ );
212
+ }
package/src/shell/repl.js CHANGED
@@ -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, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
18
+ import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, 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
@@ -743,7 +743,18 @@ export function applyRuntimeStateToShellSession(session, state) {
743
743
  logs: Array.isArray(state.logs) ? [...state.logs] : [],
744
744
  summary: state.summary ?? null,
745
745
  status: state.status ?? 'idle',
746
+ planRevision: state.planRevision ?? 0,
747
+ planPatches: Array.isArray(state.planPatches) ? state.planPatches.map((patch) => ({ ...patch })) : [],
746
748
  };
749
+ session.workflow = state.workflow && typeof state.workflow === 'object'
750
+ ? {
751
+ ...state.workflow,
752
+ nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
753
+ relations: Array.isArray(state.workflow.relations) ? state.workflow.relations.map((relation) => ({ ...relation })) : [],
754
+ waitingReasons: Array.isArray(state.workflow.waitingReasons) ? [...state.workflow.waitingReasons] : [],
755
+ warnings: Array.isArray(state.workflow.warnings) ? [...state.workflow.warnings] : [],
756
+ }
757
+ : null;
747
758
  session.headlessPlan = session.agentProjection.plan
748
759
  ? session.agentProjection.plan.map((step) => ({ ...step }))
749
760
  : null;
@@ -772,6 +783,27 @@ export function applyRuntimeStateToShellSession(session, state) {
772
783
  return true;
773
784
  }
774
785
 
786
+ // Submits a prompt to the shared runtime. If the workspace is already busy
787
+ // (HTTP 409 from POST /run), route the input through the runtime control lane
788
+ // so status questions and plan-change proposals do not become future runs.
789
+ export async function submitRuntimeRun(line, { runtime, session }) {
790
+ const workspace = session.workspace ?? null;
791
+ try {
792
+ await postRuntimeRun(line, { url: runtime.url, workspace });
793
+ return { kind: 'accepted' };
794
+ } catch (err) {
795
+ if (err?.status !== 409) {
796
+ return { kind: 'error', message: err instanceof Error ? err.message : String(err) };
797
+ }
798
+ try {
799
+ const result = await postRuntimeControl('message', { url: runtime.url, workspace, input: line });
800
+ return { kind: result?.kind ?? 'control', result };
801
+ } catch (queueErr) {
802
+ return { kind: 'error', message: queueErr instanceof Error ? queueErr.message : String(queueErr) };
803
+ }
804
+ }
805
+ }
806
+
775
807
  function activityText(session) {
776
808
  const activity = sessionActivities(session).find((item) => !item.terminal)
777
809
  ?? (session.productionActivity?.label ? session.productionActivity : null);
@@ -1499,13 +1531,25 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1499
1531
  syncRuntimeState();
1500
1532
  }
1501
1533
  } else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1502
- await postRuntimeRun(line, {
1503
- url: runtime.url,
1504
- workspace: session.workspace ?? null,
1505
- });
1506
1534
  conversationMessages(session).push({ role: 'user', content: line });
1507
- conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1508
- activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
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);
1549
+ } else {
1550
+ conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1551
+ activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
1552
+ }
1509
1553
  syncRuntimeState();
1510
1554
  } else {
1511
1555
  const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
@@ -1,6 +1,20 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyRuntimeStateToShellSession, createSession, conversationMessages } from './repl.js';
3
+ import { applyRuntimeStateToShellSession, createSession, conversationMessages, submitRuntimeRun } from './repl.js';
4
+
5
+ function stubFetch(handler) {
6
+ const original = globalThis.fetch;
7
+ globalThis.fetch = handler;
8
+ return () => { globalThis.fetch = original; };
9
+ }
10
+
11
+ function jsonResponse(status, body) {
12
+ return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body };
13
+ }
14
+
15
+ function pathOf(url) {
16
+ return new URL(String(url)).pathname;
17
+ }
4
18
 
5
19
  test('applyRuntimeStateToShellSession projects runtime state into shell session', () => {
6
20
  const session = createSession();
@@ -22,6 +36,12 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
22
36
  terminal: false,
23
37
  }],
24
38
  queue: [{ id: 'q-1', status: 'waiting' }],
39
+ workflow: {
40
+ nodes: [{ id: 'task:build', type: 'task', label: 'Build', status: 'running' }],
41
+ relations: [{ type: 'contains', from: 'run:run-1', to: 'task:build' }],
42
+ waitingReasons: ['queue:q-1'],
43
+ warnings: ['legacy_sequential_plan'],
44
+ },
25
45
  logs: ['agentic-loop: turn 1/20'],
26
46
  });
27
47
 
@@ -31,8 +51,64 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
31
51
  assert.equal(session.activities['production:job-1'].status, 'running');
32
52
  assert.equal(session.productionActivity.jobId, 'job-1');
33
53
  assert.equal(session.jobQueue[0].id, 'q-1');
54
+ assert.equal(session.workflow.nodes[0].id, 'task:build');
55
+ assert.equal(session.workflow.relations[0].type, 'contains');
56
+ assert.deepEqual(session.workflow.waitingReasons, ['queue:q-1']);
34
57
  assert.deepEqual(conversationMessages(session), [
35
58
  { role: 'user', content: 'Build docs' },
36
59
  { role: 'donna', content: 'Working.' },
37
60
  ]);
38
61
  });
62
+
63
+ test('submitRuntimeRun reports acceptance without throwing', async () => {
64
+ const restore = stubFetch(async (url) => {
65
+ assert.equal(pathOf(url), '/run');
66
+ return jsonResponse(202, { accepted: true, runId: 'run-1' });
67
+ });
68
+ try {
69
+ const session = createSession();
70
+ const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
71
+ assert.deepEqual(outcome, { kind: 'accepted' });
72
+ } finally {
73
+ restore();
74
+ }
75
+ });
76
+
77
+ test('submitRuntimeRun routes busy runtime input through the control lane', async () => {
78
+ let controlBody = null;
79
+ const restore = stubFetch(async (url, init) => {
80
+ const path = pathOf(url);
81
+ if (path === '/run') return jsonResponse(409, { error: 'A runtime run is already active.' });
82
+ if (path === '/control') {
83
+ controlBody = JSON.parse(String(init.body));
84
+ return jsonResponse(200, { accepted: true, kind: 'observe', explanation: 'Runtime run is active.' });
85
+ }
86
+ throw new Error(`unexpected fetch: ${url}`);
87
+ });
88
+ try {
89
+ const session = createSession();
90
+ const outcome = await submitRuntimeRun('Où en est le build ?', { runtime: { url: 'http://runtime.test' }, session });
91
+ assert.equal(outcome.kind, 'observe');
92
+ assert.equal(outcome.result.explanation, 'Runtime run is active.');
93
+ assert.deepEqual(controlBody, { action: 'message', input: 'Où en est le build ?' });
94
+ } finally {
95
+ restore();
96
+ }
97
+ });
98
+
99
+ test('submitRuntimeRun reports a non-409 error without throwing or calling /control', async () => {
100
+ let controlCalled = false;
101
+ const restore = stubFetch(async (url) => {
102
+ if (pathOf(url) === '/control') controlCalled = true;
103
+ return jsonResponse(503, { error: 'runtime unavailable' });
104
+ });
105
+ try {
106
+ const session = createSession();
107
+ const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
108
+ assert.equal(outcome.kind, 'error');
109
+ assert.match(outcome.message, /503/);
110
+ assert.equal(controlCalled, false);
111
+ } finally {
112
+ restore();
113
+ }
114
+ });
@@ -0,0 +1,6 @@
1
+ export function fit(value: string, width: number): string {
2
+ const max = Math.max(1, width);
3
+ if (value.length <= max) return value;
4
+ if (max <= 1) return '…';
5
+ return value.slice(0, max - 1) + '…';
6
+ }
package/src/shell/tui.tsx CHANGED
@@ -7,10 +7,70 @@ import { LeftPane } from './LeftPane';
7
7
  import { RightPane } from './RightPane';
8
8
  import { SlashDialog } from './SlashDialog';
9
9
  import { SetupWizard } from './SetupWizard';
10
+ import { StartupScreen, type StartupAction } from './StartupScreen';
10
11
  import { useSession } from './useSession';
12
+ import { buildMcpStatus } from '../core/mcp.js';
13
+ import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
14
+ import { listWorkspaces } from '../core/workspaces.js';
11
15
 
12
16
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
13
17
 
18
+ function emptyStartupInfo(version: string, workspace: { name: string } | null, workspaces: { name: string }[]) {
19
+ return {
20
+ version,
21
+ model: '',
22
+ connectedMcpServers: 0,
23
+ wikiReady: false,
24
+ workspaceName: workspace?.name ?? null,
25
+ profileName: 'default',
26
+ workspaces: workspaces.map((item) => item.name),
27
+ hasWorkspace: workspace != null,
28
+ };
29
+ }
30
+
31
+ function startupInfo(packageJson: Record<string, unknown>) {
32
+ // listWorkspaces() order is filesystem-dependent (readdirSync), not stable —
33
+ // sort so "the default workspace" is deterministic across runs/platforms.
34
+ const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
35
+ const workspace = workspaces[0] ?? null;
36
+ const version = String(packageJson.version ?? '');
37
+ if (!workspace) return emptyStartupInfo(version, null, []);
38
+
39
+ try {
40
+ const loaded = loadWikircProfile(workspace.workspacePath, 'default');
41
+ const summary = summarizeWikircConfig(loaded.profile, loaded.config);
42
+ const session = {
43
+ workspace: workspace.name,
44
+ workspacePath: workspace.workspacePath,
45
+ workspaceEnv: workspace.env,
46
+ wikirc: {
47
+ profile: loaded.profile.name,
48
+ fileName: loaded.profile.fileName,
49
+ path: loaded.profile.path,
50
+ },
51
+ wikircConfig: loaded.config,
52
+ };
53
+ const mcp = buildMcpStatus(session);
54
+ const connectedMcpServers = Object.values(mcp)
55
+ .filter((server: any) => server?.status && server.status !== 'missing')
56
+ .length;
57
+ const provider = summary.provider ? String(summary.provider) : '';
58
+ const model = summary.model ? String(summary.model) : '';
59
+ return {
60
+ version,
61
+ model: [provider, model].filter(Boolean).join(' / '),
62
+ connectedMcpServers,
63
+ wikiReady: true,
64
+ workspaceName: workspace.name,
65
+ profileName: loaded.profile.name,
66
+ workspaces: workspaces.map((item) => item.name),
67
+ hasWorkspace: true,
68
+ };
69
+ } catch {
70
+ return emptyStartupInfo(version, workspace, workspaces);
71
+ }
72
+ }
73
+
14
74
  function copyToClipboard(text: string, renderer: unknown) {
15
75
  try {
16
76
  if ((renderer as any).copyToClipboardOSC52?.(text)) return true;
@@ -49,11 +109,16 @@ function App(props: {
49
109
  const [exitHint, setExitHint] = createSignal(false);
50
110
  const [copyHint, setCopyHint] = createSignal<string | null>(null);
51
111
  const [chatInputHeight, setChatInputHeight] = createSignal(3);
112
+ // The app has exactly three mutually exclusive screens; one signal makes
113
+ // that invariant structural instead of relying on two booleans staying in
114
+ // sync at every call site.
115
+ const [screen, setScreen] = createSignal<'startup' | 'setup' | 'main'>('startup');
52
116
  let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
53
117
  let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
54
118
  let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
55
119
  let lastCopiedSelection = '';
56
120
  const state = useSession(props);
121
+ const startup = createMemo(() => startupInfo(props.packageJson));
57
122
  const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
58
123
  const rightColumns = createMemo(() => {
59
124
  const width = dimensions().width;
@@ -73,6 +138,71 @@ function App(props: {
73
138
  });
74
139
  };
75
140
 
141
+ const loadWorkspace = async (workspaceName?: string | null) => {
142
+ if (!workspaceName) return false;
143
+ if ((state.session as any).workspace === workspaceName) return true;
144
+ await state.submitInput(`/use ${workspaceName}`);
145
+ // /use does not throw on failure (e.g. a stale/deleted workspace) — it
146
+ // just returns an error message without switching session.workspace, so
147
+ // callers must check the actual post-await state rather than assume success.
148
+ return (state.session as any).workspace === workspaceName;
149
+ };
150
+
151
+ // `startup` is a memo over non-reactive fs reads (listWorkspaces() etc.),
152
+ // so it only ever reflects state at first mount. Action handlers that
153
+ // decide *which* workspace to load must re-read current state directly
154
+ // (startupInfo(...)) rather than trust the frozen memo, the same way
155
+ // closeSetup() already does.
156
+ const loadDefaultWorkspace = async () => loadWorkspace(startupInfo(props.packageJson).workspaceName);
157
+
158
+ // "Open a workspace" is really one composite action (load it, then bring
159
+ // its services up) — named here so it has one definition instead of being
160
+ // inlined as three sequential submitInput calls in openAction.
161
+ const openWorkspaceAndStartServices = async (workspaceName?: string | null) => {
162
+ const loaded = await loadWorkspace(workspaceName);
163
+ if (loaded) {
164
+ await state.submitInput('/start agents');
165
+ await state.submitInput('/start all');
166
+ }
167
+ return loaded;
168
+ };
169
+
170
+ const openAction = (action: StartupAction, workspaceName?: string) => {
171
+ if (action === 'init-workspace') {
172
+ setScreen('setup');
173
+ return;
174
+ }
175
+ setScreen('main');
176
+ void (async () => {
177
+ try {
178
+ if (action === 'open-workspace') {
179
+ await openWorkspaceAndStartServices(workspaceName ?? startupInfo(props.packageJson).workspaceName);
180
+ } else if (action === 'new-conversation' || action === 'run-workflow') {
181
+ const loaded = await loadDefaultWorkspace();
182
+ if (action === 'run-workflow' && loaded) {
183
+ await state.submitInput('/agent');
184
+ }
185
+ }
186
+ } catch {
187
+ // Individual submitInput failures already surface their own error
188
+ // text in the conversation transcript; just stop the sequence here
189
+ // instead of leaving an unhandled rejection.
190
+ }
191
+ })();
192
+ };
193
+
194
+ const closeSetup = () => {
195
+ const info = startupInfo(props.packageJson);
196
+ if (!info.hasWorkspace) {
197
+ setScreen('startup');
198
+ return;
199
+ }
200
+ setScreen('main');
201
+ // Reuse loadWorkspace (not a bare /use dispatch) so the "already on this
202
+ // workspace" short-circuit applies here too.
203
+ void loadWorkspace(info.workspaceName);
204
+ };
205
+
76
206
  const showCopyHint = (message: string) => {
77
207
  setCopyHint(message);
78
208
  if (copyHintTimer) clearTimeout(copyHintTimer);
@@ -106,6 +236,7 @@ function App(props: {
106
236
 
107
237
  useKeyboard((key) => {
108
238
  const keyName = String(key.name ?? '').toLowerCase();
239
+ if (screen() !== 'main') return;
109
240
  if (state.activeEditor()) {
110
241
  if (keyName === 'escape') state.closeEditor();
111
242
  return;
@@ -151,6 +282,26 @@ function App(props: {
151
282
  return null;
152
283
  };
153
284
 
285
+ if (screen() === 'startup') {
286
+ const info = startup();
287
+ return (
288
+ <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}
297
+ width={dimensions().width}
298
+ height={dimensions().height}
299
+ onSelect={openAction}
300
+ onQuit={() => renderer.destroy()}
301
+ />
302
+ );
303
+ }
304
+
154
305
  return (
155
306
  <box width="100%" height="100%" flexDirection="row">
156
307
  <LeftPane
@@ -198,6 +349,18 @@ function App(props: {
198
349
  onSave={state.saveEditor}
199
350
  onCancel={state.closeEditor}
200
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}
201
364
  </box>
202
365
  );
203
366
  }
@@ -1,6 +1,6 @@
1
1
  import { createSignal } from 'solid-js';
2
- import { postRuntimeCancel, postRuntimeRun } from '../runtime/client.js';
3
- import { conversationMessages, runLine } from './repl.js';
2
+ import { postRuntimeCancel } from '../runtime/client.js';
3
+ import { conversationMessages, runLine, submitRuntimeRun } from './repl.js';
4
4
 
5
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 }) {
6
6
  const [busy, setBusy] = createSignal(false);
@@ -19,14 +19,22 @@ 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
- await postRuntimeRun(trimmed, {
23
- url: props.runtimeUrl,
24
- workspace: props.session.workspace ?? null,
25
- });
26
22
  conversationMessages(props.session).push({ role: 'user', content: trimmed });
27
- conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
28
- props.onRuntimeAccepted?.();
29
- props.addLog('runtime: run accepted');
23
+ const outcome = await submitRuntimeRun(trimmed, {
24
+ runtime: { url: props.runtimeUrl },
25
+ session: props.session,
26
+ });
27
+ if (outcome.kind === 'accepted') {
28
+ conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
29
+ props.onRuntimeAccepted?.();
30
+ props.addLog('runtime: run accepted');
31
+ } else if (outcome.kind === 'queued') {
32
+ conversationMessages(props.session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
33
+ props.addLog('runtime: control queued');
34
+ } else {
35
+ conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
36
+ props.addLog(`runtime error: ${outcome.message}`);
37
+ }
30
38
  props.refresh();
31
39
  return { exit: false, runtime: true };
32
40
  }