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,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({
|
|
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 ?
|
|
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 =
|
|
49
|
-
?
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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=
|
|
160
|
+
<Text color={theme.input.modelLabel} bold={true}>
|
|
59
161
|
{model}
|
|
60
162
|
</Text>
|
|
61
163
|
)}
|
|
62
|
-
{attachmentName && <Text color=
|
|
63
|
-
{attachmentError && <Text color=
|
|
164
|
+
{attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
|
|
165
|
+
{attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
|
|
64
166
|
</Box>
|
|
65
|
-
<Text
|
|
167
|
+
<Text color={theme.input.footerHint}>{hint}</Text>
|
|
66
168
|
</Box>
|
|
67
169
|
);
|
|
68
170
|
}
|
|
69
171
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
{i
|
|
92
|
-
|
|
93
|
-
|
|
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=
|
|
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
|
|
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-
|
|
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
|
+
});
|