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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-coding",
3
- "version": "0.1.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.1.0",
16
- "mu-provider": "0.1.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 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
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 { join } from 'node:path';
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
- async function createRegistry(cwd: string, config: AppConfig) {
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 npm package plugins from config
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
- await registry.loadPlugin(name, pluginConfig);
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 registry = await createRegistry(root, config);
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
+ }
@@ -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: string;
6
+ action?: string;
7
+ execute?: (args: string) => Promise<string | undefined>;
5
8
  }
6
9
 
7
- const COMMANDS: SlashCommand[] = [
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
- return COMMANDS.filter((cmd) => cmd.name.startsWith(q));
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
- export function ChatPanelBody(props: LayoutProps) {
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, durationMs = 2000) => {
15
+ const show = useCallback((message: string, color?: string) => {
16
16
  const id = nextId++;
17
17
  setToasts((prev) => [...prev, { id, message, color }]);
18
- setTimeout(() => {
19
- setToasts((prev) => prev.filter((t) => t.id !== id));
20
- }, durationMs);
21
- };
18
+ }, []);
22
19
 
23
- return { toasts, show };
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
- <Text color={t.color ?? 'green'}>{t.message}</Text>
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
- const actionKey = COMMAND_ACTIONS[cmd.action];
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
+ }
@@ -50,7 +50,10 @@ export function useAbort(
50
50
  }
51
51
  if (onCtrlC()) {
52
52
  exit();
53
- setTimeout(() => process.exit(0), 100);
53
+ setTimeout(() => {
54
+ process.stdout.write('\x1b[<u');
55
+ process.exit(0);
56
+ }, 100);
54
57
  }
55
58
  }, [streaming, onCtrlC, exit, controllerRef]);
56
59