mu-coding 0.4.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 (71) 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 -96
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -147
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/tui/commands.ts +0 -33
  64. package/src/tui/components/chatLayout.tsx +0 -192
  65. package/src/tui/useChatSession.ts +0 -155
  66. package/src/tui/useChatUI.ts +0 -51
  67. package/tsconfig.json +0 -10
  68. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  69. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  70. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  71. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,3 +1,5 @@
1
+ import type { UIService } from 'mu-agents';
2
+
1
3
  export type DialogType = 'confirm' | 'select' | 'input';
2
4
 
3
5
  export interface DialogRequest {
@@ -17,32 +19,44 @@ export interface ToastRequest {
17
19
 
18
20
  let nextDialogId = 0;
19
21
 
22
+ type ToastListener = (toast: ToastRequest) => void;
23
+ type StatusListener = (entries: Map<string, string>) => void;
24
+
20
25
  /**
21
- * InkUIService bridges plugin UI requests with ink's React rendering.
26
+ * InkUIService bridges plugin UI requests with Ink's React rendering.
27
+ *
28
+ * All event channels (dialogs, toasts, status) follow the same multi-listener
29
+ * pattern: `subscribe`/`onToast`/`onStatusChange` return an unsubscribe
30
+ * function. This lets multiple components observe the same service safely
31
+ * (e.g. during hot-reload or component swaps) without one handler clobbering
32
+ * another.
22
33
  *
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.
34
+ * Toasts emitted before any listener subscribes are buffered and replayed
35
+ * to the first subscriber; this avoids losing plugin-load errors emitted
36
+ * before the TUI mounts.
26
37
  *
27
- * This class implements the same shape as mu-pi-compat's UIService interface
28
- * via structural typing no import needed.
38
+ * Implements `UIService` from `mu-agents` gives nominal typing so a
39
+ * change on either side fails the build.
29
40
  */
30
- export class InkUIService {
41
+ export class InkUIService implements UIService {
31
42
  private dialogQueue: DialogRequest[] = [];
32
- private subscribers: Set<() => void> = new Set();
33
- private toastCallback: ((toast: ToastRequest) => void) | null = null;
43
+ private dialogSubscribers: Set<() => void> = new Set();
44
+ private toastListeners: Set<ToastListener> = new Set();
45
+ private pendingToasts: ToastRequest[] = [];
34
46
  private statusMap: Map<string, string> = new Map();
35
- private statusCallback: ((entries: Map<string, string>) => void) | null = null;
47
+ private statusListeners: Set<StatusListener> = new Set();
36
48
 
37
- // ─── Subscription (used by React hooks) ─────────────────────────────────
49
+ // ─── Dialog Subscription (used by DialogLayer) ──────────────────────────
38
50
 
39
51
  subscribe(fn: () => void): () => void {
40
- this.subscribers.add(fn);
41
- return () => this.subscribers.delete(fn);
52
+ this.dialogSubscribers.add(fn);
53
+ return () => {
54
+ this.dialogSubscribers.delete(fn);
55
+ };
42
56
  }
43
57
 
44
- private notifySubscribers(): void {
45
- for (const fn of this.subscribers) {
58
+ private notifyDialogSubscribers(): void {
59
+ for (const fn of this.dialogSubscribers) {
46
60
  fn();
47
61
  }
48
62
  }
@@ -57,7 +71,7 @@ export class InkUIService {
57
71
  const dialog = this.dialogQueue.shift();
58
72
  if (dialog) {
59
73
  dialog.resolve(value);
60
- this.notifySubscribers();
74
+ this.notifyDialogSubscribers();
61
75
  }
62
76
  }
63
77
 
@@ -66,27 +80,44 @@ export class InkUIService {
66
80
  const dialog = this.dialogQueue.shift();
67
81
  if (dialog) {
68
82
  dialog.resolve(dialog.type === 'confirm' ? false : null);
69
- this.notifySubscribers();
83
+ this.notifyDialogSubscribers();
70
84
  }
71
85
  }
72
86
 
73
87
  // ─── Toast ──────────────────────────────────────────────────────────────
74
88
 
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);
89
+ /**
90
+ * Subscribe to toast events. Returns an unsubscribe function. If toasts
91
+ * were emitted before any listener attached, they are replayed to the
92
+ * first subscriber once.
93
+ */
94
+ onToast(callback: ToastListener): () => void {
95
+ this.toastListeners.add(callback);
96
+ if (this.pendingToasts.length > 0) {
97
+ const buffered = this.pendingToasts;
98
+ this.pendingToasts = [];
99
+ for (const toast of buffered) {
100
+ callback(toast);
101
+ }
82
102
  }
83
- this.pendingToasts = [];
103
+ return () => {
104
+ this.toastListeners.delete(callback);
105
+ };
84
106
  }
85
107
 
86
108
  // ─── Status ─────────────────────────────────────────────────────────────
87
109
 
88
- onStatusChange(callback: (entries: Map<string, string>) => void): void {
89
- this.statusCallback = callback;
110
+ onStatusChange(callback: StatusListener): () => void {
111
+ this.statusListeners.add(callback);
112
+ return () => {
113
+ this.statusListeners.delete(callback);
114
+ };
115
+ }
116
+
117
+ private emitStatus(): void {
118
+ for (const fn of this.statusListeners) {
119
+ fn(this.statusMap);
120
+ }
90
121
  }
91
122
 
92
123
  getStatusEntries(): Map<string, string> {
@@ -97,10 +128,12 @@ export class InkUIService {
97
128
 
98
129
  notify(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void {
99
130
  const toast: ToastRequest = { message, level: level ?? 'info' };
100
- if (this.toastCallback) {
101
- this.toastCallback(toast);
102
- } else {
131
+ if (this.toastListeners.size === 0) {
103
132
  this.pendingToasts.push(toast);
133
+ return;
134
+ }
135
+ for (const listener of this.toastListeners) {
136
+ listener(toast);
104
137
  }
105
138
  }
106
139
 
@@ -113,7 +146,7 @@ export class InkUIService {
113
146
  message,
114
147
  resolve: resolve as (value: unknown) => void,
115
148
  });
116
- this.notifySubscribers();
149
+ this.notifyDialogSubscribers();
117
150
  });
118
151
  }
119
152
 
@@ -126,7 +159,7 @@ export class InkUIService {
126
159
  options,
127
160
  resolve: resolve as (value: unknown) => void,
128
161
  });
129
- this.notifySubscribers();
162
+ this.notifyDialogSubscribers();
130
163
  });
131
164
  }
132
165
 
@@ -139,17 +172,17 @@ export class InkUIService {
139
172
  placeholder,
140
173
  resolve: resolve as (value: unknown) => void,
141
174
  });
142
- this.notifySubscribers();
175
+ this.notifyDialogSubscribers();
143
176
  });
144
177
  }
145
178
 
146
179
  setStatus(key: string, text: string): void {
147
180
  this.statusMap.set(key, text);
148
- this.statusCallback?.(this.statusMap);
181
+ this.emitStatus();
149
182
  }
150
183
 
151
184
  clearStatus(key: string): void {
152
185
  this.statusMap.delete(key);
153
- this.statusCallback?.(this.statusMap);
186
+ this.emitStatus();
154
187
  }
155
188
  }
@@ -0,0 +1,30 @@
1
+ import { render } from 'ink';
2
+ import type { PluginRegistry } from 'mu-agents';
3
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
+ import type { ShutdownFn } from '../app/shutdown';
5
+ import { ChatPanel } from './components/chat/ChatPanel';
6
+ import type { InkUIService } from './plugins/InkUIService';
7
+
8
+ interface RenderAppOptions {
9
+ config: ProviderConfig;
10
+ initialMessages?: ChatMessage[];
11
+ registry: PluginRegistry;
12
+ uiService: InkUIService;
13
+ shutdown: ShutdownFn;
14
+ }
15
+
16
+ export function renderApp(options: RenderAppOptions): void {
17
+ render(
18
+ <ChatPanel
19
+ config={options.config}
20
+ initialMessages={options.initialMessages}
21
+ registry={options.registry}
22
+ uiService={options.uiService}
23
+ shutdown={options.shutdown}
24
+ />,
25
+ {
26
+ exitOnCtrlC: false,
27
+ kittyKeyboard: { mode: 'enabled' },
28
+ },
29
+ );
30
+ }
@@ -0,0 +1,97 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ImageAttachment } from 'mu-provider';
6
+
7
+ const CLIPBOARD_TIMEOUT = 3000;
8
+
9
+ function tryExecFile(file: string, args: string[]): boolean {
10
+ try {
11
+ execFileSync(file, args, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function tryShell(command: string): boolean {
19
+ try {
20
+ execSync(command, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function commandExists(name: string): boolean {
28
+ return tryExecFile('which', [name]);
29
+ }
30
+
31
+ function extractMacImage(tmpFile: string): boolean {
32
+ if (commandExists('pngpaste')) {
33
+ return tryExecFile('pngpaste', [tmpFile]);
34
+ }
35
+ // Each `-e` is a discrete script line — passing as separate args avoids shell quoting traps.
36
+ return tryExecFile('osascript', [
37
+ '-e',
38
+ 'tell application "System Events"',
39
+ '-e',
40
+ 'set imgData to the clipboard as «class PNGf»',
41
+ '-e',
42
+ `set fp to open for access POSIX file "${tmpFile}" with write permission`,
43
+ '-e',
44
+ 'write imgData to fp',
45
+ '-e',
46
+ 'close access fp',
47
+ '-e',
48
+ 'end tell',
49
+ ]);
50
+ }
51
+
52
+ function extractLinuxImage(tmpFile: string): boolean {
53
+ // xclip / wl-paste write to stdout; redirection still requires a shell.
54
+ // tmpFile is constructed from tmpdir() + a numeric timestamp, so quoting is safe.
55
+ if (tryShell(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`)) {
56
+ return true;
57
+ }
58
+ return tryShell(`wl-paste --type image/png > "${tmpFile}"`);
59
+ }
60
+
61
+ function extractPlatformImage(tmpFile: string): boolean {
62
+ if (process.platform === 'darwin') {
63
+ return extractMacImage(tmpFile);
64
+ }
65
+ if (process.platform === 'linux') {
66
+ return extractLinuxImage(tmpFile);
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function readTmpAsAttachment(tmpFile: string): ImageAttachment | null {
72
+ if (!existsSync(tmpFile)) {
73
+ return null;
74
+ }
75
+ if (statSync(tmpFile).size === 0) {
76
+ unlinkSync(tmpFile);
77
+ return null;
78
+ }
79
+ const buffer = readFileSync(tmpFile);
80
+ unlinkSync(tmpFile);
81
+ return { data: buffer.toString('base64'), mimeType: 'image/png', name: 'clipboard.png' };
82
+ }
83
+
84
+ export function readClipboardImage(): ImageAttachment | null {
85
+ const tmpFile = join(tmpdir(), `mu-clip-${Date.now()}.png`);
86
+ try {
87
+ if (!extractPlatformImage(tmpFile)) {
88
+ return null;
89
+ }
90
+ return readTmpAsAttachment(tmpFile);
91
+ } catch {
92
+ if (existsSync(tmpFile)) {
93
+ unlinkSync(tmpFile);
94
+ }
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { computeDiff, renderDiff } from './diff';
3
+
4
+ describe('computeDiff', () => {
5
+ it('emits only context lines when texts are identical', () => {
6
+ const diff = computeDiff('a\nb\nc', 'a\nb\nc');
7
+ expect(diff.lines.every((l) => l.type === 'context')).toBe(true);
8
+ expect(diff.lines.find((l) => l.type === 'old')).toBeUndefined();
9
+ expect(diff.lines.find((l) => l.type === 'new')).toBeUndefined();
10
+ expect(diff.totalOldLines).toBe(3);
11
+ expect(diff.totalNewLines).toBe(3);
12
+ });
13
+
14
+ it('detects an added line in the middle', () => {
15
+ const diff = computeDiff('a\nb\nc', 'a\nx\nb\nc');
16
+ const types = diff.lines.map((l) => l.type);
17
+ expect(types).toContain('new');
18
+ expect(diff.lines.find((l) => l.type === 'new')?.value).toBe('x');
19
+ });
20
+
21
+ it('detects a removed line', () => {
22
+ const diff = computeDiff('a\nb\nc', 'a\nc');
23
+ expect(diff.lines.find((l) => l.type === 'old')?.value).toBe('b');
24
+ });
25
+
26
+ it('returns no lines when either side exceeds the size limit', () => {
27
+ const huge = Array.from({ length: 600 }, (_, i) => `line${i}`).join('\n');
28
+ const diff = computeDiff(huge, huge.replace('line0', 'lineX'));
29
+ expect(diff.lines).toEqual([]);
30
+ expect(diff.totalOldLines).toBe(600);
31
+ });
32
+
33
+ it('emits up to CONTEXT_LINES of leading/trailing context', () => {
34
+ const diff = computeDiff('a\nb\nc\nd\ne', 'a\nb\nc\nX\ne');
35
+ const contextValues = diff.lines.filter((l) => l.type === 'context').map((l) => l.value);
36
+ expect(contextValues).toContain('c');
37
+ expect(contextValues).toContain('e');
38
+ });
39
+ });
40
+
41
+ describe('renderDiff', () => {
42
+ it('prefixes added/removed/context lines and reports truncation', () => {
43
+ const diff = computeDiff('a\nb', 'a\nc');
44
+ const { lines, truncated } = renderDiff(diff, 100);
45
+ expect(lines.some((l) => l.startsWith('-'))).toBe(true);
46
+ expect(lines.some((l) => l.startsWith('+'))).toBe(true);
47
+ expect(truncated).toBe(false);
48
+ });
49
+
50
+ it('reports truncated when diff exceeds maxLines', () => {
51
+ const diff = computeDiff('a', Array.from({ length: 50 }, (_, i) => `n${i}`).join('\n'));
52
+ const { lines, truncated } = renderDiff(diff, 5);
53
+ expect(lines).toHaveLength(5);
54
+ expect(truncated).toBe(true);
55
+ });
56
+ });
package/src/cli.ts DELETED
@@ -1,96 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import type { ChatMessage } from 'mu-provider';
4
- import { getLatestSession, loadSession } from './session';
5
-
6
- interface CliArgs {
7
- model?: string;
8
- continueSession?: boolean;
9
- sessionPath?: string;
10
- }
11
-
12
- function printHelp(): never {
13
- console.log(`mu — minimal terminal AI assistant
14
-
15
- Usage:
16
- mu Start interactive chat
17
- mu -m model Interactive with specific model
18
- mu -c Continue most recent session
19
- mu --session <path> Resume a specific session file
20
- mu install npm:<package> Install a plugin from npm
21
- mu uninstall npm:<pkg> Remove an installed plugin
22
- mu -v, --version Print version and exit
23
-
24
- Config (XDG):
25
- ~/.config/mu/config.json — configuration (baseUrl, model, streamTimeoutMs)
26
- ~/.config/mu/SYSTEM.md — system prompt
27
- ~/.local/share/mu/sessions/ — saved conversation sessions (JSONL)
28
- ~/.cache/mu/repomap/ — code index cache
29
-
30
- Keyboard shortcuts (interactive):
31
- Ctrl+C Abort / Quit (press twice)
32
- Esc Stop generation (press twice while streaming)
33
- Enter Send message
34
- Shift+Enter New line
35
- Ctrl+S Send message
36
- ↑ / ↓ Navigate input history
37
- Ctrl+N New conversation
38
- Ctrl+M Cycle models
39
- Ctrl+O Model picker
40
- Ctrl+V Paste image from clipboard`);
41
- process.exit(0);
42
- }
43
-
44
- function printVersion(): never {
45
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
46
- console.log(`mu ${pkg.version}`);
47
- process.exit(0);
48
- }
49
-
50
- export function parseArgs(): CliArgs {
51
- const args = process.argv.slice(2);
52
- const result: CliArgs = {};
53
-
54
- for (let i = 0; i < args.length; i++) {
55
- const arg = args[i];
56
- if (arg === '-m' && args[i + 1]) {
57
- result.model = args[++i];
58
- } else if (arg === '-c' || arg === '--continue') {
59
- result.continueSession = true;
60
- } else if (arg === '--session' && args[i + 1]) {
61
- result.sessionPath = args[++i];
62
- } else if (arg === '-v' || arg === '--version') {
63
- printVersion();
64
- } else if (arg === '-h' || arg === '--help') {
65
- printHelp();
66
- }
67
- }
68
-
69
- return result;
70
- }
71
-
72
- export function resolveInitialMessages(cliArgs: CliArgs): ChatMessage[] | undefined {
73
- if (cliArgs.sessionPath) {
74
- const msgs = loadSession(cliArgs.sessionPath);
75
- if (msgs.length === 0) {
76
- console.error(`Error: session file is empty or not found: ${cliArgs.sessionPath}`);
77
- process.exit(1);
78
- }
79
- return msgs;
80
- }
81
- if (cliArgs.continueSession) {
82
- const latest = getLatestSession();
83
- if (!latest) {
84
- console.error('Error: no sessions found');
85
- process.exit(1);
86
- }
87
- const msgs = loadSession(latest);
88
- if (msgs.length === 0) {
89
- console.error('Error: latest session is empty');
90
- process.exit(1);
91
- }
92
- console.log(`Resuming session: ${latest}`);
93
- return msgs;
94
- }
95
- return undefined;
96
- }
package/src/clipboard.ts DELETED
@@ -1,62 +0,0 @@
1
- import { execSync } from 'node:child_process';
2
- import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import type { ImageAttachment } from 'mu-provider';
6
-
7
- const CLIPBOARD_TIMEOUT = 3000;
8
-
9
- function tryExecSync(command: string): boolean {
10
- try {
11
- execSync(command, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
12
- return true;
13
- } catch {
14
- return false;
15
- }
16
- }
17
-
18
- function extractPlatformImage(tmpFile: string): boolean {
19
- if (process.platform === 'darwin') {
20
- if (tryExecSync('which pngpaste')) {
21
- return tryExecSync(`pngpaste "${tmpFile}"`);
22
- }
23
- return tryExecSync(
24
- `osascript -e 'tell application "System Events" -e 'set imgData to the clipboard as «class PNGf»' -e 'set fp to open for access POSIX file "${tmpFile}" with write permission' -e 'write imgData to fp' -e 'close access fp' -e 'end tell'`,
25
- );
26
- }
27
- if (process.platform === 'linux') {
28
- if (tryExecSync(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`)) {
29
- return true;
30
- }
31
- return tryExecSync(`wl-paste --type image/png > "${tmpFile}"`);
32
- }
33
- return false;
34
- }
35
-
36
- function readTmpAsAttachment(tmpFile: string): ImageAttachment | null {
37
- if (!existsSync(tmpFile)) {
38
- return null;
39
- }
40
- if (statSync(tmpFile).size === 0) {
41
- unlinkSync(tmpFile);
42
- return null;
43
- }
44
- const buffer = readFileSync(tmpFile);
45
- unlinkSync(tmpFile);
46
- return { data: buffer.toString('base64'), mimeType: 'image/png', name: 'clipboard.png' };
47
- }
48
-
49
- export function readClipboardImage(): ImageAttachment | null {
50
- const tmpFile = join(tmpdir(), `mu-clip-${Date.now()}.png`);
51
- try {
52
- if (!extractPlatformImage(tmpFile)) {
53
- return null;
54
- }
55
- return readTmpAsAttachment(tmpFile);
56
- } catch {
57
- if (existsSync(tmpFile)) {
58
- unlinkSync(tmpFile);
59
- }
60
- return null;
61
- }
62
- }
package/src/config.ts DELETED
@@ -1,116 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import type { ProviderConfig } from 'mu-provider';
5
-
6
- export interface AppConfig extends ProviderConfig {
7
- plugins?: Array<string | { name: string; config?: Record<string, unknown> }>;
8
- }
9
-
10
- export function getPluginsDir(): string {
11
- return join(CONFIG_DIR, 'plugins');
12
- }
13
-
14
- // XDG Base Directory paths
15
- const HOME = homedir();
16
- const CONFIG_DIR = process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'mu') : join(HOME, '.config', 'mu');
17
- const DATA_DIR = process.env.XDG_DATA_HOME
18
- ? join(process.env.XDG_DATA_HOME, 'mu')
19
- : join(HOME, '.local', 'share', 'mu');
20
- const CACHE_DIR = process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, 'mu') : join(HOME, '.cache', 'mu');
21
-
22
- const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
23
- const SYSTEM_PROMPT_PATH = join(CONFIG_DIR, 'SYSTEM.md');
24
-
25
- export function getConfigDir(): string {
26
- return CONFIG_DIR;
27
- }
28
-
29
- export function getDataDir(): string {
30
- return DATA_DIR;
31
- }
32
-
33
- export function getCacheDir(): string {
34
- return CACHE_DIR;
35
- }
36
-
37
- function tryRead(path: string): string | undefined {
38
- try {
39
- return readFileSync(path, 'utf-8').trim() || undefined;
40
- } catch {
41
- return undefined;
42
- }
43
- }
44
-
45
- function tryParseJson(text: string | undefined): Partial<AppConfig> {
46
- if (!text) {
47
- return {};
48
- }
49
- try {
50
- return JSON.parse(text);
51
- } catch {
52
- return {};
53
- }
54
- }
55
-
56
- function envInt(key: string): number | undefined {
57
- const v = process.env[key];
58
- if (!v) {
59
- return undefined;
60
- }
61
- const n = Number.parseInt(v, 10);
62
- return Number.isNaN(n) ? undefined : n;
63
- }
64
-
65
- function envFloat(key: string): number | undefined {
66
- const v = process.env[key];
67
- if (!v) {
68
- return undefined;
69
- }
70
- const n = Number.parseFloat(v);
71
- return Number.isNaN(n) ? undefined : n;
72
- }
73
-
74
- export function loadConfig(cliModel?: string): AppConfig {
75
- const file = tryParseJson(tryRead(CONFIG_PATH));
76
-
77
- const config: AppConfig = {
78
- baseUrl: process.env.MU_BASE_URL || file.baseUrl || 'http://localhost:8080/v1',
79
- model: cliModel || process.env.MU_MODEL || file.model,
80
- maxTokens: envInt('MU_MAX_TOKENS') ?? file.maxTokens ?? 4096,
81
- temperature: envFloat('MU_TEMPERATURE') ?? file.temperature ?? 0.7,
82
- streamTimeoutMs: envInt('MU_STREAM_TIMEOUT') ?? file.streamTimeoutMs ?? 60_000,
83
- systemPrompt: process.env.MU_SYSTEM_PROMPT || file.systemPrompt || tryRead(SYSTEM_PROMPT_PATH),
84
- plugins: file.plugins,
85
- };
86
-
87
- if (!existsSync(CONFIG_PATH)) {
88
- mkdirSync(CONFIG_DIR, { recursive: true });
89
- const KEYS = [
90
- 'baseUrl',
91
- 'model',
92
- 'maxTokens',
93
- 'temperature',
94
- 'streamTimeoutMs',
95
- 'systemPrompt',
96
- 'plugins',
97
- ] as const;
98
- const fileConfig = Object.fromEntries(
99
- KEYS.filter((k) => file[k] !== undefined).map((k) => [k, file[k]]),
100
- ) as Partial<AppConfig>;
101
- writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2), 'utf-8');
102
- }
103
-
104
- return config;
105
- }
106
-
107
- export function saveConfig(updates: Partial<AppConfig>): void {
108
- const file = tryParseJson(tryRead(CONFIG_PATH));
109
- const merged = { ...file, ...updates };
110
- const KEYS = ['baseUrl', 'model', 'maxTokens', 'temperature', 'streamTimeoutMs', 'systemPrompt', 'plugins'] as const;
111
- const fileConfig = Object.fromEntries(
112
- KEYS.filter((k) => merged[k] !== undefined).map((k) => [k, merged[k]]),
113
- ) as Partial<AppConfig>;
114
- mkdirSync(CONFIG_DIR, { recursive: true });
115
- writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2), 'utf-8');
116
- }