mu-coding 0.5.0 → 0.8.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 +49 -3
- package/package.json +9 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +1 -1
- package/src/app/startApp.ts +11 -8
- package/src/cli/args.ts +14 -11
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +128 -23
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +22 -9
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +31 -3
- package/src/tui/chat/useChatPanel.ts +7 -5
- package/src/tui/chat/useChatSession.ts +222 -53
- package/src/tui/chat/useModels.ts +2 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +25 -14
- package/src/tui/chat/useStatusSegments.ts +17 -4
- package/src/tui/components/chat/ChatPanel.tsx +10 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +6 -4
- package/src/tui/components/messages/ToolHeader.tsx +3 -1
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +6 -5
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/primitives/dropdown.tsx +8 -4
- package/src/tui/components/primitives/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +3 -1
- package/src/tui/components/primitives/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +8 -1
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/input/InputBoxView.tsx +135 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +6 -1
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +1 -1
- package/src/tui/input/useCommandExecutor.ts +1 -1
- package/src/tui/input/useInputBox.ts +134 -15
- package/src/tui/input/useInputHandler.ts +316 -126
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +26 -13
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
2
3
|
|
|
3
4
|
export function ReasoningBlock({ reasoning }: { reasoning: string }) {
|
|
5
|
+
const theme = useTheme();
|
|
4
6
|
return (
|
|
5
7
|
<Box flexDirection="column" marginTop={0} marginBottom={1}>
|
|
6
|
-
<Text color=
|
|
8
|
+
<Text color={theme.reasoning.title} italic={true}>
|
|
7
9
|
thinking
|
|
8
10
|
</Text>
|
|
9
|
-
<Text
|
|
11
|
+
<Text color={theme.reasoning.body} italic={true} wrap="wrap">
|
|
10
12
|
{reasoning}
|
|
11
13
|
</Text>
|
|
12
14
|
</Box>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
2
3
|
import { ReasoningBlock } from './reasoningBlock';
|
|
3
4
|
|
|
4
5
|
export function StreamingOutput({ currentText, currentReasoning }: { currentText: string; currentReasoning: string }) {
|
|
6
|
+
const theme = useTheme();
|
|
5
7
|
return (
|
|
6
8
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
7
9
|
{currentReasoning && <ReasoningBlock reasoning={currentReasoning} />}
|
|
8
10
|
<Text wrap="wrap">
|
|
9
11
|
{currentText}
|
|
10
|
-
<Text inverse={true}
|
|
12
|
+
<Text color={theme.input.cursor} inverse={true}>
|
|
13
|
+
▎
|
|
14
|
+
</Text>
|
|
11
15
|
</Text>
|
|
12
16
|
</Box>
|
|
13
17
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import type { ToolDisplayHint } from 'mu-
|
|
3
|
-
import type { ChatMessage } from 'mu-provider';
|
|
2
|
+
import type { ChatMessage, ToolDisplayHint } from 'mu-core';
|
|
4
3
|
import { useToolDisplay } from '../../chat/ToolDisplayContext';
|
|
4
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
5
5
|
import { useSpinner } from '../../hooks/useUI';
|
|
6
6
|
import { EditOutput } from './EditOutput';
|
|
7
7
|
import { ReadOutput } from './ReadOutput';
|
|
@@ -90,6 +90,7 @@ interface GenericProps {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
|
|
93
|
+
const theme = useTheme();
|
|
93
94
|
let summary = '';
|
|
94
95
|
const commandField = hint?.fields?.command;
|
|
95
96
|
if (commandField) {
|
|
@@ -104,7 +105,7 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
|
|
|
104
105
|
const preview = content.length > 200 ? `${content.slice(0, 200)}…` : content;
|
|
105
106
|
return (
|
|
106
107
|
<Box flexDirection="column" flexShrink={0}>
|
|
107
|
-
<Text color={error ?
|
|
108
|
+
<Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
|
|
108
109
|
{error ? '✗' : '✓'} {name}
|
|
109
110
|
{summary && (
|
|
110
111
|
<>
|
|
@@ -113,8 +114,8 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
|
|
|
113
114
|
</>
|
|
114
115
|
)}
|
|
115
116
|
</Text>
|
|
116
|
-
<Box flexDirection="column" backgroundColor=
|
|
117
|
-
<Text color=
|
|
117
|
+
<Box flexDirection="column" backgroundColor={theme.tool.previewBackground} padding={1} marginTop={1}>
|
|
118
|
+
<Text color={theme.tool.previewText}>{preview}</Text>
|
|
118
119
|
</Box>
|
|
119
120
|
</Box>
|
|
120
121
|
);
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import type { ChatMessage } from 'mu-
|
|
2
|
+
import type { ChatMessage } from 'mu-core';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
|
|
4
5
|
export function UserMessage({ msg }: { msg: ChatMessage }) {
|
|
6
|
+
const theme = useTheme();
|
|
7
|
+
const borderColor = msg.display?.color ?? theme.user.border;
|
|
8
|
+
const badge = msg.display?.badge;
|
|
9
|
+
const prefix = msg.display?.prefix;
|
|
5
10
|
return (
|
|
6
11
|
<Box
|
|
7
12
|
flexDirection="column"
|
|
8
13
|
flexShrink={0}
|
|
9
14
|
marginY={1}
|
|
10
|
-
backgroundColor=
|
|
15
|
+
backgroundColor={theme.user.background}
|
|
11
16
|
paddingX={1}
|
|
12
17
|
paddingY={1}
|
|
13
18
|
borderLeft={true}
|
|
14
19
|
borderTop={false}
|
|
15
20
|
borderBottom={false}
|
|
16
21
|
borderRight={false}
|
|
17
|
-
borderColor=
|
|
22
|
+
borderColor={borderColor}
|
|
18
23
|
borderStyle="single"
|
|
19
24
|
>
|
|
25
|
+
{badge && (
|
|
26
|
+
<Box marginBottom={1}>
|
|
27
|
+
<Text color={msg.display?.color} bold={true}>
|
|
28
|
+
[{badge}]
|
|
29
|
+
</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
)}
|
|
20
32
|
{msg.images && msg.images.length > 0 && (
|
|
21
33
|
<Box>
|
|
22
|
-
<Text color=
|
|
23
|
-
<Text color=
|
|
34
|
+
<Text color={theme.user.attachment}>📷 </Text>
|
|
35
|
+
<Text color={theme.user.attachment}>{msg.images.map((img) => img.name).join(', ')}</Text>
|
|
24
36
|
</Box>
|
|
25
37
|
)}
|
|
26
|
-
<Text wrap="wrap">
|
|
38
|
+
<Text wrap="wrap">
|
|
39
|
+
{prefix && <Text color={msg.display?.color}>{prefix}</Text>}
|
|
40
|
+
{msg.content}
|
|
41
|
+
</Text>
|
|
27
42
|
</Box>
|
|
28
43
|
);
|
|
29
44
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput } from 'ink';
|
|
2
2
|
import { useMemo, useState } from 'react';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
import { sanitizeTerminalInput } from '../../input/sanitize';
|
|
4
5
|
|
|
5
6
|
interface DropdownItem {
|
|
@@ -33,6 +34,7 @@ export function Dropdown({
|
|
|
33
34
|
onCancel,
|
|
34
35
|
isActive = true,
|
|
35
36
|
}: DropdownProps) {
|
|
37
|
+
const theme = useTheme();
|
|
36
38
|
const [query, setQuery] = useState('');
|
|
37
39
|
const [index, setIndex] = useState(0);
|
|
38
40
|
|
|
@@ -82,7 +84,7 @@ export function Dropdown({
|
|
|
82
84
|
if (filtered.length === 0) {
|
|
83
85
|
return (
|
|
84
86
|
<Box paddingX={1}>
|
|
85
|
-
<Text
|
|
87
|
+
<Text color={theme.dropdown.empty} italic={true}>
|
|
86
88
|
No results
|
|
87
89
|
</Text>
|
|
88
90
|
</Box>
|
|
@@ -90,7 +92,7 @@ export function Dropdown({
|
|
|
90
92
|
}
|
|
91
93
|
return visibleItems.map((item, i) => {
|
|
92
94
|
const isSel = i === index - visibleStart;
|
|
93
|
-
const color = isSel ?
|
|
95
|
+
const color = isSel ? theme.dropdown.selected : undefined;
|
|
94
96
|
return (
|
|
95
97
|
<Box key={item.value} paddingX={1}>
|
|
96
98
|
<Text color={color} bold={isSel}>
|
|
@@ -106,9 +108,11 @@ export function Dropdown({
|
|
|
106
108
|
return (
|
|
107
109
|
<Box flexDirection="column">
|
|
108
110
|
<Box paddingX={1} marginBottom={1}>
|
|
109
|
-
<Text
|
|
111
|
+
<Text color={theme.dropdown.placeholder}>{placeholder} </Text>
|
|
110
112
|
<Text>{query}</Text>
|
|
111
|
-
<Text inverse={true}
|
|
113
|
+
<Text color={theme.dropdown.cursor} inverse={true}>
|
|
114
|
+
▎
|
|
115
|
+
</Text>
|
|
112
116
|
</Box>
|
|
113
117
|
{renderResults()}
|
|
114
118
|
{filtered.length > maxVisible && (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useStdout } from 'ink';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
|
|
4
5
|
interface ModalProps {
|
|
5
6
|
visible: boolean;
|
|
@@ -9,6 +10,7 @@ interface ModalProps {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export function Modal({ visible, title, width: requestedWidth, children }: ModalProps) {
|
|
13
|
+
const theme = useTheme();
|
|
12
14
|
const { stdout } = useStdout();
|
|
13
15
|
const columns = stdout.columns;
|
|
14
16
|
const rows = stdout.rows;
|
|
@@ -30,12 +32,12 @@ export function Modal({ visible, title, width: requestedWidth, children }: Modal
|
|
|
30
32
|
top={0}
|
|
31
33
|
left={0}
|
|
32
34
|
>
|
|
33
|
-
<Box flexDirection="column" width={modalWidth} backgroundColor=
|
|
35
|
+
<Box flexDirection="column" width={modalWidth} backgroundColor={theme.modal.background} paddingX={2} paddingY={1}>
|
|
34
36
|
{title && (
|
|
35
37
|
<Box marginBottom={1}>
|
|
36
38
|
<Text bold={true}>{title}</Text>
|
|
37
39
|
<Box flexGrow={1} />
|
|
38
|
-
<Text
|
|
40
|
+
<Text color={theme.modal.hint}>Esc to close</Text>
|
|
39
41
|
</Box>
|
|
40
42
|
)}
|
|
41
43
|
{children}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
2
3
|
import { Dropdown } from './dropdown';
|
|
3
4
|
import { Modal } from './modal';
|
|
4
5
|
|
|
@@ -25,10 +26,11 @@ export function PickerModal({
|
|
|
25
26
|
onSelect: (value: string) => void;
|
|
26
27
|
onCancel?: () => void;
|
|
27
28
|
}) {
|
|
29
|
+
const theme = useTheme();
|
|
28
30
|
return (
|
|
29
31
|
<Modal visible={visible} title={title}>
|
|
30
32
|
{items.length === 0 && emptyMessage ? (
|
|
31
|
-
<Text
|
|
33
|
+
<Text color={theme.dropdown.empty} italic={true}>
|
|
32
34
|
{emptyMessage}
|
|
33
35
|
</Text>
|
|
34
36
|
) : (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
2
2
|
import { useCallback, useState } from 'react';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
|
|
4
5
|
export interface Toast {
|
|
5
6
|
id: number;
|
|
@@ -29,6 +30,7 @@ export function useToast() {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
|
33
|
+
const theme = useTheme();
|
|
32
34
|
const { stdout } = useStdout();
|
|
33
35
|
const columns = stdout.columns;
|
|
34
36
|
|
|
@@ -48,14 +50,14 @@ export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismi
|
|
|
48
50
|
<Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
|
|
49
51
|
<Box flexDirection="column" gap={1}>
|
|
50
52
|
{toasts.map((t) => (
|
|
51
|
-
<Box key={t.id} backgroundColor=
|
|
53
|
+
<Box key={t.id} backgroundColor={theme.toast.background} paddingX={2} paddingY={0} width={maxWidth}>
|
|
52
54
|
<Box flexGrow={1} flexShrink={1}>
|
|
53
|
-
<Text color={t.color ??
|
|
55
|
+
<Text color={t.color ?? theme.toast.defaultColor} wrap="wrap">
|
|
54
56
|
{t.message}
|
|
55
57
|
</Text>
|
|
56
58
|
</Box>
|
|
57
59
|
<Box marginLeft={1} flexShrink={0}>
|
|
58
|
-
<Text color=
|
|
60
|
+
<Text color={theme.toast.closeHint} dimColor={true}>
|
|
59
61
|
[esc]✕
|
|
60
62
|
</Text>
|
|
61
63
|
</Box>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../context/ThemeContext';
|
|
2
3
|
|
|
3
4
|
export interface StatusBarSegment {
|
|
4
5
|
text: string;
|
|
@@ -7,13 +8,19 @@ export interface StatusBarSegment {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
|
|
11
|
+
const theme = useTheme();
|
|
10
12
|
return (
|
|
11
13
|
<Box flexShrink={0} paddingX={1} marginY={1}>
|
|
12
14
|
<Box justifyContent="flex-end" flexGrow={1}>
|
|
13
15
|
{segments.map((seg, i) => (
|
|
14
16
|
// biome-ignore lint/suspicious/noArrayIndexKey: positional static list
|
|
15
17
|
<Box key={i}>
|
|
16
|
-
{i > 0 &&
|
|
18
|
+
{i > 0 && (
|
|
19
|
+
<Text color={theme.status.separator} dimColor={true}>
|
|
20
|
+
{' '}
|
|
21
|
+
·{' '}
|
|
22
|
+
</Text>
|
|
23
|
+
)}
|
|
17
24
|
<Text color={seg.color} dimColor={seg.dim}>
|
|
18
25
|
{seg.text}
|
|
19
26
|
</Text>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput } from 'ink';
|
|
2
2
|
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
import { sanitizeTerminalInput } from '../../input/sanitize';
|
|
4
5
|
import type { DialogRequest, InkUIService } from '../../plugins/InkUIService';
|
|
5
6
|
import { Dropdown } from '../primitives/dropdown';
|
|
@@ -16,6 +17,7 @@ function ConfirmDialog({
|
|
|
16
17
|
onResolve: (value: unknown) => void;
|
|
17
18
|
onCancel: () => void;
|
|
18
19
|
}) {
|
|
20
|
+
const theme = useTheme();
|
|
19
21
|
const [selected, setSelected] = useState(0);
|
|
20
22
|
|
|
21
23
|
useInput((input, key) => {
|
|
@@ -42,15 +44,15 @@ function ConfirmDialog({
|
|
|
42
44
|
</Box>
|
|
43
45
|
)}
|
|
44
46
|
<Box gap={2}>
|
|
45
|
-
<Text color={selected === 0 ?
|
|
47
|
+
<Text color={selected === 0 ? theme.dialog.confirmYes : undefined} bold={selected === 0}>
|
|
46
48
|
{selected === 0 ? '▸ ' : ' '}Yes
|
|
47
49
|
</Text>
|
|
48
|
-
<Text color={selected === 1 ?
|
|
50
|
+
<Text color={selected === 1 ? theme.dialog.confirmNo : undefined} bold={selected === 1}>
|
|
49
51
|
{selected === 1 ? '▸ ' : ' '}No
|
|
50
52
|
</Text>
|
|
51
53
|
</Box>
|
|
52
54
|
<Box marginTop={1}>
|
|
53
|
-
<Text
|
|
55
|
+
<Text color={theme.dialog.hint}>y/n · Enter to confirm · Esc to cancel</Text>
|
|
54
56
|
</Box>
|
|
55
57
|
</Modal>
|
|
56
58
|
);
|
|
@@ -96,6 +98,7 @@ function InputDialog({
|
|
|
96
98
|
onResolve: (value: unknown) => void;
|
|
97
99
|
onCancel: () => void;
|
|
98
100
|
}) {
|
|
101
|
+
const theme = useTheme();
|
|
99
102
|
const [value, setValue] = useState('');
|
|
100
103
|
|
|
101
104
|
useInput((input, key) => {
|
|
@@ -121,12 +124,14 @@ function InputDialog({
|
|
|
121
124
|
<Modal visible={true} title={dialog.title}>
|
|
122
125
|
<Box flexDirection="column">
|
|
123
126
|
<Box paddingX={1} marginBottom={1}>
|
|
124
|
-
{!value && dialog.placeholder && <Text
|
|
127
|
+
{!value && dialog.placeholder && <Text color={theme.dialog.placeholder}>{dialog.placeholder}</Text>}
|
|
125
128
|
{value && <Text>{value}</Text>}
|
|
126
|
-
<Text inverse={true}
|
|
129
|
+
<Text color={theme.dialog.cursor} inverse={true}>
|
|
130
|
+
▎
|
|
131
|
+
</Text>
|
|
127
132
|
</Box>
|
|
128
133
|
<Box>
|
|
129
|
-
<Text
|
|
134
|
+
<Text color={theme.dialog.hint}>Enter to submit · Esc to cancel</Text>
|
|
130
135
|
</Box>
|
|
131
136
|
</Box>
|
|
132
137
|
</Modal>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from 'react';
|
|
2
|
+
import { DEFAULT_THEME } from '../theme/presets';
|
|
3
|
+
import type { Theme } from '../theme/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Theme is read in many small components, so a dedicated context keeps
|
|
7
|
+
* `ChatContext` focused on session/runtime state and avoids re-renders
|
|
8
|
+
* cascading through unrelated subtrees when chat state changes.
|
|
9
|
+
*/
|
|
10
|
+
const ThemeContext = createContext<Theme>(DEFAULT_THEME);
|
|
11
|
+
|
|
12
|
+
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
13
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useTheme(): Theme {
|
|
17
|
+
return useContext(ThemeContext);
|
|
18
|
+
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import type { MentionCompletion } from 'mu-core';
|
|
3
|
+
import { useTheme } from '../context/ThemeContext';
|
|
4
|
+
import type { Theme } from '../theme/types';
|
|
2
5
|
import type { SlashCommand } from './commands';
|
|
3
6
|
|
|
7
|
+
interface MentionPickerView {
|
|
8
|
+
completions: MentionCompletion[];
|
|
9
|
+
selectedIndex: number;
|
|
10
|
+
partial: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export interface InputBoxViewProps {
|
|
5
14
|
value: string;
|
|
15
|
+
cursor: number;
|
|
6
16
|
commands: SlashCommand[];
|
|
7
17
|
cmdIndex: number;
|
|
8
18
|
isCommandMode: boolean;
|
|
@@ -11,9 +21,18 @@ export interface InputBoxViewProps {
|
|
|
11
21
|
model: string;
|
|
12
22
|
attachmentName: string | null;
|
|
13
23
|
attachmentError: string | null;
|
|
24
|
+
mentions: MentionPickerView | null;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
function CommandHints({
|
|
27
|
+
function CommandHints({
|
|
28
|
+
commands,
|
|
29
|
+
selectedIndex,
|
|
30
|
+
theme,
|
|
31
|
+
}: {
|
|
32
|
+
commands: SlashCommand[];
|
|
33
|
+
selectedIndex: number;
|
|
34
|
+
theme: Theme;
|
|
35
|
+
}) {
|
|
17
36
|
if (!commands.length) {
|
|
18
37
|
return null;
|
|
19
38
|
}
|
|
@@ -21,7 +40,7 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
|
|
|
21
40
|
<Box flexDirection="column" marginBottom={1}>
|
|
22
41
|
{commands.map((cmd, i) => (
|
|
23
42
|
<Box key={cmd.name} paddingX={1}>
|
|
24
|
-
<Text color={i === selectedIndex ?
|
|
43
|
+
<Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
|
|
25
44
|
{i === selectedIndex ? '▸ ' : ' '}
|
|
26
45
|
{cmd.name}
|
|
27
46
|
</Text>
|
|
@@ -32,88 +51,176 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
|
|
|
32
51
|
);
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
|
|
55
|
+
if (!mentions.completions.length) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
60
|
+
{mentions.completions.map((c, i) => (
|
|
61
|
+
<Box key={c.value} paddingX={1}>
|
|
62
|
+
<Text
|
|
63
|
+
color={i === mentions.selectedIndex ? theme.input.commandHighlight : undefined}
|
|
64
|
+
bold={i === mentions.selectedIndex}
|
|
65
|
+
>
|
|
66
|
+
{i === mentions.selectedIndex ? '▸ @' : ' @'}
|
|
67
|
+
{c.label ?? c.value}
|
|
68
|
+
</Text>
|
|
69
|
+
{c.description && <Text dimColor={true}> {c.description}</Text>}
|
|
70
|
+
</Box>
|
|
71
|
+
))}
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
35
76
|
function InputFooter({
|
|
36
77
|
model,
|
|
37
78
|
attachmentName,
|
|
38
79
|
attachmentError,
|
|
39
80
|
hasContent,
|
|
40
81
|
isCommandMode,
|
|
82
|
+
hasMentions,
|
|
83
|
+
theme,
|
|
41
84
|
}: {
|
|
42
85
|
model: string;
|
|
43
86
|
attachmentName: string | null;
|
|
44
87
|
attachmentError: string | null;
|
|
45
88
|
hasContent: boolean;
|
|
46
89
|
isCommandMode: boolean;
|
|
90
|
+
hasMentions: boolean;
|
|
91
|
+
theme: Theme;
|
|
47
92
|
}) {
|
|
48
|
-
const hint =
|
|
49
|
-
?
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
const hint = hasMentions
|
|
94
|
+
? '↑↓ select · Tab/Enter accept'
|
|
95
|
+
: hasContent
|
|
96
|
+
? isCommandMode
|
|
97
|
+
? '↑↓ select · Enter execute'
|
|
98
|
+
: 'Enter to send · Shift+Enter for newline · ←→ move'
|
|
99
|
+
: 'Type / for commands · @ for mentions';
|
|
53
100
|
|
|
54
101
|
return (
|
|
55
102
|
<Box justifyContent="space-between">
|
|
56
103
|
<Box gap={1}>
|
|
57
104
|
{model && (
|
|
58
|
-
<Text color=
|
|
105
|
+
<Text color={theme.input.modelLabel} bold={true}>
|
|
59
106
|
{model}
|
|
60
107
|
</Text>
|
|
61
108
|
)}
|
|
62
|
-
{attachmentName && <Text color=
|
|
63
|
-
{attachmentError && <Text color=
|
|
109
|
+
{attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
|
|
110
|
+
{attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
|
|
64
111
|
</Box>
|
|
65
|
-
<Text
|
|
112
|
+
<Text color={theme.input.footerHint}>{hint}</Text>
|
|
66
113
|
</Box>
|
|
67
114
|
);
|
|
68
115
|
}
|
|
69
116
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
117
|
+
interface RowProps {
|
|
118
|
+
line: string;
|
|
119
|
+
cursorCol: number | null;
|
|
120
|
+
isCommandLine: boolean;
|
|
121
|
+
theme: Theme;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render one buffer row, splicing the cursor glyph at `cursorCol` if the
|
|
126
|
+
* cursor lives on this row. Splitting around the cursor (rather than rendering
|
|
127
|
+
* the whole line and overlaying) keeps the layout flowing inside Ink's text
|
|
128
|
+
* wrapping engine.
|
|
129
|
+
*/
|
|
130
|
+
function InputRow({ line, cursorCol, isCommandLine, theme }: RowProps) {
|
|
131
|
+
const colorize = (text: string) => (isCommandLine ? <Text color={theme.input.commandHighlight}>{text}</Text> : text);
|
|
132
|
+
|
|
133
|
+
if (cursorCol === null) {
|
|
134
|
+
return <Text wrap="wrap">{colorize(line)}</Text>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const before = line.slice(0, cursorCol);
|
|
138
|
+
const after = line.slice(cursorCol);
|
|
139
|
+
return (
|
|
140
|
+
<Text wrap="wrap">
|
|
141
|
+
{colorize(before)}
|
|
142
|
+
<Text color={theme.input.cursor} inverse={true}>
|
|
143
|
+
▎
|
|
144
|
+
</Text>
|
|
145
|
+
{colorize(after)}
|
|
146
|
+
</Text>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface DisplayProps {
|
|
76
151
|
value: string;
|
|
152
|
+
cursor: number;
|
|
77
153
|
isCommandMode: boolean;
|
|
78
154
|
streaming: boolean;
|
|
79
155
|
isActive: boolean;
|
|
80
|
-
|
|
156
|
+
theme: Theme;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
|
|
81
160
|
const showCursor = !streaming && isActive;
|
|
82
161
|
if (!value.length) {
|
|
83
|
-
return
|
|
162
|
+
return (
|
|
163
|
+
<Text>
|
|
164
|
+
{showCursor && (
|
|
165
|
+
<Text color={theme.input.cursor} inverse={true}>
|
|
166
|
+
▎
|
|
167
|
+
</Text>
|
|
168
|
+
)}
|
|
169
|
+
</Text>
|
|
170
|
+
);
|
|
84
171
|
}
|
|
85
172
|
const lines = value.split('\n');
|
|
173
|
+
// Locate cursor row/col by walking newline offsets.
|
|
174
|
+
let row = 0;
|
|
175
|
+
let consumed = 0;
|
|
176
|
+
for (let i = 0; i < lines.length; i++) {
|
|
177
|
+
const lineEnd = consumed + lines[i].length;
|
|
178
|
+
if (cursor <= lineEnd) {
|
|
179
|
+
row = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
consumed = lineEnd + 1; // +1 for the newline
|
|
183
|
+
row = i + 1;
|
|
184
|
+
}
|
|
185
|
+
const col = cursor - consumed;
|
|
86
186
|
return (
|
|
87
187
|
<>
|
|
88
188
|
{lines.map((line, i) => (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{i
|
|
92
|
-
|
|
93
|
-
|
|
189
|
+
<InputRow
|
|
190
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
|
|
191
|
+
key={`${i}-${line}`}
|
|
192
|
+
line={line}
|
|
193
|
+
cursorCol={showCursor && i === row ? col : null}
|
|
194
|
+
isCommandLine={i === 0 && isCommandMode}
|
|
195
|
+
theme={theme}
|
|
196
|
+
/>
|
|
94
197
|
))}
|
|
95
198
|
</>
|
|
96
199
|
);
|
|
97
200
|
}
|
|
98
201
|
|
|
99
202
|
export function InputBoxView(props: InputBoxViewProps) {
|
|
203
|
+
const theme = useTheme();
|
|
100
204
|
return (
|
|
101
205
|
<Box
|
|
102
206
|
flexDirection="column"
|
|
103
207
|
flexShrink={0}
|
|
104
|
-
backgroundColor=
|
|
208
|
+
backgroundColor={theme.input.background}
|
|
105
209
|
paddingX={1}
|
|
106
210
|
paddingY={1}
|
|
107
211
|
marginX={1}
|
|
108
212
|
marginTop={1}
|
|
109
213
|
>
|
|
110
|
-
{props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} />}
|
|
214
|
+
{props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
|
|
215
|
+
{props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
|
|
111
216
|
<Box flexDirection="column" minHeight={2}>
|
|
112
217
|
<InputDisplay
|
|
113
218
|
value={props.value}
|
|
219
|
+
cursor={props.cursor}
|
|
114
220
|
isCommandMode={props.isCommandMode}
|
|
115
221
|
streaming={props.streaming}
|
|
116
222
|
isActive={props.isActive}
|
|
223
|
+
theme={theme}
|
|
117
224
|
/>
|
|
118
225
|
</Box>
|
|
119
226
|
<InputFooter
|
|
@@ -122,6 +229,8 @@ export function InputBoxView(props: InputBoxViewProps) {
|
|
|
122
229
|
attachmentError={props.attachmentError}
|
|
123
230
|
hasContent={props.value.length > 0}
|
|
124
231
|
isCommandMode={props.isCommandMode}
|
|
232
|
+
hasMentions={Boolean(props.mentions)}
|
|
233
|
+
theme={theme}
|
|
125
234
|
/>
|
|
126
235
|
</Box>
|
|
127
236
|
);
|
|
@@ -23,7 +23,8 @@ describe('BUILTIN_COMMANDS', () => {
|
|
|
23
23
|
const onTogglePicker = mock(() => undefined);
|
|
24
24
|
const onToggleSessionPicker = mock(() => undefined);
|
|
25
25
|
const onNew = mock(() => undefined);
|
|
26
|
-
const
|
|
26
|
+
const onShowContext = mock(() => undefined);
|
|
27
|
+
const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
|
|
27
28
|
|
|
28
29
|
for (const cmd of BUILTIN_COMMANDS) {
|
|
29
30
|
cmd.invoke?.(actions);
|
|
@@ -32,6 +33,7 @@ describe('BUILTIN_COMMANDS', () => {
|
|
|
32
33
|
expect(onTogglePicker).toHaveBeenCalledTimes(1);
|
|
33
34
|
expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
|
|
34
35
|
expect(onNew).toHaveBeenCalledTimes(1);
|
|
36
|
+
expect(onShowContext).toHaveBeenCalledTimes(1);
|
|
35
37
|
});
|
|
36
38
|
});
|
|
37
39
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-
|
|
1
|
+
import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
|
|
2
2
|
import type { InputActions } from './useInputHandler';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -20,6 +20,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
|
|
|
20
20
|
{ name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
|
|
21
21
|
{ name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
|
|
22
22
|
{ name: '/new', description: 'New conversation', invoke: (a) => a.onNew?.() },
|
|
23
|
+
{
|
|
24
|
+
name: '/context',
|
|
25
|
+
description: 'Show the LLM context (system prompt, messages, tools) as plain text',
|
|
26
|
+
invoke: (a) => a.onShowContext?.(),
|
|
27
|
+
},
|
|
23
28
|
];
|
|
24
29
|
|
|
25
30
|
export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
|