mu-coding 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -92
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -161
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/singleShot.ts +0 -42
  64. package/src/tui/commands.ts +0 -33
  65. package/src/tui/components/chatLayout.tsx +0 -192
  66. package/src/tui/useChatSession.ts +0 -155
  67. package/src/tui/useChatUI.ts +0 -51
  68. package/tsconfig.json +0 -10
  69. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  70. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  71. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  72. /package/src/{diff.ts → utils/diff.ts} +0 -0
package/src/main.tsx DELETED
@@ -1,161 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { readdirSync } from 'node:fs';
3
- import { createRequire } from 'node:module';
4
- import { join, resolve } from 'node:path';
5
- import { render } from 'ink';
6
- import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
7
- import { parseArgs, resolveInitialMessages } from './cli';
8
- import { type AppConfig, getDataDir, getPluginsDir, loadConfig } from './config';
9
- import { runSingleShot } from './singleShot';
10
- import { handleSubcommand } from './subcommands';
11
- import { ChatPanel } from './tui/components/chat/ChatPanel';
12
- import { InkUIService } from './tui/services/uiService';
13
-
14
- function discoverPluginFiles(): string[] {
15
- const dir = getPluginsDir();
16
- try {
17
- return readdirSync(dir)
18
- .filter((f) => f.endsWith('.ts'))
19
- .map((f) => join(dir, f));
20
- } catch {
21
- return [];
22
- }
23
- }
24
-
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) {
97
- const registry = new PluginRegistry({ cwd, config: {} });
98
-
99
- // Register built-in tools (read, write, edit, bash)
100
- await registry.register(createBuiltinPlugin());
101
-
102
- // Auto-load .ts plugin files from ~/.config/mu/plugins/
103
- for (const filePath of discoverPluginFiles()) {
104
- await registry.loadPlugin(filePath);
105
- }
106
-
107
- // Load configured plugins
108
- if (config.plugins?.length) {
109
- for (const entry of config.plugins) {
110
- const name = typeof entry === 'string' ? entry : entry.name;
111
- const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
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);
117
- }
118
- }
119
-
120
- return registry;
121
- }
122
-
123
- async function main() {
124
- if (await handleSubcommand()) return;
125
-
126
- const cliArgs = parseArgs();
127
- const config = loadConfig(cliArgs.model);
128
- const root = process.cwd();
129
-
130
- const uiService = new InkUIService();
131
- const registry = await createRegistry(root, config, uiService);
132
-
133
- if (cliArgs.prompt) {
134
- try {
135
- await runSingleShot(cliArgs.prompt, config, registry);
136
- } catch (err: unknown) {
137
- const msg = err instanceof Error ? err.message : 'Unknown error';
138
- console.error(`Error: ${msg}`);
139
- process.exit(1);
140
- } finally {
141
- await registry.shutdown();
142
- }
143
- return;
144
- }
145
-
146
- const initialMessages = resolveInitialMessages(cliArgs);
147
-
148
- render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} uiService={uiService} />, {
149
- exitOnCtrlC: false,
150
- kittyKeyboard: { mode: 'enabled' },
151
- });
152
-
153
- process.on('exit', () => {
154
- registry.shutdown();
155
- });
156
- }
157
-
158
- main().catch((err) => {
159
- console.error(err);
160
- process.exit(1);
161
- });
package/src/project.ts DELETED
@@ -1,32 +0,0 @@
1
- import { execSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
-
4
- function findGitRoot(from: string): string | null {
5
- try {
6
- const root = execSync('git rev-parse --show-toplevel', {
7
- cwd: from,
8
- encoding: 'utf-8',
9
- stdio: ['pipe', 'pipe', 'pipe'],
10
- }).trim();
11
- return root || null;
12
- } catch {
13
- return null;
14
- }
15
- }
16
-
17
- function getProjectRoot(): string {
18
- const cwd = process.cwd();
19
- return findGitRoot(cwd) ?? cwd;
20
- }
21
-
22
- export function getProjectId(): string {
23
- const root = getProjectRoot();
24
- const hash = createHash('sha256').update(root).digest('hex').slice(0, 12);
25
- const name = root.split('/').pop() || 'unknown';
26
- return `${name}-${hash}`;
27
- }
28
-
29
- export function getProjectName(): string {
30
- const root = getProjectRoot();
31
- return root.split('/').pop() || root;
32
- }
package/src/session.ts DELETED
@@ -1,95 +0,0 @@
1
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import type { ChatMessage } from 'mu-provider';
4
- import { getDataDir } from './config';
5
- import { getProjectId, getProjectName } from './project';
6
-
7
- function getProjectSessionsDir(): string {
8
- return join(getDataDir(), 'sessions', getProjectId());
9
- }
10
-
11
- function getSortedSessionFiles(): string[] {
12
- try {
13
- const dir = getProjectSessionsDir();
14
- return readdirSync(dir)
15
- .filter((f) => f.endsWith('.jsonl'))
16
- .sort()
17
- .reverse();
18
- } catch {
19
- return [];
20
- }
21
- }
22
-
23
- export interface SessionInfo {
24
- path: string;
25
- name: string;
26
- date: Date;
27
- messageCount: number;
28
- preview: string;
29
- project: string;
30
- }
31
-
32
- export function generateSessionPath(): string {
33
- const dir = getProjectSessionsDir();
34
- mkdirSync(dir, { recursive: true });
35
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
36
- return join(dir, `${ts}.jsonl`);
37
- }
38
-
39
- export function saveSession(path: string, messages: ChatMessage[]): void {
40
- writeFileSync(path, `${messages.map((m) => JSON.stringify(m)).join('\n')}\n`, 'utf-8');
41
- }
42
-
43
- export function loadSession(path: string): ChatMessage[] {
44
- try {
45
- const content = readFileSync(path, 'utf-8').trim();
46
- if (!content) {
47
- return [];
48
- }
49
- return content
50
- .split('\n')
51
- .map((line) => {
52
- try {
53
- return JSON.parse(line) as ChatMessage;
54
- } catch {
55
- return null;
56
- }
57
- })
58
- .filter((msg): msg is ChatMessage => msg !== null);
59
- } catch {
60
- return [];
61
- }
62
- }
63
-
64
- export function getLatestSession(): string | null {
65
- const files = getSortedSessionFiles();
66
- return files.length ? join(getProjectSessionsDir(), files[0]) : null;
67
- }
68
-
69
- export function listSessions(): SessionInfo[] {
70
- try {
71
- const dir = getProjectSessionsDir();
72
- mkdirSync(dir, { recursive: true });
73
- const files = getSortedSessionFiles();
74
- const project = getProjectName();
75
-
76
- return files.map((file) => {
77
- const path = join(dir, file);
78
- const stat = statSync(path);
79
- const messages = loadSession(path);
80
- const firstUserMsg = messages.find((m) => m.role === 'user');
81
- const preview = firstUserMsg ? firstUserMsg.content.slice(0, 80).replace(/\n/g, ' ') : '(empty)';
82
-
83
- return {
84
- path,
85
- name: file.replace('.jsonl', ''),
86
- date: stat.mtime,
87
- messageCount: messages.length,
88
- preview,
89
- project,
90
- };
91
- });
92
- } catch {
93
- return [];
94
- }
95
- }
package/src/singleShot.ts DELETED
@@ -1,42 +0,0 @@
1
- import type { PluginRegistry } from 'mu-agents';
2
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
3
- import { listModels, streamChat } from 'mu-provider';
4
-
5
- export async function runSingleShot(prompt: string, config: ProviderConfig, registry: PluginRegistry): Promise<void> {
6
- const messages: ChatMessage[] = [{ role: 'user', content: prompt }];
7
-
8
- let resolvedModel = config.model;
9
- if (!resolvedModel) {
10
- const models = await listModels(config.baseUrl);
11
- if (models.length === 0) {
12
- console.error('Error: no models available at', config.baseUrl);
13
- process.exit(1);
14
- }
15
- resolvedModel = models[0].id;
16
- }
17
-
18
- const toolDefinitions = registry.getToolDefinitions();
19
-
20
- let tokens = 0;
21
- let hasToolCalls = false;
22
- process.stdout.write('mu: ');
23
- for await (const chunk of streamChat(messages, config, resolvedModel, {
24
- onUsage: (usage) => {
25
- tokens = usage.totalTokens;
26
- },
27
- tools: toolDefinitions,
28
- })) {
29
- if (chunk.type === 'content') {
30
- process.stdout.write(chunk.text);
31
- } else if (chunk.type === 'tool_call') {
32
- hasToolCalls = true;
33
- }
34
- }
35
- if (hasToolCalls) {
36
- process.stderr.write('\n[tool calls made — use interactive mode for tool execution]\n');
37
- }
38
- process.stdout.write('\n');
39
- if (tokens > 0) {
40
- process.stderr.write(`(${tokens} tokens)\n`);
41
- }
42
- }
@@ -1,33 +0,0 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
2
-
3
- export interface SlashCommand {
4
- name: string;
5
- description: string;
6
- action?: string;
7
- execute?: (args: string) => Promise<string | undefined>;
8
- }
9
-
10
- const BUILTIN_COMMANDS: SlashCommand[] = [
11
- { name: '/model', description: 'Select a model', action: 'model' },
12
- { name: '/sessions', description: 'List project sessions', action: 'sessions' },
13
- { name: '/new', description: 'New conversation', action: 'new' },
14
- ];
15
-
16
- export function matchCommands(input: string, pluginCommands: PluginSlashCommand[] = []): SlashCommand[] {
17
- if (!input.startsWith('/')) {
18
- return [];
19
- }
20
- const q = input.toLowerCase();
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));
33
- }
@@ -1,192 +0,0 @@
1
- import type { DOMElement } from 'ink';
2
- import { Box, Text } from 'ink';
3
- import type { StatusSegment } from 'mu-agents';
4
- import type { ChatMessage } from 'mu-provider';
5
- import type React from 'react';
6
- import { useSpinner } from '../hooks/useUI';
7
- import type { StreamState } from '../useChatSession';
8
- import { MessageItem } from './messages/messageItem';
9
- import { StreamingOutput } from './messages/streamingOutput';
10
- import { Dropdown } from './ui/dropdown';
11
- import { Modal } from './ui/modal';
12
-
13
- function Scrollbar({
14
- viewHeight,
15
- contentHeight,
16
- scrollOffset,
17
- }: {
18
- viewHeight: number;
19
- contentHeight: number;
20
- scrollOffset: number;
21
- }) {
22
- if (contentHeight <= viewHeight || viewHeight < 1) {
23
- return null;
24
- }
25
- const maxScroll = contentHeight - viewHeight;
26
- const ratio = scrollOffset / maxScroll;
27
- const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
28
- const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
29
-
30
- const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
31
-
32
- return (
33
- <Box flexDirection="column" flexShrink={0} width={1}>
34
- <Text>{track.join('')}</Text>
35
- </Box>
36
- );
37
- }
38
-
39
- export function StatusBar({
40
- streaming,
41
- abortWarning,
42
- quitWarning,
43
- error,
44
- modelError,
45
- totalTokens,
46
- tokensPerSecond,
47
- pluginStatus,
48
- }: {
49
- streaming: boolean;
50
- abortWarning: boolean;
51
- quitWarning: boolean;
52
- error: string | null;
53
- modelError: string | null;
54
- totalTokens: number;
55
- tokensPerSecond: number;
56
- pluginStatus?: StatusSegment[];
57
- }) {
58
- const spinner = useSpinner(streaming);
59
- const segments: Array<{ text: string; color?: string; dim?: boolean }> = [];
60
- if (streaming) {
61
- segments.push({ text: `${spinner} generating`, color: 'yellow' });
62
- }
63
- if (tokensPerSecond > 0) {
64
- segments.push({ text: `${tokensPerSecond} tok/s`, dim: true });
65
- }
66
- if (abortWarning) {
67
- segments.push({ text: 'Esc again to stop', color: 'yellow' });
68
- } else if (quitWarning) {
69
- segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
70
- } else if (streaming) {
71
- segments.push({ text: 'Esc to stop', dim: true });
72
- }
73
- if (error) {
74
- segments.push({ text: '⚠ error', color: 'red' });
75
- }
76
- if (modelError) {
77
- segments.push({ text: `⚠ ${modelError}`, color: 'red' });
78
- }
79
-
80
- if (totalTokens > 0) {
81
- segments.push({ text: `${formatTokens(totalTokens)} tokens`, dim: true });
82
- }
83
- if (pluginStatus) {
84
- segments.push(...pluginStatus);
85
- }
86
-
87
- return (
88
- <Box flexShrink={0} paddingX={1} marginY={1}>
89
- <Box justifyContent="flex-end" flexGrow={1}>
90
- {segments.map((seg, i) => (
91
- // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
92
- <Box key={i}>
93
- {i > 0 && <Text dimColor={true}> · </Text>}
94
- <Text color={seg.color} dimColor={seg.dim}>
95
- {seg.text}
96
- </Text>
97
- </Box>
98
- ))}
99
- </Box>
100
- </Box>
101
- );
102
- }
103
-
104
- function formatTokens(tokens: number): string {
105
- if (tokens >= 1_000_000) {
106
- return `${(tokens / 1_000_000).toFixed(1)}M`;
107
- }
108
- if (tokens >= 1_000) {
109
- return `${(tokens / 1_000).toFixed(1)}k`;
110
- }
111
- return String(tokens);
112
- }
113
-
114
- interface PickerItem {
115
- label: string;
116
- value: string;
117
- description?: string;
118
- }
119
-
120
- export function PickerModal({
121
- visible,
122
- title,
123
- items,
124
- placeholder,
125
- emptyMessage,
126
- onSelect,
127
- onCancel,
128
- }: {
129
- visible: boolean;
130
- title: string;
131
- items: PickerItem[];
132
- placeholder: string;
133
- emptyMessage?: string;
134
- onSelect: (value: string) => void;
135
- onCancel?: () => void;
136
- }) {
137
- return (
138
- <Modal visible={visible} title={title}>
139
- {items.length === 0 && emptyMessage ? (
140
- <Text dimColor={true} italic={true}>
141
- {emptyMessage}
142
- </Text>
143
- ) : (
144
- <Dropdown
145
- items={items}
146
- placeholder={placeholder}
147
- isActive={visible}
148
- onSelect={(item) => onSelect(item.value)}
149
- onCancel={onCancel}
150
- />
151
- )}
152
- </Modal>
153
- );
154
- }
155
-
156
- export function MessageView({
157
- viewRef,
158
- contentRef,
159
- messages,
160
- streaming,
161
- stream,
162
- error,
163
- scrollOffset,
164
- viewHeight,
165
- contentHeight,
166
- }: {
167
- viewRef: React.RefObject<DOMElement | null>;
168
- contentRef: React.RefObject<DOMElement | null>;
169
- messages: ChatMessage[];
170
- streaming: boolean;
171
- stream: StreamState;
172
- error: string | null;
173
- scrollOffset: number;
174
- viewHeight: number;
175
- contentHeight: number;
176
- }) {
177
- return (
178
- <Box flexGrow={1} overflow="hidden">
179
- <Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
180
- <Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
181
- {messages.map((msg, i) => (
182
- // biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
183
- <MessageItem key={i} msg={msg} messages={messages} index={i} />
184
- ))}
185
- {streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
186
- {error && <Text color="red">Error: {error}</Text>}
187
- </Box>
188
- </Box>
189
- <Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
190
- </Box>
191
- );
192
- }