@dotdrelle/wiki-manager 0.8.2 → 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.
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
  }
@@ -169,6 +169,18 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
169
169
  let lastVisiblePlan: Array<{ step: number; description: string; status: string }> | null = null;
170
170
  const activities = createMemo(() => {
171
171
  version();
172
+ const workflowActivities = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'activity'));
173
+ if (workflowActivities) {
174
+ return workflowActivities.map((node: any) => ({
175
+ ...(node.raw ?? {}),
176
+ key: node.key,
177
+ label: node.label,
178
+ status: node.status,
179
+ terminal: ['done', 'failed', 'cancelled'].includes(String(node.status)),
180
+ _runtime: true,
181
+ _workflow: true,
182
+ }));
183
+ }
172
184
  const runtimeActivities = nonEmptyRuntimeArray(runtimeState()?.activities);
173
185
  if (runtimeActivities) {
174
186
  return runtimeActivities.map((activity: any) => ({ ...activity, _runtime: true }));
@@ -184,6 +196,10 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
184
196
  });
185
197
  const queueItems = createMemo(() => {
186
198
  version();
199
+ const workflowQueue = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue'));
200
+ if (workflowQueue) {
201
+ return workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }));
202
+ }
187
203
  const runtimeQueue = nonEmptyRuntimeArray(runtimeState()?.queue);
188
204
  if (runtimeQueue) return runtimeQueue.map((item: any) => ({ ...item, _runtime: true }));
189
205
  return projectQueue((session as any).headlessPlan, (session as any).jobQueue ?? [], { workspace: (session as any).workspace ?? null })
@@ -191,6 +207,14 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
191
207
  });
192
208
  const queueInfo = createMemo(() => {
193
209
  version();
210
+ const workflowQueue = runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue');
211
+ if (Array.isArray(workflowQueue)) {
212
+ return {
213
+ active: workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
214
+ current: workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
215
+ frozen: 0,
216
+ };
217
+ }
194
218
  const runtimeQueue = runtimeState()?.queue;
195
219
  if (Array.isArray(runtimeQueue)) {
196
220
  return {
@@ -203,6 +227,16 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
203
227
  });
204
228
  const plan = createMemo(() => {
205
229
  version();
230
+ const workflowTasks = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'task'));
231
+ if (workflowTasks) {
232
+ return workflowTasks
233
+ .sort((a: any, b: any) => Number(a.step ?? 0) - Number(b.step ?? 0))
234
+ .map((node: any, index: number) => ({
235
+ step: Number(node.step ?? index + 1),
236
+ description: String(node.description ?? node.label ?? `Step ${index + 1}`),
237
+ status: String(node.status ?? 'pending'),
238
+ }));
239
+ }
206
240
  const runtimePlan = nonEmptyRuntimeArray(runtimeState()?.plan);
207
241
  if (runtimePlan) {
208
242
  return runtimePlan.map((step: any, index: number) => ({