mu-coding 0.4.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.
- package/README.md +49 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /package/src/{diff.ts → utils/diff.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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);
|
|
27
|
+
},
|
|
28
|
+
[actions],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return { commands, execute };
|
|
32
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { useChatContext } from '../chat/ChatContext';
|
|
3
|
+
import { dumpContext } from './dumpContext';
|
|
4
|
+
import type { InputBoxViewProps } from './InputBoxView';
|
|
5
|
+
import { useCommandExecutor } from './useCommandExecutor';
|
|
6
|
+
import { type InputActions, type MentionMode, useInputHandler } from './useInputHandler';
|
|
7
|
+
import { type MentionPickerState, useMentionPicker } from './useMentionPicker';
|
|
8
|
+
import { usePluginShortcuts } from './usePluginShortcuts';
|
|
9
|
+
|
|
10
|
+
export interface InputBoxProps {
|
|
11
|
+
onSubmit: (text: string) => void;
|
|
12
|
+
onScrollUp?: () => void;
|
|
13
|
+
onScrollDown?: () => void;
|
|
14
|
+
isActive?: boolean;
|
|
15
|
+
model?: string;
|
|
16
|
+
history?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BufferDraft {
|
|
20
|
+
value: string;
|
|
21
|
+
cursor: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
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>(
|
|
88
|
+
() => ({
|
|
89
|
+
onCtrlC: abort.onCtrlC,
|
|
90
|
+
onEsc: abort.onEsc,
|
|
91
|
+
onPaste: attachment.onPaste,
|
|
92
|
+
onNew: session.onNew,
|
|
93
|
+
onCycleModel: models.cycleModel,
|
|
94
|
+
onTogglePicker: toggles.onTogglePicker,
|
|
95
|
+
onToggleSessionPicker: toggles.onToggleSessionPicker,
|
|
96
|
+
onShowContext,
|
|
97
|
+
onScrollUp,
|
|
98
|
+
onScrollDown,
|
|
99
|
+
modelCount: models.models.length,
|
|
100
|
+
}),
|
|
101
|
+
[
|
|
102
|
+
abort.onCtrlC,
|
|
103
|
+
abort.onEsc,
|
|
104
|
+
attachment.onPaste,
|
|
105
|
+
session.onNew,
|
|
106
|
+
models.cycleModel,
|
|
107
|
+
models.models.length,
|
|
108
|
+
toggles.onTogglePicker,
|
|
109
|
+
toggles.onToggleSessionPicker,
|
|
110
|
+
onShowContext,
|
|
111
|
+
onScrollUp,
|
|
112
|
+
onScrollDown,
|
|
113
|
+
],
|
|
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
|
+
});
|
|
152
|
+
|
|
153
|
+
const commandContext = useMemo(
|
|
154
|
+
() => ({ messages: session.messages, cwd: process.cwd(), config }),
|
|
155
|
+
[session.messages, config],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// `registry.getCommands()` allocates a fresh array each call; cache by
|
|
159
|
+
// registry identity so `useCommandExecutor`'s memo can hit.
|
|
160
|
+
const pluginCommands = useMemo(() => registry.getCommands(), [registry]);
|
|
161
|
+
|
|
162
|
+
const commandExecutor = useCommandExecutor({
|
|
163
|
+
actions,
|
|
164
|
+
context: commandContext,
|
|
165
|
+
pluginCommands,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const { handlers: pluginShortcuts } = usePluginShortcuts(registry);
|
|
169
|
+
|
|
170
|
+
const input = useInputHandler({
|
|
171
|
+
isActive,
|
|
172
|
+
streaming: session.streaming,
|
|
173
|
+
history,
|
|
174
|
+
actions,
|
|
175
|
+
onSubmit,
|
|
176
|
+
availableCommands: commandExecutor.commands,
|
|
177
|
+
onCommand: commandExecutor.execute,
|
|
178
|
+
pluginShortcuts,
|
|
179
|
+
mentionRef,
|
|
180
|
+
});
|
|
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
|
+
|
|
187
|
+
return {
|
|
188
|
+
value: input.value,
|
|
189
|
+
cursor: input.cursor,
|
|
190
|
+
commands: input.commands,
|
|
191
|
+
cmdIndex: input.cmdIndex,
|
|
192
|
+
isCommandMode: input.isCommandMode,
|
|
193
|
+
streaming: session.streaming,
|
|
194
|
+
isActive,
|
|
195
|
+
model,
|
|
196
|
+
attachmentName: attachment.attachment?.name ?? null,
|
|
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,
|
|
206
|
+
};
|
|
207
|
+
}
|