mu-coding 0.13.0 → 0.16.1
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 +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -119
- package/src/tui/chat/useChatSession.ts +0 -382
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -82
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -64
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
});
|
package/src/sessions/project.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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,107 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
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 { SubagentRunRegistry } from 'mu-agents';
|
|
9
|
-
import type { Channel, ChatMessage, PluginRegistry } from 'mu-core';
|
|
10
|
-
import type { ShutdownFn } from '../../app/shutdown';
|
|
11
|
-
import type { AppConfig } from '../../config/index';
|
|
12
|
-
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
13
|
-
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
14
|
-
import type { InkUIService } from '../plugins/InkUIService';
|
|
15
|
-
import { renderApp } from '../renderApp';
|
|
16
|
-
|
|
17
|
-
export interface TuiChannelOptions {
|
|
18
|
-
config: AppConfig;
|
|
19
|
-
initialMessages?: ChatMessage[];
|
|
20
|
-
registry: PluginRegistry;
|
|
21
|
-
messageBus: HostMessageBus;
|
|
22
|
-
uiService: InkUIService;
|
|
23
|
-
shutdown: ShutdownFn;
|
|
24
|
-
sessionPathHolder?: SessionPathHolder;
|
|
25
|
-
subagentRuns?: SubagentRunRegistry;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function createTuiChannel(opts: TuiChannelOptions): Channel {
|
|
29
|
-
let instance: Instance | null = null;
|
|
30
|
-
return {
|
|
31
|
-
id: 'tui',
|
|
32
|
-
async start() {
|
|
33
|
-
// Idempotent: re-starting after a stop remounts; re-starting while
|
|
34
|
-
// mounted is a no-op.
|
|
35
|
-
if (instance) return;
|
|
36
|
-
instance = renderApp({
|
|
37
|
-
config: opts.config,
|
|
38
|
-
initialMessages: opts.initialMessages,
|
|
39
|
-
registry: opts.registry,
|
|
40
|
-
messageBus: opts.messageBus,
|
|
41
|
-
uiService: opts.uiService,
|
|
42
|
-
shutdown: opts.shutdown,
|
|
43
|
-
sessionPathHolder: opts.sessionPathHolder,
|
|
44
|
-
subagentRuns: opts.subagentRuns,
|
|
45
|
-
});
|
|
46
|
-
},
|
|
47
|
-
async stop() {
|
|
48
|
-
if (!instance) return;
|
|
49
|
-
try {
|
|
50
|
-
instance.unmount();
|
|
51
|
-
// Wait for Ink's exit promise so `stopAll()` callers know the
|
|
52
|
-
// terminal has been restored before they continue (e.g. emitting
|
|
53
|
-
// a final shutdown message to stdout).
|
|
54
|
-
await instance.waitUntilExit().catch(() => {
|
|
55
|
-
/* unmount-induced exit rejects with the cause; we don't care */
|
|
56
|
-
});
|
|
57
|
-
} finally {
|
|
58
|
-
instance = null;
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext } from 'react';
|
|
2
|
-
import type { ChatContextValue } from './useChat';
|
|
3
|
-
|
|
4
|
-
export const ChatContext = createContext<ChatContextValue | null>(null);
|
|
5
|
-
|
|
6
|
-
export function useChatContext() {
|
|
7
|
-
const ctx = useContext(ChatContext);
|
|
8
|
-
if (!ctx) throw new Error('useChatContext requires ChatProvider');
|
|
9
|
-
return ctx;
|
|
10
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tui/chat/useAbort.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from 'react';
|
|
2
|
-
import { restoreTerminal, type ShutdownFn } from '../../app/shutdown';
|
|
3
|
-
|
|
4
|
-
function useDoublePress(timeoutMs: number) {
|
|
5
|
-
const [warning, setWarning] = useState(false);
|
|
6
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
7
|
-
|
|
8
|
-
const confirm = useCallback(() => {
|
|
9
|
-
if (warning) {
|
|
10
|
-
// Confirmed press: cancel the pending auto-reset and clear the
|
|
11
|
-
// warning flag immediately so the status hint ("Esc again to stop"
|
|
12
|
-
// / "Ctrl+C again to quit") disappears as soon as the action fires.
|
|
13
|
-
if (timerRef.current) {
|
|
14
|
-
clearTimeout(timerRef.current);
|
|
15
|
-
timerRef.current = null;
|
|
16
|
-
}
|
|
17
|
-
setWarning(false);
|
|
18
|
-
return true;
|
|
19
|
-
}
|
|
20
|
-
setWarning(true);
|
|
21
|
-
if (timerRef.current) {
|
|
22
|
-
clearTimeout(timerRef.current);
|
|
23
|
-
}
|
|
24
|
-
timerRef.current = setTimeout(() => {
|
|
25
|
-
setWarning(false);
|
|
26
|
-
timerRef.current = null;
|
|
27
|
-
}, timeoutMs);
|
|
28
|
-
return false;
|
|
29
|
-
}, [warning, timeoutMs]);
|
|
30
|
-
|
|
31
|
-
return { warning, confirm };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface AbortState {
|
|
35
|
-
controllerRef: React.RefObject<AbortController | null>;
|
|
36
|
-
quitWarning: boolean;
|
|
37
|
-
abortWarning: boolean;
|
|
38
|
-
onCtrlC: () => void;
|
|
39
|
-
onEsc: () => void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function useAbort(
|
|
43
|
-
streaming: boolean,
|
|
44
|
-
controllerRef: React.RefObject<AbortController | null>,
|
|
45
|
-
exit: () => void,
|
|
46
|
-
timeoutMs: number,
|
|
47
|
-
shutdown?: ShutdownFn,
|
|
48
|
-
): AbortState {
|
|
49
|
-
const { warning: quitWarning, confirm: onCtrlC } = useDoublePress(timeoutMs);
|
|
50
|
-
const { warning: abortWarning, confirm: onEsc } = useDoublePress(timeoutMs);
|
|
51
|
-
|
|
52
|
-
const handleCtrlC = useCallback(() => {
|
|
53
|
-
if (streaming && controllerRef.current) {
|
|
54
|
-
controllerRef.current.abort();
|
|
55
|
-
controllerRef.current = null;
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (!onCtrlC()) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
// Restore the terminal first so even a hanging shutdown leaves a usable
|
|
62
|
-
// prompt, then unmount Ink (fires `useScroll`/etc. cleanups), then run
|
|
63
|
-
// the registry shutdown which `process.exit`s when complete.
|
|
64
|
-
restoreTerminal();
|
|
65
|
-
exit();
|
|
66
|
-
if (shutdown) {
|
|
67
|
-
void shutdown(0);
|
|
68
|
-
} else {
|
|
69
|
-
// Fallback for callers that didn't wire a shutdown function.
|
|
70
|
-
setTimeout(() => process.exit(0), 500);
|
|
71
|
-
}
|
|
72
|
-
}, [streaming, onCtrlC, exit, controllerRef, shutdown]);
|
|
73
|
-
|
|
74
|
-
const handleEsc = useCallback(() => {
|
|
75
|
-
if (!(streaming && controllerRef.current)) {
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (onEsc()) {
|
|
79
|
-
controllerRef.current.abort();
|
|
80
|
-
controllerRef.current = null;
|
|
81
|
-
}
|
|
82
|
-
}, [streaming, onEsc, controllerRef]);
|
|
83
|
-
|
|
84
|
-
return { controllerRef, quitWarning, abortWarning, onCtrlC: handleCtrlC, onEsc: handleEsc };
|
|
85
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tui/chat/useChat.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { useApp } from 'ink';
|
|
2
|
-
import type { SubagentRunRegistry } from 'mu-agents';
|
|
3
|
-
import {
|
|
4
|
-
type ChatMessage,
|
|
5
|
-
createSessionManager,
|
|
6
|
-
type PluginRegistry,
|
|
7
|
-
type ProviderConfig,
|
|
8
|
-
type SessionManager,
|
|
9
|
-
} from 'mu-core';
|
|
10
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
-
import type { ShutdownFn } from '../../app/shutdown';
|
|
12
|
-
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
13
|
-
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
14
|
-
import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
|
|
15
|
-
import type { InkUIService } from '../plugins/InkUIService';
|
|
16
|
-
import { type AbortState, useAbort } from './useAbort';
|
|
17
|
-
import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useAttachment';
|
|
18
|
-
import { type ChatSessionState, useChatSession } from './useChatSession';
|
|
19
|
-
import { type ModelListState, useModelList } from './useModels';
|
|
20
|
-
|
|
21
|
-
const ABORT_TIMEOUT_MS = 2000;
|
|
22
|
-
|
|
23
|
-
export interface ChatContextValue {
|
|
24
|
-
config: ProviderConfig;
|
|
25
|
-
session: ChatSessionState;
|
|
26
|
-
sessionManager: SessionManager;
|
|
27
|
-
toggles: TogglesState;
|
|
28
|
-
attachment: AttachmentState;
|
|
29
|
-
models: ModelListState;
|
|
30
|
-
abort: AbortState;
|
|
31
|
-
sessions: SessionInfo[];
|
|
32
|
-
registry: PluginRegistry;
|
|
33
|
-
uiService?: InkUIService;
|
|
34
|
-
messageBus?: HostMessageBus;
|
|
35
|
-
subagentRuns?: SubagentRunRegistry;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function useChat(
|
|
39
|
-
config: ProviderConfig,
|
|
40
|
-
registry: PluginRegistry,
|
|
41
|
-
initialMessages?: ChatMessage[],
|
|
42
|
-
shutdown?: ShutdownFn,
|
|
43
|
-
uiService?: InkUIService,
|
|
44
|
-
messageBus?: HostMessageBus,
|
|
45
|
-
sessionPathHolder?: SessionPathHolder,
|
|
46
|
-
subagentRuns?: SubagentRunRegistry,
|
|
47
|
-
): ChatContextValue {
|
|
48
|
-
const { exit } = useApp();
|
|
49
|
-
const controllerRef = useRef<AbortController | null>(null);
|
|
50
|
-
const attachment = useAttachment();
|
|
51
|
-
const toggles = useToggles();
|
|
52
|
-
const models = useModelList(config.baseUrl, config.model);
|
|
53
|
-
// Stable SessionManager + Session for the lifetime of the chat hook. Model
|
|
54
|
-
// updates flow through `runTurn(options)` per call, so we don't need to
|
|
55
|
-
// re-instantiate on every change.
|
|
56
|
-
const sessionManager = useMemo(
|
|
57
|
-
() => createSessionManager({ registry, config, model: models.currentModel || config.model || 'unknown' }),
|
|
58
|
-
[registry, config, models.currentModel],
|
|
59
|
-
);
|
|
60
|
-
const muSession = useMemo(
|
|
61
|
-
() => sessionManager.getOrCreate('tui', { initialMessages }),
|
|
62
|
-
[sessionManager, initialMessages],
|
|
63
|
-
);
|
|
64
|
-
const session = useChatSession({
|
|
65
|
-
session: muSession,
|
|
66
|
-
config,
|
|
67
|
-
currentModel: models.currentModel,
|
|
68
|
-
attachment,
|
|
69
|
-
controllerRef,
|
|
70
|
-
initialMessages,
|
|
71
|
-
registry,
|
|
72
|
-
messageBus,
|
|
73
|
-
sessionPathHolder,
|
|
74
|
-
});
|
|
75
|
-
const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
|
|
76
|
-
|
|
77
|
-
// Stream the session list asynchronously when the picker opens. Empty until
|
|
78
|
-
// the first listing settles; subsequent opens hit the in-memory peek cache
|
|
79
|
-
// so they're effectively instant.
|
|
80
|
-
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!toggles.showSessionPicker) {
|
|
83
|
-
setSessions([]);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
let cancelled = false;
|
|
87
|
-
listSessionsAsync()
|
|
88
|
-
.then((list) => {
|
|
89
|
-
if (!cancelled) setSessions(list);
|
|
90
|
-
})
|
|
91
|
-
.catch(() => {
|
|
92
|
-
if (!cancelled) setSessions([]);
|
|
93
|
-
});
|
|
94
|
-
return () => {
|
|
95
|
-
cancelled = true;
|
|
96
|
-
};
|
|
97
|
-
}, [toggles.showSessionPicker]);
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
config,
|
|
101
|
-
session,
|
|
102
|
-
sessionManager,
|
|
103
|
-
toggles,
|
|
104
|
-
attachment,
|
|
105
|
-
models,
|
|
106
|
-
abort,
|
|
107
|
-
sessions,
|
|
108
|
-
registry,
|
|
109
|
-
uiService,
|
|
110
|
-
messageBus,
|
|
111
|
-
subagentRuns,
|
|
112
|
-
};
|
|
113
|
-
}
|