mu-coding 0.8.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 (41) hide show
  1. package/package.json +4 -4
  2. package/src/cli/install.ts +18 -3
  3. package/src/plugin.ts +33 -5
  4. package/src/runtime/createRegistry.test.ts +4 -3
  5. package/src/runtime/createRegistry.ts +34 -2
  6. package/src/runtime/fileMentionProvider.ts +116 -0
  7. package/src/runtime/pluginLoader.ts +37 -6
  8. package/src/tui/channel/tuiChannel.ts +14 -1
  9. package/src/tui/chat/useAbort.ts +5 -0
  10. package/src/tui/chat/useChat.ts +7 -0
  11. package/src/tui/chat/useChatPanel.ts +24 -3
  12. package/src/tui/chat/useChatSession.ts +105 -7
  13. package/src/tui/chat/useModels.ts +25 -1
  14. package/src/tui/chat/useSessionPersistence.ts +27 -11
  15. package/src/tui/chat/useStatusSegments.ts +26 -6
  16. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  17. package/src/tui/components/chat/ChatPanel.tsx +16 -1
  18. package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
  19. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  20. package/src/tui/components/messages/EditOutput.tsx +11 -5
  21. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  22. package/src/tui/components/messages/ToolHeader.tsx +6 -4
  23. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  24. package/src/tui/components/messages/assistantMessage.tsx +43 -10
  25. package/src/tui/components/messages/markdown.tsx +402 -0
  26. package/src/tui/components/messages/reasoningBlock.tsx +8 -6
  27. package/src/tui/components/messages/streamingOutput.tsx +1 -1
  28. package/src/tui/components/messages/toolCallBlock.tsx +2 -2
  29. package/src/tui/components/messages/userMessage.tsx +3 -3
  30. package/src/tui/components/primitives/toast.tsx +38 -7
  31. package/src/tui/components/statusBar.tsx +24 -15
  32. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  33. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  34. package/src/tui/input/InputBoxView.tsx +71 -15
  35. package/src/tui/input/commands.ts +5 -0
  36. package/src/tui/input/useInputBox.ts +29 -3
  37. package/src/tui/input/useInputHandler.ts +1 -0
  38. package/src/tui/input/useMentionPicker.ts +26 -14
  39. package/src/tui/renderApp.tsx +29 -8
  40. package/src/tui/theme/presets.ts +12 -1
  41. package/src/tui/theme/types.ts +22 -0
@@ -0,0 +1,22 @@
1
+ import type { InputInfoSegment } from 'mu-core';
2
+ import { useEffect, useState } from 'react';
3
+ import { useChatContext } from '../chat/ChatContext';
4
+
5
+ /**
6
+ * Subscribe to the aggregated input-info segments published by plugins via
7
+ * `PluginContext.setInputInfo`. Returns the live snapshot; re-renders on
8
+ * every push from any plugin.
9
+ */
10
+ export function useInputInfoSegments(): InputInfoSegment[] {
11
+ const { registry } = useChatContext();
12
+ const [segments, setSegments] = useState<InputInfoSegment[]>(() => registry.getInputInfoSegments());
13
+
14
+ useEffect(() => {
15
+ const unsub = registry.onInputInfoChange(() => {
16
+ setSegments(registry.getInputInfoSegments());
17
+ });
18
+ return unsub;
19
+ }, [registry]);
20
+
21
+ return segments;
22
+ }
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { MentionCompletion } from 'mu-core';
2
+ import type { InputInfoSegment, MentionCompletion } from 'mu-core';
3
3
  import { useTheme } from '../context/ThemeContext';
4
4
  import type { Theme } from '../theme/types';
5
5
  import type { SlashCommand } from './commands';
@@ -19,6 +19,7 @@ export interface InputBoxViewProps {
19
19
  streaming: boolean;
20
20
  isActive: boolean;
21
21
  model: string;
22
+ infoSegments: InputInfoSegment[];
22
23
  attachmentName: string | null;
23
24
  attachmentError: string | null;
24
25
  mentions: MentionPickerView | null;
@@ -51,22 +52,69 @@ function CommandHints({
51
52
  );
52
53
  }
53
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
+
54
78
  function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
55
79
  if (!mentions.completions.length) {
56
80
  return null;
57
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());
58
94
  return (
59
95
  <Box flexDirection="column" marginBottom={1}>
60
- {mentions.completions.map((c, i) => (
61
- <Box key={c.value} paddingX={1}>
62
- <Text
63
- color={i === mentions.selectedIndex ? theme.input.commandHighlight : undefined}
64
- bold={i === mentions.selectedIndex}
65
- >
66
- {i === mentions.selectedIndex ? '▸ @' : ' @'}
67
- {c.label ?? c.value}
68
- </Text>
69
- {c.description && <Text dimColor={true}> {c.description}</Text>}
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
+ })}
70
118
  </Box>
71
119
  ))}
72
120
  </Box>
@@ -75,6 +123,7 @@ function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme:
75
123
 
76
124
  function InputFooter({
77
125
  model,
126
+ infoSegments,
78
127
  attachmentName,
79
128
  attachmentError,
80
129
  hasContent,
@@ -83,6 +132,7 @@ function InputFooter({
83
132
  theme,
84
133
  }: {
85
134
  model: string;
135
+ infoSegments: InputInfoSegment[];
86
136
  attachmentName: string | null;
87
137
  attachmentError: string | null;
88
138
  hasContent: boolean;
@@ -91,16 +141,21 @@ function InputFooter({
91
141
  theme: Theme;
92
142
  }) {
93
143
  const hint = hasMentions
94
- ? '↑↓ select · Tab/Enter accept'
144
+ ? '↑↓ · Tab accept'
95
145
  : hasContent
96
146
  ? isCommandMode
97
- ? '↑↓ select · Enter execute'
98
- : 'Enter to send · Shift+Enter for newline · ←→ move'
99
- : 'Type / for commands · @ for mentions';
147
+ ? '↑↓ · Enter run'
148
+ : ''
149
+ : '/ commands · @ mentions';
100
150
 
101
151
  return (
102
152
  <Box justifyContent="space-between">
103
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
+ ))}
104
159
  {model && (
105
160
  <Text color={theme.input.modelLabel} bold={true}>
106
161
  {model}
@@ -225,6 +280,7 @@ export function InputBoxView(props: InputBoxViewProps) {
225
280
  </Box>
226
281
  <InputFooter
227
282
  model={props.model}
283
+ infoSegments={props.infoSegments}
228
284
  attachmentName={props.attachmentName}
229
285
  attachmentError={props.attachmentError}
230
286
  hasContent={props.value.length > 0}
@@ -20,6 +20,11 @@ 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
+ },
23
28
  {
24
29
  name: '/context',
25
30
  description: 'Show the LLM context (system prompt, messages, tools) as plain text',
@@ -1,3 +1,4 @@
1
+ import type { InputInfoSegment } from 'mu-core';
1
2
  import { useCallback, useMemo, useRef } from 'react';
2
3
  import { useChatContext } from '../chat/ChatContext';
3
4
  import { dumpContext } from './dumpContext';
@@ -14,6 +15,12 @@ export interface InputBoxProps {
14
15
  isActive?: boolean;
15
16
  model?: string;
16
17
  history?: string[];
18
+ /**
19
+ * Extra info chips rendered in the footer (before the model). Generic
20
+ * mechanism for upstream consumers to surface context (active agent,
21
+ * branch, ...) without `InputBox` knowing what they are.
22
+ */
23
+ infoSegments?: InputInfoSegment[];
17
24
  }
18
25
 
19
26
  interface BufferDraft {
@@ -24,7 +31,9 @@ interface BufferDraft {
24
31
  /**
25
32
  * Replace `[triggerStart, cursor)` (the `<trigger><partial>` token) with the
26
33
  * chosen completion value plus a trailing space, so the user is left in a
27
- * sensible position for further input.
34
+ * sensible position for further input. File completions drop the trigger
35
+ * (`@`) entirely — the path stands alone in the prompt — while other
36
+ * categories (agents) keep the `@` prefix as a visible marker.
28
37
  */
29
38
  function applyMention(
30
39
  value: string,
@@ -32,10 +41,12 @@ function applyMention(
32
41
  cursor: number,
33
42
  trigger: string,
34
43
  completion: string,
44
+ category: string | undefined,
35
45
  ): BufferDraft {
36
46
  const before = value.slice(0, triggerStart);
37
47
  const after = value.slice(cursor);
38
- const insertion = `${trigger}${completion} `;
48
+ const keepTrigger = category !== 'files';
49
+ const insertion = `${keepTrigger ? trigger : ''}${completion} `;
39
50
  return { value: before + insertion + after, cursor: triggerStart + insertion.length };
40
51
  }
41
52
 
@@ -65,7 +76,14 @@ function buildMentionMode(mentions: MentionPickerState, input: InputHandle): Men
65
76
  const completion = mentions.completions[mentions.selectedIndex];
66
77
  const trig = mentions.trigger;
67
78
  if (!(completion && trig)) return;
68
- const draft = applyMention(input.value, mentions.triggerStart, input.cursor, trig, completion.value);
79
+ const draft = applyMention(
80
+ input.value,
81
+ mentions.triggerStart,
82
+ input.cursor,
83
+ trig,
84
+ completion.value,
85
+ completion.category,
86
+ );
69
87
  input.setBuffer(draft.value, draft.cursor);
70
88
  },
71
89
  };
@@ -90,6 +108,9 @@ function useInputActions(deps: ActionDeps): InputActions {
90
108
  onEsc: abort.onEsc,
91
109
  onPaste: attachment.onPaste,
92
110
  onNew: session.onNew,
111
+ onCompact: () => {
112
+ void session.onCompact();
113
+ },
93
114
  onCycleModel: models.cycleModel,
94
115
  onTogglePicker: toggles.onTogglePicker,
95
116
  onToggleSessionPicker: toggles.onToggleSessionPicker,
@@ -103,6 +124,7 @@ function useInputActions(deps: ActionDeps): InputActions {
103
124
  abort.onEsc,
104
125
  attachment.onPaste,
105
126
  session.onNew,
127
+ session.onCompact,
106
128
  models.cycleModel,
107
129
  models.models.length,
108
130
  toggles.onTogglePicker,
@@ -114,6 +136,8 @@ function useInputActions(deps: ActionDeps): InputActions {
114
136
  );
115
137
  }
116
138
 
139
+ const EMPTY_SEGMENTS: InputInfoSegment[] = [];
140
+
117
141
  export function useInputBox({
118
142
  onSubmit,
119
143
  onScrollUp,
@@ -121,6 +145,7 @@ export function useInputBox({
121
145
  isActive = true,
122
146
  model = '',
123
147
  history = [],
148
+ infoSegments = EMPTY_SEGMENTS,
124
149
  }: InputBoxProps): InputBoxViewProps {
125
150
  const { config, session, toggles, attachment, models, abort, registry, uiService } = useChatContext();
126
151
  // Ref pattern: the mention controls depend on the input handler's
@@ -193,6 +218,7 @@ export function useInputBox({
193
218
  streaming: session.streaming,
194
219
  isActive,
195
220
  model,
221
+ infoSegments,
196
222
  attachmentName: attachment.attachment?.name ?? null,
197
223
  attachmentError: attachment.attachmentError,
198
224
  mentions:
@@ -28,6 +28,7 @@ export interface InputActions {
28
28
  onCtrlC?: () => void;
29
29
  onPaste?: () => void;
30
30
  onNew?: () => void;
31
+ onCompact?: () => void;
31
32
  onCycleModel?: () => void;
32
33
  onTogglePicker?: () => void;
33
34
  onToggleSessionPicker?: () => void;
@@ -79,25 +79,37 @@ export function useMentionPicker(registry: PluginRegistry, value: string, cursor
79
79
  return;
80
80
  }
81
81
  const partial = value.slice(match.start + 1, cursor);
82
- const provider = providers.find((p) => p.trigger === match.trigger);
83
- if (!provider) {
82
+ const matching = providers.filter((p) => p.trigger === match.trigger);
83
+ if (matching.length === 0) {
84
84
  setBase(EMPTY);
85
85
  return;
86
86
  }
87
87
  let cancelled = false;
88
- Promise.resolve(provider.provider(partial))
89
- .then((completions) => {
90
- if (cancelled) return;
91
- setBase({
92
- trigger: match.trigger,
93
- partial,
94
- completions,
95
- triggerStart: match.start,
96
- });
97
- })
98
- .catch(() => {
99
- if (!cancelled) setBase(EMPTY);
88
+ // Run every provider that registered for this trigger and concatenate
89
+ // results in registration order. Providers tag completions with
90
+ // `category` so the picker can render section headers (e.g. agents
91
+ // first, then files) without the picker hard-coding any plugin id.
92
+ Promise.all(
93
+ matching.map(async (entry) => {
94
+ try {
95
+ const out = await Promise.resolve(entry.provider(partial));
96
+ // Default the category to the plugin name so legacy providers
97
+ // that don't set `category` still get visually grouped.
98
+ return out.map((c) => ({ ...c, category: c.category ?? entry.plugin }));
99
+ } catch {
100
+ return [];
101
+ }
102
+ }),
103
+ ).then((groups) => {
104
+ if (cancelled) return;
105
+ const completions = groups.flat();
106
+ setBase({
107
+ trigger: match.trigger,
108
+ partial,
109
+ completions,
110
+ triggerStart: match.start,
100
111
  });
112
+ });
101
113
  return () => {
102
114
  cancelled = true;
103
115
  };
@@ -1,7 +1,10 @@
1
1
  import { type Instance, render } from 'ink';
2
+ import { type SubagentRunRegistry, SubagentRunsProvider } from 'mu-agents';
2
3
  import type { ChatMessage, PluginRegistry } from 'mu-core';
4
+ import type { ReactNode } from 'react';
3
5
  import type { ShutdownFn } from '../app/shutdown';
4
6
  import type { AppConfig } from '../config/index';
7
+ import type { SessionPathHolder } from '../runtime/createRegistry';
5
8
  import type { HostMessageBus } from '../runtime/messageBus';
6
9
  import { ChatPanel } from './components/chat/ChatPanel';
7
10
  import { ThemeProvider } from './context/ThemeContext';
@@ -15,6 +18,19 @@ interface RenderAppOptions {
15
18
  messageBus: HostMessageBus;
16
19
  uiService: InkUIService;
17
20
  shutdown: ShutdownFn;
21
+ sessionPathHolder?: SessionPathHolder;
22
+ subagentRuns?: SubagentRunRegistry;
23
+ }
24
+
25
+ /**
26
+ * Optionally wrap children with the subagent-runs provider so the
27
+ * `↳ subagent` header renderer can subscribe to live status updates.
28
+ * Wrapping is conditional because hosts that disabled the agent plugin
29
+ * have no registry to provide.
30
+ */
31
+ function withSubagentProvider(runs: SubagentRunRegistry | undefined, children: ReactNode): ReactNode {
32
+ if (!runs) return <>{children}</>;
33
+ return <SubagentRunsProvider registry={runs}>{children}</SubagentRunsProvider>;
18
34
  }
19
35
 
20
36
  /**
@@ -26,14 +42,19 @@ export function renderApp(options: RenderAppOptions): Instance {
26
42
  const theme = resolveTheme(options.config.theme);
27
43
  return render(
28
44
  <ThemeProvider theme={theme}>
29
- <ChatPanel
30
- config={options.config}
31
- initialMessages={options.initialMessages}
32
- registry={options.registry}
33
- messageBus={options.messageBus}
34
- uiService={options.uiService}
35
- shutdown={options.shutdown}
36
- />
45
+ {withSubagentProvider(
46
+ options.subagentRuns,
47
+ <ChatPanel
48
+ config={options.config}
49
+ initialMessages={options.initialMessages}
50
+ registry={options.registry}
51
+ messageBus={options.messageBus}
52
+ uiService={options.uiService}
53
+ shutdown={options.shutdown}
54
+ sessionPathHolder={options.sessionPathHolder}
55
+ subagentRuns={options.subagentRuns}
56
+ />,
57
+ )}
37
58
  </ThemeProvider>,
38
59
  {
39
60
  exitOnCtrlC: false,
@@ -28,7 +28,7 @@ export const DEFAULT_THEME: Theme = {
28
28
  tool: {
29
29
  success: 'green',
30
30
  error: 'red',
31
- previewBackground: '#111111',
31
+ previewBackground: '#2a2a2a',
32
32
  previewText: 'white',
33
33
  summaryDim: 'gray',
34
34
  warning: 'yellow',
@@ -69,6 +69,17 @@ export const DEFAULT_THEME: Theme = {
69
69
  status: {
70
70
  separator: 'gray',
71
71
  },
72
+ markdown: {
73
+ heading: 'cyan',
74
+ codeBackground: '#2a2a2a',
75
+ codeText: 'yellow',
76
+ codeBlockBackground: '#2a2a2a',
77
+ codeBlockText: 'white',
78
+ link: 'cyan',
79
+ blockquote: 'gray',
80
+ bullet: 'cyan',
81
+ tableBorder: 'gray',
82
+ },
72
83
  common: {
73
84
  error: 'red',
74
85
  warning: 'yellow',
@@ -79,6 +79,27 @@ interface ThemeStatus {
79
79
  separator: string;
80
80
  }
81
81
 
82
+ interface ThemeMarkdown {
83
+ /** Heading text color (h1/h2/h3 share this color, h1 is also bold). */
84
+ heading: string;
85
+ /** Inline code background. */
86
+ codeBackground: string;
87
+ /** Inline code text color. */
88
+ codeText: string;
89
+ /** Fenced code-block background. */
90
+ codeBlockBackground: string;
91
+ /** Fenced code-block text color. */
92
+ codeBlockText: string;
93
+ /** Link `[label](url)` rendering — label color. */
94
+ link: string;
95
+ /** Blockquote (`> …`) text color (also dimmed). */
96
+ blockquote: string;
97
+ /** List bullet color. */
98
+ bullet: string;
99
+ /** Table border color. */
100
+ tableBorder: string;
101
+ }
102
+
82
103
  interface ThemeCommon {
83
104
  error: string;
84
105
  warning: string;
@@ -99,6 +120,7 @@ export interface Theme {
99
120
  dialog: ThemeDialog;
100
121
  diff: ThemeDiff;
101
122
  status: ThemeStatus;
123
+ markdown: ThemeMarkdown;
102
124
  common: ThemeCommon;
103
125
  }
104
126