mu-coding 0.4.0 → 0.8.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 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -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 +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- 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 +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -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/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- 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 +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /package/src/{diff.ts → utils/diff.ts} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { PluginRegistry } from 'mu-core';
|
|
6
|
+
import type { AppConfig } from '../config/index';
|
|
7
|
+
import { InkUIService } from '../tui/plugins/InkUIService';
|
|
8
|
+
import { createRegistry } from './createRegistry';
|
|
9
|
+
|
|
10
|
+
// ─── renderApp mock ───────────────────────────────────────────────────────────
|
|
11
|
+
// Captures the options renderApp is called with so tests can assert what the
|
|
12
|
+
// TUI actually receives — specifically, that the concrete PluginRegistry (with
|
|
13
|
+
// subscription methods) is passed rather than the narrow PluginRegistryView.
|
|
14
|
+
// This mock must be declared before any test imports createRegistry so Bun's
|
|
15
|
+
// module mock intercepts the import in the plugin chain.
|
|
16
|
+
|
|
17
|
+
const capturedRenderArgs: Array<{ registry: PluginRegistry }> = [];
|
|
18
|
+
const noop = (): void => {
|
|
19
|
+
/* stub */
|
|
20
|
+
};
|
|
21
|
+
mock.module('../tui/renderApp', () => ({
|
|
22
|
+
renderApp: (opts: { registry: PluginRegistry }) => {
|
|
23
|
+
capturedRenderArgs.push(opts);
|
|
24
|
+
return {
|
|
25
|
+
unmount: noop,
|
|
26
|
+
waitUntilExit: async (): Promise<void> => {
|
|
27
|
+
/* stub */
|
|
28
|
+
},
|
|
29
|
+
rerender: noop,
|
|
30
|
+
cleanup: noop,
|
|
31
|
+
clear: noop,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function fakeConfig(): AppConfig {
|
|
39
|
+
return {
|
|
40
|
+
baseUrl: 'http://localhost:0',
|
|
41
|
+
model: 'test-model',
|
|
42
|
+
maxTokens: 1024,
|
|
43
|
+
temperature: 0.7,
|
|
44
|
+
streamTimeoutMs: 10_000,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Methods the TUI subscribes to at runtime (not part of PluginRegistryView). */
|
|
49
|
+
const TUI_REGISTRY_METHODS = [
|
|
50
|
+
'onStatusChange',
|
|
51
|
+
'getStatusSegments',
|
|
52
|
+
'onRenderersChange',
|
|
53
|
+
'getRenderers',
|
|
54
|
+
'onShortcutsChange',
|
|
55
|
+
'getShortcuts',
|
|
56
|
+
'getCommands',
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe('createRegistry — activation order + plugin propagation', () => {
|
|
62
|
+
it('registers builtins so coding-agents see ctx.agents', async () => {
|
|
63
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mu-cr-'));
|
|
64
|
+
try {
|
|
65
|
+
const ui = new InkUIService();
|
|
66
|
+
const { registry, channels, providers } = await createRegistry({
|
|
67
|
+
cwd,
|
|
68
|
+
config: fakeConfig(),
|
|
69
|
+
uiService: ui,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Provider registered.
|
|
73
|
+
expect(providers.list().some((p) => p.id === 'openai')).toBe(true);
|
|
74
|
+
|
|
75
|
+
// All builtin plugins loaded in the correct order.
|
|
76
|
+
const names = registry.getPlugins().map((p) => p.name);
|
|
77
|
+
expect(names).toContain('mu-openai-provider');
|
|
78
|
+
expect(names).toContain('mu-agents');
|
|
79
|
+
expect(names).toContain('mu-coding');
|
|
80
|
+
// mu-coding-agents is opt-in via `config.plugins`, not auto-registered.
|
|
81
|
+
expect(names).not.toContain('mu-coding-agents');
|
|
82
|
+
|
|
83
|
+
// TUI channel registered.
|
|
84
|
+
expect(channels.list().map((c) => c.id)).toContain('tui');
|
|
85
|
+
|
|
86
|
+
// mu-agents exposes its approval gateway publicly.
|
|
87
|
+
interface GatewayBearer {
|
|
88
|
+
approvalGateway?: { registerChannel: unknown };
|
|
89
|
+
}
|
|
90
|
+
const agent = registry.getPlugin<GatewayBearer & { name: string; [k: string]: unknown }>('mu-agents');
|
|
91
|
+
expect(agent?.approvalGateway).toBeDefined();
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('TUI channel receives concrete PluginRegistry (not the narrow View)', async () => {
|
|
98
|
+
// This test is the REAL regression guard: it calls channels.startAll()
|
|
99
|
+
// which invokes tuiChannel.start() → renderApp(opts). The mock above
|
|
100
|
+
// captures `opts.registry`. If createCodingPlugin ever reverts to passing
|
|
101
|
+
// `ctx.registry` (the narrow View), the TUI methods below will be absent
|
|
102
|
+
// and the test fails — exactly mirroring the runtime crash that would occur.
|
|
103
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mu-cr-'));
|
|
104
|
+
try {
|
|
105
|
+
const ui = new InkUIService();
|
|
106
|
+
capturedRenderArgs.length = 0;
|
|
107
|
+
const { channels } = await createRegistry({
|
|
108
|
+
cwd,
|
|
109
|
+
config: fakeConfig(),
|
|
110
|
+
uiService: ui,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await channels.startAll();
|
|
114
|
+
await channels.stopAll();
|
|
115
|
+
|
|
116
|
+
expect(capturedRenderArgs).toHaveLength(1);
|
|
117
|
+
const reg = capturedRenderArgs[0].registry as unknown as Record<string, unknown>;
|
|
118
|
+
for (const method of TUI_REGISTRY_METHODS) {
|
|
119
|
+
expect(typeof reg[method], `registry.${method} should be a function`).toBe('function');
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
capturedRenderArgs.length = 0;
|
|
123
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('agent slash command is contributed by mu-agents (no coding-agents by default)', async () => {
|
|
128
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mu-cr-'));
|
|
129
|
+
try {
|
|
130
|
+
const ui = new InkUIService();
|
|
131
|
+
const { registry } = await createRegistry({
|
|
132
|
+
cwd,
|
|
133
|
+
config: fakeConfig(),
|
|
134
|
+
uiService: ui,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const commandNames = registry.getCommands().map((c) => c.name);
|
|
138
|
+
expect(commandNames).toContain('agent');
|
|
139
|
+
// build/plan/review come from mu-agents' DEFAULT_PRIMARY_AGENTS.
|
|
140
|
+
// `explore` originates from mu-coding-agents, which is no longer auto-loaded.
|
|
141
|
+
expect(commandNames).not.toContain('explore');
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createAgentsPlugin } from 'mu-agents';
|
|
2
|
+
import type { ChatMessage } from 'mu-core';
|
|
3
|
+
import {
|
|
4
|
+
type ActivityBus,
|
|
5
|
+
type ChannelRegistry,
|
|
6
|
+
createActivityBus,
|
|
7
|
+
createChannelRegistry,
|
|
8
|
+
createProviderRegistry,
|
|
9
|
+
PluginRegistry,
|
|
10
|
+
type ProviderRegistry,
|
|
11
|
+
} from 'mu-core';
|
|
12
|
+
import { createOpenAIProviderPlugin } from 'mu-openai-provider';
|
|
13
|
+
import type { ShutdownFn } from '../app/shutdown';
|
|
14
|
+
import type { AppConfig } from '../config/index';
|
|
15
|
+
import { createCodingPlugin } from '../plugin';
|
|
16
|
+
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
17
|
+
import { createMessageBus, type HostMessageBus } from './messageBus';
|
|
18
|
+
import { discoverPluginFiles, loadConfiguredPlugin } from './pluginLoader';
|
|
19
|
+
|
|
20
|
+
interface CreateRegistryOptions {
|
|
21
|
+
cwd: string;
|
|
22
|
+
config: AppConfig;
|
|
23
|
+
uiService: InkUIService;
|
|
24
|
+
/**
|
|
25
|
+
* Initial transcript injected into the TUI's session (e.g. resumed from
|
|
26
|
+
* disk via `mu -c`). Threaded through to the coding plugin's TUI channel.
|
|
27
|
+
*/
|
|
28
|
+
initialMessages?: ChatMessage[];
|
|
29
|
+
/**
|
|
30
|
+
* Host shutdown is forwarded to plugins via PluginContext so a plugin
|
|
31
|
+
* calling shutdown gets the same graceful path as Ctrl+C — terminal
|
|
32
|
+
* restored, plugins deactivated.
|
|
33
|
+
*
|
|
34
|
+
* Optional because some callers (tests, single-shot) don't have one.
|
|
35
|
+
*/
|
|
36
|
+
shutdown?: ShutdownFn;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RegistryBundle {
|
|
40
|
+
registry: PluginRegistry;
|
|
41
|
+
messageBus: HostMessageBus;
|
|
42
|
+
providers: ProviderRegistry;
|
|
43
|
+
channels: ChannelRegistry;
|
|
44
|
+
activity: ActivityBus;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PluginConfigInputs {
|
|
48
|
+
uiService: InkUIService;
|
|
49
|
+
shutdown: ShutdownFn | undefined;
|
|
50
|
+
appConfig: AppConfig;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the plugin config object passed into a plugin's factory.
|
|
55
|
+
* Every plugin (configured or locally discovered) receives:
|
|
56
|
+
* - `ui` — UIService for dialogs/toasts/status
|
|
57
|
+
* - `shutdown` — graceful shutdown hook (when available)
|
|
58
|
+
* - `config` — the host's ProviderConfig snapshot (baseUrl, model, …) so
|
|
59
|
+
* plugins that need to call the LLM (e.g. subagents) don't have to be
|
|
60
|
+
* re-configured manually
|
|
61
|
+
* - `model` — the host's currently configured model id
|
|
62
|
+
*/
|
|
63
|
+
function buildPluginConfig(inputs: PluginConfigInputs, base?: Record<string, unknown>): Record<string, unknown> {
|
|
64
|
+
const merged: Record<string, unknown> = base ? { ...base } : {};
|
|
65
|
+
merged.ui = inputs.uiService;
|
|
66
|
+
if (inputs.shutdown) merged.shutdown = inputs.shutdown;
|
|
67
|
+
// Forward provider info so plugins can re-issue LLM calls (e.g. subagents)
|
|
68
|
+
// without forcing users to duplicate `baseUrl`/`model` in plugin config.
|
|
69
|
+
if (!('config' in merged)) merged.config = inputs.appConfig;
|
|
70
|
+
if (!('model' in merged) && inputs.appConfig.model) merged.model = inputs.appConfig.model;
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fallback shutdown used when the host (typically tests) doesn't supply one.
|
|
76
|
+
* Logs a warning when a plugin actually invokes it: production hosts always
|
|
77
|
+
* pass the real `registerShutdown(...)` handle, so an invocation here means
|
|
78
|
+
* either the test setup forgot to mock the plugin's shutdown call or a
|
|
79
|
+
* plugin is reaching for `ctx.shutdown()` in an environment that can't honour
|
|
80
|
+
* it. Resolves cleanly so the calling plugin sees no behavioural change.
|
|
81
|
+
*/
|
|
82
|
+
async function noopShutdown(code?: number): Promise<void> {
|
|
83
|
+
console.warn(`[mu-coding] noopShutdown invoked (code=${code ?? 0}); host did not register a real shutdown handler.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wire mu-coding's standard plugin set:
|
|
88
|
+
* 1. mu-openai-provider — registers the OpenAI streaming provider
|
|
89
|
+
* 2. mu-agents — agent switcher + permissions + approval gateway
|
|
90
|
+
* 3. mu-coding — coding tools + TUI channel + Ink approval channel
|
|
91
|
+
* (registered against mu-agents' gateway)
|
|
92
|
+
*
|
|
93
|
+
* Order matters: mu-coding must activate *after* mu-agents so the approval
|
|
94
|
+
* channel finds the gateway via `ctx.getPlugin('mu-agents')`.
|
|
95
|
+
*
|
|
96
|
+
* Optional plugins (mu-coding-agents, mu-repomap, …) are opt-in via
|
|
97
|
+
* `config.plugins` and loaded below by `loadConfiguredPlugin`.
|
|
98
|
+
*/
|
|
99
|
+
async function registerBuiltins(
|
|
100
|
+
registry: PluginRegistry,
|
|
101
|
+
options: CreateRegistryOptions,
|
|
102
|
+
inputs: PluginConfigInputs,
|
|
103
|
+
messageBus: HostMessageBus,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
await registry.register(createOpenAIProviderPlugin());
|
|
106
|
+
await registry.register(
|
|
107
|
+
createAgentsPlugin({
|
|
108
|
+
config: options.config,
|
|
109
|
+
model: options.config.model,
|
|
110
|
+
approvalChannelId: 'tui',
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
await registry.register(
|
|
114
|
+
createCodingPlugin({
|
|
115
|
+
appConfig: options.config,
|
|
116
|
+
initialMessages: options.initialMessages,
|
|
117
|
+
messageBus,
|
|
118
|
+
uiService: options.uiService,
|
|
119
|
+
shutdown: options.shutdown ?? noopShutdown,
|
|
120
|
+
// Pass the concrete registry: the TUI subscribes to renderer / shortcut
|
|
121
|
+
// / status streams that are not part of the narrow `PluginRegistryView`
|
|
122
|
+
// exposed via `ctx.registry`.
|
|
123
|
+
registry,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
// Silence unused-input warning when no configured/local plugins exist.
|
|
127
|
+
void inputs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function createRegistry(options: CreateRegistryOptions): Promise<RegistryBundle> {
|
|
131
|
+
const { cwd, config, uiService, shutdown } = options;
|
|
132
|
+
const messageBus = createMessageBus();
|
|
133
|
+
const providers = createProviderRegistry();
|
|
134
|
+
const channels = createChannelRegistry();
|
|
135
|
+
const activity = createActivityBus();
|
|
136
|
+
const registry = new PluginRegistry({
|
|
137
|
+
cwd,
|
|
138
|
+
config: {},
|
|
139
|
+
ui: uiService,
|
|
140
|
+
shutdown,
|
|
141
|
+
messages: messageBus,
|
|
142
|
+
providers,
|
|
143
|
+
channels,
|
|
144
|
+
activity,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const inputs: PluginConfigInputs = { uiService, shutdown, appConfig: config };
|
|
148
|
+
|
|
149
|
+
await registerBuiltins(registry, options, inputs, messageBus);
|
|
150
|
+
|
|
151
|
+
// User-extension plugins ride on top of the builtins.
|
|
152
|
+
for (const filePath of discoverPluginFiles()) {
|
|
153
|
+
await loadConfiguredPlugin(registry, filePath, buildPluginConfig(inputs), uiService);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const entry of config.plugins ?? []) {
|
|
157
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
158
|
+
const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
|
|
159
|
+
await loadConfiguredPlugin(registry, name, buildPluginConfig(inputs, pluginConfig), uiService);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { registry, messageBus, providers, channels, activity };
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import type { Plugin, PluginRegistry } from 'mu-core';
|
|
5
|
+
import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
|
|
6
|
+
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
7
|
+
|
|
8
|
+
export function discoverPluginFiles(): string[] {
|
|
9
|
+
const dir = getPluginsDir();
|
|
10
|
+
try {
|
|
11
|
+
return readdirSync(dir)
|
|
12
|
+
.filter((f) => f.endsWith('.ts'))
|
|
13
|
+
.map((f) => join(dir, f));
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatPluginError(name: string, err: unknown): string {
|
|
20
|
+
const parts: string[] = [`Plugin "${name}" failed`];
|
|
21
|
+
let current: unknown = err;
|
|
22
|
+
while (current) {
|
|
23
|
+
if (current instanceof Error) {
|
|
24
|
+
parts.push(current.message);
|
|
25
|
+
current = current.cause;
|
|
26
|
+
} else {
|
|
27
|
+
parts.push(String(current));
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return parts.join(': ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveNpmPlugin(specifier: string): string {
|
|
35
|
+
const { name } = parseBareNpmSpec(specifier.slice(4));
|
|
36
|
+
const dataDir = getDataDir();
|
|
37
|
+
try {
|
|
38
|
+
const require = createRequire(resolve(dataDir, 'package.json'));
|
|
39
|
+
return require.resolve(name);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(`Cannot resolve "${name}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPluginShape(value: unknown): value is Plugin {
|
|
46
|
+
return typeof value === 'object' && value !== null && 'name' in value && typeof (value as Plugin).name === 'string';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract a plugin from a loaded module. Tries (in order):
|
|
51
|
+
* 1. `module.default` as a factory function
|
|
52
|
+
* 2. `module.createPlugin` as a factory function
|
|
53
|
+
* 3. `module.default` as a Plugin object
|
|
54
|
+
* 4. `module` as a Plugin object
|
|
55
|
+
*
|
|
56
|
+
* Returns `null` if no plugin shape matches; the caller should report this
|
|
57
|
+
* with the list of available exports for debugging.
|
|
58
|
+
*/
|
|
59
|
+
function extractPlugin(mod: Record<string, unknown>, pluginConfig: Record<string, unknown>): Plugin | null {
|
|
60
|
+
const factory = (mod.default ?? mod.createPlugin) as unknown;
|
|
61
|
+
if (typeof factory === 'function') {
|
|
62
|
+
const result = (factory as (cfg: Record<string, unknown>) => unknown)(pluginConfig);
|
|
63
|
+
return isPluginShape(result) ? result : null;
|
|
64
|
+
}
|
|
65
|
+
if (isPluginShape(mod.default)) {
|
|
66
|
+
return mod.default;
|
|
67
|
+
}
|
|
68
|
+
if (isPluginShape(mod)) {
|
|
69
|
+
return mod;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Loader variant that *resolves* (imports + extracts) a plugin without
|
|
76
|
+
* registering it. Used by hosts driving `startMu({ resolvePlugin })`. Errors
|
|
77
|
+
* surface via `uiService` (or are swallowed when omitted) so the host's
|
|
78
|
+
* boot log behaviour matches `loadConfiguredPlugin`.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveConfiguredPlugin(
|
|
81
|
+
name: string,
|
|
82
|
+
pluginConfig?: Record<string, unknown>,
|
|
83
|
+
uiService?: InkUIService,
|
|
84
|
+
): Promise<Plugin | null> {
|
|
85
|
+
const config = pluginConfig ?? {};
|
|
86
|
+
let target: string;
|
|
87
|
+
try {
|
|
88
|
+
target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
let mod: Record<string, unknown>;
|
|
94
|
+
try {
|
|
95
|
+
mod = (await import(target)) as Record<string, unknown>;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const plugin = extractPlugin(mod, config);
|
|
101
|
+
if (!plugin) {
|
|
102
|
+
const exportKeys = Object.keys(mod).join(', ') || '(none)';
|
|
103
|
+
uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return plugin;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function loadConfiguredPlugin(
|
|
110
|
+
registry: PluginRegistry,
|
|
111
|
+
name: string,
|
|
112
|
+
pluginConfig?: Record<string, unknown>,
|
|
113
|
+
uiService?: InkUIService,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const plugin = await resolveConfiguredPlugin(name, pluginConfig, uiService);
|
|
116
|
+
if (!plugin) return;
|
|
117
|
+
try {
|
|
118
|
+
await registry.register(plugin);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { clearSessionCache, listSessionsAsync, loadSession, saveSession } from './index';
|
|
6
|
+
|
|
7
|
+
let tmpRoot: string;
|
|
8
|
+
let originalDataHome: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'mu-sessions-'));
|
|
12
|
+
originalDataHome = process.env.XDG_DATA_HOME;
|
|
13
|
+
process.env.XDG_DATA_HOME = tmpRoot;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
if (originalDataHome === undefined) {
|
|
18
|
+
delete process.env.XDG_DATA_HOME;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.XDG_DATA_HOME = originalDataHome;
|
|
21
|
+
}
|
|
22
|
+
clearSessionCache();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('saveSession / loadSession', () => {
|
|
26
|
+
it('round-trips messages as JSONL', async () => {
|
|
27
|
+
const path = join(tmpRoot, 'roundtrip.jsonl');
|
|
28
|
+
const messages = [
|
|
29
|
+
{ role: 'user' as const, content: 'hi' },
|
|
30
|
+
{ role: 'assistant' as const, content: 'hello' },
|
|
31
|
+
];
|
|
32
|
+
await saveSession(path, messages);
|
|
33
|
+
|
|
34
|
+
const raw = readFileSync(path, 'utf-8');
|
|
35
|
+
expect(raw.split('\n').filter(Boolean)).toHaveLength(2);
|
|
36
|
+
|
|
37
|
+
const loaded = loadSession(path);
|
|
38
|
+
expect(loaded).toEqual(messages);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns [] for missing files', () => {
|
|
42
|
+
expect(loadSession(join(tmpRoot, 'does-not-exist.jsonl'))).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('skips malformed JSONL lines', () => {
|
|
46
|
+
const path = join(tmpRoot, 'partial.jsonl');
|
|
47
|
+
writeFileSync(path, '{"role":"user","content":"ok"}\n{not json}\n{"role":"assistant","content":"yo"}\n');
|
|
48
|
+
const loaded = loadSession(path);
|
|
49
|
+
expect(loaded).toHaveLength(2);
|
|
50
|
+
expect(loaded[0].role).toBe('user');
|
|
51
|
+
expect(loaded[1].role).toBe('assistant');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('writes empty content for empty arrays', async () => {
|
|
55
|
+
const path = join(tmpRoot, 'empty.jsonl');
|
|
56
|
+
await saveSession(path, []);
|
|
57
|
+
expect(readFileSync(path, 'utf-8')).toBe('');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('listSessionsAsync', () => {
|
|
62
|
+
it('returns [] when no project sessions exist', async () => {
|
|
63
|
+
const list = await listSessionsAsync();
|
|
64
|
+
expect(Array.isArray(list)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|