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.
- package/README.md +49 -3
- package/package.json +9 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +1 -1
- package/src/app/startApp.ts +11 -8
- package/src/cli/args.ts +14 -11
- package/src/cli/install.ts +18 -3
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +124 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +147 -0
- package/src/runtime/createRegistry.ts +160 -23
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +59 -15
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +62 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +38 -3
- package/src/tui/chat/useChatPanel.ts +29 -6
- package/src/tui/chat/useChatSession.ts +324 -57
- package/src/tui/chat/useModels.ts +26 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +48 -21
- package/src/tui/chat/useStatusSegments.ts +38 -5
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +25 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +17 -9
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +8 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +55 -7
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +10 -6
- package/src/tui/components/messages/streamingOutput.tsx +6 -2
- package/src/tui/components/messages/toolCallBlock.tsx +7 -6
- package/src/tui/components/messages/userMessage.tsx +22 -7
- package/src/tui/components/primitives/dropdown.tsx +8 -4
- package/src/tui/components/primitives/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +3 -1
- package/src/tui/components/primitives/toast.tsx +43 -10
- package/src/tui/components/statusBar.tsx +26 -10
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +191 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +11 -1
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +1 -1
- package/src/tui/input/useCommandExecutor.ts +1 -1
- package/src/tui/input/useInputBox.ts +160 -15
- package/src/tui/input/useInputHandler.ts +317 -126
- package/src/tui/input/useMentionPicker.ts +133 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +47 -13
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +90 -0
- package/src/tui/theme/types.ts +138 -0
- package/src/utils/clipboard.ts +1 -1
- 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-
|
|
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
|
-
|
|
35
|
-
|
|
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 (
|
|
41
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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<
|
|
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) {
|
package/src/sessions/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createReadStream, mkdirSync, readdirSync, readFileSync
|
|
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-
|
|
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
|
+
}
|
package/src/tui/chat/useAbort.ts
CHANGED
|
@@ -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);
|
package/src/tui/chat/useChat.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { useApp } from 'ink';
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
|
|
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
|
}
|