mu-coding 0.5.0 → 0.9.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/cli/install.ts +18 -3
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +124 -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 +147 -0
- package/src/runtime/createRegistry.ts +160 -23
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +59 -15
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +62 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +38 -3
- package/src/tui/chat/useChatPanel.ts +29 -6
- package/src/tui/chat/useChatSession.ts +324 -57
- package/src/tui/chat/useModels.ts +26 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +48 -21
- package/src/tui/chat/useStatusSegments.ts +38 -5
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +25 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +17 -9
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +8 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +55 -7
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +10 -6
- package/src/tui/components/messages/streamingOutput.tsx +6 -2
- package/src/tui/components/messages/toolCallBlock.tsx +7 -6
- package/src/tui/components/messages/userMessage.tsx +22 -7
- 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 +43 -10
- package/src/tui/components/statusBar.tsx +26 -10
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +191 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +11 -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 +160 -15
- package/src/tui/input/useInputHandler.ts +317 -126
- package/src/tui/input/useMentionPicker.ts +133 -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 +47 -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 +90 -0
- package/src/tui/theme/types.ts +138 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -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
|
-
import { useCallback, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
4
|
|
|
4
5
|
export interface Toast {
|
|
5
6
|
id: number;
|
|
@@ -7,28 +8,60 @@ export interface Toast {
|
|
|
7
8
|
color?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
const TOAST_TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
10
13
|
let nextId = 0;
|
|
11
14
|
|
|
12
15
|
export function useToast() {
|
|
13
16
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
14
|
-
|
|
15
|
-
const show = useCallback((message: string, color?: string) => {
|
|
16
|
-
const id = nextId++;
|
|
17
|
-
setToasts((prev) => [...prev, { id, message, color }]);
|
|
18
|
-
}, []);
|
|
17
|
+
const timersRef = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
|
19
18
|
|
|
20
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
|
+
}
|
|
21
25
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
22
26
|
}, []);
|
|
23
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
|
+
|
|
24
38
|
const dismissFirst = useCallback(() => {
|
|
25
|
-
setToasts((prev) =>
|
|
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
|
+
};
|
|
26
58
|
}, []);
|
|
27
59
|
|
|
28
60
|
return { toasts, show, dismiss, dismissFirst };
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
|
64
|
+
const theme = useTheme();
|
|
32
65
|
const { stdout } = useStdout();
|
|
33
66
|
const columns = stdout.columns;
|
|
34
67
|
|
|
@@ -48,14 +81,14 @@ export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismi
|
|
|
48
81
|
<Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
|
|
49
82
|
<Box flexDirection="column" gap={1}>
|
|
50
83
|
{toasts.map((t) => (
|
|
51
|
-
<Box key={t.id} backgroundColor=
|
|
84
|
+
<Box key={t.id} backgroundColor={theme.toast.background} paddingX={2} paddingY={0} width={maxWidth}>
|
|
52
85
|
<Box flexGrow={1} flexShrink={1}>
|
|
53
|
-
<Text color={t.color ??
|
|
86
|
+
<Text color={t.color ?? theme.toast.defaultColor} wrap="wrap">
|
|
54
87
|
{t.message}
|
|
55
88
|
</Text>
|
|
56
89
|
</Box>
|
|
57
90
|
<Box marginLeft={1} flexShrink={0}>
|
|
58
|
-
<Text color=
|
|
91
|
+
<Text color={theme.toast.closeHint} dimColor={true}>
|
|
59
92
|
[esc]✕
|
|
60
93
|
</Text>
|
|
61
94
|
</Box>
|
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../context/ThemeContext';
|
|
2
3
|
|
|
3
4
|
export interface StatusBarSegment {
|
|
4
5
|
text: string;
|
|
5
6
|
color?: string;
|
|
6
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
|
+
));
|
|
7
27
|
}
|
|
8
28
|
|
|
9
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');
|
|
10
33
|
return (
|
|
11
|
-
<Box flexShrink={0} paddingX={1}
|
|
34
|
+
<Box flexShrink={0} paddingX={1} marginTop={1}>
|
|
35
|
+
<Box>{renderZone(left, theme.status.separator)}</Box>
|
|
12
36
|
<Box justifyContent="flex-end" flexGrow={1}>
|
|
13
|
-
{
|
|
14
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: positional static list
|
|
15
|
-
<Box key={i}>
|
|
16
|
-
{i > 0 && <Text dimColor={true}> · </Text>}
|
|
17
|
-
<Text color={seg.color} dimColor={seg.dim}>
|
|
18
|
-
{seg.text}
|
|
19
|
-
</Text>
|
|
20
|
-
</Box>
|
|
21
|
-
))}
|
|
37
|
+
{renderZone(right, theme.status.separator)}
|
|
22
38
|
</Box>
|
|
23
39
|
</Box>
|
|
24
40
|
);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
}
|