mu-coding 0.8.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/package.json +4 -4
- package/src/cli/install.ts +18 -3
- package/src/plugin.ts +33 -5
- package/src/runtime/createRegistry.test.ts +4 -3
- package/src/runtime/createRegistry.ts +34 -2
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/pluginLoader.ts +37 -6
- package/src/tui/channel/tuiChannel.ts +14 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useChat.ts +7 -0
- package/src/tui/chat/useChatPanel.ts +24 -3
- package/src/tui/chat/useChatSession.ts +105 -7
- package/src/tui/chat/useModels.ts +25 -1
- package/src/tui/chat/useSessionPersistence.ts +27 -11
- package/src/tui/chat/useStatusSegments.ts +26 -6
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +16 -1
- package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messages/EditOutput.tsx +11 -5
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +6 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +43 -10
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/reasoningBlock.tsx +8 -6
- package/src/tui/components/messages/streamingOutput.tsx +1 -1
- package/src/tui/components/messages/toolCallBlock.tsx +2 -2
- package/src/tui/components/messages/userMessage.tsx +3 -3
- package/src/tui/components/primitives/toast.tsx +38 -7
- package/src/tui/components/statusBar.tsx +24 -15
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +71 -15
- package/src/tui/input/commands.ts +5 -0
- package/src/tui/input/useInputBox.ts +29 -3
- package/src/tui/input/useInputHandler.ts +1 -0
- package/src/tui/input/useMentionPicker.ts +26 -14
- package/src/tui/renderApp.tsx +29 -8
- package/src/tui/theme/presets.ts +12 -1
- 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.
|
|
3
|
+
"version": "0.9.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.
|
|
28
|
-
"mu-core": "0.
|
|
29
|
-
"mu-openai-provider": "0.
|
|
27
|
+
"mu-agents": "0.9.0",
|
|
28
|
+
"mu-core": "0.9.0",
|
|
29
|
+
"mu-openai-provider": "0.9.0",
|
|
30
30
|
"react": "^19.2.5"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
// mu-agents
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
//
|
|
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,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
|
+
}
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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 (
|
|
41
|
-
|
|
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(
|
|
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;
|
package/src/tui/chat/useAbort.ts
CHANGED
|
@@ -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);
|
package/src/tui/chat/useChat.ts
CHANGED
|
@@ -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 } =
|
|
33
|
-
|
|
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 };
|