mu-coding 0.5.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/cli/install.ts +18 -3
  8. package/src/config/index.test.ts +26 -0
  9. package/src/config/index.ts +25 -7
  10. package/src/plugin.ts +124 -0
  11. package/src/runtime/codingTools/bash.ts +114 -0
  12. package/src/runtime/codingTools/edit-file.ts +60 -0
  13. package/src/runtime/codingTools/index.ts +39 -0
  14. package/src/runtime/codingTools/read-file.ts +83 -0
  15. package/src/runtime/codingTools/utils.ts +21 -0
  16. package/src/runtime/codingTools/write-file.ts +42 -0
  17. package/src/runtime/createRegistry.test.ts +147 -0
  18. package/src/runtime/createRegistry.ts +160 -23
  19. package/src/runtime/fileMentionProvider.ts +116 -0
  20. package/src/runtime/messageBus.test.ts +62 -0
  21. package/src/runtime/messageBus.ts +78 -0
  22. package/src/runtime/pluginLoader.ts +59 -15
  23. package/src/sessions/index.ts +2 -9
  24. package/src/tui/channel/tuiChannel.test.ts +107 -0
  25. package/src/tui/channel/tuiChannel.ts +62 -0
  26. package/src/tui/chat/MessageRendererContext.ts +44 -0
  27. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  28. package/src/tui/chat/useAbort.ts +5 -0
  29. package/src/tui/chat/useAttachment.ts +1 -1
  30. package/src/tui/chat/useChat.ts +38 -3
  31. package/src/tui/chat/useChatPanel.ts +29 -6
  32. package/src/tui/chat/useChatSession.ts +324 -57
  33. package/src/tui/chat/useModels.ts +26 -1
  34. package/src/tui/chat/usePluginStatus.ts +1 -1
  35. package/src/tui/chat/useSessionPersistence.ts +48 -21
  36. package/src/tui/chat/useStatusSegments.ts +38 -5
  37. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  38. package/src/tui/components/chat/ChatPanel.tsx +25 -4
  39. package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
  40. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  41. package/src/tui/components/messageView.tsx +4 -2
  42. package/src/tui/components/messages/EditOutput.tsx +17 -9
  43. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  44. package/src/tui/components/messages/ToolHeader.tsx +8 -4
  45. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  46. package/src/tui/components/messages/assistantMessage.tsx +55 -7
  47. package/src/tui/components/messages/markdown.tsx +402 -0
  48. package/src/tui/components/messages/messageItem.tsx +19 -1
  49. package/src/tui/components/messages/reasoningBlock.tsx +10 -6
  50. package/src/tui/components/messages/streamingOutput.tsx +6 -2
  51. package/src/tui/components/messages/toolCallBlock.tsx +7 -6
  52. package/src/tui/components/messages/userMessage.tsx +22 -7
  53. package/src/tui/components/primitives/dropdown.tsx +8 -4
  54. package/src/tui/components/primitives/modal.tsx +4 -2
  55. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  56. package/src/tui/components/primitives/toast.tsx +43 -10
  57. package/src/tui/components/statusBar.tsx +26 -10
  58. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  59. package/src/tui/context/ThemeContext.tsx +18 -0
  60. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  61. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  62. package/src/tui/input/InputBoxView.tsx +191 -26
  63. package/src/tui/input/commands.test.ts +3 -1
  64. package/src/tui/input/commands.ts +11 -1
  65. package/src/tui/input/cursor.test.ts +136 -0
  66. package/src/tui/input/cursor.ts +214 -0
  67. package/src/tui/input/dumpContext.ts +107 -0
  68. package/src/tui/input/sanitize.ts +1 -1
  69. package/src/tui/input/useCommandExecutor.ts +1 -1
  70. package/src/tui/input/useInputBox.ts +160 -15
  71. package/src/tui/input/useInputHandler.ts +317 -126
  72. package/src/tui/input/useMentionPicker.ts +133 -0
  73. package/src/tui/input/usePluginShortcuts.ts +29 -0
  74. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  75. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  76. package/src/tui/plugins/InkUIService.ts +1 -1
  77. package/src/tui/renderApp.tsx +47 -13
  78. package/src/tui/theme/index.ts +1 -0
  79. package/src/tui/theme/merge.test.ts +49 -0
  80. package/src/tui/theme/merge.ts +43 -0
  81. package/src/tui/theme/presets.ts +90 -0
  82. package/src/tui/theme/types.ts +138 -0
  83. package/src/utils/clipboard.ts +1 -1
  84. package/src/tui/chat/useStreamConsumer.ts +0 -118
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
- - Code indexing via `mu-repomap` plugin (auto-loaded)
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.5.0",
3
+ "version": "0.9.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
- "./config": "./src/config/index.ts"
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.5.0",
24
- "mu-provider": "0.5.0",
27
+ "mu-agents": "0.9.0",
28
+ "mu-core": "0.9.0",
29
+ "mu-openai-provider": "0.9.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.
@@ -1,4 +1,4 @@
1
- import type { PluginRegistry } from 'mu-agents';
1
+ import type { PluginRegistry } from 'mu-core';
2
2
 
3
3
  /**
4
4
  * Escape sequences to disable every SGR mouse-tracking mode the TUI may have
@@ -1,10 +1,9 @@
1
- import type { PluginRegistry } from 'mu-agents';
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 registry = await createRegistry({ cwd: process.cwd(), config, uiService, shutdown });
24
- registryRef = registry;
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-provider';
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 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
- / ↓ Navigate input history
40
- Ctrl+N New conversation
41
- Ctrl+M Cycle models
42
- Ctrl+O Model picker
43
- Ctrl+V Paste image from clipboard`);
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
 
@@ -5,7 +5,7 @@ import { canonicalNpmSpecifier, getDataDir, loadConfig, parseBareNpmSpec, saveCo
5
5
 
6
6
  const INIT_PACKAGE_JSON = JSON.stringify({ private: true, dependencies: {} }, null, 2);
7
7
 
8
- function ensureDataDir(): string {
8
+ export function ensureDataDir(): string {
9
9
  const dataDir = getDataDir();
10
10
  mkdirSync(dataDir, { recursive: true });
11
11
 
@@ -17,6 +17,21 @@ function ensureDataDir(): string {
17
17
  return dataDir;
18
18
  }
19
19
 
20
+ /**
21
+ * Install an npm package into the mu data dir using `bun add`. Shared by the
22
+ * `mu install` CLI and the runtime auto-installer (pluginLoader). `bare` is
23
+ * the spec without the `npm:` prefix (e.g. `mu-coding-agents`,
24
+ * `@scope/foo@^1.0.0`). When `silent`, stdout is suppressed (used by the
25
+ * runtime path so the TUI isn't garbled at startup).
26
+ */
27
+ export function installNpmPackage(bare: string, options: { silent?: boolean } = {}): void {
28
+ const dataDir = ensureDataDir();
29
+ execFileSync('bun', ['add', bare], {
30
+ cwd: dataDir,
31
+ stdio: options.silent ? 'pipe' : 'inherit',
32
+ });
33
+ }
34
+
20
35
  function stripNpmPrefix(specifier: string): string {
21
36
  if (!specifier.startsWith('npm:')) {
22
37
  console.error(`Error: package specifier must start with npm: — got "${specifier}"`);
@@ -31,7 +46,7 @@ export async function runInstall(args: string[]): Promise<void> {
31
46
  process.exit(1);
32
47
  }
33
48
 
34
- const dataDir = ensureDataDir();
49
+ ensureDataDir();
35
50
  const config = loadConfig();
36
51
  const plugins = config.plugins ?? [];
37
52
 
@@ -41,7 +56,7 @@ export async function runInstall(args: string[]): Promise<void> {
41
56
 
42
57
  console.log(`Installing ${bare}...`);
43
58
  try {
44
- execFileSync('bun', ['add', bare], { cwd: dataDir, stdio: 'inherit' });
59
+ installNpmPackage(bare);
45
60
  } catch (err) {
46
61
  console.error(`Failed to install ${bare}: ${err instanceof Error ? err.message : err}`);
47
62
  process.exit(1);
@@ -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
+ });
@@ -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 type { ProviderConfig } from 'mu-provider';
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: process.env.MU_SYSTEM_PROMPT || file.systemPrompt || tryRead(systemPromptPath()),
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,124 @@
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, SubagentRunRegistry } 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 { SessionPathHolder } from './runtime/createRegistry';
21
+ import { createFileMentionProvider } from './runtime/fileMentionProvider';
22
+ import type { HostMessageBus } from './runtime/messageBus';
23
+ import { createTuiChannel } from './tui/channel/tuiChannel';
24
+ import { createInkApprovalChannel } from './tui/plugins/InkApprovalChannel';
25
+ import type { InkUIService } from './tui/plugins/InkUIService';
26
+
27
+ export interface CodingPluginConfig {
28
+ appConfig: AppConfig;
29
+ initialMessages?: ChatMessage[];
30
+ messageBus: HostMessageBus;
31
+ uiService: InkUIService;
32
+ shutdown: ShutdownFn;
33
+ /**
34
+ * Mutable holder updated by the TUI's session persistence hook so other
35
+ * plugins (mu-agents) can read the current parent session path when
36
+ * dispatching subagents. `undefined` until the React tree mounts.
37
+ */
38
+ sessionPathHolder?: SessionPathHolder;
39
+ /**
40
+ * Concrete `PluginRegistry` instance used by the TUI to subscribe to
41
+ * renderers / shortcuts / status segments. Required because `ctx.registry`
42
+ * (the read-only View) does not expose those subscription methods.
43
+ */
44
+ registry: PluginRegistry;
45
+ }
46
+
47
+ interface AgentPluginShape {
48
+ approvalGateway?: ApprovalGateway;
49
+ runs?: SubagentRunRegistry;
50
+ }
51
+
52
+ export function createCodingPlugin(config: CodingPluginConfig): Plugin {
53
+ // Coding tools are an inner plugin; we delegate via the registered tools
54
+ // list rather than a recursive register call so a single Plugin object
55
+ // is returned (matches the SDK's expected factory shape).
56
+ const inner = createCodingToolsPlugin();
57
+
58
+ // Captured at activation time so deactivate can clean up both registrations.
59
+ let unregisterTuiChannel: (() => void) | null = null;
60
+ let unregisterApprovalChannel: (() => void) | null = null;
61
+ let unregisterFileMentions: (() => void) | null = null;
62
+
63
+ return {
64
+ name: 'mu-coding',
65
+ version: '0.5.0',
66
+ tools: inner.tools,
67
+ systemPrompt: inner.systemPrompt,
68
+ activate(ctx: PluginContext) {
69
+ // Forward inner plugin's activate (captures cwd for tool path resolution).
70
+ inner.activate?.(ctx);
71
+
72
+ // Register the TUI channel so other code can stop it gracefully via
73
+ // ctx.channels.stopAll(). The TUI subscribes to the concrete registry
74
+ // (passed in via config), not the narrow context-exposed view.
75
+ // Resolve the live mu-agents handle so the TUI can subscribe to the
76
+ // subagent run registry (browser panel + live header). Looked up
77
+ // loosely so coding still works in setups that disabled the agent
78
+ // plugin — the TUI then renders a fallback header without the live
79
+ // subagent navigation surfaces.
80
+ const agentPlugin = ctx.getPlugin?.<Plugin & AgentPluginShape>('mu-agents');
81
+
82
+ unregisterTuiChannel =
83
+ ctx.channels?.register(
84
+ createTuiChannel({
85
+ config: config.appConfig,
86
+ initialMessages: config.initialMessages,
87
+ registry: config.registry,
88
+ messageBus: config.messageBus,
89
+ uiService: config.uiService,
90
+ shutdown: config.shutdown,
91
+ sessionPathHolder: config.sessionPathHolder,
92
+ subagentRuns: agentPlugin?.runs,
93
+ }),
94
+ ) ?? null;
95
+
96
+ // Register a file completion provider on `@`. Sits alongside the
97
+ // mu-agents `@`-provider — useMentionPicker concatenates results from
98
+ // every provider matching a trigger, grouped by category in the UI.
99
+ // When the user types `@foo`, agents that match by name appear first;
100
+ // files matching by basename/path follow.
101
+ if (ctx.registerMentionProvider) {
102
+ unregisterFileMentions = ctx.registerMentionProvider('@', createFileMentionProvider(ctx.cwd));
103
+ }
104
+
105
+ // Register the Ink approval channel against mu-agents' gateway when
106
+ // it's available (the lookup above already resolved `agentPlugin`).
107
+ if (agentPlugin?.approvalGateway) {
108
+ unregisterApprovalChannel = agentPlugin.approvalGateway.registerChannel(
109
+ 'tui',
110
+ createInkApprovalChannel(config.uiService),
111
+ );
112
+ }
113
+ },
114
+ deactivate() {
115
+ unregisterApprovalChannel?.();
116
+ unregisterApprovalChannel = null;
117
+ unregisterFileMentions?.();
118
+ unregisterFileMentions = null;
119
+ unregisterTuiChannel?.();
120
+ unregisterTuiChannel = null;
121
+ inner.deactivate?.();
122
+ },
123
+ };
124
+ }
@@ -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
+ }