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,19 +1,39 @@
1
1
  import { Box, Text } from 'ink';
2
+ import type { InputInfoSegment, MentionCompletion } from 'mu-core';
3
+ import { useTheme } from '../context/ThemeContext';
4
+ import type { Theme } from '../theme/types';
2
5
  import type { SlashCommand } from './commands';
3
6
 
7
+ interface MentionPickerView {
8
+ completions: MentionCompletion[];
9
+ selectedIndex: number;
10
+ partial: string;
11
+ }
12
+
4
13
  export interface InputBoxViewProps {
5
14
  value: string;
15
+ cursor: number;
6
16
  commands: SlashCommand[];
7
17
  cmdIndex: number;
8
18
  isCommandMode: boolean;
9
19
  streaming: boolean;
10
20
  isActive: boolean;
11
21
  model: string;
22
+ infoSegments: InputInfoSegment[];
12
23
  attachmentName: string | null;
13
24
  attachmentError: string | null;
25
+ mentions: MentionPickerView | null;
14
26
  }
15
27
 
16
- function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
28
+ function CommandHints({
29
+ commands,
30
+ selectedIndex,
31
+ theme,
32
+ }: {
33
+ commands: SlashCommand[];
34
+ selectedIndex: number;
35
+ theme: Theme;
36
+ }) {
17
37
  if (!commands.length) {
18
38
  return null;
19
39
  }
@@ -21,7 +41,7 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
21
41
  <Box flexDirection="column" marginBottom={1}>
22
42
  {commands.map((cmd, i) => (
23
43
  <Box key={cmd.name} paddingX={1}>
24
- <Text color={i === selectedIndex ? 'green' : undefined} bold={i === selectedIndex}>
44
+ <Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
25
45
  {i === selectedIndex ? '▸ ' : ' '}
26
46
  {cmd.name}
27
47
  </Text>
@@ -32,96 +52,241 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
32
52
  );
33
53
  }
34
54
 
55
+ /**
56
+ * Render a label with the substring matching `partial` (case-insensitive)
57
+ * highlighted using the same accent color the picker uses for the selected
58
+ * row. Preserves the original casing of the label since we only slice it.
59
+ */
60
+ function renderHighlightedLabel(label: string, partial: string, theme: Theme) {
61
+ if (!partial) return <>{label}</>;
62
+ const idx = label.toLowerCase().indexOf(partial.toLowerCase());
63
+ if (idx < 0) return <>{label}</>;
64
+ const head = label.slice(0, idx);
65
+ const match = label.slice(idx, idx + partial.length);
66
+ const tail = label.slice(idx + partial.length);
67
+ return (
68
+ <>
69
+ {head}
70
+ <Text color={theme.input.commandHighlight} bold={true}>
71
+ {match}
72
+ </Text>
73
+ {tail}
74
+ </>
75
+ );
76
+ }
77
+
78
+ function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
79
+ if (!mentions.completions.length) {
80
+ return null;
81
+ }
82
+ // Group completions by category while preserving the global index so
83
+ // ↑/↓ navigation still maps to the correct entry. When only one
84
+ // category is present we hide the header to keep the dropdown compact.
85
+ const grouped = new Map<string, { c: MentionCompletion; i: number }[]>();
86
+ mentions.completions.forEach((c, i) => {
87
+ const key = c.category ?? '';
88
+ const arr = grouped.get(key);
89
+ if (arr) arr.push({ c, i });
90
+ else grouped.set(key, [{ c, i }]);
91
+ });
92
+ const showHeaders = grouped.size > 1;
93
+ const sections = Array.from(grouped.entries());
94
+ return (
95
+ <Box flexDirection="column" marginBottom={1}>
96
+ {sections.map(([category, items]) => (
97
+ <Box key={category || 'default'} flexDirection="column">
98
+ {showHeaders && category && (
99
+ <Box paddingX={1}>
100
+ <Text dimColor={true} bold={true}>
101
+ {category}
102
+ </Text>
103
+ </Box>
104
+ )}
105
+ {items.map(({ c, i }) => {
106
+ const selected = i === mentions.selectedIndex;
107
+ const labelText = c.label ?? c.value;
108
+ return (
109
+ <Box key={`${category}:${c.value}`} paddingX={1}>
110
+ <Text wrap="truncate-start" color={selected ? theme.input.commandHighlight : undefined} bold={selected}>
111
+ {selected ? '▸ @' : ' @'}
112
+ {renderHighlightedLabel(labelText, mentions.partial, theme)}
113
+ </Text>
114
+ {c.description && <Text dimColor={true}> {c.description}</Text>}
115
+ </Box>
116
+ );
117
+ })}
118
+ </Box>
119
+ ))}
120
+ </Box>
121
+ );
122
+ }
123
+
35
124
  function InputFooter({
36
125
  model,
126
+ infoSegments,
37
127
  attachmentName,
38
128
  attachmentError,
39
129
  hasContent,
40
130
  isCommandMode,
131
+ hasMentions,
132
+ theme,
41
133
  }: {
42
134
  model: string;
135
+ infoSegments: InputInfoSegment[];
43
136
  attachmentName: string | null;
44
137
  attachmentError: string | null;
45
138
  hasContent: boolean;
46
139
  isCommandMode: boolean;
140
+ hasMentions: boolean;
141
+ theme: Theme;
47
142
  }) {
48
- const hint = hasContent
49
- ? isCommandMode
50
- ? '↑↓ select · Enter execute'
51
- : 'Enter to send · Shift+Enter for newline'
52
- : 'Type / for commands';
143
+ const hint = hasMentions
144
+ ? '↑↓ · Tab accept'
145
+ : hasContent
146
+ ? isCommandMode
147
+ ? '↑↓ · Enter run'
148
+ : ''
149
+ : '/ commands · @ mentions';
53
150
 
54
151
  return (
55
152
  <Box justifyContent="space-between">
56
153
  <Box gap={1}>
154
+ {infoSegments.map((seg) => (
155
+ <Text key={seg.key} color={seg.color} bold={seg.bold}>
156
+ {seg.text}
157
+ </Text>
158
+ ))}
57
159
  {model && (
58
- <Text color="white" bold={true}>
160
+ <Text color={theme.input.modelLabel} bold={true}>
59
161
  {model}
60
162
  </Text>
61
163
  )}
62
- {attachmentName && <Text color="cyan">📷 {attachmentName}</Text>}
63
- {attachmentError && <Text color="red">{attachmentError}</Text>}
164
+ {attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
165
+ {attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
64
166
  </Box>
65
- <Text dimColor={true}>{hint}</Text>
167
+ <Text color={theme.input.footerHint}>{hint}</Text>
66
168
  </Box>
67
169
  );
68
170
  }
69
171
 
70
- function InputDisplay({
71
- value,
72
- isCommandMode,
73
- streaming,
74
- isActive,
75
- }: {
172
+ interface RowProps {
173
+ line: string;
174
+ cursorCol: number | null;
175
+ isCommandLine: boolean;
176
+ theme: Theme;
177
+ }
178
+
179
+ /**
180
+ * Render one buffer row, splicing the cursor glyph at `cursorCol` if the
181
+ * cursor lives on this row. Splitting around the cursor (rather than rendering
182
+ * the whole line and overlaying) keeps the layout flowing inside Ink's text
183
+ * wrapping engine.
184
+ */
185
+ function InputRow({ line, cursorCol, isCommandLine, theme }: RowProps) {
186
+ const colorize = (text: string) => (isCommandLine ? <Text color={theme.input.commandHighlight}>{text}</Text> : text);
187
+
188
+ if (cursorCol === null) {
189
+ return <Text wrap="wrap">{colorize(line)}</Text>;
190
+ }
191
+
192
+ const before = line.slice(0, cursorCol);
193
+ const after = line.slice(cursorCol);
194
+ return (
195
+ <Text wrap="wrap">
196
+ {colorize(before)}
197
+ <Text color={theme.input.cursor} inverse={true}>
198
+
199
+ </Text>
200
+ {colorize(after)}
201
+ </Text>
202
+ );
203
+ }
204
+
205
+ interface DisplayProps {
76
206
  value: string;
207
+ cursor: number;
77
208
  isCommandMode: boolean;
78
209
  streaming: boolean;
79
210
  isActive: boolean;
80
- }) {
211
+ theme: Theme;
212
+ }
213
+
214
+ function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
81
215
  const showCursor = !streaming && isActive;
82
216
  if (!value.length) {
83
- return <Text>{showCursor && <Text inverse={true}>▎</Text>}</Text>;
217
+ return (
218
+ <Text>
219
+ {showCursor && (
220
+ <Text color={theme.input.cursor} inverse={true}>
221
+
222
+ </Text>
223
+ )}
224
+ </Text>
225
+ );
84
226
  }
85
227
  const lines = value.split('\n');
228
+ // Locate cursor row/col by walking newline offsets.
229
+ let row = 0;
230
+ let consumed = 0;
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const lineEnd = consumed + lines[i].length;
233
+ if (cursor <= lineEnd) {
234
+ row = i;
235
+ break;
236
+ }
237
+ consumed = lineEnd + 1; // +1 for the newline
238
+ row = i + 1;
239
+ }
240
+ const col = cursor - consumed;
86
241
  return (
87
242
  <>
88
243
  {lines.map((line, i) => (
89
- // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
90
- <Text key={`${i}-${line}`} wrap="wrap">
91
- {i === 0 && isCommandMode ? <Text color="green">{line}</Text> : line}
92
- {i === lines.length - 1 && showCursor && <Text inverse={true}>▎</Text>}
93
- </Text>
244
+ <InputRow
245
+ // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
246
+ key={`${i}-${line}`}
247
+ line={line}
248
+ cursorCol={showCursor && i === row ? col : null}
249
+ isCommandLine={i === 0 && isCommandMode}
250
+ theme={theme}
251
+ />
94
252
  ))}
95
253
  </>
96
254
  );
97
255
  }
98
256
 
99
257
  export function InputBoxView(props: InputBoxViewProps) {
258
+ const theme = useTheme();
100
259
  return (
101
260
  <Box
102
261
  flexDirection="column"
103
262
  flexShrink={0}
104
- backgroundColor="#222222"
263
+ backgroundColor={theme.input.background}
105
264
  paddingX={1}
106
265
  paddingY={1}
107
266
  marginX={1}
108
267
  marginTop={1}
109
268
  >
110
- {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} />}
269
+ {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
270
+ {props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
111
271
  <Box flexDirection="column" minHeight={2}>
112
272
  <InputDisplay
113
273
  value={props.value}
274
+ cursor={props.cursor}
114
275
  isCommandMode={props.isCommandMode}
115
276
  streaming={props.streaming}
116
277
  isActive={props.isActive}
278
+ theme={theme}
117
279
  />
118
280
  </Box>
119
281
  <InputFooter
120
282
  model={props.model}
283
+ infoSegments={props.infoSegments}
121
284
  attachmentName={props.attachmentName}
122
285
  attachmentError={props.attachmentError}
123
286
  hasContent={props.value.length > 0}
124
287
  isCommandMode={props.isCommandMode}
288
+ hasMentions={Boolean(props.mentions)}
289
+ theme={theme}
125
290
  />
126
291
  </Box>
127
292
  );
@@ -23,7 +23,8 @@ describe('BUILTIN_COMMANDS', () => {
23
23
  const onTogglePicker = mock(() => undefined);
24
24
  const onToggleSessionPicker = mock(() => undefined);
25
25
  const onNew = mock(() => undefined);
26
- const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew };
26
+ const onShowContext = mock(() => undefined);
27
+ const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
27
28
 
28
29
  for (const cmd of BUILTIN_COMMANDS) {
29
30
  cmd.invoke?.(actions);
@@ -32,6 +33,7 @@ describe('BUILTIN_COMMANDS', () => {
32
33
  expect(onTogglePicker).toHaveBeenCalledTimes(1);
33
34
  expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
34
35
  expect(onNew).toHaveBeenCalledTimes(1);
36
+ expect(onShowContext).toHaveBeenCalledTimes(1);
35
37
  });
36
38
  });
37
39
 
@@ -1,4 +1,4 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
2
2
  import type { InputActions } from './useInputHandler';
3
3
 
4
4
  /**
@@ -20,6 +20,16 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
20
20
  { name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
21
21
  { name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
22
22
  { name: '/new', description: 'New conversation', invoke: (a) => a.onNew?.() },
23
+ {
24
+ name: '/compact',
25
+ description: 'Summarize the conversation and replace history with the summary (frees context)',
26
+ invoke: (a) => a.onCompact?.(),
27
+ },
28
+ {
29
+ name: '/context',
30
+ description: 'Show the LLM context (system prompt, messages, tools) as plain text',
31
+ invoke: (a) => a.onShowContext?.(),
32
+ },
23
33
  ];
24
34
 
25
35
  export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
@@ -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
+ });