mu-coding 0.5.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 -3
- package/package.json +9 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +1 -1
- package/src/app/startApp.ts +11 -8
- package/src/cli/args.ts +14 -11
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- 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 +128 -23
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +22 -9
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +31 -3
- package/src/tui/chat/useChatPanel.ts +7 -5
- package/src/tui/chat/useChatSession.ts +222 -53
- package/src/tui/chat/useModels.ts +2 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +25 -14
- package/src/tui/chat/useStatusSegments.ts +17 -4
- package/src/tui/components/chat/ChatPanel.tsx +10 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +6 -4
- package/src/tui/components/messages/ToolHeader.tsx +3 -1
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +19 -1
- 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 +6 -5
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/primitives/dropdown.tsx +8 -4
- package/src/tui/components/primitives/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +3 -1
- package/src/tui/components/primitives/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +8 -1
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/input/InputBoxView.tsx +135 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +6 -1
- 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 +1 -1
- package/src/tui/input/useCommandExecutor.ts +1 -1
- package/src/tui/input/useInputBox.ts +134 -15
- package/src/tui/input/useInputHandler.ts +316 -126
- 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/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +26 -13
- 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 +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PluginRegistry, ShortcutHandler } from 'mu-core';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
interface PluginShortcuts {
|
|
5
|
+
/** Map of `keyId` (e.g. "tab", "ctrl+t") to plugin handler. */
|
|
6
|
+
handlers: Map<string, ShortcutHandler>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to the registry's shortcut bindings and expose a stable map keyed
|
|
11
|
+
* by keyId. Multiple registrations on the same key are last-write-wins; the
|
|
12
|
+
* agent plugin is expected to grab Tab and unregister cleanly on deactivation.
|
|
13
|
+
*/
|
|
14
|
+
export function usePluginShortcuts(registry: PluginRegistry): PluginShortcuts {
|
|
15
|
+
const [handlers, setHandlers] = useState<Map<string, ShortcutHandler>>(() => buildMap(registry));
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setHandlers(buildMap(registry));
|
|
18
|
+
return registry.onShortcutsChange(() => setHandlers(buildMap(registry)));
|
|
19
|
+
}, [registry]);
|
|
20
|
+
return { handlers };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildMap(registry: PluginRegistry): Map<string, ShortcutHandler> {
|
|
24
|
+
const out = new Map<string, ShortcutHandler>();
|
|
25
|
+
for (const entry of registry.getShortcuts()) {
|
|
26
|
+
out.set(entry.key.toLowerCase(), entry.handler);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ApprovalRequest } from 'mu-agents';
|
|
3
|
+
import { createInkApprovalChannel } from './InkApprovalChannel';
|
|
4
|
+
import { InkUIService } from './InkUIService';
|
|
5
|
+
|
|
6
|
+
function fakeRequest(extra?: Partial<ApprovalRequest>): ApprovalRequest {
|
|
7
|
+
return {
|
|
8
|
+
id: 'r1',
|
|
9
|
+
token: 't1',
|
|
10
|
+
agentId: 'build',
|
|
11
|
+
toolName: 'bash',
|
|
12
|
+
toolArgs: { cmd: 'echo hi' },
|
|
13
|
+
channelId: 'tui',
|
|
14
|
+
createdAt: 0,
|
|
15
|
+
status: 'pending',
|
|
16
|
+
...extra,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('InkApprovalChannel', () => {
|
|
21
|
+
it('returns approved when user confirms', async () => {
|
|
22
|
+
const ui = new InkUIService();
|
|
23
|
+
const channel = createInkApprovalChannel(ui);
|
|
24
|
+
const promise = channel.sendApprovalRequest(fakeRequest());
|
|
25
|
+
// Resolve the dialog with `true` (approve).
|
|
26
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
27
|
+
ui.resolveDialog(true);
|
|
28
|
+
expect(await promise).toBe('approved');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns denied when user declines', async () => {
|
|
32
|
+
const ui = new InkUIService();
|
|
33
|
+
const channel = createInkApprovalChannel(ui);
|
|
34
|
+
const promise = channel.sendApprovalRequest(fakeRequest());
|
|
35
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
36
|
+
ui.resolveDialog(false);
|
|
37
|
+
expect(await promise).toBe('denied');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('serialises tool args into the dialog message', async () => {
|
|
41
|
+
const ui = new InkUIService();
|
|
42
|
+
const channel = createInkApprovalChannel(ui);
|
|
43
|
+
const promise = channel.sendApprovalRequest(fakeRequest({ toolArgs: { path: 'src/x.ts' } }));
|
|
44
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
45
|
+
const dialog = ui.currentDialog();
|
|
46
|
+
expect(dialog?.title).toBe('Run `bash`?');
|
|
47
|
+
expect(dialog?.message).toContain('src/x.ts');
|
|
48
|
+
ui.resolveDialog(true);
|
|
49
|
+
await promise;
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InkApprovalChannel — bridges the ApprovalGateway to the Ink confirm
|
|
3
|
+
* dialog. Returns the user's choice synchronously (no token round-trip
|
|
4
|
+
* through HTTP / Telegram); the gateway honours that immediate result.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ApprovalChannel, ApprovalRequest, ApprovalResult } from 'mu-agents';
|
|
8
|
+
import type { InkUIService } from './InkUIService';
|
|
9
|
+
|
|
10
|
+
function formatArgs(args: unknown): string {
|
|
11
|
+
if (args === null || args === undefined) return '';
|
|
12
|
+
if (typeof args === 'string') return args;
|
|
13
|
+
try {
|
|
14
|
+
const json = JSON.stringify(args, null, 2);
|
|
15
|
+
return json.length > 800 ? `${json.slice(0, 800)}…` : json;
|
|
16
|
+
} catch {
|
|
17
|
+
return String(args);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createInkApprovalChannel(ui: InkUIService): ApprovalChannel {
|
|
22
|
+
return {
|
|
23
|
+
async sendApprovalRequest(req: ApprovalRequest): Promise<ApprovalResult | undefined> {
|
|
24
|
+
const title = `Run \`${req.toolName}\`?`;
|
|
25
|
+
const message = formatArgs(req.toolArgs);
|
|
26
|
+
const ok = await ui.confirm(title, message);
|
|
27
|
+
return ok ? 'approved' : 'denied';
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/tui/renderApp.tsx
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
|
-
import { render } from 'ink';
|
|
2
|
-
import type { PluginRegistry } from 'mu-
|
|
3
|
-
import type { ChatMessage, ProviderConfig } from 'mu-provider';
|
|
1
|
+
import { type Instance, render } from 'ink';
|
|
2
|
+
import type { ChatMessage, PluginRegistry } from 'mu-core';
|
|
4
3
|
import type { ShutdownFn } from '../app/shutdown';
|
|
4
|
+
import type { AppConfig } from '../config/index';
|
|
5
|
+
import type { HostMessageBus } from '../runtime/messageBus';
|
|
5
6
|
import { ChatPanel } from './components/chat/ChatPanel';
|
|
7
|
+
import { ThemeProvider } from './context/ThemeContext';
|
|
6
8
|
import type { InkUIService } from './plugins/InkUIService';
|
|
9
|
+
import { resolveTheme } from './theme';
|
|
7
10
|
|
|
8
11
|
interface RenderAppOptions {
|
|
9
|
-
config:
|
|
12
|
+
config: AppConfig;
|
|
10
13
|
initialMessages?: ChatMessage[];
|
|
11
14
|
registry: PluginRegistry;
|
|
15
|
+
messageBus: HostMessageBus;
|
|
12
16
|
uiService: InkUIService;
|
|
13
17
|
shutdown: ShutdownFn;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Renders the chat TUI and returns the Ink `Instance` so callers (the TUI
|
|
22
|
+
* channel, tests) can unmount it explicitly. Ink stays mounted until the
|
|
23
|
+
* caller invokes `instance.unmount()` or the process exits.
|
|
24
|
+
*/
|
|
25
|
+
export function renderApp(options: RenderAppOptions): Instance {
|
|
26
|
+
const theme = resolveTheme(options.config.theme);
|
|
27
|
+
return render(
|
|
28
|
+
<ThemeProvider theme={theme}>
|
|
29
|
+
<ChatPanel
|
|
30
|
+
config={options.config}
|
|
31
|
+
initialMessages={options.initialMessages}
|
|
32
|
+
registry={options.registry}
|
|
33
|
+
messageBus={options.messageBus}
|
|
34
|
+
uiService={options.uiService}
|
|
35
|
+
shutdown={options.shutdown}
|
|
36
|
+
/>
|
|
37
|
+
</ThemeProvider>,
|
|
25
38
|
{
|
|
26
39
|
exitOnCtrlC: false,
|
|
27
40
|
kittyKeyboard: { mode: 'enabled' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveTheme } from './merge';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mergeTheme, resolveTheme } from './merge';
|
|
3
|
+
import { DEFAULT_THEME } from './presets';
|
|
4
|
+
|
|
5
|
+
describe('mergeTheme', () => {
|
|
6
|
+
it('returns the base theme untouched when no override is provided', () => {
|
|
7
|
+
expect(mergeTheme(DEFAULT_THEME)).toBe(DEFAULT_THEME);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('merges leaf overrides without dropping siblings', () => {
|
|
11
|
+
const merged = mergeTheme(DEFAULT_THEME, { input: { cursor: '#ff00ff' } });
|
|
12
|
+
expect(merged.input.cursor).toBe('#ff00ff');
|
|
13
|
+
expect(merged.input.background).toBe(DEFAULT_THEME.input.background);
|
|
14
|
+
expect(merged.user).toEqual(DEFAULT_THEME.user);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not mutate the base theme', () => {
|
|
18
|
+
const before = DEFAULT_THEME.input.cursor;
|
|
19
|
+
mergeTheme(DEFAULT_THEME, { input: { cursor: '#000000' } });
|
|
20
|
+
expect(DEFAULT_THEME.input.cursor).toBe(before);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('ignores non-object sections defensively', () => {
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing malformed user input on purpose
|
|
25
|
+
const merged = mergeTheme(DEFAULT_THEME, { input: 'red' as any });
|
|
26
|
+
expect(merged.input).toEqual(DEFAULT_THEME.input);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('resolveTheme', () => {
|
|
31
|
+
it('returns the default theme when config is undefined', () => {
|
|
32
|
+
expect(resolveTheme(undefined)).toBe(DEFAULT_THEME);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies overrides on top of the default theme', () => {
|
|
36
|
+
const theme = resolveTheme({ user: { border: 'magenta' } });
|
|
37
|
+
expect(theme.user.border).toBe('magenta');
|
|
38
|
+
expect(theme.input).toEqual(DEFAULT_THEME.input);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns default for non-object input', () => {
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: malformed value
|
|
43
|
+
expect(resolveTheme(42 as any)).toBe(DEFAULT_THEME);
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: malformed value
|
|
45
|
+
expect(resolveTheme(null as any)).toBe(DEFAULT_THEME);
|
|
46
|
+
// biome-ignore lint/suspicious/noExplicitAny: string presets are no longer supported
|
|
47
|
+
expect(resolveTheme('dark' as any)).toBe(DEFAULT_THEME);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DEFAULT_THEME } from './presets';
|
|
2
|
+
import type { PartialTheme, Theme, ThemeConfig } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Two-level deep merge tailored to the `Theme` shape: each top-level key maps
|
|
6
|
+
* to an object of color leaves (strings), so we never need recursion beyond
|
|
7
|
+
* one nesting level. Keeping it flat avoids accidentally merging into nested
|
|
8
|
+
* structures users haven't opted into and also avoids `any`/recursion that
|
|
9
|
+
* would trip Biome's complexity rules.
|
|
10
|
+
*/
|
|
11
|
+
function mergeTheme(base: Theme, override?: PartialTheme): Theme {
|
|
12
|
+
if (!override) return base;
|
|
13
|
+
const out: Theme = { ...base };
|
|
14
|
+
const keys = Object.keys(override) as (keyof Theme)[];
|
|
15
|
+
for (const key of keys) {
|
|
16
|
+
mergeSection(out, base, override, key);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mergeSection<K extends keyof Theme>(out: Theme, base: Theme, override: PartialTheme, key: K): void {
|
|
22
|
+
const section = override[key];
|
|
23
|
+
if (section && typeof section === 'object') {
|
|
24
|
+
out[key] = { ...base[key], ...section };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the user-supplied `theme` field from config.json into a fully
|
|
34
|
+
* populated `Theme`. Tolerates malformed input (wrong type) by silently
|
|
35
|
+
* falling back to the default — config corruption should never crash the TUI.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveTheme(config: ThemeConfig | undefined): Theme {
|
|
38
|
+
if (config === undefined) return DEFAULT_THEME;
|
|
39
|
+
if (!isPlainObject(config)) return DEFAULT_THEME;
|
|
40
|
+
return mergeTheme(DEFAULT_THEME, config as PartialTheme);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { mergeTheme };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Theme } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single built-in theme. Reproduces the exact hard-coded colors that
|
|
5
|
+
* lived inline in the components prior to the theme extraction — using it
|
|
6
|
+
* must be a visual no-op. Users may still override individual leaves via
|
|
7
|
+
* the `theme` field in their config.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_THEME: Theme = {
|
|
10
|
+
input: {
|
|
11
|
+
background: '#222222',
|
|
12
|
+
text: 'white',
|
|
13
|
+
cursor: 'white',
|
|
14
|
+
commandHighlight: 'green',
|
|
15
|
+
footerHint: 'gray',
|
|
16
|
+
attachmentName: 'cyan',
|
|
17
|
+
attachmentError: 'red',
|
|
18
|
+
modelLabel: 'white',
|
|
19
|
+
},
|
|
20
|
+
user: {
|
|
21
|
+
background: '#1a1a1a',
|
|
22
|
+
border: 'yellow',
|
|
23
|
+
attachment: 'cyan',
|
|
24
|
+
},
|
|
25
|
+
assistant: {
|
|
26
|
+
text: 'white',
|
|
27
|
+
},
|
|
28
|
+
tool: {
|
|
29
|
+
success: 'green',
|
|
30
|
+
error: 'red',
|
|
31
|
+
previewBackground: '#111111',
|
|
32
|
+
previewText: 'white',
|
|
33
|
+
summaryDim: 'gray',
|
|
34
|
+
warning: 'yellow',
|
|
35
|
+
},
|
|
36
|
+
reasoning: {
|
|
37
|
+
title: 'yellow',
|
|
38
|
+
body: 'gray',
|
|
39
|
+
},
|
|
40
|
+
modal: {
|
|
41
|
+
background: '#1a1a1a',
|
|
42
|
+
hint: 'gray',
|
|
43
|
+
},
|
|
44
|
+
toast: {
|
|
45
|
+
background: '#1a1a1a',
|
|
46
|
+
defaultColor: 'green',
|
|
47
|
+
closeHint: 'gray',
|
|
48
|
+
},
|
|
49
|
+
dropdown: {
|
|
50
|
+
selected: 'green',
|
|
51
|
+
description: 'gray',
|
|
52
|
+
placeholder: 'gray',
|
|
53
|
+
cursor: 'white',
|
|
54
|
+
empty: 'gray',
|
|
55
|
+
},
|
|
56
|
+
dialog: {
|
|
57
|
+
confirmYes: 'green',
|
|
58
|
+
confirmNo: 'red',
|
|
59
|
+
hint: 'gray',
|
|
60
|
+
cursor: 'white',
|
|
61
|
+
placeholder: 'gray',
|
|
62
|
+
},
|
|
63
|
+
diff: {
|
|
64
|
+
added: 'green',
|
|
65
|
+
removed: 'red',
|
|
66
|
+
context: 'gray',
|
|
67
|
+
warning: 'yellow',
|
|
68
|
+
},
|
|
69
|
+
status: {
|
|
70
|
+
separator: 'gray',
|
|
71
|
+
},
|
|
72
|
+
common: {
|
|
73
|
+
error: 'red',
|
|
74
|
+
warning: 'yellow',
|
|
75
|
+
success: 'green',
|
|
76
|
+
accent: 'cyan',
|
|
77
|
+
info: 'blue',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Theme type definitions. Kept free of any Ink import so the type can travel
|
|
2
|
+
// to `config/index.ts` without dragging the renderer into the config layer.
|
|
3
|
+
//
|
|
4
|
+
// Color values are plain strings: either an Ink-supported color name
|
|
5
|
+
// ("red", "cyan", "yellow"...) or a hex code ("#1a1a1a"). All optional fields
|
|
6
|
+
// in `PartialTheme` mirror this so users can override one leaf at a time
|
|
7
|
+
// without having to redeclare a full theme.
|
|
8
|
+
|
|
9
|
+
interface ThemeInput {
|
|
10
|
+
background: string;
|
|
11
|
+
text: string;
|
|
12
|
+
cursor: string;
|
|
13
|
+
commandHighlight: string;
|
|
14
|
+
footerHint: string;
|
|
15
|
+
attachmentName: string;
|
|
16
|
+
attachmentError: string;
|
|
17
|
+
modelLabel: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ThemeUser {
|
|
21
|
+
background: string;
|
|
22
|
+
border: string;
|
|
23
|
+
attachment: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ThemeAssistant {
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ThemeTool {
|
|
31
|
+
success: string;
|
|
32
|
+
error: string;
|
|
33
|
+
previewBackground: string;
|
|
34
|
+
previewText: string;
|
|
35
|
+
summaryDim: string;
|
|
36
|
+
warning: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ThemeReasoning {
|
|
40
|
+
title: string;
|
|
41
|
+
body: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ThemeModal {
|
|
45
|
+
background: string;
|
|
46
|
+
hint: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ThemeToast {
|
|
50
|
+
background: string;
|
|
51
|
+
defaultColor: string;
|
|
52
|
+
closeHint: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ThemeDropdown {
|
|
56
|
+
selected: string;
|
|
57
|
+
description: string;
|
|
58
|
+
placeholder: string;
|
|
59
|
+
cursor: string;
|
|
60
|
+
empty: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ThemeDialog {
|
|
64
|
+
confirmYes: string;
|
|
65
|
+
confirmNo: string;
|
|
66
|
+
hint: string;
|
|
67
|
+
cursor: string;
|
|
68
|
+
placeholder: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ThemeDiff {
|
|
72
|
+
added: string;
|
|
73
|
+
removed: string;
|
|
74
|
+
context: string;
|
|
75
|
+
warning: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ThemeStatus {
|
|
79
|
+
separator: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ThemeCommon {
|
|
83
|
+
error: string;
|
|
84
|
+
warning: string;
|
|
85
|
+
success: string;
|
|
86
|
+
accent: string;
|
|
87
|
+
info: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Theme {
|
|
91
|
+
input: ThemeInput;
|
|
92
|
+
user: ThemeUser;
|
|
93
|
+
assistant: ThemeAssistant;
|
|
94
|
+
tool: ThemeTool;
|
|
95
|
+
reasoning: ThemeReasoning;
|
|
96
|
+
modal: ThemeModal;
|
|
97
|
+
toast: ThemeToast;
|
|
98
|
+
dropdown: ThemeDropdown;
|
|
99
|
+
dialog: ThemeDialog;
|
|
100
|
+
diff: ThemeDiff;
|
|
101
|
+
status: ThemeStatus;
|
|
102
|
+
common: ThemeCommon;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type DeepPartial<T> = {
|
|
106
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type PartialTheme = DeepPartial<Theme>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Shape stored in `~/.config/mu/config.json` under the `theme` key. An object
|
|
113
|
+
* with per-leaf overrides on top of the default theme. Kept loose on purpose:
|
|
114
|
+
* malformed input falls back to the default theme rather than throwing.
|
|
115
|
+
*/
|
|
116
|
+
export type ThemeConfig = PartialTheme;
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync, execSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import type { ImageAttachment } from 'mu-
|
|
5
|
+
import type { ImageAttachment } from 'mu-core';
|
|
6
6
|
|
|
7
7
|
const CLIPBOARD_TIMEOUT = 3000;
|
|
8
8
|
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { type AgentEvent, type PluginRegistry, runAgent } from 'mu-agents';
|
|
2
|
-
import type { ChatMessage, ProviderConfig } from 'mu-provider';
|
|
3
|
-
import { useCallback, useState } from 'react';
|
|
4
|
-
|
|
5
|
-
export interface StreamState {
|
|
6
|
-
text: string;
|
|
7
|
-
reasoning: string;
|
|
8
|
-
tps: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const EMPTY_STREAM: StreamState = { text: '', reasoning: '', tps: 0 };
|
|
12
|
-
const TPS_WARMUP_SEC = 0.5;
|
|
13
|
-
|
|
14
|
-
export interface StreamConsumerState {
|
|
15
|
-
streaming: boolean;
|
|
16
|
-
error: string | null;
|
|
17
|
-
stream: StreamState;
|
|
18
|
-
/**
|
|
19
|
-
* Run the agent against `messages` and stream events into local state.
|
|
20
|
-
* Returns the final message array (or null if the agent didn't produce one,
|
|
21
|
-
* e.g. on abort). Throws are caught and reported via `error`.
|
|
22
|
-
*/
|
|
23
|
-
runStream: (
|
|
24
|
-
messages: ChatMessage[],
|
|
25
|
-
config: ProviderConfig,
|
|
26
|
-
model: string,
|
|
27
|
-
signal: AbortSignal,
|
|
28
|
-
registry: PluginRegistry,
|
|
29
|
-
onMessages: (messages: ChatMessage[]) => void,
|
|
30
|
-
) => Promise<ChatMessage[] | null>;
|
|
31
|
-
resetError: () => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function applyEvent(prev: StreamState, event: AgentEvent, tps: number): StreamState {
|
|
35
|
-
switch (event.type) {
|
|
36
|
-
case 'content':
|
|
37
|
-
return { ...prev, text: event.text, tps };
|
|
38
|
-
case 'reasoning':
|
|
39
|
-
return { ...prev, reasoning: event.text, tps };
|
|
40
|
-
case 'turn_end':
|
|
41
|
-
return { ...prev, text: '', reasoning: '' };
|
|
42
|
-
default:
|
|
43
|
-
return prev;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function consumeAgent(
|
|
48
|
-
events: AsyncGenerator<AgentEvent>,
|
|
49
|
-
onStream: (updater: (prev: StreamState) => StreamState) => void,
|
|
50
|
-
onMessages: (messages: ChatMessage[]) => void,
|
|
51
|
-
): Promise<ChatMessage[] | null> {
|
|
52
|
-
let final: ChatMessage[] | null = null;
|
|
53
|
-
const start = Date.now();
|
|
54
|
-
let tokenCount = 0;
|
|
55
|
-
|
|
56
|
-
for await (const event of events) {
|
|
57
|
-
if (event.type === 'content' || event.type === 'reasoning') {
|
|
58
|
-
tokenCount++;
|
|
59
|
-
const elapsed = (Date.now() - start) / 1000;
|
|
60
|
-
const tps = elapsed > TPS_WARMUP_SEC ? Math.round(tokenCount / elapsed) : 0;
|
|
61
|
-
onStream((prev) => applyEvent(prev, event, tps));
|
|
62
|
-
} else if (event.type === 'messages') {
|
|
63
|
-
final = event.messages;
|
|
64
|
-
onMessages(event.messages);
|
|
65
|
-
} else {
|
|
66
|
-
onStream((prev) => applyEvent(prev, event, 0));
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return final;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Owns the in-flight streaming view: which tokens have been received, the
|
|
74
|
-
* tokens-per-second meter, error text, and the streaming flag. Decoupled
|
|
75
|
-
* from message persistence so it can be reused by single-shot agents or
|
|
76
|
-
* test harnesses.
|
|
77
|
-
*/
|
|
78
|
-
export function useStreamConsumer(): StreamConsumerState {
|
|
79
|
-
const [streaming, setStreaming] = useState(false);
|
|
80
|
-
const [error, setError] = useState<string | null>(null);
|
|
81
|
-
const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
|
|
82
|
-
|
|
83
|
-
const resetError = useCallback(() => setError(null), []);
|
|
84
|
-
|
|
85
|
-
const runStream = useCallback(
|
|
86
|
-
async (
|
|
87
|
-
messages: ChatMessage[],
|
|
88
|
-
config: ProviderConfig,
|
|
89
|
-
model: string,
|
|
90
|
-
signal: AbortSignal,
|
|
91
|
-
registry: PluginRegistry,
|
|
92
|
-
onMessages: (messages: ChatMessage[]) => void,
|
|
93
|
-
): Promise<ChatMessage[] | null> => {
|
|
94
|
-
setStream(EMPTY_STREAM);
|
|
95
|
-
setError(null);
|
|
96
|
-
setStreaming(true);
|
|
97
|
-
try {
|
|
98
|
-
return await consumeAgent(runAgent(messages, config, model, signal, registry), setStream, onMessages);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
if (!(err instanceof Error && err.name === 'AbortError')) {
|
|
101
|
-
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
104
|
-
} finally {
|
|
105
|
-
setStreaming(false);
|
|
106
|
-
// Preserve partial output on abort so the user can see what arrived;
|
|
107
|
-
// clear it on clean completion so the persisted assistant message
|
|
108
|
-
// doesn't render twice.
|
|
109
|
-
if (!signal.aborted) {
|
|
110
|
-
setStream((s) => ({ ...s, text: '', reasoning: '' }));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
[],
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
return { streaming, error, stream, runStream, resetError };
|
|
118
|
-
}
|