mu-coding 0.1.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 (40) hide show
  1. package/README.md +81 -0
  2. package/bin/mu.js +2 -0
  3. package/package.json +19 -0
  4. package/src/cli.ts +90 -0
  5. package/src/clipboard.ts +62 -0
  6. package/src/config.ts +116 -0
  7. package/src/diff.ts +81 -0
  8. package/src/main.tsx +80 -0
  9. package/src/project.ts +32 -0
  10. package/src/session.ts +95 -0
  11. package/src/singleShot.ts +42 -0
  12. package/src/tui/commands.ts +19 -0
  13. package/src/tui/components/chat/ChatPanel.tsx +55 -0
  14. package/src/tui/components/chat/ChatPanelBody.tsx +67 -0
  15. package/src/tui/components/chat/Pickers.tsx +44 -0
  16. package/src/tui/components/chatLayout.tsx +192 -0
  17. package/src/tui/components/inputBox.tsx +152 -0
  18. package/src/tui/components/messages/EditOutput.tsx +89 -0
  19. package/src/tui/components/messages/ReadOutput.tsx +43 -0
  20. package/src/tui/components/messages/WriteOutput.tsx +68 -0
  21. package/src/tui/components/messages/assistantMessage.tsx +24 -0
  22. package/src/tui/components/messages/messageItem.tsx +36 -0
  23. package/src/tui/components/messages/reasoningBlock.tsx +14 -0
  24. package/src/tui/components/messages/streamingOutput.tsx +14 -0
  25. package/src/tui/components/messages/toolCallBlock.tsx +99 -0
  26. package/src/tui/components/messages/userMessage.tsx +29 -0
  27. package/src/tui/components/ui/dropdown.tsx +96 -0
  28. package/src/tui/components/ui/modal.tsx +45 -0
  29. package/src/tui/components/ui/toast.tsx +45 -0
  30. package/src/tui/context/chat.ts +10 -0
  31. package/src/tui/hooks/useInputHandler.ts +257 -0
  32. package/src/tui/hooks/useScroll.ts +56 -0
  33. package/src/tui/hooks/useTerminal.ts +40 -0
  34. package/src/tui/hooks/useUI.ts +15 -0
  35. package/src/tui/useAbort.ts +68 -0
  36. package/src/tui/useChat.ts +52 -0
  37. package/src/tui/useChatSession.ts +155 -0
  38. package/src/tui/useChatUI.ts +51 -0
  39. package/src/tui/useModelList.ts +49 -0
  40. package/tsconfig.json +10 -0
@@ -0,0 +1,257 @@
1
+ import { type Key, useInput, useStdin } from 'ink';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { matchCommands, type SlashCommand } from '../commands';
4
+
5
+ const BACKSPACE_BYTES = new Set(['\x7f', '\x08']);
6
+
7
+ export interface InputActions {
8
+ onCtrlC?: () => void;
9
+ onPaste?: () => void;
10
+ onNew?: () => void;
11
+ onCycleModel?: () => void;
12
+ onTogglePicker?: () => void;
13
+ onToggleSessionPicker?: () => void;
14
+ onEsc?: () => void;
15
+ onScrollUp?: () => void;
16
+ onScrollDown?: () => void;
17
+ modelCount?: number;
18
+ }
19
+
20
+ interface InputState {
21
+ value: string;
22
+ commands: SlashCommand[];
23
+ cmdIndex: number;
24
+ isCommandMode: boolean;
25
+ }
26
+
27
+ interface UseInputHandlerOptions {
28
+ isActive: boolean;
29
+ streaming: boolean;
30
+ history: string[];
31
+ actions: InputActions;
32
+ onSubmit: (text: string) => void;
33
+ }
34
+
35
+ // Build a stable key identifier from an Ink key event
36
+ function keyId(input: string, key: Key): string | null {
37
+ if (key.ctrl && key.shift && input) {
38
+ return `ctrl+shift+${input}`;
39
+ }
40
+ if (key.ctrl && input) {
41
+ return `ctrl+${input}`;
42
+ }
43
+ if (key.escape) {
44
+ return 'escape';
45
+ }
46
+ if (key.pageUp) {
47
+ return 'pageup';
48
+ }
49
+ if (key.pageDown) {
50
+ return 'pagedown';
51
+ }
52
+ if (key.return) {
53
+ return key.shift ? 'shift+return' : 'return';
54
+ }
55
+ if (key.tab) {
56
+ return 'tab';
57
+ }
58
+ if (key.upArrow) {
59
+ return 'up';
60
+ }
61
+ if (key.downArrow) {
62
+ return 'down';
63
+ }
64
+ if (key.backspace || key.delete) {
65
+ return 'backspace';
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function useHistoryNavigation(value: string, history: string[]) {
71
+ const idx = useRef(-1);
72
+ const draft = useRef('');
73
+
74
+ const up = (): string | null => {
75
+ if (!history.length) {
76
+ return null;
77
+ }
78
+ if (idx.current === -1) {
79
+ draft.current = value;
80
+ idx.current = history.length - 1;
81
+ } else if (idx.current > 0) {
82
+ idx.current -= 1;
83
+ }
84
+ return history[idx.current] ?? null;
85
+ };
86
+
87
+ const down = (): string | null => {
88
+ if (idx.current === -1) {
89
+ return null;
90
+ }
91
+ if (idx.current < history.length - 1) {
92
+ idx.current += 1;
93
+ return history[idx.current] ?? null;
94
+ }
95
+ idx.current = -1;
96
+ return draft.current;
97
+ };
98
+
99
+ return { up, down, reset: () => (idx.current = -1) };
100
+ }
101
+
102
+ function useRawBackspace(isActive: boolean, setValue: (fn: (p: string) => string) => void) {
103
+ const { stdin } = useStdin();
104
+ const handledRef = useRef(false);
105
+
106
+ useEffect(() => {
107
+ if (!(stdin && isActive)) {
108
+ return;
109
+ }
110
+ const onData = (data: Buffer) => {
111
+ if (BACKSPACE_BYTES.has(data.toString())) {
112
+ handledRef.current = true;
113
+ setValue((p) => p.slice(0, -1));
114
+ }
115
+ };
116
+ stdin.on('data', onData);
117
+ return () => {
118
+ stdin.off('data', onData);
119
+ };
120
+ }, [stdin, isActive, setValue]);
121
+
122
+ return handledRef;
123
+ }
124
+
125
+ const COMMAND_ACTIONS: Record<string, keyof InputActions> = {
126
+ model: 'onTogglePicker',
127
+ sessions: 'onToggleSessionPicker',
128
+ new: 'onNew',
129
+ };
130
+
131
+ interface BindingCtx {
132
+ value: string;
133
+ setValue: React.Dispatch<React.SetStateAction<string>>;
134
+ setCmdIndex: React.Dispatch<React.SetStateAction<number>>;
135
+ nav: ReturnType<typeof useHistoryNavigation>;
136
+ submit: () => void;
137
+ isCommandMode: boolean;
138
+ commands: SlashCommand[];
139
+ actions: InputActions;
140
+ }
141
+
142
+ type Binding = (ctx: BindingCtx) => void;
143
+
144
+ const BINDINGS: Record<string, Binding> = {
145
+ 'ctrl+c': (c) => c.actions.onCtrlC?.(),
146
+ 'ctrl+v': (c) => c.actions.onPaste?.(),
147
+ 'ctrl+o': (c) => c.actions.onTogglePicker?.(),
148
+ 'ctrl+s': (c) => c.submit(),
149
+ 'ctrl+j': (c) => c.setValue((p) => `${p}\n`),
150
+ 'ctrl+m': (c) => {
151
+ if (c.actions.modelCount) {
152
+ c.actions.onCycleModel?.();
153
+ }
154
+ },
155
+ 'ctrl+n': (c) => {
156
+ c.actions.onNew?.();
157
+ c.setValue('');
158
+ c.nav.reset();
159
+ },
160
+ escape: (c) => c.actions.onEsc?.(),
161
+ pageup: (c) => c.actions.onScrollUp?.(),
162
+ pagedown: (c) => c.actions.onScrollDown?.(),
163
+ 'shift+return': (c) => c.setValue((p) => `${p}\n`),
164
+ return: (c) => c.submit(),
165
+ tab: (c) => c.setValue((p) => `${p} `),
166
+ up: (c) => {
167
+ if (c.isCommandMode) {
168
+ c.setCmdIndex((i) => (i > 0 ? i - 1 : c.commands.length - 1));
169
+ return;
170
+ }
171
+ const r = c.nav.up();
172
+ if (r !== null) {
173
+ c.setValue(r);
174
+ }
175
+ },
176
+ down: (c) => {
177
+ if (c.isCommandMode) {
178
+ c.setCmdIndex((i) => (i < c.commands.length - 1 ? i + 1 : 0));
179
+ return;
180
+ }
181
+ const r = c.nav.down();
182
+ if (r !== null) {
183
+ c.setValue(r);
184
+ }
185
+ },
186
+ };
187
+
188
+ function handleBackspace(c: BindingCtx, alreadyHandled: boolean) {
189
+ if (!alreadyHandled) {
190
+ c.setValue((p) => p.slice(0, -1));
191
+ }
192
+ c.nav.reset();
193
+ }
194
+
195
+ function handleInsert(input: string, c: BindingCtx) {
196
+ if (input && input.length === 1) {
197
+ c.setValue((p) => p + input);
198
+ c.nav.reset();
199
+ }
200
+ }
201
+
202
+ export function useInputHandler(options: UseInputHandlerOptions): InputState {
203
+ const { isActive, streaming, history, actions, onSubmit } = options;
204
+ const [value, setValue] = useState('');
205
+ const [cmdIndex, setCmdIndex] = useState(0);
206
+ const nav = useHistoryNavigation(value, history);
207
+ const backspaceHandledRef = useRawBackspace(isActive, setValue);
208
+
209
+ const commands = useMemo(() => matchCommands(value.trim()), [value]);
210
+ const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
211
+
212
+ const submit = useCallback(() => {
213
+ if (streaming) {
214
+ return;
215
+ }
216
+ if (isCommandMode) {
217
+ const cmd = commands[cmdIndex];
218
+ if (cmd) {
219
+ setValue('');
220
+ const actionKey = COMMAND_ACTIONS[cmd.action];
221
+ if (actionKey) {
222
+ (actions[actionKey] as (() => void) | undefined)?.();
223
+ }
224
+ }
225
+ return;
226
+ }
227
+ if (!value.trim()) {
228
+ return;
229
+ }
230
+ onSubmit(value);
231
+ setValue('');
232
+ nav.reset();
233
+ }, [streaming, isCommandMode, commands, cmdIndex, value, actions, onSubmit, nav]);
234
+
235
+ useInput(
236
+ (input, key) => {
237
+ const alreadyHandled = backspaceHandledRef.current;
238
+ backspaceHandledRef.current = false;
239
+
240
+ const ctx: BindingCtx = { value, setValue, setCmdIndex, nav, submit, isCommandMode, commands, actions };
241
+ const id = keyId(input, key);
242
+
243
+ if (id === 'backspace') {
244
+ handleBackspace(ctx, alreadyHandled);
245
+ return;
246
+ }
247
+ if (id && BINDINGS[id]) {
248
+ BINDINGS[id](ctx);
249
+ return;
250
+ }
251
+ handleInsert(input, ctx);
252
+ },
253
+ { isActive },
254
+ );
255
+
256
+ return { value, commands, cmdIndex, isCommandMode };
257
+ }
@@ -0,0 +1,56 @@
1
+ import { useInput, useStdout } from 'ink';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+
4
+ const SCROLL_STEP = 3;
5
+
6
+ export function useScroll(contentHeight: number, viewHeight: number) {
7
+ const [scrollOffset, setScrollOffset] = useState(0);
8
+ const autoScrollRef = useRef(true);
9
+ const maxScroll = Math.max(0, contentHeight - viewHeight);
10
+
11
+ // Enable SGR mouse mode so wheel sequences arrive through Ink's input pipeline
12
+ const { stdout } = useStdout();
13
+ useEffect(() => {
14
+ stdout.write('\x1b[?1002h\x1b[?1006h');
15
+ return () => {
16
+ stdout.write('\x1b[?1002l\x1b[?1006l');
17
+ };
18
+ }, [stdout]);
19
+
20
+ useEffect(() => {
21
+ if (autoScrollRef.current && contentHeight > viewHeight) {
22
+ setScrollOffset(contentHeight - viewHeight);
23
+ }
24
+ }, [contentHeight, viewHeight]);
25
+
26
+ const scrollUp = useCallback(() => {
27
+ autoScrollRef.current = false;
28
+ setScrollOffset((o) => Math.max(0, o - SCROLL_STEP));
29
+ }, []);
30
+
31
+ const scrollDown = useCallback(() => {
32
+ setScrollOffset((o) => {
33
+ const next = Math.min(maxScroll, o + SCROLL_STEP);
34
+ if (next >= maxScroll) {
35
+ autoScrollRef.current = true;
36
+ }
37
+ return next;
38
+ });
39
+ }, [maxScroll]);
40
+
41
+ // Detect SGR mouse wheel sequences via Ink's useInput hook.
42
+ // Ink's parseKeypress doesn't recognize SGR mouse, so raw sequences
43
+ // pass through with \x1b stripped: [<64;... (up), [<65;... (down)
44
+ useInput(
45
+ (input) => {
46
+ if (input.startsWith('[<64')) {
47
+ scrollUp();
48
+ } else if (input.startsWith('[<65')) {
49
+ scrollDown();
50
+ }
51
+ },
52
+ { isActive: true },
53
+ );
54
+
55
+ return { scrollOffset, onScrollUp: scrollUp, onScrollDown: scrollDown };
56
+ }
@@ -0,0 +1,40 @@
1
+ import type { DOMElement } from 'ink';
2
+ import { measureElement, useStdout } from 'ink';
3
+ import { useEffect, useLayoutEffect, useState } from 'react';
4
+
5
+ export function useTerminalSize() {
6
+ const { stdout } = useStdout();
7
+ const [size, setSize] = useState({ width: stdout.columns, height: stdout.rows });
8
+ useEffect(() => {
9
+ const onResize = () => setSize({ width: stdout.columns, height: stdout.rows });
10
+ stdout.on('resize', onResize);
11
+ return () => {
12
+ stdout.off('resize', onResize);
13
+ };
14
+ }, [stdout]);
15
+ return size;
16
+ }
17
+
18
+ export function useMeasure(
19
+ viewRef: React.RefObject<DOMElement | null>,
20
+ contentRef: React.RefObject<DOMElement | null>,
21
+ contentKey?: unknown,
22
+ ) {
23
+ const [viewHeight, setViewHeight] = useState(0);
24
+ const [contentHeight, setContentHeight] = useState(0);
25
+
26
+ // biome-ignore lint/correctness/useExhaustiveDependencies: contentKey triggers re-measure on content changes
27
+ useLayoutEffect(() => {
28
+ const timer = setTimeout(() => {
29
+ if (viewRef.current) {
30
+ setViewHeight(measureElement(viewRef.current).height);
31
+ }
32
+ if (contentRef.current) {
33
+ setContentHeight(measureElement(contentRef.current).height);
34
+ }
35
+ }, 100);
36
+ return () => clearTimeout(timer);
37
+ }, [viewRef, contentRef, contentKey]);
38
+
39
+ return { viewHeight, contentHeight };
40
+ }
@@ -0,0 +1,15 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+
5
+ export function useSpinner(active: boolean): string {
6
+ const [frame, setFrame] = useState(0);
7
+ useEffect(() => {
8
+ if (!active) {
9
+ return;
10
+ }
11
+ const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
12
+ return () => clearInterval(timer);
13
+ }, [active]);
14
+ return active ? SPINNER_FRAMES[frame] : '';
15
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ function useDoublePress(timeoutMs: number) {
4
+ const [warning, setWarning] = useState(false);
5
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6
+
7
+ const confirm = useCallback(() => {
8
+ if (warning) {
9
+ if (timerRef.current) {
10
+ clearTimeout(timerRef.current);
11
+ }
12
+ return true;
13
+ }
14
+ setWarning(true);
15
+ if (timerRef.current) {
16
+ clearTimeout(timerRef.current);
17
+ }
18
+ timerRef.current = setTimeout(() => {
19
+ setWarning(false);
20
+ timerRef.current = null;
21
+ }, timeoutMs);
22
+ return false;
23
+ }, [warning, timeoutMs]);
24
+
25
+ return { warning, confirm };
26
+ }
27
+
28
+ export interface AbortState {
29
+ controllerRef: React.RefObject<AbortController | null>;
30
+ quitWarning: boolean;
31
+ abortWarning: boolean;
32
+ onCtrlC: () => void;
33
+ onEsc: () => void;
34
+ }
35
+
36
+ export function useAbort(
37
+ streaming: boolean,
38
+ controllerRef: React.RefObject<AbortController | null>,
39
+ exit: () => void,
40
+ timeoutMs: number,
41
+ ): AbortState {
42
+ const { warning: quitWarning, confirm: onCtrlC } = useDoublePress(timeoutMs);
43
+ const { warning: abortWarning, confirm: onEsc } = useDoublePress(timeoutMs);
44
+
45
+ const handleCtrlC = useCallback(() => {
46
+ if (streaming && controllerRef.current) {
47
+ controllerRef.current.abort();
48
+ controllerRef.current = null;
49
+ return;
50
+ }
51
+ if (onCtrlC()) {
52
+ exit();
53
+ setTimeout(() => process.exit(0), 100);
54
+ }
55
+ }, [streaming, onCtrlC, exit, controllerRef]);
56
+
57
+ const handleEsc = useCallback(() => {
58
+ if (!(streaming && controllerRef.current)) {
59
+ return;
60
+ }
61
+ if (onEsc()) {
62
+ controllerRef.current.abort();
63
+ controllerRef.current = null;
64
+ }
65
+ }, [streaming, onEsc, controllerRef]);
66
+
67
+ return { controllerRef, quitWarning, abortWarning, onCtrlC: handleCtrlC, onEsc: handleEsc };
68
+ }
@@ -0,0 +1,52 @@
1
+ import { useApp } from 'ink';
2
+ import type { PluginRegistry } from 'mu-agents';
3
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
+ import { useRef } from 'react';
5
+ import { listSessions, type SessionInfo } from '../session';
6
+ import { type AbortState, useAbort } from './useAbort';
7
+ import { type ChatSessionState, useChatSession } from './useChatSession';
8
+ import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useChatUI';
9
+ import { type ModelListState, useModelList } from './useModelList';
10
+
11
+ const ABORT_TIMEOUT_MS = 2000;
12
+
13
+ export interface ChatContextValue {
14
+ session: ChatSessionState;
15
+ toggles: TogglesState;
16
+ attachment: AttachmentState;
17
+ models: ModelListState;
18
+ abort: AbortState;
19
+ sessions: SessionInfo[];
20
+ registry: PluginRegistry;
21
+ }
22
+
23
+ export function useChat(
24
+ config: ProviderConfig,
25
+ registry: PluginRegistry,
26
+ initialMessages?: ChatMessage[],
27
+ ): ChatContextValue {
28
+ const { exit } = useApp();
29
+ const controllerRef = useRef<AbortController | null>(null);
30
+ const attachment = useAttachment();
31
+ const toggles = useToggles();
32
+ const models = useModelList(config.baseUrl, config.model);
33
+ const session = useChatSession({
34
+ config,
35
+ currentModel: models.currentModel,
36
+ attachment,
37
+ controllerRef,
38
+ initialMessages,
39
+ registry,
40
+ });
41
+ const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS);
42
+
43
+ return {
44
+ session,
45
+ toggles,
46
+ attachment,
47
+ models,
48
+ abort,
49
+ sessions: toggles.showSessionPicker ? listSessions() : [],
50
+ registry,
51
+ };
52
+ }
@@ -0,0 +1,155 @@
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
+ }
@@ -0,0 +1,51 @@
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
+ }