mu-coding 0.2.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 (72) 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 -92
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -161
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/singleShot.ts +0 -42
  64. package/src/tui/commands.ts +0 -33
  65. package/src/tui/components/chatLayout.tsx +0 -192
  66. package/src/tui/useChatSession.ts +0 -155
  67. package/src/tui/useChatUI.ts +0 -51
  68. package/tsconfig.json +0 -10
  69. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  70. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  71. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  72. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,16 +1,18 @@
1
1
  import { useApp } from 'ink';
2
2
  import type { PluginRegistry } from 'mu-agents';
3
3
  import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
- import { useRef } from 'react';
5
- import { listSessions, type SessionInfo } from '../session';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import type { ShutdownFn } from '../../app/shutdown';
6
+ import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
6
7
  import { type AbortState, useAbort } from './useAbort';
8
+ import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useAttachment';
7
9
  import { type ChatSessionState, useChatSession } from './useChatSession';
8
- import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useChatUI';
9
- import { type ModelListState, useModelList } from './useModelList';
10
+ import { type ModelListState, useModelList } from './useModels';
10
11
 
11
12
  const ABORT_TIMEOUT_MS = 2000;
12
13
 
13
14
  export interface ChatContextValue {
15
+ config: ProviderConfig;
14
16
  session: ChatSessionState;
15
17
  toggles: TogglesState;
16
18
  attachment: AttachmentState;
@@ -24,6 +26,7 @@ export function useChat(
24
26
  config: ProviderConfig,
25
27
  registry: PluginRegistry,
26
28
  initialMessages?: ChatMessage[],
29
+ shutdown?: ShutdownFn,
27
30
  ): ChatContextValue {
28
31
  const { exit } = useApp();
29
32
  const controllerRef = useRef<AbortController | null>(null);
@@ -38,15 +41,38 @@ export function useChat(
38
41
  initialMessages,
39
42
  registry,
40
43
  });
41
- const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS);
44
+ const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
45
+
46
+ // Stream the session list asynchronously when the picker opens. Empty until
47
+ // the first listing settles; subsequent opens hit the in-memory peek cache
48
+ // so they're effectively instant.
49
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
50
+ useEffect(() => {
51
+ if (!toggles.showSessionPicker) {
52
+ setSessions([]);
53
+ return;
54
+ }
55
+ let cancelled = false;
56
+ listSessionsAsync()
57
+ .then((list) => {
58
+ if (!cancelled) setSessions(list);
59
+ })
60
+ .catch(() => {
61
+ if (!cancelled) setSessions([]);
62
+ });
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [toggles.showSessionPicker]);
42
67
 
43
68
  return {
69
+ config,
44
70
  session,
45
71
  toggles,
46
72
  attachment,
47
73
  models,
48
74
  abort,
49
- sessions: toggles.showSessionPicker ? listSessions() : [],
75
+ sessions,
50
76
  registry,
51
77
  };
52
78
  }
@@ -0,0 +1,96 @@
1
+ import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
+ import type { PluginRegistry } from 'mu-agents';
3
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
+ import { useEffect, useMemo, useRef } from 'react';
5
+ import type { ShutdownFn } from '../../app/shutdown';
6
+ import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
7
+ import { useToast } from '../components/primitives/toast';
8
+ import { useScroll } from '../hooks/useScroll';
9
+ import { useMeasure, useTerminalSize } from '../hooks/useTerminal';
10
+ import type { InkUIService, ToastRequest } from '../plugins/InkUIService';
11
+ import { useChat } from './useChat';
12
+ import { usePluginStatus } from './usePluginStatus';
13
+ import { useStatusSegments } from './useStatusSegments';
14
+
15
+ const TOAST_LEVEL_COLORS: Record<string, string> = {
16
+ info: 'cyan',
17
+ success: 'green',
18
+ warning: 'yellow',
19
+ error: 'red',
20
+ };
21
+
22
+ interface UseChatPanelOptions {
23
+ config: ProviderConfig;
24
+ initialMessages?: ChatMessage[];
25
+ registry: PluginRegistry;
26
+ uiService?: InkUIService;
27
+ shutdown?: ShutdownFn;
28
+ }
29
+
30
+ export function useChatPanel(options: UseChatPanelOptions) {
31
+ const { config, initialMessages, registry, uiService, shutdown } = options;
32
+ const ctx = useChat(config, registry, initialMessages, shutdown);
33
+ const { width, height } = useTerminalSize();
34
+ const viewRef = useRef<InkDOMElement>(null);
35
+ const contentRef = useRef<InkDOMElement>(null);
36
+ // The composite key only needs to change when content visible to the layout
37
+ // shifts: number of messages or active stream length. Mapping over every
38
+ // message's content per render was O(n) wasted work.
39
+ const measureKey = useMemo(
40
+ () =>
41
+ [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length ?? 0].join(
42
+ '|',
43
+ ),
44
+ [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length],
45
+ );
46
+ const { viewHeight, contentHeight } = useMeasure(viewRef, contentRef, measureKey);
47
+ const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
48
+ const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
49
+ const pluginStatus = usePluginStatus(registry, uiService);
50
+ const { toasts, show, dismiss } = useToast();
51
+
52
+ useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
53
+
54
+ useEffect(() => {
55
+ if (!uiService) return;
56
+ return uiService.onToast((toast: ToastRequest) => {
57
+ show(toast.message, TOAST_LEVEL_COLORS[toast.level] ?? 'white');
58
+ });
59
+ }, [uiService, show]);
60
+
61
+ const statusSegments = useStatusSegments({
62
+ streaming: ctx.session.streaming,
63
+ abortWarning: ctx.abort.abortWarning,
64
+ quitWarning: ctx.abort.quitWarning,
65
+ error: ctx.session.error,
66
+ modelError: ctx.models.modelError,
67
+ tokensPerSecond: ctx.session.stream.tps,
68
+ pluginStatus,
69
+ });
70
+
71
+ const bodyProps: ChatPanelBodyProps = {
72
+ width,
73
+ height,
74
+ viewRef,
75
+ contentRef,
76
+ scrollOffset,
77
+ viewHeight,
78
+ contentHeight,
79
+ isActive: !anyModalOpen,
80
+ onScrollUp,
81
+ onScrollDown,
82
+ uiService,
83
+ messages: ctx.session.messages,
84
+ streaming: ctx.session.streaming,
85
+ stream: ctx.session.stream,
86
+ error: ctx.session.error,
87
+ onSubmit: ctx.session.onSend,
88
+ model: ctx.models.currentModel,
89
+ history: ctx.session.inputHistory,
90
+ statusSegments,
91
+ toasts,
92
+ onDismissToast: dismiss,
93
+ };
94
+
95
+ return { ctx, bodyProps };
96
+ }
@@ -0,0 +1,115 @@
1
+ import type { PluginRegistry } from 'mu-agents';
2
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
3
+ import { useCallback } from 'react';
4
+ import type { AttachmentState } from './useAttachment';
5
+ import { useSessionPersistence } from './useSessionPersistence';
6
+ import { type StreamState, useStreamConsumer } from './useStreamConsumer';
7
+
8
+ export type { StreamState } from './useStreamConsumer';
9
+
10
+ export interface ChatSessionState {
11
+ messages: ChatMessage[];
12
+ streaming: boolean;
13
+ error: string | null;
14
+ stream: StreamState;
15
+ inputHistory: string[];
16
+ onSend: (text: string) => Promise<void>;
17
+ onNew: () => void;
18
+ onLoadSession: (path: string) => void;
19
+ }
20
+
21
+ interface SessionDeps {
22
+ config: ProviderConfig;
23
+ currentModel: string;
24
+ attachment: AttachmentState;
25
+ controllerRef: React.RefObject<AbortController | null>;
26
+ initialMessages?: ChatMessage[];
27
+ registry: PluginRegistry;
28
+ }
29
+
30
+ /**
31
+ * Top-level chat-session hook. Composes:
32
+ * - `useSessionPersistence` — transcript, history, save path
33
+ * - `useStreamConsumer` — in-flight tokens, tps, error
34
+ *
35
+ * Provides the `onSend` glue that wires user input through the agent.
36
+ */
37
+ export function useChatSession(deps: SessionDeps): ChatSessionState {
38
+ const { config, currentModel, attachment, controllerRef, initialMessages, registry } = deps;
39
+ const persistence = useSessionPersistence(initialMessages);
40
+ const consumer = useStreamConsumer();
41
+ const { messages, setMessages, appendHistory, saveCurrent } = persistence;
42
+
43
+ const onSend = useCallback(
44
+ async (text: string) => {
45
+ if (consumer.streaming) {
46
+ return;
47
+ }
48
+ const userMsg: ChatMessage = {
49
+ role: 'user',
50
+ content: text,
51
+ ...(attachment.attachment ? { images: [attachment.attachment] } : {}),
52
+ };
53
+ setMessages((prev) => [...prev, userMsg]);
54
+ appendHistory(text);
55
+ attachment.clear();
56
+
57
+ const controller = new AbortController();
58
+ controllerRef.current = controller;
59
+
60
+ try {
61
+ const final = await consumer.runStream(
62
+ [...messages, userMsg],
63
+ config,
64
+ currentModel,
65
+ controller.signal,
66
+ registry,
67
+ setMessages,
68
+ );
69
+ if (final) {
70
+ saveCurrent(final);
71
+ }
72
+ } finally {
73
+ controllerRef.current = null;
74
+ }
75
+ },
76
+ [
77
+ consumer.streaming,
78
+ consumer.runStream,
79
+ messages,
80
+ config,
81
+ currentModel,
82
+ attachment,
83
+ controllerRef,
84
+ registry,
85
+ setMessages,
86
+ appendHistory,
87
+ saveCurrent,
88
+ ],
89
+ );
90
+
91
+ const onNew = useCallback(() => {
92
+ persistence.onNew();
93
+ consumer.resetError();
94
+ attachment.clear();
95
+ }, [persistence.onNew, consumer.resetError, attachment]);
96
+
97
+ const onLoadSession = useCallback(
98
+ (path: string) => {
99
+ persistence.onLoadSession(path);
100
+ consumer.resetError();
101
+ },
102
+ [persistence.onLoadSession, consumer.resetError],
103
+ );
104
+
105
+ return {
106
+ messages: persistence.messages,
107
+ streaming: consumer.streaming,
108
+ error: consumer.error,
109
+ stream: consumer.stream,
110
+ inputHistory: persistence.inputHistory,
111
+ onSend,
112
+ onNew,
113
+ onLoadSession,
114
+ };
115
+ }
@@ -1,6 +1,6 @@
1
1
  import { type ApiModel, listModels } from 'mu-provider';
2
2
  import { useCallback, useEffect, useState } from 'react';
3
- import { saveConfig } from '../config';
3
+ import { saveConfig } from '../../config/index';
4
4
 
5
5
  export interface ModelListState {
6
6
  models: ApiModel[];
@@ -16,8 +16,13 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
16
16
  const [error, setError] = useState<string | null>(null);
17
17
 
18
18
  useEffect(() => {
19
+ // Guard against late resolution: if the user quits or `baseUrl` changes
20
+ // before the request settles, swallow the response so we don't call
21
+ // setState on an unmounted hook.
22
+ let cancelled = false;
19
23
  listModels(baseUrl)
20
24
  .then((list) => {
25
+ if (cancelled) return;
21
26
  if (list.length === 0) {
22
27
  setError(`No models found at ${baseUrl}`);
23
28
  return;
@@ -28,8 +33,12 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
28
33
  setCurrentModel(target);
29
34
  })
30
35
  .catch((err) => {
36
+ if (cancelled) return;
31
37
  setError(err instanceof Error ? err.message : 'Failed to fetch models');
32
38
  });
39
+ return () => {
40
+ cancelled = true;
41
+ };
33
42
  }, [baseUrl, preferredModel]);
34
43
 
35
44
  const cycleModel = useCallback(() => {
@@ -0,0 +1,44 @@
1
+ import type { PluginRegistry, StatusSegment } from 'mu-agents';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import type { InkUIService } from '../plugins/InkUIService';
4
+
5
+ /**
6
+ * Aggregate plugin status from the two complementary channels into one
7
+ * flat segment list:
8
+ *
9
+ * 1. `registry.onStatusChange` — push-based, structured `StatusSegment[]` per
10
+ * plugin (color/dim metadata). Producers use `PluginContext.setStatusLine`.
11
+ * 2. `uiService.onStatusChange` — free-form `key → text` map. Producers use
12
+ * `UIService.setStatus` (e.g. `mu-repomap` progress, Pi `pi.ui.setStatus`,
13
+ * Pi `pi.ui.setWidget`). Rendered as dim text since the API carries no
14
+ * color metadata.
15
+ *
16
+ * The split lets producers pick the right granularity; callers see a single
17
+ * pre-merged list ready for the status bar.
18
+ */
19
+ export function usePluginStatus(registry: PluginRegistry, uiService?: InkUIService): StatusSegment[] {
20
+ const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
21
+ const [uiStatus, setUiStatus] = useState<StatusSegment[]>([]);
22
+
23
+ useEffect(() => {
24
+ setPluginStatus(registry.getStatusSegments());
25
+ return registry.onStatusChange(() => {
26
+ setPluginStatus(registry.getStatusSegments());
27
+ });
28
+ }, [registry]);
29
+
30
+ useEffect(() => {
31
+ if (!uiService) return;
32
+ const apply = (entries: Map<string, string>) => {
33
+ const segments: StatusSegment[] = [];
34
+ for (const [, text] of entries) {
35
+ segments.push({ text, dim: true });
36
+ }
37
+ setUiStatus(segments);
38
+ };
39
+ apply(uiService.getStatusEntries());
40
+ return uiService.onStatusChange(apply);
41
+ }, [uiService]);
42
+
43
+ return useMemo(() => [...pluginStatus, ...uiStatus], [pluginStatus, uiStatus]);
44
+ }
@@ -0,0 +1,57 @@
1
+ import type { ChatMessage } from 'mu-provider';
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { generateSessionPath, loadSession, saveSession } from '../../sessions/index';
4
+
5
+ export interface SessionPersistenceState {
6
+ messages: ChatMessage[];
7
+ setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
8
+ inputHistory: string[];
9
+ appendHistory: (text: string) => void;
10
+ sessionPathRef: React.RefObject<string>;
11
+ saveCurrent: (messages: ChatMessage[]) => void;
12
+ onNew: () => void;
13
+ onLoadSession: (path: string) => void;
14
+ }
15
+
16
+ function userPromptsFrom(messages: ChatMessage[]): string[] {
17
+ return messages.filter((m) => m.role === 'user').map((m) => m.content);
18
+ }
19
+
20
+ /**
21
+ * Owns the conversation transcript and its on-disk persistence. Keeps the
22
+ * current session path, the transcript, and the user-input history in sync.
23
+ *
24
+ * Save errors are logged to stderr and do not surface to the chat error
25
+ * channel — they're considered non-fatal (next save attempt may succeed).
26
+ */
27
+ export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionPersistenceState {
28
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? []);
29
+ const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
30
+ const sessionPathRef = useRef(generateSessionPath());
31
+
32
+ const appendHistory = useCallback((text: string) => {
33
+ setInputHistory((prev) => [...prev, text]);
34
+ }, []);
35
+
36
+ const saveCurrent = useCallback((finalMessages: ChatMessage[]) => {
37
+ saveSession(sessionPathRef.current, finalMessages).catch((err) => {
38
+ console.error('Failed to save session:', err);
39
+ });
40
+ }, []);
41
+
42
+ const onNew = useCallback(() => {
43
+ setMessages([]);
44
+ sessionPathRef.current = generateSessionPath();
45
+ }, []);
46
+
47
+ const onLoadSession = useCallback((path: string) => {
48
+ const msgs = loadSession(path);
49
+ if (msgs.length > 0) {
50
+ setMessages(msgs);
51
+ setInputHistory(userPromptsFrom(msgs));
52
+ sessionPathRef.current = path;
53
+ }
54
+ }, []);
55
+
56
+ return { messages, setMessages, inputHistory, appendHistory, sessionPathRef, saveCurrent, onNew, onLoadSession };
57
+ }
@@ -0,0 +1,49 @@
1
+ import type { StatusSegment } from 'mu-agents';
2
+ import type { StatusBarSegment } from '../components/statusBar';
3
+ import { useSpinner } from '../hooks/useUI';
4
+
5
+ const ERROR_PREVIEW_LEN = 40;
6
+
7
+ interface StatusSegmentOptions {
8
+ streaming: boolean;
9
+ abortWarning: boolean;
10
+ quitWarning: boolean;
11
+ error: string | null;
12
+ modelError: string | null;
13
+ tokensPerSecond: number;
14
+ pluginStatus?: StatusSegment[];
15
+ }
16
+
17
+ function truncate(text: string, max: number): string {
18
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
19
+ }
20
+
21
+ export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegment[] {
22
+ const spinner = useSpinner(options.streaming);
23
+ const segments: StatusBarSegment[] = [];
24
+
25
+ if (options.streaming) {
26
+ segments.push({ text: `${spinner} generating`, color: 'yellow' });
27
+ }
28
+ if (options.tokensPerSecond > 0) {
29
+ segments.push({ text: `${options.tokensPerSecond} tok/s`, dim: true });
30
+ }
31
+ if (options.abortWarning) {
32
+ segments.push({ text: 'Esc again to stop', color: 'yellow' });
33
+ } else if (options.quitWarning) {
34
+ segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
35
+ } else if (options.streaming) {
36
+ segments.push({ text: 'Esc to stop', dim: true });
37
+ }
38
+ if (options.error) {
39
+ segments.push({ text: `⚠ ${truncate(options.error, ERROR_PREVIEW_LEN)}`, color: 'red' });
40
+ }
41
+ if (options.modelError) {
42
+ segments.push({ text: `⚠ ${truncate(options.modelError, ERROR_PREVIEW_LEN)}`, color: 'red' });
43
+ }
44
+ if (options.pluginStatus) {
45
+ segments.push(...options.pluginStatus);
46
+ }
47
+
48
+ return segments;
49
+ }
@@ -0,0 +1,118 @@
1
+ import { type AgentEvent, type PluginRegistry, runAgent } from 'mu-agents';
2
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
3
+ import { useCallback, useState } from 'react';
4
+
5
+ export interface StreamState {
6
+ text: string;
7
+ reasoning: string;
8
+ tps: number;
9
+ }
10
+
11
+ const EMPTY_STREAM: StreamState = { text: '', reasoning: '', tps: 0 };
12
+ const TPS_WARMUP_SEC = 0.5;
13
+
14
+ export interface StreamConsumerState {
15
+ streaming: boolean;
16
+ error: string | null;
17
+ stream: StreamState;
18
+ /**
19
+ * Run the agent against `messages` and stream events into local state.
20
+ * Returns the final message array (or null if the agent didn't produce one,
21
+ * e.g. on abort). Throws are caught and reported via `error`.
22
+ */
23
+ runStream: (
24
+ messages: ChatMessage[],
25
+ config: ProviderConfig,
26
+ model: string,
27
+ signal: AbortSignal,
28
+ registry: PluginRegistry,
29
+ onMessages: (messages: ChatMessage[]) => void,
30
+ ) => Promise<ChatMessage[] | null>;
31
+ resetError: () => void;
32
+ }
33
+
34
+ function applyEvent(prev: StreamState, event: AgentEvent, tps: number): StreamState {
35
+ switch (event.type) {
36
+ case 'content':
37
+ return { ...prev, text: event.text, tps };
38
+ case 'reasoning':
39
+ return { ...prev, reasoning: event.text, tps };
40
+ case 'turn_end':
41
+ return { ...prev, text: '', reasoning: '' };
42
+ default:
43
+ return prev;
44
+ }
45
+ }
46
+
47
+ async function consumeAgent(
48
+ events: AsyncGenerator<AgentEvent>,
49
+ onStream: (updater: (prev: StreamState) => StreamState) => void,
50
+ onMessages: (messages: ChatMessage[]) => void,
51
+ ): Promise<ChatMessage[] | null> {
52
+ let final: ChatMessage[] | null = null;
53
+ const start = Date.now();
54
+ let tokenCount = 0;
55
+
56
+ for await (const event of events) {
57
+ if (event.type === 'content' || event.type === 'reasoning') {
58
+ tokenCount++;
59
+ const elapsed = (Date.now() - start) / 1000;
60
+ const tps = elapsed > TPS_WARMUP_SEC ? Math.round(tokenCount / elapsed) : 0;
61
+ onStream((prev) => applyEvent(prev, event, tps));
62
+ } else if (event.type === 'messages') {
63
+ final = event.messages;
64
+ onMessages(event.messages);
65
+ } else {
66
+ onStream((prev) => applyEvent(prev, event, 0));
67
+ }
68
+ }
69
+ return final;
70
+ }
71
+
72
+ /**
73
+ * Owns the in-flight streaming view: which tokens have been received, the
74
+ * tokens-per-second meter, error text, and the streaming flag. Decoupled
75
+ * from message persistence so it can be reused by single-shot agents or
76
+ * test harnesses.
77
+ */
78
+ export function useStreamConsumer(): StreamConsumerState {
79
+ const [streaming, setStreaming] = useState(false);
80
+ const [error, setError] = useState<string | null>(null);
81
+ const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
82
+
83
+ const resetError = useCallback(() => setError(null), []);
84
+
85
+ const runStream = useCallback(
86
+ async (
87
+ messages: ChatMessage[],
88
+ config: ProviderConfig,
89
+ model: string,
90
+ signal: AbortSignal,
91
+ registry: PluginRegistry,
92
+ onMessages: (messages: ChatMessage[]) => void,
93
+ ): Promise<ChatMessage[] | null> => {
94
+ setStream(EMPTY_STREAM);
95
+ setError(null);
96
+ setStreaming(true);
97
+ try {
98
+ return await consumeAgent(runAgent(messages, config, model, signal, registry), setStream, onMessages);
99
+ } catch (err) {
100
+ if (!(err instanceof Error && err.name === 'AbortError')) {
101
+ setError(err instanceof Error ? err.message : 'Unknown error');
102
+ }
103
+ return null;
104
+ } finally {
105
+ setStreaming(false);
106
+ // Preserve partial output on abort so the user can see what arrived;
107
+ // clear it on clean completion so the persisted assistant message
108
+ // doesn't render twice.
109
+ if (!signal.aborted) {
110
+ setStream((s) => ({ ...s, text: '', reasoning: '' }));
111
+ }
112
+ }
113
+ },
114
+ [],
115
+ );
116
+
117
+ return { streaming, error, stream, runStream, resetError };
118
+ }
@@ -1,12 +1,10 @@
1
- import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
1
  import type { PluginRegistry } from 'mu-agents';
3
2
  import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
- import { useRef } from 'react';
5
- import { ChatContext } from '../../context/chat';
6
- import { useScroll } from '../../hooks/useScroll';
7
- import { useMeasure, useTerminalSize } from '../../hooks/useTerminal';
8
- import type { InkUIService } from '../../services/uiService';
9
- import { useChat } from '../../useChat';
3
+ import type { ShutdownFn } from '../../../app/shutdown';
4
+ import { ChatContext } from '../../chat/ChatContext';
5
+ import { ToolDisplayProvider, useToolDisplayMap } from '../../chat/ToolDisplayContext';
6
+ import { useChatPanel } from '../../chat/useChatPanel';
7
+ import type { InkUIService } from '../../plugins/InkUIService';
10
8
  import { ChatPanelBody } from './ChatPanelBody';
11
9
 
12
10
  export function ChatPanel({
@@ -14,46 +12,22 @@ export function ChatPanel({
14
12
  initialMessages,
15
13
  registry,
16
14
  uiService,
15
+ shutdown,
17
16
  }: {
18
17
  config: ProviderConfig;
19
18
  initialMessages?: ChatMessage[];
20
19
  registry: PluginRegistry;
21
20
  uiService?: InkUIService;
21
+ shutdown?: ShutdownFn;
22
22
  }) {
23
- const ctx = useChat(config, registry, initialMessages);
24
- const { width, height } = useTerminalSize();
25
- const viewRef = useRef<InkDOMElement>(null);
26
- const contentRef = useRef<InkDOMElement>(null);
27
- const { viewHeight, contentHeight } = useMeasure(
28
- viewRef,
29
- contentRef,
30
- [
31
- ctx.session.messages.length,
32
- ...ctx.session.messages.map((m) => m.content.length),
33
- ctx.session.stream.text.length,
34
- ctx.session.stream.reasoning?.length ?? 0,
35
- ].join('|'),
36
- );
37
- const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
38
-
39
- const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
40
- useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
23
+ const { ctx, bodyProps } = useChatPanel({ config, initialMessages, registry, uiService, shutdown });
24
+ const toolDisplays = useToolDisplayMap(registry);
41
25
 
42
26
  return (
43
27
  <ChatContext.Provider value={ctx}>
44
- <ChatPanelBody
45
- width={width}
46
- height={height}
47
- viewRef={viewRef}
48
- contentRef={contentRef}
49
- scrollOffset={scrollOffset}
50
- viewHeight={viewHeight}
51
- contentHeight={contentHeight}
52
- isActive={!anyModalOpen}
53
- onScrollUp={onScrollUp}
54
- onScrollDown={onScrollDown}
55
- uiService={uiService}
56
- />
28
+ <ToolDisplayProvider value={toolDisplays}>
29
+ <ChatPanelBody {...bodyProps} />
30
+ </ToolDisplayProvider>
57
31
  </ChatContext.Provider>
58
32
  );
59
33
  }