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,43 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ interface ReadOutputProps {
4
+ args: string;
5
+ error: boolean;
6
+ }
7
+
8
+ export function ReadOutput({ args, error }: ReadOutputProps) {
9
+ let paths: string[] = ['(unknown)'];
10
+ let startLine: number | undefined;
11
+ let endLine: number | undefined;
12
+
13
+ try {
14
+ const parsed = JSON.parse(args);
15
+ const p = parsed.path;
16
+ paths = Array.isArray(p) ? p : [p];
17
+ startLine = parsed.start;
18
+ endLine = parsed.end;
19
+ } catch {
20
+ // ignore
21
+ }
22
+
23
+ const rangeLabel = startLine != null && endLine != null ? ` (lines ${startLine}-${endLine})` : '';
24
+
25
+ return (
26
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
27
+ <Text dimColor={true} wrap="wrap">
28
+ <Text color={error ? 'red' : 'green'} bold={true}>
29
+ {error ? '✗' : '✓'} read_file
30
+ </Text>{' '}
31
+ {paths.length > 1 ? `(${paths.length} files)` : ''}
32
+ {paths.length > 1 ? '\n' : ''}
33
+ {paths.map((p) => (
34
+ <Text key={p} dimColor={true} wrap="wrap">
35
+ {paths.length > 1 ? ' • ' : ''}
36
+ {p}
37
+ </Text>
38
+ ))}
39
+ {rangeLabel}
40
+ </Text>
41
+ </Box>
42
+ );
43
+ }
@@ -0,0 +1,68 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ const PREVIEW_LINES = 30;
4
+
5
+ interface WriteOutputProps {
6
+ args: string;
7
+ content: string;
8
+ error: boolean;
9
+ expanded: boolean;
10
+ }
11
+
12
+ export function WriteOutput({ args, content, error, expanded }: WriteOutputProps) {
13
+ let path = '(unknown)';
14
+ try {
15
+ const parsed = JSON.parse(args);
16
+ path = parsed.path ?? '(unknown)';
17
+ } catch {
18
+ // ignore
19
+ }
20
+
21
+ if (error) {
22
+ return (
23
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
24
+ <Text color="red" bold={true}>
25
+ ✗ write_file
26
+ </Text>
27
+ <Text dimColor={true} wrap="wrap">
28
+ {content}
29
+ </Text>
30
+ </Box>
31
+ );
32
+ }
33
+
34
+ const lines = content.split('\n');
35
+ const totalLines = lines.length;
36
+ const preview = lines.slice(0, PREVIEW_LINES).join('\n');
37
+ const hasMore = totalLines > PREVIEW_LINES;
38
+
39
+ return (
40
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
41
+ <Text color="green" bold={true}>
42
+ ✓ write_file
43
+ </Text>
44
+ <Text dimColor={true}> {path}</Text>
45
+ <Box flexDirection="column" flexShrink={0}>
46
+ <Text dimColor={true}>
47
+ {totalLines} line{totalLines !== 1 ? 's' : ''}
48
+ </Text>
49
+ <Box flexDirection="column" flexShrink={0}>
50
+ <Text dimColor={true} wrap="wrap">
51
+ {expanded ? content : preview}
52
+ </Text>
53
+ {hasMore && !expanded && <Text dimColor={true}>… ({totalLines - PREVIEW_LINES} more lines)</Text>}
54
+ {!expanded && (
55
+ <Box>
56
+ <Text color="cyan"> [Enter] show more </Text>
57
+ </Box>
58
+ )}
59
+ {expanded && (
60
+ <Box>
61
+ <Text color="cyan"> [Enter] show less </Text>
62
+ </Box>
63
+ )}
64
+ </Box>
65
+ </Box>
66
+ </Box>
67
+ );
68
+ }
@@ -0,0 +1,24 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { ChatMessage } from 'mu-provider';
3
+ import React from 'react';
4
+ import { ReasoningBlock } from './reasoningBlock';
5
+ import { ToolCallBlock } from './toolCallBlock';
6
+
7
+ export const AssistantMessage: React.FC<{
8
+ msg: ChatMessage;
9
+ toolMessages?: ChatMessage[];
10
+ }> = React.memo(function AssistantMessage({ msg, toolMessages }) {
11
+ return (
12
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
13
+ {msg.reasoning && <ReasoningBlock reasoning={msg.reasoning} />}
14
+ {msg.toolCalls?.length ? (
15
+ <Box flexDirection="column" marginBottom={1}>
16
+ {msg.toolCalls.map((tc, i) => (
17
+ <ToolCallBlock key={tc.id} toolCall={tc} toolMsg={toolMessages?.[i]} />
18
+ ))}
19
+ </Box>
20
+ ) : null}
21
+ {msg.content && <Text wrap="wrap">{msg.content}</Text>}
22
+ </Box>
23
+ );
24
+ });
@@ -0,0 +1,36 @@
1
+ import type { ChatMessage } from 'mu-provider';
2
+ import React from 'react';
3
+ import { AssistantMessage } from './assistantMessage';
4
+ import { UserMessage } from './userMessage';
5
+
6
+ export const MessageItem: React.FC<{
7
+ msg: ChatMessage;
8
+ messages: ChatMessage[];
9
+ index: number;
10
+ }> = React.memo(function MessageItem({ msg, messages, index }) {
11
+ // Tool result messages are rendered inline within ToolCallBlock
12
+ if (msg.role === 'tool') {
13
+ return null;
14
+ }
15
+
16
+ // Check if this assistant message has tool calls
17
+ if (msg.role === 'assistant' && msg.toolCalls?.length) {
18
+ // Collect following tool messages
19
+ const toolMessages: ChatMessage[] = [];
20
+ for (let i = index + 1; i < messages.length; i++) {
21
+ if (messages[i].role === 'tool') {
22
+ toolMessages.push(messages[i]);
23
+ } else {
24
+ break;
25
+ }
26
+ }
27
+
28
+ return <AssistantMessage msg={msg} toolMessages={toolMessages} />;
29
+ }
30
+
31
+ if (msg.role === 'user') {
32
+ return <UserMessage msg={msg} />;
33
+ }
34
+
35
+ return <AssistantMessage msg={msg} />;
36
+ });
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ export function ReasoningBlock({ reasoning }: { reasoning: string }) {
4
+ return (
5
+ <Box flexDirection="column" marginTop={0} marginBottom={1}>
6
+ <Text color="yellow" italic={true}>
7
+ thinking
8
+ </Text>
9
+ <Text dimColor={true} italic={true} wrap="wrap">
10
+ {reasoning}
11
+ </Text>
12
+ </Box>
13
+ );
14
+ }
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from 'ink';
2
+ import { ReasoningBlock } from './reasoningBlock';
3
+
4
+ export function StreamingOutput({ currentText, currentReasoning }: { currentText: string; currentReasoning: string }) {
5
+ return (
6
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
7
+ {currentReasoning && <ReasoningBlock reasoning={currentReasoning} />}
8
+ <Text wrap="wrap">
9
+ {currentText}
10
+ <Text inverse={true}>▎</Text>
11
+ </Text>
12
+ </Box>
13
+ );
14
+ }
@@ -0,0 +1,99 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { ChatMessage } from 'mu-provider';
3
+ import { useSpinner } from '../../hooks/useUI';
4
+ import { EditOutput } from './EditOutput';
5
+ import { ReadOutput } from './ReadOutput';
6
+ import { WriteOutput } from './WriteOutput';
7
+
8
+ const TOOL_VERBS: Record<string, string> = {
9
+ bash: 'running',
10
+ read_file: 'reading',
11
+ write_file: 'writing',
12
+ edit_file: 'editing',
13
+ };
14
+
15
+ function getToolArgSummary(name: string, args: string): string {
16
+ if (name === 'bash') {
17
+ try {
18
+ const parsed = JSON.parse(args);
19
+ return parsed.command ?? args;
20
+ } catch {
21
+ return args;
22
+ }
23
+ }
24
+ return args;
25
+ }
26
+
27
+ export function ToolCallBlock({
28
+ toolCall,
29
+ toolMsg,
30
+ }: {
31
+ toolCall: { id: string; function: { name: string; arguments: string } };
32
+ toolMsg?: ChatMessage;
33
+ }) {
34
+ const name = toolCall.function.name;
35
+ const args = toolCall.function.arguments;
36
+
37
+ // Find the matching tool result message
38
+ const result = toolMsg?.toolResult;
39
+ const hasResult = result !== undefined;
40
+ const spinner = useSpinner(!hasResult);
41
+ const verb = TOOL_VERBS[name] ?? 'executing';
42
+ const argSummary = getToolArgSummary(name, args);
43
+
44
+ return (
45
+ <Box flexDirection="column" flexShrink={0}>
46
+ {!hasResult ? (
47
+ <Box>
48
+ <Text dimColor={true}>
49
+ {' '}
50
+ {spinner} {verb}... <Text dimColor={true}>{argSummary}</Text>
51
+ </Text>
52
+ </Box>
53
+ ) : (
54
+ renderToolOutput(name, args, result.content, result.error ?? false, result.expanded)
55
+ )}
56
+ </Box>
57
+ );
58
+ }
59
+
60
+ function renderToolOutput(name: string, args: string, content: string, error: boolean, expanded?: boolean) {
61
+ if (name === 'read_file') {
62
+ return <ReadOutput args={args} error={error} />;
63
+ }
64
+ if (name === 'write_file') {
65
+ return <WriteOutput args={args} content={content} error={error} expanded={expanded ?? false} />;
66
+ }
67
+ if (name === 'edit_file') {
68
+ return <EditOutput args={args} content={content} error={error} />;
69
+ }
70
+
71
+ // Fallback for bash and unknown tools
72
+ let command = '';
73
+ if (name === 'bash') {
74
+ try {
75
+ const parsed = JSON.parse(args);
76
+ command = parsed.command ?? '';
77
+ } catch {
78
+ // ignore
79
+ }
80
+ }
81
+
82
+ const preview = content.length > 200 ? `${content.slice(0, 200)}…` : content;
83
+ return (
84
+ <Box flexDirection="column" flexShrink={0}>
85
+ <Text color={error ? 'red' : 'green'} bold={true}>
86
+ {error ? '✗' : '✓'} {name}
87
+ {command && (
88
+ <>
89
+ {' '}
90
+ <Text dimColor={true}>{command}</Text>
91
+ </>
92
+ )}
93
+ </Text>
94
+ <Box flexDirection="column" backgroundColor="#111111" padding={1} marginTop={1}>
95
+ <Text color="white">{preview}</Text>
96
+ </Box>
97
+ </Box>
98
+ );
99
+ }
@@ -0,0 +1,29 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { ChatMessage } from 'mu-provider';
3
+
4
+ export function UserMessage({ msg }: { msg: ChatMessage }) {
5
+ return (
6
+ <Box
7
+ flexDirection="column"
8
+ flexShrink={0}
9
+ marginY={1}
10
+ backgroundColor="#1a1a1a"
11
+ paddingX={1}
12
+ paddingY={1}
13
+ borderLeft={true}
14
+ borderTop={false}
15
+ borderBottom={false}
16
+ borderRight={false}
17
+ borderColor="yellow"
18
+ borderStyle="single"
19
+ >
20
+ {msg.images && msg.images.length > 0 && (
21
+ <Box>
22
+ <Text color="cyan">📷 </Text>
23
+ <Text color="cyan">{msg.images.map((img) => img.name).join(', ')}</Text>
24
+ </Box>
25
+ )}
26
+ <Text wrap="wrap">{msg.content}</Text>
27
+ </Box>
28
+ );
29
+ }
@@ -0,0 +1,96 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useMemo, useState } from 'react';
3
+
4
+ interface DropdownItem {
5
+ label: string;
6
+ value: string;
7
+ description?: string;
8
+ }
9
+
10
+ function matches(query: string) {
11
+ const q = query.toLowerCase();
12
+ return (item: DropdownItem) =>
13
+ item.label.toLowerCase().includes(q) ||
14
+ item.value.toLowerCase().includes(q) ||
15
+ item.description?.toLowerCase().includes(q);
16
+ }
17
+
18
+ interface DropdownProps {
19
+ items: DropdownItem[];
20
+ placeholder?: string;
21
+ maxVisible?: number;
22
+ onSelect: (item: DropdownItem) => void;
23
+ onCancel?: () => void;
24
+ isActive?: boolean;
25
+ }
26
+
27
+ export function Dropdown({
28
+ items,
29
+ placeholder = 'Search...',
30
+ maxVisible = 8,
31
+ onSelect,
32
+ onCancel,
33
+ isActive = true,
34
+ }: DropdownProps) {
35
+ const [query, setQuery] = useState('');
36
+ const [index, setIndex] = useState(0);
37
+
38
+ const filtered = useMemo(() => (query ? items.filter(matches(query)) : items), [items, query]);
39
+
40
+ const visibleStart = Math.max(0, Math.min(index - Math.floor(maxVisible / 2), filtered.length - maxVisible));
41
+ const visibleItems = filtered.slice(visibleStart, visibleStart + maxVisible);
42
+
43
+ useInput(
44
+ (input, key) => {
45
+ if (!isActive || key.tab) return;
46
+ if (key.escape) onCancel?.();
47
+ else if (key.return && filtered[index]) onSelect(filtered[index]);
48
+ else if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
49
+ else if (key.downArrow) setIndex((i) => Math.min(filtered.length - 1, i + 1));
50
+ else if (key.backspace) setQuery((q) => q.slice(0, -1));
51
+ else if (input?.length === 1) setQuery((q) => q + input);
52
+ },
53
+ { isActive },
54
+ );
55
+
56
+ function renderResults() {
57
+ if (filtered.length === 0) {
58
+ return (
59
+ <Box paddingX={1}>
60
+ <Text dimColor={true} italic={true}>
61
+ No results
62
+ </Text>
63
+ </Box>
64
+ );
65
+ }
66
+ return visibleItems.map((item, i) => {
67
+ const isSel = i === index - visibleStart;
68
+ const color = isSel ? 'green' : undefined;
69
+ return (
70
+ <Box key={item.value} paddingX={1}>
71
+ <Text color={color} bold={isSel}>
72
+ {isSel && '▸ '}
73
+ {item.label}
74
+ {item.description && <Text dimColor={true}> {item.description}</Text>}
75
+ </Text>
76
+ </Box>
77
+ );
78
+ });
79
+ }
80
+
81
+ return (
82
+ <Box flexDirection="column">
83
+ <Box paddingX={1} marginBottom={1}>
84
+ <Text dimColor={true}>{placeholder} </Text>
85
+ <Text>{query}</Text>
86
+ <Text inverse={true}>▎</Text>
87
+ </Box>
88
+ {renderResults()}
89
+ {filtered.length > maxVisible && (
90
+ <Box paddingX={1} marginTop={1}>
91
+ <Text dimColor={true}>{filtered.length} items · ↑↓ navigate · Enter select</Text>
92
+ </Box>
93
+ )}
94
+ </Box>
95
+ );
96
+ }
@@ -0,0 +1,45 @@
1
+ import { Box, Text, useStdout } from 'ink';
2
+ import type { ReactNode } from 'react';
3
+
4
+ interface ModalProps {
5
+ visible: boolean;
6
+ title?: string;
7
+ width?: number;
8
+ children: ReactNode;
9
+ }
10
+
11
+ export function Modal({ visible, title, width: requestedWidth, children }: ModalProps) {
12
+ const { stdout } = useStdout();
13
+ const columns = stdout.columns;
14
+ const rows = stdout.rows;
15
+
16
+ if (!visible) {
17
+ return null;
18
+ }
19
+
20
+ const modalWidth = requestedWidth ?? Math.min(60, columns - 4);
21
+
22
+ return (
23
+ <Box
24
+ position="absolute"
25
+ flexDirection="column"
26
+ justifyContent="center"
27
+ alignItems="center"
28
+ width={columns}
29
+ height={rows}
30
+ top={0}
31
+ left={0}
32
+ >
33
+ <Box flexDirection="column" width={modalWidth} backgroundColor="#1a1a1a" paddingX={2} paddingY={1}>
34
+ {title && (
35
+ <Box marginBottom={1}>
36
+ <Text bold={true}>{title}</Text>
37
+ <Box flexGrow={1} />
38
+ <Text dimColor={true}>Esc to close</Text>
39
+ </Box>
40
+ )}
41
+ {children}
42
+ </Box>
43
+ </Box>
44
+ );
45
+ }
@@ -0,0 +1,45 @@
1
+ import { Box, Text, useStdout } from 'ink';
2
+ import { useState } from 'react';
3
+
4
+ export interface Toast {
5
+ id: number;
6
+ message: string;
7
+ color?: string;
8
+ }
9
+
10
+ let nextId = 0;
11
+
12
+ export function useToast() {
13
+ const [toasts, setToasts] = useState<Toast[]>([]);
14
+
15
+ const show = (message: string, color?: string, durationMs = 2000) => {
16
+ const id = nextId++;
17
+ setToasts((prev) => [...prev, { id, message, color }]);
18
+ setTimeout(() => {
19
+ setToasts((prev) => prev.filter((t) => t.id !== id));
20
+ }, durationMs);
21
+ };
22
+
23
+ return { toasts, show };
24
+ }
25
+
26
+ export function ToastContainer({ toasts }: { toasts: Toast[] }) {
27
+ const { stdout } = useStdout();
28
+ const columns = stdout.columns;
29
+
30
+ if (toasts.length === 0) {
31
+ return null;
32
+ }
33
+
34
+ return (
35
+ <Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
36
+ <Box flexDirection="column" gap={1}>
37
+ {toasts.map((t) => (
38
+ <Box key={t.id} backgroundColor="#1a1a1a" paddingX={2} paddingY={0}>
39
+ <Text color={t.color ?? 'green'}>{t.message}</Text>
40
+ </Box>
41
+ ))}
42
+ </Box>
43
+ </Box>
44
+ );
45
+ }
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { ChatContextValue } from '../useChat';
3
+
4
+ export const ChatContext = createContext<ChatContextValue | null>(null);
5
+
6
+ export function useChatContext() {
7
+ const ctx = useContext(ChatContext);
8
+ if (!ctx) throw new Error('useChatContext requires ChatProvider');
9
+ return ctx;
10
+ }