mu-coding 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -96
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -147
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/tui/commands.ts +0 -33
  64. package/src/tui/components/chatLayout.tsx +0 -192
  65. package/src/tui/useChatSession.ts +0 -155
  66. package/src/tui/useChatUI.ts +0 -51
  67. package/tsconfig.json +0 -10
  68. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  69. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  70. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  71. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext } from 'react';
2
- import type { ChatContextValue } from '../useChat';
2
+ import type { ChatContextValue } from './useChat';
3
3
 
4
4
  export const ChatContext = createContext<ChatContextValue | null>(null);
5
5
 
@@ -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
- exit();
53
- setTimeout(() => {
54
- process.stdout.write('\x1b[<u');
55
- process.exit(0);
56
- }, 100);
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
+ }