mu-coding 0.15.0 → 0.16.1
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 +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -120
- package/src/tui/chat/useChatSession.ts +0 -384
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -85
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -66
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { Box, Text, useInput } from 'ink';
|
|
2
|
-
import { useMemo, useState } from 'react';
|
|
3
|
-
import { useTheme } from '../../context/ThemeContext';
|
|
4
|
-
import { sanitizeTerminalInput } from '../../input/sanitize';
|
|
5
|
-
|
|
6
|
-
interface DropdownItem {
|
|
7
|
-
label: string;
|
|
8
|
-
value: string;
|
|
9
|
-
description?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function matches(query: string) {
|
|
13
|
-
const q = query.toLowerCase();
|
|
14
|
-
return (item: DropdownItem) =>
|
|
15
|
-
item.label.toLowerCase().includes(q) ||
|
|
16
|
-
item.value.toLowerCase().includes(q) ||
|
|
17
|
-
item.description?.toLowerCase().includes(q);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface DropdownProps {
|
|
21
|
-
items: DropdownItem[];
|
|
22
|
-
placeholder?: string;
|
|
23
|
-
maxVisible?: number;
|
|
24
|
-
onSelect: (item: DropdownItem) => void;
|
|
25
|
-
onCancel?: () => void;
|
|
26
|
-
isActive?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function Dropdown({
|
|
30
|
-
items,
|
|
31
|
-
placeholder = 'Search...',
|
|
32
|
-
maxVisible = 8,
|
|
33
|
-
onSelect,
|
|
34
|
-
onCancel,
|
|
35
|
-
isActive = true,
|
|
36
|
-
}: DropdownProps) {
|
|
37
|
-
const theme = useTheme();
|
|
38
|
-
const [query, setQuery] = useState('');
|
|
39
|
-
const [index, setIndex] = useState(0);
|
|
40
|
-
|
|
41
|
-
const filtered = useMemo(() => (query ? items.filter(matches(query)) : items), [items, query]);
|
|
42
|
-
|
|
43
|
-
const visibleStart = Math.max(0, Math.min(index - Math.floor(maxVisible / 2), filtered.length - maxVisible));
|
|
44
|
-
const visibleItems = filtered.slice(visibleStart, visibleStart + maxVisible);
|
|
45
|
-
|
|
46
|
-
useInput(
|
|
47
|
-
(input, key) => {
|
|
48
|
-
if (!isActive) return;
|
|
49
|
-
// Tab is reserved for the input box's "insert two spaces" binding when
|
|
50
|
-
// dropdowns are not focused; inside a focused dropdown we ignore it
|
|
51
|
-
// rather than risk inserting whitespace into the query.
|
|
52
|
-
if (key.tab) return;
|
|
53
|
-
if (key.escape) {
|
|
54
|
-
onCancel?.();
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (key.return && filtered[index]) {
|
|
58
|
-
onSelect(filtered[index]);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (key.upArrow) {
|
|
62
|
-
setIndex((i) => Math.max(0, i - 1));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (key.downArrow) {
|
|
66
|
-
setIndex((i) => Math.min(filtered.length - 1, i + 1));
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (key.backspace) {
|
|
70
|
-
setQuery((q) => q.slice(0, -1));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
// Accept multi-char input (paste) into the filter; strip control bytes
|
|
74
|
-
// and any SGR mouse sequences that may leak through. Single-line: drop \t/\n.
|
|
75
|
-
if (input) {
|
|
76
|
-
const clean = sanitizeTerminalInput(input).replace(/[\t\n]/g, '');
|
|
77
|
-
if (clean) setQuery((q) => q + clean);
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
{ isActive },
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
function renderResults() {
|
|
84
|
-
if (filtered.length === 0) {
|
|
85
|
-
return (
|
|
86
|
-
<Box paddingX={1}>
|
|
87
|
-
<Text color={theme.dropdown.empty} italic={true}>
|
|
88
|
-
No results
|
|
89
|
-
</Text>
|
|
90
|
-
</Box>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
return visibleItems.map((item, i) => {
|
|
94
|
-
const isSel = i === index - visibleStart;
|
|
95
|
-
const color = isSel ? theme.dropdown.selected : undefined;
|
|
96
|
-
return (
|
|
97
|
-
<Box key={item.value} paddingX={1}>
|
|
98
|
-
<Text color={color} bold={isSel}>
|
|
99
|
-
{isSel && '▸ '}
|
|
100
|
-
{item.label}
|
|
101
|
-
{item.description && <Text dimColor={true}> {item.description}</Text>}
|
|
102
|
-
</Text>
|
|
103
|
-
</Box>
|
|
104
|
-
);
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return (
|
|
109
|
-
<Box flexDirection="column">
|
|
110
|
-
<Box paddingX={1} marginBottom={1}>
|
|
111
|
-
<Text color={theme.dropdown.placeholder}>{placeholder} </Text>
|
|
112
|
-
<Text>{query}</Text>
|
|
113
|
-
<Text color={theme.dropdown.cursor} inverse={true}>
|
|
114
|
-
▎
|
|
115
|
-
</Text>
|
|
116
|
-
</Box>
|
|
117
|
-
{renderResults()}
|
|
118
|
-
{filtered.length > maxVisible && (
|
|
119
|
-
<Box paddingX={1} marginTop={1}>
|
|
120
|
-
<Text dimColor={true}>{filtered.length} items · ↑↓ navigate · Enter select</Text>
|
|
121
|
-
</Box>
|
|
122
|
-
)}
|
|
123
|
-
</Box>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { Box, Text, useStdout } from 'ink';
|
|
2
|
-
import type { ReactNode } from 'react';
|
|
3
|
-
import { useTheme } from '../../context/ThemeContext';
|
|
4
|
-
|
|
5
|
-
interface ModalProps {
|
|
6
|
-
visible: boolean;
|
|
7
|
-
title?: string;
|
|
8
|
-
width?: number;
|
|
9
|
-
children: ReactNode;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function Modal({ visible, title, width: requestedWidth, children }: ModalProps) {
|
|
13
|
-
const theme = useTheme();
|
|
14
|
-
const { stdout } = useStdout();
|
|
15
|
-
const columns = stdout.columns;
|
|
16
|
-
const rows = stdout.rows;
|
|
17
|
-
|
|
18
|
-
if (!visible) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const modalWidth = requestedWidth ?? Math.min(60, columns - 4);
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<Box
|
|
26
|
-
position="absolute"
|
|
27
|
-
flexDirection="column"
|
|
28
|
-
justifyContent="center"
|
|
29
|
-
alignItems="center"
|
|
30
|
-
width={columns}
|
|
31
|
-
height={rows}
|
|
32
|
-
top={0}
|
|
33
|
-
left={0}
|
|
34
|
-
>
|
|
35
|
-
<Box flexDirection="column" width={modalWidth} backgroundColor={theme.modal.background} paddingX={2} paddingY={1}>
|
|
36
|
-
{title && (
|
|
37
|
-
<Box marginBottom={1}>
|
|
38
|
-
<Text bold={true}>{title}</Text>
|
|
39
|
-
<Box flexGrow={1} />
|
|
40
|
-
<Text color={theme.modal.hint}>Esc to close</Text>
|
|
41
|
-
</Box>
|
|
42
|
-
)}
|
|
43
|
-
{children}
|
|
44
|
-
</Box>
|
|
45
|
-
</Box>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { Text } from 'ink';
|
|
2
|
-
import { useTheme } from '../../context/ThemeContext';
|
|
3
|
-
import { Dropdown } from './dropdown';
|
|
4
|
-
import { Modal } from './modal';
|
|
5
|
-
|
|
6
|
-
interface PickerItem {
|
|
7
|
-
label: string;
|
|
8
|
-
value: string;
|
|
9
|
-
description?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function PickerModal({
|
|
13
|
-
visible,
|
|
14
|
-
title,
|
|
15
|
-
items,
|
|
16
|
-
placeholder,
|
|
17
|
-
emptyMessage,
|
|
18
|
-
onSelect,
|
|
19
|
-
onCancel,
|
|
20
|
-
}: {
|
|
21
|
-
visible: boolean;
|
|
22
|
-
title: string;
|
|
23
|
-
items: PickerItem[];
|
|
24
|
-
placeholder: string;
|
|
25
|
-
emptyMessage?: string;
|
|
26
|
-
onSelect: (value: string) => void;
|
|
27
|
-
onCancel?: () => void;
|
|
28
|
-
}) {
|
|
29
|
-
const theme = useTheme();
|
|
30
|
-
return (
|
|
31
|
-
<Modal visible={visible} title={title}>
|
|
32
|
-
{items.length === 0 && emptyMessage ? (
|
|
33
|
-
<Text color={theme.dropdown.empty} italic={true}>
|
|
34
|
-
{emptyMessage}
|
|
35
|
-
</Text>
|
|
36
|
-
) : (
|
|
37
|
-
<Dropdown
|
|
38
|
-
items={items}
|
|
39
|
-
placeholder={placeholder}
|
|
40
|
-
isActive={visible}
|
|
41
|
-
onSelect={(item) => onSelect(item.value)}
|
|
42
|
-
onCancel={onCancel}
|
|
43
|
-
/>
|
|
44
|
-
)}
|
|
45
|
-
</Modal>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
2
|
-
|
|
3
|
-
export function Scrollbar({
|
|
4
|
-
viewHeight,
|
|
5
|
-
contentHeight,
|
|
6
|
-
scrollOffset,
|
|
7
|
-
}: {
|
|
8
|
-
viewHeight: number;
|
|
9
|
-
contentHeight: number;
|
|
10
|
-
scrollOffset: number;
|
|
11
|
-
}) {
|
|
12
|
-
if (contentHeight <= viewHeight || viewHeight < 1) {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
const maxScroll = contentHeight - viewHeight;
|
|
16
|
-
const ratio = scrollOffset / maxScroll;
|
|
17
|
-
const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
|
|
18
|
-
const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
|
|
19
|
-
|
|
20
|
-
const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<Box flexDirection="column" flexShrink={0} width={1}>
|
|
24
|
-
<Text>{track.join('')}</Text>
|
|
25
|
-
</Box>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { Box, Text, useInput, useStdout } from 'ink';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { useTheme } from '../../context/ThemeContext';
|
|
4
|
-
|
|
5
|
-
export interface Toast {
|
|
6
|
-
id: number;
|
|
7
|
-
message: string;
|
|
8
|
-
color?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const TOAST_TIMEOUT_MS = 60_000;
|
|
12
|
-
|
|
13
|
-
let nextId = 0;
|
|
14
|
-
|
|
15
|
-
export function useToast() {
|
|
16
|
-
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
17
|
-
const timersRef = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
|
18
|
-
|
|
19
|
-
const dismiss = useCallback((id: number) => {
|
|
20
|
-
const timer = timersRef.current.get(id);
|
|
21
|
-
if (timer) {
|
|
22
|
-
clearTimeout(timer);
|
|
23
|
-
timersRef.current.delete(id);
|
|
24
|
-
}
|
|
25
|
-
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
26
|
-
}, []);
|
|
27
|
-
|
|
28
|
-
const show = useCallback(
|
|
29
|
-
(message: string, color?: string) => {
|
|
30
|
-
const id = nextId++;
|
|
31
|
-
setToasts((prev) => [...prev, { id, message, color }]);
|
|
32
|
-
const timer = setTimeout(() => dismiss(id), TOAST_TIMEOUT_MS);
|
|
33
|
-
timersRef.current.set(id, timer);
|
|
34
|
-
},
|
|
35
|
-
[dismiss],
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
const dismissFirst = useCallback(() => {
|
|
39
|
-
setToasts((prev) => {
|
|
40
|
-
const [first, ...rest] = prev;
|
|
41
|
-
if (first) {
|
|
42
|
-
const timer = timersRef.current.get(first.id);
|
|
43
|
-
if (timer) {
|
|
44
|
-
clearTimeout(timer);
|
|
45
|
-
timersRef.current.delete(first.id);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return rest;
|
|
49
|
-
});
|
|
50
|
-
}, []);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
const timers = timersRef.current;
|
|
54
|
-
return () => {
|
|
55
|
-
for (const timer of timers.values()) clearTimeout(timer);
|
|
56
|
-
timers.clear();
|
|
57
|
-
};
|
|
58
|
-
}, []);
|
|
59
|
-
|
|
60
|
-
return { toasts, show, dismiss, dismissFirst };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
|
64
|
-
const theme = useTheme();
|
|
65
|
-
const { stdout } = useStdout();
|
|
66
|
-
const columns = stdout.columns;
|
|
67
|
-
|
|
68
|
-
useInput((_input, key) => {
|
|
69
|
-
if (toasts.length > 0 && key.escape) {
|
|
70
|
-
onDismiss(toasts[0].id);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
if (toasts.length === 0) {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const maxWidth = Math.min(60, columns - 4);
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
|
|
82
|
-
<Box flexDirection="column" gap={1}>
|
|
83
|
-
{toasts.map((t) => (
|
|
84
|
-
<Box key={t.id} backgroundColor={theme.toast.background} paddingX={2} paddingY={0} width={maxWidth}>
|
|
85
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
86
|
-
<Text color={t.color ?? theme.toast.defaultColor} wrap="wrap">
|
|
87
|
-
{t.message}
|
|
88
|
-
</Text>
|
|
89
|
-
</Box>
|
|
90
|
-
<Box marginLeft={1} flexShrink={0}>
|
|
91
|
-
<Text color={theme.toast.closeHint} dimColor={true}>
|
|
92
|
-
[esc]✕
|
|
93
|
-
</Text>
|
|
94
|
-
</Box>
|
|
95
|
-
</Box>
|
|
96
|
-
))}
|
|
97
|
-
</Box>
|
|
98
|
-
</Box>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
2
|
-
import { useTheme } from '../context/ThemeContext';
|
|
3
|
-
|
|
4
|
-
export interface StatusBarSegment {
|
|
5
|
-
text: string;
|
|
6
|
-
color?: string;
|
|
7
|
-
dim?: boolean;
|
|
8
|
-
/** Pin to the left zone of the status bar. Defaults to right-aligned. */
|
|
9
|
-
align?: 'left' | 'right';
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function renderZone(segments: StatusBarSegment[], separatorColor: string) {
|
|
13
|
-
return segments.map((seg, i) => (
|
|
14
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: positional static list
|
|
15
|
-
<Box key={i}>
|
|
16
|
-
{i > 0 && (
|
|
17
|
-
<Text color={separatorColor} dimColor={true}>
|
|
18
|
-
{' '}
|
|
19
|
-
·{' '}
|
|
20
|
-
</Text>
|
|
21
|
-
)}
|
|
22
|
-
<Text color={seg.color} dimColor={seg.dim}>
|
|
23
|
-
{seg.text}
|
|
24
|
-
</Text>
|
|
25
|
-
</Box>
|
|
26
|
-
));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
|
|
30
|
-
const theme = useTheme();
|
|
31
|
-
const left = segments.filter((s) => s.align === 'left');
|
|
32
|
-
const right = segments.filter((s) => s.align !== 'left');
|
|
33
|
-
return (
|
|
34
|
-
<Box flexShrink={0} paddingX={1} marginTop={1}>
|
|
35
|
-
<Box>{renderZone(left, theme.status.separator)}</Box>
|
|
36
|
-
<Box justifyContent="flex-end" flexGrow={1}>
|
|
37
|
-
{renderZone(right, theme.status.separator)}
|
|
38
|
-
</Box>
|
|
39
|
-
</Box>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { Box, Text, useInput } from 'ink';
|
|
2
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
-
import { useTheme } from '../../context/ThemeContext';
|
|
4
|
-
import { sanitizeTerminalInput } from '../../input/sanitize';
|
|
5
|
-
import type { DialogRequest, InkUIService } from '../../plugins/InkUIService';
|
|
6
|
-
import { Dropdown } from '../primitives/dropdown';
|
|
7
|
-
import { Modal } from '../primitives/modal';
|
|
8
|
-
|
|
9
|
-
// ─── Confirm Dialog ───────────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
function ConfirmDialog({
|
|
12
|
-
dialog,
|
|
13
|
-
onResolve,
|
|
14
|
-
onCancel,
|
|
15
|
-
}: {
|
|
16
|
-
dialog: DialogRequest;
|
|
17
|
-
onResolve: (value: unknown) => void;
|
|
18
|
-
onCancel: () => void;
|
|
19
|
-
}) {
|
|
20
|
-
const theme = useTheme();
|
|
21
|
-
const [selected, setSelected] = useState(0);
|
|
22
|
-
|
|
23
|
-
useInput((input, key) => {
|
|
24
|
-
if (key.escape) {
|
|
25
|
-
onCancel();
|
|
26
|
-
} else if (key.return) {
|
|
27
|
-
onResolve(selected === 0);
|
|
28
|
-
} else if (key.leftArrow || input === 'h') {
|
|
29
|
-
setSelected(0);
|
|
30
|
-
} else if (key.rightArrow || input === 'l') {
|
|
31
|
-
setSelected(1);
|
|
32
|
-
} else if (input === 'y' || input === 'Y') {
|
|
33
|
-
onResolve(true);
|
|
34
|
-
} else if (input === 'n' || input === 'N') {
|
|
35
|
-
onResolve(false);
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<Modal visible={true} title={dialog.title}>
|
|
41
|
-
{dialog.message && (
|
|
42
|
-
<Box marginBottom={1}>
|
|
43
|
-
<Text>{dialog.message}</Text>
|
|
44
|
-
</Box>
|
|
45
|
-
)}
|
|
46
|
-
<Box gap={2}>
|
|
47
|
-
<Text color={selected === 0 ? theme.dialog.confirmYes : undefined} bold={selected === 0}>
|
|
48
|
-
{selected === 0 ? '▸ ' : ' '}Yes
|
|
49
|
-
</Text>
|
|
50
|
-
<Text color={selected === 1 ? theme.dialog.confirmNo : undefined} bold={selected === 1}>
|
|
51
|
-
{selected === 1 ? '▸ ' : ' '}No
|
|
52
|
-
</Text>
|
|
53
|
-
</Box>
|
|
54
|
-
<Box marginTop={1}>
|
|
55
|
-
<Text color={theme.dialog.hint}>y/n · Enter to confirm · Esc to cancel</Text>
|
|
56
|
-
</Box>
|
|
57
|
-
</Modal>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ─── Select Dialog ────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
function SelectDialog({
|
|
64
|
-
dialog,
|
|
65
|
-
onResolve,
|
|
66
|
-
onCancel,
|
|
67
|
-
}: {
|
|
68
|
-
dialog: DialogRequest;
|
|
69
|
-
onResolve: (value: unknown) => void;
|
|
70
|
-
onCancel: () => void;
|
|
71
|
-
}) {
|
|
72
|
-
const items = (dialog.options ?? []).map((opt) => ({
|
|
73
|
-
label: opt,
|
|
74
|
-
value: opt,
|
|
75
|
-
}));
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<Modal visible={true} title={dialog.title}>
|
|
79
|
-
<Dropdown items={items} placeholder="Filter..." onSelect={(item) => onResolve(item.value)} onCancel={onCancel} />
|
|
80
|
-
</Modal>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ─── Input Dialog ─────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
function sanitizeDialogInput(text: string): string {
|
|
87
|
-
// Strip mouse sequences + control bytes via the shared helper, then drop
|
|
88
|
-
// \t/\n that the shared helper preserves — this dialog is single-line.
|
|
89
|
-
return sanitizeTerminalInput(text).replace(/[\t\n]/g, '');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function InputDialog({
|
|
93
|
-
dialog,
|
|
94
|
-
onResolve,
|
|
95
|
-
onCancel,
|
|
96
|
-
}: {
|
|
97
|
-
dialog: DialogRequest;
|
|
98
|
-
onResolve: (value: unknown) => void;
|
|
99
|
-
onCancel: () => void;
|
|
100
|
-
}) {
|
|
101
|
-
const theme = useTheme();
|
|
102
|
-
const [value, setValue] = useState('');
|
|
103
|
-
|
|
104
|
-
useInput((input, key) => {
|
|
105
|
-
if (key.escape) {
|
|
106
|
-
onCancel();
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (key.return) {
|
|
110
|
-
onResolve(value || null);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (key.backspace || key.delete) {
|
|
114
|
-
setValue((v) => v.slice(0, -1));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
const insert = sanitizeDialogInput(input);
|
|
118
|
-
if (insert) {
|
|
119
|
-
setValue((v) => v + insert);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<Modal visible={true} title={dialog.title}>
|
|
125
|
-
<Box flexDirection="column">
|
|
126
|
-
<Box paddingX={1} marginBottom={1}>
|
|
127
|
-
{!value && dialog.placeholder && <Text color={theme.dialog.placeholder}>{dialog.placeholder}</Text>}
|
|
128
|
-
{value && <Text>{value}</Text>}
|
|
129
|
-
<Text color={theme.dialog.cursor} inverse={true}>
|
|
130
|
-
▎
|
|
131
|
-
</Text>
|
|
132
|
-
</Box>
|
|
133
|
-
<Box>
|
|
134
|
-
<Text color={theme.dialog.hint}>Enter to submit · Esc to cancel</Text>
|
|
135
|
-
</Box>
|
|
136
|
-
</Box>
|
|
137
|
-
</Modal>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ─── Dialog Layer ─────────────────────────────────────────────────────────────
|
|
142
|
-
|
|
143
|
-
export function DialogLayer({ service }: { service: InkUIService }) {
|
|
144
|
-
const [dialog, setDialog] = useState<DialogRequest | null>(service.currentDialog());
|
|
145
|
-
|
|
146
|
-
useEffect(() => {
|
|
147
|
-
return service.subscribe(() => {
|
|
148
|
-
setDialog(service.currentDialog());
|
|
149
|
-
});
|
|
150
|
-
}, [service]);
|
|
151
|
-
|
|
152
|
-
const handleResolve = useCallback(
|
|
153
|
-
(value: unknown) => {
|
|
154
|
-
service.resolveDialog(value);
|
|
155
|
-
},
|
|
156
|
-
[service],
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
const handleCancel = useCallback(() => {
|
|
160
|
-
service.cancelDialog();
|
|
161
|
-
}, [service]);
|
|
162
|
-
|
|
163
|
-
if (!dialog) return null;
|
|
164
|
-
|
|
165
|
-
switch (dialog.type) {
|
|
166
|
-
case 'confirm':
|
|
167
|
-
return <ConfirmDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
168
|
-
case 'select':
|
|
169
|
-
return <SelectDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
170
|
-
case 'input':
|
|
171
|
-
return <InputDialog dialog={dialog} onResolve={handleResolve} onCancel={handleCancel} />;
|
|
172
|
-
default:
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Two-key emacs-style chord prefix.
|
|
3
|
-
*
|
|
4
|
-
* `useChordKeyboard` takes a prefix predicate (e.g. "Ctrl+X") and a map of
|
|
5
|
-
* follow-up handlers. Pressing the prefix arms a chord state for
|
|
6
|
-
* `timeoutMs` (default 1000); the next key event dispatches to the
|
|
7
|
-
* matching handler, or — if nothing matches before the timer fires — the
|
|
8
|
-
* chord is dropped silently.
|
|
9
|
-
*
|
|
10
|
-
* Integrates with Ink's `useInput` so it cooperates with the rest of the
|
|
11
|
-
* keyboard pipeline; keys consumed while armed are swallowed regardless of
|
|
12
|
-
* whether they matched a follow-up handler, so a stray `g` after `Ctrl+X`
|
|
13
|
-
* does not leak into the chat input.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { type Key, useInput } from 'ink';
|
|
17
|
-
import { useEffect, useRef } from 'react';
|
|
18
|
-
|
|
19
|
-
export interface ChordKey {
|
|
20
|
-
/** Lower-case input character, when the press produced one. */
|
|
21
|
-
input: string;
|
|
22
|
-
/** Modifiers / arrow keys provided by Ink. */
|
|
23
|
-
key: Key;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type ChordPredicate = (k: ChordKey) => boolean;
|
|
27
|
-
export type ChordHandler = () => void;
|
|
28
|
-
|
|
29
|
-
export interface ChordSpec {
|
|
30
|
-
/** Predicate matching the prefix (e.g. `({key, input}) => key.ctrl && input === 'x'`). */
|
|
31
|
-
prefix: ChordPredicate;
|
|
32
|
-
/**
|
|
33
|
-
* Follow-up handlers. The first matching predicate (by insertion order)
|
|
34
|
-
* runs; non-matching follow-ups still consume the key and clear the
|
|
35
|
-
* armed state — i.e. the chord is "spent" on any keypress.
|
|
36
|
-
*/
|
|
37
|
-
followUps: Array<{
|
|
38
|
-
match: ChordPredicate;
|
|
39
|
-
handler: ChordHandler;
|
|
40
|
-
}>;
|
|
41
|
-
/** When false, the hook is dormant. Defaults to `true`. */
|
|
42
|
-
isActive?: boolean;
|
|
43
|
-
/** Window after the prefix during which a follow-up is accepted. */
|
|
44
|
-
timeoutMs?: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function useChordKeyboard(spec: ChordSpec): void {
|
|
48
|
-
const armedRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
|
-
const timeoutMs = spec.timeoutMs ?? 1000;
|
|
50
|
-
|
|
51
|
-
// Clear any pending timer if the component using the hook unmounts mid-chord.
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
return () => {
|
|
54
|
-
if (armedRef.current) {
|
|
55
|
-
clearTimeout(armedRef.current);
|
|
56
|
-
armedRef.current = null;
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
}, []);
|
|
60
|
-
|
|
61
|
-
useInput(
|
|
62
|
-
(input, key) => {
|
|
63
|
-
const event: ChordKey = { input, key };
|
|
64
|
-
|
|
65
|
-
if (armedRef.current) {
|
|
66
|
-
// We're inside the chord window. Any keypress consumes the chord;
|
|
67
|
-
// dispatch when one of the follow-ups matches.
|
|
68
|
-
clearTimeout(armedRef.current);
|
|
69
|
-
armedRef.current = null;
|
|
70
|
-
for (const fu of spec.followUps) {
|
|
71
|
-
if (fu.match(event)) {
|
|
72
|
-
fu.handler();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (spec.prefix(event)) {
|
|
80
|
-
armedRef.current = setTimeout(() => {
|
|
81
|
-
armedRef.current = null;
|
|
82
|
-
}, timeoutMs);
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
{ isActive: spec.isActive ?? true },
|
|
86
|
-
);
|
|
87
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { InputInfoSegment } from 'mu-core';
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
3
|
-
import { useChatContext } from '../chat/ChatContext';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Subscribe to the aggregated input-info segments published by plugins via
|
|
7
|
-
* `PluginContext.setInputInfo`. Returns the live snapshot; re-renders on
|
|
8
|
-
* every push from any plugin.
|
|
9
|
-
*/
|
|
10
|
-
export function useInputInfoSegments(): InputInfoSegment[] {
|
|
11
|
-
const { registry } = useChatContext();
|
|
12
|
-
const [segments, setSegments] = useState<InputInfoSegment[]>(() => registry.getInputInfoSegments());
|
|
13
|
-
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
const unsub = registry.onInputInfoChange(() => {
|
|
16
|
-
setSegments(registry.getInputInfoSegments());
|
|
17
|
-
});
|
|
18
|
-
return unsub;
|
|
19
|
-
}, [registry]);
|
|
20
|
-
|
|
21
|
-
return segments;
|
|
22
|
-
}
|