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.
- package/README.md +0 -2
- package/bin/mu.js +1 -1
- package/package.json +12 -4
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +40 -0
- package/src/cli/args.ts +128 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +51 -0
- package/src/config/index.ts +181 -0
- package/src/main.ts +4 -0
- package/src/runtime/createRegistry.ts +58 -0
- package/src/runtime/pluginLoader.ts +109 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +190 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
- package/src/tui/chat/useChatPanel.ts +96 -0
- package/src/tui/chat/useChatSession.ts +115 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +57 -0
- package/src/tui/chat/useStatusSegments.ts +49 -0
- package/src/tui/chat/useStreamConsumer.ts +118 -0
- package/src/tui/components/chat/ChatPanel.tsx +12 -38
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +70 -0
- package/src/tui/components/messages/EditOutput.tsx +42 -27
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +26 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/messageItem.tsx +4 -15
- package/src/tui/components/messages/toolCallBlock.tsx +56 -34
- package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
- package/src/tui/components/primitives/pickerModal.tsx +45 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/statusBar.tsx +25 -0
- package/src/tui/components/ui/dialogLayer.tsx +21 -7
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
- package/src/tui/input/commands.test.ts +49 -0
- package/src/tui/input/commands.ts +39 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +88 -0
- package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +30 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -92
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -161
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/singleShot.ts +0 -42
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
- /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
- /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 {
|
|
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
|
|
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
|
|
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 '
|
|
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 {
|
|
5
|
-
import { ChatContext } from '../../
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import type { InkUIService } from '../../
|
|
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 =
|
|
24
|
-
const
|
|
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
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|