mu-coding 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,3 +1,5 @@
1
+ import type { UIService } from 'mu-core';
2
+
1
3
  export type DialogType = 'confirm' | 'select' | 'input';
2
4
 
3
5
  export interface DialogRequest {
@@ -17,32 +19,44 @@ export interface ToastRequest {
17
19
 
18
20
  let nextDialogId = 0;
19
21
 
22
+ type ToastListener = (toast: ToastRequest) => void;
23
+ type StatusListener = (entries: Map<string, string>) => void;
24
+
20
25
  /**
21
- * InkUIService bridges plugin UI requests with ink's React rendering.
26
+ * InkUIService bridges plugin UI requests with Ink's React rendering.
27
+ *
28
+ * All event channels (dialogs, toasts, status) follow the same multi-listener
29
+ * pattern: `subscribe`/`onToast`/`onStatusChange` return an unsubscribe
30
+ * function. This lets multiple components observe the same service safely
31
+ * (e.g. during hot-reload or component swaps) without one handler clobbering
32
+ * another.
22
33
  *
23
- * Dialog methods (confirm, select, input) push requests into a queue.
24
- * The TUI's DialogLayer component consumes the queue and renders appropriate modals.
25
- * When the user interacts, the promise is resolved.
34
+ * Toasts emitted before any listener subscribes are buffered and replayed
35
+ * to the first subscriber; this avoids losing plugin-load errors emitted
36
+ * before the TUI mounts.
26
37
  *
27
- * This class implements the same shape as mu-pi-compat's UIService interface
28
- * via structural typing no import needed.
38
+ * Implements `UIService` from `mu-agents` gives nominal typing so a
39
+ * change on either side fails the build.
29
40
  */
30
- export class InkUIService {
41
+ export class InkUIService implements UIService {
31
42
  private dialogQueue: DialogRequest[] = [];
32
- private subscribers: Set<() => void> = new Set();
33
- private toastCallback: ((toast: ToastRequest) => void) | null = null;
43
+ private dialogSubscribers: Set<() => void> = new Set();
44
+ private toastListeners: Set<ToastListener> = new Set();
45
+ private pendingToasts: ToastRequest[] = [];
34
46
  private statusMap: Map<string, string> = new Map();
35
- private statusCallback: ((entries: Map<string, string>) => void) | null = null;
47
+ private statusListeners: Set<StatusListener> = new Set();
36
48
 
37
- // ─── Subscription (used by React hooks) ─────────────────────────────────
49
+ // ─── Dialog Subscription (used by DialogLayer) ──────────────────────────
38
50
 
39
51
  subscribe(fn: () => void): () => void {
40
- this.subscribers.add(fn);
41
- return () => this.subscribers.delete(fn);
52
+ this.dialogSubscribers.add(fn);
53
+ return () => {
54
+ this.dialogSubscribers.delete(fn);
55
+ };
42
56
  }
43
57
 
44
- private notifySubscribers(): void {
45
- for (const fn of this.subscribers) {
58
+ private notifyDialogSubscribers(): void {
59
+ for (const fn of this.dialogSubscribers) {
46
60
  fn();
47
61
  }
48
62
  }
@@ -57,7 +71,7 @@ export class InkUIService {
57
71
  const dialog = this.dialogQueue.shift();
58
72
  if (dialog) {
59
73
  dialog.resolve(value);
60
- this.notifySubscribers();
74
+ this.notifyDialogSubscribers();
61
75
  }
62
76
  }
63
77
 
@@ -66,27 +80,44 @@ export class InkUIService {
66
80
  const dialog = this.dialogQueue.shift();
67
81
  if (dialog) {
68
82
  dialog.resolve(dialog.type === 'confirm' ? false : null);
69
- this.notifySubscribers();
83
+ this.notifyDialogSubscribers();
70
84
  }
71
85
  }
72
86
 
73
87
  // ─── Toast ──────────────────────────────────────────────────────────────
74
88
 
75
- private pendingToasts: ToastRequest[] = [];
76
-
77
- onToast(callback: (toast: ToastRequest) => void): void {
78
- this.toastCallback = callback;
79
- // Flush any toasts that fired before the TUI mounted
80
- for (const toast of this.pendingToasts) {
81
- callback(toast);
89
+ /**
90
+ * Subscribe to toast events. Returns an unsubscribe function. If toasts
91
+ * were emitted before any listener attached, they are replayed to the
92
+ * first subscriber once.
93
+ */
94
+ onToast(callback: ToastListener): () => void {
95
+ this.toastListeners.add(callback);
96
+ if (this.pendingToasts.length > 0) {
97
+ const buffered = this.pendingToasts;
98
+ this.pendingToasts = [];
99
+ for (const toast of buffered) {
100
+ callback(toast);
101
+ }
82
102
  }
83
- this.pendingToasts = [];
103
+ return () => {
104
+ this.toastListeners.delete(callback);
105
+ };
84
106
  }
85
107
 
86
108
  // ─── Status ─────────────────────────────────────────────────────────────
87
109
 
88
- onStatusChange(callback: (entries: Map<string, string>) => void): void {
89
- this.statusCallback = callback;
110
+ onStatusChange(callback: StatusListener): () => void {
111
+ this.statusListeners.add(callback);
112
+ return () => {
113
+ this.statusListeners.delete(callback);
114
+ };
115
+ }
116
+
117
+ private emitStatus(): void {
118
+ for (const fn of this.statusListeners) {
119
+ fn(this.statusMap);
120
+ }
90
121
  }
91
122
 
92
123
  getStatusEntries(): Map<string, string> {
@@ -97,10 +128,12 @@ export class InkUIService {
97
128
 
98
129
  notify(message: string, level?: 'info' | 'success' | 'warning' | 'error'): void {
99
130
  const toast: ToastRequest = { message, level: level ?? 'info' };
100
- if (this.toastCallback) {
101
- this.toastCallback(toast);
102
- } else {
131
+ if (this.toastListeners.size === 0) {
103
132
  this.pendingToasts.push(toast);
133
+ return;
134
+ }
135
+ for (const listener of this.toastListeners) {
136
+ listener(toast);
104
137
  }
105
138
  }
106
139
 
@@ -113,7 +146,7 @@ export class InkUIService {
113
146
  message,
114
147
  resolve: resolve as (value: unknown) => void,
115
148
  });
116
- this.notifySubscribers();
149
+ this.notifyDialogSubscribers();
117
150
  });
118
151
  }
119
152
 
@@ -126,7 +159,7 @@ export class InkUIService {
126
159
  options,
127
160
  resolve: resolve as (value: unknown) => void,
128
161
  });
129
- this.notifySubscribers();
162
+ this.notifyDialogSubscribers();
130
163
  });
131
164
  }
132
165
 
@@ -139,17 +172,17 @@ export class InkUIService {
139
172
  placeholder,
140
173
  resolve: resolve as (value: unknown) => void,
141
174
  });
142
- this.notifySubscribers();
175
+ this.notifyDialogSubscribers();
143
176
  });
144
177
  }
145
178
 
146
179
  setStatus(key: string, text: string): void {
147
180
  this.statusMap.set(key, text);
148
- this.statusCallback?.(this.statusMap);
181
+ this.emitStatus();
149
182
  }
150
183
 
151
184
  clearStatus(key: string): void {
152
185
  this.statusMap.delete(key);
153
- this.statusCallback?.(this.statusMap);
186
+ this.emitStatus();
154
187
  }
155
188
  }
@@ -0,0 +1,43 @@
1
+ import { type Instance, render } from 'ink';
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 { ChatPanel } from './components/chat/ChatPanel';
7
+ import { ThemeProvider } from './context/ThemeContext';
8
+ import type { InkUIService } from './plugins/InkUIService';
9
+ import { resolveTheme } from './theme';
10
+
11
+ interface RenderAppOptions {
12
+ config: AppConfig;
13
+ initialMessages?: ChatMessage[];
14
+ registry: PluginRegistry;
15
+ messageBus: HostMessageBus;
16
+ uiService: InkUIService;
17
+ shutdown: ShutdownFn;
18
+ }
19
+
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>,
38
+ {
39
+ exitOnCtrlC: false,
40
+ kittyKeyboard: { mode: 'enabled' },
41
+ },
42
+ );
43
+ }
@@ -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;
@@ -0,0 +1,97 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ImageAttachment } from 'mu-core';
6
+
7
+ const CLIPBOARD_TIMEOUT = 3000;
8
+
9
+ function tryExecFile(file: string, args: string[]): boolean {
10
+ try {
11
+ execFileSync(file, args, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function tryShell(command: string): boolean {
19
+ try {
20
+ execSync(command, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function commandExists(name: string): boolean {
28
+ return tryExecFile('which', [name]);
29
+ }
30
+
31
+ function extractMacImage(tmpFile: string): boolean {
32
+ if (commandExists('pngpaste')) {
33
+ return tryExecFile('pngpaste', [tmpFile]);
34
+ }
35
+ // Each `-e` is a discrete script line — passing as separate args avoids shell quoting traps.
36
+ return tryExecFile('osascript', [
37
+ '-e',
38
+ 'tell application "System Events"',
39
+ '-e',
40
+ 'set imgData to the clipboard as «class PNGf»',
41
+ '-e',
42
+ `set fp to open for access POSIX file "${tmpFile}" with write permission`,
43
+ '-e',
44
+ 'write imgData to fp',
45
+ '-e',
46
+ 'close access fp',
47
+ '-e',
48
+ 'end tell',
49
+ ]);
50
+ }
51
+
52
+ function extractLinuxImage(tmpFile: string): boolean {
53
+ // xclip / wl-paste write to stdout; redirection still requires a shell.
54
+ // tmpFile is constructed from tmpdir() + a numeric timestamp, so quoting is safe.
55
+ if (tryShell(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`)) {
56
+ return true;
57
+ }
58
+ return tryShell(`wl-paste --type image/png > "${tmpFile}"`);
59
+ }
60
+
61
+ function extractPlatformImage(tmpFile: string): boolean {
62
+ if (process.platform === 'darwin') {
63
+ return extractMacImage(tmpFile);
64
+ }
65
+ if (process.platform === 'linux') {
66
+ return extractLinuxImage(tmpFile);
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function readTmpAsAttachment(tmpFile: string): ImageAttachment | null {
72
+ if (!existsSync(tmpFile)) {
73
+ return null;
74
+ }
75
+ if (statSync(tmpFile).size === 0) {
76
+ unlinkSync(tmpFile);
77
+ return null;
78
+ }
79
+ const buffer = readFileSync(tmpFile);
80
+ unlinkSync(tmpFile);
81
+ return { data: buffer.toString('base64'), mimeType: 'image/png', name: 'clipboard.png' };
82
+ }
83
+
84
+ export function readClipboardImage(): ImageAttachment | null {
85
+ const tmpFile = join(tmpdir(), `mu-clip-${Date.now()}.png`);
86
+ try {
87
+ if (!extractPlatformImage(tmpFile)) {
88
+ return null;
89
+ }
90
+ return readTmpAsAttachment(tmpFile);
91
+ } catch {
92
+ if (existsSync(tmpFile)) {
93
+ unlinkSync(tmpFile);
94
+ }
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { computeDiff, renderDiff } from './diff';
3
+
4
+ describe('computeDiff', () => {
5
+ it('emits only context lines when texts are identical', () => {
6
+ const diff = computeDiff('a\nb\nc', 'a\nb\nc');
7
+ expect(diff.lines.every((l) => l.type === 'context')).toBe(true);
8
+ expect(diff.lines.find((l) => l.type === 'old')).toBeUndefined();
9
+ expect(diff.lines.find((l) => l.type === 'new')).toBeUndefined();
10
+ expect(diff.totalOldLines).toBe(3);
11
+ expect(diff.totalNewLines).toBe(3);
12
+ });
13
+
14
+ it('detects an added line in the middle', () => {
15
+ const diff = computeDiff('a\nb\nc', 'a\nx\nb\nc');
16
+ const types = diff.lines.map((l) => l.type);
17
+ expect(types).toContain('new');
18
+ expect(diff.lines.find((l) => l.type === 'new')?.value).toBe('x');
19
+ });
20
+
21
+ it('detects a removed line', () => {
22
+ const diff = computeDiff('a\nb\nc', 'a\nc');
23
+ expect(diff.lines.find((l) => l.type === 'old')?.value).toBe('b');
24
+ });
25
+
26
+ it('returns no lines when either side exceeds the size limit', () => {
27
+ const huge = Array.from({ length: 600 }, (_, i) => `line${i}`).join('\n');
28
+ const diff = computeDiff(huge, huge.replace('line0', 'lineX'));
29
+ expect(diff.lines).toEqual([]);
30
+ expect(diff.totalOldLines).toBe(600);
31
+ });
32
+
33
+ it('emits up to CONTEXT_LINES of leading/trailing context', () => {
34
+ const diff = computeDiff('a\nb\nc\nd\ne', 'a\nb\nc\nX\ne');
35
+ const contextValues = diff.lines.filter((l) => l.type === 'context').map((l) => l.value);
36
+ expect(contextValues).toContain('c');
37
+ expect(contextValues).toContain('e');
38
+ });
39
+ });
40
+
41
+ describe('renderDiff', () => {
42
+ it('prefixes added/removed/context lines and reports truncation', () => {
43
+ const diff = computeDiff('a\nb', 'a\nc');
44
+ const { lines, truncated } = renderDiff(diff, 100);
45
+ expect(lines.some((l) => l.startsWith('-'))).toBe(true);
46
+ expect(lines.some((l) => l.startsWith('+'))).toBe(true);
47
+ expect(truncated).toBe(false);
48
+ });
49
+
50
+ it('reports truncated when diff exceeds maxLines', () => {
51
+ const diff = computeDiff('a', Array.from({ length: 50 }, (_, i) => `n${i}`).join('\n'));
52
+ const { lines, truncated } = renderDiff(diff, 5);
53
+ expect(lines).toHaveLength(5);
54
+ expect(truncated).toBe(true);
55
+ });
56
+ });