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.
Files changed (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -119
  57. package/src/tui/chat/useChatSession.ts +0 -382
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -82
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -64
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. 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
- });
@@ -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
- }
@@ -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
- }
@@ -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
- }