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.
Files changed (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -120
  57. package/src/tui/chat/useChatSession.ts +0 -384
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -85
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -66
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. 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
+ }
@@ -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 };
@@ -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
+ }