mu-coding 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -120
  57. package/src/tui/chat/useChatSession.ts +0 -384
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -85
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -66
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. package/src/utils/diff.ts +0 -81
@@ -1,214 +0,0 @@
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
- }
@@ -1,107 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
1
- // Matches xterm SGR (1006) mouse-event sequences after Ink has stripped the
2
- // leading \x1b. Format: \x1b[<button;x;y[Mm]
3
- // - M = press / motion
4
- // - m = release
5
- // Examples: "[<0;126;31M", "[<32;36;51M", "[<0;31;51m"
6
- const SGR_MOUSE_RE = /\[<\d+;\d+;\d+[Mm]/g;
7
-
8
- const SGR_MOUSE_EXACT_RE = /^\[<\d+;\d+;\d+[Mm]$/;
9
-
10
- /** Single-event chunk (Ink usually delivers one event per input call). */
11
- export function isMouseSequence(input: string): boolean {
12
- return SGR_MOUSE_EXACT_RE.test(input);
13
- }
14
-
15
- /**
16
- * Strip terminal-input bytes that should never become text:
17
- * 1. Any embedded SGR mouse-event sequences (clicks/drags/release/wheel).
18
- * 2. ASCII control bytes < 0x20 *except* \t and \n which paste should keep.
19
- *
20
- * Multi-event chunks (e.g. fast clicks batched into one data frame) are
21
- * handled because the regex is global.
22
- */
23
- export function sanitizeTerminalInput(text: string): string {
24
- const stripped = text.replace(SGR_MOUSE_RE, '');
25
- let out = '';
26
- for (const ch of stripped) {
27
- const code = ch.charCodeAt(0);
28
- if (ch === '\t' || ch === '\n' || code >= 0x20) {
29
- out += ch;
30
- }
31
- }
32
- return out;
33
- }
@@ -1,32 +0,0 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
2
- import { useCallback, useMemo } from 'react';
3
- import { BUILTIN_COMMANDS, fromPluginCommand, type SlashCommand } from './commands';
4
- import type { InputActions } from './useInputHandler';
5
-
6
- interface CommandExecutorOptions {
7
- actions: InputActions;
8
- context: CommandContext;
9
- pluginCommands: PluginSlashCommand[];
10
- }
11
-
12
- export function useCommandExecutor(options: CommandExecutorOptions) {
13
- const { actions, context, pluginCommands } = options;
14
-
15
- const commands = useMemo(
16
- () => [...BUILTIN_COMMANDS, ...pluginCommands.map((command) => fromPluginCommand(command, context))],
17
- [context, pluginCommands],
18
- );
19
-
20
- const execute = useCallback(
21
- (command: SlashCommand, args: string) => {
22
- if (command.execute) {
23
- void command.execute(args);
24
- return;
25
- }
26
- command.invoke?.(actions, args);
27
- },
28
- [actions],
29
- );
30
-
31
- return { commands, execute };
32
- }
@@ -1,265 +0,0 @@
1
- import type { InputInfoSegment } from 'mu-core';
2
- import { useCallback, useMemo, useRef } from 'react';
3
- import { useChatContext } from '../chat/ChatContext';
4
- import type { InkUIService } from '../plugins/InkUIService';
5
- import { runUpdateInTui, type UpdateScope } from '../update/runUpdateInTui';
6
- import { dumpContext } from './dumpContext';
7
- import type { InputBoxViewProps } from './InputBoxView';
8
- import { useCommandExecutor } from './useCommandExecutor';
9
- import { type InputActions, type MentionMode, useInputHandler } from './useInputHandler';
10
- import { type MentionPickerState, useMentionPicker } from './useMentionPicker';
11
- import { usePluginShortcuts } from './usePluginShortcuts';
12
-
13
- function parseUpdateScope(args: string): UpdateScope | null {
14
- const trimmed = args.trim().toLowerCase();
15
- if (trimmed === '' || trimmed === 'all') return 'all';
16
- if (trimmed === 'plugins') return 'plugins';
17
- if (trimmed === 'self' || trimmed === 'mu') return 'self';
18
- return null;
19
- }
20
-
21
- export interface InputBoxProps {
22
- onSubmit: (text: string) => void;
23
- onScrollUp?: () => void;
24
- onScrollDown?: () => void;
25
- isActive?: boolean;
26
- model?: string;
27
- history?: string[];
28
- /**
29
- * Extra info chips rendered in the footer (before the model). Generic
30
- * mechanism for upstream consumers to surface context (active agent,
31
- * branch, ...) without `InputBox` knowing what they are.
32
- */
33
- infoSegments?: InputInfoSegment[];
34
- }
35
-
36
- interface BufferDraft {
37
- value: string;
38
- cursor: number;
39
- }
40
-
41
- /**
42
- * Replace `[triggerStart, cursor)` (the `<trigger><partial>` token) with the
43
- * chosen completion value plus a trailing space, so the user is left in a
44
- * sensible position for further input. File completions drop the trigger
45
- * (`@`) entirely — the path stands alone in the prompt — while other
46
- * categories (agents) keep the `@` prefix as a visible marker.
47
- */
48
- function applyMention(
49
- value: string,
50
- triggerStart: number,
51
- cursor: number,
52
- trigger: string,
53
- completion: string,
54
- category: string | undefined,
55
- ): BufferDraft {
56
- const before = value.slice(0, triggerStart);
57
- const after = value.slice(cursor);
58
- const keepTrigger = category !== 'files';
59
- const insertion = `${keepTrigger ? trigger : ''}${completion} `;
60
- return { value: before + insertion + after, cursor: triggerStart + insertion.length };
61
- }
62
-
63
- interface InputHandle {
64
- value: string;
65
- cursor: number;
66
- setBuffer: (value: string, cursor: number) => void;
67
- }
68
-
69
- /**
70
- * Build the mention-mode controls for the picker that the input handler
71
- * consumes via a ref. Returns `null` when no completions are available so
72
- * the handler skips the override and runs the default editing bindings.
73
- */
74
- function buildMentionMode(mentions: MentionPickerState, input: InputHandle): MentionMode | null {
75
- if (!mentions.trigger || mentions.completions.length === 0) return null;
76
- return {
77
- active: true,
78
- count: mentions.completions.length,
79
- selectedIndex: mentions.selectedIndex,
80
- next: () => mentions.setSelectedIndex((mentions.selectedIndex + 1) % mentions.completions.length),
81
- prev: () =>
82
- mentions.setSelectedIndex(
83
- mentions.selectedIndex === 0 ? mentions.completions.length - 1 : mentions.selectedIndex - 1,
84
- ),
85
- accept: () => {
86
- const completion = mentions.completions[mentions.selectedIndex];
87
- const trig = mentions.trigger;
88
- if (!(completion && trig)) return;
89
- const draft = applyMention(
90
- input.value,
91
- mentions.triggerStart,
92
- input.cursor,
93
- trig,
94
- completion.value,
95
- completion.category,
96
- );
97
- input.setBuffer(draft.value, draft.cursor);
98
- },
99
- };
100
- }
101
-
102
- interface ActionDeps {
103
- abort: ReturnType<typeof useChatContext>['abort'];
104
- attachment: ReturnType<typeof useChatContext>['attachment'];
105
- session: ReturnType<typeof useChatContext>['session'];
106
- models: ReturnType<typeof useChatContext>['models'];
107
- toggles: ReturnType<typeof useChatContext>['toggles'];
108
- onShowContext: () => Promise<void>;
109
- onUpdate: (args: string) => void;
110
- onScrollUp?: () => void;
111
- onScrollDown?: () => void;
112
- }
113
-
114
- function useInputActions(deps: ActionDeps): InputActions {
115
- const { abort, attachment, session, models, toggles, onShowContext, onUpdate, onScrollUp, onScrollDown } = deps;
116
- return useMemo<InputActions>(
117
- () => ({
118
- onCtrlC: abort.onCtrlC,
119
- onEsc: abort.onEsc,
120
- onPaste: attachment.onPaste,
121
- onNew: session.onNew,
122
- onCompact: () => {
123
- void session.onCompact();
124
- },
125
- onCycleModel: models.cycleModel,
126
- onTogglePicker: toggles.onTogglePicker,
127
- onToggleSessionPicker: toggles.onToggleSessionPicker,
128
- onShowContext,
129
- onUpdate,
130
- onScrollUp,
131
- onScrollDown,
132
- modelCount: models.models.length,
133
- }),
134
- [
135
- abort.onCtrlC,
136
- abort.onEsc,
137
- attachment.onPaste,
138
- session.onNew,
139
- session.onCompact,
140
- models.cycleModel,
141
- models.models.length,
142
- toggles.onTogglePicker,
143
- toggles.onToggleSessionPicker,
144
- onShowContext,
145
- onUpdate,
146
- onScrollUp,
147
- onScrollDown,
148
- ],
149
- );
150
- }
151
-
152
- function useOnUpdate(uiService: InkUIService | undefined) {
153
- return useCallback(
154
- (args: string) => {
155
- if (!uiService) return;
156
- const scope = parseUpdateScope(args);
157
- if (!scope) {
158
- uiService.notify('Usage: /update [plugins|self|all]', 'warning');
159
- return;
160
- }
161
- uiService.notify(scope === 'all' ? 'Starting update…' : `Starting ${scope} update…`, 'info');
162
- void runUpdateInTui(scope, uiService);
163
- },
164
- [uiService],
165
- );
166
- }
167
-
168
- const EMPTY_SEGMENTS: InputInfoSegment[] = [];
169
-
170
- export function useInputBox({
171
- onSubmit,
172
- onScrollUp,
173
- onScrollDown,
174
- isActive = true,
175
- model = '',
176
- history = [],
177
- infoSegments = EMPTY_SEGMENTS,
178
- }: InputBoxProps): InputBoxViewProps {
179
- const { config, session, toggles, attachment, models, abort, registry, uiService } = useChatContext();
180
- // Ref pattern: the mention controls depend on the input handler's
181
- // `value`/`cursor`, but the handler also needs to reach the controls at key
182
- // dispatch time. Pushing the latest snapshot into a ref every render lets
183
- // both directions flow without re-calling hooks.
184
- const mentionRef = useRef<MentionMode | null>(null);
185
-
186
- const onShowContext = useCallback(async () => {
187
- try {
188
- const path = await dumpContext(config, session.messages, registry);
189
- uiService?.notify(`Context written to ${path}`, 'success');
190
- } catch (err) {
191
- const msg = err instanceof Error ? err.message : 'Unknown error';
192
- uiService?.notify(`Failed to dump context: ${msg}`, 'error');
193
- }
194
- }, [config, session.messages, registry, uiService]);
195
-
196
- const onUpdate = useOnUpdate(uiService);
197
-
198
- const actions = useInputActions({
199
- abort,
200
- attachment,
201
- session,
202
- models,
203
- toggles,
204
- onShowContext,
205
- onUpdate,
206
- onScrollUp,
207
- onScrollDown,
208
- });
209
-
210
- const commandContext = useMemo(
211
- () => ({ messages: session.messages, cwd: process.cwd(), config }),
212
- [session.messages, config],
213
- );
214
-
215
- // `registry.getCommands()` allocates a fresh array each call; cache by
216
- // registry identity so `useCommandExecutor`'s memo can hit.
217
- const pluginCommands = useMemo(() => registry.getCommands(), [registry]);
218
-
219
- const commandExecutor = useCommandExecutor({
220
- actions,
221
- context: commandContext,
222
- pluginCommands,
223
- });
224
-
225
- const { handlers: pluginShortcuts } = usePluginShortcuts(registry);
226
-
227
- const input = useInputHandler({
228
- isActive,
229
- streaming: session.streaming,
230
- history,
231
- actions,
232
- onSubmit,
233
- availableCommands: commandExecutor.commands,
234
- onCommand: commandExecutor.execute,
235
- pluginShortcuts,
236
- mentionRef,
237
- });
238
-
239
- const mentions = useMentionPicker(registry, input.value, input.cursor);
240
-
241
- // Update the ref every render so the dispatch closure sees the latest.
242
- mentionRef.current = buildMentionMode(mentions, input);
243
-
244
- return {
245
- value: input.value,
246
- cursor: input.cursor,
247
- commands: input.commands,
248
- cmdIndex: input.cmdIndex,
249
- isCommandMode: input.isCommandMode,
250
- streaming: session.streaming,
251
- isActive,
252
- model,
253
- infoSegments,
254
- attachmentName: attachment.attachment?.name ?? null,
255
- attachmentError: attachment.attachmentError,
256
- mentions:
257
- mentions.completions.length > 0
258
- ? {
259
- completions: mentions.completions,
260
- selectedIndex: mentions.selectedIndex,
261
- partial: mentions.partial,
262
- }
263
- : null,
264
- };
265
- }