mu-coding 0.5.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 -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/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- 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 +128 -23
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +22 -9
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +31 -3
- package/src/tui/chat/useChatPanel.ts +7 -5
- package/src/tui/chat/useChatSession.ts +222 -53
- package/src/tui/chat/useModels.ts +2 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +25 -14
- package/src/tui/chat/useStatusSegments.ts +17 -4
- package/src/tui/components/chat/ChatPanel.tsx +10 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +6 -4
- package/src/tui/components/messages/ToolHeader.tsx +3 -1
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +19 -1
- 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 +6 -5
- package/src/tui/components/messages/userMessage.tsx +21 -6
- 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 +5 -3
- package/src/tui/components/statusBar.tsx +8 -1
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/input/InputBoxView.tsx +135 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +6 -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 +134 -15
- package/src/tui/input/useInputHandler.ts +316 -126
- 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/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +26 -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 +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
package/README.md
CHANGED
|
@@ -40,16 +40,61 @@ Config files follow XDG conventions:
|
|
|
40
40
|
}
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
### Theming
|
|
44
|
+
|
|
45
|
+
The `theme` field selects the UI palette. Either name a built-in preset:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "theme": "solarized-dark" }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Built-in presets: `dark` (default), `light`, `solarized-dark`, `monochrome`.
|
|
52
|
+
|
|
53
|
+
Or pass an object to override individual leaves on top of a preset:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"theme": {
|
|
58
|
+
"preset": "dark",
|
|
59
|
+
"input": { "background": "#1e1e2e", "cursor": "#f5c2e7" },
|
|
60
|
+
"user": { "border": "magenta" },
|
|
61
|
+
"common": { "accent": "#89dceb" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Color values accept Ink's named colors (`red`, `green`, `cyan`, `yellow`,
|
|
67
|
+
`magenta`, `blue`, `white`, `black`, `gray`) or hex strings (`#1a1a1a`).
|
|
68
|
+
|
|
69
|
+
Sections available: `input`, `user`, `assistant`, `tool`, `reasoning`,
|
|
70
|
+
`modal`, `toast`, `dropdown`, `dialog`, `diff`, `status`, `common`. See
|
|
71
|
+
`src/tui/theme/types.ts` for the full leaf list.
|
|
72
|
+
|
|
43
73
|
## Keyboard Shortcuts
|
|
44
74
|
|
|
75
|
+
### Input editing
|
|
76
|
+
|
|
77
|
+
| Key | Action |
|
|
78
|
+
|-----|--------|
|
|
79
|
+
| `←` / `→` | Move cursor one character |
|
|
80
|
+
| `Ctrl+←` / `Ctrl+→` (or `Alt+←/→`) | Move cursor by word |
|
|
81
|
+
| `Home` / `End` (or `Ctrl+A` / `Ctrl+E`) | Jump to start / end of line |
|
|
82
|
+
| `↑` / `↓` | Move cursor between lines (multi-line buffer); navigate history at edges |
|
|
83
|
+
| `Backspace` | Delete char before cursor |
|
|
84
|
+
| `Delete` | Delete char under cursor |
|
|
85
|
+
| `Ctrl+W` | Delete previous word |
|
|
86
|
+
| `Ctrl+U` | Delete from start of line to cursor |
|
|
87
|
+
| `Ctrl+K` | Delete from cursor to end of line |
|
|
88
|
+
|
|
89
|
+
### Submission & app
|
|
90
|
+
|
|
45
91
|
| Key | Action |
|
|
46
92
|
|-----|--------|
|
|
47
93
|
| `Enter` | Send message |
|
|
48
|
-
| `Shift+Enter` | New line |
|
|
94
|
+
| `Shift+Enter` (or `Ctrl+J`) | New line |
|
|
49
95
|
| `Ctrl+S` | Send message |
|
|
50
96
|
| `Ctrl+C` | Abort / Quit (press twice) |
|
|
51
97
|
| `Esc` | Stop generation (press twice) |
|
|
52
|
-
| `↑` / `↓` | Navigate input history |
|
|
53
98
|
| `Ctrl+N` | New conversation |
|
|
54
99
|
| `Ctrl+M` | Cycle models |
|
|
55
100
|
| `Ctrl+O` | Model picker |
|
|
@@ -69,7 +114,8 @@ Config files follow XDG conventions:
|
|
|
69
114
|
|
|
70
115
|
- Streams responses with live token/s display
|
|
71
116
|
- Multi-turn tool calling (bash, read, write, edit files)
|
|
72
|
-
-
|
|
117
|
+
- Optional code indexing via the `mu-repomap` plugin (enable via `config.plugins`)
|
|
118
|
+
- Optional default agents (build/plan/explore/review) via `mu-coding-agents` (enable via `config.plugins`)
|
|
73
119
|
- Image attachment support
|
|
74
120
|
- Session persistence and resume
|
|
75
121
|
- Mouse wheel scrolling
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Minimal terminal AI assistant for local models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mu": "./bin/mu.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
|
-
"
|
|
10
|
+
".": "./src/plugin.ts",
|
|
11
|
+
"./config": "./src/config/index.ts",
|
|
12
|
+
"./tools": "./src/runtime/codingTools/index.ts",
|
|
13
|
+
"./loader": "./src/runtime/pluginLoader.ts"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
16
|
"bin",
|
|
17
|
+
"prompts",
|
|
14
18
|
"src"
|
|
15
19
|
],
|
|
16
20
|
"scripts": {
|
|
@@ -20,8 +24,9 @@
|
|
|
20
24
|
},
|
|
21
25
|
"dependencies": {
|
|
22
26
|
"ink": "^7.0.1",
|
|
23
|
-
"mu-agents": "0.
|
|
24
|
-
"mu-
|
|
27
|
+
"mu-agents": "0.8.0",
|
|
28
|
+
"mu-core": "0.8.0",
|
|
29
|
+
"mu-openai-provider": "0.8.0",
|
|
25
30
|
"react": "^19.2.5"
|
|
26
31
|
}
|
|
27
32
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
You are mu, a terminal coding agent. Be concise, direct, accurate.
|
|
2
|
+
|
|
3
|
+
## Working style
|
|
4
|
+
- Investigate before editing; don't guess at APIs.
|
|
5
|
+
- Issue independent tool calls in parallel.
|
|
6
|
+
- Ask only when genuinely ambiguous; otherwise proceed.
|
|
7
|
+
- After non-trivial edits, run the project's check command if known (e.g. `bun run check`).
|
|
8
|
+
|
|
9
|
+
## Output
|
|
10
|
+
- Plain terminal text. Backticks for `paths`, `commands`, `identifiers`.
|
|
11
|
+
- Reference code as `path/to/file.ts:LINE`.
|
|
12
|
+
- No filler. Lead with the result or next action.
|
|
13
|
+
|
|
14
|
+
## Safety
|
|
15
|
+
- Never run destructive commands (`rm -rf`, force-push, history rewrites) without explicit request.
|
|
16
|
+
- Never commit, amend, or push unless asked.
|
package/src/app/shutdown.ts
CHANGED
package/src/app/startApp.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import type { PluginRegistry } from 'mu-
|
|
1
|
+
import type { PluginRegistry } from 'mu-core';
|
|
2
2
|
import { parseArgs, resolveInitialMessages } from '../cli/args';
|
|
3
3
|
import { handleSubcommand } from '../cli/subcommands';
|
|
4
4
|
import { loadConfig } from '../config/index';
|
|
5
5
|
import { createRegistry } from '../runtime/createRegistry';
|
|
6
6
|
import { InkUIService } from '../tui/plugins/InkUIService';
|
|
7
|
-
import { renderApp } from '../tui/renderApp';
|
|
8
7
|
import { registerShutdown } from './shutdown';
|
|
9
8
|
|
|
10
9
|
async function runApp(): Promise<void> {
|
|
@@ -20,16 +19,20 @@ async function runApp(): Promise<void> {
|
|
|
20
19
|
let registryRef: PluginRegistry | null = null;
|
|
21
20
|
const shutdown = registerShutdown(() => registryRef);
|
|
22
21
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
renderApp({
|
|
22
|
+
const initialMessages = resolveInitialMessages(cliArgs);
|
|
23
|
+
const { registry, channels } = await createRegistry({
|
|
24
|
+
cwd: process.cwd(),
|
|
27
25
|
config,
|
|
28
|
-
initialMessages: resolveInitialMessages(cliArgs),
|
|
29
|
-
registry,
|
|
30
26
|
uiService,
|
|
27
|
+
initialMessages,
|
|
31
28
|
shutdown,
|
|
32
29
|
});
|
|
30
|
+
registryRef = registry;
|
|
31
|
+
|
|
32
|
+
// The TUI is registered as a `Channel` by `createCodingPlugin`. Starting
|
|
33
|
+
// it mounts Ink with the same options that were captured at activation
|
|
34
|
+
// time (config, initialMessages, registry, messageBus, uiService, shutdown).
|
|
35
|
+
await channels.startAll();
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export function startApp(): void {
|
package/src/cli/args.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
5
|
-
import type { ChatMessage } from 'mu-
|
|
5
|
+
import type { ChatMessage } from 'mu-core';
|
|
6
6
|
import { getLatestSession, loadSession } from '../sessions/index';
|
|
7
7
|
|
|
8
8
|
interface CliArgs {
|
|
@@ -31,16 +31,19 @@ Config (XDG):
|
|
|
31
31
|
~/.cache/mu/repomap/ — code index cache
|
|
32
32
|
|
|
33
33
|
Keyboard shortcuts (interactive):
|
|
34
|
-
Ctrl+C
|
|
35
|
-
Esc
|
|
36
|
-
Enter
|
|
37
|
-
Shift+Enter
|
|
38
|
-
Ctrl+S
|
|
39
|
-
|
|
40
|
-
Ctrl+
|
|
41
|
-
|
|
42
|
-
Ctrl+
|
|
43
|
-
Ctrl+
|
|
34
|
+
Ctrl+C Abort / Quit (press twice)
|
|
35
|
+
Esc Stop generation (press twice while streaming)
|
|
36
|
+
Enter Send message
|
|
37
|
+
Shift+Enter New line
|
|
38
|
+
Ctrl+S Send message
|
|
39
|
+
← / → Move cursor (Ctrl/Alt+arrow: by word)
|
|
40
|
+
Home/End Start/end of line (or Ctrl+A / Ctrl+E)
|
|
41
|
+
↑ / ↓ Move between lines; navigate history at edges
|
|
42
|
+
Backspace/Del Delete around cursor (Ctrl+W word, Ctrl+U/K line)
|
|
43
|
+
Ctrl+N New conversation
|
|
44
|
+
Ctrl+M Cycle models
|
|
45
|
+
Ctrl+O Model picker
|
|
46
|
+
Ctrl+V Paste image from clipboard`);
|
|
44
47
|
process.exit(0);
|
|
45
48
|
}
|
|
46
49
|
|
package/src/config/index.test.ts
CHANGED
|
@@ -49,3 +49,29 @@ describe('saveConfig', () => {
|
|
|
49
49
|
expect('model' in persisted).toBe(false);
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe('loadConfig theme', () => {
|
|
54
|
+
it('reads the theme field from config.json', async () => {
|
|
55
|
+
const { loadConfig } = await import('./index');
|
|
56
|
+
writeFileSync(
|
|
57
|
+
configPath,
|
|
58
|
+
JSON.stringify({ baseUrl: 'http://x', theme: { preset: 'light', input: { cursor: '#abcdef' } } }),
|
|
59
|
+
);
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
expect(cfg.theme).toEqual({ preset: 'light', input: { cursor: '#abcdef' } });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts a preset name string', async () => {
|
|
65
|
+
const { loadConfig } = await import('./index');
|
|
66
|
+
writeFileSync(configPath, JSON.stringify({ baseUrl: 'http://x', theme: 'solarized-dark' }));
|
|
67
|
+
const cfg = loadConfig();
|
|
68
|
+
expect(cfg.theme).toBe('solarized-dark');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('omits theme when not present', async () => {
|
|
72
|
+
const { loadConfig } = await import('./index');
|
|
73
|
+
writeFileSync(configPath, JSON.stringify({ baseUrl: 'http://x' }));
|
|
74
|
+
const cfg = loadConfig();
|
|
75
|
+
expect(cfg.theme).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import type { ProviderConfig } from 'mu-core';
|
|
6
|
+
import type { ThemeConfig } from '../tui/theme/types';
|
|
5
7
|
|
|
6
8
|
// ─── XDG Path Helpers ─────────────────────────────────────────────────────────
|
|
7
9
|
//
|
|
@@ -9,9 +11,6 @@ import type { ProviderConfig } from 'mu-provider';
|
|
|
9
11
|
// "where do mu's files live?" question end-to-end (config.json, SYSTEM.md,
|
|
10
12
|
// sessions, plugin caches). Resolved lazily so tests can stub the env after
|
|
11
13
|
// module import; production callers pay only one `process.env` lookup per call.
|
|
12
|
-
//
|
|
13
|
-
// Other workspace packages (e.g. `mu-pi-compat`) import these via the
|
|
14
|
-
// `mu-coding/config` subpath export — see `mu-coding/package.json`.
|
|
15
14
|
|
|
16
15
|
const HOME = homedir();
|
|
17
16
|
|
|
@@ -73,6 +72,11 @@ export function canonicalNpmSpecifier(bare: string): string {
|
|
|
73
72
|
|
|
74
73
|
export interface AppConfig extends ProviderConfig {
|
|
75
74
|
plugins?: Array<string | { name: string; config?: Record<string, unknown> }>;
|
|
75
|
+
/**
|
|
76
|
+
* Optional per-leaf overrides on top of the built-in theme. See
|
|
77
|
+
* `tui/theme/types.ts` for the available sections and color leaves.
|
|
78
|
+
*/
|
|
79
|
+
theme?: ThemeConfig;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
|
@@ -87,8 +91,8 @@ const CONFIG_FILE_KEYS = [
|
|
|
87
91
|
'maxTokens',
|
|
88
92
|
'temperature',
|
|
89
93
|
'streamTimeoutMs',
|
|
90
|
-
'systemPrompt',
|
|
91
94
|
'plugins',
|
|
95
|
+
'theme',
|
|
92
96
|
] as const;
|
|
93
97
|
|
|
94
98
|
function configPath(): string {
|
|
@@ -99,6 +103,19 @@ function systemPromptPath(): string {
|
|
|
99
103
|
return join(getConfigDir(), 'SYSTEM.md');
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Path to the SYSTEM.md bundled with mu-coding. Used as the lowest-priority
|
|
108
|
+
* fallback when no user override is configured. Resolved from this module's
|
|
109
|
+
* location so it works both from `src/` (dev via bun) and any compiled layout
|
|
110
|
+
* that preserves the `prompts/` sibling of `src/` or `dist/`.
|
|
111
|
+
*/
|
|
112
|
+
function bundledSystemPromptPath(): string {
|
|
113
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
114
|
+
// src/config/index.ts → ../../prompts/SYSTEM.md
|
|
115
|
+
// dist/config/index.js → ../../prompts/SYSTEM.md
|
|
116
|
+
return join(here, '..', '..', 'prompts', 'SYSTEM.md');
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
function tryRead(path: string): string | undefined {
|
|
103
120
|
try {
|
|
104
121
|
return readFileSync(path, 'utf-8').trim() || undefined;
|
|
@@ -146,8 +163,9 @@ export function loadConfig(cliModel?: string): AppConfig {
|
|
|
146
163
|
maxTokens: envInt('MU_MAX_TOKENS') ?? file.maxTokens ?? 4096,
|
|
147
164
|
temperature: envFloat('MU_TEMPERATURE') ?? file.temperature ?? 0.7,
|
|
148
165
|
streamTimeoutMs: envInt('MU_STREAM_TIMEOUT') ?? file.streamTimeoutMs ?? 60_000,
|
|
149
|
-
systemPrompt:
|
|
166
|
+
systemPrompt: tryRead(systemPromptPath()) || tryRead(bundledSystemPromptPath()),
|
|
150
167
|
plugins: file.plugins,
|
|
168
|
+
theme: file.theme,
|
|
151
169
|
};
|
|
152
170
|
|
|
153
171
|
if (!existsSync(path)) {
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mu-coding plugin — packages the TUI channel + coding tools into a single
|
|
3
|
+
* plugin. The standalone `mu` binary uses this as its primary surface
|
|
4
|
+
* (registered by `createRegistry`) and any generic host (Arya, future web
|
|
5
|
+
* app) can opt in to the coding tools by including it in its plugin list.
|
|
6
|
+
*
|
|
7
|
+
* The factory takes a `CodingPluginConfig` rather than reading values from
|
|
8
|
+
* `PluginContext` because the TUI layer needs the **concrete**
|
|
9
|
+
* `PluginRegistry` (it subscribes to renderer / shortcut / status changes
|
|
10
|
+
* via methods that are not part of the read-only `PluginRegistryView`).
|
|
11
|
+
* `ctx.registry` only exposes the View; the host wires the concrete
|
|
12
|
+
* registry in after constructing it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ApprovalGateway } from 'mu-agents';
|
|
16
|
+
import type { ChatMessage, Plugin, PluginContext, PluginRegistry } from 'mu-core';
|
|
17
|
+
import type { ShutdownFn } from './app/shutdown';
|
|
18
|
+
import type { AppConfig } from './config/index';
|
|
19
|
+
import { createCodingToolsPlugin } from './runtime/codingTools/index';
|
|
20
|
+
import type { HostMessageBus } from './runtime/messageBus';
|
|
21
|
+
import { createTuiChannel } from './tui/channel/tuiChannel';
|
|
22
|
+
import { createInkApprovalChannel } from './tui/plugins/InkApprovalChannel';
|
|
23
|
+
import type { InkUIService } from './tui/plugins/InkUIService';
|
|
24
|
+
|
|
25
|
+
export interface CodingPluginConfig {
|
|
26
|
+
appConfig: AppConfig;
|
|
27
|
+
initialMessages?: ChatMessage[];
|
|
28
|
+
messageBus: HostMessageBus;
|
|
29
|
+
uiService: InkUIService;
|
|
30
|
+
shutdown: ShutdownFn;
|
|
31
|
+
/**
|
|
32
|
+
* Concrete `PluginRegistry` instance used by the TUI to subscribe to
|
|
33
|
+
* renderers / shortcuts / status segments. Required because `ctx.registry`
|
|
34
|
+
* (the read-only View) does not expose those subscription methods.
|
|
35
|
+
*/
|
|
36
|
+
registry: PluginRegistry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface AgentPluginShape {
|
|
40
|
+
approvalGateway?: ApprovalGateway;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createCodingPlugin(config: CodingPluginConfig): Plugin {
|
|
44
|
+
// Coding tools are an inner plugin; we delegate via the registered tools
|
|
45
|
+
// list rather than a recursive register call so a single Plugin object
|
|
46
|
+
// is returned (matches the SDK's expected factory shape).
|
|
47
|
+
const inner = createCodingToolsPlugin();
|
|
48
|
+
|
|
49
|
+
// Captured at activation time so deactivate can clean up both registrations.
|
|
50
|
+
let unregisterTuiChannel: (() => void) | null = null;
|
|
51
|
+
let unregisterApprovalChannel: (() => void) | null = null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: 'mu-coding',
|
|
55
|
+
version: '0.5.0',
|
|
56
|
+
tools: inner.tools,
|
|
57
|
+
systemPrompt: inner.systemPrompt,
|
|
58
|
+
activate(ctx: PluginContext) {
|
|
59
|
+
// Forward inner plugin's activate (captures cwd for tool path resolution).
|
|
60
|
+
inner.activate?.(ctx);
|
|
61
|
+
|
|
62
|
+
// Register the TUI channel so other code can stop it gracefully via
|
|
63
|
+
// ctx.channels.stopAll(). The TUI subscribes to the concrete registry
|
|
64
|
+
// (passed in via config), not the narrow context-exposed view.
|
|
65
|
+
unregisterTuiChannel =
|
|
66
|
+
ctx.channels?.register(
|
|
67
|
+
createTuiChannel({
|
|
68
|
+
config: config.appConfig,
|
|
69
|
+
initialMessages: config.initialMessages,
|
|
70
|
+
registry: config.registry,
|
|
71
|
+
messageBus: config.messageBus,
|
|
72
|
+
uiService: config.uiService,
|
|
73
|
+
shutdown: config.shutdown,
|
|
74
|
+
}),
|
|
75
|
+
) ?? null;
|
|
76
|
+
|
|
77
|
+
// Register the Ink approval channel against mu-agents' gateway, when
|
|
78
|
+
// mu-agents is present. We use `ctx.getPlugin` to look it up loosely
|
|
79
|
+
// so coding still works in setups that disabled the agent plugin.
|
|
80
|
+
const agentPlugin = ctx.getPlugin?.<Plugin & AgentPluginShape>('mu-agents');
|
|
81
|
+
if (agentPlugin?.approvalGateway) {
|
|
82
|
+
unregisterApprovalChannel = agentPlugin.approvalGateway.registerChannel(
|
|
83
|
+
'tui',
|
|
84
|
+
createInkApprovalChannel(config.uiService),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
deactivate() {
|
|
89
|
+
unregisterApprovalChannel?.();
|
|
90
|
+
unregisterApprovalChannel = null;
|
|
91
|
+
unregisterTuiChannel?.();
|
|
92
|
+
unregisterTuiChannel = null;
|
|
93
|
+
inner.deactivate?.();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { PluginTool, ToolExecutorResult } from 'mu-core';
|
|
3
|
+
|
|
4
|
+
function executeBash(command: string, cwd: string, signal?: AbortSignal): Promise<ToolExecutorResult> {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const proc = spawn('bash', ['-c', command], {
|
|
7
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
8
|
+
detached: true,
|
|
9
|
+
cwd,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
let stdout = '';
|
|
13
|
+
let stderr = '';
|
|
14
|
+
|
|
15
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
16
|
+
try {
|
|
17
|
+
stdout += data.toString('utf-8');
|
|
18
|
+
} catch {
|
|
19
|
+
// skip binary data
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
23
|
+
try {
|
|
24
|
+
stderr += data.toString('utf-8');
|
|
25
|
+
} catch {
|
|
26
|
+
// skip binary data
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const onAbort = () => {
|
|
31
|
+
const pid = proc.pid;
|
|
32
|
+
if (pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(-pid, 'SIGTERM');
|
|
35
|
+
} catch {
|
|
36
|
+
proc.kill('SIGTERM');
|
|
37
|
+
}
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
if (!proc.killed) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(-pid, 'SIGKILL');
|
|
42
|
+
} catch {
|
|
43
|
+
proc.kill('SIGKILL');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}, 500);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (signal) {
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
onAbort();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
proc.on('close', (code) => {
|
|
59
|
+
signal?.removeEventListener('abort', onAbort);
|
|
60
|
+
const output = [stdout, stderr]
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join('\n');
|
|
64
|
+
if (signal?.aborted) {
|
|
65
|
+
resolve({ content: 'Aborted', error: true });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (code !== 0 && !output) {
|
|
69
|
+
resolve({ content: `Error: Process exited with code ${code}`, error: true });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Non-zero exit with output: treat as error so the LLM sees it as such,
|
|
73
|
+
// but preserve stdout/stderr in the content.
|
|
74
|
+
resolve({ content: output || '(no output)', error: code !== 0 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
proc.on('error', (err) => {
|
|
78
|
+
signal?.removeEventListener('abort', onAbort);
|
|
79
|
+
resolve({ content: `Error: ${err.message}`, error: true });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createBashTool(getCwd: () => string): PluginTool {
|
|
85
|
+
return {
|
|
86
|
+
definition: {
|
|
87
|
+
type: 'function',
|
|
88
|
+
function: {
|
|
89
|
+
name: 'bash',
|
|
90
|
+
description:
|
|
91
|
+
'Run a shell command via bash in the project cwd. Returns stdout+stderr; non-zero exit is an error.',
|
|
92
|
+
parameters: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
cmd: { type: 'string' },
|
|
96
|
+
},
|
|
97
|
+
required: ['cmd'],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
display: {
|
|
103
|
+
verb: 'running',
|
|
104
|
+
kind: 'shell',
|
|
105
|
+
fields: { command: 'cmd' },
|
|
106
|
+
},
|
|
107
|
+
permission: {
|
|
108
|
+
matchKey: (args) => args.cmd as string | undefined,
|
|
109
|
+
},
|
|
110
|
+
execute(args, signal) {
|
|
111
|
+
return executeBash(args.cmd as string, getCwd(), signal);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { PluginTool, ToolExecutorResult } from 'mu-core';
|
|
3
|
+
import { sanitizePath } from './utils';
|
|
4
|
+
|
|
5
|
+
export function createEditFileTool(getCwd: () => string): PluginTool {
|
|
6
|
+
return {
|
|
7
|
+
definition: {
|
|
8
|
+
type: 'function',
|
|
9
|
+
function: {
|
|
10
|
+
name: 'edit',
|
|
11
|
+
description: 'Replace an exact substring in an existing file.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string' },
|
|
16
|
+
from: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description:
|
|
19
|
+
'Must occur exactly once in the file \u2014 include surrounding context to disambiguate. Whitespace must match exactly.',
|
|
20
|
+
},
|
|
21
|
+
to: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
required: ['path', 'from', 'to'],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
display: {
|
|
29
|
+
verb: 'editing',
|
|
30
|
+
kind: 'diff',
|
|
31
|
+
fields: { path: 'path', from: 'from', to: 'to' },
|
|
32
|
+
},
|
|
33
|
+
permission: {
|
|
34
|
+
matchKey: (args) => args.path as string | undefined,
|
|
35
|
+
},
|
|
36
|
+
execute(args): ToolExecutorResult {
|
|
37
|
+
const path = sanitizePath(args.path as string, getCwd());
|
|
38
|
+
const oldString = args.from as string;
|
|
39
|
+
const newString = args.to as string;
|
|
40
|
+
|
|
41
|
+
if (!existsSync(path)) {
|
|
42
|
+
return { content: `Error: File not found: ${path}`, error: true };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const content = readFileSync(path, 'utf-8');
|
|
46
|
+
const count = content.split(oldString).length - 1;
|
|
47
|
+
if (count === 0) {
|
|
48
|
+
return { content: 'Error: "from" not found in file', error: true };
|
|
49
|
+
}
|
|
50
|
+
if (count > 1) {
|
|
51
|
+
return { content: `Error: "from" found ${count} times, must be unique`, error: true };
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(path, content.replace(oldString, newString), 'utf-8');
|
|
54
|
+
return { content: `File edited: ${path}` };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, error: true };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -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 };
|