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.
- package/README.md +0 -2
- package/bin/mu.js +1 -1
- package/package.json +12 -4
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +40 -0
- package/src/cli/args.ts +128 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +51 -0
- package/src/config/index.ts +181 -0
- package/src/main.ts +4 -0
- package/src/runtime/createRegistry.ts +58 -0
- package/src/runtime/pluginLoader.ts +109 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +190 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
- package/src/tui/chat/useChatPanel.ts +96 -0
- package/src/tui/chat/useChatSession.ts +115 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +57 -0
- package/src/tui/chat/useStatusSegments.ts +49 -0
- package/src/tui/chat/useStreamConsumer.ts +118 -0
- package/src/tui/components/chat/ChatPanel.tsx +12 -38
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +70 -0
- package/src/tui/components/messages/EditOutput.tsx +42 -27
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +26 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/messageItem.tsx +4 -15
- package/src/tui/components/messages/toolCallBlock.tsx +56 -34
- package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
- package/src/tui/components/primitives/pickerModal.tsx +45 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/statusBar.tsx +25 -0
- package/src/tui/components/ui/dialogLayer.tsx +21 -7
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
- package/src/tui/input/commands.test.ts +49 -0
- package/src/tui/input/commands.ts +39 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +88 -0
- package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +30 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
- /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
- /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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
|
33
|
-
private
|
|
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
|
|
47
|
+
private statusListeners: Set<StatusListener> = new Set();
|
|
36
48
|
|
|
37
|
-
// ─── Subscription (used by
|
|
49
|
+
// ─── Dialog Subscription (used by DialogLayer) ──────────────────────────
|
|
38
50
|
|
|
39
51
|
subscribe(fn: () => void): () => void {
|
|
40
|
-
this.
|
|
41
|
-
return () =>
|
|
52
|
+
this.dialogSubscribers.add(fn);
|
|
53
|
+
return () => {
|
|
54
|
+
this.dialogSubscribers.delete(fn);
|
|
55
|
+
};
|
|
42
56
|
}
|
|
43
57
|
|
|
44
|
-
private
|
|
45
|
-
for (const fn of this.
|
|
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.
|
|
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.
|
|
83
|
+
this.notifyDialogSubscribers();
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
// ─── Toast ──────────────────────────────────────────────────────────────
|
|
74
88
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
103
|
+
return () => {
|
|
104
|
+
this.toastListeners.delete(callback);
|
|
105
|
+
};
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
// ─── Status ─────────────────────────────────────────────────────────────
|
|
87
109
|
|
|
88
|
-
onStatusChange(callback:
|
|
89
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
181
|
+
this.emitStatus();
|
|
149
182
|
}
|
|
150
183
|
|
|
151
184
|
clearStatus(key: string): void {
|
|
152
185
|
this.statusMap.delete(key);
|
|
153
|
-
this.
|
|
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
|
-
}
|