mu-coding 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -92
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -161
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/singleShot.ts +0 -42
- 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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import type { Plugin, PluginRegistry } from 'mu-agents';
|
|
5
|
+
import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
|
|
6
|
+
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
7
|
+
|
|
8
|
+
export function discoverPluginFiles(): string[] {
|
|
9
|
+
const dir = getPluginsDir();
|
|
10
|
+
try {
|
|
11
|
+
return readdirSync(dir)
|
|
12
|
+
.filter((f) => f.endsWith('.ts'))
|
|
13
|
+
.map((f) => join(dir, f));
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatPluginError(name: string, err: unknown): string {
|
|
20
|
+
const parts: string[] = [`Plugin "${name}" failed`];
|
|
21
|
+
let current: unknown = err;
|
|
22
|
+
while (current) {
|
|
23
|
+
if (current instanceof Error) {
|
|
24
|
+
parts.push(current.message);
|
|
25
|
+
current = current.cause;
|
|
26
|
+
} else {
|
|
27
|
+
parts.push(String(current));
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return parts.join(': ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveNpmPlugin(specifier: string): string {
|
|
35
|
+
const { name } = parseBareNpmSpec(specifier.slice(4));
|
|
36
|
+
const dataDir = getDataDir();
|
|
37
|
+
try {
|
|
38
|
+
const require = createRequire(resolve(dataDir, 'package.json'));
|
|
39
|
+
return require.resolve(name);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(`Cannot resolve "${name}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPluginShape(value: unknown): value is Plugin {
|
|
46
|
+
return typeof value === 'object' && value !== null && 'name' in value && typeof (value as Plugin).name === 'string';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract a plugin from a loaded module. Tries (in order):
|
|
51
|
+
* 1. `module.default` as a factory function
|
|
52
|
+
* 2. `module.createPlugin` as a factory function
|
|
53
|
+
* 3. `module.default` as a Plugin object
|
|
54
|
+
* 4. `module` as a Plugin object
|
|
55
|
+
*
|
|
56
|
+
* Returns `null` if no plugin shape matches; the caller should report this
|
|
57
|
+
* with the list of available exports for debugging.
|
|
58
|
+
*/
|
|
59
|
+
function extractPlugin(mod: Record<string, unknown>, pluginConfig: Record<string, unknown>): Plugin | null {
|
|
60
|
+
const factory = (mod.default ?? mod.createPlugin) as unknown;
|
|
61
|
+
if (typeof factory === 'function') {
|
|
62
|
+
const result = (factory as (cfg: Record<string, unknown>) => unknown)(pluginConfig);
|
|
63
|
+
return isPluginShape(result) ? result : null;
|
|
64
|
+
}
|
|
65
|
+
if (isPluginShape(mod.default)) {
|
|
66
|
+
return mod.default;
|
|
67
|
+
}
|
|
68
|
+
if (isPluginShape(mod)) {
|
|
69
|
+
return mod;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadConfiguredPlugin(
|
|
75
|
+
registry: PluginRegistry,
|
|
76
|
+
name: string,
|
|
77
|
+
pluginConfig?: Record<string, unknown>,
|
|
78
|
+
uiService?: InkUIService,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const config = pluginConfig ?? {};
|
|
81
|
+
let target: string;
|
|
82
|
+
try {
|
|
83
|
+
target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let mod: Record<string, unknown>;
|
|
90
|
+
try {
|
|
91
|
+
mod = (await import(target)) as Record<string, unknown>;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const plugin = extractPlugin(mod, config);
|
|
98
|
+
if (!plugin) {
|
|
99
|
+
const exportKeys = Object.keys(mod).join(', ') || '(none)';
|
|
100
|
+
uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await registry.register(plugin);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
uiService?.notify(formatPluginError(name, err), 'error');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { clearSessionCache, listSessionsAsync, loadSession, saveSession } from './index';
|
|
6
|
+
|
|
7
|
+
let tmpRoot: string;
|
|
8
|
+
let originalDataHome: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'mu-sessions-'));
|
|
12
|
+
originalDataHome = process.env.XDG_DATA_HOME;
|
|
13
|
+
process.env.XDG_DATA_HOME = tmpRoot;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
if (originalDataHome === undefined) {
|
|
18
|
+
delete process.env.XDG_DATA_HOME;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.XDG_DATA_HOME = originalDataHome;
|
|
21
|
+
}
|
|
22
|
+
clearSessionCache();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('saveSession / loadSession', () => {
|
|
26
|
+
it('round-trips messages as JSONL', async () => {
|
|
27
|
+
const path = join(tmpRoot, 'roundtrip.jsonl');
|
|
28
|
+
const messages = [
|
|
29
|
+
{ role: 'user' as const, content: 'hi' },
|
|
30
|
+
{ role: 'assistant' as const, content: 'hello' },
|
|
31
|
+
];
|
|
32
|
+
await saveSession(path, messages);
|
|
33
|
+
|
|
34
|
+
const raw = readFileSync(path, 'utf-8');
|
|
35
|
+
expect(raw.split('\n').filter(Boolean)).toHaveLength(2);
|
|
36
|
+
|
|
37
|
+
const loaded = loadSession(path);
|
|
38
|
+
expect(loaded).toEqual(messages);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns [] for missing files', () => {
|
|
42
|
+
expect(loadSession(join(tmpRoot, 'does-not-exist.jsonl'))).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('skips malformed JSONL lines', () => {
|
|
46
|
+
const path = join(tmpRoot, 'partial.jsonl');
|
|
47
|
+
writeFileSync(path, '{"role":"user","content":"ok"}\n{not json}\n{"role":"assistant","content":"yo"}\n');
|
|
48
|
+
const loaded = loadSession(path);
|
|
49
|
+
expect(loaded).toHaveLength(2);
|
|
50
|
+
expect(loaded[0].role).toBe('user');
|
|
51
|
+
expect(loaded[1].role).toBe('assistant');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('writes empty content for empty arrays', async () => {
|
|
55
|
+
const path = join(tmpRoot, 'empty.jsonl');
|
|
56
|
+
await saveSession(path, []);
|
|
57
|
+
expect(readFileSync(path, 'utf-8')).toBe('');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('listSessionsAsync', () => {
|
|
62
|
+
it('returns [] when no project sessions exist', async () => {
|
|
63
|
+
const list = await listSessionsAsync();
|
|
64
|
+
expect(Array.isArray(list)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createReadStream, mkdirSync, readdirSync, readFileSync, writeFileSync } 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-provider';
|
|
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
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sync helper preserved for legacy/test callers — wraps the same data path
|
|
186
|
+
// without the streaming optimization. New code should prefer `listSessionsAsync`.
|
|
187
|
+
export function saveSessionSync(path: string, messages: ChatMessage[]): void {
|
|
188
|
+
const content = messages.length > 0 ? `${messages.map((m) => JSON.stringify(m)).join('\n')}\n` : '';
|
|
189
|
+
writeFileSync(path, content, 'utf-8');
|
|
190
|
+
}
|
|
@@ -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,33 @@
|
|
|
1
|
+
import type { PluginRegistry, ToolDisplayHint } from 'mu-agents';
|
|
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-provider';
|
|
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
|
+
}
|