mu-coding 0.4.0 → 0.5.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 (71) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -96
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -147
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/tui/commands.ts +0 -33
  64. package/src/tui/components/chatLayout.tsx +0 -192
  65. package/src/tui/useChatSession.ts +0 -155
  66. package/src/tui/useChatUI.ts +0 -51
  67. package/tsconfig.json +0 -10
  68. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  69. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  70. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  71. /package/src/{diff.ts → utils/diff.ts} +0 -0
package/src/main.tsx DELETED
@@ -1,147 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { readdirSync } from 'node:fs';
3
- import { createRequire } from 'node:module';
4
- import { join, resolve } from 'node:path';
5
- import { render } from 'ink';
6
- import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
7
- import { parseArgs, resolveInitialMessages } from './cli';
8
- import { type AppConfig, getDataDir, getPluginsDir, loadConfig } from './config';
9
- import { handleSubcommand } from './subcommands';
10
- import { ChatPanel } from './tui/components/chat/ChatPanel';
11
- import { InkUIService } from './tui/services/uiService';
12
-
13
- function discoverPluginFiles(): string[] {
14
- const dir = getPluginsDir();
15
- try {
16
- return readdirSync(dir)
17
- .filter((f) => f.endsWith('.ts'))
18
- .map((f) => join(dir, f));
19
- } catch {
20
- return [];
21
- }
22
- }
23
-
24
- /**
25
- * Resolve an npm: specifier to an absolute path via the data dir's node_modules.
26
- */
27
- function formatPluginError(name: string, err: unknown): string {
28
- const parts: string[] = [`Plugin "${name}" failed`];
29
- let current: unknown = err;
30
- while (current) {
31
- if (current instanceof Error) {
32
- parts.push(current.message);
33
- current = current.cause;
34
- } else {
35
- parts.push(String(current));
36
- break;
37
- }
38
- }
39
- return parts.join(': ');
40
- }
41
-
42
- function resolveNpmPlugin(specifier: string): string {
43
- const bare = specifier.slice(4);
44
- const dataDir = getDataDir();
45
- try {
46
- const require = createRequire(resolve(dataDir, 'package.json'));
47
- return require.resolve(bare);
48
- } catch (err) {
49
- throw new Error(`Cannot resolve "${bare}" from ${dataDir}/node_modules — is it installed?`, { cause: err });
50
- }
51
- }
52
-
53
- /**
54
- * Load a plugin by name or path, resolving from this package's context.
55
- * This allows workspace packages (like mu-pi-compat) to be found even though
56
- * mu-agents' registry can't resolve them from its own location.
57
- *
58
- * Plugins prefixed with npm: are resolved from ~/.local/share/mu/node_modules/.
59
- */
60
- async function loadPluginFromHere(
61
- registry: PluginRegistry,
62
- name: string,
63
- pluginConfig?: Record<string, unknown>,
64
- uiService?: InkUIService,
65
- ): Promise<void> {
66
- try {
67
- const target = name.startsWith('npm:') ? resolveNpmPlugin(name) : name;
68
- const mod = await import(target);
69
- const factory = mod.default ?? mod.createPlugin;
70
-
71
- if (typeof factory === 'function') {
72
- const plugin = factory(pluginConfig ?? {});
73
- await registry.register(plugin);
74
- } else if (typeof mod === 'object' && mod !== null && 'name' in mod) {
75
- await registry.register(mod);
76
- } else {
77
- const exportKeys = Object.keys(mod).join(', ') || '(none)';
78
- uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
79
- }
80
- } catch (err) {
81
- // npm: plugins don't fall back — they must resolve from data dir
82
- if (name.startsWith('npm:')) {
83
- uiService?.notify(formatPluginError(name, err), 'error');
84
- return;
85
- }
86
- // Non-npm plugins fall back to registry loader (for file paths)
87
- try {
88
- await registry.loadPlugin(name, pluginConfig);
89
- } catch (fallbackErr) {
90
- uiService?.notify(formatPluginError(name, fallbackErr), 'error');
91
- }
92
- }
93
- }
94
-
95
- async function createRegistry(cwd: string, config: AppConfig, uiService: InkUIService) {
96
- const registry = new PluginRegistry({ cwd, config: {} });
97
-
98
- // Register built-in tools (read, write, edit, bash)
99
- await registry.register(createBuiltinPlugin());
100
-
101
- // Auto-load .ts plugin files from ~/.config/mu/plugins/
102
- for (const filePath of discoverPluginFiles()) {
103
- await registry.loadPlugin(filePath);
104
- }
105
-
106
- // Load configured plugins
107
- if (config.plugins?.length) {
108
- for (const entry of config.plugins) {
109
- const name = typeof entry === 'string' ? entry : entry.name;
110
- const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
111
-
112
- // Inject uiService for plugins that accept it (duck typing)
113
- const finalConfig = pluginConfig ? { ...pluginConfig, ui: uiService } : { ui: uiService };
114
-
115
- await loadPluginFromHere(registry, name, finalConfig, uiService);
116
- }
117
- }
118
-
119
- return registry;
120
- }
121
-
122
- async function main() {
123
- if (await handleSubcommand()) return;
124
-
125
- const cliArgs = parseArgs();
126
- const config = loadConfig(cliArgs.model);
127
- const root = process.cwd();
128
-
129
- const uiService = new InkUIService();
130
- const registry = await createRegistry(root, config, uiService);
131
-
132
- const initialMessages = resolveInitialMessages(cliArgs);
133
-
134
- render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} uiService={uiService} />, {
135
- exitOnCtrlC: false,
136
- kittyKeyboard: { mode: 'enabled' },
137
- });
138
-
139
- process.on('exit', () => {
140
- registry.shutdown();
141
- });
142
- }
143
-
144
- main().catch((err) => {
145
- console.error(err);
146
- process.exit(1);
147
- });
package/src/project.ts DELETED
@@ -1,32 +0,0 @@
1
- import { execSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
-
4
- function findGitRoot(from: string): string | null {
5
- try {
6
- const root = execSync('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
- function getProjectRoot(): string {
18
- const cwd = process.cwd();
19
- return findGitRoot(cwd) ?? cwd;
20
- }
21
-
22
- export function getProjectId(): string {
23
- const root = getProjectRoot();
24
- const hash = createHash('sha256').update(root).digest('hex').slice(0, 12);
25
- const name = root.split('/').pop() || 'unknown';
26
- return `${name}-${hash}`;
27
- }
28
-
29
- export function getProjectName(): string {
30
- const root = getProjectRoot();
31
- return root.split('/').pop() || root;
32
- }
package/src/session.ts DELETED
@@ -1,95 +0,0 @@
1
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import type { ChatMessage } from 'mu-provider';
4
- import { getDataDir } from './config';
5
- import { getProjectId, getProjectName } from './project';
6
-
7
- function getProjectSessionsDir(): string {
8
- return join(getDataDir(), 'sessions', getProjectId());
9
- }
10
-
11
- function getSortedSessionFiles(): string[] {
12
- try {
13
- const dir = getProjectSessionsDir();
14
- return readdirSync(dir)
15
- .filter((f) => f.endsWith('.jsonl'))
16
- .sort()
17
- .reverse();
18
- } catch {
19
- return [];
20
- }
21
- }
22
-
23
- export interface SessionInfo {
24
- path: string;
25
- name: string;
26
- date: Date;
27
- messageCount: number;
28
- preview: string;
29
- project: string;
30
- }
31
-
32
- export function generateSessionPath(): string {
33
- const dir = getProjectSessionsDir();
34
- mkdirSync(dir, { recursive: true });
35
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
36
- return join(dir, `${ts}.jsonl`);
37
- }
38
-
39
- export function saveSession(path: string, messages: ChatMessage[]): void {
40
- writeFileSync(path, `${messages.map((m) => JSON.stringify(m)).join('\n')}\n`, 'utf-8');
41
- }
42
-
43
- export function loadSession(path: string): ChatMessage[] {
44
- try {
45
- const content = readFileSync(path, 'utf-8').trim();
46
- if (!content) {
47
- return [];
48
- }
49
- return content
50
- .split('\n')
51
- .map((line) => {
52
- try {
53
- return JSON.parse(line) as ChatMessage;
54
- } catch {
55
- return null;
56
- }
57
- })
58
- .filter((msg): msg is ChatMessage => msg !== null);
59
- } catch {
60
- return [];
61
- }
62
- }
63
-
64
- export function getLatestSession(): string | null {
65
- const files = getSortedSessionFiles();
66
- return files.length ? join(getProjectSessionsDir(), files[0]) : null;
67
- }
68
-
69
- export function listSessions(): SessionInfo[] {
70
- try {
71
- const dir = getProjectSessionsDir();
72
- mkdirSync(dir, { recursive: true });
73
- const files = getSortedSessionFiles();
74
- const project = getProjectName();
75
-
76
- return files.map((file) => {
77
- const path = join(dir, file);
78
- const stat = statSync(path);
79
- const messages = loadSession(path);
80
- const firstUserMsg = messages.find((m) => m.role === 'user');
81
- const preview = firstUserMsg ? firstUserMsg.content.slice(0, 80).replace(/\n/g, ' ') : '(empty)';
82
-
83
- return {
84
- path,
85
- name: file.replace('.jsonl', ''),
86
- date: stat.mtime,
87
- messageCount: messages.length,
88
- preview,
89
- project,
90
- };
91
- });
92
- } catch {
93
- return [];
94
- }
95
- }
@@ -1,33 +0,0 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
2
-
3
- export interface SlashCommand {
4
- name: string;
5
- description: string;
6
- action?: string;
7
- execute?: (args: string) => Promise<string | undefined>;
8
- }
9
-
10
- const BUILTIN_COMMANDS: SlashCommand[] = [
11
- { name: '/model', description: 'Select a model', action: 'model' },
12
- { name: '/sessions', description: 'List project sessions', action: 'sessions' },
13
- { name: '/new', description: 'New conversation', action: 'new' },
14
- ];
15
-
16
- export function matchCommands(input: string, pluginCommands: PluginSlashCommand[] = []): SlashCommand[] {
17
- if (!input.startsWith('/')) {
18
- return [];
19
- }
20
- const q = input.toLowerCase();
21
-
22
- const fromPlugins: SlashCommand[] = pluginCommands.map((pc) => ({
23
- name: `/${pc.name}`,
24
- description: pc.description,
25
- execute: (args: string) => {
26
- const ctx: CommandContext = { messages: [], cwd: process.cwd(), config: {} as CommandContext['config'] };
27
- return pc.execute(args, ctx);
28
- },
29
- }));
30
-
31
- const all = [...BUILTIN_COMMANDS, ...fromPlugins];
32
- return all.filter((cmd) => cmd.name.startsWith(q));
33
- }
@@ -1,192 +0,0 @@
1
- import type { DOMElement } from 'ink';
2
- import { Box, Text } from 'ink';
3
- import type { StatusSegment } from 'mu-agents';
4
- import type { ChatMessage } from 'mu-provider';
5
- import type React from 'react';
6
- import { useSpinner } from '../hooks/useUI';
7
- import type { StreamState } from '../useChatSession';
8
- import { MessageItem } from './messages/messageItem';
9
- import { StreamingOutput } from './messages/streamingOutput';
10
- import { Dropdown } from './ui/dropdown';
11
- import { Modal } from './ui/modal';
12
-
13
- function Scrollbar({
14
- viewHeight,
15
- contentHeight,
16
- scrollOffset,
17
- }: {
18
- viewHeight: number;
19
- contentHeight: number;
20
- scrollOffset: number;
21
- }) {
22
- if (contentHeight <= viewHeight || viewHeight < 1) {
23
- return null;
24
- }
25
- const maxScroll = contentHeight - viewHeight;
26
- const ratio = scrollOffset / maxScroll;
27
- const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
28
- const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
29
-
30
- const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
31
-
32
- return (
33
- <Box flexDirection="column" flexShrink={0} width={1}>
34
- <Text>{track.join('')}</Text>
35
- </Box>
36
- );
37
- }
38
-
39
- export function StatusBar({
40
- streaming,
41
- abortWarning,
42
- quitWarning,
43
- error,
44
- modelError,
45
- totalTokens,
46
- tokensPerSecond,
47
- pluginStatus,
48
- }: {
49
- streaming: boolean;
50
- abortWarning: boolean;
51
- quitWarning: boolean;
52
- error: string | null;
53
- modelError: string | null;
54
- totalTokens: number;
55
- tokensPerSecond: number;
56
- pluginStatus?: StatusSegment[];
57
- }) {
58
- const spinner = useSpinner(streaming);
59
- const segments: Array<{ text: string; color?: string; dim?: boolean }> = [];
60
- if (streaming) {
61
- segments.push({ text: `${spinner} generating`, color: 'yellow' });
62
- }
63
- if (tokensPerSecond > 0) {
64
- segments.push({ text: `${tokensPerSecond} tok/s`, dim: true });
65
- }
66
- if (abortWarning) {
67
- segments.push({ text: 'Esc again to stop', color: 'yellow' });
68
- } else if (quitWarning) {
69
- segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
70
- } else if (streaming) {
71
- segments.push({ text: 'Esc to stop', dim: true });
72
- }
73
- if (error) {
74
- segments.push({ text: '⚠ error', color: 'red' });
75
- }
76
- if (modelError) {
77
- segments.push({ text: `⚠ ${modelError}`, color: 'red' });
78
- }
79
-
80
- if (totalTokens > 0) {
81
- segments.push({ text: `${formatTokens(totalTokens)} tokens`, dim: true });
82
- }
83
- if (pluginStatus) {
84
- segments.push(...pluginStatus);
85
- }
86
-
87
- return (
88
- <Box flexShrink={0} paddingX={1} marginY={1}>
89
- <Box justifyContent="flex-end" flexGrow={1}>
90
- {segments.map((seg, i) => (
91
- // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
92
- <Box key={i}>
93
- {i > 0 && <Text dimColor={true}> · </Text>}
94
- <Text color={seg.color} dimColor={seg.dim}>
95
- {seg.text}
96
- </Text>
97
- </Box>
98
- ))}
99
- </Box>
100
- </Box>
101
- );
102
- }
103
-
104
- function formatTokens(tokens: number): string {
105
- if (tokens >= 1_000_000) {
106
- return `${(tokens / 1_000_000).toFixed(1)}M`;
107
- }
108
- if (tokens >= 1_000) {
109
- return `${(tokens / 1_000).toFixed(1)}k`;
110
- }
111
- return String(tokens);
112
- }
113
-
114
- interface PickerItem {
115
- label: string;
116
- value: string;
117
- description?: string;
118
- }
119
-
120
- export function PickerModal({
121
- visible,
122
- title,
123
- items,
124
- placeholder,
125
- emptyMessage,
126
- onSelect,
127
- onCancel,
128
- }: {
129
- visible: boolean;
130
- title: string;
131
- items: PickerItem[];
132
- placeholder: string;
133
- emptyMessage?: string;
134
- onSelect: (value: string) => void;
135
- onCancel?: () => void;
136
- }) {
137
- return (
138
- <Modal visible={visible} title={title}>
139
- {items.length === 0 && emptyMessage ? (
140
- <Text dimColor={true} italic={true}>
141
- {emptyMessage}
142
- </Text>
143
- ) : (
144
- <Dropdown
145
- items={items}
146
- placeholder={placeholder}
147
- isActive={visible}
148
- onSelect={(item) => onSelect(item.value)}
149
- onCancel={onCancel}
150
- />
151
- )}
152
- </Modal>
153
- );
154
- }
155
-
156
- export function MessageView({
157
- viewRef,
158
- contentRef,
159
- messages,
160
- streaming,
161
- stream,
162
- error,
163
- scrollOffset,
164
- viewHeight,
165
- contentHeight,
166
- }: {
167
- viewRef: React.RefObject<DOMElement | null>;
168
- contentRef: React.RefObject<DOMElement | null>;
169
- messages: ChatMessage[];
170
- streaming: boolean;
171
- stream: StreamState;
172
- error: string | null;
173
- scrollOffset: number;
174
- viewHeight: number;
175
- contentHeight: number;
176
- }) {
177
- return (
178
- <Box flexGrow={1} overflow="hidden">
179
- <Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
180
- <Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
181
- {messages.map((msg, i) => (
182
- // biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
183
- <MessageItem key={i} msg={msg} messages={messages} index={i} />
184
- ))}
185
- {streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
186
- {error && <Text color="red">Error: {error}</Text>}
187
- </Box>
188
- </Box>
189
- <Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
190
- </Box>
191
- );
192
- }
@@ -1,155 +0,0 @@
1
- import { type AgentEvent, type PluginRegistry, runAgent } from 'mu-agents';
2
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
3
- import { useCallback, useRef, useState } from 'react';
4
- import { generateSessionPath, loadSession, saveSession } from '../session';
5
- import type { AttachmentState } from './useChatUI';
6
-
7
- export interface StreamState {
8
- text: string;
9
- reasoning: string;
10
- totalTokens: number;
11
- tps: number;
12
- }
13
-
14
- const EMPTY_STREAM: StreamState = { text: '', reasoning: '', totalTokens: 0, tps: 0 };
15
-
16
- export interface ChatSessionState {
17
- messages: ChatMessage[];
18
- streaming: boolean;
19
- error: string | null;
20
- stream: StreamState;
21
- inputHistory: string[];
22
- onSend: (text: string) => Promise<void>;
23
- onNew: () => void;
24
- onLoadSession: (path: string) => void;
25
- }
26
-
27
- interface SessionDeps {
28
- config: ProviderConfig;
29
- currentModel: string;
30
- attachment: AttachmentState;
31
- controllerRef: React.RefObject<AbortController | null>;
32
- initialMessages?: ChatMessage[];
33
- registry: PluginRegistry;
34
- }
35
-
36
- function applyEvent(prev: StreamState, event: AgentEvent, tps: number): StreamState {
37
- switch (event.type) {
38
- case 'content':
39
- return { ...prev, text: event.text, tps };
40
- case 'reasoning':
41
- return { ...prev, reasoning: event.text, tps };
42
- case 'usage':
43
- return { ...prev, totalTokens: prev.totalTokens + event.totalTokens };
44
- case 'turn_end':
45
- return { ...prev, text: '', reasoning: '' };
46
- default:
47
- return prev;
48
- }
49
- }
50
-
51
- async function consumeAgent(
52
- events: AsyncGenerator<AgentEvent>,
53
- onStream: (updater: (prev: StreamState) => StreamState) => void,
54
- onMessages: (messages: ChatMessage[]) => void,
55
- ): Promise<ChatMessage[] | null> {
56
- let final: ChatMessage[] | null = null;
57
- const start = Date.now();
58
- let tokenCount = 0;
59
-
60
- for await (const event of events) {
61
- if (event.type === 'content' || event.type === 'reasoning') {
62
- tokenCount++;
63
- const elapsed = (Date.now() - start) / 1000;
64
- const tps = elapsed > 0.5 ? Math.round(tokenCount / elapsed) : 0;
65
- onStream((prev) => applyEvent(prev, event, tps));
66
- } else if (event.type === 'messages') {
67
- final = event.messages;
68
- onMessages(event.messages);
69
- } else {
70
- onStream((prev) => applyEvent(prev, event, 0));
71
- }
72
- }
73
- return final;
74
- }
75
-
76
- export function useChatSession(deps: SessionDeps): ChatSessionState {
77
- const { config, currentModel, attachment, controllerRef, initialMessages, registry } = deps;
78
- const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? []);
79
- const [streaming, setStreaming] = useState(false);
80
- const [error, setError] = useState<string | null>(null);
81
- const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
82
- const [inputHistory, setInputHistory] = useState<string[]>(
83
- initialMessages?.filter((m) => m.role === 'user').map((m) => m.content) ?? [],
84
- );
85
- const sessionPathRef = useRef(generateSessionPath());
86
-
87
- const reset = useCallback(() => {
88
- setStream(EMPTY_STREAM);
89
- setError(null);
90
- }, []);
91
-
92
- const onSend = useCallback(
93
- async (text: string) => {
94
- if (streaming) {
95
- return;
96
- }
97
- const userMsg: ChatMessage = {
98
- role: 'user',
99
- content: text,
100
- ...(attachment.attachment ? { images: [attachment.attachment] } : {}),
101
- };
102
- setMessages((prev) => [...prev, userMsg]);
103
- setInputHistory((prev) => [...prev, text]);
104
- reset();
105
- setStreaming(true);
106
- attachment.clear();
107
-
108
- const controller = new AbortController();
109
- controllerRef.current = controller;
110
-
111
- try {
112
- const final = await consumeAgent(
113
- runAgent([...messages, userMsg], config, currentModel, controller.signal, registry),
114
- setStream,
115
- setMessages,
116
- );
117
- if (final) {
118
- saveSession(sessionPathRef.current, final);
119
- }
120
- } catch (err) {
121
- if (!(err instanceof Error && err.name === 'AbortError')) {
122
- setError(err instanceof Error ? err.message : 'Unknown error');
123
- }
124
- } finally {
125
- setStreaming(false);
126
- controllerRef.current = null;
127
- if (!controller.signal.aborted) {
128
- setStream((s) => ({ ...s, text: '', reasoning: '' }));
129
- }
130
- }
131
- },
132
- [streaming, messages, config, currentModel, attachment, controllerRef, reset, registry],
133
- );
134
-
135
- const onNew = useCallback(() => {
136
- setMessages([]);
137
- reset();
138
- sessionPathRef.current = generateSessionPath();
139
- attachment.clear();
140
- }, [attachment, reset]);
141
-
142
- const onLoadSession = useCallback(
143
- (path: string) => {
144
- const msgs = loadSession(path);
145
- if (msgs.length > 0) {
146
- setMessages(msgs);
147
- sessionPathRef.current = path;
148
- reset();
149
- }
150
- },
151
- [reset],
152
- );
153
-
154
- return { messages, streaming, error, stream, inputHistory, onSend, onNew, onLoadSession };
155
- }
@@ -1,51 +0,0 @@
1
- import type { ImageAttachment } from 'mu-provider';
2
- import { useCallback, useState } from 'react';
3
- import { readClipboardImage } from '../clipboard';
4
-
5
- export interface AttachmentState {
6
- attachment: ImageAttachment | null;
7
- attachmentError: string | null;
8
- onPaste: () => void;
9
- clear: () => void;
10
- }
11
-
12
- export function useAttachment(): AttachmentState {
13
- const [attachment, setAttachment] = useState<ImageAttachment | null>(null);
14
- const [attachmentError, setAttachmentError] = useState<string | null>(null);
15
-
16
- const onPaste = useCallback(() => {
17
- const img = readClipboardImage();
18
- if (img) {
19
- setAttachment(img);
20
- setAttachmentError(null);
21
- return;
22
- }
23
- setAttachmentError('No image on clipboard');
24
- setTimeout(() => setAttachmentError(null), 3000);
25
- }, []);
26
-
27
- const clear = useCallback(() => {
28
- setAttachment(null);
29
- setAttachmentError(null);
30
- }, []);
31
-
32
- return { attachment, attachmentError, onPaste, clear };
33
- }
34
-
35
- export interface TogglesState {
36
- showModelPicker: boolean;
37
- showSessionPicker: boolean;
38
- onTogglePicker: () => void;
39
- onToggleSessionPicker: () => void;
40
- }
41
-
42
- export function useToggles(): TogglesState {
43
- const [showModelPicker, setShowModelPicker] = useState(false);
44
- const [showSessionPicker, setShowSessionPicker] = useState(false);
45
- return {
46
- showModelPicker,
47
- showSessionPicker,
48
- onTogglePicker: useCallback(() => setShowModelPicker((p) => !p), []),
49
- onToggleSessionPicker: useCallback(() => setShowSessionPicker((p) => !p), []),
50
- };
51
- }