mu-coding 0.15.0 → 0.16.1
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 +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -120
- package/src/tui/chat/useChatSession.ts +0 -384
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -85
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -66
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ChatCommand {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
run: (args: string) => void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CommandHost {
|
|
8
|
+
newSession(): void;
|
|
9
|
+
openModelPicker(): void;
|
|
10
|
+
toggleExpand(): void;
|
|
11
|
+
toggleThinking(): void;
|
|
12
|
+
exportContext(args: string): void | Promise<void>;
|
|
13
|
+
quit(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildCommands(host: CommandHost): ChatCommand[] {
|
|
17
|
+
return [
|
|
18
|
+
{ name: 'new', description: 'start a new session', run: () => host.newSession() },
|
|
19
|
+
{ name: 'model', description: 'switch the active model', run: () => host.openModelPicker() },
|
|
20
|
+
{ name: 'thinking', description: 'expand/collapse reasoning blocks', run: () => host.toggleThinking() },
|
|
21
|
+
{ name: 'expand', description: 'toggle output block expansion', run: () => host.toggleExpand() },
|
|
22
|
+
{
|
|
23
|
+
name: 'context-export',
|
|
24
|
+
description: 'export the conversation to a JSON file',
|
|
25
|
+
run: (args) => host.exportContext(args),
|
|
26
|
+
},
|
|
27
|
+
{ name: 'quit', description: 'exit mu', run: () => host.quit() },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function filterCommands(commands: ChatCommand[], value: string, dismissedFor: string): ChatCommand[] {
|
|
32
|
+
if (!value.startsWith('/') || value.includes(' ') || value === dismissedFor) return [];
|
|
33
|
+
const query = value.slice(1).toLowerCase();
|
|
34
|
+
return commands.filter((command) => command.name.toLowerCase().startsWith(query));
|
|
35
|
+
}
|
package/src/ui/editor.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Component, InputEvent, KeyInputEvent, Surface } from 'mu-tui';
|
|
2
|
+
import { truncateToWidth, visibleWidth } from 'mu-tui';
|
|
3
|
+
|
|
4
|
+
export interface MultilineEditorOptions {
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
maxRows?: number;
|
|
7
|
+
onSubmit?: (value: string) => void;
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CURSOR = '\x1b[7m';
|
|
12
|
+
const RESET = '\x1b[0m';
|
|
13
|
+
const DIM = '\x1b[2m';
|
|
14
|
+
|
|
15
|
+
export class MultilineEditor implements Component {
|
|
16
|
+
private value = '';
|
|
17
|
+
private cursor = 0;
|
|
18
|
+
private readonly placeholder: string;
|
|
19
|
+
private readonly maxRows: number;
|
|
20
|
+
hiddenPrefix = '';
|
|
21
|
+
onSubmit?: (value: string) => void;
|
|
22
|
+
onChange?: (value: string) => void;
|
|
23
|
+
|
|
24
|
+
constructor(opts: MultilineEditorOptions = {}) {
|
|
25
|
+
this.placeholder = opts.placeholder ?? '';
|
|
26
|
+
this.maxRows = opts.maxRows ?? 7;
|
|
27
|
+
this.onSubmit = opts.onSubmit;
|
|
28
|
+
this.onChange = opts.onChange;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getValue(): string {
|
|
32
|
+
return this.value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setValue(value: string): void {
|
|
36
|
+
this.value = value;
|
|
37
|
+
if (this.cursor > value.length) this.cursor = value.length;
|
|
38
|
+
this.onChange?.(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get cursorPos(): number {
|
|
42
|
+
return this.cursor;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setCursor(n: number): void {
|
|
46
|
+
this.cursor = Math.max(0, Math.min(n, this.value.length));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
rows(): number {
|
|
50
|
+
return Math.min(this.maxRows, Math.max(1, this.value.split('\n').length));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handleInput(event: InputEvent): void {
|
|
54
|
+
if (event.type === 'paste') {
|
|
55
|
+
this.insert(event.text);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.type === 'text') {
|
|
59
|
+
this.insert(event.text);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (event.type !== 'key' || event.kind === 'release') return;
|
|
63
|
+
this.handleKey(event);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private handleKey(event: KeyInputEvent): void {
|
|
67
|
+
switch (event.key) {
|
|
68
|
+
case 'enter':
|
|
69
|
+
if (event.ctrl || event.shift) this.insert('\n');
|
|
70
|
+
else this.onSubmit?.(this.value);
|
|
71
|
+
return;
|
|
72
|
+
case 'backspace':
|
|
73
|
+
this.backspace();
|
|
74
|
+
return;
|
|
75
|
+
case 'delete':
|
|
76
|
+
this.deleteForward();
|
|
77
|
+
return;
|
|
78
|
+
case 'left':
|
|
79
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
80
|
+
return;
|
|
81
|
+
case 'right':
|
|
82
|
+
this.cursor = Math.min(this.value.length, this.cursor + 1);
|
|
83
|
+
return;
|
|
84
|
+
case 'home':
|
|
85
|
+
this.cursor = this.lineStart();
|
|
86
|
+
return;
|
|
87
|
+
case 'end':
|
|
88
|
+
this.cursor = this.lineEnd();
|
|
89
|
+
return;
|
|
90
|
+
default:
|
|
91
|
+
if (event.text && !event.ctrl && !event.meta && !event.alt) this.insert(event.text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private lineStart(): number {
|
|
96
|
+
return this.value.lastIndexOf('\n', this.cursor - 1) + 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private lineEnd(): number {
|
|
100
|
+
const next = this.value.indexOf('\n', this.cursor);
|
|
101
|
+
return next === -1 ? this.value.length : next;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private insert(text: string): void {
|
|
105
|
+
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
|
106
|
+
this.cursor += text.length;
|
|
107
|
+
this.onChange?.(this.value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private backspace(): void {
|
|
111
|
+
if (this.cursor === 0) return;
|
|
112
|
+
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
|
|
113
|
+
this.cursor -= 1;
|
|
114
|
+
this.onChange?.(this.value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private deleteForward(): void {
|
|
118
|
+
if (this.cursor >= this.value.length) return;
|
|
119
|
+
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
120
|
+
this.onChange?.(this.value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private cursorRowCol(lines: string[], cursor: number): { row: number; col: number } {
|
|
124
|
+
let remaining = cursor;
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (remaining <= lines[i].length) return { row: i, col: remaining };
|
|
127
|
+
remaining -= lines[i].length + 1;
|
|
128
|
+
}
|
|
129
|
+
return { row: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render(s: Surface): void {
|
|
133
|
+
const width = s.width;
|
|
134
|
+
if (width <= 0) return;
|
|
135
|
+
|
|
136
|
+
const hidden = this.hiddenPrefix !== '' && this.value.startsWith(this.hiddenPrefix);
|
|
137
|
+
const value = hidden ? this.value.slice(1) : this.value;
|
|
138
|
+
const cursorIdx = hidden ? Math.max(0, this.cursor - 1) : this.cursor;
|
|
139
|
+
|
|
140
|
+
if (value.length === 0 && this.placeholder && !s.focused) {
|
|
141
|
+
const ph = visibleWidth(this.placeholder) > width ? truncateToWidth(this.placeholder, width) : this.placeholder;
|
|
142
|
+
s.text(0, 0, `${DIM}${ph}${RESET}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lines = value.split('\n');
|
|
147
|
+
const { row: cr, col: cc } = this.cursorRowCol(lines, cursorIdx);
|
|
148
|
+
const height = Math.max(1, s.height);
|
|
149
|
+
const top = cr >= height ? cr - height + 1 : 0;
|
|
150
|
+
|
|
151
|
+
for (let r = 0; r < height && top + r < lines.length; r++) {
|
|
152
|
+
const line = lines[top + r];
|
|
153
|
+
if (top + r === cr && s.focused) {
|
|
154
|
+
const hscroll = cc >= width ? cc - width + 1 : 0;
|
|
155
|
+
const visible = line.slice(hscroll, hscroll + width);
|
|
156
|
+
const col = cc - hscroll;
|
|
157
|
+
const before = visible.slice(0, col);
|
|
158
|
+
const at = visible.slice(col, col + 1) || ' ';
|
|
159
|
+
const after = visible.slice(col + 1);
|
|
160
|
+
s.text(0, r, `${before}${CURSOR}${at}${RESET}${after}`);
|
|
161
|
+
} else {
|
|
162
|
+
s.text(0, r, line.length > width ? line.slice(0, width) : line);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { type Component, truncateToWidth, visibleWidth, wrapText } from 'mu-tui';
|
|
2
|
+
import { styleToAnsi, type Theme } from './theme';
|
|
3
|
+
|
|
4
|
+
const RESET = '\x1b[0m';
|
|
5
|
+
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
|
|
6
|
+
const QUOTE_RE = /^>\s?(.*)$/;
|
|
7
|
+
const LIST_RE = /^(\s*)((?:[-*+])|\d+[.)])\s+(.+)$/;
|
|
8
|
+
const INLINE_CODE_RE = /`([^`\n]+)`/g;
|
|
9
|
+
const INLINE_MARKDOWN_RE = /(`([^`\n]+)`|\*\*([^*\n]+)\*\*|__([^_\n]+)__)/g;
|
|
10
|
+
const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
|
11
|
+
const FENCE_RE = /^\s*```/;
|
|
12
|
+
|
|
13
|
+
function stripMarkdown(line: string): string {
|
|
14
|
+
const heading = HEADING_RE.exec(line);
|
|
15
|
+
const quote = QUOTE_RE.exec(line);
|
|
16
|
+
const list = LIST_RE.exec(line);
|
|
17
|
+
const text = heading ? (heading[2] ?? '') : quote ? (quote[1] ?? '') : list ? `${list[2]} ${list[3]}` : line;
|
|
18
|
+
return text.replace(INLINE_CODE_RE, '$1').replace(/\*\*([^*\n]+)\*\*|__([^_\n]+)__/g, '$1$2');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function styleSegment(text: string, prefix: string): string {
|
|
22
|
+
if (!text) return '';
|
|
23
|
+
return prefix ? `${prefix}${text}${RESET}` : text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StyledSegment {
|
|
27
|
+
text: string;
|
|
28
|
+
prefix: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function markdownSegments(
|
|
32
|
+
line: string,
|
|
33
|
+
textPrefix: string,
|
|
34
|
+
headingPrefix: string,
|
|
35
|
+
boldPrefix: string,
|
|
36
|
+
codePrefix: string,
|
|
37
|
+
): StyledSegment[] {
|
|
38
|
+
const heading = HEADING_RE.exec(line);
|
|
39
|
+
const text = heading ? (heading[2] ?? '') : line;
|
|
40
|
+
const prefix = heading ? headingPrefix : textPrefix;
|
|
41
|
+
const segments: StyledSegment[] = [];
|
|
42
|
+
let last = 0;
|
|
43
|
+
for (const match of text.matchAll(INLINE_MARKDOWN_RE)) {
|
|
44
|
+
const index = match.index ?? 0;
|
|
45
|
+
if (index > last) segments.push({ text: text.slice(last, index), prefix });
|
|
46
|
+
if (match[2] !== undefined) segments.push({ text: match[2], prefix: codePrefix });
|
|
47
|
+
else segments.push({ text: match[3] ?? match[4] ?? '', prefix: boldPrefix });
|
|
48
|
+
last = index + match[0].length;
|
|
49
|
+
}
|
|
50
|
+
if (last < text.length) segments.push({ text: text.slice(last), prefix });
|
|
51
|
+
return segments;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface WrapState {
|
|
55
|
+
lines: string[];
|
|
56
|
+
current: string;
|
|
57
|
+
col: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pushLine(state: WrapState): void {
|
|
61
|
+
state.lines.push(state.current);
|
|
62
|
+
state.current = '';
|
|
63
|
+
state.col = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendStyled(state: WrapState, text: string, prefix: string): void {
|
|
67
|
+
if (!text) return;
|
|
68
|
+
state.current += styleSegment(text, prefix);
|
|
69
|
+
state.col += visibleWidth(text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendWrappedToken(state: WrapState, token: string, prefix: string, width: number): void {
|
|
73
|
+
const tokenWidth = visibleWidth(token);
|
|
74
|
+
if (tokenWidth === 0) return;
|
|
75
|
+
|
|
76
|
+
if (/^\s+$/.test(token)) {
|
|
77
|
+
if (state.col + tokenWidth <= width) appendStyled(state, token, prefix);
|
|
78
|
+
else pushLine(state);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (tokenWidth <= width) {
|
|
83
|
+
if (state.col > 0 && state.col + tokenWidth > width) pushLine(state);
|
|
84
|
+
appendStyled(state, token, prefix);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (state.col > 0) pushLine(state);
|
|
89
|
+
let chunk = '';
|
|
90
|
+
let chunkWidth = 0;
|
|
91
|
+
for (const ch of token) {
|
|
92
|
+
const chWidth = visibleWidth(ch);
|
|
93
|
+
if (chunkWidth + chWidth > width) {
|
|
94
|
+
appendStyled(state, chunk, prefix);
|
|
95
|
+
pushLine(state);
|
|
96
|
+
chunk = ch;
|
|
97
|
+
chunkWidth = chWidth;
|
|
98
|
+
} else {
|
|
99
|
+
chunk += ch;
|
|
100
|
+
chunkWidth += chWidth;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
appendStyled(state, chunk, prefix);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function wrapMarkdownLine(
|
|
107
|
+
line: string,
|
|
108
|
+
textPrefix: string,
|
|
109
|
+
headingPrefix: string,
|
|
110
|
+
quotePrefix: string,
|
|
111
|
+
markerPrefix: string,
|
|
112
|
+
boldPrefix: string,
|
|
113
|
+
codePrefix: string,
|
|
114
|
+
width: number,
|
|
115
|
+
): string[] {
|
|
116
|
+
const quote = QUOTE_RE.exec(line);
|
|
117
|
+
if (quote) {
|
|
118
|
+
const innerWidth = Math.max(1, width - 2);
|
|
119
|
+
return wrapMarkdownLine(
|
|
120
|
+
quote[1] ?? '',
|
|
121
|
+
quotePrefix,
|
|
122
|
+
quotePrefix,
|
|
123
|
+
quotePrefix,
|
|
124
|
+
quotePrefix,
|
|
125
|
+
boldPrefix,
|
|
126
|
+
codePrefix,
|
|
127
|
+
innerWidth,
|
|
128
|
+
)
|
|
129
|
+
.map((wrappedLine) => `${styleSegment('| ', quotePrefix)}${wrappedLine}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const list = LIST_RE.exec(line);
|
|
133
|
+
if (list) {
|
|
134
|
+
const marker = `${list[2]} `;
|
|
135
|
+
const markerWidth = visibleWidth(marker);
|
|
136
|
+
const innerWidth = Math.max(1, width - markerWidth);
|
|
137
|
+
return wrapMarkdownLine(
|
|
138
|
+
list[3] ?? '',
|
|
139
|
+
textPrefix,
|
|
140
|
+
headingPrefix,
|
|
141
|
+
quotePrefix,
|
|
142
|
+
markerPrefix,
|
|
143
|
+
boldPrefix,
|
|
144
|
+
codePrefix,
|
|
145
|
+
innerWidth,
|
|
146
|
+
)
|
|
147
|
+
.map((wrappedLine, index) => {
|
|
148
|
+
const prefix = index === 0
|
|
149
|
+
? styleSegment(marker, markerPrefix)
|
|
150
|
+
: styleSegment(' '.repeat(markerWidth), markerPrefix);
|
|
151
|
+
return `${prefix}${wrappedLine}`;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const state: WrapState = { lines: [], current: '', col: 0 };
|
|
156
|
+
const segments = markdownSegments(line, textPrefix, headingPrefix, boldPrefix, codePrefix);
|
|
157
|
+
for (const segment of segments) {
|
|
158
|
+
for (const token of segment.text.split(/(\s+)/)) {
|
|
159
|
+
appendWrappedToken(state, token, segment.prefix, width);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
state.lines.push(state.current);
|
|
163
|
+
return state.lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isTableStart(lines: string[], index: number): boolean {
|
|
167
|
+
const header = lines[index] ?? '';
|
|
168
|
+
const separator = lines[index + 1] ?? '';
|
|
169
|
+
return header.includes('|') && TABLE_SEPARATOR_RE.test(separator);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function splitTableRow(line: string): string[] {
|
|
173
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function tableColumnWidths(rows: string[][], width: number): number[] {
|
|
177
|
+
const columnCount = Math.max(0, ...rows.map((row) => row.length));
|
|
178
|
+
const widths = Array.from({ length: columnCount }, (_, column) => {
|
|
179
|
+
let max = 1;
|
|
180
|
+
for (const row of rows) {
|
|
181
|
+
const cellWidth = visibleWidth(stripMarkdown(row[column] ?? ''));
|
|
182
|
+
if (cellWidth > max) max = cellWidth;
|
|
183
|
+
}
|
|
184
|
+
return max;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const separatorWidth = Math.max(0, columnCount - 1) * 3;
|
|
188
|
+
let total = widths.reduce((sum, value) => sum + value, 0) + separatorWidth;
|
|
189
|
+
while (total > width && Math.max(...widths) > 3) {
|
|
190
|
+
const widest = widths.indexOf(Math.max(...widths));
|
|
191
|
+
widths[widest] -= 1;
|
|
192
|
+
total -= 1;
|
|
193
|
+
}
|
|
194
|
+
return widths;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderTableCell(
|
|
198
|
+
cell: string,
|
|
199
|
+
width: number,
|
|
200
|
+
textPrefix: string,
|
|
201
|
+
boldPrefix: string,
|
|
202
|
+
codePrefix: string,
|
|
203
|
+
): string {
|
|
204
|
+
const plain = stripMarkdown(cell);
|
|
205
|
+
if (visibleWidth(plain) > width) return styleSegment(truncateToWidth(plain, width), textPrefix);
|
|
206
|
+
const styled = wrapMarkdownLine(
|
|
207
|
+
cell,
|
|
208
|
+
textPrefix,
|
|
209
|
+
textPrefix,
|
|
210
|
+
textPrefix,
|
|
211
|
+
textPrefix,
|
|
212
|
+
boldPrefix,
|
|
213
|
+
codePrefix,
|
|
214
|
+
Math.max(1, width),
|
|
215
|
+
)[0] ?? '';
|
|
216
|
+
const padding = Math.max(0, width - visibleWidth(plain));
|
|
217
|
+
return `${styled}${styleSegment(' '.repeat(padding), textPrefix)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderTableRow(
|
|
221
|
+
cells: string[],
|
|
222
|
+
widths: number[],
|
|
223
|
+
cellPrefix: string,
|
|
224
|
+
borderPrefix: string,
|
|
225
|
+
boldPrefix: string,
|
|
226
|
+
codePrefix: string,
|
|
227
|
+
): string {
|
|
228
|
+
return widths.map((columnWidth, index) =>
|
|
229
|
+
renderTableCell(cells[index] ?? '', columnWidth, cellPrefix, boldPrefix, codePrefix)
|
|
230
|
+
)
|
|
231
|
+
.join(styleSegment(' | ', borderPrefix));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderTableBlock(
|
|
235
|
+
lines: string[],
|
|
236
|
+
start: number,
|
|
237
|
+
width: number,
|
|
238
|
+
textPrefix: string,
|
|
239
|
+
headingPrefix: string,
|
|
240
|
+
borderPrefix: string,
|
|
241
|
+
boldPrefix: string,
|
|
242
|
+
codePrefix: string,
|
|
243
|
+
): { lines: string[]; nextIndex: number } {
|
|
244
|
+
const header = splitTableRow(lines[start] ?? '');
|
|
245
|
+
const rows: string[][] = [header];
|
|
246
|
+
let index = start + 2;
|
|
247
|
+
while (index < lines.length && lines[index]?.includes('|') && !TABLE_SEPARATOR_RE.test(lines[index] ?? '')) {
|
|
248
|
+
rows.push(splitTableRow(lines[index] ?? ''));
|
|
249
|
+
index += 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const widths = tableColumnWidths(rows, width);
|
|
253
|
+
const rendered = [
|
|
254
|
+
renderTableRow(header, widths, headingPrefix, borderPrefix, boldPrefix, codePrefix),
|
|
255
|
+
styleSegment(widths.map((columnWidth) => '-'.repeat(columnWidth)).join('-+-'), borderPrefix),
|
|
256
|
+
...rows.slice(1).map((row) => renderTableRow(row, widths, textPrefix, borderPrefix, boldPrefix, codePrefix)),
|
|
257
|
+
];
|
|
258
|
+
return {
|
|
259
|
+
lines: rendered.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line)),
|
|
260
|
+
nextIndex: index,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function renderCodeBlock(
|
|
265
|
+
lines: string[],
|
|
266
|
+
start: number,
|
|
267
|
+
width: number,
|
|
268
|
+
codeBlockPrefix: string,
|
|
269
|
+
labelPrefix: string,
|
|
270
|
+
): { lines: string[]; nextIndex: number } {
|
|
271
|
+
const rendered: string[] = [];
|
|
272
|
+
const fenceLine = lines[start] ?? '';
|
|
273
|
+
const lang = fenceLine.replace(/^\s*```/, '').trim();
|
|
274
|
+
|
|
275
|
+
if (lang) {
|
|
276
|
+
const label = ` ${lang}`;
|
|
277
|
+
const padded = visibleWidth(label) > width
|
|
278
|
+
? truncateToWidth(label, width)
|
|
279
|
+
: label + ' '.repeat(Math.max(0, width - visibleWidth(label)));
|
|
280
|
+
rendered.push(styleSegment(padded, labelPrefix));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const PAD = 2;
|
|
284
|
+
const innerWidth = Math.max(1, width - PAD);
|
|
285
|
+
let index = start + 1;
|
|
286
|
+
while (index < lines.length && !FENCE_RE.test(lines[index] ?? '')) {
|
|
287
|
+
const line = lines[index] ?? '';
|
|
288
|
+
const content = visibleWidth(line) > innerWidth ? truncateToWidth(line, innerWidth) : line;
|
|
289
|
+
const padded = `${' '.repeat(PAD)}${content}${' '.repeat(Math.max(0, innerWidth - visibleWidth(content)))}`;
|
|
290
|
+
rendered.push(styleSegment(padded, codeBlockPrefix));
|
|
291
|
+
index += 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (rendered.length === 0 || (lang && rendered.length === 1)) {
|
|
295
|
+
rendered.push(styleSegment(' '.repeat(width), codeBlockPrefix));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { lines: rendered, nextIndex: index < lines.length ? index + 1 : index };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface Prefixes {
|
|
302
|
+
text: string;
|
|
303
|
+
heading: string;
|
|
304
|
+
quote: string;
|
|
305
|
+
border: string;
|
|
306
|
+
marker: string;
|
|
307
|
+
bold: string;
|
|
308
|
+
code: string;
|
|
309
|
+
codeBlock: string;
|
|
310
|
+
codeBlockLabel: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderBlocks(content: string, width: number, p: Prefixes): string[] {
|
|
314
|
+
const lines = content.split('\n');
|
|
315
|
+
const rendered: string[] = [];
|
|
316
|
+
for (let i = 0; i < lines.length;) {
|
|
317
|
+
if (FENCE_RE.test(lines[i] ?? '')) {
|
|
318
|
+
const block = renderCodeBlock(lines, i, width, p.codeBlock, p.codeBlockLabel);
|
|
319
|
+
rendered.push(...block.lines);
|
|
320
|
+
i = block.nextIndex;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (isTableStart(lines, i)) {
|
|
324
|
+
const table = renderTableBlock(lines, i, width, p.text, p.heading, p.border, p.bold, p.code);
|
|
325
|
+
rendered.push(...table.lines);
|
|
326
|
+
i = table.nextIndex;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
rendered.push(...wrapMarkdownLine(lines[i] ?? '', p.text, p.heading, p.quote, p.marker, p.bold, p.code, width));
|
|
330
|
+
i += 1;
|
|
331
|
+
}
|
|
332
|
+
return rendered;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function renderMarkdown(content: string, width: number, theme: Theme): string[] {
|
|
336
|
+
const p: Prefixes = {
|
|
337
|
+
text: styleToAnsi(theme.styles.assistantMessage),
|
|
338
|
+
heading: styleToAnsi({ ...theme.styles.assistantMessage, fg: theme.colors.warning, bold: true }),
|
|
339
|
+
quote: styleToAnsi(theme.styles.muted),
|
|
340
|
+
border: styleToAnsi(theme.styles.muted),
|
|
341
|
+
marker: styleToAnsi(theme.styles.muted),
|
|
342
|
+
bold: styleToAnsi({ ...theme.styles.assistantMessage, bold: true }),
|
|
343
|
+
code: styleToAnsi({ ...theme.styles.assistantMessage, fg: theme.colors.success }),
|
|
344
|
+
codeBlock: styleToAnsi({ ...theme.styles.assistantMessage, bg: theme.colors.surfaceMuted }),
|
|
345
|
+
codeBlockLabel: styleToAnsi({ fg: theme.colors.textMuted, bg: theme.colors.surfaceMuted, dim: true }),
|
|
346
|
+
};
|
|
347
|
+
return renderBlocks(content, Math.max(1, width), p);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function markdown(content: string, theme: Theme): Component {
|
|
351
|
+
return {
|
|
352
|
+
render: (s) => {
|
|
353
|
+
if (s.width <= 0) return;
|
|
354
|
+
const lines = renderMarkdown(content, s.width, theme);
|
|
355
|
+
for (let i = 0; i < lines.length; i++) {
|
|
356
|
+
const line = lines[i];
|
|
357
|
+
s.text(0, i, visibleWidth(line) > s.width ? truncateToWidth(line, s.width) : line);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export { wrapText };
|
package/src/ui/picker.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface Candidate {
|
|
5
|
+
label: string;
|
|
6
|
+
insert: string;
|
|
7
|
+
kind: 'file' | 'agent';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const IGNORED = new Set([
|
|
11
|
+
'node_modules',
|
|
12
|
+
'.git',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.next',
|
|
16
|
+
'target',
|
|
17
|
+
'.cache',
|
|
18
|
+
'coverage',
|
|
19
|
+
'.venv',
|
|
20
|
+
'venv',
|
|
21
|
+
'__pycache__',
|
|
22
|
+
'.idea',
|
|
23
|
+
'.vscode',
|
|
24
|
+
'npm',
|
|
25
|
+
'vendor',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const MAX_ENTRIES = 5000;
|
|
29
|
+
const MAX_DEPTH = 6;
|
|
30
|
+
|
|
31
|
+
let cache: { cwd: string; files: string[] } | undefined;
|
|
32
|
+
|
|
33
|
+
function walk(cwd: string): string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
const stack: { dir: string; depth: number }[] = [{ dir: cwd, depth: 0 }];
|
|
36
|
+
while (stack.length > 0 && out.length < MAX_ENTRIES) {
|
|
37
|
+
const { dir, depth } = stack.pop()!;
|
|
38
|
+
let entries: string[];
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(dir);
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const name of entries) {
|
|
45
|
+
if (name.startsWith('.') && name !== '.mu') continue;
|
|
46
|
+
if (IGNORED.has(name)) continue;
|
|
47
|
+
const full = join(dir, name);
|
|
48
|
+
let isDir: boolean;
|
|
49
|
+
try {
|
|
50
|
+
isDir = statSync(full).isDirectory();
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (isDir) {
|
|
55
|
+
if (depth < MAX_DEPTH) stack.push({ dir: full, depth: depth + 1 });
|
|
56
|
+
} else {
|
|
57
|
+
out.push(relative(cwd, full));
|
|
58
|
+
if (out.length >= MAX_ENTRIES) break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out.sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function collectCandidates(cwd: string, agentNames: string[]): Candidate[] {
|
|
66
|
+
if (!cache || cache.cwd !== cwd) cache = { cwd, files: walk(cwd) };
|
|
67
|
+
const agents: Candidate[] = agentNames.map((name) => ({ label: `@${name}`, insert: name, kind: 'agent' }));
|
|
68
|
+
const files: Candidate[] = cache.files.map((path) => ({ label: path, insert: path, kind: 'file' }));
|
|
69
|
+
return [...agents, ...files];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function invalidateCandidates(): void {
|
|
73
|
+
cache = undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function score(query: string, target: string): number | undefined {
|
|
77
|
+
if (query.length === 0) return 0;
|
|
78
|
+
const q = query.toLowerCase();
|
|
79
|
+
const t = target.toLowerCase();
|
|
80
|
+
let qi = 0;
|
|
81
|
+
let total = 0;
|
|
82
|
+
let prev = -2;
|
|
83
|
+
for (let i = 0; i < t.length && qi < q.length; i++) {
|
|
84
|
+
if (t[i] === q[qi]) {
|
|
85
|
+
let s = 1;
|
|
86
|
+
if (i === prev + 1) s += 2;
|
|
87
|
+
if (i === 0 || /[/_\-. ]/.test(t[i - 1] ?? '')) s += 3;
|
|
88
|
+
total += s;
|
|
89
|
+
prev = i;
|
|
90
|
+
qi += 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (qi < q.length) return undefined;
|
|
94
|
+
return total - t.length * 0.01;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function rank(query: string, candidates: Candidate[], limit = 8): Candidate[] {
|
|
98
|
+
if (!query) return candidates.slice(0, limit);
|
|
99
|
+
const scored: { candidate: Candidate; score: number }[] = [];
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
const s = score(query, candidate.label);
|
|
102
|
+
if (s !== undefined) scored.push({ candidate, score: s });
|
|
103
|
+
}
|
|
104
|
+
scored.sort((a, b) => b.score - a.score);
|
|
105
|
+
return scored.slice(0, limit).map((entry) => entry.candidate);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ActiveMention {
|
|
109
|
+
start: number;
|
|
110
|
+
query: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function activeMention(value: string, cursor: number): ActiveMention | undefined {
|
|
114
|
+
let start = -1;
|
|
115
|
+
for (let i = cursor - 1; i >= 0; i--) {
|
|
116
|
+
const ch = value[i];
|
|
117
|
+
if (ch === '@') {
|
|
118
|
+
start = i;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (ch === ' ' || ch === '\n' || ch === '\t') break;
|
|
122
|
+
}
|
|
123
|
+
if (start === -1) return undefined;
|
|
124
|
+
if (start > 0 && !/\s/.test(value[start - 1] ?? ' ')) return undefined;
|
|
125
|
+
return { start, query: value.slice(start + 1, cursor) };
|
|
126
|
+
}
|