@dotdrelle/wiki-manager 0.11.4 → 0.11.6

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
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { render, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions } from '@opentui/solid';
4
- import { createMemo, createSignal, onCleanup } from 'solid-js';
4
+ import { createMemo, createSignal, onCleanup, Show } from 'solid-js';
5
5
  import { FileEditorDialog } from './FileEditorDialog';
6
6
  import { LeftPane } from './LeftPane';
7
7
  import { RightPane } from './RightPane';
@@ -12,6 +12,7 @@ import { useSession } from './useSession';
12
12
  import { buildMcpStatus } from '../core/mcp.js';
13
13
  import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
14
14
  import { listWorkspaces } from '../core/workspaces.js';
15
+ import { postRuntimeShutdown } from '../runtime/client.js';
15
16
 
16
17
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
17
18
 
@@ -109,6 +110,7 @@ function App(props: {
109
110
  const [exitHint, setExitHint] = createSignal(false);
110
111
  const [copyHint, setCopyHint] = createSignal<string | null>(null);
111
112
  const [chatInputHeight, setChatInputHeight] = createSignal(3);
113
+ const [startupKeyboardEvent, setStartupKeyboardEvent] = createSignal<{ id: number; key: any } | null>(null);
112
114
  // The app has exactly three mutually exclusive screens; one signal makes
113
115
  // that invariant structural instead of relying on two booleans staying in
114
116
  // sync at every call site.
@@ -116,7 +118,9 @@ function App(props: {
116
118
  let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
117
119
  let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
118
120
  let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
121
+ let startupKeyboardEventId = 0;
119
122
  let lastCopiedSelection = '';
123
+ let runtimeShutdownRequested = false;
120
124
  const state = useSession(props);
121
125
  const startup = createMemo(() => startupInfo(props.packageJson));
122
126
  const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
@@ -224,6 +228,10 @@ function App(props: {
224
228
  onCleanup(() => {
225
229
  if (copyHintTimer) clearTimeout(copyHintTimer);
226
230
  if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
231
+ if (props.runtime?.url && !runtimeShutdownRequested) {
232
+ runtimeShutdownRequested = true;
233
+ void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
234
+ }
227
235
  });
228
236
 
229
237
  const spinnerTimer = setInterval(() => {
@@ -236,6 +244,11 @@ function App(props: {
236
244
 
237
245
  useKeyboard((key) => {
238
246
  const keyName = String(key.name ?? '').toLowerCase();
247
+ if (screen() === 'startup') {
248
+ startupKeyboardEventId += 1;
249
+ setStartupKeyboardEvent({ id: startupKeyboardEventId, key });
250
+ return;
251
+ }
239
252
  if (screen() !== 'main') return;
240
253
  if (state.activeEditor()) {
241
254
  if (keyName === 'escape') state.closeEditor();
@@ -279,89 +292,91 @@ function App(props: {
279
292
  const hintLine = () => {
280
293
  if (copyHint()) return copyHint();
281
294
  if (exitHint()) return 'Press Ctrl+C again to exit.';
295
+ if (state.runtimeHint()) return state.runtimeHint();
282
296
  return null;
283
297
  };
284
298
 
285
- if (screen() === 'startup') {
286
- const info = startup();
287
- return (
299
+ return (
300
+ <Show
301
+ when={screen() === 'startup'}
302
+ fallback={
303
+ <box width="100%" height="100%" flexDirection="row">
304
+ <LeftPane
305
+ width={leftColumns()}
306
+ title={state.title()}
307
+ statusLine={state.statusLine()}
308
+ hintLine={hintLine()}
309
+ showWelcome={state.showWelcome()}
310
+ messages={state.messages()}
311
+ prompt={state.prompt()}
312
+ input={state.input()}
313
+ busy={state.busy()}
314
+ chatMode={state.chatMode()}
315
+ chatFocused={!state.activeEditor()}
316
+ setInput={state.setInput}
317
+ submit={submit}
318
+ conversationRows={conversationRows()}
319
+ conversationColumns={conversationColumns()}
320
+ conversationScroll={state.conversationScroll()}
321
+ scrollConversation={state.scrollConversation}
322
+ spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
323
+ onInputHeightChange={setChatInputHeight}
324
+ onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
325
+ />
326
+ <box width={1} height="100%" flexDirection="column">
327
+ {Array.from({ length: dimensions().height }, () => (
328
+ <text fg="#4B5563">│</text>
329
+ ))}
330
+ </box>
331
+ <RightPane
332
+ width={rightColumns()}
333
+ activities={state.activities()}
334
+ logs={state.logs()}
335
+ plan={state.plan()}
336
+ queueItems={state.queueItems()}
337
+ queueInfo={state.queueInfo()}
338
+ activeTab={state.rightTab()}
339
+ onTabClick={state.selectRightTab}
340
+ />
341
+ <SlashDialog context={state.activeEditor() ? null : state.slash()} />
342
+ <FileEditorDialog
343
+ editor={state.activeEditor()}
344
+ width={dimensions().width}
345
+ height={dimensions().height}
346
+ onSave={state.saveEditor}
347
+ onCancel={state.closeEditor}
348
+ />
349
+ {screen() === 'setup' ? (
350
+ <SetupWizard
351
+ mode="setup"
352
+ session={state.session}
353
+ width={dimensions().width}
354
+ height={dimensions().height}
355
+ initialRoute="workspace-name"
356
+ closeOnDone
357
+ onComplete={closeSetup}
358
+ onClose={closeSetup}
359
+ />
360
+ ) : null}
361
+ </box>
362
+ }
363
+ >
288
364
  <StartupScreen
289
- version={info.version}
290
- model={info.model}
291
- connectedMcpServers={info.connectedMcpServers}
292
- wikiReady={info.wikiReady}
293
- workspaceName={info.workspaceName}
294
- profileName={info.profileName}
295
- workspaces={info.workspaces}
296
- hasWorkspace={info.hasWorkspace}
365
+ version={startup().version}
366
+ model={startup().model}
367
+ connectedMcpServers={startup().connectedMcpServers}
368
+ wikiReady={startup().wikiReady}
369
+ workspaceName={startup().workspaceName}
370
+ profileName={startup().profileName}
371
+ workspaces={startup().workspaces}
372
+ hasWorkspace={startup().hasWorkspace}
297
373
  width={dimensions().width}
298
374
  height={dimensions().height}
375
+ keyboardEvent={startupKeyboardEvent()}
299
376
  onSelect={openAction}
300
377
  onQuit={() => renderer.destroy()}
301
378
  />
302
- );
303
- }
304
-
305
- return (
306
- <box width="100%" height="100%" flexDirection="row">
307
- <LeftPane
308
- width={leftColumns()}
309
- title={state.title()}
310
- statusLine={state.statusLine()}
311
- hintLine={hintLine()}
312
- showWelcome={state.showWelcome()}
313
- messages={state.messages()}
314
- prompt={state.prompt()}
315
- input={state.input()}
316
- busy={state.busy()}
317
- chatMode={state.chatMode()}
318
- chatFocused={!state.activeEditor()}
319
- setInput={state.setInput}
320
- submit={submit}
321
- conversationRows={conversationRows()}
322
- conversationColumns={conversationColumns()}
323
- conversationScroll={state.conversationScroll()}
324
- scrollConversation={state.scrollConversation}
325
- spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
326
- onInputHeightChange={setChatInputHeight}
327
- onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
328
- />
329
- <box width={1} height="100%" flexDirection="column">
330
- {Array.from({ length: dimensions().height }, () => (
331
- <text fg="#4B5563">│</text>
332
- ))}
333
- </box>
334
- <RightPane
335
- width={rightColumns()}
336
- activities={state.activities()}
337
- logs={state.logs()}
338
- plan={state.plan()}
339
- queueItems={state.queueItems()}
340
- queueInfo={state.queueInfo()}
341
- activeTab={state.rightTab()}
342
- onTabClick={state.selectRightTab}
343
- />
344
- <SlashDialog context={state.activeEditor() ? null : state.slash()} />
345
- <FileEditorDialog
346
- editor={state.activeEditor()}
347
- width={dimensions().width}
348
- height={dimensions().height}
349
- onSave={state.saveEditor}
350
- onCancel={state.closeEditor}
351
- />
352
- {screen() === 'setup' ? (
353
- <SetupWizard
354
- mode="setup"
355
- session={state.session}
356
- width={dimensions().width}
357
- height={dimensions().height}
358
- initialRoute="workspace-name"
359
- closeOnDone
360
- onComplete={closeSetup}
361
- onClose={closeSetup}
362
- />
363
- ) : null}
364
- </box>
379
+ </Show>
365
380
  );
366
381
  }
367
382
 
@@ -1,8 +1,8 @@
1
1
  import { createSignal } from 'solid-js';
2
2
  import { postRuntimeCancel } from '../runtime/client.js';
3
- import { conversationMessages, runLine, submitRuntimeRun } from './repl.js';
3
+ import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, submitRuntimeRun } from './repl.js';
4
4
 
5
- export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
5
+ export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
6
6
  const [busy, setBusy] = createSignal(false);
7
7
  const [abortController, setAbortController] = createSignal<AbortController | null>(null);
8
8
 
@@ -19,13 +19,15 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
19
19
 
20
20
  try {
21
21
  if (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/')) {
22
- conversationMessages(props.session).push({ role: 'user', content: trimmed });
22
+ // Marked _pending so mergeRuntimeConversation (useSession.ts) can
23
+ // confirm this exact entry instead of pushing a second copy once the
24
+ // same user message comes back from the runtime's own /state.
25
+ conversationMessages(props.session).push({ role: 'user', content: trimmed, _pending: true });
23
26
  const outcome = await submitRuntimeRun(trimmed, {
24
27
  runtime: { url: props.runtimeUrl },
25
28
  session: props.session,
26
29
  });
27
30
  if (outcome.kind === 'accepted') {
28
- conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
29
31
  props.onRuntimeAccepted?.();
30
32
  props.addLog('runtime: run accepted');
31
33
  } else if (outcome.kind === 'queued') {
@@ -38,6 +40,14 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
38
40
  props.refresh();
39
41
  return { exit: false, runtime: true };
40
42
  }
43
+ if (!props.chatMode() && !trimmed.startsWith('/')) {
44
+ const message = recordRuntimeUnavailableAgentInput(props.session, trimmed, {
45
+ error: props.runtimeUnavailableReason ?? 'runtime introuvable',
46
+ });
47
+ props.addLog(message ?? 'runtime: disconnected');
48
+ props.refresh();
49
+ return { exit: false, runtimeUnavailable: true };
50
+ }
41
51
  const result = await runLine(trimmed, {
42
52
  agent: props.agent,
43
53
  packageJson: props.packageJson,
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { isAbsolute, relative, resolve, sep } from 'node:path';
3
- import { createMemo, createSignal, onCleanup } from 'solid-js';
3
+ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
4
4
  import { formatMcpToolResult, callMcpTool } from '../core/mcp.js';
5
5
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
6
6
  import { formatPlanStatus, formatCompletedActivities } from '../core/plan.js';
@@ -14,12 +14,13 @@ import {
14
14
  completionDescription,
15
15
  conversationMessages,
16
16
  createSession,
17
+ runtimeUnavailableAgentMessage,
17
18
  } from './repl.js';
18
19
  import { useAgent } from './useAgent';
19
20
 
20
- function runtimeStatusText(status: 'disabled' | 'connected' | 'disconnected', runStatus: string): string {
21
- if (status === 'connected') return `runtime ${runStatus}`;
22
- if (status === 'disconnected') return 'runtime offline';
21
+ function runtimeStatusText(status: 'disabled' | 'connected' | 'disconnected', runStatus: string, reason: string | null, workspace: string | null): string {
22
+ if (status === 'connected') return `runtime: connected (${workspace ?? 'no workspace'}) ${runStatus}`;
23
+ if (status === 'disconnected') return reason ? `runtime: disconnected: ${reason}` : 'runtime offline';
23
24
  return 'runtime off';
24
25
  }
25
26
 
@@ -74,13 +75,21 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
74
75
  const addLog = (line: string) => {
75
76
  setLogs((items) => [...items, `${new Date().toLocaleTimeString()} ${line}`].slice(-200));
76
77
  };
78
+ const runtimeUnavailableReason = createMemo(() => {
79
+ if (props.runtime?.url) return null;
80
+ const reason = props.runtime?.error ?? props.runtime?.unavailableReason ?? props.runtime?.reason ?? null;
81
+ return reason ? String(reason) : null;
82
+ });
77
83
  if (props.runtime?.url) addLog(`runtime: connected ${props.runtime.url}${props.runtime.started ? ' (started)' : ''}`);
84
+ else if (props.runtime?.error) addLog(`runtime: unavailable — ${props.runtime.error}`);
85
+ else if (runtimeUnavailableReason()) addLog(`runtime: disconnected: ${runtimeUnavailableReason()}`);
78
86
  const agent = useAgent({
79
87
  agent: props.agent,
80
88
  packageJson: props.packageJson,
81
89
  session,
82
90
  chatMode,
83
91
  runtimeUrl: props.runtime?.url ?? null,
92
+ runtimeUnavailableReason: runtimeUnavailableReason(),
84
93
  refresh,
85
94
  addLog,
86
95
  onRuntimeAccepted: () => {
@@ -97,23 +106,22 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
97
106
  );
98
107
  const localFallbackActive = createMemo(() => !props.runtime?.url || runtimeStatus() === 'disconnected');
99
108
 
109
+ // Runtime conversation entries are merged into conversationMessages(session)
110
+ // (see mergeRuntimeConversation below) as they arrive, so they land in true
111
+ // chronological order alongside local command output (/status, /use, ...)
112
+ // instead of being reconstructed as two separately-ordered blocks at render
113
+ // time — that used to put every local command before the whole runtime
114
+ // history regardless of when each one actually happened.
100
115
  const messages = createMemo(() => {
101
116
  version();
117
+ return [...conversationMessages(session)].slice(-200);
118
+ });
119
+ createEffect(() => {
102
120
  const runtimeConversation = runtimeState()?.conversation;
103
- const localCommands = conversationMessages(session)
104
- .filter((message: any) => message.role === 'command')
105
- .map((message: any) => ({
106
- role: 'command',
107
- content: String(message.content ?? ''),
108
- }));
109
121
  if (Array.isArray(runtimeConversation) && runtimeConversation.length > 0) {
110
- const runtimeMessages = runtimeConversation.map((message: any) => ({
111
- role: message.role === 'assistant' ? 'donna' : message.role,
112
- content: String(message.content ?? ''),
113
- }));
114
- return [...runtimeMessages, ...localCommands].slice(-200);
122
+ setShowWelcome(false);
123
+ setConversationScroll(0);
115
124
  }
116
- return [...conversationMessages(session)];
117
125
  });
118
126
  const prompt = createMemo(() => {
119
127
  version();
@@ -133,9 +141,14 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
133
141
  session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
134
142
  session.language ? session.language : 'no language',
135
143
  session.llm ? 'llm ready' : 'llm limited',
136
- runtimeStatusText(runtimeStatus(), runtimeRunStatus()),
144
+ runtimeStatusText(runtimeStatus(), runtimeRunStatus(), runtimeUnavailableReason(), session.workspace),
137
145
  ].join(' ');
138
146
  });
147
+ const runtimeHint = createMemo(() => {
148
+ version();
149
+ if (props.runtime?.url) return null;
150
+ return runtimeUnavailableAgentMessage({ error: runtimeUnavailableReason() ?? 'runtime introuvable' });
151
+ });
139
152
  const matchContext = createMemo(() => {
140
153
  if (input() === dismissedSlashInput()) return null;
141
154
  return completionContext(input(), session);
@@ -263,10 +276,54 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
263
276
  return [...logs(), ...tagged].slice(-200);
264
277
  });
265
278
 
279
+ // Index-aligned with each workspace's runtimeConversation array — not just
280
+ // a length count. The server sometimes finalizes a streaming placeholder
281
+ // (queued during a tool-calling turn, never closed out because that turn's
282
+ // reply had no final text) by overwriting the *same* conversation entry's
283
+ // content on a later turn, without the array growing. A length-based diff
284
+ // would miss that update entirely and leave the stale placeholder text
285
+ // shown forever; tracking local entry references per index picks it up.
286
+ const runtimeConversationRefsByWorkspace = new Map<string, any[]>();
287
+ function mergeRuntimeConversation(state: any) {
288
+ const workspace = (session as any).workspace || '__global__';
289
+ const runtimeConversation = Array.isArray(state?.conversation) ? state.conversation : [];
290
+ if (runtimeConversation.length === 0) return;
291
+ const target = conversationMessages(session);
292
+ const backed = runtimeConversationRefsByWorkspace.get(workspace) ?? [];
293
+ runtimeConversationRefsByWorkspace.set(workspace, backed);
294
+ for (let i = 0; i < runtimeConversation.length; i += 1) {
295
+ const message = runtimeConversation[i];
296
+ const content = String(message.content ?? '');
297
+ const role = message.role === 'assistant' ? 'donna' : message.role;
298
+ if (i < backed.length) {
299
+ const entry = backed[i];
300
+ if (entry.content !== content) entry.content = content;
301
+ if (entry.role !== role) entry.role = role;
302
+ continue;
303
+ }
304
+ if (role === 'user') {
305
+ // The user's own message was already pushed optimistically (see
306
+ // useAgent.ts) and marked _pending — confirm that entry instead of
307
+ // adding a second copy. No matching pending entry (e.g. history
308
+ // restored after a fresh reconnect) falls through to a normal push.
309
+ const pendingIndex = target.findIndex((entry: any) => entry._pending && entry.role === 'user' && entry.content === content);
310
+ if (pendingIndex !== -1) {
311
+ delete target[pendingIndex]._pending;
312
+ backed.push(target[pendingIndex]);
313
+ continue;
314
+ }
315
+ }
316
+ const entry = { role, content };
317
+ target.push(entry);
318
+ backed.push(entry);
319
+ }
320
+ }
321
+
266
322
  function syncRuntimeState() {
267
323
  void fetchRuntimeState({ url: props.runtime.url, workspace: (session as any).workspace ?? null })
268
324
  .then((state) => {
269
325
  setRuntimeState(state);
326
+ mergeRuntimeConversation(state);
270
327
  setRuntimeStatus('connected');
271
328
  refresh();
272
329
  })
@@ -310,6 +367,22 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
310
367
  syncRuntimeState();
311
368
  void subscribeRuntimeEvents();
312
369
  }
370
+
371
+ let currentRuntimeWorkspace = (session as any).workspace ?? null;
372
+ function resyncRuntimeWorkspaceIfChanged() {
373
+ const workspace = (session as any).workspace ?? null;
374
+ if (workspace === currentRuntimeWorkspace) return;
375
+ currentRuntimeWorkspace = workspace;
376
+ if (!props.runtime?.url) return;
377
+ if (runtimeReconnectTimer) {
378
+ clearTimeout(runtimeReconnectTimer);
379
+ runtimeReconnectTimer = null;
380
+ }
381
+ runtimeStreamAbort?.abort();
382
+ syncRuntimeState();
383
+ void subscribeRuntimeEvents();
384
+ }
385
+
313
386
  onCleanup(() => {
314
387
  runtimeStreamStopped = true;
315
388
  runtimeStreamAbort?.abort();
@@ -408,6 +481,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
408
481
  lastActivityLines.clear();
409
482
  lastActivityDetails.clear();
410
483
  const result = await agent.submit(line);
484
+ resyncRuntimeWorkspaceIfChanged();
411
485
  if ((result as any)?.setMode === 'chat') {
412
486
  setChatMode(true);
413
487
  (session as any).chatMode = true;
@@ -523,6 +597,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
523
597
  setInput: updateInput,
524
598
  title,
525
599
  statusLine,
600
+ runtimeHint,
526
601
  chatMode,
527
602
  showWelcome,
528
603
  activeEditor,