mu-coding 0.8.0 → 0.10.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 (41) hide show
  1. package/package.json +4 -4
  2. package/src/cli/install.ts +18 -3
  3. package/src/plugin.ts +33 -5
  4. package/src/runtime/createRegistry.test.ts +4 -3
  5. package/src/runtime/createRegistry.ts +34 -2
  6. package/src/runtime/fileMentionProvider.ts +117 -0
  7. package/src/runtime/pluginLoader.ts +37 -6
  8. package/src/tui/channel/tuiChannel.ts +14 -1
  9. package/src/tui/chat/useAbort.ts +5 -0
  10. package/src/tui/chat/useChat.ts +7 -0
  11. package/src/tui/chat/useChatPanel.ts +24 -3
  12. package/src/tui/chat/useChatSession.ts +105 -7
  13. package/src/tui/chat/useModels.ts +25 -1
  14. package/src/tui/chat/useSessionPersistence.ts +27 -11
  15. package/src/tui/chat/useStatusSegments.ts +26 -6
  16. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  17. package/src/tui/components/chat/ChatPanel.tsx +16 -1
  18. package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
  19. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  20. package/src/tui/components/messages/EditOutput.tsx +11 -5
  21. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  22. package/src/tui/components/messages/ToolHeader.tsx +6 -4
  23. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  24. package/src/tui/components/messages/assistantMessage.tsx +43 -10
  25. package/src/tui/components/messages/markdown.tsx +407 -0
  26. package/src/tui/components/messages/reasoningBlock.tsx +8 -6
  27. package/src/tui/components/messages/streamingOutput.tsx +1 -1
  28. package/src/tui/components/messages/toolCallBlock.tsx +2 -2
  29. package/src/tui/components/messages/userMessage.tsx +3 -3
  30. package/src/tui/components/primitives/toast.tsx +38 -7
  31. package/src/tui/components/statusBar.tsx +24 -15
  32. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  33. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  34. package/src/tui/input/InputBoxView.tsx +71 -15
  35. package/src/tui/input/commands.ts +5 -0
  36. package/src/tui/input/useInputBox.ts +29 -3
  37. package/src/tui/input/useInputHandler.ts +1 -0
  38. package/src/tui/input/useMentionPicker.ts +26 -14
  39. package/src/tui/renderApp.tsx +29 -8
  40. package/src/tui/theme/presets.ts +12 -1
  41. package/src/tui/theme/types.ts +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-coding",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Minimal terminal AI assistant for local models",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "ink": "^7.0.1",
27
- "mu-agents": "0.8.0",
28
- "mu-core": "0.8.0",
29
- "mu-openai-provider": "0.8.0",
27
+ "mu-agents": "0.10.0",
28
+ "mu-core": "0.10.0",
29
+ "mu-openai-provider": "0.10.0",
30
30
  "react": "^19.2.5"
31
31
  }
32
32
  }
@@ -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);
package/src/plugin.ts CHANGED
@@ -12,11 +12,13 @@
12
12
  * registry in after constructing it.
13
13
  */
14
14
 
15
- import type { ApprovalGateway } from 'mu-agents';
15
+ import type { ApprovalGateway, SubagentRunRegistry } from 'mu-agents';
16
16
  import type { ChatMessage, Plugin, PluginContext, PluginRegistry } from 'mu-core';
17
17
  import type { ShutdownFn } from './app/shutdown';
18
18
  import type { AppConfig } from './config/index';
19
19
  import { createCodingToolsPlugin } from './runtime/codingTools/index';
20
+ import type { SessionPathHolder } from './runtime/createRegistry';
21
+ import { createFileMentionProvider } from './runtime/fileMentionProvider';
20
22
  import type { HostMessageBus } from './runtime/messageBus';
21
23
  import { createTuiChannel } from './tui/channel/tuiChannel';
22
24
  import { createInkApprovalChannel } from './tui/plugins/InkApprovalChannel';
@@ -28,6 +30,12 @@ export interface CodingPluginConfig {
28
30
  messageBus: HostMessageBus;
29
31
  uiService: InkUIService;
30
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;
31
39
  /**
32
40
  * Concrete `PluginRegistry` instance used by the TUI to subscribe to
33
41
  * renderers / shortcuts / status segments. Required because `ctx.registry`
@@ -38,6 +46,7 @@ export interface CodingPluginConfig {
38
46
 
39
47
  interface AgentPluginShape {
40
48
  approvalGateway?: ApprovalGateway;
49
+ runs?: SubagentRunRegistry;
41
50
  }
42
51
 
43
52
  export function createCodingPlugin(config: CodingPluginConfig): Plugin {
@@ -49,6 +58,7 @@ export function createCodingPlugin(config: CodingPluginConfig): Plugin {
49
58
  // Captured at activation time so deactivate can clean up both registrations.
50
59
  let unregisterTuiChannel: (() => void) | null = null;
51
60
  let unregisterApprovalChannel: (() => void) | null = null;
61
+ let unregisterFileMentions: (() => void) | null = null;
52
62
 
53
63
  return {
54
64
  name: 'mu-coding',
@@ -62,6 +72,13 @@ export function createCodingPlugin(config: CodingPluginConfig): Plugin {
62
72
  // Register the TUI channel so other code can stop it gracefully via
63
73
  // ctx.channels.stopAll(). The TUI subscribes to the concrete registry
64
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
+
65
82
  unregisterTuiChannel =
66
83
  ctx.channels?.register(
67
84
  createTuiChannel({
@@ -71,13 +88,22 @@ export function createCodingPlugin(config: CodingPluginConfig): Plugin {
71
88
  messageBus: config.messageBus,
72
89
  uiService: config.uiService,
73
90
  shutdown: config.shutdown,
91
+ sessionPathHolder: config.sessionPathHolder,
92
+ subagentRuns: agentPlugin?.runs,
74
93
  }),
75
94
  ) ?? null;
76
95
 
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');
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`).
81
107
  if (agentPlugin?.approvalGateway) {
82
108
  unregisterApprovalChannel = agentPlugin.approvalGateway.registerChannel(
83
109
  'tui',
@@ -88,6 +114,8 @@ export function createCodingPlugin(config: CodingPluginConfig): Plugin {
88
114
  deactivate() {
89
115
  unregisterApprovalChannel?.();
90
116
  unregisterApprovalChannel = null;
117
+ unregisterFileMentions?.();
118
+ unregisterFileMentions = null;
91
119
  unregisterTuiChannel?.();
92
120
  unregisterTuiChannel = null;
93
121
  inner.deactivate?.();
@@ -124,7 +124,7 @@ describe('createRegistry — activation order + plugin propagation', () => {
124
124
  }
125
125
  });
126
126
 
127
- it('agent slash command is contributed by mu-agents (no coding-agents by default)', async () => {
127
+ it('mu-agents contributes per-primary-agent slash commands but no generic /agent', async () => {
128
128
  const cwd = mkdtempSync(join(tmpdir(), 'mu-cr-'));
129
129
  try {
130
130
  const ui = new InkUIService();
@@ -135,8 +135,9 @@ describe('createRegistry — activation order + plugin propagation', () => {
135
135
  });
136
136
 
137
137
  const commandNames = registry.getCommands().map((c) => c.name);
138
- expect(commandNames).toContain('agent');
139
- // build/plan/review come from mu-agents' DEFAULT_PRIMARY_AGENTS.
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');
140
141
  // `explore` originates from mu-coding-agents, which is no longer auto-loaded.
141
142
  expect(commandNames).not.toContain('explore');
142
143
  } finally {
@@ -1,3 +1,5 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
1
3
  import { createAgentsPlugin } from 'mu-agents';
2
4
  import type { ChatMessage } from 'mu-core';
3
5
  import {
@@ -13,10 +15,28 @@ import { createOpenAIProviderPlugin } from 'mu-openai-provider';
13
15
  import type { ShutdownFn } from '../app/shutdown';
14
16
  import type { AppConfig } from '../config/index';
15
17
  import { createCodingPlugin } from '../plugin';
18
+ import { saveSession } from '../sessions/index';
16
19
  import type { InkUIService } from '../tui/plugins/InkUIService';
17
20
  import { createMessageBus, type HostMessageBus } from './messageBus';
18
21
  import { discoverPluginFiles, loadConfiguredPlugin } from './pluginLoader';
19
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
+
20
40
  interface CreateRegistryOptions {
21
41
  cwd: string;
22
42
  config: AppConfig;
@@ -42,6 +62,7 @@ interface RegistryBundle {
42
62
  providers: ProviderRegistry;
43
63
  channels: ChannelRegistry;
44
64
  activity: ActivityBus;
65
+ sessionPathHolder: SessionPathHolder;
45
66
  }
46
67
 
47
68
  interface PluginConfigInputs {
@@ -101,6 +122,7 @@ async function registerBuiltins(
101
122
  options: CreateRegistryOptions,
102
123
  inputs: PluginConfigInputs,
103
124
  messageBus: HostMessageBus,
125
+ sessionPathHolder: SessionPathHolder,
104
126
  ): Promise<void> {
105
127
  await registry.register(createOpenAIProviderPlugin());
106
128
  await registry.register(
@@ -108,6 +130,14 @@ async function registerBuiltins(
108
130
  config: options.config,
109
131
  model: options.config.model,
110
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
+ },
111
141
  }),
112
142
  );
113
143
  await registry.register(
@@ -117,6 +147,7 @@ async function registerBuiltins(
117
147
  messageBus,
118
148
  uiService: options.uiService,
119
149
  shutdown: options.shutdown ?? noopShutdown,
150
+ sessionPathHolder,
120
151
  // Pass the concrete registry: the TUI subscribes to renderer / shortcut
121
152
  // / status streams that are not part of the narrow `PluginRegistryView`
122
153
  // exposed via `ctx.registry`.
@@ -133,6 +164,7 @@ export async function createRegistry(options: CreateRegistryOptions): Promise<Re
133
164
  const providers = createProviderRegistry();
134
165
  const channels = createChannelRegistry();
135
166
  const activity = createActivityBus();
167
+ const sessionPathHolder = createSessionPathHolder();
136
168
  const registry = new PluginRegistry({
137
169
  cwd,
138
170
  config: {},
@@ -146,7 +178,7 @@ export async function createRegistry(options: CreateRegistryOptions): Promise<Re
146
178
 
147
179
  const inputs: PluginConfigInputs = { uiService, shutdown, appConfig: config };
148
180
 
149
- await registerBuiltins(registry, options, inputs, messageBus);
181
+ await registerBuiltins(registry, options, inputs, messageBus, sessionPathHolder);
150
182
 
151
183
  // User-extension plugins ride on top of the builtins.
152
184
  for (const filePath of discoverPluginFiles()) {
@@ -159,5 +191,5 @@ export async function createRegistry(options: CreateRegistryOptions): Promise<Re
159
191
  await loadConfiguredPlugin(registry, name, buildPluginConfig(inputs, pluginConfig), uiService);
160
192
  }
161
193
 
162
- return { registry, messageBus, providers, channels, activity };
194
+ return { registry, messageBus, providers, channels, activity, sessionPathHolder };
163
195
  }
@@ -0,0 +1,117 @@
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
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: iterative FS walker with MAX_FILES early-exit — branching is the algorithm; extracting helpers would scatter the cap check.
35
+ function walkFs(root: string): string[] {
36
+ const out: string[] = [];
37
+ const stack: string[] = [root];
38
+ while (stack.length > 0 && out.length < MAX_FILES) {
39
+ const dir = stack.pop();
40
+ if (!dir) break;
41
+ let entries: Dirent[];
42
+ try {
43
+ entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
44
+ } catch {
45
+ continue;
46
+ }
47
+ for (const entry of entries) {
48
+ if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
49
+ if (IGNORE_DIRS.has(entry.name)) continue;
50
+ const full = join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ stack.push(full);
53
+ } else if (entry.isFile()) {
54
+ out.push(relative(root, full).split(sep).join('/'));
55
+ if (out.length >= MAX_FILES) break;
56
+ }
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function refreshCache(cwd: string): string[] {
63
+ if (cache && cacheCwd === cwd && Date.now() - cache.builtAt < CACHE_TTL_MS) {
64
+ return cache.files;
65
+ }
66
+ const files = listGitFiles(cwd) ?? walkFs(cwd);
67
+ cache = { files, builtAt: Date.now() };
68
+ cacheCwd = cwd;
69
+ return files;
70
+ }
71
+
72
+ /**
73
+ * Score a file path against `partial` for ranking.
74
+ * - exact basename match → 0 (best)
75
+ * - basename starts with partial → 1
76
+ * - basename contains partial → 2
77
+ * - path contains partial → 3
78
+ * - otherwise → Infinity (filtered out)
79
+ */
80
+ function scorePath(path: string, partial: string): number {
81
+ if (!partial) return path.length; // empty partial → shorter paths first
82
+ const lower = path.toLowerCase();
83
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
84
+ const p = partial.toLowerCase();
85
+ if (base === p) return 0;
86
+ if (base.startsWith(p)) return 1;
87
+ if (base.includes(p)) return 2;
88
+ if (lower.includes(p)) return 3;
89
+ return Number.POSITIVE_INFINITY;
90
+ }
91
+
92
+ /**
93
+ * Build the file mention provider bound to `cwd`. Suggests up to
94
+ * `MAX_RESULTS` files matched against the partial, ranked by basename
95
+ * proximity. Cached for {@link CACHE_TTL_MS} so rapid keystrokes don't
96
+ * re-walk the tree.
97
+ */
98
+ export function createFileMentionProvider(cwd: string): MentionProvider {
99
+ return (partial: string): MentionCompletion[] => {
100
+ const files = refreshCache(cwd);
101
+ if (files.length === 0) return [];
102
+ const scored: { path: string; score: number }[] = [];
103
+ for (const f of files) {
104
+ const score = scorePath(f, partial);
105
+ if (score < Number.POSITIVE_INFINITY) scored.push({ path: f, score });
106
+ }
107
+ scored.sort((a, b) => a.score - b.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
108
+ return scored.slice(0, MAX_RESULTS).map(({ path }) => ({
109
+ value: path,
110
+ // Show the full path so the basename sits at the end of the line —
111
+ // the picker truncates the prefix when needed (`wrap="truncate-start"`)
112
+ // so the filename stays visible.
113
+ label: path,
114
+ category: 'files',
115
+ }));
116
+ };
117
+ }
@@ -2,6 +2,7 @@ import { readdirSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { join, resolve } from 'node:path';
4
4
  import type { Plugin, PluginRegistry } from 'mu-core';
5
+ import { installNpmPackage } from '../cli/install';
5
6
  import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
6
7
  import type { InkUIService } from '../tui/plugins/InkUIService';
7
8
 
@@ -31,14 +32,44 @@ function formatPluginError(name: string, err: unknown): string {
31
32
  return parts.join(': ');
32
33
  }
33
34
 
34
- function resolveNpmPlugin(specifier: string): string {
35
- const { name } = parseBareNpmSpec(specifier.slice(4));
35
+ /**
36
+ * Resolve an `npm:<spec>` plugin specifier to an absolute path on disk.
37
+ *
38
+ * If the package isn't installed yet, runs `bun add <spec>` against the mu
39
+ * data dir and retries — so users can list a plugin in `config.plugins`
40
+ * without having to invoke `mu install` first.
41
+ *
42
+ * `uiService` is optional: when provided, surface "Installing …" / failure
43
+ * messages through the TUI; otherwise fall back to stderr so the host's
44
+ * boot log still shows what happened.
45
+ */
46
+ async function resolveNpmPlugin(specifier: string, uiService?: InkUIService): Promise<string> {
47
+ const bare = specifier.slice(4);
48
+ const { name } = parseBareNpmSpec(bare);
36
49
  const dataDir = getDataDir();
50
+ const require = createRequire(resolve(dataDir, 'package.json'));
51
+
37
52
  try {
38
- const require = createRequire(resolve(dataDir, 'package.json'));
39
53
  return require.resolve(name);
40
- } catch (err) {
41
- throw new Error(`Cannot resolve "${name}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
54
+ } catch (_firstErr) {
55
+ const installMsg = `Installing ${name}…`;
56
+ if (uiService) uiService.notify(installMsg, 'info');
57
+ else console.error(`[mu] ${installMsg}`);
58
+
59
+ try {
60
+ installNpmPackage(bare, { silent: true });
61
+ } catch (installErr) {
62
+ throw new Error(`Failed to auto-install "${name}" into ${dataDir}/node_modules`, { cause: installErr });
63
+ }
64
+
65
+ try {
66
+ return require.resolve(name);
67
+ } catch (retryErr) {
68
+ throw new Error(
69
+ `Auto-installed "${name}" but cannot resolve it from ${dataDir}/node_modules — install may have failed silently`,
70
+ { cause: retryErr },
71
+ );
72
+ }
42
73
  }
43
74
  }
44
75
 
@@ -85,7 +116,7 @@ export async function resolveConfiguredPlugin(
85
116
  const config = pluginConfig ?? {};
86
117
  let target: string;
87
118
  try {
88
- target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
119
+ target = name.startsWith('npm:') ? await resolveNpmPlugin(name, uiService) : name;
89
120
  } catch (err) {
90
121
  uiService?.notify(formatPluginError(name, err), 'error');
91
122
  return null;
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  import type { Instance } from 'ink';
8
+ import type { SubagentRunRegistry } from 'mu-agents';
8
9
  import type { Channel, ChatMessage, PluginRegistry } from 'mu-core';
9
10
  import type { ShutdownFn } from '../../app/shutdown';
10
11
  import type { AppConfig } from '../../config/index';
12
+ import type { SessionPathHolder } from '../../runtime/createRegistry';
11
13
  import type { HostMessageBus } from '../../runtime/messageBus';
12
14
  import type { InkUIService } from '../plugins/InkUIService';
13
15
  import { renderApp } from '../renderApp';
@@ -19,6 +21,8 @@ export interface TuiChannelOptions {
19
21
  messageBus: HostMessageBus;
20
22
  uiService: InkUIService;
21
23
  shutdown: ShutdownFn;
24
+ sessionPathHolder?: SessionPathHolder;
25
+ subagentRuns?: SubagentRunRegistry;
22
26
  }
23
27
 
24
28
  export function createTuiChannel(opts: TuiChannelOptions): Channel {
@@ -29,7 +33,16 @@ export function createTuiChannel(opts: TuiChannelOptions): Channel {
29
33
  // Idempotent: re-starting after a stop remounts; re-starting while
30
34
  // mounted is a no-op.
31
35
  if (instance) return;
32
- instance = renderApp(opts);
36
+ instance = renderApp({
37
+ config: opts.config,
38
+ initialMessages: opts.initialMessages,
39
+ registry: opts.registry,
40
+ messageBus: opts.messageBus,
41
+ uiService: opts.uiService,
42
+ shutdown: opts.shutdown,
43
+ sessionPathHolder: opts.sessionPathHolder,
44
+ subagentRuns: opts.subagentRuns,
45
+ });
33
46
  },
34
47
  async stop() {
35
48
  if (!instance) return;
@@ -7,9 +7,14 @@ function useDoublePress(timeoutMs: number) {
7
7
 
8
8
  const confirm = useCallback(() => {
9
9
  if (warning) {
10
+ // Confirmed press: cancel the pending auto-reset and clear the
11
+ // warning flag immediately so the status hint ("Esc again to stop"
12
+ // / "Ctrl+C again to quit") disappears as soon as the action fires.
10
13
  if (timerRef.current) {
11
14
  clearTimeout(timerRef.current);
15
+ timerRef.current = null;
12
16
  }
17
+ setWarning(false);
13
18
  return true;
14
19
  }
15
20
  setWarning(true);
@@ -1,4 +1,5 @@
1
1
  import { useApp } from 'ink';
2
+ import type { SubagentRunRegistry } from 'mu-agents';
2
3
  import {
3
4
  type ChatMessage,
4
5
  createSessionManager,
@@ -8,6 +9,7 @@ import {
8
9
  } from 'mu-core';
9
10
  import { useEffect, useMemo, useRef, useState } from 'react';
10
11
  import type { ShutdownFn } from '../../app/shutdown';
12
+ import type { SessionPathHolder } from '../../runtime/createRegistry';
11
13
  import type { HostMessageBus } from '../../runtime/messageBus';
12
14
  import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
13
15
  import type { InkUIService } from '../plugins/InkUIService';
@@ -30,6 +32,7 @@ export interface ChatContextValue {
30
32
  registry: PluginRegistry;
31
33
  uiService?: InkUIService;
32
34
  messageBus?: HostMessageBus;
35
+ subagentRuns?: SubagentRunRegistry;
33
36
  }
34
37
 
35
38
  export function useChat(
@@ -39,6 +42,8 @@ export function useChat(
39
42
  shutdown?: ShutdownFn,
40
43
  uiService?: InkUIService,
41
44
  messageBus?: HostMessageBus,
45
+ sessionPathHolder?: SessionPathHolder,
46
+ subagentRuns?: SubagentRunRegistry,
42
47
  ): ChatContextValue {
43
48
  const { exit } = useApp();
44
49
  const controllerRef = useRef<AbortController | null>(null);
@@ -65,6 +70,7 @@ export function useChat(
65
70
  initialMessages,
66
71
  registry,
67
72
  messageBus,
73
+ sessionPathHolder,
68
74
  });
69
75
  const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
70
76
 
@@ -102,5 +108,6 @@ export function useChat(
102
108
  registry,
103
109
  uiService,
104
110
  messageBus,
111
+ subagentRuns,
105
112
  };
106
113
  }
@@ -1,7 +1,9 @@
1
1
  import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
+ import type { SubagentRunRegistry } from 'mu-agents';
2
3
  import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
3
4
  import { useEffect, useMemo, useRef } from 'react';
4
5
  import type { ShutdownFn } from '../../app/shutdown';
6
+ import type { SessionPathHolder } from '../../runtime/createRegistry';
5
7
  import type { HostMessageBus } from '../../runtime/messageBus';
6
8
  import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
7
9
  import { useToast } from '../components/primitives/toast';
@@ -11,6 +13,7 @@ import type { InkUIService, ToastRequest } from '../plugins/InkUIService';
11
13
  import { useChat } from './useChat';
12
14
  import { usePluginStatus } from './usePluginStatus';
13
15
  import { useStatusSegments } from './useStatusSegments';
16
+ import { type SubagentBrowserState, useSubagentBrowser } from './useSubagentBrowser';
14
17
 
15
18
  const TOAST_LEVEL_COLORS: Record<string, string> = {
16
19
  info: 'cyan',
@@ -26,11 +29,24 @@ interface UseChatPanelOptions {
26
29
  messageBus?: HostMessageBus;
27
30
  uiService?: InkUIService;
28
31
  shutdown?: ShutdownFn;
32
+ sessionPathHolder?: SessionPathHolder;
33
+ subagentRuns?: SubagentRunRegistry;
29
34
  }
30
35
 
31
36
  export function useChatPanel(options: UseChatPanelOptions) {
32
- const { config, initialMessages, registry, messageBus, uiService, shutdown } = options;
33
- const ctx = useChat(config, registry, initialMessages, shutdown, uiService, messageBus);
37
+ const { config, initialMessages, registry, messageBus, uiService, shutdown, sessionPathHolder, subagentRuns } =
38
+ options;
39
+ const ctx = useChat(
40
+ config,
41
+ registry,
42
+ initialMessages,
43
+ shutdown,
44
+ uiService,
45
+ messageBus,
46
+ sessionPathHolder,
47
+ subagentRuns,
48
+ );
49
+ const browser = useSubagentBrowser(subagentRuns);
34
50
  const { width, height } = useTerminalSize();
35
51
  const viewRef = useRef<InkDOMElement>(null);
36
52
  const contentRef = useRef<InkDOMElement>(null);
@@ -59,6 +75,7 @@ export function useChatPanel(options: UseChatPanelOptions) {
59
75
  });
60
76
  }, [uiService, show]);
61
77
 
78
+ const contextLimit = ctx.models.models.find((m) => m.id === ctx.models.currentModel)?.contextLimit;
62
79
  const statusSegments = useStatusSegments({
63
80
  streaming: ctx.session.streaming,
64
81
  abortWarning: ctx.abort.abortWarning,
@@ -67,6 +84,7 @@ export function useChatPanel(options: UseChatPanelOptions) {
67
84
  modelError: ctx.models.modelError,
68
85
  totalTokens: ctx.session.stream.totalTokens,
69
86
  cachedTokens: ctx.session.stream.cachedTokens,
87
+ contextLimit,
70
88
  pluginStatus,
71
89
  });
72
90
 
@@ -78,7 +96,7 @@ export function useChatPanel(options: UseChatPanelOptions) {
78
96
  scrollOffset,
79
97
  viewHeight,
80
98
  contentHeight,
81
- isActive: !anyModalOpen,
99
+ isActive: !anyModalOpen && browser.mode.kind === 'chat',
82
100
  onScrollUp,
83
101
  onScrollDown,
84
102
  uiService,
@@ -92,7 +110,10 @@ export function useChatPanel(options: UseChatPanelOptions) {
92
110
  statusSegments,
93
111
  toasts,
94
112
  onDismissToast: dismiss,
113
+ browser,
95
114
  };
96
115
 
97
116
  return { ctx, bodyProps };
98
117
  }
118
+
119
+ export type { SubagentBrowserState };