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,24 +1,72 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage } from 'mu-core';
3
3
  import React from 'react';
4
+ import { MarkdownContent } from './markdown';
4
5
  import { ReasoningBlock } from './reasoningBlock';
5
6
  import { ToolCallBlock } from './toolCallBlock';
6
7
 
8
+ /**
9
+ * Tool names whose calls are already represented in the transcript by the
10
+ * `mu-agents.subagent` custom message renderer (`SubagentMessage`).
11
+ * Filtering them out here prevents a redundant `✓ subagent` block from
12
+ * rendering the same body the SubagentMessage already shows.
13
+ *
14
+ * Reload caveat: the `SubagentRunRegistry` is in-memory only, so after a
15
+ * session reload the SubagentMessage block has no live run and shows
16
+ * just the `↳ <name>` glyph without a body. The wrapped tool result
17
+ * still lives in the persisted transcript (and the LLM payload), so the
18
+ * parent agent's relay paragraph is intact; the user just can't see the
19
+ * raw subagent output inline after reopening the session. Accepted
20
+ * trade-off; revisit if/when run hydration is wired into reload.
21
+ */
22
+ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_parallel']);
23
+
7
24
  export const AssistantMessage: React.FC<{
8
25
  msg: ChatMessage;
9
26
  toolMessages?: ChatMessage[];
10
27
  }> = React.memo(function AssistantMessage({ msg, toolMessages }) {
28
+ const badge = msg.display?.badge;
29
+ const prefix = msg.display?.prefix;
30
+ const color = msg.display?.color;
31
+
32
+ // Filter subagent tool calls out of the visible list, dropping their
33
+ // matching `toolMessages` entries in lock-step so positional indexing
34
+ // stays correct for the surviving calls.
35
+ const visibleEntries = (msg.toolCalls ?? []).flatMap((tc, i) =>
36
+ SUBAGENT_TOOL_NAMES.has(tc.function.name) ? [] : [{ tc, toolMsg: toolMessages?.[i] }],
37
+ );
38
+
39
+ // If every renderable surface on this assistant message is empty after
40
+ // filtering, suppress the entire block — otherwise we'd render a
41
+ // dangling badge bubble for assistant turns that were nothing but a
42
+ // subagent dispatch.
43
+ const hasAnything = visibleEntries.length > 0 || !!msg.content || !!msg.reasoning;
44
+ if (!hasAnything) return null;
45
+
46
+ const hasVisibleToolCalls = visibleEntries.length > 0;
11
47
  return (
12
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
48
+ <Box flexDirection="column" flexShrink={0} marginBottom={hasVisibleToolCalls ? 0 : 1}>
49
+ {badge && (
50
+ <Box>
51
+ <Text color={color} bold={true}>
52
+ {badge.charAt(0).toUpperCase() + badge.slice(1)}
53
+ </Text>
54
+ </Box>
55
+ )}
13
56
  {msg.reasoning && <ReasoningBlock reasoning={msg.reasoning} />}
14
- {msg.toolCalls?.length ? (
15
- <Box flexDirection="column" marginBottom={1}>
16
- {msg.toolCalls.map((tc, i) => (
17
- <ToolCallBlock key={tc.id} toolCall={tc} toolMsg={toolMessages?.[i]} />
57
+ {hasVisibleToolCalls ? (
58
+ <Box flexDirection="column">
59
+ {visibleEntries.map(({ tc, toolMsg }) => (
60
+ <ToolCallBlock key={tc.id} toolCall={tc} toolMsg={toolMsg} />
18
61
  ))}
19
62
  </Box>
20
63
  ) : null}
21
- {msg.content && <Text wrap="wrap">{msg.content}</Text>}
64
+ {msg.content && (
65
+ <Box flexDirection="column">
66
+ {prefix && <Text color={color}>{prefix}</Text>}
67
+ <MarkdownContent content={msg.content} color={color} />
68
+ </Box>
69
+ )}
22
70
  </Box>
23
71
  );
24
72
  });
@@ -0,0 +1,402 @@
1
+ import { Box, Text } from 'ink';
2
+ import type React from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
4
+ import type { Theme } from '../../theme/types';
5
+
6
+ /**
7
+ * Lightweight Markdown renderer for assistant messages. Intentionally
8
+ * pragmatic — covers headings, lists, tables, blockquotes, code blocks,
9
+ * and the common inline tokens (bold, italic, inline code, links).
10
+ *
11
+ * Implementation is two-pass:
12
+ * - `parseBlocks` splits the raw text into a list of typed blocks
13
+ * - each block has its own React renderer that delegates inline
14
+ * formatting to `renderInline` for paragraph / list / heading content.
15
+ *
16
+ * No external markdown dependency: the renderer must work in any host
17
+ * shipping `mu-coding` without forcing them to ship a parser.
18
+ */
19
+
20
+ // ─── Block model ──────────────────────────────────────────────────────────────
21
+
22
+ type Block =
23
+ | { type: 'heading'; level: 1 | 2 | 3; text: string }
24
+ | { type: 'paragraph'; text: string }
25
+ | { type: 'list'; ordered: boolean; items: string[] }
26
+ | { type: 'code'; lang: string; lines: string[] }
27
+ | { type: 'quote'; lines: string[] }
28
+ | { type: 'table'; header: string[]; rows: string[][] }
29
+ | { type: 'hr' };
30
+
31
+ const FENCE = /^```(.*)$/;
32
+ const HEADING = /^(#{1,3})\s+(.*)$/;
33
+ const UL_ITEM = /^(\s*)[-*+]\s+(.*)$/;
34
+ const OL_ITEM = /^(\s*)\d+\.\s+(.*)$/;
35
+ const QUOTE = /^>\s?(.*)$/;
36
+ const HR = /^\s*(---|\*\*\*|___)\s*$/;
37
+ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
38
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
39
+
40
+ function splitTableRow(line: string): string[] {
41
+ // Trim outer pipes then split. Handles `| a | b |` and `a | b`.
42
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
43
+ return trimmed.split('|').map((c) => c.trim());
44
+ }
45
+
46
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: block parsing is dispatch-heavy
47
+ function parseBlocks(input: string): Block[] {
48
+ const lines = input.split('\n');
49
+ const blocks: Block[] = [];
50
+ let i = 0;
51
+
52
+ while (i < lines.length) {
53
+ const line = lines[i];
54
+
55
+ // Code fence — capture until matching closing fence (or EOF).
56
+ const fence = line.match(FENCE);
57
+ if (fence) {
58
+ const lang = fence[1].trim();
59
+ const codeLines: string[] = [];
60
+ i++;
61
+ while (i < lines.length && !FENCE.test(lines[i])) {
62
+ codeLines.push(lines[i]);
63
+ i++;
64
+ }
65
+ if (i < lines.length) i++; // skip closing fence
66
+ blocks.push({ type: 'code', lang, lines: codeLines });
67
+ continue;
68
+ }
69
+
70
+ if (line.trim() === '') {
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ if (HR.test(line)) {
76
+ blocks.push({ type: 'hr' });
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ const heading = line.match(HEADING);
82
+ if (heading) {
83
+ blocks.push({ type: 'heading', level: heading[1].length as 1 | 2 | 3, text: heading[2] });
84
+ i++;
85
+ continue;
86
+ }
87
+
88
+ // Quote — consume consecutive `> ` lines.
89
+ if (QUOTE.test(line)) {
90
+ const quoteLines: string[] = [];
91
+ while (i < lines.length && QUOTE.test(lines[i])) {
92
+ quoteLines.push((lines[i].match(QUOTE) as RegExpMatchArray)[1]);
93
+ i++;
94
+ }
95
+ blocks.push({ type: 'quote', lines: quoteLines });
96
+ continue;
97
+ }
98
+
99
+ // Table — header row, separator row, then body rows.
100
+ if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
101
+ const header = splitTableRow(line);
102
+ i += 2; // skip header + separator
103
+ const rows: string[][] = [];
104
+ while (i < lines.length && TABLE_ROW.test(lines[i])) {
105
+ rows.push(splitTableRow(lines[i]));
106
+ i++;
107
+ }
108
+ blocks.push({ type: 'table', header, rows });
109
+ continue;
110
+ }
111
+
112
+ // List — ordered or unordered, contiguous items only.
113
+ const ulMatch = line.match(UL_ITEM);
114
+ const olMatch = line.match(OL_ITEM);
115
+ if (ulMatch || olMatch) {
116
+ const ordered = !!olMatch;
117
+ const re = ordered ? OL_ITEM : UL_ITEM;
118
+ const items: string[] = [];
119
+ while (i < lines.length) {
120
+ const m = lines[i].match(re);
121
+ if (!m) break;
122
+ items.push(m[2]);
123
+ i++;
124
+ }
125
+ blocks.push({ type: 'list', ordered, items });
126
+ continue;
127
+ }
128
+
129
+ // Paragraph — contiguous non-empty, non-block lines.
130
+ const paraLines: string[] = [];
131
+ while (i < lines.length) {
132
+ const cur = lines[i];
133
+ if (cur.trim() === '') break;
134
+ if (FENCE.test(cur) || HEADING.test(cur) || QUOTE.test(cur) || HR.test(cur)) break;
135
+ if (UL_ITEM.test(cur) || OL_ITEM.test(cur)) break;
136
+ if (TABLE_ROW.test(cur) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) break;
137
+ paraLines.push(cur);
138
+ i++;
139
+ }
140
+ if (paraLines.length > 0) {
141
+ blocks.push({ type: 'paragraph', text: paraLines.join(' ') });
142
+ }
143
+ }
144
+
145
+ return blocks;
146
+ }
147
+
148
+ // ─── Inline rendering ─────────────────────────────────────────────────────────
149
+
150
+ interface InlineToken {
151
+ kind: 'text' | 'bold' | 'italic' | 'code' | 'link';
152
+ text: string;
153
+ href?: string;
154
+ }
155
+
156
+ const INLINE_PATTERNS: { kind: InlineToken['kind']; re: RegExp; capture: number; href?: number }[] = [
157
+ // Order matters: longer / more specific patterns first.
158
+ { kind: 'code', re: /`([^`\n]+)`/, capture: 1 },
159
+ { kind: 'link', re: /\[([^\]]+)\]\(([^)]+)\)/, capture: 1, href: 2 },
160
+ { kind: 'bold', re: /\*\*([^*\n]+)\*\*/, capture: 1 },
161
+ { kind: 'bold', re: /__([^_\n]+)__/, capture: 1 },
162
+ { kind: 'italic', re: /\*([^*\n]+)\*/, capture: 1 },
163
+ { kind: 'italic', re: /_([^_\n]+)_/, capture: 1 },
164
+ ];
165
+
166
+ function tokenizeInline(input: string): InlineToken[] {
167
+ const out: InlineToken[] = [];
168
+ let cursor = 0;
169
+ while (cursor < input.length) {
170
+ let bestIdx = -1;
171
+ let bestMatch: { kind: InlineToken['kind']; text: string; href?: string; raw: string } | null = null;
172
+ for (const p of INLINE_PATTERNS) {
173
+ const sub = input.slice(cursor);
174
+ const m = sub.match(p.re);
175
+ if (m && m.index !== undefined) {
176
+ if (bestIdx === -1 || m.index < bestIdx) {
177
+ bestIdx = m.index;
178
+ bestMatch = {
179
+ kind: p.kind,
180
+ text: m[p.capture],
181
+ href: p.href !== undefined ? m[p.href] : undefined,
182
+ raw: m[0],
183
+ };
184
+ }
185
+ }
186
+ }
187
+ if (!bestMatch || bestIdx === -1) {
188
+ out.push({ kind: 'text', text: input.slice(cursor) });
189
+ break;
190
+ }
191
+ if (bestIdx > 0) {
192
+ out.push({ kind: 'text', text: input.slice(cursor, cursor + bestIdx) });
193
+ }
194
+ out.push({ kind: bestMatch.kind, text: bestMatch.text, href: bestMatch.href });
195
+ cursor += bestIdx + bestMatch.raw.length;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ function renderInline(text: string, theme: Theme, baseColor?: string): React.ReactNode[] {
201
+ const tokens = tokenizeInline(text);
202
+ return tokens.map((tok, i) => {
203
+ const key = `${i}-${tok.kind}`;
204
+ if (tok.kind === 'text') {
205
+ return (
206
+ <Text key={key} color={baseColor}>
207
+ {tok.text}
208
+ </Text>
209
+ );
210
+ }
211
+ if (tok.kind === 'bold') {
212
+ return (
213
+ <Text key={key} color={baseColor} bold={true}>
214
+ {tok.text}
215
+ </Text>
216
+ );
217
+ }
218
+ if (tok.kind === 'italic') {
219
+ return (
220
+ <Text key={key} color={baseColor} italic={true}>
221
+ {tok.text}
222
+ </Text>
223
+ );
224
+ }
225
+ if (tok.kind === 'code') {
226
+ return (
227
+ <Text key={key} color={theme.markdown.codeText} backgroundColor={theme.markdown.codeBackground}>
228
+ {` ${tok.text} `}
229
+ </Text>
230
+ );
231
+ }
232
+ if (tok.kind === 'link') {
233
+ return (
234
+ <Text key={key} color={theme.markdown.link} underline={true}>
235
+ {tok.text}
236
+ {tok.href ? (
237
+ <Text color={theme.markdown.link} dimColor={true}>
238
+ {' '}
239
+ ({tok.href})
240
+ </Text>
241
+ ) : null}
242
+ </Text>
243
+ );
244
+ }
245
+ return null;
246
+ });
247
+ }
248
+
249
+ // ─── Block renderers ──────────────────────────────────────────────────────────
250
+
251
+ function HeadingBlock({ block, theme }: { block: Extract<Block, { type: 'heading' }>; theme: Theme }) {
252
+ const prefix = block.level === 1 ? '# ' : block.level === 2 ? '## ' : '### ';
253
+ return (
254
+ <Box marginBottom={1}>
255
+ <Text color={theme.markdown.heading} bold={block.level <= 2}>
256
+ {prefix}
257
+ {block.text}
258
+ </Text>
259
+ </Box>
260
+ );
261
+ }
262
+
263
+ function ParagraphBlock({
264
+ block,
265
+ theme,
266
+ color,
267
+ }: {
268
+ block: Extract<Block, { type: 'paragraph' }>;
269
+ theme: Theme;
270
+ color?: string;
271
+ }) {
272
+ return (
273
+ <Box marginBottom={1}>
274
+ <Text wrap="wrap" color={color}>
275
+ {renderInline(block.text, theme, color)}
276
+ </Text>
277
+ </Box>
278
+ );
279
+ }
280
+
281
+ function ListBlock({ block, theme, color }: { block: Extract<Block, { type: 'list' }>; theme: Theme; color?: string }) {
282
+ return (
283
+ <Box flexDirection="column" marginBottom={1}>
284
+ {block.items.map((item, idx) => {
285
+ const marker = block.ordered ? `${idx + 1}.` : '•';
286
+ return (
287
+ // biome-ignore lint/suspicious/noArrayIndexKey: list items have no stable id
288
+ <Box key={idx}>
289
+ <Text color={theme.markdown.bullet}>{` ${marker} `}</Text>
290
+ <Box flexShrink={1} flexGrow={1}>
291
+ <Text wrap="wrap" color={color}>
292
+ {renderInline(item, theme, color)}
293
+ </Text>
294
+ </Box>
295
+ </Box>
296
+ );
297
+ })}
298
+ </Box>
299
+ );
300
+ }
301
+
302
+ function CodeBlock({ block, theme }: { block: Extract<Block, { type: 'code' }>; theme: Theme }) {
303
+ return (
304
+ <Box flexDirection="column" marginBottom={1} paddingX={1} backgroundColor={theme.markdown.codeBlockBackground}>
305
+ {block.lang && (
306
+ <Text dimColor={true} color={theme.markdown.codeBlockText}>
307
+ {block.lang}
308
+ </Text>
309
+ )}
310
+ {block.lines.map((ln, i) => (
311
+ // biome-ignore lint/suspicious/noArrayIndexKey: code lines have no stable id and may repeat
312
+ <Text key={`${i}-${ln}`} color={theme.markdown.codeBlockText}>
313
+ {ln || ' '}
314
+ </Text>
315
+ ))}
316
+ </Box>
317
+ );
318
+ }
319
+
320
+ function QuoteBlock({ block, theme }: { block: Extract<Block, { type: 'quote' }>; theme: Theme }) {
321
+ return (
322
+ <Box flexDirection="column" marginBottom={1}>
323
+ {block.lines.map((ln, i) => (
324
+ <Box key={`${i}-${ln}`}>
325
+ <Text color={theme.markdown.blockquote}> │ </Text>
326
+ <Box flexShrink={1} flexGrow={1}>
327
+ <Text wrap="wrap" color={theme.markdown.blockquote} italic={true}>
328
+ {renderInline(ln, theme, theme.markdown.blockquote)}
329
+ </Text>
330
+ </Box>
331
+ </Box>
332
+ ))}
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ function TableBlock({ block, theme }: { block: Extract<Block, { type: 'table' }>; theme: Theme }) {
338
+ // Compute column widths based on the longest cell per column.
339
+ const colCount = Math.max(block.header.length, ...block.rows.map((r) => r.length));
340
+ const widths: number[] = new Array(colCount).fill(0);
341
+ for (let c = 0; c < colCount; c++) {
342
+ widths[c] = (block.header[c] ?? '').length;
343
+ for (const row of block.rows) {
344
+ widths[c] = Math.max(widths[c], (row[c] ?? '').length);
345
+ }
346
+ }
347
+ const renderRow = (cells: string[], bold: boolean, key: string) => (
348
+ <Box key={key}>
349
+ {Array.from({ length: colCount }, (_, c) => (
350
+ <Box key={c} marginRight={c === colCount - 1 ? 0 : 2}>
351
+ <Text bold={bold}>{(cells[c] ?? '').padEnd(widths[c])}</Text>
352
+ </Box>
353
+ ))}
354
+ </Box>
355
+ );
356
+ const sep = (
357
+ <Box>
358
+ {Array.from({ length: colCount }, (_, c) => (
359
+ <Box key={c} marginRight={c === colCount - 1 ? 0 : 2}>
360
+ <Text color={theme.markdown.tableBorder}>{'─'.repeat(widths[c])}</Text>
361
+ </Box>
362
+ ))}
363
+ </Box>
364
+ );
365
+ return (
366
+ <Box flexDirection="column" marginBottom={1}>
367
+ {renderRow(block.header, true, 'header')}
368
+ {sep}
369
+ {block.rows.map((r, i) => renderRow(r, false, `r-${i}`))}
370
+ </Box>
371
+ );
372
+ }
373
+
374
+ function HrBlock({ theme }: { theme: Theme }) {
375
+ return (
376
+ <Box marginBottom={1}>
377
+ <Text color={theme.markdown.tableBorder}>{'─'.repeat(40)}</Text>
378
+ </Box>
379
+ );
380
+ }
381
+
382
+ // ─── Public renderer ─────────────────────────────────────────────────────────
383
+
384
+ export function MarkdownContent({ content, color }: { content: string; color?: string }) {
385
+ const theme = useTheme();
386
+ const blocks = parseBlocks(content);
387
+ return (
388
+ <Box flexDirection="column">
389
+ {blocks.map((b, i) => {
390
+ const key = `${i}-${b.type}`;
391
+ if (b.type === 'heading') return <HeadingBlock key={key} block={b} theme={theme} />;
392
+ if (b.type === 'paragraph') return <ParagraphBlock key={key} block={b} theme={theme} color={color} />;
393
+ if (b.type === 'list') return <ListBlock key={key} block={b} theme={theme} color={color} />;
394
+ if (b.type === 'code') return <CodeBlock key={key} block={b} theme={theme} />;
395
+ if (b.type === 'quote') return <QuoteBlock key={key} block={b} theme={theme} />;
396
+ if (b.type === 'table') return <TableBlock key={key} block={b} theme={theme} />;
397
+ if (b.type === 'hr') return <HrBlock key={key} theme={theme} />;
398
+ return null;
399
+ })}
400
+ </Box>
401
+ );
402
+ }
@@ -1,5 +1,6 @@
1
- import type { ChatMessage } from 'mu-provider';
1
+ import type { ChatMessage } from 'mu-core';
2
2
  import React from 'react';
3
+ import { useMessageRenderer } from '../../chat/MessageRendererContext';
3
4
  import { AssistantMessage } from './assistantMessage';
4
5
  import { UserMessage } from './userMessage';
5
6
 
@@ -7,6 +8,23 @@ export const MessageItem: React.FC<{
7
8
  msg: ChatMessage;
8
9
  toolMessages?: ChatMessage[];
9
10
  }> = React.memo(function MessageItem({ msg, toolMessages }) {
11
+ const customRenderer = useMessageRenderer(msg.customType);
12
+
13
+ // Plugins may flag a message as `hidden` to keep it in the LLM transcript
14
+ // while suppressing on-screen rendering (e.g. system reminders carried with
15
+ // the user's next turn).
16
+ if (msg.display?.hidden) {
17
+ return null;
18
+ }
19
+
20
+ // Custom-typed messages always defer to a registered renderer when one is
21
+ // available; otherwise fall through to the role-default rendering so a
22
+ // plugin can ship messages whose renderer isn't loaded yet without losing
23
+ // their content.
24
+ if (customRenderer) {
25
+ return <>{customRenderer(msg)}</>;
26
+ }
27
+
10
28
  // Tool result messages are rendered inline within ToolCallBlock via the
11
29
  // pre-built index passed from MessageView; suppress them at the top level.
12
30
  if (msg.role === 'tool') {
@@ -1,13 +1,17 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
 
3
4
  export function ReasoningBlock({ reasoning }: { reasoning: string }) {
5
+ const theme = useTheme();
4
6
  return (
5
- <Box flexDirection="column" marginTop={0} marginBottom={1}>
6
- <Text color="yellow" italic={true}>
7
- thinking
8
- </Text>
9
- <Text dimColor={true} italic={true} wrap="wrap">
10
- {reasoning}
7
+ <Box marginBottom={0}>
8
+ <Text wrap="wrap">
9
+ <Text color={theme.reasoning.title} italic={true}>
10
+ thinking:{' '}
11
+ </Text>
12
+ <Text color={theme.reasoning.body} italic={true}>
13
+ {reasoning}
14
+ </Text>
11
15
  </Text>
12
16
  </Box>
13
17
  );
@@ -1,13 +1,17 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
  import { ReasoningBlock } from './reasoningBlock';
3
4
 
4
5
  export function StreamingOutput({ currentText, currentReasoning }: { currentText: string; currentReasoning: string }) {
6
+ const theme = useTheme();
5
7
  return (
6
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
8
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
7
9
  {currentReasoning && <ReasoningBlock reasoning={currentReasoning} />}
8
10
  <Text wrap="wrap">
9
11
  {currentText}
10
- <Text inverse={true}>▎</Text>
12
+ <Text color={theme.input.cursor} inverse={true}>
13
+
14
+ </Text>
11
15
  </Text>
12
16
  </Box>
13
17
  );
@@ -1,7 +1,7 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ToolDisplayHint } from 'mu-agents';
3
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage, ToolDisplayHint } from 'mu-core';
4
3
  import { useToolDisplay } from '../../chat/ToolDisplayContext';
4
+ import { useTheme } from '../../context/ThemeContext';
5
5
  import { useSpinner } from '../../hooks/useUI';
6
6
  import { EditOutput } from './EditOutput';
7
7
  import { ReadOutput } from './ReadOutput';
@@ -47,7 +47,7 @@ export function ToolCallBlock({
47
47
  const argSummary = getArgSummary(args, hint);
48
48
 
49
49
  return (
50
- <Box flexDirection="column" flexShrink={0}>
50
+ <Box flexDirection="column" flexShrink={0} marginTop={1} marginBottom={1}>
51
51
  {!hasResult ? (
52
52
  <Box>
53
53
  <Text dimColor={true}>
@@ -90,6 +90,7 @@ interface GenericProps {
90
90
  }
91
91
 
92
92
  function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
93
+ const theme = useTheme();
93
94
  let summary = '';
94
95
  const commandField = hint?.fields?.command;
95
96
  if (commandField) {
@@ -104,7 +105,7 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
104
105
  const preview = content.length > 200 ? `${content.slice(0, 200)}…` : content;
105
106
  return (
106
107
  <Box flexDirection="column" flexShrink={0}>
107
- <Text color={error ? 'red' : 'green'} bold={true}>
108
+ <Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
108
109
  {error ? '✗' : '✓'} {name}
109
110
  {summary && (
110
111
  <>
@@ -113,8 +114,8 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
113
114
  </>
114
115
  )}
115
116
  </Text>
116
- <Box flexDirection="column" backgroundColor="#111111" padding={1} marginTop={1}>
117
- <Text color="white">{preview}</Text>
117
+ <Box flexDirection="column" backgroundColor={theme.tool.previewBackground} paddingX={1} paddingY={0}>
118
+ <Text color={theme.tool.previewText}>{preview}</Text>
118
119
  </Box>
119
120
  </Box>
120
121
  );
@@ -1,29 +1,44 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage } from 'mu-core';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
 
4
5
  export function UserMessage({ msg }: { msg: ChatMessage }) {
6
+ const theme = useTheme();
7
+ const borderColor = msg.display?.color ?? theme.user.border;
8
+ const badge = msg.display?.badge;
9
+ const prefix = msg.display?.prefix;
5
10
  return (
6
11
  <Box
7
12
  flexDirection="column"
8
13
  flexShrink={0}
9
- marginY={1}
10
- backgroundColor="#1a1a1a"
14
+ marginBottom={1}
15
+ backgroundColor={theme.user.background}
11
16
  paddingX={1}
12
17
  paddingY={1}
13
18
  borderLeft={true}
14
19
  borderTop={false}
15
20
  borderBottom={false}
16
21
  borderRight={false}
17
- borderColor="yellow"
22
+ borderColor={borderColor}
18
23
  borderStyle="single"
19
24
  >
25
+ {badge && (
26
+ <Box>
27
+ <Text color={msg.display?.color} bold={true}>
28
+ {badge.charAt(0).toUpperCase() + badge.slice(1)}
29
+ </Text>
30
+ </Box>
31
+ )}
20
32
  {msg.images && msg.images.length > 0 && (
21
33
  <Box>
22
- <Text color="cyan">📷 </Text>
23
- <Text color="cyan">{msg.images.map((img) => img.name).join(', ')}</Text>
34
+ <Text color={theme.user.attachment}>📷 </Text>
35
+ <Text color={theme.user.attachment}>{msg.images.map((img) => img.name).join(', ')}</Text>
24
36
  </Box>
25
37
  )}
26
- <Text wrap="wrap">{msg.content}</Text>
38
+ <Text wrap="wrap">
39
+ {prefix && <Text color={msg.display?.color}>{prefix}</Text>}
40
+ {msg.content}
41
+ </Text>
27
42
  </Box>
28
43
  );
29
44
  }