mu-coding 0.5.0 → 0.8.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 (74) 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/config/index.test.ts +26 -0
  8. package/src/config/index.ts +25 -7
  9. package/src/plugin.ts +96 -0
  10. package/src/runtime/codingTools/bash.ts +114 -0
  11. package/src/runtime/codingTools/edit-file.ts +60 -0
  12. package/src/runtime/codingTools/index.ts +39 -0
  13. package/src/runtime/codingTools/read-file.ts +83 -0
  14. package/src/runtime/codingTools/utils.ts +21 -0
  15. package/src/runtime/codingTools/write-file.ts +42 -0
  16. package/src/runtime/createRegistry.test.ts +146 -0
  17. package/src/runtime/createRegistry.ts +128 -23
  18. package/src/runtime/messageBus.test.ts +62 -0
  19. package/src/runtime/messageBus.ts +78 -0
  20. package/src/runtime/pluginLoader.ts +22 -9
  21. package/src/sessions/index.ts +2 -9
  22. package/src/tui/channel/tuiChannel.test.ts +107 -0
  23. package/src/tui/channel/tuiChannel.ts +49 -0
  24. package/src/tui/chat/MessageRendererContext.ts +44 -0
  25. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  26. package/src/tui/chat/useAttachment.ts +1 -1
  27. package/src/tui/chat/useChat.ts +31 -3
  28. package/src/tui/chat/useChatPanel.ts +7 -5
  29. package/src/tui/chat/useChatSession.ts +222 -53
  30. package/src/tui/chat/useModels.ts +2 -1
  31. package/src/tui/chat/usePluginStatus.ts +1 -1
  32. package/src/tui/chat/useSessionPersistence.ts +25 -14
  33. package/src/tui/chat/useStatusSegments.ts +17 -4
  34. package/src/tui/components/chat/ChatPanel.tsx +10 -4
  35. package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
  36. package/src/tui/components/messageView.tsx +4 -2
  37. package/src/tui/components/messages/EditOutput.tsx +6 -4
  38. package/src/tui/components/messages/ToolHeader.tsx +3 -1
  39. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  40. package/src/tui/components/messages/messageItem.tsx +19 -1
  41. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  42. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  43. package/src/tui/components/messages/toolCallBlock.tsx +6 -5
  44. package/src/tui/components/messages/userMessage.tsx +21 -6
  45. package/src/tui/components/primitives/dropdown.tsx +8 -4
  46. package/src/tui/components/primitives/modal.tsx +4 -2
  47. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  48. package/src/tui/components/primitives/toast.tsx +5 -3
  49. package/src/tui/components/statusBar.tsx +8 -1
  50. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  51. package/src/tui/context/ThemeContext.tsx +18 -0
  52. package/src/tui/input/InputBoxView.tsx +135 -26
  53. package/src/tui/input/commands.test.ts +3 -1
  54. package/src/tui/input/commands.ts +6 -1
  55. package/src/tui/input/cursor.test.ts +136 -0
  56. package/src/tui/input/cursor.ts +214 -0
  57. package/src/tui/input/dumpContext.ts +107 -0
  58. package/src/tui/input/sanitize.ts +1 -1
  59. package/src/tui/input/useCommandExecutor.ts +1 -1
  60. package/src/tui/input/useInputBox.ts +134 -15
  61. package/src/tui/input/useInputHandler.ts +316 -126
  62. package/src/tui/input/useMentionPicker.ts +121 -0
  63. package/src/tui/input/usePluginShortcuts.ts +29 -0
  64. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  65. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  66. package/src/tui/plugins/InkUIService.ts +1 -1
  67. package/src/tui/renderApp.tsx +26 -13
  68. package/src/tui/theme/index.ts +1 -0
  69. package/src/tui/theme/merge.test.ts +49 -0
  70. package/src/tui/theme/merge.ts +43 -0
  71. package/src/tui/theme/presets.ts +79 -0
  72. package/src/tui/theme/types.ts +116 -0
  73. package/src/utils/clipboard.ts +1 -1
  74. package/src/tui/chat/useStreamConsumer.ts +0 -118
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ cursorRowCol,
4
+ deleteBackward,
5
+ deleteForward,
6
+ deleteWordBackward,
7
+ insertAt,
8
+ killToLineEnd,
9
+ killToLineStart,
10
+ moveLeft,
11
+ moveLineDown,
12
+ moveLineEnd,
13
+ moveLineHome,
14
+ moveLineUp,
15
+ moveRight,
16
+ moveWordLeft,
17
+ moveWordRight,
18
+ positionAt,
19
+ } from './cursor';
20
+
21
+ const s = (value: string, cursor: number) => ({ value, cursor });
22
+
23
+ describe('insertAt', () => {
24
+ it('inserts at the cursor and advances it', () => {
25
+ expect(insertAt(s('hello', 5), '!')).toEqual({ value: 'hello!', cursor: 6 });
26
+ expect(insertAt(s('helo', 2), 'l')).toEqual({ value: 'hello', cursor: 3 });
27
+ });
28
+ it('is a no-op for empty text', () => {
29
+ expect(insertAt(s('a', 1), '')).toEqual({ value: 'a', cursor: 1 });
30
+ });
31
+ it('clamps an out-of-range cursor', () => {
32
+ expect(insertAt(s('abc', 99), 'x')).toEqual({ value: 'abcx', cursor: 4 });
33
+ });
34
+ });
35
+
36
+ describe('deleteBackward', () => {
37
+ it('removes the char left of the cursor', () => {
38
+ expect(deleteBackward(s('hello', 5))).toEqual({ value: 'hell', cursor: 4 });
39
+ expect(deleteBackward(s('hello', 1))).toEqual({ value: 'ello', cursor: 0 });
40
+ });
41
+ it('is a no-op at position 0', () => {
42
+ expect(deleteBackward(s('hello', 0))).toEqual({ value: 'hello', cursor: 0 });
43
+ });
44
+ });
45
+
46
+ describe('deleteForward', () => {
47
+ it('removes the char at the cursor', () => {
48
+ expect(deleteForward(s('hello', 0))).toEqual({ value: 'ello', cursor: 0 });
49
+ expect(deleteForward(s('hello', 4))).toEqual({ value: 'hell', cursor: 4 });
50
+ });
51
+ it('is a no-op at end of buffer', () => {
52
+ expect(deleteForward(s('hello', 5))).toEqual({ value: 'hello', cursor: 5 });
53
+ });
54
+ });
55
+
56
+ describe('deleteWordBackward', () => {
57
+ it('eats the previous word', () => {
58
+ expect(deleteWordBackward(s('hello world', 11))).toEqual({ value: 'hello ', cursor: 6 });
59
+ expect(deleteWordBackward(s('hello world', 13))).toEqual({ value: 'hello ', cursor: 8 });
60
+ });
61
+ it('eats trailing whitespace before the word', () => {
62
+ expect(deleteWordBackward(s('foo bar ', 9))).toEqual({ value: 'foo ', cursor: 4 });
63
+ });
64
+ it('is a no-op at start', () => {
65
+ expect(deleteWordBackward(s('foo', 0))).toEqual({ value: 'foo', cursor: 0 });
66
+ });
67
+ });
68
+
69
+ describe('killToLineEnd / killToLineStart', () => {
70
+ it('kills from cursor to end of line', () => {
71
+ expect(killToLineEnd(s('hello\nworld', 3))).toEqual({ value: 'hel\nworld', cursor: 3 });
72
+ });
73
+ it('eats the newline when cursor sits at line end', () => {
74
+ expect(killToLineEnd(s('hello\nworld', 5))).toEqual({ value: 'helloworld', cursor: 5 });
75
+ });
76
+ it('kills from start of line to cursor', () => {
77
+ expect(killToLineStart(s('hello\nworld', 3))).toEqual({ value: 'lo\nworld', cursor: 0 });
78
+ expect(killToLineStart(s('hello\nworld', 8))).toEqual({ value: 'hello\nrld', cursor: 6 });
79
+ });
80
+ });
81
+
82
+ describe('horizontal movement', () => {
83
+ it('moveLeft / moveRight respect bounds', () => {
84
+ expect(moveLeft(s('abc', 0))).toEqual({ value: 'abc', cursor: 0 });
85
+ expect(moveLeft(s('abc', 2))).toEqual({ value: 'abc', cursor: 1 });
86
+ expect(moveRight(s('abc', 3))).toEqual({ value: 'abc', cursor: 3 });
87
+ expect(moveRight(s('abc', 1))).toEqual({ value: 'abc', cursor: 2 });
88
+ });
89
+ it('moveWordLeft / moveWordRight jump across word boundaries', () => {
90
+ expect(moveWordLeft(s('foo bar baz', 11))).toEqual({ value: 'foo bar baz', cursor: 8 });
91
+ expect(moveWordLeft(s('foo bar baz', 8))).toEqual({ value: 'foo bar baz', cursor: 4 });
92
+ expect(moveWordRight(s('foo bar baz', 0))).toEqual({ value: 'foo bar baz', cursor: 3 });
93
+ expect(moveWordRight(s('foo bar baz', 3))).toEqual({ value: 'foo bar baz', cursor: 7 });
94
+ });
95
+ it('Home/End operate on the current line', () => {
96
+ expect(moveLineHome(s('hello\nworld', 8))).toEqual({ value: 'hello\nworld', cursor: 6 });
97
+ expect(moveLineEnd(s('hello\nworld', 6))).toEqual({ value: 'hello\nworld', cursor: 11 });
98
+ });
99
+ });
100
+
101
+ describe('vertical movement', () => {
102
+ it('moveLineUp returns null on first line', () => {
103
+ expect(moveLineUp(s('one\ntwo', 1), null)).toBeNull();
104
+ });
105
+ it('moveLineDown returns null on last line', () => {
106
+ expect(moveLineDown(s('one\ntwo', 5), null)).toBeNull();
107
+ });
108
+ it('preserves desired column across short lines', () => {
109
+ // Cursor on line 0 col 5 → down to line 1 (3 chars) clamps to 3 → down again to line 2 (10 chars) restores 5.
110
+ const start = { value: 'hello\nfoo\nlonglineee', cursor: 5 };
111
+ const down1 = moveLineDown(start, 5);
112
+ expect(down1).toEqual({ value: start.value, cursor: 9 }); // end of "foo"
113
+ const down2 = moveLineDown(down1 as { value: string; cursor: number }, 5);
114
+ expect(down2).toEqual({ value: start.value, cursor: 15 }); // 10 + 5
115
+ });
116
+ it('round-trips up then down to the same offset when desired column is preserved', () => {
117
+ const state = { value: 'aaaa\nbbbb\ncccc', cursor: 12 }; // line 2, col 2
118
+ const up = moveLineUp(state, 2);
119
+ expect(up).toEqual({ value: state.value, cursor: 7 });
120
+ const down = moveLineDown(up as { value: string; cursor: number }, 2);
121
+ expect(down).toEqual({ value: state.value, cursor: 12 });
122
+ });
123
+ });
124
+
125
+ describe('cursorRowCol / positionAt', () => {
126
+ it('reports row and col correctly', () => {
127
+ expect(cursorRowCol('hello\nworld', 0)).toEqual({ row: 0, col: 0 });
128
+ expect(cursorRowCol('hello\nworld', 5)).toEqual({ row: 0, col: 5 });
129
+ expect(cursorRowCol('hello\nworld', 6)).toEqual({ row: 1, col: 0 });
130
+ expect(cursorRowCol('hello\nworld', 11)).toEqual({ row: 1, col: 5 });
131
+ });
132
+ it('positionAt clamps to line length', () => {
133
+ expect(positionAt('hello\nfoo', 1, 99)).toBe(9);
134
+ expect(positionAt('hello\nfoo', 0, 2)).toBe(2);
135
+ });
136
+ });
@@ -0,0 +1,214 @@
1
+ // Pure helpers for editing/navigating a `(value, cursor)` pair, where `value`
2
+ // is the buffer text (potentially multi-line) and `cursor` is a 0-based offset
3
+ // into that string in the half-open range `[0, value.length]`.
4
+ //
5
+ // All operations are pure: they take the current state and return the new
6
+ // state without mutating anything. Keeping the helpers free of any React /
7
+ // Ink dependency makes them trivially unit-testable and cheap to reason
8
+ // about (no hidden ordering, no refs).
9
+
10
+ export interface BufferState {
11
+ value: string;
12
+ cursor: number;
13
+ }
14
+
15
+ const WORD_RE = /\w/;
16
+
17
+ function clamp(n: number, min: number, max: number): number {
18
+ if (n < min) return min;
19
+ if (n > max) return max;
20
+ return n;
21
+ }
22
+
23
+ function clampCursor(value: string, cursor: number): number {
24
+ return clamp(cursor, 0, value.length);
25
+ }
26
+
27
+ // ─── Editing ──────────────────────────────────────────────────────────────────
28
+
29
+ export function insertAt(state: BufferState, text: string): BufferState {
30
+ if (!text) return state;
31
+ const cursor = clampCursor(state.value, state.cursor);
32
+ return {
33
+ value: state.value.slice(0, cursor) + text + state.value.slice(cursor),
34
+ cursor: cursor + text.length,
35
+ };
36
+ }
37
+
38
+ export function deleteBackward(state: BufferState): BufferState {
39
+ const cursor = clampCursor(state.value, state.cursor);
40
+ if (cursor === 0) return state;
41
+ return {
42
+ value: state.value.slice(0, cursor - 1) + state.value.slice(cursor),
43
+ cursor: cursor - 1,
44
+ };
45
+ }
46
+
47
+ export function deleteForward(state: BufferState): BufferState {
48
+ const cursor = clampCursor(state.value, state.cursor);
49
+ if (cursor >= state.value.length) return state;
50
+ return {
51
+ value: state.value.slice(0, cursor) + state.value.slice(cursor + 1),
52
+ cursor,
53
+ };
54
+ }
55
+
56
+ export function deleteWordBackward(state: BufferState): BufferState {
57
+ const cursor = clampCursor(state.value, state.cursor);
58
+ if (cursor === 0) return state;
59
+ const start = wordStart(state.value, cursor);
60
+ return {
61
+ value: state.value.slice(0, start) + state.value.slice(cursor),
62
+ cursor: start,
63
+ };
64
+ }
65
+
66
+ /** Kill from cursor to end of line (Emacs-style Ctrl+K). */
67
+ export function killToLineEnd(state: BufferState): BufferState {
68
+ const cursor = clampCursor(state.value, state.cursor);
69
+ const eol = lineEnd(state.value, cursor);
70
+ if (eol === cursor) {
71
+ // At end of line — eat the newline itself, mirroring Emacs/readline.
72
+ if (cursor < state.value.length) {
73
+ return { value: state.value.slice(0, cursor) + state.value.slice(cursor + 1), cursor };
74
+ }
75
+ return state;
76
+ }
77
+ return { value: state.value.slice(0, cursor) + state.value.slice(eol), cursor };
78
+ }
79
+
80
+ /** Kill from start of line to cursor (Emacs-style Ctrl+U). */
81
+ export function killToLineStart(state: BufferState): BufferState {
82
+ const cursor = clampCursor(state.value, state.cursor);
83
+ const sol = lineStart(state.value, cursor);
84
+ if (sol === cursor) return state;
85
+ return { value: state.value.slice(0, sol) + state.value.slice(cursor), cursor: sol };
86
+ }
87
+
88
+ // ─── Movement ─────────────────────────────────────────────────────────────────
89
+
90
+ export function moveLeft(state: BufferState): BufferState {
91
+ const cursor = clampCursor(state.value, state.cursor);
92
+ return cursor === 0 ? state : { ...state, cursor: cursor - 1 };
93
+ }
94
+
95
+ export function moveRight(state: BufferState): BufferState {
96
+ const cursor = clampCursor(state.value, state.cursor);
97
+ return cursor >= state.value.length ? state : { ...state, cursor: cursor + 1 };
98
+ }
99
+
100
+ export function moveWordLeft(state: BufferState): BufferState {
101
+ const cursor = clampCursor(state.value, state.cursor);
102
+ return { ...state, cursor: wordStart(state.value, cursor) };
103
+ }
104
+
105
+ export function moveWordRight(state: BufferState): BufferState {
106
+ const cursor = clampCursor(state.value, state.cursor);
107
+ return { ...state, cursor: wordEnd(state.value, cursor) };
108
+ }
109
+
110
+ export function moveLineHome(state: BufferState): BufferState {
111
+ const cursor = clampCursor(state.value, state.cursor);
112
+ return { ...state, cursor: lineStart(state.value, cursor) };
113
+ }
114
+
115
+ export function moveLineEnd(state: BufferState): BufferState {
116
+ const cursor = clampCursor(state.value, state.cursor);
117
+ return { ...state, cursor: lineEnd(state.value, cursor) };
118
+ }
119
+
120
+ /**
121
+ * Move the cursor up one display line. Returns `null` when the cursor is
122
+ * already on the first line — callers can then route the keystroke to history
123
+ * navigation instead of swallowing it. `desiredColumn` makes vertical motion
124
+ * "sticky": it remembers the column the user originally departed from so that
125
+ * traversing a short line and coming back lands in the original column.
126
+ */
127
+ export function moveLineUp(state: BufferState, desiredColumn: number | null): BufferState | null {
128
+ const { row, col } = cursorRowCol(state.value, state.cursor);
129
+ if (row === 0) return null;
130
+ const targetCol = desiredColumn ?? col;
131
+ return { ...state, cursor: positionAt(state.value, row - 1, targetCol) };
132
+ }
133
+
134
+ export function moveLineDown(state: BufferState, desiredColumn: number | null): BufferState | null {
135
+ const { row, col } = cursorRowCol(state.value, state.cursor);
136
+ const lineCount = countLines(state.value);
137
+ if (row >= lineCount - 1) return null;
138
+ const targetCol = desiredColumn ?? col;
139
+ return { ...state, cursor: positionAt(state.value, row + 1, targetCol) };
140
+ }
141
+
142
+ // ─── Geometry helpers ─────────────────────────────────────────────────────────
143
+
144
+ export function cursorRowCol(value: string, cursor: number): { row: number; col: number } {
145
+ const c = clampCursor(value, cursor);
146
+ let row = 0;
147
+ let lastNl = -1;
148
+ for (let i = 0; i < c; i++) {
149
+ if (value.charCodeAt(i) === 10) {
150
+ row++;
151
+ lastNl = i;
152
+ }
153
+ }
154
+ return { row, col: c - lastNl - 1 };
155
+ }
156
+
157
+ function countLines(value: string): number {
158
+ let count = 1;
159
+ for (let i = 0; i < value.length; i++) {
160
+ if (value.charCodeAt(i) === 10) count++;
161
+ }
162
+ return count;
163
+ }
164
+
165
+ /** Resolve a `(row, col)` pair back to a flat offset, clamping to the line length. */
166
+ export function positionAt(value: string, row: number, col: number): number {
167
+ let currentRow = 0;
168
+ let lineStartIdx = 0;
169
+ for (let i = 0; i <= value.length; i++) {
170
+ const isEol = i === value.length || value.charCodeAt(i) === 10;
171
+ if (currentRow === row && isEol) {
172
+ const lineLength = i - lineStartIdx;
173
+ return lineStartIdx + Math.min(col, lineLength);
174
+ }
175
+ if (isEol) {
176
+ currentRow++;
177
+ lineStartIdx = i + 1;
178
+ }
179
+ }
180
+ return value.length;
181
+ }
182
+
183
+ function lineStart(value: string, cursor: number): number {
184
+ for (let i = cursor - 1; i >= 0; i--) {
185
+ if (value.charCodeAt(i) === 10) return i + 1;
186
+ }
187
+ return 0;
188
+ }
189
+
190
+ function lineEnd(value: string, cursor: number): number {
191
+ for (let i = cursor; i < value.length; i++) {
192
+ if (value.charCodeAt(i) === 10) return i;
193
+ }
194
+ return value.length;
195
+ }
196
+
197
+ /**
198
+ * Standard "previous word boundary" semantics: skip whitespace/punctuation
199
+ * backwards, then skip the contiguous word characters. Mirrors what shells
200
+ * (bash/zsh) do for Ctrl+W and Alt+Left.
201
+ */
202
+ function wordStart(value: string, cursor: number): number {
203
+ let i = cursor;
204
+ while (i > 0 && !WORD_RE.test(value[i - 1])) i--;
205
+ while (i > 0 && WORD_RE.test(value[i - 1])) i--;
206
+ return i;
207
+ }
208
+
209
+ function wordEnd(value: string, cursor: number): number {
210
+ let i = cursor;
211
+ while (i < value.length && !WORD_RE.test(value[i])) i++;
212
+ while (i < value.length && WORD_RE.test(value[i])) i++;
213
+ return i;
214
+ }
@@ -0,0 +1,107 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { ChatMessage, PluginRegistry, ProviderConfig, ToolDefinition } from 'mu-core';
5
+
6
+ /**
7
+ * Render a plain-text view of the full LLM context — the merged system
8
+ * prompt, registered tool definitions, and the message transcript — in the
9
+ * order and shape mirrored from `mu-provider/src/stream.ts buildMessages`.
10
+ *
11
+ * This is a *logical* view: the exact token framing applied on the wire is
12
+ * controlled by the server's per-model chat template (ChatML, Llama-3,
13
+ * Hermes, etc.) and cannot be reproduced client-side. The dump captures
14
+ * what the model semantically receives, which is what matters for
15
+ * estimating token usage and pruning context.
16
+ */
17
+ function mergedSystemPrompt(config: ProviderConfig, pluginPrompts: string[]): string {
18
+ return pluginPrompts.length > 0
19
+ ? [config.systemPrompt, ...pluginPrompts].filter(Boolean).join('\n\n')
20
+ : (config.systemPrompt ?? '');
21
+ }
22
+
23
+ function renderTools(tools: ToolDefinition[]): string {
24
+ if (tools.length === 0) return '=== TOOLS (0) ===\n(no tools registered)\n';
25
+ const blocks = tools.map((t, i) => {
26
+ // Compact JSON mirrors what gets injected into the system prompt by
27
+ // most chat templates — pretty-printing here would misrepresent the
28
+ // actual on-the-wire token cost.
29
+ const params = JSON.stringify(t.function.parameters);
30
+ const desc = t.function.description ? ` ${t.function.description}` : '';
31
+ return `[${i + 1}] ${t.function.name}${desc}\n parameters: ${params}`;
32
+ });
33
+ return `=== TOOLS (${tools.length}) ===\n${blocks.join('\n')}\n`;
34
+ }
35
+
36
+ function renderUser(m: ChatMessage): string {
37
+ const lines: string[] = [];
38
+ const hasImages = (m.images?.length ?? 0) > 0;
39
+ const text = m.content.trim() || (hasImages ? '(image attached)' : '');
40
+ if (text) lines.push(text);
41
+ if (m.images) {
42
+ for (const img of m.images) {
43
+ lines.push(`[image: ${img.mimeType}, ${img.name}]`);
44
+ }
45
+ }
46
+ return lines.join('\n');
47
+ }
48
+
49
+ function renderAssistant(m: ChatMessage): string {
50
+ const lines: string[] = [];
51
+ if (m.content) {
52
+ lines.push(m.content);
53
+ } else if (m.toolCalls?.length) {
54
+ lines.push('(no content)');
55
+ }
56
+ if (m.toolCalls) {
57
+ for (const tc of m.toolCalls) {
58
+ lines.push(`[tool_call id=${tc.id}] ${tc.function.name}(${tc.function.arguments})`);
59
+ }
60
+ }
61
+ return lines.join('\n');
62
+ }
63
+
64
+ function renderMessage(m: ChatMessage, index: number): string {
65
+ if (m.role === 'tool') {
66
+ const header = `--- [${index}] tool (call_id=${m.toolCallId ?? ''}) ---`;
67
+ return `${header}\n${m.content}`;
68
+ }
69
+ const header = `--- [${index}] ${m.role} ---`;
70
+ let body: string;
71
+ if (m.role === 'user') body = renderUser(m);
72
+ else if (m.role === 'assistant') body = renderAssistant(m);
73
+ else body = m.content; // system / fallback
74
+ return `${header}\n${body}`;
75
+ }
76
+
77
+ function renderContext(
78
+ config: ProviderConfig,
79
+ messages: ChatMessage[],
80
+ tools: ToolDefinition[],
81
+ pluginPrompts: string[],
82
+ ): string {
83
+ const system = mergedSystemPrompt(config, pluginPrompts);
84
+ const header = `# Logical context view — exact token framing depends on the server-side chat template for model "${config.model ?? '(unset)'}".\n`;
85
+ const systemBlock = `=== SYSTEM ===\n${system || '(empty)'}\n`;
86
+ const toolsBlock = renderTools(tools);
87
+ const messagesHeader = `=== MESSAGES (${messages.length}) ===`;
88
+ const messagesBlock = messages.map((m, i) => renderMessage(m, i + 1)).join('\n\n');
89
+ return [header, systemBlock, toolsBlock, messagesHeader, messagesBlock].join('\n');
90
+ }
91
+
92
+ /**
93
+ * Build the plain-text context view and write it to a temp file. Returns
94
+ * the absolute path so the caller can surface it (e.g. via toast).
95
+ */
96
+ export async function dumpContext(
97
+ config: ProviderConfig,
98
+ messages: ChatMessage[],
99
+ registry: PluginRegistry,
100
+ ): Promise<string> {
101
+ const pluginPrompts = await registry.getSystemPrompts();
102
+ const tools = registry.getToolDefinitions();
103
+ const text = renderContext(config, messages, tools, pluginPrompts);
104
+ const path = join(tmpdir(), `mu-context-${Date.now()}.txt`);
105
+ await writeFile(path, text, 'utf-8');
106
+ return path;
107
+ }
@@ -3,7 +3,7 @@
3
3
  // - M = press / motion
4
4
  // - m = release
5
5
  // Examples: "[<0;126;31M", "[<32;36;51M", "[<0;31;51m"
6
- export const SGR_MOUSE_RE = /\[<\d+;\d+;\d+[Mm]/g;
6
+ const SGR_MOUSE_RE = /\[<\d+;\d+;\d+[Mm]/g;
7
7
 
8
8
  const SGR_MOUSE_EXACT_RE = /^\[<\d+;\d+;\d+[Mm]$/;
9
9
 
@@ -1,4 +1,4 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
2
2
  import { useCallback, useMemo } from 'react';
3
3
  import { BUILTIN_COMMANDS, fromPluginCommand, type SlashCommand } from './commands';
4
4
  import type { InputActions } from './useInputHandler';
@@ -1,8 +1,11 @@
1
- import { useMemo } from 'react';
1
+ import { useCallback, useMemo, useRef } from 'react';
2
2
  import { useChatContext } from '../chat/ChatContext';
3
+ import { dumpContext } from './dumpContext';
3
4
  import type { InputBoxViewProps } from './InputBoxView';
4
5
  import { useCommandExecutor } from './useCommandExecutor';
5
- import { type InputActions, useInputHandler } from './useInputHandler';
6
+ import { type InputActions, type MentionMode, useInputHandler } from './useInputHandler';
7
+ import { type MentionPickerState, useMentionPicker } from './useMentionPicker';
8
+ import { usePluginShortcuts } from './usePluginShortcuts';
6
9
 
7
10
  export interface InputBoxProps {
8
11
  onSubmit: (text: string) => void;
@@ -13,19 +16,75 @@ export interface InputBoxProps {
13
16
  history?: string[];
14
17
  }
15
18
 
16
- export function useInputBox({
17
- onSubmit,
18
- onScrollUp,
19
- onScrollDown,
20
- isActive = true,
21
- model = '',
22
- history = [],
23
- }: InputBoxProps): InputBoxViewProps {
24
- const { config, session, toggles, attachment, models, abort, registry } = useChatContext();
19
+ interface BufferDraft {
20
+ value: string;
21
+ cursor: number;
22
+ }
25
23
 
26
- // Stable references prevent downstream `useMemo`s (e.g. inside
27
- // `useCommandExecutor`) from being invalidated on every render.
28
- const actions: InputActions = useMemo(
24
+ /**
25
+ * Replace `[triggerStart, cursor)` (the `<trigger><partial>` token) with the
26
+ * chosen completion value plus a trailing space, so the user is left in a
27
+ * sensible position for further input.
28
+ */
29
+ function applyMention(
30
+ value: string,
31
+ triggerStart: number,
32
+ cursor: number,
33
+ trigger: string,
34
+ completion: string,
35
+ ): BufferDraft {
36
+ const before = value.slice(0, triggerStart);
37
+ const after = value.slice(cursor);
38
+ const insertion = `${trigger}${completion} `;
39
+ return { value: before + insertion + after, cursor: triggerStart + insertion.length };
40
+ }
41
+
42
+ interface InputHandle {
43
+ value: string;
44
+ cursor: number;
45
+ setBuffer: (value: string, cursor: number) => void;
46
+ }
47
+
48
+ /**
49
+ * Build the mention-mode controls for the picker that the input handler
50
+ * consumes via a ref. Returns `null` when no completions are available so
51
+ * the handler skips the override and runs the default editing bindings.
52
+ */
53
+ function buildMentionMode(mentions: MentionPickerState, input: InputHandle): MentionMode | null {
54
+ if (!mentions.trigger || mentions.completions.length === 0) return null;
55
+ return {
56
+ active: true,
57
+ count: mentions.completions.length,
58
+ selectedIndex: mentions.selectedIndex,
59
+ next: () => mentions.setSelectedIndex((mentions.selectedIndex + 1) % mentions.completions.length),
60
+ prev: () =>
61
+ mentions.setSelectedIndex(
62
+ mentions.selectedIndex === 0 ? mentions.completions.length - 1 : mentions.selectedIndex - 1,
63
+ ),
64
+ accept: () => {
65
+ const completion = mentions.completions[mentions.selectedIndex];
66
+ const trig = mentions.trigger;
67
+ if (!(completion && trig)) return;
68
+ const draft = applyMention(input.value, mentions.triggerStart, input.cursor, trig, completion.value);
69
+ input.setBuffer(draft.value, draft.cursor);
70
+ },
71
+ };
72
+ }
73
+
74
+ interface ActionDeps {
75
+ abort: ReturnType<typeof useChatContext>['abort'];
76
+ attachment: ReturnType<typeof useChatContext>['attachment'];
77
+ session: ReturnType<typeof useChatContext>['session'];
78
+ models: ReturnType<typeof useChatContext>['models'];
79
+ toggles: ReturnType<typeof useChatContext>['toggles'];
80
+ onShowContext: () => Promise<void>;
81
+ onScrollUp?: () => void;
82
+ onScrollDown?: () => void;
83
+ }
84
+
85
+ function useInputActions(deps: ActionDeps): InputActions {
86
+ const { abort, attachment, session, models, toggles, onShowContext, onScrollUp, onScrollDown } = deps;
87
+ return useMemo<InputActions>(
29
88
  () => ({
30
89
  onCtrlC: abort.onCtrlC,
31
90
  onEsc: abort.onEsc,
@@ -34,6 +93,7 @@ export function useInputBox({
34
93
  onCycleModel: models.cycleModel,
35
94
  onTogglePicker: toggles.onTogglePicker,
36
95
  onToggleSessionPicker: toggles.onToggleSessionPicker,
96
+ onShowContext,
37
97
  onScrollUp,
38
98
  onScrollDown,
39
99
  modelCount: models.models.length,
@@ -47,10 +107,48 @@ export function useInputBox({
47
107
  models.models.length,
48
108
  toggles.onTogglePicker,
49
109
  toggles.onToggleSessionPicker,
110
+ onShowContext,
50
111
  onScrollUp,
51
112
  onScrollDown,
52
113
  ],
53
114
  );
115
+ }
116
+
117
+ export function useInputBox({
118
+ onSubmit,
119
+ onScrollUp,
120
+ onScrollDown,
121
+ isActive = true,
122
+ model = '',
123
+ history = [],
124
+ }: InputBoxProps): InputBoxViewProps {
125
+ const { config, session, toggles, attachment, models, abort, registry, uiService } = useChatContext();
126
+ // Ref pattern: the mention controls depend on the input handler's
127
+ // `value`/`cursor`, but the handler also needs to reach the controls at key
128
+ // dispatch time. Pushing the latest snapshot into a ref every render lets
129
+ // both directions flow without re-calling hooks.
130
+ const mentionRef = useRef<MentionMode | null>(null);
131
+
132
+ const onShowContext = useCallback(async () => {
133
+ try {
134
+ const path = await dumpContext(config, session.messages, registry);
135
+ uiService?.notify(`Context written to ${path}`, 'success');
136
+ } catch (err) {
137
+ const msg = err instanceof Error ? err.message : 'Unknown error';
138
+ uiService?.notify(`Failed to dump context: ${msg}`, 'error');
139
+ }
140
+ }, [config, session.messages, registry, uiService]);
141
+
142
+ const actions = useInputActions({
143
+ abort,
144
+ attachment,
145
+ session,
146
+ models,
147
+ toggles,
148
+ onShowContext,
149
+ onScrollUp,
150
+ onScrollDown,
151
+ });
54
152
 
55
153
  const commandContext = useMemo(
56
154
  () => ({ messages: session.messages, cwd: process.cwd(), config }),
@@ -67,6 +165,8 @@ export function useInputBox({
67
165
  pluginCommands,
68
166
  });
69
167
 
168
+ const { handlers: pluginShortcuts } = usePluginShortcuts(registry);
169
+
70
170
  const input = useInputHandler({
71
171
  isActive,
72
172
  streaming: session.streaming,
@@ -75,14 +175,33 @@ export function useInputBox({
75
175
  onSubmit,
76
176
  availableCommands: commandExecutor.commands,
77
177
  onCommand: commandExecutor.execute,
178
+ pluginShortcuts,
179
+ mentionRef,
78
180
  });
79
181
 
182
+ const mentions = useMentionPicker(registry, input.value, input.cursor);
183
+
184
+ // Update the ref every render so the dispatch closure sees the latest.
185
+ mentionRef.current = buildMentionMode(mentions, input);
186
+
80
187
  return {
81
- ...input,
188
+ value: input.value,
189
+ cursor: input.cursor,
190
+ commands: input.commands,
191
+ cmdIndex: input.cmdIndex,
192
+ isCommandMode: input.isCommandMode,
82
193
  streaming: session.streaming,
83
194
  isActive,
84
195
  model,
85
196
  attachmentName: attachment.attachment?.name ?? null,
86
197
  attachmentError: attachment.attachmentError,
198
+ mentions:
199
+ mentions.completions.length > 0
200
+ ? {
201
+ completions: mentions.completions,
202
+ selectedIndex: mentions.selectedIndex,
203
+ partial: mentions.partial,
204
+ }
205
+ : null,
87
206
  };
88
207
  }