@dotdrelle/wiki-manager 0.9.3 → 0.11.0
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/README.md +21 -6
- package/agents.docker-compose.yml +5 -0
- package/package.json +5 -2
- package/src/agent/graph.js +68 -9
- package/src/agent/graph.test.js +64 -0
- package/src/cli/wiki-manager.js +1 -1
- package/src/commands/slash.js +6 -2
- package/src/contracts/README.md +16 -0
- package/src/contracts/schemas.js +302 -0
- package/src/contracts/schemas.test.js +93 -0
- package/src/core/activity.js +14 -2
- package/src/core/activity.test.js +4 -2
- package/src/core/agentEvents.js +158 -15
- package/src/core/agentEvents.test.js +54 -12
- package/src/core/agentLoop.js +32 -7
- package/src/core/mcp.js +3 -1
- package/src/core/plan.js +4 -0
- package/src/core/planPatch.js +224 -0
- package/src/core/planPatch.test.js +63 -0
- package/src/core/workflow.js +264 -0
- package/src/core/workflow.test.js +66 -0
- package/src/runtime/client.js +28 -1
- package/src/runtime/runner.js +432 -20
- package/src/runtime/runner.test.js +273 -1
- package/src/runtime/server.js +322 -15
- package/src/runtime/server.test.js +339 -0
- package/src/runtime/store.js +59 -10
- package/src/runtime/store.test.js +47 -1
- package/src/shell/RightPane.tsx +1 -7
- package/src/shell/StartupScreen.tsx +212 -0
- package/src/shell/repl.js +51 -7
- package/src/shell/repl.test.js +77 -1
- package/src/shell/textFit.ts +6 -0
- package/src/shell/tui.tsx +163 -0
- package/src/shell/useAgent.ts +17 -9
- package/src/shell/useSession.ts +34 -0
|
@@ -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
|
-
|
|
1508
|
-
|
|
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 });
|
package/src/shell/repl.test.js
CHANGED
|
@@ -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
|
+
});
|
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
|
}
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
|
-
import { postRuntimeCancel
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|