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.
Files changed (74) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/config/index.test.ts +26 -0
  8. package/src/config/index.ts +25 -7
  9. package/src/plugin.ts +96 -0
  10. package/src/runtime/codingTools/bash.ts +114 -0
  11. package/src/runtime/codingTools/edit-file.ts +60 -0
  12. package/src/runtime/codingTools/index.ts +39 -0
  13. package/src/runtime/codingTools/read-file.ts +83 -0
  14. package/src/runtime/codingTools/utils.ts +21 -0
  15. package/src/runtime/codingTools/write-file.ts +42 -0
  16. package/src/runtime/createRegistry.test.ts +146 -0
  17. package/src/runtime/createRegistry.ts +128 -23
  18. package/src/runtime/messageBus.test.ts +62 -0
  19. package/src/runtime/messageBus.ts +78 -0
  20. package/src/runtime/pluginLoader.ts +22 -9
  21. package/src/sessions/index.ts +2 -9
  22. package/src/tui/channel/tuiChannel.test.ts +107 -0
  23. package/src/tui/channel/tuiChannel.ts +49 -0
  24. package/src/tui/chat/MessageRendererContext.ts +44 -0
  25. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  26. package/src/tui/chat/useAttachment.ts +1 -1
  27. package/src/tui/chat/useChat.ts +31 -3
  28. package/src/tui/chat/useChatPanel.ts +7 -5
  29. package/src/tui/chat/useChatSession.ts +222 -53
  30. package/src/tui/chat/useModels.ts +2 -1
  31. package/src/tui/chat/usePluginStatus.ts +1 -1
  32. package/src/tui/chat/useSessionPersistence.ts +25 -14
  33. package/src/tui/chat/useStatusSegments.ts +17 -4
  34. package/src/tui/components/chat/ChatPanel.tsx +10 -4
  35. package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
  36. package/src/tui/components/messageView.tsx +4 -2
  37. package/src/tui/components/messages/EditOutput.tsx +6 -4
  38. package/src/tui/components/messages/ToolHeader.tsx +3 -1
  39. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  40. package/src/tui/components/messages/messageItem.tsx +19 -1
  41. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  42. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  43. package/src/tui/components/messages/toolCallBlock.tsx +6 -5
  44. package/src/tui/components/messages/userMessage.tsx +21 -6
  45. package/src/tui/components/primitives/dropdown.tsx +8 -4
  46. package/src/tui/components/primitives/modal.tsx +4 -2
  47. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  48. package/src/tui/components/primitives/toast.tsx +5 -3
  49. package/src/tui/components/statusBar.tsx +8 -1
  50. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  51. package/src/tui/context/ThemeContext.tsx +18 -0
  52. package/src/tui/input/InputBoxView.tsx +135 -26
  53. package/src/tui/input/commands.test.ts +3 -1
  54. package/src/tui/input/commands.ts +6 -1
  55. package/src/tui/input/cursor.test.ts +136 -0
  56. package/src/tui/input/cursor.ts +214 -0
  57. package/src/tui/input/dumpContext.ts +107 -0
  58. package/src/tui/input/sanitize.ts +1 -1
  59. package/src/tui/input/useCommandExecutor.ts +1 -1
  60. package/src/tui/input/useInputBox.ts +134 -15
  61. package/src/tui/input/useInputHandler.ts +316 -126
  62. package/src/tui/input/useMentionPicker.ts +121 -0
  63. package/src/tui/input/usePluginShortcuts.ts +29 -0
  64. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  65. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  66. package/src/tui/plugins/InkUIService.ts +1 -1
  67. package/src/tui/renderApp.tsx +26 -13
  68. package/src/tui/theme/index.ts +1 -0
  69. package/src/tui/theme/merge.test.ts +49 -0
  70. package/src/tui/theme/merge.ts +43 -0
  71. package/src/tui/theme/presets.ts +79 -0
  72. package/src/tui/theme/types.ts +116 -0
  73. package/src/utils/clipboard.ts +1 -1
  74. package/src/tui/chat/useStreamConsumer.ts +0 -118
@@ -1,7 +1,7 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { join, resolve } from 'node:path';
4
- import type { Plugin, PluginRegistry } from 'mu-agents';
4
+ import type { Plugin, PluginRegistry } from 'mu-core';
5
5
  import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
6
6
  import type { InkUIService } from '../tui/plugins/InkUIService';
7
7
 
@@ -71,36 +71,49 @@ function extractPlugin(mod: Record<string, unknown>, pluginConfig: Record<string
71
71
  return null;
72
72
  }
73
73
 
74
- export async function loadConfiguredPlugin(
75
- registry: PluginRegistry,
74
+ /**
75
+ * Loader variant that *resolves* (imports + extracts) a plugin without
76
+ * registering it. Used by hosts driving `startMu({ resolvePlugin })`. Errors
77
+ * surface via `uiService` (or are swallowed when omitted) so the host's
78
+ * boot log behaviour matches `loadConfiguredPlugin`.
79
+ */
80
+ export async function resolveConfiguredPlugin(
76
81
  name: string,
77
82
  pluginConfig?: Record<string, unknown>,
78
83
  uiService?: InkUIService,
79
- ): Promise<void> {
84
+ ): Promise<Plugin | null> {
80
85
  const config = pluginConfig ?? {};
81
86
  let target: string;
82
87
  try {
83
88
  target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
84
89
  } catch (err) {
85
90
  uiService?.notify(formatPluginError(name, err), 'error');
86
- return;
91
+ return null;
87
92
  }
88
-
89
93
  let mod: Record<string, unknown>;
90
94
  try {
91
95
  mod = (await import(target)) as Record<string, unknown>;
92
96
  } catch (err) {
93
97
  uiService?.notify(formatPluginError(name, err), 'error');
94
- return;
98
+ return null;
95
99
  }
96
-
97
100
  const plugin = extractPlugin(mod, config);
98
101
  if (!plugin) {
99
102
  const exportKeys = Object.keys(mod).join(', ') || '(none)';
100
103
  uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
101
- return;
104
+ return null;
102
105
  }
106
+ return plugin;
107
+ }
103
108
 
109
+ export async function loadConfiguredPlugin(
110
+ registry: PluginRegistry,
111
+ name: string,
112
+ pluginConfig?: Record<string, unknown>,
113
+ uiService?: InkUIService,
114
+ ): Promise<void> {
115
+ const plugin = await resolveConfiguredPlugin(name, pluginConfig, uiService);
116
+ if (!plugin) return;
104
117
  try {
105
118
  await registry.register(plugin);
106
119
  } catch (err) {
@@ -1,8 +1,8 @@
1
- import { createReadStream, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { createReadStream, mkdirSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { stat, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
- import type { ChatMessage } from 'mu-provider';
5
+ import type { ChatMessage } from 'mu-core';
6
6
  import { getDataDir } from '../config/index';
7
7
  import { getProjectId, getProjectName } from './project';
8
8
 
@@ -181,10 +181,3 @@ export async function listSessionsAsync(): Promise<SessionInfo[]> {
181
181
 
182
182
  return results.filter((s): s is SessionInfo => s !== null);
183
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,107 @@
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
+ });
@@ -0,0 +1,49 @@
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 { Channel, ChatMessage, PluginRegistry } from 'mu-core';
9
+ import type { ShutdownFn } from '../../app/shutdown';
10
+ import type { AppConfig } from '../../config/index';
11
+ import type { HostMessageBus } from '../../runtime/messageBus';
12
+ import type { InkUIService } from '../plugins/InkUIService';
13
+ import { renderApp } from '../renderApp';
14
+
15
+ export interface TuiChannelOptions {
16
+ config: AppConfig;
17
+ initialMessages?: ChatMessage[];
18
+ registry: PluginRegistry;
19
+ messageBus: HostMessageBus;
20
+ uiService: InkUIService;
21
+ shutdown: ShutdownFn;
22
+ }
23
+
24
+ export function createTuiChannel(opts: TuiChannelOptions): Channel {
25
+ let instance: Instance | null = null;
26
+ return {
27
+ id: 'tui',
28
+ async start() {
29
+ // Idempotent: re-starting after a stop remounts; re-starting while
30
+ // mounted is a no-op.
31
+ if (instance) return;
32
+ instance = renderApp(opts);
33
+ },
34
+ async stop() {
35
+ if (!instance) return;
36
+ try {
37
+ instance.unmount();
38
+ // Wait for Ink's exit promise so `stopAll()` callers know the
39
+ // terminal has been restored before they continue (e.g. emitting
40
+ // a final shutdown message to stdout).
41
+ await instance.waitUntilExit().catch(() => {
42
+ /* unmount-induced exit rejects with the cause; we don't care */
43
+ });
44
+ } finally {
45
+ instance = null;
46
+ }
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,44 @@
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,4 +1,4 @@
1
- import type { PluginRegistry, ToolDisplayHint } from 'mu-agents';
1
+ import type { PluginRegistry, ToolDisplayHint } from 'mu-core';
2
2
  import { createContext, useContext, useMemo } from 'react';
3
3
 
4
4
  type ToolDisplayMap = Map<string, ToolDisplayHint>;
@@ -1,4 +1,4 @@
1
- import type { ImageAttachment } from 'mu-provider';
1
+ import type { ImageAttachment } from 'mu-core';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { readClipboardImage } from '../../utils/clipboard';
4
4
 
@@ -1,9 +1,16 @@
1
1
  import { useApp } from 'ink';
2
- import type { PluginRegistry } from 'mu-agents';
3
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
- import { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ type ChatMessage,
4
+ createSessionManager,
5
+ type PluginRegistry,
6
+ type ProviderConfig,
7
+ type SessionManager,
8
+ } from 'mu-core';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
10
  import type { ShutdownFn } from '../../app/shutdown';
11
+ import type { HostMessageBus } from '../../runtime/messageBus';
6
12
  import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
13
+ import type { InkUIService } from '../plugins/InkUIService';
7
14
  import { type AbortState, useAbort } from './useAbort';
8
15
  import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useAttachment';
9
16
  import { type ChatSessionState, useChatSession } from './useChatSession';
@@ -14,12 +21,15 @@ const ABORT_TIMEOUT_MS = 2000;
14
21
  export interface ChatContextValue {
15
22
  config: ProviderConfig;
16
23
  session: ChatSessionState;
24
+ sessionManager: SessionManager;
17
25
  toggles: TogglesState;
18
26
  attachment: AttachmentState;
19
27
  models: ModelListState;
20
28
  abort: AbortState;
21
29
  sessions: SessionInfo[];
22
30
  registry: PluginRegistry;
31
+ uiService?: InkUIService;
32
+ messageBus?: HostMessageBus;
23
33
  }
24
34
 
25
35
  export function useChat(
@@ -27,19 +37,34 @@ export function useChat(
27
37
  registry: PluginRegistry,
28
38
  initialMessages?: ChatMessage[],
29
39
  shutdown?: ShutdownFn,
40
+ uiService?: InkUIService,
41
+ messageBus?: HostMessageBus,
30
42
  ): ChatContextValue {
31
43
  const { exit } = useApp();
32
44
  const controllerRef = useRef<AbortController | null>(null);
33
45
  const attachment = useAttachment();
34
46
  const toggles = useToggles();
35
47
  const models = useModelList(config.baseUrl, config.model);
48
+ // Stable SessionManager + Session for the lifetime of the chat hook. Model
49
+ // updates flow through `runTurn(options)` per call, so we don't need to
50
+ // re-instantiate on every change.
51
+ const sessionManager = useMemo(
52
+ () => createSessionManager({ registry, config, model: models.currentModel || config.model || 'unknown' }),
53
+ [registry, config, models.currentModel],
54
+ );
55
+ const muSession = useMemo(
56
+ () => sessionManager.getOrCreate('tui', { initialMessages }),
57
+ [sessionManager, initialMessages],
58
+ );
36
59
  const session = useChatSession({
60
+ session: muSession,
37
61
  config,
38
62
  currentModel: models.currentModel,
39
63
  attachment,
40
64
  controllerRef,
41
65
  initialMessages,
42
66
  registry,
67
+ messageBus,
43
68
  });
44
69
  const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
45
70
 
@@ -68,11 +93,14 @@ export function useChat(
68
93
  return {
69
94
  config,
70
95
  session,
96
+ sessionManager,
71
97
  toggles,
72
98
  attachment,
73
99
  models,
74
100
  abort,
75
101
  sessions,
76
102
  registry,
103
+ uiService,
104
+ messageBus,
77
105
  };
78
106
  }
@@ -1,8 +1,8 @@
1
1
  import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
- import type { PluginRegistry } from 'mu-agents';
3
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
2
+ import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
4
3
  import { useEffect, useMemo, useRef } from 'react';
5
4
  import type { ShutdownFn } from '../../app/shutdown';
5
+ import type { HostMessageBus } from '../../runtime/messageBus';
6
6
  import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
7
7
  import { useToast } from '../components/primitives/toast';
8
8
  import { useScroll } from '../hooks/useScroll';
@@ -23,13 +23,14 @@ interface UseChatPanelOptions {
23
23
  config: ProviderConfig;
24
24
  initialMessages?: ChatMessage[];
25
25
  registry: PluginRegistry;
26
+ messageBus?: HostMessageBus;
26
27
  uiService?: InkUIService;
27
28
  shutdown?: ShutdownFn;
28
29
  }
29
30
 
30
31
  export function useChatPanel(options: UseChatPanelOptions) {
31
- const { config, initialMessages, registry, uiService, shutdown } = options;
32
- const ctx = useChat(config, registry, initialMessages, shutdown);
32
+ const { config, initialMessages, registry, messageBus, uiService, shutdown } = options;
33
+ const ctx = useChat(config, registry, initialMessages, shutdown, uiService, messageBus);
33
34
  const { width, height } = useTerminalSize();
34
35
  const viewRef = useRef<InkDOMElement>(null);
35
36
  const contentRef = useRef<InkDOMElement>(null);
@@ -64,7 +65,8 @@ export function useChatPanel(options: UseChatPanelOptions) {
64
65
  quitWarning: ctx.abort.quitWarning,
65
66
  error: ctx.session.error,
66
67
  modelError: ctx.models.modelError,
67
- tokensPerSecond: ctx.session.stream.tps,
68
+ totalTokens: ctx.session.stream.totalTokens,
69
+ cachedTokens: ctx.session.stream.cachedTokens,
68
70
  pluginStatus,
69
71
  });
70
72