mu-coding 0.1.0 → 0.2.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 +3 -3
- package/src/cli.ts +8 -6
- package/src/install.ts +88 -0
- package/src/main.tsx +88 -7
- package/src/subcommands.ts +20 -0
- package/src/tui/commands.ts +18 -4
- package/src/tui/components/chat/ChatPanel.tsx +4 -0
- package/src/tui/components/chat/ChatPanelBody.tsx +21 -1
- package/src/tui/components/inputBox.tsx +2 -1
- package/src/tui/components/ui/dialogLayer.tsx +156 -0
- package/src/tui/components/ui/toast.tsx +33 -11
- package/src/tui/hooks/useInputHandler.ts +17 -6
- package/src/tui/services/uiService.ts +155 -0
- package/src/tui/useAbort.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Minimal terminal AI assistant for local models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"ink": "^7.0.1",
|
|
15
|
-
"mu-agents": "0.
|
|
16
|
-
"mu-provider": "0.
|
|
15
|
+
"mu-agents": "0.2.0",
|
|
16
|
+
"mu-provider": "0.2.0",
|
|
17
17
|
"react": "^19.2.5"
|
|
18
18
|
}
|
|
19
19
|
}
|
package/src/cli.ts
CHANGED
|
@@ -12,12 +12,14 @@ function printHelp(): never {
|
|
|
12
12
|
console.log(`mu — minimal terminal AI assistant
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
15
|
-
mu
|
|
16
|
-
mu -p "prompt"
|
|
17
|
-
mu -m model -p "p"
|
|
18
|
-
mu -m model
|
|
19
|
-
mu -c
|
|
20
|
-
mu --session <path>
|
|
15
|
+
mu Start interactive chat
|
|
16
|
+
mu -p "prompt" Single-shot prompt, then exit
|
|
17
|
+
mu -m model -p "p" Single-shot with specific model
|
|
18
|
+
mu -m model Interactive with specific model
|
|
19
|
+
mu -c Continue most recent session
|
|
20
|
+
mu --session <path> Resume a specific session file
|
|
21
|
+
mu install npm:<package> Install a plugin from npm
|
|
22
|
+
mu uninstall npm:<pkg> Remove an installed plugin
|
|
21
23
|
|
|
22
24
|
Config (XDG):
|
|
23
25
|
~/.config/mu/config.json — configuration (baseUrl, model, streamTimeoutMs)
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getDataDir, loadConfig, saveConfig } from './config';
|
|
5
|
+
|
|
6
|
+
const INIT_PACKAGE_JSON = JSON.stringify({ private: true, dependencies: {} }, null, 2);
|
|
7
|
+
|
|
8
|
+
function ensureDataDir(): string {
|
|
9
|
+
const dataDir = getDataDir();
|
|
10
|
+
mkdirSync(dataDir, { recursive: true });
|
|
11
|
+
|
|
12
|
+
const pkgPath = join(dataDir, 'package.json');
|
|
13
|
+
if (!existsSync(pkgPath)) {
|
|
14
|
+
writeFileSync(pkgPath, INIT_PACKAGE_JSON, 'utf-8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return dataDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stripNpmPrefix(specifier: string): string {
|
|
21
|
+
if (!specifier.startsWith('npm:')) {
|
|
22
|
+
console.error(`Error: package specifier must start with npm: — got "${specifier}"`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return specifier.slice(4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runInstall(args: string[]): Promise<void> {
|
|
29
|
+
if (args.length === 0) {
|
|
30
|
+
console.error('Usage: mu install npm:<package>');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dataDir = ensureDataDir();
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const plugins = config.plugins ?? [];
|
|
37
|
+
|
|
38
|
+
for (const specifier of args) {
|
|
39
|
+
const bare = stripNpmPrefix(specifier);
|
|
40
|
+
|
|
41
|
+
console.log(`Installing ${bare}...`);
|
|
42
|
+
try {
|
|
43
|
+
execSync(`bun add ${bare}`, { cwd: dataDir, stdio: 'inherit' });
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(`Failed to install ${bare}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Add to plugins if not already present
|
|
50
|
+
const existing = plugins.some((p) => (typeof p === 'string' ? p : p.name) === specifier);
|
|
51
|
+
if (!existing) {
|
|
52
|
+
plugins.push(specifier);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(`✓ ${specifier}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
saveConfig({ plugins });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runUninstall(args: string[]): Promise<void> {
|
|
62
|
+
if (args.length === 0) {
|
|
63
|
+
console.error('Usage: mu uninstall npm:<package>');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dataDir = ensureDataDir();
|
|
68
|
+
const config = loadConfig();
|
|
69
|
+
let plugins = config.plugins ?? [];
|
|
70
|
+
|
|
71
|
+
for (const specifier of args) {
|
|
72
|
+
const bare = stripNpmPrefix(specifier);
|
|
73
|
+
|
|
74
|
+
console.log(`Removing ${bare}...`);
|
|
75
|
+
try {
|
|
76
|
+
execSync(`bun remove ${bare}`, { cwd: dataDir, stdio: 'inherit' });
|
|
77
|
+
} catch {
|
|
78
|
+
console.error(`Failed to remove ${bare}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
plugins = plugins.filter((p) => (typeof p === 'string' ? p : p.name) !== specifier);
|
|
83
|
+
|
|
84
|
+
console.log(`✓ Removed ${specifier}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
saveConfig({ plugins });
|
|
88
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { readdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
4
5
|
import { render } from 'ink';
|
|
5
6
|
import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
|
|
6
7
|
import { parseArgs, resolveInitialMessages } from './cli';
|
|
7
|
-
import { type AppConfig, getPluginsDir, loadConfig } from './config';
|
|
8
|
+
import { type AppConfig, getDataDir, getPluginsDir, loadConfig } from './config';
|
|
8
9
|
import { runSingleShot } from './singleShot';
|
|
10
|
+
import { handleSubcommand } from './subcommands';
|
|
9
11
|
import { ChatPanel } from './tui/components/chat/ChatPanel';
|
|
12
|
+
import { InkUIService } from './tui/services/uiService';
|
|
10
13
|
|
|
11
14
|
function discoverPluginFiles(): string[] {
|
|
12
15
|
const dir = getPluginsDir();
|
|
@@ -19,7 +22,78 @@ function discoverPluginFiles(): string[] {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Resolve an npm: specifier to an absolute path via the data dir's node_modules.
|
|
27
|
+
*/
|
|
28
|
+
function formatPluginError(name: string, err: unknown): string {
|
|
29
|
+
const parts: string[] = [`Plugin "${name}" failed`];
|
|
30
|
+
let current: unknown = err;
|
|
31
|
+
while (current) {
|
|
32
|
+
if (current instanceof Error) {
|
|
33
|
+
parts.push(current.message);
|
|
34
|
+
current = current.cause;
|
|
35
|
+
} else {
|
|
36
|
+
parts.push(String(current));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return parts.join(': ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveNpmPlugin(specifier: string): string {
|
|
44
|
+
const bare = specifier.slice(4);
|
|
45
|
+
const dataDir = getDataDir();
|
|
46
|
+
try {
|
|
47
|
+
const require = createRequire(resolve(dataDir, 'package.json'));
|
|
48
|
+
return require.resolve(bare);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`Cannot resolve "${bare}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load a plugin by name or path, resolving from this package's context.
|
|
56
|
+
* This allows workspace packages (like mu-pi-compat) to be found even though
|
|
57
|
+
* mu-agents' registry can't resolve them from its own location.
|
|
58
|
+
*
|
|
59
|
+
* Plugins prefixed with npm: are resolved from ~/.local/share/mu/node_modules/.
|
|
60
|
+
*/
|
|
61
|
+
async function loadPluginFromHere(
|
|
62
|
+
registry: PluginRegistry,
|
|
63
|
+
name: string,
|
|
64
|
+
pluginConfig?: Record<string, unknown>,
|
|
65
|
+
uiService?: InkUIService,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
const target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
|
|
69
|
+
const mod = await import(target);
|
|
70
|
+
const factory = mod.default ?? mod.createPlugin;
|
|
71
|
+
|
|
72
|
+
if (typeof factory === 'function') {
|
|
73
|
+
const plugin = factory(pluginConfig ?? {});
|
|
74
|
+
await registry.register(plugin);
|
|
75
|
+
} else if (typeof mod === 'object' && mod !== null && 'name' in mod) {
|
|
76
|
+
await registry.register(mod);
|
|
77
|
+
} else {
|
|
78
|
+
const exportKeys = Object.keys(mod).join(', ') || '(none)';
|
|
79
|
+
uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// npm: plugins don't fall back — they must resolve from data dir
|
|
83
|
+
if (name.startsWith('npm:')) {
|
|
84
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Non-npm plugins fall back to registry loader (for file paths)
|
|
88
|
+
try {
|
|
89
|
+
await registry.loadPlugin(name, pluginConfig);
|
|
90
|
+
} catch (fallbackErr) {
|
|
91
|
+
uiService?.notify(formatPluginError(name, fallbackErr), 'error');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function createRegistry(cwd: string, config: AppConfig, uiService: InkUIService) {
|
|
23
97
|
const registry = new PluginRegistry({ cwd, config: {} });
|
|
24
98
|
|
|
25
99
|
// Register built-in tools (read, write, edit, bash)
|
|
@@ -30,12 +104,16 @@ async function createRegistry(cwd: string, config: AppConfig) {
|
|
|
30
104
|
await registry.loadPlugin(filePath);
|
|
31
105
|
}
|
|
32
106
|
|
|
33
|
-
// Load
|
|
107
|
+
// Load configured plugins
|
|
34
108
|
if (config.plugins?.length) {
|
|
35
109
|
for (const entry of config.plugins) {
|
|
36
110
|
const name = typeof entry === 'string' ? entry : entry.name;
|
|
37
111
|
const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
|
|
38
|
-
|
|
112
|
+
|
|
113
|
+
// Inject uiService for plugins that accept it (duck typing)
|
|
114
|
+
const finalConfig = pluginConfig ? { ...pluginConfig, ui: uiService } : { ui: uiService };
|
|
115
|
+
|
|
116
|
+
await loadPluginFromHere(registry, name, finalConfig, uiService);
|
|
39
117
|
}
|
|
40
118
|
}
|
|
41
119
|
|
|
@@ -43,11 +121,14 @@ async function createRegistry(cwd: string, config: AppConfig) {
|
|
|
43
121
|
}
|
|
44
122
|
|
|
45
123
|
async function main() {
|
|
124
|
+
if (await handleSubcommand()) return;
|
|
125
|
+
|
|
46
126
|
const cliArgs = parseArgs();
|
|
47
127
|
const config = loadConfig(cliArgs.model);
|
|
48
128
|
const root = process.cwd();
|
|
49
129
|
|
|
50
|
-
const
|
|
130
|
+
const uiService = new InkUIService();
|
|
131
|
+
const registry = await createRegistry(root, config, uiService);
|
|
51
132
|
|
|
52
133
|
if (cliArgs.prompt) {
|
|
53
134
|
try {
|
|
@@ -64,7 +145,7 @@ async function main() {
|
|
|
64
145
|
|
|
65
146
|
const initialMessages = resolveInitialMessages(cliArgs);
|
|
66
147
|
|
|
67
|
-
render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} />, {
|
|
148
|
+
render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} uiService={uiService} />, {
|
|
68
149
|
exitOnCtrlC: false,
|
|
69
150
|
kittyKeyboard: { mode: 'enabled' },
|
|
70
151
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runInstall, runUninstall } from './install';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handle CLI subcommands that run before the TUI.
|
|
5
|
+
* Returns true if a subcommand was handled (caller should exit).
|
|
6
|
+
*/
|
|
7
|
+
export async function handleSubcommand(): Promise<boolean> {
|
|
8
|
+
const sub = process.argv[2];
|
|
9
|
+
|
|
10
|
+
switch (sub) {
|
|
11
|
+
case 'install':
|
|
12
|
+
await runInstall(process.argv.slice(3));
|
|
13
|
+
return true;
|
|
14
|
+
case 'uninstall':
|
|
15
|
+
await runUninstall(process.argv.slice(3));
|
|
16
|
+
return true;
|
|
17
|
+
default:
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/tui/commands.ts
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
|
+
import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
|
|
2
|
+
|
|
1
3
|
export interface SlashCommand {
|
|
2
4
|
name: string;
|
|
3
5
|
description: string;
|
|
4
|
-
action
|
|
6
|
+
action?: string;
|
|
7
|
+
execute?: (args: string) => Promise<string | undefined>;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
|
-
const
|
|
10
|
+
const BUILTIN_COMMANDS: SlashCommand[] = [
|
|
8
11
|
{ name: '/model', description: 'Select a model', action: 'model' },
|
|
9
12
|
{ name: '/sessions', description: 'List project sessions', action: 'sessions' },
|
|
10
13
|
{ name: '/new', description: 'New conversation', action: 'new' },
|
|
11
14
|
];
|
|
12
15
|
|
|
13
|
-
export function matchCommands(input: string): SlashCommand[] {
|
|
16
|
+
export function matchCommands(input: string, pluginCommands: PluginSlashCommand[] = []): SlashCommand[] {
|
|
14
17
|
if (!input.startsWith('/')) {
|
|
15
18
|
return [];
|
|
16
19
|
}
|
|
17
20
|
const q = input.toLowerCase();
|
|
18
|
-
|
|
21
|
+
|
|
22
|
+
const fromPlugins: SlashCommand[] = pluginCommands.map((pc) => ({
|
|
23
|
+
name: `/${pc.name}`,
|
|
24
|
+
description: pc.description,
|
|
25
|
+
execute: (args: string) => {
|
|
26
|
+
const ctx: CommandContext = { messages: [], cwd: process.cwd(), config: {} as CommandContext['config'] };
|
|
27
|
+
return pc.execute(args, ctx);
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const all = [...BUILTIN_COMMANDS, ...fromPlugins];
|
|
32
|
+
return all.filter((cmd) => cmd.name.startsWith(q));
|
|
19
33
|
}
|
|
@@ -5,6 +5,7 @@ import { useRef } from 'react';
|
|
|
5
5
|
import { ChatContext } from '../../context/chat';
|
|
6
6
|
import { useScroll } from '../../hooks/useScroll';
|
|
7
7
|
import { useMeasure, useTerminalSize } from '../../hooks/useTerminal';
|
|
8
|
+
import type { InkUIService } from '../../services/uiService';
|
|
8
9
|
import { useChat } from '../../useChat';
|
|
9
10
|
import { ChatPanelBody } from './ChatPanelBody';
|
|
10
11
|
|
|
@@ -12,10 +13,12 @@ export function ChatPanel({
|
|
|
12
13
|
config,
|
|
13
14
|
initialMessages,
|
|
14
15
|
registry,
|
|
16
|
+
uiService,
|
|
15
17
|
}: {
|
|
16
18
|
config: ProviderConfig;
|
|
17
19
|
initialMessages?: ChatMessage[];
|
|
18
20
|
registry: PluginRegistry;
|
|
21
|
+
uiService?: InkUIService;
|
|
19
22
|
}) {
|
|
20
23
|
const ctx = useChat(config, registry, initialMessages);
|
|
21
24
|
const { width, height } = useTerminalSize();
|
|
@@ -49,6 +52,7 @@ export function ChatPanel({
|
|
|
49
52
|
isActive={!anyModalOpen}
|
|
50
53
|
onScrollUp={onScrollUp}
|
|
51
54
|
onScrollDown={onScrollDown}
|
|
55
|
+
uiService={uiService}
|
|
52
56
|
/>
|
|
53
57
|
</ChatContext.Provider>
|
|
54
58
|
);
|
|
@@ -2,8 +2,11 @@ import { Box, type DOMElement as InkDOMElement } from 'ink';
|
|
|
2
2
|
import type { StatusSegment } from 'mu-agents';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { useChatContext } from '../../context/chat';
|
|
5
|
+
import type { InkUIService, ToastRequest } from '../../services/uiService';
|
|
5
6
|
import { MessageView, StatusBar } from '../chatLayout';
|
|
6
7
|
import { InputBox } from '../inputBox';
|
|
8
|
+
import { DialogLayer } from '../ui/dialogLayer';
|
|
9
|
+
import { ToastContainer, useToast } from '../ui/toast';
|
|
7
10
|
import { Pickers } from './Pickers';
|
|
8
11
|
|
|
9
12
|
interface LayoutProps {
|
|
@@ -19,9 +22,24 @@ interface LayoutProps {
|
|
|
19
22
|
onScrollDown: () => void;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
const TOAST_LEVEL_COLORS: Record<string, string> = {
|
|
26
|
+
info: 'cyan',
|
|
27
|
+
success: 'green',
|
|
28
|
+
warning: 'yellow',
|
|
29
|
+
error: 'red',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function ChatPanelBody(props: LayoutProps & { uiService?: InkUIService }) {
|
|
23
33
|
const { session, models, abort, registry } = useChatContext();
|
|
24
34
|
const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
|
|
35
|
+
const { toasts, show, dismiss } = useToast();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!props.uiService) return;
|
|
39
|
+
props.uiService.onToast((toast: ToastRequest) => {
|
|
40
|
+
show(toast.message, TOAST_LEVEL_COLORS[toast.level] ?? 'white');
|
|
41
|
+
});
|
|
42
|
+
}, [props.uiService, show]);
|
|
25
43
|
|
|
26
44
|
useEffect(() => {
|
|
27
45
|
const refresh = () => setPluginStatus(registry.getStatusSegments());
|
|
@@ -62,6 +80,8 @@ export function ChatPanelBody(props: LayoutProps) {
|
|
|
62
80
|
pluginStatus={pluginStatus}
|
|
63
81
|
/>
|
|
64
82
|
<Pickers />
|
|
83
|
+
{props.uiService && <DialogLayer service={props.uiService} />}
|
|
84
|
+
<ToastContainer toasts={toasts} onDismiss={dismiss} />
|
|
65
85
|
</Box>
|
|
66
86
|
);
|
|
67
87
|
}
|
|
@@ -103,7 +103,7 @@ export function InputBox({
|
|
|
103
103
|
model = '',
|
|
104
104
|
history = [],
|
|
105
105
|
}: InputBoxProps) {
|
|
106
|
-
const { session, toggles, attachment, models, abort } = useChatContext();
|
|
106
|
+
const { session, toggles, attachment, models, abort, registry } = useChatContext();
|
|
107
107
|
|
|
108
108
|
const actions: InputActions = {
|
|
109
109
|
onCtrlC: abort.onCtrlC,
|
|
@@ -124,6 +124,7 @@ export function InputBox({
|
|
|
124
124
|
history,
|
|
125
125
|
actions,
|
|
126
126
|
onSubmit,
|
|
127
|
+
pluginCommands: registry.getCommands(),
|
|
127
128
|
});
|
|
128
129
|
|
|
129
130
|
return (
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import type { DialogRequest, InkUIService } from '../../services/uiService';
|
|
4
|
+
import { Dropdown } from './dropdown';
|
|
5
|
+
import { Modal } from './modal';
|
|
6
|
+
|
|
7
|
+
// ─── Confirm Dialog ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function ConfirmDialog({
|
|
10
|
+
dialog,
|
|
11
|
+
onResolve,
|
|
12
|
+
onCancel,
|
|
13
|
+
}: {
|
|
14
|
+
dialog: DialogRequest;
|
|
15
|
+
onResolve: (value: unknown) => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const [selected, setSelected] = useState(0);
|
|
19
|
+
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
if (key.escape) {
|
|
22
|
+
onCancel();
|
|
23
|
+
} else if (key.return) {
|
|
24
|
+
onResolve(selected === 0);
|
|
25
|
+
} else if (key.leftArrow || input === 'h') {
|
|
26
|
+
setSelected(0);
|
|
27
|
+
} else if (key.rightArrow || input === 'l') {
|
|
28
|
+
setSelected(1);
|
|
29
|
+
} else if (input === 'y' || input === 'Y') {
|
|
30
|
+
onResolve(true);
|
|
31
|
+
} else if (input === 'n' || input === 'N') {
|
|
32
|
+
onResolve(false);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Modal visible={true} title={dialog.title}>
|
|
38
|
+
{dialog.message && (
|
|
39
|
+
<Box marginBottom={1}>
|
|
40
|
+
<Text>{dialog.message}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
)}
|
|
43
|
+
<Box gap={2}>
|
|
44
|
+
<Text color={selected === 0 ? 'green' : undefined} bold={selected === 0}>
|
|
45
|
+
{selected === 0 ? '▸ ' : ' '}Yes
|
|
46
|
+
</Text>
|
|
47
|
+
<Text color={selected === 1 ? 'red' : undefined} bold={selected === 1}>
|
|
48
|
+
{selected === 1 ? '▸ ' : ' '}No
|
|
49
|
+
</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
<Box marginTop={1}>
|
|
52
|
+
<Text dimColor={true}>y/n · Enter to confirm · Esc to cancel</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
</Modal>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Select Dialog ────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function SelectDialog({
|
|
61
|
+
dialog,
|
|
62
|
+
onResolve,
|
|
63
|
+
onCancel,
|
|
64
|
+
}: {
|
|
65
|
+
dialog: DialogRequest;
|
|
66
|
+
onResolve: (value: unknown) => void;
|
|
67
|
+
onCancel: () => void;
|
|
68
|
+
}) {
|
|
69
|
+
const items = (dialog.options ?? []).map((opt) => ({
|
|
70
|
+
label: opt,
|
|
71
|
+
value: opt,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Modal visible={true} title={dialog.title}>
|
|
76
|
+
<Dropdown items={items} placeholder="Filter..." onSelect={(item) => onResolve(item.value)} onCancel={onCancel} />
|
|
77
|
+
</Modal>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Input Dialog ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function InputDialog({
|
|
84
|
+
dialog,
|
|
85
|
+
onResolve,
|
|
86
|
+
onCancel,
|
|
87
|
+
}: {
|
|
88
|
+
dialog: DialogRequest;
|
|
89
|
+
onResolve: (value: unknown) => void;
|
|
90
|
+
onCancel: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
const [value, setValue] = useState('');
|
|
93
|
+
|
|
94
|
+
useInput((input, key) => {
|
|
95
|
+
if (key.escape) {
|
|
96
|
+
onCancel();
|
|
97
|
+
} else if (key.return) {
|
|
98
|
+
onResolve(value || null);
|
|
99
|
+
} else if (key.backspace || key.delete) {
|
|
100
|
+
setValue((v) => v.slice(0, -1));
|
|
101
|
+
} else if (input && input.length === 1) {
|
|
102
|
+
setValue((v) => v + input);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Modal visible={true} title={dialog.title}>
|
|
108
|
+
<Box flexDirection="column">
|
|
109
|
+
<Box paddingX={1} marginBottom={1}>
|
|
110
|
+
{!value && dialog.placeholder && <Text dimColor={true}>{dialog.placeholder}</Text>}
|
|
111
|
+
{value && <Text>{value}</Text>}
|
|
112
|
+
<Text inverse={true}>▎</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
<Box>
|
|
115
|
+
<Text dimColor={true}>Enter to submit · Esc to cancel</Text>
|
|
116
|
+
</Box>
|
|
117
|
+
</Box>
|
|
118
|
+
</Modal>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Dialog Layer ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export function DialogLayer({ service }: { service: InkUIService }) {
|
|
125
|
+
const [dialog, setDialog] = useState<DialogRequest | null>(service.currentDialog());
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
return service.subscribe(() => {
|
|
129
|
+
setDialog(service.currentDialog());
|
|
130
|
+
});
|
|
131
|
+
}, [service]);
|
|
132
|
+
|
|
133
|
+
const handleResolve = useCallback(
|
|
134
|
+
(value: unknown) => {
|
|
135
|
+
service.resolveDialog(value);
|
|
136
|
+
},
|
|
137
|
+
[service],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const handleCancel = useCallback(() => {
|
|
141
|
+
service.cancelDialog();
|
|
142
|
+
}, [service]);
|
|
143
|
+
|
|
144
|
+
if (!dialog) return null;
|
|
145
|
+
|
|
146
|
+
switch (dialog.type) {
|
|
147
|
+
case 'confirm':
|
|
148
|
+
return <ConfirmDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
149
|
+
case 'select':
|
|
150
|
+
return <SelectDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
151
|
+
case 'input':
|
|
152
|
+
return <InputDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
153
|
+
default:
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Box, Text, useStdout } from 'ink';
|
|
2
|
-
import { useState } from 'react';
|
|
1
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
2
|
+
import { useCallback, useState } from 'react';
|
|
3
3
|
|
|
4
4
|
export interface Toast {
|
|
5
5
|
id: number;
|
|
@@ -12,31 +12,53 @@ let nextId = 0;
|
|
|
12
12
|
export function useToast() {
|
|
13
13
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
14
14
|
|
|
15
|
-
const show = (message: string, color?: string
|
|
15
|
+
const show = useCallback((message: string, color?: string) => {
|
|
16
16
|
const id = nextId++;
|
|
17
17
|
setToasts((prev) => [...prev, { id, message, color }]);
|
|
18
|
-
|
|
19
|
-
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
20
|
-
}, durationMs);
|
|
21
|
-
};
|
|
18
|
+
}, []);
|
|
22
19
|
|
|
23
|
-
|
|
20
|
+
const dismiss = useCallback((id: number) => {
|
|
21
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const dismissFirst = useCallback(() => {
|
|
25
|
+
setToasts((prev) => prev.slice(1));
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return { toasts, show, dismiss, dismissFirst };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
export function ToastContainer({ toasts }: { toasts: Toast[] }) {
|
|
31
|
+
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
|
27
32
|
const { stdout } = useStdout();
|
|
28
33
|
const columns = stdout.columns;
|
|
29
34
|
|
|
35
|
+
useInput((_input, key) => {
|
|
36
|
+
if (toasts.length > 0 && key.escape) {
|
|
37
|
+
onDismiss(toasts[0].id);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
30
41
|
if (toasts.length === 0) {
|
|
31
42
|
return null;
|
|
32
43
|
}
|
|
33
44
|
|
|
45
|
+
const maxWidth = Math.min(60, columns - 4);
|
|
46
|
+
|
|
34
47
|
return (
|
|
35
48
|
<Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
|
|
36
49
|
<Box flexDirection="column" gap={1}>
|
|
37
50
|
{toasts.map((t) => (
|
|
38
|
-
<Box key={t.id} backgroundColor="#1a1a1a" paddingX={2} paddingY={0}>
|
|
39
|
-
<
|
|
51
|
+
<Box key={t.id} backgroundColor="#1a1a1a" paddingX={2} paddingY={0} width={maxWidth}>
|
|
52
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
53
|
+
<Text color={t.color ?? 'green'} wrap="wrap">
|
|
54
|
+
{t.message}
|
|
55
|
+
</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
<Box marginLeft={1} flexShrink={0}>
|
|
58
|
+
<Text color="gray" dimColor={true}>
|
|
59
|
+
[esc]✕
|
|
60
|
+
</Text>
|
|
61
|
+
</Box>
|
|
40
62
|
</Box>
|
|
41
63
|
))}
|
|
42
64
|
</Box>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Key, useInput, useStdin } from 'ink';
|
|
2
|
+
import type { SlashCommand as PluginSlashCommand } from 'mu-agents';
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import { matchCommands, type SlashCommand } from '../commands';
|
|
4
5
|
|
|
@@ -30,6 +31,7 @@ interface UseInputHandlerOptions {
|
|
|
30
31
|
history: string[];
|
|
31
32
|
actions: InputActions;
|
|
32
33
|
onSubmit: (text: string) => void;
|
|
34
|
+
pluginCommands?: PluginSlashCommand[];
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Build a stable key identifier from an Ink key event
|
|
@@ -199,14 +201,25 @@ function handleInsert(input: string, c: BindingCtx) {
|
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
function executeCommand(cmd: SlashCommand, args: string, actions: InputActions): void {
|
|
205
|
+
if (cmd.execute) {
|
|
206
|
+
cmd.execute(args);
|
|
207
|
+
} else if (cmd.action) {
|
|
208
|
+
const actionKey = COMMAND_ACTIONS[cmd.action];
|
|
209
|
+
if (actionKey) {
|
|
210
|
+
(actions[actionKey] as (() => void) | undefined)?.();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
export function useInputHandler(options: UseInputHandlerOptions): InputState {
|
|
203
|
-
const { isActive, streaming, history, actions, onSubmit } = options;
|
|
216
|
+
const { isActive, streaming, history, actions, onSubmit, pluginCommands } = options;
|
|
204
217
|
const [value, setValue] = useState('');
|
|
205
218
|
const [cmdIndex, setCmdIndex] = useState(0);
|
|
206
219
|
const nav = useHistoryNavigation(value, history);
|
|
207
220
|
const backspaceHandledRef = useRawBackspace(isActive, setValue);
|
|
208
221
|
|
|
209
|
-
const commands = useMemo(() => matchCommands(value.trim()), [value]);
|
|
222
|
+
const commands = useMemo(() => matchCommands(value.trim(), pluginCommands), [value, pluginCommands]);
|
|
210
223
|
const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
|
|
211
224
|
|
|
212
225
|
const submit = useCallback(() => {
|
|
@@ -216,11 +229,9 @@ export function useInputHandler(options: UseInputHandlerOptions): InputState {
|
|
|
216
229
|
if (isCommandMode) {
|
|
217
230
|
const cmd = commands[cmdIndex];
|
|
218
231
|
if (cmd) {
|
|
232
|
+
const args = value.trim().slice(cmd.name.length).trim();
|
|
219
233
|
setValue('');
|
|
220
|
-
|
|
221
|
-
if (actionKey) {
|
|
222
|
-
(actions[actionKey] as (() => void) | undefined)?.();
|
|
223
|
-
}
|
|
234
|
+
executeCommand(cmd, args, actions);
|
|
224
235
|
}
|
|
225
236
|
return;
|
|
226
237
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export type DialogType = 'confirm' | 'select' | 'input';
|
|
2
|
+
|
|
3
|
+
export interface DialogRequest {
|
|
4
|
+
id: number;
|
|
5
|
+
type: DialogType;
|
|
6
|
+
title: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
options?: string[];
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
resolve: (value: unknown) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ToastRequest {
|
|
14
|
+
message: string;
|
|
15
|
+
level: 'info' | 'success' | 'warning' | 'error';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let nextDialogId = 0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* InkUIService bridges plugin UI requests with ink's React rendering.
|
|
22
|
+
*
|
|
23
|
+
* Dialog methods (confirm, select, input) push requests into a queue.
|
|
24
|
+
* The TUI's DialogLayer component consumes the queue and renders appropriate modals.
|
|
25
|
+
* When the user interacts, the promise is resolved.
|
|
26
|
+
*
|
|
27
|
+
* This class implements the same shape as mu-pi-compat's UIService interface
|
|
28
|
+
* via structural typing — no import needed.
|
|
29
|
+
*/
|
|
30
|
+
export class InkUIService {
|
|
31
|
+
private dialogQueue: DialogRequest[] = [];
|
|
32
|
+
private subscribers: Set<() => void> = new Set();
|
|
33
|
+
private toastCallback: ((toast: ToastRequest) => void) | null = null;
|
|
34
|
+
private statusMap: Map<string, string> = new Map();
|
|
35
|
+
private statusCallback: ((entries: Map<string, string>) => void) | null = null;
|
|
36
|
+
|
|
37
|
+
// ─── Subscription (used by React hooks) ─────────────────────────────────
|
|
38
|
+
|
|
39
|
+
subscribe(fn: () => void): () => void {
|
|
40
|
+
this.subscribers.add(fn);
|
|
41
|
+
return () => this.subscribers.delete(fn);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private notifySubscribers(): void {
|
|
45
|
+
for (const fn of this.subscribers) {
|
|
46
|
+
fn();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get the current dialog at the front of the queue */
|
|
51
|
+
currentDialog(): DialogRequest | null {
|
|
52
|
+
return this.dialogQueue[0] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the current dialog and advance the queue */
|
|
56
|
+
resolveDialog(value: unknown): void {
|
|
57
|
+
const dialog = this.dialogQueue.shift();
|
|
58
|
+
if (dialog) {
|
|
59
|
+
dialog.resolve(value);
|
|
60
|
+
this.notifySubscribers();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cancel/dismiss the current dialog */
|
|
65
|
+
cancelDialog(): void {
|
|
66
|
+
const dialog = this.dialogQueue.shift();
|
|
67
|
+
if (dialog) {
|
|
68
|
+
dialog.resolve(dialog.type === 'confirm' ? false : null);
|
|
69
|
+
this.notifySubscribers();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Toast ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
private pendingToasts: ToastRequest[] = [];
|
|
76
|
+
|
|
77
|
+
onToast(callback: (toast: ToastRequest) => void): void {
|
|
78
|
+
this.toastCallback = callback;
|
|
79
|
+
// Flush any toasts that fired before the TUI mounted
|
|
80
|
+
for (const toast of this.pendingToasts) {
|
|
81
|
+
callback(toast);
|
|
82
|
+
}
|
|
83
|
+
this.pendingToasts = [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Status ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
onStatusChange(callback: (entries: Map<string, string>) => void): void {
|
|
89
|
+
this.statusCallback = callback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getStatusEntries(): Map<string, string> {
|
|
93
|
+
return new Map(this.statusMap);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Plugin UI Methods ──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
notify(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void {
|
|
99
|
+
const toast: ToastRequest = { message, level: level ?? 'info' };
|
|
100
|
+
if (this.toastCallback) {
|
|
101
|
+
this.toastCallback(toast);
|
|
102
|
+
} else {
|
|
103
|
+
this.pendingToasts.push(toast);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
confirm(title: string, message: string): Promise<boolean> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
this.dialogQueue.push({
|
|
110
|
+
id: nextDialogId++,
|
|
111
|
+
type: 'confirm',
|
|
112
|
+
title,
|
|
113
|
+
message,
|
|
114
|
+
resolve: resolve as (value: unknown) => void,
|
|
115
|
+
});
|
|
116
|
+
this.notifySubscribers();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
select(title: string, options: string[]): Promise<string | null> {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
this.dialogQueue.push({
|
|
123
|
+
id: nextDialogId++,
|
|
124
|
+
type: 'select',
|
|
125
|
+
title,
|
|
126
|
+
options,
|
|
127
|
+
resolve: resolve as (value: unknown) => void,
|
|
128
|
+
});
|
|
129
|
+
this.notifySubscribers();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
input(title: string, placeholder?: string): Promise<string | null> {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
this.dialogQueue.push({
|
|
136
|
+
id: nextDialogId++,
|
|
137
|
+
type: 'input',
|
|
138
|
+
title,
|
|
139
|
+
placeholder,
|
|
140
|
+
resolve: resolve as (value: unknown) => void,
|
|
141
|
+
});
|
|
142
|
+
this.notifySubscribers();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setStatus(key: string, text: string): void {
|
|
147
|
+
this.statusMap.set(key, text);
|
|
148
|
+
this.statusCallback?.(this.statusMap);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
clearStatus(key: string): void {
|
|
152
|
+
this.statusMap.delete(key);
|
|
153
|
+
this.statusCallback?.(this.statusMap);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/tui/useAbort.ts
CHANGED