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.
- package/README.md +81 -0
- package/bin/mu.js +2 -0
- package/package.json +19 -0
- package/src/cli.ts +90 -0
- package/src/clipboard.ts +62 -0
- package/src/config.ts +116 -0
- package/src/diff.ts +81 -0
- package/src/main.tsx +80 -0
- package/src/project.ts +32 -0
- package/src/session.ts +95 -0
- package/src/singleShot.ts +42 -0
- package/src/tui/commands.ts +19 -0
- package/src/tui/components/chat/ChatPanel.tsx +55 -0
- package/src/tui/components/chat/ChatPanelBody.tsx +67 -0
- package/src/tui/components/chat/Pickers.tsx +44 -0
- package/src/tui/components/chatLayout.tsx +192 -0
- package/src/tui/components/inputBox.tsx +152 -0
- package/src/tui/components/messages/EditOutput.tsx +89 -0
- package/src/tui/components/messages/ReadOutput.tsx +43 -0
- package/src/tui/components/messages/WriteOutput.tsx +68 -0
- package/src/tui/components/messages/assistantMessage.tsx +24 -0
- package/src/tui/components/messages/messageItem.tsx +36 -0
- package/src/tui/components/messages/reasoningBlock.tsx +14 -0
- package/src/tui/components/messages/streamingOutput.tsx +14 -0
- package/src/tui/components/messages/toolCallBlock.tsx +99 -0
- package/src/tui/components/messages/userMessage.tsx +29 -0
- package/src/tui/components/ui/dropdown.tsx +96 -0
- package/src/tui/components/ui/modal.tsx +45 -0
- package/src/tui/components/ui/toast.tsx +45 -0
- package/src/tui/context/chat.ts +10 -0
- package/src/tui/hooks/useInputHandler.ts +257 -0
- package/src/tui/hooks/useScroll.ts +56 -0
- package/src/tui/hooks/useTerminal.ts +40 -0
- package/src/tui/hooks/useUI.ts +15 -0
- package/src/tui/useAbort.ts +68 -0
- package/src/tui/useChat.ts +52 -0
- package/src/tui/useChatSession.ts +155 -0
- package/src/tui/useChatUI.ts +51 -0
- package/src/tui/useModelList.ts +49 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PluginRegistry } from 'mu-agents';
|
|
2
|
+
import type { ChatMessage, ProviderConfig } from 'mu-provider';
|
|
3
|
+
import { listModels, streamChat } from 'mu-provider';
|
|
4
|
+
|
|
5
|
+
export async function runSingleShot(prompt: string, config: ProviderConfig, registry: PluginRegistry): Promise<void> {
|
|
6
|
+
const messages: ChatMessage[] = [{ role: 'user', content: prompt }];
|
|
7
|
+
|
|
8
|
+
let resolvedModel = config.model;
|
|
9
|
+
if (!resolvedModel) {
|
|
10
|
+
const models = await listModels(config.baseUrl);
|
|
11
|
+
if (models.length === 0) {
|
|
12
|
+
console.error('Error: no models available at', config.baseUrl);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
resolvedModel = models[0].id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const toolDefinitions = registry.getToolDefinitions();
|
|
19
|
+
|
|
20
|
+
let tokens = 0;
|
|
21
|
+
let hasToolCalls = false;
|
|
22
|
+
process.stdout.write('mu: ');
|
|
23
|
+
for await (const chunk of streamChat(messages, config, resolvedModel, {
|
|
24
|
+
onUsage: (usage) => {
|
|
25
|
+
tokens = usage.totalTokens;
|
|
26
|
+
},
|
|
27
|
+
tools: toolDefinitions,
|
|
28
|
+
})) {
|
|
29
|
+
if (chunk.type === 'content') {
|
|
30
|
+
process.stdout.write(chunk.text);
|
|
31
|
+
} else if (chunk.type === 'tool_call') {
|
|
32
|
+
hasToolCalls = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (hasToolCalls) {
|
|
36
|
+
process.stderr.write('\n[tool calls made — use interactive mode for tool execution]\n');
|
|
37
|
+
}
|
|
38
|
+
process.stdout.write('\n');
|
|
39
|
+
if (tokens > 0) {
|
|
40
|
+
process.stderr.write(`(${tokens} tokens)\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SlashCommand {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
action: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const COMMANDS: SlashCommand[] = [
|
|
8
|
+
{ name: '/model', description: 'Select a model', action: 'model' },
|
|
9
|
+
{ name: '/sessions', description: 'List project sessions', action: 'sessions' },
|
|
10
|
+
{ name: '/new', description: 'New conversation', action: 'new' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function matchCommands(input: string): SlashCommand[] {
|
|
14
|
+
if (!input.startsWith('/')) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const q = input.toLowerCase();
|
|
18
|
+
return COMMANDS.filter((cmd) => cmd.name.startsWith(q));
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { useRef } from 'react';
|
|
5
|
+
import { ChatContext } from '../../context/chat';
|
|
6
|
+
import { useScroll } from '../../hooks/useScroll';
|
|
7
|
+
import { useMeasure, useTerminalSize } from '../../hooks/useTerminal';
|
|
8
|
+
import { useChat } from '../../useChat';
|
|
9
|
+
import { ChatPanelBody } from './ChatPanelBody';
|
|
10
|
+
|
|
11
|
+
export function ChatPanel({
|
|
12
|
+
config,
|
|
13
|
+
initialMessages,
|
|
14
|
+
registry,
|
|
15
|
+
}: {
|
|
16
|
+
config: ProviderConfig;
|
|
17
|
+
initialMessages?: ChatMessage[];
|
|
18
|
+
registry: PluginRegistry;
|
|
19
|
+
}) {
|
|
20
|
+
const ctx = useChat(config, registry, initialMessages);
|
|
21
|
+
const { width, height } = useTerminalSize();
|
|
22
|
+
const viewRef = useRef<InkDOMElement>(null);
|
|
23
|
+
const contentRef = useRef<InkDOMElement>(null);
|
|
24
|
+
const { viewHeight, contentHeight } = useMeasure(
|
|
25
|
+
viewRef,
|
|
26
|
+
contentRef,
|
|
27
|
+
[
|
|
28
|
+
ctx.session.messages.length,
|
|
29
|
+
...ctx.session.messages.map((m) => m.content.length),
|
|
30
|
+
ctx.session.stream.text.length,
|
|
31
|
+
ctx.session.stream.reasoning?.length ?? 0,
|
|
32
|
+
].join('|'),
|
|
33
|
+
);
|
|
34
|
+
const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
|
|
35
|
+
|
|
36
|
+
const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
|
|
37
|
+
useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ChatContext.Provider value={ctx}>
|
|
41
|
+
<ChatPanelBody
|
|
42
|
+
width={width}
|
|
43
|
+
height={height}
|
|
44
|
+
viewRef={viewRef}
|
|
45
|
+
contentRef={contentRef}
|
|
46
|
+
scrollOffset={scrollOffset}
|
|
47
|
+
viewHeight={viewHeight}
|
|
48
|
+
contentHeight={contentHeight}
|
|
49
|
+
isActive={!anyModalOpen}
|
|
50
|
+
onScrollUp={onScrollUp}
|
|
51
|
+
onScrollDown={onScrollDown}
|
|
52
|
+
/>
|
|
53
|
+
</ChatContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Box, type DOMElement as InkDOMElement } from 'ink';
|
|
2
|
+
import type { StatusSegment } from 'mu-agents';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useChatContext } from '../../context/chat';
|
|
5
|
+
import { MessageView, StatusBar } from '../chatLayout';
|
|
6
|
+
import { InputBox } from '../inputBox';
|
|
7
|
+
import { Pickers } from './Pickers';
|
|
8
|
+
|
|
9
|
+
interface LayoutProps {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
viewRef: React.RefObject<InkDOMElement | null>;
|
|
13
|
+
contentRef: React.RefObject<InkDOMElement | null>;
|
|
14
|
+
scrollOffset: number;
|
|
15
|
+
viewHeight: number;
|
|
16
|
+
contentHeight: number;
|
|
17
|
+
isActive: boolean;
|
|
18
|
+
onScrollUp: () => void;
|
|
19
|
+
onScrollDown: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ChatPanelBody(props: LayoutProps) {
|
|
23
|
+
const { session, models, abort, registry } = useChatContext();
|
|
24
|
+
const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const refresh = () => setPluginStatus(registry.getStatusSegments());
|
|
28
|
+
refresh();
|
|
29
|
+
const interval = setInterval(refresh, 2000);
|
|
30
|
+
return () => clearInterval(interval);
|
|
31
|
+
}, [registry]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box flexDirection="column" height={props.height} width={props.width}>
|
|
35
|
+
<MessageView
|
|
36
|
+
viewRef={props.viewRef}
|
|
37
|
+
contentRef={props.contentRef}
|
|
38
|
+
messages={session.messages}
|
|
39
|
+
streaming={session.streaming}
|
|
40
|
+
stream={session.stream}
|
|
41
|
+
error={session.error}
|
|
42
|
+
scrollOffset={props.scrollOffset}
|
|
43
|
+
viewHeight={props.viewHeight}
|
|
44
|
+
contentHeight={props.contentHeight}
|
|
45
|
+
/>
|
|
46
|
+
<InputBox
|
|
47
|
+
onSubmit={session.onSend}
|
|
48
|
+
onScrollUp={props.onScrollUp}
|
|
49
|
+
onScrollDown={props.onScrollDown}
|
|
50
|
+
isActive={props.isActive}
|
|
51
|
+
model={models.currentModel}
|
|
52
|
+
history={session.inputHistory}
|
|
53
|
+
/>
|
|
54
|
+
<StatusBar
|
|
55
|
+
streaming={session.streaming}
|
|
56
|
+
abortWarning={abort.abortWarning}
|
|
57
|
+
quitWarning={abort.quitWarning}
|
|
58
|
+
error={session.error}
|
|
59
|
+
modelError={models.modelError}
|
|
60
|
+
totalTokens={session.stream.totalTokens}
|
|
61
|
+
tokensPerSecond={session.stream.tps}
|
|
62
|
+
pluginStatus={pluginStatus}
|
|
63
|
+
/>
|
|
64
|
+
<Pickers />
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useChatContext } from '../../context/chat';
|
|
3
|
+
import { PickerModal } from '../chatLayout';
|
|
4
|
+
|
|
5
|
+
export function Pickers() {
|
|
6
|
+
const { toggles, models, sessions, session } = useChatContext();
|
|
7
|
+
const sessionItems = useMemo(
|
|
8
|
+
() =>
|
|
9
|
+
sessions.map((s) => ({
|
|
10
|
+
label: s.preview,
|
|
11
|
+
value: s.path,
|
|
12
|
+
description: `${s.messageCount} msgs`,
|
|
13
|
+
})),
|
|
14
|
+
[sessions],
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<PickerModal
|
|
20
|
+
visible={toggles.showModelPicker}
|
|
21
|
+
title="Select model"
|
|
22
|
+
items={models.models.map((m) => ({ label: m.id, value: m.id }))}
|
|
23
|
+
placeholder="Search models..."
|
|
24
|
+
onSelect={(id) => {
|
|
25
|
+
models.selectModel(id);
|
|
26
|
+
toggles.onTogglePicker();
|
|
27
|
+
}}
|
|
28
|
+
onCancel={toggles.onTogglePicker}
|
|
29
|
+
/>
|
|
30
|
+
<PickerModal
|
|
31
|
+
visible={toggles.showSessionPicker}
|
|
32
|
+
title={`Sessions · ${sessions[0]?.project ?? 'project'}`}
|
|
33
|
+
items={sessionItems}
|
|
34
|
+
placeholder="Search sessions..."
|
|
35
|
+
emptyMessage="No sessions found for this project"
|
|
36
|
+
onSelect={(p) => {
|
|
37
|
+
session.onLoadSession(p);
|
|
38
|
+
toggles.onToggleSessionPicker();
|
|
39
|
+
}}
|
|
40
|
+
onCancel={toggles.onToggleSessionPicker}
|
|
41
|
+
/>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { SlashCommand } from '../commands';
|
|
3
|
+
import { useChatContext } from '../context/chat';
|
|
4
|
+
import { type InputActions, useInputHandler } from '../hooks/useInputHandler';
|
|
5
|
+
|
|
6
|
+
interface InputBoxProps {
|
|
7
|
+
onSubmit: (text: string) => void;
|
|
8
|
+
onScrollUp?: () => void;
|
|
9
|
+
onScrollDown?: () => void;
|
|
10
|
+
isActive?: boolean;
|
|
11
|
+
model?: string;
|
|
12
|
+
history?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
|
|
16
|
+
if (!commands.length) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return (
|
|
20
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
21
|
+
{commands.map((cmd, i) => (
|
|
22
|
+
<Box key={cmd.name} paddingX={1}>
|
|
23
|
+
<Text color={i === selectedIndex ? 'green' : undefined} bold={i === selectedIndex}>
|
|
24
|
+
{i === selectedIndex ? '▸ ' : ' '}
|
|
25
|
+
{cmd.name}
|
|
26
|
+
</Text>
|
|
27
|
+
<Text dimColor={true}> {cmd.description}</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
))}
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function InputFooter({
|
|
35
|
+
model,
|
|
36
|
+
attachmentName,
|
|
37
|
+
attachmentError,
|
|
38
|
+
hasContent,
|
|
39
|
+
isCommandMode,
|
|
40
|
+
}: {
|
|
41
|
+
model: string;
|
|
42
|
+
attachmentName: string | null;
|
|
43
|
+
attachmentError: string | null;
|
|
44
|
+
hasContent: boolean;
|
|
45
|
+
isCommandMode: boolean;
|
|
46
|
+
}) {
|
|
47
|
+
const hint = hasContent
|
|
48
|
+
? isCommandMode
|
|
49
|
+
? '↑↓ select · Enter execute'
|
|
50
|
+
: 'Enter to send · Shift+Enter for newline'
|
|
51
|
+
: 'Type / for commands';
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box justifyContent="space-between">
|
|
55
|
+
<Box gap={1}>
|
|
56
|
+
{model && (
|
|
57
|
+
<Text color="white" bold={true}>
|
|
58
|
+
{model}
|
|
59
|
+
</Text>
|
|
60
|
+
)}
|
|
61
|
+
{attachmentName && <Text color="cyan">📷 {attachmentName}</Text>}
|
|
62
|
+
{attachmentError && <Text color="red">{attachmentError}</Text>}
|
|
63
|
+
</Box>
|
|
64
|
+
<Text dimColor={true}>{hint}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function InputDisplay({
|
|
70
|
+
value,
|
|
71
|
+
isCommandMode,
|
|
72
|
+
streaming,
|
|
73
|
+
isActive,
|
|
74
|
+
}: {
|
|
75
|
+
value: string;
|
|
76
|
+
isCommandMode: boolean;
|
|
77
|
+
streaming: boolean;
|
|
78
|
+
isActive: boolean;
|
|
79
|
+
}) {
|
|
80
|
+
const showCursor = !streaming && isActive;
|
|
81
|
+
if (!value.length) {
|
|
82
|
+
return <Text>{showCursor && <Text inverse={true}>▎</Text>}</Text>;
|
|
83
|
+
}
|
|
84
|
+
const lines = value.split('\n');
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
{lines.map((line, i) => (
|
|
88
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
|
|
89
|
+
<Text key={`${i}-${line}`} wrap="wrap">
|
|
90
|
+
{i === 0 && isCommandMode ? <Text color="green">{line}</Text> : line}
|
|
91
|
+
{i === lines.length - 1 && showCursor && <Text inverse={true}>▎</Text>}
|
|
92
|
+
</Text>
|
|
93
|
+
))}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function InputBox({
|
|
99
|
+
onSubmit,
|
|
100
|
+
onScrollUp,
|
|
101
|
+
onScrollDown,
|
|
102
|
+
isActive = true,
|
|
103
|
+
model = '',
|
|
104
|
+
history = [],
|
|
105
|
+
}: InputBoxProps) {
|
|
106
|
+
const { session, toggles, attachment, models, abort } = useChatContext();
|
|
107
|
+
|
|
108
|
+
const actions: InputActions = {
|
|
109
|
+
onCtrlC: abort.onCtrlC,
|
|
110
|
+
onEsc: abort.onEsc,
|
|
111
|
+
onPaste: attachment.onPaste,
|
|
112
|
+
onNew: session.onNew,
|
|
113
|
+
onCycleModel: models.cycleModel,
|
|
114
|
+
onTogglePicker: toggles.onTogglePicker,
|
|
115
|
+
onToggleSessionPicker: toggles.onToggleSessionPicker,
|
|
116
|
+
onScrollUp,
|
|
117
|
+
onScrollDown,
|
|
118
|
+
modelCount: models.models.length,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { value, commands, cmdIndex, isCommandMode } = useInputHandler({
|
|
122
|
+
isActive,
|
|
123
|
+
streaming: session.streaming,
|
|
124
|
+
history,
|
|
125
|
+
actions,
|
|
126
|
+
onSubmit,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Box
|
|
131
|
+
flexDirection="column"
|
|
132
|
+
flexShrink={0}
|
|
133
|
+
backgroundColor="#222222"
|
|
134
|
+
paddingX={1}
|
|
135
|
+
paddingY={1}
|
|
136
|
+
marginX={1}
|
|
137
|
+
marginTop={1}
|
|
138
|
+
>
|
|
139
|
+
{isCommandMode && <CommandHints commands={commands} selectedIndex={cmdIndex} />}
|
|
140
|
+
<Box flexDirection="column" minHeight={2}>
|
|
141
|
+
<InputDisplay value={value} isCommandMode={isCommandMode} streaming={session.streaming} isActive={isActive} />
|
|
142
|
+
</Box>
|
|
143
|
+
<InputFooter
|
|
144
|
+
model={model}
|
|
145
|
+
attachmentName={attachment.attachment?.name ?? null}
|
|
146
|
+
attachmentError={attachment.attachmentError}
|
|
147
|
+
hasContent={value.length > 0}
|
|
148
|
+
isCommandMode={isCommandMode}
|
|
149
|
+
/>
|
|
150
|
+
</Box>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { computeDiff, renderDiff } from '../../../diff';
|
|
3
|
+
|
|
4
|
+
interface EditOutputProps {
|
|
5
|
+
args: string;
|
|
6
|
+
content: string;
|
|
7
|
+
error: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function EditOutput({ args, content, error }: EditOutputProps) {
|
|
11
|
+
let path = '(unknown)';
|
|
12
|
+
let oldString = '';
|
|
13
|
+
let newString = '';
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(args);
|
|
17
|
+
path = parsed.path ?? '(unknown)';
|
|
18
|
+
oldString = parsed.old_string ?? '';
|
|
19
|
+
newString = parsed.new_string ?? '';
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (error) {
|
|
25
|
+
return (
|
|
26
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
27
|
+
<Text color="red" bold={true}>
|
|
28
|
+
✗ edit_file
|
|
29
|
+
</Text>
|
|
30
|
+
<Text dimColor={true}> {path}</Text>
|
|
31
|
+
<Text dimColor={true} wrap="wrap">
|
|
32
|
+
{content}
|
|
33
|
+
</Text>
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const diff = computeDiff(oldString, newString);
|
|
39
|
+
|
|
40
|
+
if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
43
|
+
<Text color="yellow" bold={true}>
|
|
44
|
+
! edit_file
|
|
45
|
+
</Text>
|
|
46
|
+
<Text dimColor={true}> {path}</Text>
|
|
47
|
+
<Text dimColor={true}>
|
|
48
|
+
Diff too large to display ({diff.totalOldLines} → {diff.totalNewLines} lines)
|
|
49
|
+
</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (diff.lines.length === 0) {
|
|
55
|
+
return (
|
|
56
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
57
|
+
<Text color="green" bold={true}>
|
|
58
|
+
✓ edit_file
|
|
59
|
+
</Text>
|
|
60
|
+
<Text dimColor={true}> {path}</Text>
|
|
61
|
+
<Text dimColor={true}>No changes (content identical)</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { lines, truncated } = renderDiff(diff, 30);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
70
|
+
<Text color="green" bold={true}>
|
|
71
|
+
✓ edit_file
|
|
72
|
+
</Text>
|
|
73
|
+
<Text dimColor={true}> {path}</Text>
|
|
74
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
75
|
+
{lines.map((line) => {
|
|
76
|
+
let color: string | undefined;
|
|
77
|
+
if (line.startsWith('-')) color = 'red';
|
|
78
|
+
else if (line.startsWith('+')) color = 'green';
|
|
79
|
+
return (
|
|
80
|
+
<Text key={line} color={color} dimColor={color === undefined} wrap="wrap">
|
|
81
|
+
{line}
|
|
82
|
+
</Text>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
{truncated && <Text dimColor={true}>… (truncated, 30 line limit)</Text>}
|
|
86
|
+
</Box>
|
|
87
|
+
</Box>
|
|
88
|
+
);
|
|
89
|
+
}
|