mu-coding 0.5.0 → 0.9.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.
Files changed (84) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/cli/install.ts +18 -3
  8. package/src/config/index.test.ts +26 -0
  9. package/src/config/index.ts +25 -7
  10. package/src/plugin.ts +124 -0
  11. package/src/runtime/codingTools/bash.ts +114 -0
  12. package/src/runtime/codingTools/edit-file.ts +60 -0
  13. package/src/runtime/codingTools/index.ts +39 -0
  14. package/src/runtime/codingTools/read-file.ts +83 -0
  15. package/src/runtime/codingTools/utils.ts +21 -0
  16. package/src/runtime/codingTools/write-file.ts +42 -0
  17. package/src/runtime/createRegistry.test.ts +147 -0
  18. package/src/runtime/createRegistry.ts +160 -23
  19. package/src/runtime/fileMentionProvider.ts +116 -0
  20. package/src/runtime/messageBus.test.ts +62 -0
  21. package/src/runtime/messageBus.ts +78 -0
  22. package/src/runtime/pluginLoader.ts +59 -15
  23. package/src/sessions/index.ts +2 -9
  24. package/src/tui/channel/tuiChannel.test.ts +107 -0
  25. package/src/tui/channel/tuiChannel.ts +62 -0
  26. package/src/tui/chat/MessageRendererContext.ts +44 -0
  27. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  28. package/src/tui/chat/useAbort.ts +5 -0
  29. package/src/tui/chat/useAttachment.ts +1 -1
  30. package/src/tui/chat/useChat.ts +38 -3
  31. package/src/tui/chat/useChatPanel.ts +29 -6
  32. package/src/tui/chat/useChatSession.ts +324 -57
  33. package/src/tui/chat/useModels.ts +26 -1
  34. package/src/tui/chat/usePluginStatus.ts +1 -1
  35. package/src/tui/chat/useSessionPersistence.ts +48 -21
  36. package/src/tui/chat/useStatusSegments.ts +38 -5
  37. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  38. package/src/tui/components/chat/ChatPanel.tsx +25 -4
  39. package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
  40. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  41. package/src/tui/components/messageView.tsx +4 -2
  42. package/src/tui/components/messages/EditOutput.tsx +17 -9
  43. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  44. package/src/tui/components/messages/ToolHeader.tsx +8 -4
  45. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  46. package/src/tui/components/messages/assistantMessage.tsx +55 -7
  47. package/src/tui/components/messages/markdown.tsx +402 -0
  48. package/src/tui/components/messages/messageItem.tsx +19 -1
  49. package/src/tui/components/messages/reasoningBlock.tsx +10 -6
  50. package/src/tui/components/messages/streamingOutput.tsx +6 -2
  51. package/src/tui/components/messages/toolCallBlock.tsx +7 -6
  52. package/src/tui/components/messages/userMessage.tsx +22 -7
  53. package/src/tui/components/primitives/dropdown.tsx +8 -4
  54. package/src/tui/components/primitives/modal.tsx +4 -2
  55. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  56. package/src/tui/components/primitives/toast.tsx +43 -10
  57. package/src/tui/components/statusBar.tsx +26 -10
  58. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  59. package/src/tui/context/ThemeContext.tsx +18 -0
  60. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  61. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  62. package/src/tui/input/InputBoxView.tsx +191 -26
  63. package/src/tui/input/commands.test.ts +3 -1
  64. package/src/tui/input/commands.ts +11 -1
  65. package/src/tui/input/cursor.test.ts +136 -0
  66. package/src/tui/input/cursor.ts +214 -0
  67. package/src/tui/input/dumpContext.ts +107 -0
  68. package/src/tui/input/sanitize.ts +1 -1
  69. package/src/tui/input/useCommandExecutor.ts +1 -1
  70. package/src/tui/input/useInputBox.ts +160 -15
  71. package/src/tui/input/useInputHandler.ts +317 -126
  72. package/src/tui/input/useMentionPicker.ts +133 -0
  73. package/src/tui/input/usePluginShortcuts.ts +29 -0
  74. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  75. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  76. package/src/tui/plugins/InkUIService.ts +1 -1
  77. package/src/tui/renderApp.tsx +47 -13
  78. package/src/tui/theme/index.ts +1 -0
  79. package/src/tui/theme/merge.test.ts +49 -0
  80. package/src/tui/theme/merge.ts +43 -0
  81. package/src/tui/theme/presets.ts +90 -0
  82. package/src/tui/theme/types.ts +138 -0
  83. package/src/utils/clipboard.ts +1 -1
  84. package/src/tui/chat/useStreamConsumer.ts +0 -118
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import type { ChatMessage } from 'mu-core';
3
+ import { createMessageBus } from './messageBus';
4
+
5
+ const userMsg = (text: string): ChatMessage => ({ role: 'user', content: text });
6
+
7
+ describe('createMessageBus', () => {
8
+ it('queues appends until an appender is wired', () => {
9
+ const bus = createMessageBus();
10
+ bus.append(userMsg('hi'));
11
+ bus.append(userMsg('there'));
12
+ const seen: ChatMessage[] = [];
13
+ bus.setAppender((m) => seen.push(m));
14
+ expect(seen.map((m) => m.content)).toEqual(['hi', 'there']);
15
+ });
16
+
17
+ it('forwards subsequent appends through the wired appender', () => {
18
+ const bus = createMessageBus();
19
+ const appender = mock(() => {
20
+ /* spy only */
21
+ });
22
+ bus.setAppender(appender);
23
+ bus.append(userMsg('one'));
24
+ bus.append(userMsg('two'));
25
+ expect(appender).toHaveBeenCalledTimes(2);
26
+ });
27
+
28
+ it('drainNext returns and clears injectNext payload', () => {
29
+ const bus = createMessageBus();
30
+ bus.injectNext(userMsg('a'));
31
+ bus.injectNext(userMsg('b'));
32
+ expect(bus.drainNext().map((m) => m.content)).toEqual(['a', 'b']);
33
+ expect(bus.drainNext()).toEqual([]);
34
+ });
35
+
36
+ it('subscribe replays the current snapshot once', () => {
37
+ const bus = createMessageBus();
38
+ bus.setMessages([userMsg('hello')]);
39
+ const seen: string[] = [];
40
+ bus.subscribe((m) => seen.push(m.map((x) => x.content).join(',')));
41
+ expect(seen).toEqual(['hello']);
42
+ });
43
+
44
+ it('subscribe fires on subsequent setMessages and unsubscribes cleanly', () => {
45
+ const bus = createMessageBus();
46
+ const updates: number[] = [];
47
+ const off = bus.subscribe((m) => updates.push(m.length));
48
+ bus.setMessages([userMsg('a')]);
49
+ bus.setMessages([userMsg('a'), userMsg('b')]);
50
+ off();
51
+ bus.setMessages([userMsg('c')]);
52
+ expect(updates).toEqual([0, 1, 2]);
53
+ });
54
+
55
+ it('get reflects the latest setMessages snapshot', () => {
56
+ const bus = createMessageBus();
57
+ expect(bus.get()).toEqual([]);
58
+ const msgs = [userMsg('x')];
59
+ bus.setMessages(msgs);
60
+ expect(bus.get()).toEqual(msgs);
61
+ });
62
+ });
@@ -0,0 +1,78 @@
1
+ import type { ChatMessage, MessageBus } from 'mu-core';
2
+
3
+ type MessageListener = (messages: ChatMessage[]) => void;
4
+ type Appender = (message: ChatMessage) => void;
5
+
6
+ export interface HostMessageBus extends MessageBus {
7
+ /**
8
+ * Provide the live "append a message to the transcript" hook from the React
9
+ * tree. Called by `useChatSession` once it has stable `setMessages`. Until
10
+ * the hook is wired, `append` queues messages so plugin activations during
11
+ * registry construction don't lose entries.
12
+ */
13
+ setAppender: (fn: Appender | null) => void;
14
+ /**
15
+ * Replace the current `messages` array reported via `get()` and broadcast
16
+ * to subscribers. Driven by the chat session whenever the transcript
17
+ * changes (user send, streamed response, session reload, ...).
18
+ */
19
+ setMessages: (messages: ChatMessage[]) => void;
20
+ }
21
+
22
+ /**
23
+ * Host-side bridge between plugins and the live chat transcript.
24
+ *
25
+ * Plugins call `append`/`injectNext`/`get`/`subscribe` through the registry
26
+ * `PluginContext`. The mu-coding chat session wires the live transcript and
27
+ * appender into this bus so calls made before the React tree mounts are
28
+ * buffered and replayed once the wiring lands.
29
+ */
30
+ export function createMessageBus(): HostMessageBus {
31
+ let appender: Appender | null = null;
32
+ const pendingAppends: ChatMessage[] = [];
33
+ const pendingNextTurn: ChatMessage[] = [];
34
+ let currentMessages: ChatMessage[] = [];
35
+ const listeners = new Set<MessageListener>();
36
+
37
+ const flushAppends = (): void => {
38
+ if (!appender) return;
39
+ while (pendingAppends.length) {
40
+ const msg = pendingAppends.shift();
41
+ if (msg) appender(msg);
42
+ }
43
+ };
44
+
45
+ return {
46
+ append(message) {
47
+ if (appender) appender(message);
48
+ else pendingAppends.push(message);
49
+ },
50
+ injectNext(message) {
51
+ pendingNextTurn.push(message);
52
+ },
53
+ drainNext() {
54
+ const out = pendingNextTurn.slice();
55
+ pendingNextTurn.length = 0;
56
+ return out;
57
+ },
58
+ subscribe(listener) {
59
+ listeners.add(listener);
60
+ // Replay current snapshot so subscribers don't miss the initial state.
61
+ listener(currentMessages);
62
+ return () => {
63
+ listeners.delete(listener);
64
+ };
65
+ },
66
+ get() {
67
+ return currentMessages;
68
+ },
69
+ setAppender(fn) {
70
+ appender = fn;
71
+ flushAppends();
72
+ },
73
+ setMessages(next) {
74
+ currentMessages = next;
75
+ for (const fn of listeners) fn(next);
76
+ },
77
+ };
78
+ }
@@ -1,7 +1,8 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { join, resolve } from 'node:path';
4
- import type { Plugin, PluginRegistry } from 'mu-agents';
4
+ import type { Plugin, PluginRegistry } from 'mu-core';
5
+ import { installNpmPackage } from '../cli/install';
5
6
  import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
6
7
  import type { InkUIService } from '../tui/plugins/InkUIService';
7
8
 
@@ -31,14 +32,44 @@ function formatPluginError(name: string, err: unknown): string {
31
32
  return parts.join(': ');
32
33
  }
33
34
 
34
- function resolveNpmPlugin(specifier: string): string {
35
- const { name } = parseBareNpmSpec(specifier.slice(4));
35
+ /**
36
+ * Resolve an `npm:<spec>` plugin specifier to an absolute path on disk.
37
+ *
38
+ * If the package isn't installed yet, runs `bun add <spec>` against the mu
39
+ * data dir and retries — so users can list a plugin in `config.plugins`
40
+ * without having to invoke `mu install` first.
41
+ *
42
+ * `uiService` is optional: when provided, surface "Installing …" / failure
43
+ * messages through the TUI; otherwise fall back to stderr so the host's
44
+ * boot log still shows what happened.
45
+ */
46
+ async function resolveNpmPlugin(specifier: string, uiService?: InkUIService): Promise<string> {
47
+ const bare = specifier.slice(4);
48
+ const { name } = parseBareNpmSpec(bare);
36
49
  const dataDir = getDataDir();
50
+ const require = createRequire(resolve(dataDir, 'package.json'));
51
+
37
52
  try {
38
- const require = createRequire(resolve(dataDir, 'package.json'));
39
53
  return require.resolve(name);
40
- } catch (err) {
41
- throw new Error(`Cannot resolve "${name}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
54
+ } catch (_firstErr) {
55
+ const installMsg = `Installing ${name}…`;
56
+ if (uiService) uiService.notify(installMsg, 'info');
57
+ else console.error(`[mu] ${installMsg}`);
58
+
59
+ try {
60
+ installNpmPackage(bare, { silent: true });
61
+ } catch (installErr) {
62
+ throw new Error(`Failed to auto-install "${name}" into ${dataDir}/node_modules`, { cause: installErr });
63
+ }
64
+
65
+ try {
66
+ return require.resolve(name);
67
+ } catch (retryErr) {
68
+ throw new Error(
69
+ `Auto-installed "${name}" but cannot resolve it from ${dataDir}/node_modules — install may have failed silently`,
70
+ { cause: retryErr },
71
+ );
72
+ }
42
73
  }
43
74
  }
44
75
 
@@ -71,36 +102,49 @@ function extractPlugin(mod: Record<string, unknown>, pluginConfig: Record<string
71
102
  return null;
72
103
  }
73
104
 
74
- export async function loadConfiguredPlugin(
75
- registry: PluginRegistry,
105
+ /**
106
+ * Loader variant that *resolves* (imports + extracts) a plugin without
107
+ * registering it. Used by hosts driving `startMu({ resolvePlugin })`. Errors
108
+ * surface via `uiService` (or are swallowed when omitted) so the host's
109
+ * boot log behaviour matches `loadConfiguredPlugin`.
110
+ */
111
+ export async function resolveConfiguredPlugin(
76
112
  name: string,
77
113
  pluginConfig?: Record<string, unknown>,
78
114
  uiService?: InkUIService,
79
- ): Promise<void> {
115
+ ): Promise<Plugin | null> {
80
116
  const config = pluginConfig ?? {};
81
117
  let target: string;
82
118
  try {
83
- target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
119
+ target = name.startsWith('npm:') ? await resolveNpmPlugin(name, uiService) : name;
84
120
  } catch (err) {
85
121
  uiService?.notify(formatPluginError(name, err), 'error');
86
- return;
122
+ return null;
87
123
  }
88
-
89
124
  let mod: Record<string, unknown>;
90
125
  try {
91
126
  mod = (await import(target)) as Record<string, unknown>;
92
127
  } catch (err) {
93
128
  uiService?.notify(formatPluginError(name, err), 'error');
94
- return;
129
+ return null;
95
130
  }
96
-
97
131
  const plugin = extractPlugin(mod, config);
98
132
  if (!plugin) {
99
133
  const exportKeys = Object.keys(mod).join(', ') || '(none)';
100
134
  uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
101
- return;
135
+ return null;
102
136
  }
137
+ return plugin;
138
+ }
103
139
 
140
+ export async function loadConfiguredPlugin(
141
+ registry: PluginRegistry,
142
+ name: string,
143
+ pluginConfig?: Record<string, unknown>,
144
+ uiService?: InkUIService,
145
+ ): Promise<void> {
146
+ const plugin = await resolveConfiguredPlugin(name, pluginConfig, uiService);
147
+ if (!plugin) return;
104
148
  try {
105
149
  await registry.register(plugin);
106
150
  } catch (err) {
@@ -1,8 +1,8 @@
1
- import { createReadStream, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { createReadStream, mkdirSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { stat, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
- import type { ChatMessage } from 'mu-provider';
5
+ import type { ChatMessage } from 'mu-core';
6
6
  import { getDataDir } from '../config/index';
7
7
  import { getProjectId, getProjectName } from './project';
8
8
 
@@ -181,10 +181,3 @@ export async function listSessionsAsync(): Promise<SessionInfo[]> {
181
181
 
182
182
  return results.filter((s): s is SessionInfo => s !== null);
183
183
  }
184
-
185
- // Sync helper preserved for legacy/test callers — wraps the same data path
186
- // without the streaming optimization. New code should prefer `listSessionsAsync`.
187
- export function saveSessionSync(path: string, messages: ChatMessage[]): void {
188
- const content = messages.length > 0 ? `${messages.map((m) => JSON.stringify(m)).join('\n')}\n` : '';
189
- writeFileSync(path, content, 'utf-8');
190
- }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import type { ChatMessage, PluginRegistry } from 'mu-core';
3
+ import type { ShutdownFn } from '../../app/shutdown';
4
+ import type { AppConfig } from '../../config/index';
5
+ import type { HostMessageBus } from '../../runtime/messageBus';
6
+ import type { InkUIService } from '../plugins/InkUIService';
7
+ import { createTuiChannel } from './tuiChannel';
8
+
9
+ // Stub renderApp by mocking the import surface. We can't actually mount Ink
10
+ // in a non-TTY test environment, but we can verify the channel structure
11
+ // and that the registry passed in has the methods the TUI subscribes to.
12
+ const noop = (): void => {
13
+ /* stub */
14
+ };
15
+
16
+ const renderArgs: Array<{ registry: PluginRegistry; config: AppConfig }> = [];
17
+ mock.module('../renderApp', () => ({
18
+ renderApp: (opts: { registry: PluginRegistry; config: AppConfig }) => {
19
+ renderArgs.push(opts);
20
+ return {
21
+ unmount: noop,
22
+ waitUntilExit: async () => {
23
+ /* stub */
24
+ },
25
+ rerender: noop,
26
+ cleanup: noop,
27
+ clear: noop,
28
+ };
29
+ },
30
+ }));
31
+
32
+ const fakeOpts = {
33
+ config: {} as AppConfig,
34
+ initialMessages: [] as ChatMessage[],
35
+ registry: {} as PluginRegistry,
36
+ messageBus: {} as HostMessageBus,
37
+ uiService: {} as InkUIService,
38
+ shutdown: (async () => {
39
+ /* test shutdown stub */
40
+ }) as ShutdownFn,
41
+ };
42
+
43
+ describe('createTuiChannel', () => {
44
+ it('exposes id="tui"', () => {
45
+ const ch = createTuiChannel(fakeOpts);
46
+ expect(ch.id).toBe('tui');
47
+ });
48
+
49
+ it('start is idempotent — second start is a no-op', async () => {
50
+ const ch = createTuiChannel(fakeOpts);
51
+ await ch.start();
52
+ await ch.start(); // should not throw / re-mount
53
+ });
54
+
55
+ it('stop without start is a no-op', async () => {
56
+ const ch = createTuiChannel(fakeOpts);
57
+ await ch.stop?.();
58
+ });
59
+
60
+ it('start → stop → start cycles cleanly', async () => {
61
+ const ch = createTuiChannel(fakeOpts);
62
+ await ch.start();
63
+ await ch.stop?.();
64
+ await ch.start();
65
+ await ch.stop?.();
66
+ });
67
+ });
68
+
69
+ describe('createTuiChannel — registry shape contract', () => {
70
+ it('forwards a registry that exposes the subscription methods the TUI relies on', async () => {
71
+ // Build a registry mock whose methods are all functions; the channel's
72
+ // `start()` calls renderApp which (in production) mounts components that
73
+ // immediately invoke onStatusChange / onRenderersChange / etc.
74
+ const stubFn = (): (() => void) => () => {
75
+ /* unsub */
76
+ };
77
+ const fakeRegistry: Record<string, unknown> = {
78
+ getTools: () => [],
79
+ getFilteredTools: async () => [],
80
+ getHooks: () => [],
81
+ getStatusSegments: () => new Map(),
82
+ onStatusChange: stubFn(),
83
+ getRenderers: () => [],
84
+ onRenderersChange: stubFn(),
85
+ getShortcuts: () => [],
86
+ onShortcutsChange: stubFn(),
87
+ getCommands: () => [],
88
+ };
89
+ const ch = createTuiChannel({ ...fakeOpts, registry: fakeRegistry as unknown as PluginRegistry });
90
+ renderArgs.length = 0;
91
+ await ch.start();
92
+ expect(renderArgs).toHaveLength(1);
93
+ const seen = renderArgs[0].registry as unknown as Record<string, unknown>;
94
+ for (const method of [
95
+ 'onStatusChange',
96
+ 'getStatusSegments',
97
+ 'onRenderersChange',
98
+ 'getRenderers',
99
+ 'onShortcutsChange',
100
+ 'getShortcuts',
101
+ 'getCommands',
102
+ ]) {
103
+ expect(typeof seen[method]).toBe('function');
104
+ }
105
+ await ch.stop?.();
106
+ });
107
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * TUI Channel — wraps Ink rendering inside the mu-core `Channel` contract.
3
+ * `start()` mounts the app and captures the Ink instance; `stop()` unmounts
4
+ * it cleanly so `channels.stopAll()` restores the terminal.
5
+ */
6
+
7
+ import type { Instance } from 'ink';
8
+ import type { SubagentRunRegistry } from 'mu-agents';
9
+ import type { Channel, ChatMessage, PluginRegistry } from 'mu-core';
10
+ import type { ShutdownFn } from '../../app/shutdown';
11
+ import type { AppConfig } from '../../config/index';
12
+ import type { SessionPathHolder } from '../../runtime/createRegistry';
13
+ import type { HostMessageBus } from '../../runtime/messageBus';
14
+ import type { InkUIService } from '../plugins/InkUIService';
15
+ import { renderApp } from '../renderApp';
16
+
17
+ export interface TuiChannelOptions {
18
+ config: AppConfig;
19
+ initialMessages?: ChatMessage[];
20
+ registry: PluginRegistry;
21
+ messageBus: HostMessageBus;
22
+ uiService: InkUIService;
23
+ shutdown: ShutdownFn;
24
+ sessionPathHolder?: SessionPathHolder;
25
+ subagentRuns?: SubagentRunRegistry;
26
+ }
27
+
28
+ export function createTuiChannel(opts: TuiChannelOptions): Channel {
29
+ let instance: Instance | null = null;
30
+ return {
31
+ id: 'tui',
32
+ async start() {
33
+ // Idempotent: re-starting after a stop remounts; re-starting while
34
+ // mounted is a no-op.
35
+ if (instance) return;
36
+ instance = renderApp({
37
+ config: opts.config,
38
+ initialMessages: opts.initialMessages,
39
+ registry: opts.registry,
40
+ messageBus: opts.messageBus,
41
+ uiService: opts.uiService,
42
+ shutdown: opts.shutdown,
43
+ sessionPathHolder: opts.sessionPathHolder,
44
+ subagentRuns: opts.subagentRuns,
45
+ });
46
+ },
47
+ async stop() {
48
+ if (!instance) return;
49
+ try {
50
+ instance.unmount();
51
+ // Wait for Ink's exit promise so `stopAll()` callers know the
52
+ // terminal has been restored before they continue (e.g. emitting
53
+ // a final shutdown message to stdout).
54
+ await instance.waitUntilExit().catch(() => {
55
+ /* unmount-induced exit rejects with the cause; we don't care */
56
+ });
57
+ } finally {
58
+ instance = null;
59
+ }
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,44 @@
1
+ import type { ChatMessage, PluginRegistry } from 'mu-core';
2
+ import { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
3
+
4
+ /**
5
+ * Plugin renderers are typed `unknown` in mu-agents (kept renderer-agnostic);
6
+ * the host narrows to React at the boundary so renderer authors can return
7
+ * any `ReactNode`.
8
+ */
9
+ type ReactMessageRenderer = (msg: ChatMessage) => ReactNode;
10
+
11
+ type RendererMap = Map<string, ReactMessageRenderer>;
12
+
13
+ const MessageRendererContext = createContext<RendererMap>(new Map());
14
+
15
+ export const MessageRendererProvider = MessageRendererContext.Provider;
16
+
17
+ /** Hook used by `MessageItem` to look up custom renderers by `customType`. */
18
+ export function useMessageRenderer(customType: string | undefined): ReactMessageRenderer | undefined {
19
+ const map = useContext(MessageRendererContext);
20
+ if (!customType) return undefined;
21
+ return map.get(customType);
22
+ }
23
+
24
+ /**
25
+ * Track the registry's custom renderer set. Re-builds the map whenever a
26
+ * plugin registers or unregisters one. The cast from `unknown` to ReactNode
27
+ * happens here so descendant components stay strictly typed.
28
+ */
29
+ export function useRegistryRenderers(registry: PluginRegistry): RendererMap {
30
+ const [map, setMap] = useState<RendererMap>(() => buildMap(registry));
31
+ useEffect(() => {
32
+ setMap(buildMap(registry));
33
+ return registry.onRenderersChange(() => setMap(buildMap(registry)));
34
+ }, [registry]);
35
+ return map;
36
+ }
37
+
38
+ function buildMap(registry: PluginRegistry): RendererMap {
39
+ const out: RendererMap = new Map();
40
+ for (const [customType, renderer] of registry.getRenderers()) {
41
+ out.set(customType, (msg) => renderer(msg) as ReactNode);
42
+ }
43
+ return out;
44
+ }
@@ -1,4 +1,4 @@
1
- import type { PluginRegistry, ToolDisplayHint } from 'mu-agents';
1
+ import type { PluginRegistry, ToolDisplayHint } from 'mu-core';
2
2
  import { createContext, useContext, useMemo } from 'react';
3
3
 
4
4
  type ToolDisplayMap = Map<string, ToolDisplayHint>;
@@ -7,9 +7,14 @@ function useDoublePress(timeoutMs: number) {
7
7
 
8
8
  const confirm = useCallback(() => {
9
9
  if (warning) {
10
+ // Confirmed press: cancel the pending auto-reset and clear the
11
+ // warning flag immediately so the status hint ("Esc again to stop"
12
+ // / "Ctrl+C again to quit") disappears as soon as the action fires.
10
13
  if (timerRef.current) {
11
14
  clearTimeout(timerRef.current);
15
+ timerRef.current = null;
12
16
  }
17
+ setWarning(false);
13
18
  return true;
14
19
  }
15
20
  setWarning(true);
@@ -1,4 +1,4 @@
1
- import type { ImageAttachment } from 'mu-provider';
1
+ import type { ImageAttachment } from 'mu-core';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { readClipboardImage } from '../../utils/clipboard';
4
4
 
@@ -1,9 +1,18 @@
1
1
  import { useApp } from 'ink';
2
- import type { PluginRegistry } from 'mu-agents';
3
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
- import { useEffect, useRef, useState } from 'react';
2
+ import type { SubagentRunRegistry } from 'mu-agents';
3
+ import {
4
+ type ChatMessage,
5
+ createSessionManager,
6
+ type PluginRegistry,
7
+ type ProviderConfig,
8
+ type SessionManager,
9
+ } from 'mu-core';
10
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
11
  import type { ShutdownFn } from '../../app/shutdown';
12
+ import type { SessionPathHolder } from '../../runtime/createRegistry';
13
+ import type { HostMessageBus } from '../../runtime/messageBus';
6
14
  import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
15
+ import type { InkUIService } from '../plugins/InkUIService';
7
16
  import { type AbortState, useAbort } from './useAbort';
8
17
  import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useAttachment';
9
18
  import { type ChatSessionState, useChatSession } from './useChatSession';
@@ -14,12 +23,16 @@ const ABORT_TIMEOUT_MS = 2000;
14
23
  export interface ChatContextValue {
15
24
  config: ProviderConfig;
16
25
  session: ChatSessionState;
26
+ sessionManager: SessionManager;
17
27
  toggles: TogglesState;
18
28
  attachment: AttachmentState;
19
29
  models: ModelListState;
20
30
  abort: AbortState;
21
31
  sessions: SessionInfo[];
22
32
  registry: PluginRegistry;
33
+ uiService?: InkUIService;
34
+ messageBus?: HostMessageBus;
35
+ subagentRuns?: SubagentRunRegistry;
23
36
  }
24
37
 
25
38
  export function useChat(
@@ -27,19 +40,37 @@ export function useChat(
27
40
  registry: PluginRegistry,
28
41
  initialMessages?: ChatMessage[],
29
42
  shutdown?: ShutdownFn,
43
+ uiService?: InkUIService,
44
+ messageBus?: HostMessageBus,
45
+ sessionPathHolder?: SessionPathHolder,
46
+ subagentRuns?: SubagentRunRegistry,
30
47
  ): ChatContextValue {
31
48
  const { exit } = useApp();
32
49
  const controllerRef = useRef<AbortController | null>(null);
33
50
  const attachment = useAttachment();
34
51
  const toggles = useToggles();
35
52
  const models = useModelList(config.baseUrl, config.model);
53
+ // Stable SessionManager + Session for the lifetime of the chat hook. Model
54
+ // updates flow through `runTurn(options)` per call, so we don't need to
55
+ // re-instantiate on every change.
56
+ const sessionManager = useMemo(
57
+ () => createSessionManager({ registry, config, model: models.currentModel || config.model || 'unknown' }),
58
+ [registry, config, models.currentModel],
59
+ );
60
+ const muSession = useMemo(
61
+ () => sessionManager.getOrCreate('tui', { initialMessages }),
62
+ [sessionManager, initialMessages],
63
+ );
36
64
  const session = useChatSession({
65
+ session: muSession,
37
66
  config,
38
67
  currentModel: models.currentModel,
39
68
  attachment,
40
69
  controllerRef,
41
70
  initialMessages,
42
71
  registry,
72
+ messageBus,
73
+ sessionPathHolder,
43
74
  });
44
75
  const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
45
76
 
@@ -68,11 +99,15 @@ export function useChat(
68
99
  return {
69
100
  config,
70
101
  session,
102
+ sessionManager,
71
103
  toggles,
72
104
  attachment,
73
105
  models,
74
106
  abort,
75
107
  sessions,
76
108
  registry,
109
+ uiService,
110
+ messageBus,
111
+ subagentRuns,
77
112
  };
78
113
  }