mu-coding 0.4.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.
Files changed (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,8 +1,10 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useCallback, useEffect, useState } from 'react';
3
- import type { DialogRequest, InkUIService } from '../../services/uiService';
4
- import { Dropdown } from './dropdown';
5
- import { Modal } from './modal';
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';
6
8
 
7
9
  // ─── Confirm Dialog ───────────────────────────────────────────────────────────
8
10
 
@@ -15,6 +17,7 @@ function ConfirmDialog({
15
17
  onResolve: (value: unknown) => void;
16
18
  onCancel: () => void;
17
19
  }) {
20
+ const theme = useTheme();
18
21
  const [selected, setSelected] = useState(0);
19
22
 
20
23
  useInput((input, key) => {
@@ -41,15 +44,15 @@ function ConfirmDialog({
41
44
  </Box>
42
45
  )}
43
46
  <Box gap={2}>
44
- <Text color={selected === 0 ? 'green' : undefined} bold={selected === 0}>
47
+ <Text color={selected === 0 ? theme.dialog.confirmYes : undefined} bold={selected === 0}>
45
48
  {selected === 0 ? '▸ ' : ' '}Yes
46
49
  </Text>
47
- <Text color={selected === 1 ? 'red' : undefined} bold={selected === 1}>
50
+ <Text color={selected === 1 ? theme.dialog.confirmNo : undefined} bold={selected === 1}>
48
51
  {selected === 1 ? '▸ ' : ' '}No
49
52
  </Text>
50
53
  </Box>
51
54
  <Box marginTop={1}>
52
- <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>
53
56
  </Box>
54
57
  </Modal>
55
58
  );
@@ -80,6 +83,12 @@ function SelectDialog({
80
83
 
81
84
  // ─── Input Dialog ─────────────────────────────────────────────────────────────
82
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
+
83
92
  function InputDialog({
84
93
  dialog,
85
94
  onResolve,
@@ -89,17 +98,25 @@ function InputDialog({
89
98
  onResolve: (value: unknown) => void;
90
99
  onCancel: () => void;
91
100
  }) {
101
+ const theme = useTheme();
92
102
  const [value, setValue] = useState('');
93
103
 
94
104
  useInput((input, key) => {
95
105
  if (key.escape) {
96
106
  onCancel();
97
- } else if (key.return) {
107
+ return;
108
+ }
109
+ if (key.return) {
98
110
  onResolve(value || null);
99
- } else if (key.backspace || key.delete) {
111
+ return;
112
+ }
113
+ if (key.backspace || key.delete) {
100
114
  setValue((v) => v.slice(0, -1));
101
- } else if (input && input.length === 1) {
102
- setValue((v) => v + input);
115
+ return;
116
+ }
117
+ const insert = sanitizeDialogInput(input);
118
+ if (insert) {
119
+ setValue((v) => v + insert);
103
120
  }
104
121
  });
105
122
 
@@ -107,12 +124,14 @@ function InputDialog({
107
124
  <Modal visible={true} title={dialog.title}>
108
125
  <Box flexDirection="column">
109
126
  <Box paddingX={1} marginBottom={1}>
110
- {!value && dialog.placeholder && <Text dimColor={true}>{dialog.placeholder}</Text>}
127
+ {!value && dialog.placeholder && <Text color={theme.dialog.placeholder}>{dialog.placeholder}</Text>}
111
128
  {value && <Text>{value}</Text>}
112
- <Text inverse={true}>▎</Text>
129
+ <Text color={theme.dialog.cursor} inverse={true}>
130
+
131
+ </Text>
113
132
  </Box>
114
133
  <Box>
115
- <Text dimColor={true}>Enter to submit · Esc to cancel</Text>
134
+ <Text color={theme.dialog.hint}>Enter to submit · Esc to cancel</Text>
116
135
  </Box>
117
136
  </Box>
118
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
+ }
@@ -8,12 +8,20 @@ export function useScroll(contentHeight: number, viewHeight: number) {
8
8
  const autoScrollRef = useRef(true);
9
9
  const maxScroll = Math.max(0, contentHeight - viewHeight);
10
10
 
11
- // Enable SGR mouse mode so wheel sequences arrive through Ink's input pipeline
11
+ // Enable SGR mouse mode (1000 = press/release+wheel only, no drag motion;
12
+ // 1006 = SGR-encoded coordinates) so wheel sequences arrive through Ink's
13
+ // input pipeline. Mode 1002 (button-event w/ drag) was previously used but
14
+ // produced spurious "[<32;...M" drag events that leaked into text inputs.
15
+ //
16
+ // On cleanup we defensively disable 1000/1002/1003 — any of them might be
17
+ // active from a prior session/binary/extension and disabling already-off
18
+ // modes is a no-op. Without this, mouse tracking can leak into the parent
19
+ // shell after abort.
12
20
  const { stdout } = useStdout();
13
21
  useEffect(() => {
14
- stdout.write('\x1b[?1002h\x1b[?1006h');
22
+ stdout.write('\x1b[?1000h\x1b[?1006h');
15
23
  return () => {
16
- stdout.write('\x1b[?1002l\x1b[?1006l');
24
+ stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
17
25
  };
18
26
  }, [stdout]);
19
27
 
@@ -0,0 +1,6 @@
1
+ import { InputBoxView } from './InputBoxView';
2
+ import { type InputBoxProps, useInputBox } from './useInputBox';
3
+
4
+ export function InputBox(props: InputBoxProps) {
5
+ return <InputBoxView {...useInputBox(props)} />;
6
+ }
@@ -0,0 +1,237 @@
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';
5
+ import type { SlashCommand } from './commands';
6
+
7
+ interface MentionPickerView {
8
+ completions: MentionCompletion[];
9
+ selectedIndex: number;
10
+ partial: string;
11
+ }
12
+
13
+ export interface InputBoxViewProps {
14
+ value: string;
15
+ cursor: number;
16
+ commands: SlashCommand[];
17
+ cmdIndex: number;
18
+ isCommandMode: boolean;
19
+ streaming: boolean;
20
+ isActive: boolean;
21
+ model: string;
22
+ attachmentName: string | null;
23
+ attachmentError: string | null;
24
+ mentions: MentionPickerView | null;
25
+ }
26
+
27
+ function CommandHints({
28
+ commands,
29
+ selectedIndex,
30
+ theme,
31
+ }: {
32
+ commands: SlashCommand[];
33
+ selectedIndex: number;
34
+ theme: Theme;
35
+ }) {
36
+ if (!commands.length) {
37
+ return null;
38
+ }
39
+ return (
40
+ <Box flexDirection="column" marginBottom={1}>
41
+ {commands.map((cmd, i) => (
42
+ <Box key={cmd.name} paddingX={1}>
43
+ <Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
44
+ {i === selectedIndex ? '▸ ' : ' '}
45
+ {cmd.name}
46
+ </Text>
47
+ <Text dimColor={true}> {cmd.description}</Text>
48
+ </Box>
49
+ ))}
50
+ </Box>
51
+ );
52
+ }
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
+
76
+ function InputFooter({
77
+ model,
78
+ attachmentName,
79
+ attachmentError,
80
+ hasContent,
81
+ isCommandMode,
82
+ hasMentions,
83
+ theme,
84
+ }: {
85
+ model: string;
86
+ attachmentName: string | null;
87
+ attachmentError: string | null;
88
+ hasContent: boolean;
89
+ isCommandMode: boolean;
90
+ hasMentions: boolean;
91
+ theme: Theme;
92
+ }) {
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';
100
+
101
+ return (
102
+ <Box justifyContent="space-between">
103
+ <Box gap={1}>
104
+ {model && (
105
+ <Text color={theme.input.modelLabel} bold={true}>
106
+ {model}
107
+ </Text>
108
+ )}
109
+ {attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
110
+ {attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
111
+ </Box>
112
+ <Text color={theme.input.footerHint}>{hint}</Text>
113
+ </Box>
114
+ );
115
+ }
116
+
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 {
151
+ value: string;
152
+ cursor: number;
153
+ isCommandMode: boolean;
154
+ streaming: boolean;
155
+ isActive: boolean;
156
+ theme: Theme;
157
+ }
158
+
159
+ function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
160
+ const showCursor = !streaming && isActive;
161
+ if (!value.length) {
162
+ return (
163
+ <Text>
164
+ {showCursor && (
165
+ <Text color={theme.input.cursor} inverse={true}>
166
+
167
+ </Text>
168
+ )}
169
+ </Text>
170
+ );
171
+ }
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;
186
+ return (
187
+ <>
188
+ {lines.map((line, i) => (
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
+ />
197
+ ))}
198
+ </>
199
+ );
200
+ }
201
+
202
+ export function InputBoxView(props: InputBoxViewProps) {
203
+ const theme = useTheme();
204
+ return (
205
+ <Box
206
+ flexDirection="column"
207
+ flexShrink={0}
208
+ backgroundColor={theme.input.background}
209
+ paddingX={1}
210
+ paddingY={1}
211
+ marginX={1}
212
+ marginTop={1}
213
+ >
214
+ {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
215
+ {props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
216
+ <Box flexDirection="column" minHeight={2}>
217
+ <InputDisplay
218
+ value={props.value}
219
+ cursor={props.cursor}
220
+ isCommandMode={props.isCommandMode}
221
+ streaming={props.streaming}
222
+ isActive={props.isActive}
223
+ theme={theme}
224
+ />
225
+ </Box>
226
+ <InputFooter
227
+ model={props.model}
228
+ attachmentName={props.attachmentName}
229
+ attachmentError={props.attachmentError}
230
+ hasContent={props.value.length > 0}
231
+ isCommandMode={props.isCommandMode}
232
+ hasMentions={Boolean(props.mentions)}
233
+ theme={theme}
234
+ />
235
+ </Box>
236
+ );
237
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { BUILTIN_COMMANDS, fromPluginCommand, matchCommands } from './commands';
3
+ import type { InputActions } from './useInputHandler';
4
+
5
+ describe('matchCommands', () => {
6
+ it('returns no matches for input that does not start with /', () => {
7
+ expect(matchCommands('model', BUILTIN_COMMANDS)).toEqual([]);
8
+ });
9
+
10
+ it('filters by prefix case-insensitively', () => {
11
+ const result = matchCommands('/MOD', BUILTIN_COMMANDS);
12
+ expect(result.map((c) => c.name)).toEqual(['/model']);
13
+ });
14
+
15
+ it('returns all commands when input is just /', () => {
16
+ const result = matchCommands('/', BUILTIN_COMMANDS);
17
+ expect(result.length).toBe(BUILTIN_COMMANDS.length);
18
+ });
19
+ });
20
+
21
+ describe('BUILTIN_COMMANDS', () => {
22
+ it('each builtin invokes the expected action', () => {
23
+ const onTogglePicker = mock(() => undefined);
24
+ const onToggleSessionPicker = mock(() => undefined);
25
+ const onNew = mock(() => undefined);
26
+ const onShowContext = mock(() => undefined);
27
+ const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
28
+
29
+ for (const cmd of BUILTIN_COMMANDS) {
30
+ cmd.invoke?.(actions);
31
+ }
32
+
33
+ expect(onTogglePicker).toHaveBeenCalledTimes(1);
34
+ expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
35
+ expect(onNew).toHaveBeenCalledTimes(1);
36
+ expect(onShowContext).toHaveBeenCalledTimes(1);
37
+ });
38
+ });
39
+
40
+ describe('fromPluginCommand', () => {
41
+ it('prepends a slash and forwards args/context to execute', async () => {
42
+ const execute = mock(async () => 'ok');
43
+ const wrapped = fromPluginCommand(
44
+ { name: 'foo', description: 'plugin', execute },
45
+ { messages: [], cwd: '/tmp', config: { baseUrl: '', maxTokens: 0, temperature: 0, streamTimeoutMs: 0 } },
46
+ );
47
+ expect(wrapped.name).toBe('/foo');
48
+ await wrapped.execute?.('hello');
49
+ expect(execute).toHaveBeenCalledWith('hello', expect.any(Object));
50
+ });
51
+ });
@@ -0,0 +1,44 @@
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
2
+ import type { InputActions } from './useInputHandler';
3
+
4
+ /**
5
+ * A slash command can either:
6
+ * - run via `invoke(actions)` — for builtins that just toggle UI state, or
7
+ * - run via `execute(args)` — for plugin-supplied commands that produce
8
+ * side-effects through the agent runtime.
9
+ *
10
+ * Exactly one of `invoke` / `execute` should be set per command.
11
+ */
12
+ export interface SlashCommand {
13
+ name: string;
14
+ description: string;
15
+ invoke?: (actions: InputActions) => void;
16
+ execute?: (args: string) => Promise<string | undefined>;
17
+ }
18
+
19
+ export const BUILTIN_COMMANDS: SlashCommand[] = [
20
+ { name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
21
+ { name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
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
+ },
28
+ ];
29
+
30
+ export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
31
+ return {
32
+ name: `/${command.name}`,
33
+ description: command.description,
34
+ execute: (args: string) => command.execute(args, context),
35
+ };
36
+ }
37
+
38
+ export function matchCommands(input: string, commands: SlashCommand[]): SlashCommand[] {
39
+ if (!input.startsWith('/')) {
40
+ return [];
41
+ }
42
+ const q = input.toLowerCase();
43
+ return commands.filter((cmd) => cmd.name.startsWith(q));
44
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ cursorRowCol,
4
+ deleteBackward,
5
+ deleteForward,
6
+ deleteWordBackward,
7
+ insertAt,
8
+ killToLineEnd,
9
+ killToLineStart,
10
+ moveLeft,
11
+ moveLineDown,
12
+ moveLineEnd,
13
+ moveLineHome,
14
+ moveLineUp,
15
+ moveRight,
16
+ moveWordLeft,
17
+ moveWordRight,
18
+ positionAt,
19
+ } from './cursor';
20
+
21
+ const s = (value: string, cursor: number) => ({ value, cursor });
22
+
23
+ describe('insertAt', () => {
24
+ it('inserts at the cursor and advances it', () => {
25
+ expect(insertAt(s('hello', 5), '!')).toEqual({ value: 'hello!', cursor: 6 });
26
+ expect(insertAt(s('helo', 2), 'l')).toEqual({ value: 'hello', cursor: 3 });
27
+ });
28
+ it('is a no-op for empty text', () => {
29
+ expect(insertAt(s('a', 1), '')).toEqual({ value: 'a', cursor: 1 });
30
+ });
31
+ it('clamps an out-of-range cursor', () => {
32
+ expect(insertAt(s('abc', 99), 'x')).toEqual({ value: 'abcx', cursor: 4 });
33
+ });
34
+ });
35
+
36
+ describe('deleteBackward', () => {
37
+ it('removes the char left of the cursor', () => {
38
+ expect(deleteBackward(s('hello', 5))).toEqual({ value: 'hell', cursor: 4 });
39
+ expect(deleteBackward(s('hello', 1))).toEqual({ value: 'ello', cursor: 0 });
40
+ });
41
+ it('is a no-op at position 0', () => {
42
+ expect(deleteBackward(s('hello', 0))).toEqual({ value: 'hello', cursor: 0 });
43
+ });
44
+ });
45
+
46
+ describe('deleteForward', () => {
47
+ it('removes the char at the cursor', () => {
48
+ expect(deleteForward(s('hello', 0))).toEqual({ value: 'ello', cursor: 0 });
49
+ expect(deleteForward(s('hello', 4))).toEqual({ value: 'hell', cursor: 4 });
50
+ });
51
+ it('is a no-op at end of buffer', () => {
52
+ expect(deleteForward(s('hello', 5))).toEqual({ value: 'hello', cursor: 5 });
53
+ });
54
+ });
55
+
56
+ describe('deleteWordBackward', () => {
57
+ it('eats the previous word', () => {
58
+ expect(deleteWordBackward(s('hello world', 11))).toEqual({ value: 'hello ', cursor: 6 });
59
+ expect(deleteWordBackward(s('hello world', 13))).toEqual({ value: 'hello ', cursor: 8 });
60
+ });
61
+ it('eats trailing whitespace before the word', () => {
62
+ expect(deleteWordBackward(s('foo bar ', 9))).toEqual({ value: 'foo ', cursor: 4 });
63
+ });
64
+ it('is a no-op at start', () => {
65
+ expect(deleteWordBackward(s('foo', 0))).toEqual({ value: 'foo', cursor: 0 });
66
+ });
67
+ });
68
+
69
+ describe('killToLineEnd / killToLineStart', () => {
70
+ it('kills from cursor to end of line', () => {
71
+ expect(killToLineEnd(s('hello\nworld', 3))).toEqual({ value: 'hel\nworld', cursor: 3 });
72
+ });
73
+ it('eats the newline when cursor sits at line end', () => {
74
+ expect(killToLineEnd(s('hello\nworld', 5))).toEqual({ value: 'helloworld', cursor: 5 });
75
+ });
76
+ it('kills from start of line to cursor', () => {
77
+ expect(killToLineStart(s('hello\nworld', 3))).toEqual({ value: 'lo\nworld', cursor: 0 });
78
+ expect(killToLineStart(s('hello\nworld', 8))).toEqual({ value: 'hello\nrld', cursor: 6 });
79
+ });
80
+ });
81
+
82
+ describe('horizontal movement', () => {
83
+ it('moveLeft / moveRight respect bounds', () => {
84
+ expect(moveLeft(s('abc', 0))).toEqual({ value: 'abc', cursor: 0 });
85
+ expect(moveLeft(s('abc', 2))).toEqual({ value: 'abc', cursor: 1 });
86
+ expect(moveRight(s('abc', 3))).toEqual({ value: 'abc', cursor: 3 });
87
+ expect(moveRight(s('abc', 1))).toEqual({ value: 'abc', cursor: 2 });
88
+ });
89
+ it('moveWordLeft / moveWordRight jump across word boundaries', () => {
90
+ expect(moveWordLeft(s('foo bar baz', 11))).toEqual({ value: 'foo bar baz', cursor: 8 });
91
+ expect(moveWordLeft(s('foo bar baz', 8))).toEqual({ value: 'foo bar baz', cursor: 4 });
92
+ expect(moveWordRight(s('foo bar baz', 0))).toEqual({ value: 'foo bar baz', cursor: 3 });
93
+ expect(moveWordRight(s('foo bar baz', 3))).toEqual({ value: 'foo bar baz', cursor: 7 });
94
+ });
95
+ it('Home/End operate on the current line', () => {
96
+ expect(moveLineHome(s('hello\nworld', 8))).toEqual({ value: 'hello\nworld', cursor: 6 });
97
+ expect(moveLineEnd(s('hello\nworld', 6))).toEqual({ value: 'hello\nworld', cursor: 11 });
98
+ });
99
+ });
100
+
101
+ describe('vertical movement', () => {
102
+ it('moveLineUp returns null on first line', () => {
103
+ expect(moveLineUp(s('one\ntwo', 1), null)).toBeNull();
104
+ });
105
+ it('moveLineDown returns null on last line', () => {
106
+ expect(moveLineDown(s('one\ntwo', 5), null)).toBeNull();
107
+ });
108
+ it('preserves desired column across short lines', () => {
109
+ // Cursor on line 0 col 5 → down to line 1 (3 chars) clamps to 3 → down again to line 2 (10 chars) restores 5.
110
+ const start = { value: 'hello\nfoo\nlonglineee', cursor: 5 };
111
+ const down1 = moveLineDown(start, 5);
112
+ expect(down1).toEqual({ value: start.value, cursor: 9 }); // end of "foo"
113
+ const down2 = moveLineDown(down1 as { value: string; cursor: number }, 5);
114
+ expect(down2).toEqual({ value: start.value, cursor: 15 }); // 10 + 5
115
+ });
116
+ it('round-trips up then down to the same offset when desired column is preserved', () => {
117
+ const state = { value: 'aaaa\nbbbb\ncccc', cursor: 12 }; // line 2, col 2
118
+ const up = moveLineUp(state, 2);
119
+ expect(up).toEqual({ value: state.value, cursor: 7 });
120
+ const down = moveLineDown(up as { value: string; cursor: number }, 2);
121
+ expect(down).toEqual({ value: state.value, cursor: 12 });
122
+ });
123
+ });
124
+
125
+ describe('cursorRowCol / positionAt', () => {
126
+ it('reports row and col correctly', () => {
127
+ expect(cursorRowCol('hello\nworld', 0)).toEqual({ row: 0, col: 0 });
128
+ expect(cursorRowCol('hello\nworld', 5)).toEqual({ row: 0, col: 5 });
129
+ expect(cursorRowCol('hello\nworld', 6)).toEqual({ row: 1, col: 0 });
130
+ expect(cursorRowCol('hello\nworld', 11)).toEqual({ row: 1, col: 5 });
131
+ });
132
+ it('positionAt clamps to line length', () => {
133
+ expect(positionAt('hello\nfoo', 1, 99)).toBe(9);
134
+ expect(positionAt('hello\nfoo', 0, 2)).toBe(2);
135
+ });
136
+ });