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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mu-coding's filesystem + shell tools, packaged as a plugin. Replaces the
|
|
3
|
+
* legacy `createBuiltinPlugin` that lived in mu-core. Tools declare a
|
|
4
|
+
* `permission.matchKey` so agent definitions can authorise them via globs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Plugin } from 'mu-core';
|
|
8
|
+
import { createBashTool } from './bash';
|
|
9
|
+
import { createEditFileTool } from './edit-file';
|
|
10
|
+
import { createReadFileTool } from './read-file';
|
|
11
|
+
import { createWriteFileTool } from './write-file';
|
|
12
|
+
|
|
13
|
+
export function createCodingToolsPlugin(): Plugin {
|
|
14
|
+
let pluginCwd: string | undefined;
|
|
15
|
+
const getCwd = (): string => pluginCwd ?? process.cwd();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
name: 'mu-coding-tools',
|
|
19
|
+
version: '0.5.0',
|
|
20
|
+
tools: [
|
|
21
|
+
createReadFileTool(getCwd),
|
|
22
|
+
createWriteFileTool(getCwd),
|
|
23
|
+
createEditFileTool(getCwd),
|
|
24
|
+
createBashTool(getCwd),
|
|
25
|
+
],
|
|
26
|
+
systemPrompt: [
|
|
27
|
+
'File & shell tools:',
|
|
28
|
+
'- Prefer `read` over `cat`/`sed`; pass `start`/`end` for large files.',
|
|
29
|
+
'- Use `edit` for surgical changes; include enough context in `from` to be unique. One `edit` call per change site.',
|
|
30
|
+
'- Use `write` only for new files or full rewrites.',
|
|
31
|
+
'- Use `bash` for ops without a dedicated tool (ls, rg, build, tests). Avoid using it to read or rewrite files.',
|
|
32
|
+
].join('\n'),
|
|
33
|
+
activate(ctx) {
|
|
34
|
+
pluginCwd = ctx.cwd;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { createBashTool, createEditFileTool, createReadFileTool, createWriteFileTool };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import type { PluginTool } from 'mu-core';
|
|
3
|
+
import { sanitizePath } from './utils';
|
|
4
|
+
|
|
5
|
+
function executeReadFileSingle(rawPath: string, cwd: string, start?: number, end?: number): string {
|
|
6
|
+
const path = sanitizePath(rawPath, cwd);
|
|
7
|
+
if (!existsSync(path)) {
|
|
8
|
+
return `Error: File not found: ${path}`;
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const content = readFileSync(path, 'utf-8');
|
|
12
|
+
const allLines = content.split('\n');
|
|
13
|
+
const totalLines = allLines.length;
|
|
14
|
+
|
|
15
|
+
const startLine = Math.max(1, start ?? 1);
|
|
16
|
+
const endLine = end ?? totalLines;
|
|
17
|
+
const clampedStart = Math.min(startLine, totalLines);
|
|
18
|
+
const clampedEnd = Math.min(endLine, totalLines);
|
|
19
|
+
|
|
20
|
+
if (clampedStart > clampedEnd) {
|
|
21
|
+
return `Error: start (${startLine}) > end (${endLine})`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = allLines.slice(clampedStart - 1, clampedEnd);
|
|
25
|
+
const numbered = lines.map((line, i) => `${String(clampedStart + i).padStart(4)} │ ${line}`).join('\n');
|
|
26
|
+
const rangeLabel = start ? ` (lines ${clampedStart}-${clampedEnd})` : '';
|
|
27
|
+
const header = `── ${path}${rangeLabel} (${lines.length} lines) ──`;
|
|
28
|
+
return `${header}\n${numbered}`;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return `Error: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createReadFileTool(getCwd: () => string): PluginTool {
|
|
35
|
+
return {
|
|
36
|
+
definition: {
|
|
37
|
+
type: 'function',
|
|
38
|
+
function: {
|
|
39
|
+
name: 'read',
|
|
40
|
+
description: 'Read text file(s) with line numbers. `path` may be a single path or array.',
|
|
41
|
+
parameters: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
path: { type: ['string', 'array'], items: { type: 'string' } },
|
|
45
|
+
start: { type: 'integer', description: '1-indexed first line, inclusive.' },
|
|
46
|
+
end: { type: 'integer', description: '1-indexed last line, inclusive.' },
|
|
47
|
+
},
|
|
48
|
+
required: ['path'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
display: {
|
|
54
|
+
verb: 'reading',
|
|
55
|
+
kind: 'file-read',
|
|
56
|
+
fields: { path: 'path', start: 'start', end: 'end' },
|
|
57
|
+
},
|
|
58
|
+
permission: {
|
|
59
|
+
matchKey: (args) => {
|
|
60
|
+
const p = args.path;
|
|
61
|
+
if (typeof p === 'string') return p;
|
|
62
|
+
if (Array.isArray(p)) return p[0] as string | undefined;
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
execute(args) {
|
|
67
|
+
const paths = Array.isArray(args.path) ? (args.path as string[]) : [args.path as string];
|
|
68
|
+
const start = args.start as number | undefined;
|
|
69
|
+
const end = args.end as number | undefined;
|
|
70
|
+
const cwd = getCwd();
|
|
71
|
+
|
|
72
|
+
if (paths.length === 1) {
|
|
73
|
+
return executeReadFileSingle(paths[0], cwd, start, end);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const results: string[] = [];
|
|
77
|
+
for (const path of paths) {
|
|
78
|
+
results.push(executeReadFileSingle(path, cwd, start, end));
|
|
79
|
+
}
|
|
80
|
+
return results.join('\n\n');
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a file path from LLM arguments.
|
|
5
|
+
* Local models often wrap paths in extra quotes or add whitespace.
|
|
6
|
+
*
|
|
7
|
+
* When `cwd` is supplied, relative paths are resolved against it instead of
|
|
8
|
+
* `process.cwd()` — this lets the agent operate on a different working
|
|
9
|
+
* directory than the host process (the host passes its `PluginContext.cwd`
|
|
10
|
+
* into the builtin plugin factory at activation time).
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizePath(raw: string, cwd?: string): string {
|
|
13
|
+
let p = raw.trim();
|
|
14
|
+
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
|
|
15
|
+
p = p.slice(1, -1).trim();
|
|
16
|
+
}
|
|
17
|
+
if (cwd && !isAbsolute(p)) {
|
|
18
|
+
return resolve(cwd, p);
|
|
19
|
+
}
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import type { PluginTool } from 'mu-core';
|
|
3
|
+
import { sanitizePath } from './utils';
|
|
4
|
+
|
|
5
|
+
export function createWriteFileTool(getCwd: () => string): PluginTool {
|
|
6
|
+
return {
|
|
7
|
+
definition: {
|
|
8
|
+
type: 'function',
|
|
9
|
+
function: {
|
|
10
|
+
name: 'write',
|
|
11
|
+
description: 'Create or overwrite a file. Use `edit` for partial changes to existing files.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string' },
|
|
16
|
+
content: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
required: ['path', 'content'],
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
display: {
|
|
24
|
+
verb: 'writing',
|
|
25
|
+
kind: 'file-write',
|
|
26
|
+
fields: { path: 'path', content: 'content' },
|
|
27
|
+
},
|
|
28
|
+
permission: {
|
|
29
|
+
matchKey: (args) => args.path as string | undefined,
|
|
30
|
+
},
|
|
31
|
+
execute(args) {
|
|
32
|
+
const path = sanitizePath(args.path as string, getCwd());
|
|
33
|
+
const content = args.content as string;
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(path, content, 'utf-8');
|
|
36
|
+
return { content: `File written: ${path}` };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, error: true };
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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('mu-agents contributes per-primary-agent slash commands but no generic /agent', 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
|
+
// The generic `/agent` command was removed; discoverability is via
|
|
139
|
+
// the per-agent switch commands and the Tab shortcut.
|
|
140
|
+
expect(commandNames).not.toContain('agent');
|
|
141
|
+
// `explore` originates from mu-coding-agents, which is no longer auto-loaded.
|
|
142
|
+
expect(commandNames).not.toContain('explore');
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -1,58 +1,195 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { createAgentsPlugin } from 'mu-agents';
|
|
4
|
+
import type { ChatMessage } from 'mu-core';
|
|
5
|
+
import {
|
|
6
|
+
type ActivityBus,
|
|
7
|
+
type ChannelRegistry,
|
|
8
|
+
createActivityBus,
|
|
9
|
+
createChannelRegistry,
|
|
10
|
+
createProviderRegistry,
|
|
11
|
+
PluginRegistry,
|
|
12
|
+
type ProviderRegistry,
|
|
13
|
+
} from 'mu-core';
|
|
14
|
+
import { createOpenAIProviderPlugin } from 'mu-openai-provider';
|
|
2
15
|
import type { ShutdownFn } from '../app/shutdown';
|
|
3
16
|
import type { AppConfig } from '../config/index';
|
|
17
|
+
import { createCodingPlugin } from '../plugin';
|
|
18
|
+
import { saveSession } from '../sessions/index';
|
|
4
19
|
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
20
|
+
import { createMessageBus, type HostMessageBus } from './messageBus';
|
|
5
21
|
import { discoverPluginFiles, loadConfiguredPlugin } from './pluginLoader';
|
|
6
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Tiny mutable holder used to bridge the React-owned current session path
|
|
25
|
+
* (`useSessionPersistence`) with plugins that need it at construction time
|
|
26
|
+
* (here, mu-agents — to derive a sibling directory for subagent runs).
|
|
27
|
+
*
|
|
28
|
+
* The holder is created up-front and passed both to `mu-agents` (read via
|
|
29
|
+
* `getParentSessionPath`) and to the TUI channel (which forwards it into
|
|
30
|
+
* the React tree where `useSessionPersistence` keeps it in sync).
|
|
31
|
+
*/
|
|
32
|
+
export interface SessionPathHolder {
|
|
33
|
+
current: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createSessionPathHolder(): SessionPathHolder {
|
|
37
|
+
return { current: undefined };
|
|
38
|
+
}
|
|
39
|
+
|
|
7
40
|
interface CreateRegistryOptions {
|
|
8
41
|
cwd: string;
|
|
9
42
|
config: AppConfig;
|
|
10
43
|
uiService: InkUIService;
|
|
11
44
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
45
|
+
* Initial transcript injected into the TUI's session (e.g. resumed from
|
|
46
|
+
* disk via `mu -c`). Threaded through to the coding plugin's TUI channel.
|
|
47
|
+
*/
|
|
48
|
+
initialMessages?: ChatMessage[];
|
|
49
|
+
/**
|
|
50
|
+
* Host shutdown is forwarded to plugins via PluginContext so a plugin
|
|
51
|
+
* calling shutdown gets the same graceful path as Ctrl+C — terminal
|
|
52
|
+
* restored, plugins deactivated.
|
|
15
53
|
*
|
|
16
54
|
* Optional because some callers (tests, single-shot) don't have one.
|
|
17
55
|
*/
|
|
18
56
|
shutdown?: ShutdownFn;
|
|
19
57
|
}
|
|
20
58
|
|
|
59
|
+
interface RegistryBundle {
|
|
60
|
+
registry: PluginRegistry;
|
|
61
|
+
messageBus: HostMessageBus;
|
|
62
|
+
providers: ProviderRegistry;
|
|
63
|
+
channels: ChannelRegistry;
|
|
64
|
+
activity: ActivityBus;
|
|
65
|
+
sessionPathHolder: SessionPathHolder;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PluginConfigInputs {
|
|
69
|
+
uiService: InkUIService;
|
|
70
|
+
shutdown: ShutdownFn | undefined;
|
|
71
|
+
appConfig: AppConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
/**
|
|
22
75
|
* Build the plugin config object passed into a plugin's factory.
|
|
23
|
-
* Every plugin (configured or locally discovered)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
76
|
+
* Every plugin (configured or locally discovered) receives:
|
|
77
|
+
* - `ui` — UIService for dialogs/toasts/status
|
|
78
|
+
* - `shutdown` — graceful shutdown hook (when available)
|
|
79
|
+
* - `config` — the host's ProviderConfig snapshot (baseUrl, model, …) so
|
|
80
|
+
* plugins that need to call the LLM (e.g. subagents) don't have to be
|
|
81
|
+
* re-configured manually
|
|
82
|
+
* - `model` — the host's currently configured model id
|
|
26
83
|
*/
|
|
27
|
-
function buildPluginConfig(
|
|
28
|
-
uiService: InkUIService,
|
|
29
|
-
shutdown: ShutdownFn | undefined,
|
|
30
|
-
base?: Record<string, unknown>,
|
|
31
|
-
): Record<string, unknown> {
|
|
84
|
+
function buildPluginConfig(inputs: PluginConfigInputs, base?: Record<string, unknown>): Record<string, unknown> {
|
|
32
85
|
const merged: Record<string, unknown> = base ? { ...base } : {};
|
|
33
|
-
merged.ui = uiService;
|
|
34
|
-
if (shutdown) merged.shutdown = shutdown;
|
|
86
|
+
merged.ui = inputs.uiService;
|
|
87
|
+
if (inputs.shutdown) merged.shutdown = inputs.shutdown;
|
|
88
|
+
// Forward provider info so plugins can re-issue LLM calls (e.g. subagents)
|
|
89
|
+
// without forcing users to duplicate `baseUrl`/`model` in plugin config.
|
|
90
|
+
if (!('config' in merged)) merged.config = inputs.appConfig;
|
|
91
|
+
if (!('model' in merged) && inputs.appConfig.model) merged.model = inputs.appConfig.model;
|
|
35
92
|
return merged;
|
|
36
93
|
}
|
|
37
94
|
|
|
38
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Fallback shutdown used when the host (typically tests) doesn't supply one.
|
|
97
|
+
* Logs a warning when a plugin actually invokes it: production hosts always
|
|
98
|
+
* pass the real `registerShutdown(...)` handle, so an invocation here means
|
|
99
|
+
* either the test setup forgot to mock the plugin's shutdown call or a
|
|
100
|
+
* plugin is reaching for `ctx.shutdown()` in an environment that can't honour
|
|
101
|
+
* it. Resolves cleanly so the calling plugin sees no behavioural change.
|
|
102
|
+
*/
|
|
103
|
+
async function noopShutdown(code?: number): Promise<void> {
|
|
104
|
+
console.warn(`[mu-coding] noopShutdown invoked (code=${code ?? 0}); host did not register a real shutdown handler.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wire mu-coding's standard plugin set:
|
|
109
|
+
* 1. mu-openai-provider — registers the OpenAI streaming provider
|
|
110
|
+
* 2. mu-agents — agent switcher + permissions + approval gateway
|
|
111
|
+
* 3. mu-coding — coding tools + TUI channel + Ink approval channel
|
|
112
|
+
* (registered against mu-agents' gateway)
|
|
113
|
+
*
|
|
114
|
+
* Order matters: mu-coding must activate *after* mu-agents so the approval
|
|
115
|
+
* channel finds the gateway via `ctx.getPlugin('mu-agents')`.
|
|
116
|
+
*
|
|
117
|
+
* Optional plugins (mu-coding-agents, mu-repomap, …) are opt-in via
|
|
118
|
+
* `config.plugins` and loaded below by `loadConfiguredPlugin`.
|
|
119
|
+
*/
|
|
120
|
+
async function registerBuiltins(
|
|
121
|
+
registry: PluginRegistry,
|
|
122
|
+
options: CreateRegistryOptions,
|
|
123
|
+
inputs: PluginConfigInputs,
|
|
124
|
+
messageBus: HostMessageBus,
|
|
125
|
+
sessionPathHolder: SessionPathHolder,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
await registry.register(createOpenAIProviderPlugin());
|
|
128
|
+
await registry.register(
|
|
129
|
+
createAgentsPlugin({
|
|
130
|
+
config: options.config,
|
|
131
|
+
model: options.config.model,
|
|
132
|
+
approvalChannelId: 'tui',
|
|
133
|
+
// The chat session updates this holder once `useSessionPersistence`
|
|
134
|
+
// mounts; mu-agents reads it lazily so subagent runs always land
|
|
135
|
+
// beside the *current* parent transcript file (survives /new + load).
|
|
136
|
+
getParentSessionPath: () => sessionPathHolder.current,
|
|
137
|
+
sessionWriter: async (path, messages) => {
|
|
138
|
+
await mkdir(dirname(path), { recursive: true });
|
|
139
|
+
await saveSession(path, messages);
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
await registry.register(
|
|
144
|
+
createCodingPlugin({
|
|
145
|
+
appConfig: options.config,
|
|
146
|
+
initialMessages: options.initialMessages,
|
|
147
|
+
messageBus,
|
|
148
|
+
uiService: options.uiService,
|
|
149
|
+
shutdown: options.shutdown ?? noopShutdown,
|
|
150
|
+
sessionPathHolder,
|
|
151
|
+
// Pass the concrete registry: the TUI subscribes to renderer / shortcut
|
|
152
|
+
// / status streams that are not part of the narrow `PluginRegistryView`
|
|
153
|
+
// exposed via `ctx.registry`.
|
|
154
|
+
registry,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
// Silence unused-input warning when no configured/local plugins exist.
|
|
158
|
+
void inputs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function createRegistry(options: CreateRegistryOptions): Promise<RegistryBundle> {
|
|
39
162
|
const { cwd, config, uiService, shutdown } = options;
|
|
40
|
-
const
|
|
163
|
+
const messageBus = createMessageBus();
|
|
164
|
+
const providers = createProviderRegistry();
|
|
165
|
+
const channels = createChannelRegistry();
|
|
166
|
+
const activity = createActivityBus();
|
|
167
|
+
const sessionPathHolder = createSessionPathHolder();
|
|
168
|
+
const registry = new PluginRegistry({
|
|
169
|
+
cwd,
|
|
170
|
+
config: {},
|
|
171
|
+
ui: uiService,
|
|
172
|
+
shutdown,
|
|
173
|
+
messages: messageBus,
|
|
174
|
+
providers,
|
|
175
|
+
channels,
|
|
176
|
+
activity,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const inputs: PluginConfigInputs = { uiService, shutdown, appConfig: config };
|
|
41
180
|
|
|
42
|
-
await registry
|
|
181
|
+
await registerBuiltins(registry, options, inputs, messageBus, sessionPathHolder);
|
|
43
182
|
|
|
44
|
-
//
|
|
45
|
-
// loader as configured ones so they receive `{ ui, shutdown }` and get
|
|
46
|
-
// consistent error reporting.
|
|
183
|
+
// User-extension plugins ride on top of the builtins.
|
|
47
184
|
for (const filePath of discoverPluginFiles()) {
|
|
48
|
-
await loadConfiguredPlugin(registry, filePath, buildPluginConfig(
|
|
185
|
+
await loadConfiguredPlugin(registry, filePath, buildPluginConfig(inputs), uiService);
|
|
49
186
|
}
|
|
50
187
|
|
|
51
188
|
for (const entry of config.plugins ?? []) {
|
|
52
189
|
const name = typeof entry === 'string' ? entry : entry.name;
|
|
53
190
|
const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
|
|
54
|
-
await loadConfiguredPlugin(registry, name, buildPluginConfig(
|
|
191
|
+
await loadConfiguredPlugin(registry, name, buildPluginConfig(inputs, pluginConfig), uiService);
|
|
55
192
|
}
|
|
56
193
|
|
|
57
|
-
return registry;
|
|
194
|
+
return { registry, messageBus, providers, channels, activity, sessionPathHolder };
|
|
58
195
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { type Dirent, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import type { MentionCompletion, MentionProvider } from 'mu-core';
|
|
5
|
+
|
|
6
|
+
const CACHE_TTL_MS = 5_000;
|
|
7
|
+
const MAX_FILES = 5_000;
|
|
8
|
+
const MAX_RESULTS = 12;
|
|
9
|
+
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.turbo', 'coverage']);
|
|
10
|
+
|
|
11
|
+
interface FileCache {
|
|
12
|
+
files: string[];
|
|
13
|
+
builtAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let cache: FileCache | null = null;
|
|
17
|
+
let cacheCwd = '';
|
|
18
|
+
|
|
19
|
+
function listGitFiles(cwd: string): string[] | null {
|
|
20
|
+
try {
|
|
21
|
+
const stdout = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
});
|
|
27
|
+
const files = stdout.split('\n').filter((line) => line.length > 0);
|
|
28
|
+
return files.slice(0, MAX_FILES);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walkFs(root: string): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
const stack: string[] = [root];
|
|
37
|
+
while (stack.length > 0 && out.length < MAX_FILES) {
|
|
38
|
+
const dir = stack.pop();
|
|
39
|
+
if (!dir) break;
|
|
40
|
+
let entries: Dirent[];
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
|
|
48
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
49
|
+
const full = join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
stack.push(full);
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
out.push(relative(root, full).split(sep).join('/'));
|
|
54
|
+
if (out.length >= MAX_FILES) break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function refreshCache(cwd: string): string[] {
|
|
62
|
+
if (cache && cacheCwd === cwd && Date.now() - cache.builtAt < CACHE_TTL_MS) {
|
|
63
|
+
return cache.files;
|
|
64
|
+
}
|
|
65
|
+
const files = listGitFiles(cwd) ?? walkFs(cwd);
|
|
66
|
+
cache = { files, builtAt: Date.now() };
|
|
67
|
+
cacheCwd = cwd;
|
|
68
|
+
return files;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Score a file path against `partial` for ranking.
|
|
73
|
+
* - exact basename match → 0 (best)
|
|
74
|
+
* - basename starts with partial → 1
|
|
75
|
+
* - basename contains partial → 2
|
|
76
|
+
* - path contains partial → 3
|
|
77
|
+
* - otherwise → Infinity (filtered out)
|
|
78
|
+
*/
|
|
79
|
+
function scorePath(path: string, partial: string): number {
|
|
80
|
+
if (!partial) return path.length; // empty partial → shorter paths first
|
|
81
|
+
const lower = path.toLowerCase();
|
|
82
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
83
|
+
const p = partial.toLowerCase();
|
|
84
|
+
if (base === p) return 0;
|
|
85
|
+
if (base.startsWith(p)) return 1;
|
|
86
|
+
if (base.includes(p)) return 2;
|
|
87
|
+
if (lower.includes(p)) return 3;
|
|
88
|
+
return Number.POSITIVE_INFINITY;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the file mention provider bound to `cwd`. Suggests up to
|
|
93
|
+
* `MAX_RESULTS` files matched against the partial, ranked by basename
|
|
94
|
+
* proximity. Cached for {@link CACHE_TTL_MS} so rapid keystrokes don't
|
|
95
|
+
* re-walk the tree.
|
|
96
|
+
*/
|
|
97
|
+
export function createFileMentionProvider(cwd: string): MentionProvider {
|
|
98
|
+
return (partial: string): MentionCompletion[] => {
|
|
99
|
+
const files = refreshCache(cwd);
|
|
100
|
+
if (files.length === 0) return [];
|
|
101
|
+
const scored: { path: string; score: number }[] = [];
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const score = scorePath(f, partial);
|
|
104
|
+
if (score < Number.POSITIVE_INFINITY) scored.push({ path: f, score });
|
|
105
|
+
}
|
|
106
|
+
scored.sort((a, b) => a.score - b.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
|
|
107
|
+
return scored.slice(0, MAX_RESULTS).map(({ path }) => ({
|
|
108
|
+
value: path,
|
|
109
|
+
// Show the full path so the basename sits at the end of the line —
|
|
110
|
+
// the picker truncates the prefix when needed (`wrap="truncate-start"`)
|
|
111
|
+
// so the filename stays visible.
|
|
112
|
+
label: path,
|
|
113
|
+
category: 'files',
|
|
114
|
+
}));
|
|
115
|
+
};
|
|
116
|
+
}
|