mu-coding 0.1.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 +81 -0
- package/bin/mu.js +2 -0
- package/package.json +19 -0
- package/src/cli.ts +90 -0
- package/src/clipboard.ts +62 -0
- package/src/config.ts +116 -0
- package/src/diff.ts +81 -0
- package/src/main.tsx +80 -0
- package/src/project.ts +32 -0
- package/src/session.ts +95 -0
- package/src/singleShot.ts +42 -0
- package/src/tui/commands.ts +19 -0
- package/src/tui/components/chat/ChatPanel.tsx +55 -0
- package/src/tui/components/chat/ChatPanelBody.tsx +67 -0
- package/src/tui/components/chat/Pickers.tsx +44 -0
- package/src/tui/components/chatLayout.tsx +192 -0
- package/src/tui/components/inputBox.tsx +152 -0
- package/src/tui/components/messages/EditOutput.tsx +89 -0
- package/src/tui/components/messages/ReadOutput.tsx +43 -0
- package/src/tui/components/messages/WriteOutput.tsx +68 -0
- package/src/tui/components/messages/assistantMessage.tsx +24 -0
- package/src/tui/components/messages/messageItem.tsx +36 -0
- package/src/tui/components/messages/reasoningBlock.tsx +14 -0
- package/src/tui/components/messages/streamingOutput.tsx +14 -0
- package/src/tui/components/messages/toolCallBlock.tsx +99 -0
- package/src/tui/components/messages/userMessage.tsx +29 -0
- package/src/tui/components/ui/dropdown.tsx +96 -0
- package/src/tui/components/ui/modal.tsx +45 -0
- package/src/tui/components/ui/toast.tsx +45 -0
- package/src/tui/context/chat.ts +10 -0
- package/src/tui/hooks/useInputHandler.ts +257 -0
- package/src/tui/hooks/useScroll.ts +56 -0
- package/src/tui/hooks/useTerminal.ts +40 -0
- package/src/tui/hooks/useUI.ts +15 -0
- package/src/tui/useAbort.ts +68 -0
- package/src/tui/useChat.ts +52 -0
- package/src/tui/useChatSession.ts +155 -0
- package/src/tui/useChatUI.ts +51 -0
- package/src/tui/useModelList.ts +49 -0
- package/tsconfig.json +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# mu-coding
|
|
2
|
+
|
|
3
|
+
Minimal terminal AI assistant for local models. A TUI chat interface with tool-calling support, built with Ink and React.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g mu-coding
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
mu # Start interactive chat
|
|
15
|
+
mu -p "prompt" # Single-shot prompt, then exit
|
|
16
|
+
mu -m model -p "p" # Single-shot with specific model
|
|
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
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Config files follow XDG conventions:
|
|
25
|
+
|
|
26
|
+
| Path | Purpose |
|
|
27
|
+
|------|---------|
|
|
28
|
+
| `~/.config/mu/config.json` | Settings (baseUrl, model, maxTokens, temperature) |
|
|
29
|
+
| `~/.config/mu/SYSTEM.md` | System prompt |
|
|
30
|
+
| `~/.local/share/mu/sessions/` | Saved conversation sessions (JSONL) |
|
|
31
|
+
| `~/.cache/mu/repomap/` | Code index cache |
|
|
32
|
+
|
|
33
|
+
### Example `config.json`
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"baseUrl": "http://localhost:11434/v1",
|
|
38
|
+
"model": "qwen2.5",
|
|
39
|
+
"maxTokens": 4096,
|
|
40
|
+
"temperature": 0.7,
|
|
41
|
+
"streamTimeoutMs": 30000
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Keyboard Shortcuts
|
|
46
|
+
|
|
47
|
+
| Key | Action |
|
|
48
|
+
|-----|--------|
|
|
49
|
+
| `Enter` | Send message |
|
|
50
|
+
| `Shift+Enter` | New line |
|
|
51
|
+
| `Ctrl+S` | Send message |
|
|
52
|
+
| `Ctrl+C` | Abort / Quit (press twice) |
|
|
53
|
+
| `Esc` | Stop generation (press twice) |
|
|
54
|
+
| `↑` / `↓` | Navigate input history |
|
|
55
|
+
| `Ctrl+N` | New conversation |
|
|
56
|
+
| `Ctrl+M` | Cycle models |
|
|
57
|
+
| `Ctrl+O` | Model picker |
|
|
58
|
+
| `Ctrl+V` | Paste image from clipboard |
|
|
59
|
+
| `PageUp` / `PageDown` | Scroll |
|
|
60
|
+
| Mouse wheel | Scroll |
|
|
61
|
+
|
|
62
|
+
## Slash Commands
|
|
63
|
+
|
|
64
|
+
| Command | Action |
|
|
65
|
+
|---------|--------|
|
|
66
|
+
| `/model` | Select a model |
|
|
67
|
+
| `/sessions` | List project sessions |
|
|
68
|
+
| `/new` | New conversation |
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- Streams responses with live token/s display
|
|
73
|
+
- Multi-turn tool calling (bash, read, write, edit files)
|
|
74
|
+
- Code indexing via `mu-repomap` plugin (auto-loaded)
|
|
75
|
+
- Image attachment support
|
|
76
|
+
- Session persistence and resume
|
|
77
|
+
- Mouse wheel scrolling
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/bin/mu.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mu-coding",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal terminal AI assistant for local models",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mu": "./bin/mu.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "bun --watch bin/mu.js",
|
|
11
|
+
"start": "bun bin/mu.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"ink": "^7.0.1",
|
|
15
|
+
"mu-agents": "0.1.0",
|
|
16
|
+
"mu-provider": "0.1.0",
|
|
17
|
+
"react": "^19.2.5"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChatMessage } from 'mu-provider';
|
|
2
|
+
import { getLatestSession, loadSession } from './session';
|
|
3
|
+
|
|
4
|
+
interface CliArgs {
|
|
5
|
+
model?: string;
|
|
6
|
+
prompt?: string;
|
|
7
|
+
continueSession?: boolean;
|
|
8
|
+
sessionPath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function printHelp(): never {
|
|
12
|
+
console.log(`mu — minimal terminal AI assistant
|
|
13
|
+
|
|
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
|
|
21
|
+
|
|
22
|
+
Config (XDG):
|
|
23
|
+
~/.config/mu/config.json — configuration (baseUrl, model, streamTimeoutMs)
|
|
24
|
+
~/.config/mu/SYSTEM.md — system prompt
|
|
25
|
+
~/.local/share/mu/sessions/ — saved conversation sessions (JSONL)
|
|
26
|
+
~/.cache/mu/repomap/ — code index cache
|
|
27
|
+
|
|
28
|
+
Keyboard shortcuts (interactive):
|
|
29
|
+
Ctrl+C Abort / Quit (press twice)
|
|
30
|
+
Esc Stop generation (press twice while streaming)
|
|
31
|
+
Enter Send message
|
|
32
|
+
Shift+Enter New line
|
|
33
|
+
Ctrl+S Send message
|
|
34
|
+
↑ / ↓ Navigate input history
|
|
35
|
+
Ctrl+N New conversation
|
|
36
|
+
Ctrl+M Cycle models
|
|
37
|
+
Ctrl+O Model picker
|
|
38
|
+
Ctrl+V Paste image from clipboard`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseArgs(): CliArgs {
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const result: CliArgs = {};
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
const arg = args[i];
|
|
48
|
+
if (arg === '-m' && args[i + 1]) {
|
|
49
|
+
result.model = args[++i];
|
|
50
|
+
} else if (arg === '-p' && args[i + 1]) {
|
|
51
|
+
result.prompt = args[++i];
|
|
52
|
+
} else if (arg === '-c' || arg === '--continue') {
|
|
53
|
+
result.continueSession = true;
|
|
54
|
+
} else if (arg === '--session' && args[i + 1]) {
|
|
55
|
+
result.sessionPath = args[++i];
|
|
56
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
57
|
+
printHelp();
|
|
58
|
+
} else if (!(result.prompt || arg.startsWith('-'))) {
|
|
59
|
+
result.prompt = arg;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveInitialMessages(cliArgs: CliArgs): ChatMessage[] | undefined {
|
|
67
|
+
if (cliArgs.sessionPath) {
|
|
68
|
+
const msgs = loadSession(cliArgs.sessionPath);
|
|
69
|
+
if (msgs.length === 0) {
|
|
70
|
+
console.error(`Error: session file is empty or not found: ${cliArgs.sessionPath}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
return msgs;
|
|
74
|
+
}
|
|
75
|
+
if (cliArgs.continueSession) {
|
|
76
|
+
const latest = getLatestSession();
|
|
77
|
+
if (!latest) {
|
|
78
|
+
console.error('Error: no sessions found');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const msgs = loadSession(latest);
|
|
82
|
+
if (msgs.length === 0) {
|
|
83
|
+
console.error('Error: latest session is empty');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
console.log(`Resuming session: ${latest}`);
|
|
87
|
+
return msgs;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Lightweight diff for edit_file tool output.
|
|
2
|
+
// Uses prefix/suffix matching — sufficient for small, localized edits.
|
|
3
|
+
|
|
4
|
+
export interface DiffLine {
|
|
5
|
+
type: 'context' | 'old' | 'new';
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DiffResult {
|
|
10
|
+
lines: DiffLine[];
|
|
11
|
+
totalOldLines: number;
|
|
12
|
+
totalNewLines: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CONTEXT_LINES = 3;
|
|
16
|
+
const MAX_LINES = 500;
|
|
17
|
+
|
|
18
|
+
export function computeDiff(oldText: string, newText: string): DiffResult {
|
|
19
|
+
const oldLines = oldText.split('\n');
|
|
20
|
+
const newLines = newText.split('\n');
|
|
21
|
+
|
|
22
|
+
if (oldLines.length > MAX_LINES || newLines.length > MAX_LINES) {
|
|
23
|
+
return { lines: [], totalOldLines: oldLines.length, totalNewLines: newLines.length };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Find common prefix
|
|
27
|
+
let prefixLen = 0;
|
|
28
|
+
const minLen = Math.min(oldLines.length, newLines.length);
|
|
29
|
+
while (prefixLen < minLen && oldLines[prefixLen] === newLines[prefixLen]) {
|
|
30
|
+
prefixLen++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find common suffix (don't overlap with prefix)
|
|
34
|
+
let suffixLen = 0;
|
|
35
|
+
const maxSuffix = Math.min(oldLines.length - prefixLen, newLines.length - prefixLen);
|
|
36
|
+
while (
|
|
37
|
+
suffixLen < maxSuffix &&
|
|
38
|
+
oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
|
|
39
|
+
) {
|
|
40
|
+
suffixLen++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result: DiffLine[] = [];
|
|
44
|
+
|
|
45
|
+
// Context from prefix (last N lines)
|
|
46
|
+
const ctxStart = Math.max(0, prefixLen - CONTEXT_LINES);
|
|
47
|
+
for (let i = ctxStart; i < prefixLen; i++) {
|
|
48
|
+
result.push({ type: 'context', value: oldLines[i] });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Removed lines
|
|
52
|
+
for (let i = prefixLen; i < oldLines.length - suffixLen; i++) {
|
|
53
|
+
result.push({ type: 'old', value: oldLines[i] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Added lines
|
|
57
|
+
for (let i = prefixLen; i < newLines.length - suffixLen; i++) {
|
|
58
|
+
result.push({ type: 'new', value: newLines[i] });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Context from suffix (first N lines)
|
|
62
|
+
const ctxEnd = Math.min(suffixLen, CONTEXT_LINES);
|
|
63
|
+
for (let i = 0; i < ctxEnd; i++) {
|
|
64
|
+
const idx = oldLines.length - suffixLen + i;
|
|
65
|
+
result.push({ type: 'context', value: oldLines[idx] });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { lines: result, totalOldLines: oldLines.length, totalNewLines: newLines.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderDiff(diff: DiffResult, maxLines: number): { lines: string[]; truncated: boolean } {
|
|
72
|
+
const result: string[] = [];
|
|
73
|
+
const capped = diff.lines.slice(0, maxLines);
|
|
74
|
+
|
|
75
|
+
for (const line of capped) {
|
|
76
|
+
const prefix = line.type === 'old' ? '-' : line.type === 'new' ? '+' : ' ';
|
|
77
|
+
result.push(`${prefix} ${line.value}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { lines: result, truncated: diff.lines.length > maxLines };
|
|
81
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
|
|
6
|
+
import { parseArgs, resolveInitialMessages } from './cli';
|
|
7
|
+
import { type AppConfig, getPluginsDir, loadConfig } from './config';
|
|
8
|
+
import { runSingleShot } from './singleShot';
|
|
9
|
+
import { ChatPanel } from './tui/components/chat/ChatPanel';
|
|
10
|
+
|
|
11
|
+
function discoverPluginFiles(): string[] {
|
|
12
|
+
const dir = getPluginsDir();
|
|
13
|
+
try {
|
|
14
|
+
return readdirSync(dir)
|
|
15
|
+
.filter((f) => f.endsWith('.ts'))
|
|
16
|
+
.map((f) => join(dir, f));
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function createRegistry(cwd: string, config: AppConfig) {
|
|
23
|
+
const registry = new PluginRegistry({ cwd, config: {} });
|
|
24
|
+
|
|
25
|
+
// Register built-in tools (read, write, edit, bash)
|
|
26
|
+
await registry.register(createBuiltinPlugin());
|
|
27
|
+
|
|
28
|
+
// Auto-load .ts plugin files from ~/.config/mu/plugins/
|
|
29
|
+
for (const filePath of discoverPluginFiles()) {
|
|
30
|
+
await registry.loadPlugin(filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Load npm package plugins from config
|
|
34
|
+
if (config.plugins?.length) {
|
|
35
|
+
for (const entry of config.plugins) {
|
|
36
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
37
|
+
const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
|
|
38
|
+
await registry.loadPlugin(name, pluginConfig);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return registry;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const cliArgs = parseArgs();
|
|
47
|
+
const config = loadConfig(cliArgs.model);
|
|
48
|
+
const root = process.cwd();
|
|
49
|
+
|
|
50
|
+
const registry = await createRegistry(root, config);
|
|
51
|
+
|
|
52
|
+
if (cliArgs.prompt) {
|
|
53
|
+
try {
|
|
54
|
+
await runSingleShot(cliArgs.prompt, config, registry);
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
57
|
+
console.error(`Error: ${msg}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
} finally {
|
|
60
|
+
await registry.shutdown();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const initialMessages = resolveInitialMessages(cliArgs);
|
|
66
|
+
|
|
67
|
+
render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} />, {
|
|
68
|
+
exitOnCtrlC: false,
|
|
69
|
+
kittyKeyboard: { mode: 'enabled' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
process.on('exit', () => {
|
|
73
|
+
registry.shutdown();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
}
|