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.
Files changed (84) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/cli/install.ts +18 -3
  8. package/src/config/index.test.ts +26 -0
  9. package/src/config/index.ts +25 -7
  10. package/src/plugin.ts +124 -0
  11. package/src/runtime/codingTools/bash.ts +114 -0
  12. package/src/runtime/codingTools/edit-file.ts +60 -0
  13. package/src/runtime/codingTools/index.ts +39 -0
  14. package/src/runtime/codingTools/read-file.ts +83 -0
  15. package/src/runtime/codingTools/utils.ts +21 -0
  16. package/src/runtime/codingTools/write-file.ts +42 -0
  17. package/src/runtime/createRegistry.test.ts +147 -0
  18. package/src/runtime/createRegistry.ts +160 -23
  19. package/src/runtime/fileMentionProvider.ts +116 -0
  20. package/src/runtime/messageBus.test.ts +62 -0
  21. package/src/runtime/messageBus.ts +78 -0
  22. package/src/runtime/pluginLoader.ts +59 -15
  23. package/src/sessions/index.ts +2 -9
  24. package/src/tui/channel/tuiChannel.test.ts +107 -0
  25. package/src/tui/channel/tuiChannel.ts +62 -0
  26. package/src/tui/chat/MessageRendererContext.ts +44 -0
  27. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  28. package/src/tui/chat/useAbort.ts +5 -0
  29. package/src/tui/chat/useAttachment.ts +1 -1
  30. package/src/tui/chat/useChat.ts +38 -3
  31. package/src/tui/chat/useChatPanel.ts +29 -6
  32. package/src/tui/chat/useChatSession.ts +324 -57
  33. package/src/tui/chat/useModels.ts +26 -1
  34. package/src/tui/chat/usePluginStatus.ts +1 -1
  35. package/src/tui/chat/useSessionPersistence.ts +48 -21
  36. package/src/tui/chat/useStatusSegments.ts +38 -5
  37. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  38. package/src/tui/components/chat/ChatPanel.tsx +25 -4
  39. package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
  40. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  41. package/src/tui/components/messageView.tsx +4 -2
  42. package/src/tui/components/messages/EditOutput.tsx +17 -9
  43. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  44. package/src/tui/components/messages/ToolHeader.tsx +8 -4
  45. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  46. package/src/tui/components/messages/assistantMessage.tsx +55 -7
  47. package/src/tui/components/messages/markdown.tsx +402 -0
  48. package/src/tui/components/messages/messageItem.tsx +19 -1
  49. package/src/tui/components/messages/reasoningBlock.tsx +10 -6
  50. package/src/tui/components/messages/streamingOutput.tsx +6 -2
  51. package/src/tui/components/messages/toolCallBlock.tsx +7 -6
  52. package/src/tui/components/messages/userMessage.tsx +22 -7
  53. package/src/tui/components/primitives/dropdown.tsx +8 -4
  54. package/src/tui/components/primitives/modal.tsx +4 -2
  55. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  56. package/src/tui/components/primitives/toast.tsx +43 -10
  57. package/src/tui/components/statusBar.tsx +26 -10
  58. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  59. package/src/tui/context/ThemeContext.tsx +18 -0
  60. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  61. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  62. package/src/tui/input/InputBoxView.tsx +191 -26
  63. package/src/tui/input/commands.test.ts +3 -1
  64. package/src/tui/input/commands.ts +11 -1
  65. package/src/tui/input/cursor.test.ts +136 -0
  66. package/src/tui/input/cursor.ts +214 -0
  67. package/src/tui/input/dumpContext.ts +107 -0
  68. package/src/tui/input/sanitize.ts +1 -1
  69. package/src/tui/input/useCommandExecutor.ts +1 -1
  70. package/src/tui/input/useInputBox.ts +160 -15
  71. package/src/tui/input/useInputHandler.ts +317 -126
  72. package/src/tui/input/useMentionPicker.ts +133 -0
  73. package/src/tui/input/usePluginShortcuts.ts +29 -0
  74. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  75. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  76. package/src/tui/plugins/InkUIService.ts +1 -1
  77. package/src/tui/renderApp.tsx +47 -13
  78. package/src/tui/theme/index.ts +1 -0
  79. package/src/tui/theme/merge.test.ts +49 -0
  80. package/src/tui/theme/merge.ts +43 -0
  81. package/src/tui/theme/presets.ts +90 -0
  82. package/src/tui/theme/types.ts +138 -0
  83. package/src/utils/clipboard.ts +1 -1
  84. 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 dimColor={true} italic={true}>
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 ? 'green' : undefined;
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 dimColor={true}>{placeholder} </Text>
111
+ <Text color={theme.dropdown.placeholder}>{placeholder} </Text>
110
112
  <Text>{query}</Text>
111
- <Text inverse={true}>▎</Text>
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="#1a1a1a" paddingX={2} paddingY={1}>
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 dimColor={true}>Esc to close</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 dimColor={true} italic={true}>
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) => prev.slice(1));
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="#1a1a1a" paddingX={2} paddingY={0} width={maxWidth}>
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 ?? 'green'} wrap="wrap">
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="gray" dimColor={true}>
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} marginY={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
- {segments.map((seg, i) => (
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 ? 'green' : undefined} bold={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 ? 'red' : undefined} bold={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 dimColor={true}>y/n · Enter to confirm · Esc to cancel</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 dimColor={true}>{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}>▎</Text>
129
+ <Text color={theme.dialog.cursor} inverse={true}>
130
+
131
+ </Text>
127
132
  </Box>
128
133
  <Box>
129
- <Text dimColor={true}>Enter to submit · Esc to cancel</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
+ }