mu-coding 0.4.0 → 0.8.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 +49 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- 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/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -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/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- 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/{diff.ts → utils/diff.ts} +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createReadStream, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import type { ChatMessage } from 'mu-core';
|
|
6
|
+
import { getDataDir } from '../config/index';
|
|
7
|
+
import { getProjectId, getProjectName } from './project';
|
|
8
|
+
|
|
9
|
+
function getProjectSessionsDir(): string {
|
|
10
|
+
return join(getDataDir(), 'sessions', getProjectId());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSortedSessionFiles(): string[] {
|
|
14
|
+
try {
|
|
15
|
+
const dir = getProjectSessionsDir();
|
|
16
|
+
return readdirSync(dir)
|
|
17
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
18
|
+
.sort()
|
|
19
|
+
.reverse();
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionInfo {
|
|
26
|
+
path: string;
|
|
27
|
+
name: string;
|
|
28
|
+
date: Date;
|
|
29
|
+
messageCount: number;
|
|
30
|
+
preview: string;
|
|
31
|
+
project: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function generateSessionPath(): string {
|
|
35
|
+
const dir = getProjectSessionsDir();
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
38
|
+
return join(dir, `${ts}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Persist `messages` as JSONL. Async to avoid blocking the event loop on
|
|
43
|
+
* large sessions; callers should `await` to apply backpressure.
|
|
44
|
+
*/
|
|
45
|
+
export async function saveSession(path: string, messages: ChatMessage[]): Promise<void> {
|
|
46
|
+
const content = messages.length > 0 ? `${messages.map((m) => JSON.stringify(m)).join('\n')}\n` : '';
|
|
47
|
+
await writeFile(path, content, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadSession(path: string): ChatMessage[] {
|
|
51
|
+
try {
|
|
52
|
+
const content = readFileSync(path, 'utf-8').trim();
|
|
53
|
+
if (!content) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return content
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((line) => {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(line) as ChatMessage;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.filter((msg): msg is ChatMessage => msg !== null);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SessionPeek {
|
|
72
|
+
messageCount: number;
|
|
73
|
+
preview: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PREVIEW_LENGTH = 80;
|
|
77
|
+
const NO_USER_PREVIEW = '(no user message)';
|
|
78
|
+
const EMPTY_PEEK: SessionPeek = { messageCount: 0, preview: '(empty)' };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* In-memory cache of session metadata, keyed by absolute path. Entries are
|
|
82
|
+
* invalidated when the file's mtime changes (a fresh `saveSession` after a
|
|
83
|
+
* new message bumps mtime). Lifetime is the process — no on-disk index.
|
|
84
|
+
*/
|
|
85
|
+
const peekCache = new Map<string, { mtimeMs: number; peek: SessionPeek }>();
|
|
86
|
+
|
|
87
|
+
function extractUserPreview(line: string): string | null {
|
|
88
|
+
try {
|
|
89
|
+
const msg = JSON.parse(line) as ChatMessage;
|
|
90
|
+
if (msg && msg.role === 'user' && typeof msg.content === 'string') {
|
|
91
|
+
return msg.content.slice(0, PREVIEW_LENGTH).replace(/\n/g, ' ');
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Skip malformed lines.
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stream a session file line-by-line so memory use is bounded regardless of
|
|
101
|
+
* file size, and we can stop expensive `JSON.parse` work as soon as we've
|
|
102
|
+
* captured the first user message.
|
|
103
|
+
*/
|
|
104
|
+
async function peekSessionStreaming(path: string): Promise<SessionPeek> {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const stream = createReadStream(path, { encoding: 'utf-8', highWaterMark: 64 * 1024 });
|
|
107
|
+
const rl = createInterface({ input: stream });
|
|
108
|
+
let messageCount = 0;
|
|
109
|
+
let preview: string | null = null;
|
|
110
|
+
|
|
111
|
+
const finish = (): void => {
|
|
112
|
+
resolve({ messageCount, preview: preview ?? NO_USER_PREVIEW });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
rl.on('line', (line) => {
|
|
116
|
+
if (!line) return;
|
|
117
|
+
messageCount++;
|
|
118
|
+
if (preview !== null) return;
|
|
119
|
+
preview = extractUserPreview(line);
|
|
120
|
+
});
|
|
121
|
+
rl.on('close', finish);
|
|
122
|
+
stream.on('error', () => resolve(EMPTY_PEEK));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function peekSessionCached(path: string, mtimeMs: number): Promise<SessionPeek> {
|
|
127
|
+
const cached = peekCache.get(path);
|
|
128
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
129
|
+
return cached.peek;
|
|
130
|
+
}
|
|
131
|
+
const peek = await peekSessionStreaming(path);
|
|
132
|
+
peekCache.set(path, { mtimeMs, peek });
|
|
133
|
+
return peek;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Test/maintenance helper — drop the in-memory peek cache. */
|
|
137
|
+
export function clearSessionCache(): void {
|
|
138
|
+
peekCache.clear();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getLatestSession(): string | null {
|
|
142
|
+
const files = getSortedSessionFiles();
|
|
143
|
+
return files.length ? join(getProjectSessionsDir(), files[0]) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve session metadata for the picker. Each file is peeked concurrently
|
|
148
|
+
* (typically just a few hundred bytes per file), and successive picker opens
|
|
149
|
+
* hit the in-memory cache keyed by mtime.
|
|
150
|
+
*/
|
|
151
|
+
export async function listSessionsAsync(): Promise<SessionInfo[]> {
|
|
152
|
+
let dir: string;
|
|
153
|
+
try {
|
|
154
|
+
dir = getProjectSessionsDir();
|
|
155
|
+
mkdirSync(dir, { recursive: true });
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const files = getSortedSessionFiles();
|
|
160
|
+
const project = getProjectName();
|
|
161
|
+
|
|
162
|
+
const results = await Promise.all(
|
|
163
|
+
files.map(async (file) => {
|
|
164
|
+
const path = join(dir, file);
|
|
165
|
+
try {
|
|
166
|
+
const fileStat = await stat(path);
|
|
167
|
+
const peek = await peekSessionCached(path, fileStat.mtimeMs);
|
|
168
|
+
return {
|
|
169
|
+
path,
|
|
170
|
+
name: file.replace('.jsonl', ''),
|
|
171
|
+
date: fileStat.mtime,
|
|
172
|
+
messageCount: peek.messageCount,
|
|
173
|
+
preview: peek.preview,
|
|
174
|
+
project,
|
|
175
|
+
} satisfies SessionInfo;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return results.filter((s): s is SessionInfo => s !== null);
|
|
183
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the streaming `listSessionsAsync` end-to-end against a tmp data
|
|
3
|
+
* directory laid out exactly like a real `~/.local/share/mu/sessions/<proj>`.
|
|
4
|
+
* The cache is exercised by listing twice and confirming we get the same
|
|
5
|
+
* structural result without re-reading.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
|
|
8
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { clearSessionCache, listSessionsAsync } from './index';
|
|
12
|
+
import { getProjectId } from './project';
|
|
13
|
+
|
|
14
|
+
const PROJECT_ID = getProjectId();
|
|
15
|
+
|
|
16
|
+
let tmpRoot: string;
|
|
17
|
+
let sessionsDir: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'mu-peek-'));
|
|
21
|
+
process.env.XDG_DATA_HOME = tmpRoot;
|
|
22
|
+
sessionsDir = join(tmpRoot, 'mu', 'sessions', PROJECT_ID);
|
|
23
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
clearSessionCache();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function writeSession(name: string, messages: Array<{ role: string; content: string }>): string {
|
|
31
|
+
const path = join(sessionsDir, name);
|
|
32
|
+
writeFileSync(path, `${messages.map((m) => JSON.stringify(m)).join('\n')}\n`);
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('listSessionsAsync', () => {
|
|
37
|
+
it('captures message count and the first user preview', async () => {
|
|
38
|
+
writeSession('2026-01-01.jsonl', [
|
|
39
|
+
{ role: 'user', content: 'hello world' },
|
|
40
|
+
{ role: 'assistant', content: 'hi back' },
|
|
41
|
+
{ role: 'user', content: 'a follow-up' },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const list = await listSessionsAsync();
|
|
45
|
+
const entry = list.find((s) => s.name === '2026-01-01');
|
|
46
|
+
expect(entry).toBeDefined();
|
|
47
|
+
expect(entry?.messageCount).toBe(3);
|
|
48
|
+
expect(entry?.preview).toBe('hello world');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles malformed lines without crashing', async () => {
|
|
52
|
+
writeSession('2026-01-02.jsonl', [{ role: 'user', content: 'ok' }]);
|
|
53
|
+
// Append a junk line outside the structured writer.
|
|
54
|
+
const broken = join(sessionsDir, '2026-01-03.jsonl');
|
|
55
|
+
writeFileSync(broken, '{"role":"user","content":"valid"}\n{not-json\n');
|
|
56
|
+
|
|
57
|
+
const list = await listSessionsAsync();
|
|
58
|
+
const entry = list.find((s) => s.name === '2026-01-03');
|
|
59
|
+
expect(entry?.messageCount).toBe(2);
|
|
60
|
+
expect(entry?.preview).toBe('valid');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reports a placeholder when no user message is present', async () => {
|
|
64
|
+
writeSession('2026-01-04.jsonl', [{ role: 'assistant', content: 'no user here' }]);
|
|
65
|
+
const list = await listSessionsAsync();
|
|
66
|
+
const entry = list.find((s) => s.name === '2026-01-04');
|
|
67
|
+
expect(entry?.preview).toBe('(no user message)');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('truncates long previews to PREVIEW_LENGTH and replaces newlines', async () => {
|
|
71
|
+
const longMessage = `line one\n${'x'.repeat(200)}`;
|
|
72
|
+
writeSession('2026-01-05.jsonl', [{ role: 'user', content: longMessage }]);
|
|
73
|
+
const list = await listSessionsAsync();
|
|
74
|
+
const entry = list.find((s) => s.name === '2026-01-05');
|
|
75
|
+
expect(entry?.preview).not.toContain('\n');
|
|
76
|
+
expect(entry?.preview.length).toBe(80);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('serves cached results on the second call (same mtime)', async () => {
|
|
80
|
+
writeSession('2026-01-06.jsonl', [{ role: 'user', content: 'cached' }]);
|
|
81
|
+
const first = await listSessionsAsync();
|
|
82
|
+
const second = await listSessionsAsync();
|
|
83
|
+
const a = first.find((s) => s.name === '2026-01-06');
|
|
84
|
+
const b = second.find((s) => s.name === '2026-01-06');
|
|
85
|
+
expect(a?.preview).toBe(b?.preview);
|
|
86
|
+
expect(a?.messageCount).toBe(b?.messageCount);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function findGitRoot(from: string): string | null {
|
|
5
|
+
try {
|
|
6
|
+
const root = execFileSync('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
|
+
// The project root is determined once per process — `process.cwd()` doesn't
|
|
18
|
+
// move during a TUI session and shelling out to git on every save is wasteful.
|
|
19
|
+
let cachedRoot: string | null = null;
|
|
20
|
+
|
|
21
|
+
function getProjectRoot(): string {
|
|
22
|
+
if (cachedRoot !== null) {
|
|
23
|
+
return cachedRoot;
|
|
24
|
+
}
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
cachedRoot = findGitRoot(cwd) ?? cwd;
|
|
27
|
+
return cachedRoot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cachedId: string | null = null;
|
|
31
|
+
let cachedName: string | null = null;
|
|
32
|
+
|
|
33
|
+
export function getProjectId(): string {
|
|
34
|
+
if (cachedId !== null) {
|
|
35
|
+
return cachedId;
|
|
36
|
+
}
|
|
37
|
+
const root = getProjectRoot();
|
|
38
|
+
const hash = createHash('sha256').update(root).digest('hex').slice(0, 12);
|
|
39
|
+
const name = root.split('/').pop() || 'unknown';
|
|
40
|
+
cachedId = `${name}-${hash}`;
|
|
41
|
+
return cachedId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getProjectName(): string {
|
|
45
|
+
if (cachedName !== null) {
|
|
46
|
+
return cachedName;
|
|
47
|
+
}
|
|
48
|
+
const root = getProjectRoot();
|
|
49
|
+
cachedName = root.split('/').pop() || root;
|
|
50
|
+
return cachedName;
|
|
51
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import type { ChatMessage, PluginRegistry } from 'mu-core';
|
|
3
|
+
import type { ShutdownFn } from '../../app/shutdown';
|
|
4
|
+
import type { AppConfig } from '../../config/index';
|
|
5
|
+
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
6
|
+
import type { InkUIService } from '../plugins/InkUIService';
|
|
7
|
+
import { createTuiChannel } from './tuiChannel';
|
|
8
|
+
|
|
9
|
+
// Stub renderApp by mocking the import surface. We can't actually mount Ink
|
|
10
|
+
// in a non-TTY test environment, but we can verify the channel structure
|
|
11
|
+
// and that the registry passed in has the methods the TUI subscribes to.
|
|
12
|
+
const noop = (): void => {
|
|
13
|
+
/* stub */
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const renderArgs: Array<{ registry: PluginRegistry; config: AppConfig }> = [];
|
|
17
|
+
mock.module('../renderApp', () => ({
|
|
18
|
+
renderApp: (opts: { registry: PluginRegistry; config: AppConfig }) => {
|
|
19
|
+
renderArgs.push(opts);
|
|
20
|
+
return {
|
|
21
|
+
unmount: noop,
|
|
22
|
+
waitUntilExit: async () => {
|
|
23
|
+
/* stub */
|
|
24
|
+
},
|
|
25
|
+
rerender: noop,
|
|
26
|
+
cleanup: noop,
|
|
27
|
+
clear: noop,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const fakeOpts = {
|
|
33
|
+
config: {} as AppConfig,
|
|
34
|
+
initialMessages: [] as ChatMessage[],
|
|
35
|
+
registry: {} as PluginRegistry,
|
|
36
|
+
messageBus: {} as HostMessageBus,
|
|
37
|
+
uiService: {} as InkUIService,
|
|
38
|
+
shutdown: (async () => {
|
|
39
|
+
/* test shutdown stub */
|
|
40
|
+
}) as ShutdownFn,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('createTuiChannel', () => {
|
|
44
|
+
it('exposes id="tui"', () => {
|
|
45
|
+
const ch = createTuiChannel(fakeOpts);
|
|
46
|
+
expect(ch.id).toBe('tui');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('start is idempotent — second start is a no-op', async () => {
|
|
50
|
+
const ch = createTuiChannel(fakeOpts);
|
|
51
|
+
await ch.start();
|
|
52
|
+
await ch.start(); // should not throw / re-mount
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('stop without start is a no-op', async () => {
|
|
56
|
+
const ch = createTuiChannel(fakeOpts);
|
|
57
|
+
await ch.stop?.();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('start → stop → start cycles cleanly', async () => {
|
|
61
|
+
const ch = createTuiChannel(fakeOpts);
|
|
62
|
+
await ch.start();
|
|
63
|
+
await ch.stop?.();
|
|
64
|
+
await ch.start();
|
|
65
|
+
await ch.stop?.();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('createTuiChannel — registry shape contract', () => {
|
|
70
|
+
it('forwards a registry that exposes the subscription methods the TUI relies on', async () => {
|
|
71
|
+
// Build a registry mock whose methods are all functions; the channel's
|
|
72
|
+
// `start()` calls renderApp which (in production) mounts components that
|
|
73
|
+
// immediately invoke onStatusChange / onRenderersChange / etc.
|
|
74
|
+
const stubFn = (): (() => void) => () => {
|
|
75
|
+
/* unsub */
|
|
76
|
+
};
|
|
77
|
+
const fakeRegistry: Record<string, unknown> = {
|
|
78
|
+
getTools: () => [],
|
|
79
|
+
getFilteredTools: async () => [],
|
|
80
|
+
getHooks: () => [],
|
|
81
|
+
getStatusSegments: () => new Map(),
|
|
82
|
+
onStatusChange: stubFn(),
|
|
83
|
+
getRenderers: () => [],
|
|
84
|
+
onRenderersChange: stubFn(),
|
|
85
|
+
getShortcuts: () => [],
|
|
86
|
+
onShortcutsChange: stubFn(),
|
|
87
|
+
getCommands: () => [],
|
|
88
|
+
};
|
|
89
|
+
const ch = createTuiChannel({ ...fakeOpts, registry: fakeRegistry as unknown as PluginRegistry });
|
|
90
|
+
renderArgs.length = 0;
|
|
91
|
+
await ch.start();
|
|
92
|
+
expect(renderArgs).toHaveLength(1);
|
|
93
|
+
const seen = renderArgs[0].registry as unknown as Record<string, unknown>;
|
|
94
|
+
for (const method of [
|
|
95
|
+
'onStatusChange',
|
|
96
|
+
'getStatusSegments',
|
|
97
|
+
'onRenderersChange',
|
|
98
|
+
'getRenderers',
|
|
99
|
+
'onShortcutsChange',
|
|
100
|
+
'getShortcuts',
|
|
101
|
+
'getCommands',
|
|
102
|
+
]) {
|
|
103
|
+
expect(typeof seen[method]).toBe('function');
|
|
104
|
+
}
|
|
105
|
+
await ch.stop?.();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Channel — wraps Ink rendering inside the mu-core `Channel` contract.
|
|
3
|
+
* `start()` mounts the app and captures the Ink instance; `stop()` unmounts
|
|
4
|
+
* it cleanly so `channels.stopAll()` restores the terminal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Instance } from 'ink';
|
|
8
|
+
import type { Channel, ChatMessage, PluginRegistry } from 'mu-core';
|
|
9
|
+
import type { ShutdownFn } from '../../app/shutdown';
|
|
10
|
+
import type { AppConfig } from '../../config/index';
|
|
11
|
+
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
12
|
+
import type { InkUIService } from '../plugins/InkUIService';
|
|
13
|
+
import { renderApp } from '../renderApp';
|
|
14
|
+
|
|
15
|
+
export interface TuiChannelOptions {
|
|
16
|
+
config: AppConfig;
|
|
17
|
+
initialMessages?: ChatMessage[];
|
|
18
|
+
registry: PluginRegistry;
|
|
19
|
+
messageBus: HostMessageBus;
|
|
20
|
+
uiService: InkUIService;
|
|
21
|
+
shutdown: ShutdownFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createTuiChannel(opts: TuiChannelOptions): Channel {
|
|
25
|
+
let instance: Instance | null = null;
|
|
26
|
+
return {
|
|
27
|
+
id: 'tui',
|
|
28
|
+
async start() {
|
|
29
|
+
// Idempotent: re-starting after a stop remounts; re-starting while
|
|
30
|
+
// mounted is a no-op.
|
|
31
|
+
if (instance) return;
|
|
32
|
+
instance = renderApp(opts);
|
|
33
|
+
},
|
|
34
|
+
async stop() {
|
|
35
|
+
if (!instance) return;
|
|
36
|
+
try {
|
|
37
|
+
instance.unmount();
|
|
38
|
+
// Wait for Ink's exit promise so `stopAll()` callers know the
|
|
39
|
+
// terminal has been restored before they continue (e.g. emitting
|
|
40
|
+
// a final shutdown message to stdout).
|
|
41
|
+
await instance.waitUntilExit().catch(() => {
|
|
42
|
+
/* unmount-induced exit rejects with the cause; we don't care */
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
instance = null;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChatMessage, PluginRegistry } from 'mu-core';
|
|
2
|
+
import { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin renderers are typed `unknown` in mu-agents (kept renderer-agnostic);
|
|
6
|
+
* the host narrows to React at the boundary so renderer authors can return
|
|
7
|
+
* any `ReactNode`.
|
|
8
|
+
*/
|
|
9
|
+
type ReactMessageRenderer = (msg: ChatMessage) => ReactNode;
|
|
10
|
+
|
|
11
|
+
type RendererMap = Map<string, ReactMessageRenderer>;
|
|
12
|
+
|
|
13
|
+
const MessageRendererContext = createContext<RendererMap>(new Map());
|
|
14
|
+
|
|
15
|
+
export const MessageRendererProvider = MessageRendererContext.Provider;
|
|
16
|
+
|
|
17
|
+
/** Hook used by `MessageItem` to look up custom renderers by `customType`. */
|
|
18
|
+
export function useMessageRenderer(customType: string | undefined): ReactMessageRenderer | undefined {
|
|
19
|
+
const map = useContext(MessageRendererContext);
|
|
20
|
+
if (!customType) return undefined;
|
|
21
|
+
return map.get(customType);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Track the registry's custom renderer set. Re-builds the map whenever a
|
|
26
|
+
* plugin registers or unregisters one. The cast from `unknown` to ReactNode
|
|
27
|
+
* happens here so descendant components stay strictly typed.
|
|
28
|
+
*/
|
|
29
|
+
export function useRegistryRenderers(registry: PluginRegistry): RendererMap {
|
|
30
|
+
const [map, setMap] = useState<RendererMap>(() => buildMap(registry));
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setMap(buildMap(registry));
|
|
33
|
+
return registry.onRenderersChange(() => setMap(buildMap(registry)));
|
|
34
|
+
}, [registry]);
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildMap(registry: PluginRegistry): RendererMap {
|
|
39
|
+
const out: RendererMap = new Map();
|
|
40
|
+
for (const [customType, renderer] of registry.getRenderers()) {
|
|
41
|
+
out.set(customType, (msg) => renderer(msg) as ReactNode);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PluginRegistry, ToolDisplayHint } from 'mu-core';
|
|
2
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
3
|
+
|
|
4
|
+
type ToolDisplayMap = Map<string, ToolDisplayHint>;
|
|
5
|
+
|
|
6
|
+
const ToolDisplayContext = createContext<ToolDisplayMap>(new Map());
|
|
7
|
+
|
|
8
|
+
export const ToolDisplayProvider = ToolDisplayContext.Provider;
|
|
9
|
+
|
|
10
|
+
/** Hook used by tool renderers to look up rendering hints for a tool name. */
|
|
11
|
+
export function useToolDisplay(name: string): ToolDisplayHint | undefined {
|
|
12
|
+
const map = useContext(ToolDisplayContext);
|
|
13
|
+
return map.get(name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a lookup table from the registry, keyed by tool function name. Tools
|
|
18
|
+
* without a `display` hint are omitted; the renderer falls back to a generic
|
|
19
|
+
* preview block. Memoized on the registry reference — registration is
|
|
20
|
+
* effectively startup-only today, but the dependency makes the contract
|
|
21
|
+
* explicit if hot-loading lands later.
|
|
22
|
+
*/
|
|
23
|
+
export function useToolDisplayMap(registry: PluginRegistry): ToolDisplayMap {
|
|
24
|
+
return useMemo(() => {
|
|
25
|
+
const map: ToolDisplayMap = new Map();
|
|
26
|
+
for (const tool of registry.getTools()) {
|
|
27
|
+
if (tool.display) {
|
|
28
|
+
map.set(tool.definition.function.name, tool.display);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}, [registry]);
|
|
33
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { restoreTerminal, type ShutdownFn } from '../../app/shutdown';
|
|
2
3
|
|
|
3
4
|
function useDoublePress(timeoutMs: number) {
|
|
4
5
|
const [warning, setWarning] = useState(false);
|
|
@@ -38,6 +39,7 @@ export function useAbort(
|
|
|
38
39
|
controllerRef: React.RefObject<AbortController | null>,
|
|
39
40
|
exit: () => void,
|
|
40
41
|
timeoutMs: number,
|
|
42
|
+
shutdown?: ShutdownFn,
|
|
41
43
|
): AbortState {
|
|
42
44
|
const { warning: quitWarning, confirm: onCtrlC } = useDoublePress(timeoutMs);
|
|
43
45
|
const { warning: abortWarning, confirm: onEsc } = useDoublePress(timeoutMs);
|
|
@@ -48,14 +50,21 @@ export function useAbort(
|
|
|
48
50
|
controllerRef.current = null;
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
|
-
if (onCtrlC()) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (!onCtrlC()) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Restore the terminal first so even a hanging shutdown leaves a usable
|
|
57
|
+
// prompt, then unmount Ink (fires `useScroll`/etc. cleanups), then run
|
|
58
|
+
// the registry shutdown which `process.exit`s when complete.
|
|
59
|
+
restoreTerminal();
|
|
60
|
+
exit();
|
|
61
|
+
if (shutdown) {
|
|
62
|
+
void shutdown(0);
|
|
63
|
+
} else {
|
|
64
|
+
// Fallback for callers that didn't wire a shutdown function.
|
|
65
|
+
setTimeout(() => process.exit(0), 500);
|
|
57
66
|
}
|
|
58
|
-
}, [streaming, onCtrlC, exit, controllerRef]);
|
|
67
|
+
}, [streaming, onCtrlC, exit, controllerRef, shutdown]);
|
|
59
68
|
|
|
60
69
|
const handleEsc = useCallback(() => {
|
|
61
70
|
if (!(streaming && controllerRef.current)) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ImageAttachment } from 'mu-core';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { readClipboardImage } from '../../utils/clipboard';
|
|
4
|
+
|
|
5
|
+
const ERROR_TIMEOUT_MS = 3000;
|
|
6
|
+
|
|
7
|
+
export interface AttachmentState {
|
|
8
|
+
attachment: ImageAttachment | null;
|
|
9
|
+
attachmentError: string | null;
|
|
10
|
+
onPaste: () => void;
|
|
11
|
+
clear: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useAttachment(): AttachmentState {
|
|
15
|
+
const [attachment, setAttachment] = useState<ImageAttachment | null>(null);
|
|
16
|
+
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
|
17
|
+
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
18
|
+
|
|
19
|
+
const cancelErrorTimer = useCallback(() => {
|
|
20
|
+
if (errorTimerRef.current) {
|
|
21
|
+
clearTimeout(errorTimerRef.current);
|
|
22
|
+
errorTimerRef.current = null;
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
// Cancel any pending error-clear timer if the component unmounts.
|
|
27
|
+
useEffect(() => cancelErrorTimer, [cancelErrorTimer]);
|
|
28
|
+
|
|
29
|
+
const onPaste = useCallback(() => {
|
|
30
|
+
cancelErrorTimer();
|
|
31
|
+
const img = readClipboardImage();
|
|
32
|
+
if (img) {
|
|
33
|
+
setAttachment(img);
|
|
34
|
+
setAttachmentError(null);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setAttachmentError('No image on clipboard');
|
|
38
|
+
errorTimerRef.current = setTimeout(() => {
|
|
39
|
+
setAttachmentError(null);
|
|
40
|
+
errorTimerRef.current = null;
|
|
41
|
+
}, ERROR_TIMEOUT_MS);
|
|
42
|
+
}, [cancelErrorTimer]);
|
|
43
|
+
|
|
44
|
+
const clear = useCallback(() => {
|
|
45
|
+
cancelErrorTimer();
|
|
46
|
+
setAttachment(null);
|
|
47
|
+
setAttachmentError(null);
|
|
48
|
+
}, [cancelErrorTimer]);
|
|
49
|
+
|
|
50
|
+
return { attachment, attachmentError, onPaste, clear };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type PickerKind = 'model' | 'sessions' | null;
|
|
54
|
+
|
|
55
|
+
export interface TogglesState {
|
|
56
|
+
showModelPicker: boolean;
|
|
57
|
+
showSessionPicker: boolean;
|
|
58
|
+
onTogglePicker: () => void;
|
|
59
|
+
onToggleSessionPicker: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* At most one picker is visible at a time. Toggling on a picker while the
|
|
64
|
+
* other is open swaps to the new one rather than stacking modals.
|
|
65
|
+
*/
|
|
66
|
+
export function useToggles(): TogglesState {
|
|
67
|
+
const [picker, setPicker] = useState<PickerKind>(null);
|
|
68
|
+
return {
|
|
69
|
+
showModelPicker: picker === 'model',
|
|
70
|
+
showSessionPicker: picker === 'sessions',
|
|
71
|
+
onTogglePicker: useCallback(() => setPicker((p) => (p === 'model' ? null : 'model')), []),
|
|
72
|
+
onToggleSessionPicker: useCallback(() => setPicker((p) => (p === 'sessions' ? null : 'sessions')), []),
|
|
73
|
+
};
|
|
74
|
+
}
|