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
@@ -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,12 @@
1
- import { useMemo } from 'react';
1
+ import type { InputInfoSegment } from 'mu-core';
2
+ import { useCallback, useMemo, useRef } from 'react';
2
3
  import { useChatContext } from '../chat/ChatContext';
4
+ import { dumpContext } from './dumpContext';
3
5
  import type { InputBoxViewProps } from './InputBoxView';
4
6
  import { useCommandExecutor } from './useCommandExecutor';
5
- import { type InputActions, useInputHandler } from './useInputHandler';
7
+ import { type InputActions, type MentionMode, useInputHandler } from './useInputHandler';
8
+ import { type MentionPickerState, useMentionPicker } from './useMentionPicker';
9
+ import { usePluginShortcuts } from './usePluginShortcuts';
6
10
 
7
11
  export interface InputBoxProps {
8
12
  onSubmit: (text: string) => void;
@@ -11,29 +15,106 @@ export interface InputBoxProps {
11
15
  isActive?: boolean;
12
16
  model?: string;
13
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[];
14
24
  }
15
25
 
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();
26
+ interface BufferDraft {
27
+ value: string;
28
+ cursor: number;
29
+ }
30
+
31
+ /**
32
+ * Replace `[triggerStart, cursor)` (the `<trigger><partial>` token) with the
33
+ * chosen completion value plus a trailing space, so the user is left in a
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.
37
+ */
38
+ function applyMention(
39
+ value: string,
40
+ triggerStart: number,
41
+ cursor: number,
42
+ trigger: string,
43
+ completion: string,
44
+ category: string | undefined,
45
+ ): BufferDraft {
46
+ const before = value.slice(0, triggerStart);
47
+ const after = value.slice(cursor);
48
+ const keepTrigger = category !== 'files';
49
+ const insertion = `${keepTrigger ? trigger : ''}${completion} `;
50
+ return { value: before + insertion + after, cursor: triggerStart + insertion.length };
51
+ }
52
+
53
+ interface InputHandle {
54
+ value: string;
55
+ cursor: number;
56
+ setBuffer: (value: string, cursor: number) => void;
57
+ }
58
+
59
+ /**
60
+ * Build the mention-mode controls for the picker that the input handler
61
+ * consumes via a ref. Returns `null` when no completions are available so
62
+ * the handler skips the override and runs the default editing bindings.
63
+ */
64
+ function buildMentionMode(mentions: MentionPickerState, input: InputHandle): MentionMode | null {
65
+ if (!mentions.trigger || mentions.completions.length === 0) return null;
66
+ return {
67
+ active: true,
68
+ count: mentions.completions.length,
69
+ selectedIndex: mentions.selectedIndex,
70
+ next: () => mentions.setSelectedIndex((mentions.selectedIndex + 1) % mentions.completions.length),
71
+ prev: () =>
72
+ mentions.setSelectedIndex(
73
+ mentions.selectedIndex === 0 ? mentions.completions.length - 1 : mentions.selectedIndex - 1,
74
+ ),
75
+ accept: () => {
76
+ const completion = mentions.completions[mentions.selectedIndex];
77
+ const trig = mentions.trigger;
78
+ if (!(completion && trig)) return;
79
+ const draft = applyMention(
80
+ input.value,
81
+ mentions.triggerStart,
82
+ input.cursor,
83
+ trig,
84
+ completion.value,
85
+ completion.category,
86
+ );
87
+ input.setBuffer(draft.value, draft.cursor);
88
+ },
89
+ };
90
+ }
91
+
92
+ interface ActionDeps {
93
+ abort: ReturnType<typeof useChatContext>['abort'];
94
+ attachment: ReturnType<typeof useChatContext>['attachment'];
95
+ session: ReturnType<typeof useChatContext>['session'];
96
+ models: ReturnType<typeof useChatContext>['models'];
97
+ toggles: ReturnType<typeof useChatContext>['toggles'];
98
+ onShowContext: () => Promise<void>;
99
+ onScrollUp?: () => void;
100
+ onScrollDown?: () => void;
101
+ }
25
102
 
26
- // Stable references prevent downstream `useMemo`s (e.g. inside
27
- // `useCommandExecutor`) from being invalidated on every render.
28
- const actions: InputActions = useMemo(
103
+ function useInputActions(deps: ActionDeps): InputActions {
104
+ const { abort, attachment, session, models, toggles, onShowContext, onScrollUp, onScrollDown } = deps;
105
+ return useMemo<InputActions>(
29
106
  () => ({
30
107
  onCtrlC: abort.onCtrlC,
31
108
  onEsc: abort.onEsc,
32
109
  onPaste: attachment.onPaste,
33
110
  onNew: session.onNew,
111
+ onCompact: () => {
112
+ void session.onCompact();
113
+ },
34
114
  onCycleModel: models.cycleModel,
35
115
  onTogglePicker: toggles.onTogglePicker,
36
116
  onToggleSessionPicker: toggles.onToggleSessionPicker,
117
+ onShowContext,
37
118
  onScrollUp,
38
119
  onScrollDown,
39
120
  modelCount: models.models.length,
@@ -43,14 +124,56 @@ export function useInputBox({
43
124
  abort.onEsc,
44
125
  attachment.onPaste,
45
126
  session.onNew,
127
+ session.onCompact,
46
128
  models.cycleModel,
47
129
  models.models.length,
48
130
  toggles.onTogglePicker,
49
131
  toggles.onToggleSessionPicker,
132
+ onShowContext,
50
133
  onScrollUp,
51
134
  onScrollDown,
52
135
  ],
53
136
  );
137
+ }
138
+
139
+ const EMPTY_SEGMENTS: InputInfoSegment[] = [];
140
+
141
+ export function useInputBox({
142
+ onSubmit,
143
+ onScrollUp,
144
+ onScrollDown,
145
+ isActive = true,
146
+ model = '',
147
+ history = [],
148
+ infoSegments = EMPTY_SEGMENTS,
149
+ }: InputBoxProps): InputBoxViewProps {
150
+ const { config, session, toggles, attachment, models, abort, registry, uiService } = useChatContext();
151
+ // Ref pattern: the mention controls depend on the input handler's
152
+ // `value`/`cursor`, but the handler also needs to reach the controls at key
153
+ // dispatch time. Pushing the latest snapshot into a ref every render lets
154
+ // both directions flow without re-calling hooks.
155
+ const mentionRef = useRef<MentionMode | null>(null);
156
+
157
+ const onShowContext = useCallback(async () => {
158
+ try {
159
+ const path = await dumpContext(config, session.messages, registry);
160
+ uiService?.notify(`Context written to ${path}`, 'success');
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : 'Unknown error';
163
+ uiService?.notify(`Failed to dump context: ${msg}`, 'error');
164
+ }
165
+ }, [config, session.messages, registry, uiService]);
166
+
167
+ const actions = useInputActions({
168
+ abort,
169
+ attachment,
170
+ session,
171
+ models,
172
+ toggles,
173
+ onShowContext,
174
+ onScrollUp,
175
+ onScrollDown,
176
+ });
54
177
 
55
178
  const commandContext = useMemo(
56
179
  () => ({ messages: session.messages, cwd: process.cwd(), config }),
@@ -67,6 +190,8 @@ export function useInputBox({
67
190
  pluginCommands,
68
191
  });
69
192
 
193
+ const { handlers: pluginShortcuts } = usePluginShortcuts(registry);
194
+
70
195
  const input = useInputHandler({
71
196
  isActive,
72
197
  streaming: session.streaming,
@@ -75,14 +200,34 @@ export function useInputBox({
75
200
  onSubmit,
76
201
  availableCommands: commandExecutor.commands,
77
202
  onCommand: commandExecutor.execute,
203
+ pluginShortcuts,
204
+ mentionRef,
78
205
  });
79
206
 
207
+ const mentions = useMentionPicker(registry, input.value, input.cursor);
208
+
209
+ // Update the ref every render so the dispatch closure sees the latest.
210
+ mentionRef.current = buildMentionMode(mentions, input);
211
+
80
212
  return {
81
- ...input,
213
+ value: input.value,
214
+ cursor: input.cursor,
215
+ commands: input.commands,
216
+ cmdIndex: input.cmdIndex,
217
+ isCommandMode: input.isCommandMode,
82
218
  streaming: session.streaming,
83
219
  isActive,
84
220
  model,
221
+ infoSegments,
85
222
  attachmentName: attachment.attachment?.name ?? null,
86
223
  attachmentError: attachment.attachmentError,
224
+ mentions:
225
+ mentions.completions.length > 0
226
+ ? {
227
+ completions: mentions.completions,
228
+ selectedIndex: mentions.selectedIndex,
229
+ partial: mentions.partial,
230
+ }
231
+ : null,
87
232
  };
88
233
  }