min-agent 0.2.0 → 0.3.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 +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/package.json +7 -8
package/dist/tui/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { App } from "./App.js";
|
|
4
|
+
import { createCaretAwareStdout, setCaretPosition } from "./caret.js";
|
|
4
5
|
/**
|
|
5
6
|
* TUI renderer — manages the Ink app lifecycle and provides
|
|
6
7
|
* imperative methods to update state from the agent loop.
|
|
@@ -10,6 +11,11 @@ export class TuiRenderer {
|
|
|
10
11
|
rerender = null;
|
|
11
12
|
inkInstance = null;
|
|
12
13
|
callbacks;
|
|
14
|
+
pendingDelta = "";
|
|
15
|
+
deltaTimer = null;
|
|
16
|
+
pendingThinking = "";
|
|
17
|
+
thinkingTimer = null;
|
|
18
|
+
copyNoticeTimer = null;
|
|
13
19
|
constructor(callbacks, model) {
|
|
14
20
|
this.state = {
|
|
15
21
|
messages: [],
|
|
@@ -19,63 +25,142 @@ export class TuiRenderer {
|
|
|
19
25
|
this.callbacks = callbacks;
|
|
20
26
|
}
|
|
21
27
|
start() {
|
|
22
|
-
this.inkInstance = render(_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onExit: this.callbacks.onExit }), { exitOnCtrlC: false });
|
|
23
|
-
// Position cursor at bottom-left for IME
|
|
24
|
-
const rows = process.stdout.rows ?? 24;
|
|
25
|
-
process.stdout.write(`\x1b[${rows};3H`);
|
|
28
|
+
this.inkInstance = render(_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onQuestionAnswer: this.callbacks.onQuestionAnswer, onModelPick: this.callbacks.onModelPick, onModelCancel: this.callbacks.onModelCancel, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text) }), { exitOnCtrlC: false, stdout: createCaretAwareStdout(process.stdout) });
|
|
26
29
|
}
|
|
27
|
-
/** Update state and trigger re-render */
|
|
30
|
+
/** Update state and trigger re-render — always replaces the reference so React sees a new prop. */
|
|
28
31
|
update(partial) {
|
|
29
|
-
|
|
30
|
-
// Ink re-renders automatically when we call render again
|
|
32
|
+
this.state = { ...this.state, ...partial };
|
|
31
33
|
if (this.inkInstance) {
|
|
32
|
-
this.inkInstance.rerender(_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onExit: this.callbacks.onExit }));
|
|
33
|
-
// Move terminal cursor to bottom-left (input bar area) for IME positioning
|
|
34
|
-
const rows = process.stdout.rows ?? 24;
|
|
35
|
-
process.stdout.write(`\x1b[${rows};3H`);
|
|
34
|
+
this.inkInstance.rerender(_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onQuestionAnswer: this.callbacks.onQuestionAnswer, onModelPick: this.callbacks.onModelPick, onModelCancel: this.callbacks.onModelCancel, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text) }));
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
37
|
addMessage(msg) {
|
|
38
|
+
this.flushPendingDeltas();
|
|
39
|
+
this.flushPendingThinking();
|
|
39
40
|
this.state.messages = [...this.state.messages, msg];
|
|
40
41
|
this.update({});
|
|
41
42
|
}
|
|
42
|
-
/**
|
|
43
|
+
/** Number of messages currently rendered (used by /undo). */
|
|
44
|
+
messageCount() {
|
|
45
|
+
return this.state.messages.length;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Attach a tool result to the row created by the matching tool call. Falls
|
|
49
|
+
* back to the newest still-pending row of the same tool when the id is
|
|
50
|
+
* unknown (e.g. a provider that does not echo it back).
|
|
51
|
+
*/
|
|
52
|
+
setToolResult(toolCallId, toolName, summary, full, isError) {
|
|
53
|
+
const msgs = [...this.state.messages];
|
|
54
|
+
let idx = msgs.findIndex((m) => m.role === "tool" && m.toolCallId === toolCallId);
|
|
55
|
+
if (idx === -1) {
|
|
56
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
57
|
+
const m = msgs[i];
|
|
58
|
+
if (m.role === "tool" && m.toolName === toolName && m.toolResult === undefined) {
|
|
59
|
+
idx = i;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (idx === -1)
|
|
65
|
+
return;
|
|
66
|
+
msgs[idx] = { ...msgs[idx], toolResultSummary: summary, toolResult: full, toolIsError: isError };
|
|
67
|
+
this.state.messages = msgs;
|
|
68
|
+
this.update({});
|
|
69
|
+
}
|
|
70
|
+
/** Keep only the first `count` messages (used by /undo). */
|
|
71
|
+
truncateMessages(count) {
|
|
72
|
+
this.state.messages = this.state.messages.slice(0, count);
|
|
73
|
+
this.update({});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Append text to the last assistant message (for streaming).
|
|
77
|
+
* Deltas are coalesced (50ms) so a burst of stream tokens renders as a
|
|
78
|
+
* handful of frames instead of one per token; flushed when the run ends.
|
|
79
|
+
*/
|
|
43
80
|
appendToLast(delta) {
|
|
81
|
+
this.pendingDelta += delta;
|
|
82
|
+
if (this.deltaTimer !== null)
|
|
83
|
+
return;
|
|
84
|
+
this.deltaTimer = setTimeout(() => {
|
|
85
|
+
this.deltaTimer = null;
|
|
86
|
+
this.applyPendingDelta();
|
|
87
|
+
}, 16);
|
|
88
|
+
}
|
|
89
|
+
appendToThinking(delta) {
|
|
90
|
+
this.pendingThinking += delta;
|
|
91
|
+
if (this.thinkingTimer !== null)
|
|
92
|
+
return;
|
|
93
|
+
this.thinkingTimer = setTimeout(() => {
|
|
94
|
+
this.thinkingTimer = null;
|
|
95
|
+
this.applyPendingThinking();
|
|
96
|
+
}, 16);
|
|
97
|
+
}
|
|
98
|
+
applyPendingThinking() {
|
|
99
|
+
const accumulated = this.pendingThinking;
|
|
100
|
+
this.pendingThinking = "";
|
|
101
|
+
if (!accumulated)
|
|
102
|
+
return;
|
|
44
103
|
const msgs = [...this.state.messages];
|
|
45
104
|
const last = msgs[msgs.length - 1];
|
|
46
|
-
if (last && last.role === "
|
|
47
|
-
last.content
|
|
105
|
+
if (last && last.role === "thinking") {
|
|
106
|
+
msgs[msgs.length - 1] = { ...last, content: last.content + accumulated };
|
|
48
107
|
this.state.messages = msgs;
|
|
49
108
|
this.update({});
|
|
50
109
|
}
|
|
51
110
|
else {
|
|
52
111
|
this.addMessage({
|
|
53
|
-
id: `
|
|
54
|
-
role: "
|
|
55
|
-
content:
|
|
112
|
+
id: `thinking-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
113
|
+
role: "thinking",
|
|
114
|
+
content: accumulated,
|
|
56
115
|
timestamp: Date.now(),
|
|
57
116
|
});
|
|
58
117
|
}
|
|
59
118
|
}
|
|
60
|
-
|
|
61
|
-
|
|
119
|
+
flushPendingThinking() {
|
|
120
|
+
if (this.thinkingTimer !== null) {
|
|
121
|
+
clearTimeout(this.thinkingTimer);
|
|
122
|
+
this.thinkingTimer = null;
|
|
123
|
+
}
|
|
124
|
+
this.applyPendingThinking();
|
|
125
|
+
}
|
|
126
|
+
applyPendingDelta() {
|
|
127
|
+
const accumulated = this.pendingDelta;
|
|
128
|
+
this.pendingDelta = "";
|
|
129
|
+
if (!accumulated)
|
|
130
|
+
return;
|
|
131
|
+
if (accumulated.trim() === "")
|
|
132
|
+
return;
|
|
62
133
|
const msgs = [...this.state.messages];
|
|
63
134
|
const last = msgs[msgs.length - 1];
|
|
64
|
-
if (last && last.role === "
|
|
65
|
-
last.content
|
|
135
|
+
if (last && last.role === "assistant") {
|
|
136
|
+
msgs[msgs.length - 1] = { ...last, content: last.content + accumulated };
|
|
66
137
|
this.state.messages = msgs;
|
|
67
138
|
this.update({});
|
|
68
139
|
}
|
|
69
140
|
else {
|
|
70
141
|
this.addMessage({
|
|
71
|
-
id: `
|
|
72
|
-
role: "
|
|
73
|
-
content:
|
|
142
|
+
id: `msg-${Date.now()}`,
|
|
143
|
+
role: "assistant",
|
|
144
|
+
content: accumulated,
|
|
74
145
|
timestamp: Date.now(),
|
|
75
146
|
});
|
|
76
147
|
}
|
|
77
148
|
}
|
|
149
|
+
/** Apply any coalesced delta immediately (run end, new message, exit). */
|
|
150
|
+
flushPendingDeltas() {
|
|
151
|
+
if (this.deltaTimer !== null) {
|
|
152
|
+
clearTimeout(this.deltaTimer);
|
|
153
|
+
this.deltaTimer = null;
|
|
154
|
+
}
|
|
155
|
+
this.applyPendingDelta();
|
|
156
|
+
}
|
|
78
157
|
setRunning(running, spinnerText) {
|
|
158
|
+
if (!running) {
|
|
159
|
+
this.flushPendingDeltas();
|
|
160
|
+
this.flushPendingThinking();
|
|
161
|
+
}
|
|
162
|
+
if (this.state.isRunning === running && this.state.spinnerText === spinnerText)
|
|
163
|
+
return;
|
|
79
164
|
this.update({ isRunning: running, spinnerText });
|
|
80
165
|
}
|
|
81
166
|
showConfirm(message) {
|
|
@@ -84,10 +169,54 @@ export class TuiRenderer {
|
|
|
84
169
|
hideConfirm() {
|
|
85
170
|
this.update({ confirmMessage: undefined });
|
|
86
171
|
}
|
|
172
|
+
showQuestion(prompt, options) {
|
|
173
|
+
this.update({ question: { prompt, options } });
|
|
174
|
+
}
|
|
175
|
+
hideQuestion() {
|
|
176
|
+
this.update({ question: undefined });
|
|
177
|
+
}
|
|
178
|
+
showModelPicker() {
|
|
179
|
+
this.update({ modelPicker: true });
|
|
180
|
+
}
|
|
181
|
+
hideModelPicker() {
|
|
182
|
+
this.update({ modelPicker: false });
|
|
183
|
+
}
|
|
184
|
+
setModel(model) {
|
|
185
|
+
this.update({ model });
|
|
186
|
+
}
|
|
87
187
|
setTokenInfo(input, output, contextWindow) {
|
|
88
188
|
this.update({ tokenInfo: { input, output, contextWindow } });
|
|
89
189
|
}
|
|
190
|
+
/** Flash a transient copy confirmation in the status bar (2.5s). */
|
|
191
|
+
showCopyNotice(text) {
|
|
192
|
+
this.update({ copyNotice: text });
|
|
193
|
+
if (this.copyNoticeTimer !== null)
|
|
194
|
+
clearTimeout(this.copyNoticeTimer);
|
|
195
|
+
this.copyNoticeTimer = setTimeout(() => {
|
|
196
|
+
this.copyNoticeTimer = null;
|
|
197
|
+
this.update({ copyNotice: undefined });
|
|
198
|
+
}, 2500);
|
|
199
|
+
}
|
|
90
200
|
destroy() {
|
|
201
|
+
this.flushPendingDeltas();
|
|
202
|
+
this.flushPendingThinking();
|
|
203
|
+
if (this.copyNoticeTimer !== null) {
|
|
204
|
+
clearTimeout(this.copyNoticeTimer);
|
|
205
|
+
this.copyNoticeTimer = null;
|
|
206
|
+
}
|
|
207
|
+
if (this.deltaTimer !== null) {
|
|
208
|
+
clearTimeout(this.deltaTimer);
|
|
209
|
+
this.deltaTimer = null;
|
|
210
|
+
}
|
|
211
|
+
if (this.thinkingTimer !== null) {
|
|
212
|
+
clearTimeout(this.thinkingTimer);
|
|
213
|
+
this.thinkingTimer = null;
|
|
214
|
+
}
|
|
215
|
+
setCaretPosition(null);
|
|
216
|
+
try {
|
|
217
|
+
process.stdout.write("\x1b[?1000l\x1b[?1002l\x1b[?1006l");
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
91
220
|
this.inkInstance?.unmount();
|
|
92
221
|
this.inkInstance = null;
|
|
93
222
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function createHistory() {
|
|
2
|
+
return { entries: [], index: -1, draft: "" };
|
|
3
|
+
}
|
|
4
|
+
const HISTORY_LIMIT = 500;
|
|
5
|
+
export function pushInput(state, text) {
|
|
6
|
+
if (!text)
|
|
7
|
+
return state;
|
|
8
|
+
if (state.entries.length > 0 && state.entries[state.entries.length - 1] === text)
|
|
9
|
+
return state;
|
|
10
|
+
const entries = [...state.entries, text];
|
|
11
|
+
if (entries.length > HISTORY_LIMIT)
|
|
12
|
+
entries.splice(0, entries.length - HISTORY_LIMIT);
|
|
13
|
+
return { entries, index: -1, draft: "" };
|
|
14
|
+
}
|
|
15
|
+
export function searchHistory(state, query) {
|
|
16
|
+
const q = query.toLowerCase();
|
|
17
|
+
if (!q)
|
|
18
|
+
return null;
|
|
19
|
+
for (let i = state.entries.length - 1; i >= 0; i--) {
|
|
20
|
+
if (state.entries[i].toLowerCase().includes(q))
|
|
21
|
+
return state.entries[i];
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function browseHistory(state, direction) {
|
|
26
|
+
const count = state.entries.length;
|
|
27
|
+
if (count === 0)
|
|
28
|
+
return { text: state.draft, state };
|
|
29
|
+
if (direction === 1) {
|
|
30
|
+
const index = state.index === -1 ? count - 1 : Math.max(0, Math.min(count - 1, state.index - 1));
|
|
31
|
+
return { text: state.entries[index], state: { ...state, index } };
|
|
32
|
+
}
|
|
33
|
+
if (state.index === -1)
|
|
34
|
+
return { text: state.draft, state };
|
|
35
|
+
if (state.index >= count - 1)
|
|
36
|
+
return { text: state.draft, state: { ...state, index: -1 } };
|
|
37
|
+
const index = state.index + 1;
|
|
38
|
+
return { text: state.entries[index], state: { ...state, index } };
|
|
39
|
+
}
|
|
40
|
+
export function resetHistory(state, text) {
|
|
41
|
+
if (state.index === -1 && state.draft === text)
|
|
42
|
+
return state;
|
|
43
|
+
return { entries: state.entries, index: -1, draft: text };
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const BOX_CHROME = 4;
|
|
2
|
+
export const GUTTER_WIDTH = 2;
|
|
3
|
+
export const TEXT_START_COLUMN = 5;
|
|
4
|
+
export const STATUS_BAR_ROWS = 2;
|
|
5
|
+
export const SAFETY_MARGIN = 2;
|
|
6
|
+
export const INPUT_BAR_ROWS = 3;
|
|
7
|
+
export function innerTextWidth(columns) {
|
|
8
|
+
return Math.max(1, columns - 5);
|
|
9
|
+
}
|
|
10
|
+
export function inputTextWidth(columns) {
|
|
11
|
+
return Math.max(8, columns - BOX_CHROME - GUTTER_WIDTH);
|
|
12
|
+
}
|
|
13
|
+
export function computeMessageMaxHeight(terminalRows, footerRows, spinnerRows) {
|
|
14
|
+
if (terminalRows <= 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
return Math.max(2, terminalRows - footerRows - STATUS_BAR_ROWS - spinnerRows - SAFETY_MARGIN);
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enable button-event tracking (1000), drag motion while a button is held
|
|
3
|
+
* (1002) with SGR coordinates (1006). 1002 reports press/release plus motion
|
|
4
|
+
* events while dragging, which the message list uses for text selection.
|
|
5
|
+
*/
|
|
6
|
+
export const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
7
|
+
/** Disable button-event tracking. */
|
|
8
|
+
export const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1002l\x1b[?1006l";
|
|
9
|
+
const SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
10
|
+
export function parseSgrMouse(input) {
|
|
11
|
+
const match = input.match(SGR_RE);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
button: parseInt(match[1], 10),
|
|
16
|
+
col: parseInt(match[2], 10),
|
|
17
|
+
row: parseInt(match[3], 10),
|
|
18
|
+
press: match[4] === "M",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Wheel-up / wheel-down button codes in xterm button-event tracking (1000/1006). */
|
|
22
|
+
export const WHEEL_UP = 64;
|
|
23
|
+
export const WHEEL_DOWN = 65;
|
|
24
|
+
/** Motion events (drag) add this to the button code (SGR, mode 1002/1003). */
|
|
25
|
+
export const MOTION = 32;
|
|
26
|
+
/** Modifier flags share the button code's low bits; Shift=4, Meta=8, Ctrl=16. Keep Ctrl so drag isn't mis-classified as "plain". */
|
|
27
|
+
export const MOD_MASK = 31;
|
|
28
|
+
/**
|
|
29
|
+
* Parse the first complete SGR mouse event in a raw stdin buffer, returning
|
|
30
|
+
* the event and the buffer remainder so the caller can process a stream of
|
|
31
|
+
* events without re-reading consumed bytes.
|
|
32
|
+
*/
|
|
33
|
+
export function scanSgrMouse(buffer) {
|
|
34
|
+
const match = buffer.match(SGR_RE);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
event: {
|
|
39
|
+
button: parseInt(match[1], 10),
|
|
40
|
+
col: parseInt(match[2], 10),
|
|
41
|
+
row: parseInt(match[3], 10),
|
|
42
|
+
press: match[4] === "M",
|
|
43
|
+
},
|
|
44
|
+
rest: buffer.slice((match.index ?? 0) + match[0].length),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { charWidth, displayWidth } from "./text-width.js";
|
|
2
|
+
const RESET = "\x1b[0m";
|
|
3
|
+
const INVERSE_ON = "\x1b[7m";
|
|
4
|
+
const INVERSE_OFF = "\x1b[27m";
|
|
5
|
+
/** Remove SGR color/style codes from a rendered line. */
|
|
6
|
+
export function stripAnsi(line) {
|
|
7
|
+
return line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Wrap the cells in [start, end) with inverse video, preserving any styles
|
|
11
|
+
* already active. A style reset inside the selection would cancel the
|
|
12
|
+
* highlight, so a fresh INVERSE_ON is re-emitted after it; the closing
|
|
13
|
+
* INVERSE_OFF restores the caller's original attribute state.
|
|
14
|
+
*/
|
|
15
|
+
export function applySelection(line, start, end) {
|
|
16
|
+
if (end <= start)
|
|
17
|
+
return line;
|
|
18
|
+
let out = "";
|
|
19
|
+
let col = 0;
|
|
20
|
+
let inSel = false;
|
|
21
|
+
for (const token of line.split(/(\x1b\[[0-9;]*m)/g)) {
|
|
22
|
+
if (token.startsWith("\x1b[")) {
|
|
23
|
+
// A reset inside the selection would cancel the highlight, so it is
|
|
24
|
+
// re-applied right after; a reset at/after the selection end closes the
|
|
25
|
+
// highlight first, keeping the boundary between the two styles.
|
|
26
|
+
if (inSel && token === RESET) {
|
|
27
|
+
if (col < end)
|
|
28
|
+
out += token + INVERSE_ON;
|
|
29
|
+
else {
|
|
30
|
+
out += INVERSE_OFF + token;
|
|
31
|
+
inSel = false;
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
out += token;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
for (const ch of token) {
|
|
39
|
+
const cellStart = col;
|
|
40
|
+
const cellEnd = col + charWidth(ch);
|
|
41
|
+
const selected = cellEnd > start && cellStart < end;
|
|
42
|
+
if (selected && !inSel) {
|
|
43
|
+
out += INVERSE_ON;
|
|
44
|
+
inSel = true;
|
|
45
|
+
}
|
|
46
|
+
else if (!selected && inSel) {
|
|
47
|
+
out += INVERSE_OFF;
|
|
48
|
+
inSel = false;
|
|
49
|
+
}
|
|
50
|
+
out += ch;
|
|
51
|
+
col = cellEnd;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (inSel)
|
|
55
|
+
out += INVERSE_OFF;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** The plain text of the cells in [start, end) of an ANSI line. */
|
|
59
|
+
export function extractText(line, start, end) {
|
|
60
|
+
if (end <= start)
|
|
61
|
+
return "";
|
|
62
|
+
let out = "";
|
|
63
|
+
let col = 0;
|
|
64
|
+
for (const ch of cachedStrip(line)) {
|
|
65
|
+
const cellEnd = col + charWidth(ch);
|
|
66
|
+
if (cellEnd > start && col < end)
|
|
67
|
+
out += ch;
|
|
68
|
+
col = cellEnd;
|
|
69
|
+
if (col >= end)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Flow-style selection over the message area. `rows` mirrors the rendered
|
|
76
|
+
* rows (null = blank/chrome row, no text); anchor/current are 0-based
|
|
77
|
+
* (row = index into `rows`, col = display column). Dragging down selects the
|
|
78
|
+
* anchor to end-of-line, full middle rows, then start to current; dragging up
|
|
79
|
+
* is symmetric. Returns one range per row (null = nothing selected there).
|
|
80
|
+
*/
|
|
81
|
+
const strippedCache = new Map();
|
|
82
|
+
function cachedStrip(row) {
|
|
83
|
+
const hit = strippedCache.get(row);
|
|
84
|
+
if (hit !== undefined)
|
|
85
|
+
return hit;
|
|
86
|
+
const s = stripAnsi(row);
|
|
87
|
+
if (strippedCache.size > 512)
|
|
88
|
+
strippedCache.clear();
|
|
89
|
+
strippedCache.set(row, s);
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
export function selectionRanges(rows, anchor, current) {
|
|
93
|
+
const result = rows.map(() => null);
|
|
94
|
+
const top = Math.min(anchor.row, current.row);
|
|
95
|
+
const bottom = Math.max(anchor.row, current.row);
|
|
96
|
+
for (let r = top; r <= bottom; r++) {
|
|
97
|
+
const line = rows[r];
|
|
98
|
+
if (r < 0 || r >= rows.length || line === null)
|
|
99
|
+
continue;
|
|
100
|
+
const width = displayWidth(cachedStrip(line));
|
|
101
|
+
let start;
|
|
102
|
+
let end;
|
|
103
|
+
if (anchor.row === current.row) {
|
|
104
|
+
start = Math.min(anchor.col, current.col);
|
|
105
|
+
end = Math.max(anchor.col, current.col);
|
|
106
|
+
}
|
|
107
|
+
else if (r === anchor.row) {
|
|
108
|
+
if (anchor.row < current.row) {
|
|
109
|
+
start = anchor.col;
|
|
110
|
+
end = width;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
start = 0;
|
|
114
|
+
end = anchor.col;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else if (r === current.row) {
|
|
118
|
+
if (anchor.row < current.row) {
|
|
119
|
+
start = 0;
|
|
120
|
+
end = current.col;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
start = current.col;
|
|
124
|
+
end = width;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
start = 0;
|
|
129
|
+
end = width;
|
|
130
|
+
}
|
|
131
|
+
result[r] = { start: Math.max(0, start), end };
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export const SLASH_COMMANDS = [
|
|
2
|
+
{ name: "clear", description: "清除对话历史" },
|
|
3
|
+
{ name: "compact", description: "压缩上下文" },
|
|
4
|
+
{ name: "model", description: "选择/切换模型" },
|
|
5
|
+
{ name: "models", description: "列出可用模型" },
|
|
6
|
+
{ name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
|
|
7
|
+
{ name: "memory", description: "查看/保存记忆", argHint: "[t]" },
|
|
8
|
+
{ name: "tokens", description: "显示 token 用量与成本" },
|
|
9
|
+
{ name: "budget", description: "查看/设置预算上限", argHint: "[n]" },
|
|
10
|
+
{ name: "undo", description: "撤销上一轮" },
|
|
11
|
+
{ name: "redo", description: "重发最后一条消息" },
|
|
12
|
+
{ name: "diff", description: "显示工作区未提交变更" },
|
|
13
|
+
{ name: "plan", description: "切换只读计划模式" },
|
|
14
|
+
{ name: "sessions", description: "列出已保存会话" },
|
|
15
|
+
{ name: "rename", description: "重命名当前会话", argHint: "<title>" },
|
|
16
|
+
{ name: "attach", description: "附加本地图片", argHint: "<path>", aliases: ["image"] },
|
|
17
|
+
{ name: "mcp", description: "显示 MCP 状态" },
|
|
18
|
+
{ name: "skills", description: "显示/启用/禁用 skills", argHint: "[list|info <name>|enable|disable <name> [--project]]" },
|
|
19
|
+
{ name: "path", description: "显示工作目录", aliases: ["pwd"] },
|
|
20
|
+
{ name: "paste", description: "粘贴剪贴板图片", argHint: "[t]" },
|
|
21
|
+
{ name: "help", description: "显示帮助" },
|
|
22
|
+
{ name: "exit", description: "退出", aliases: ["quit", "q"] },
|
|
23
|
+
];
|
|
24
|
+
export function filterSlashCommands(input, commands = SLASH_COMMANDS) {
|
|
25
|
+
const q = input.slice(1).trim().toLowerCase();
|
|
26
|
+
if (q === "")
|
|
27
|
+
return commands;
|
|
28
|
+
return commands.filter((c) => c.name.startsWith(q) || (c.aliases ?? []).some((a) => a.startsWith(q)));
|
|
29
|
+
}
|
|
30
|
+
const argProviders = new Map();
|
|
31
|
+
/** Registered by the app at startup so this module stays free of app imports. */
|
|
32
|
+
export function setSlashArgProvider(command, provider) {
|
|
33
|
+
argProviders.set(command, provider);
|
|
34
|
+
}
|
|
35
|
+
export function clearSlashArgProviders() {
|
|
36
|
+
argProviders.clear();
|
|
37
|
+
}
|
|
38
|
+
function splitArgs(input) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let cur = "";
|
|
41
|
+
let q = null;
|
|
42
|
+
for (const ch of input) {
|
|
43
|
+
if (q) {
|
|
44
|
+
if (ch === q)
|
|
45
|
+
q = null;
|
|
46
|
+
else
|
|
47
|
+
cur += ch;
|
|
48
|
+
}
|
|
49
|
+
else if (ch === '"' || ch === "'")
|
|
50
|
+
q = ch;
|
|
51
|
+
else if (/\s/.test(ch)) {
|
|
52
|
+
if (cur) {
|
|
53
|
+
out.push(cur);
|
|
54
|
+
cur = "";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
cur += ch;
|
|
59
|
+
}
|
|
60
|
+
if (cur)
|
|
61
|
+
out.push(cur);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Null while the command name itself is still being typed (no space yet). */
|
|
65
|
+
export function parseSlashArgs(value) {
|
|
66
|
+
if (!value.startsWith("/"))
|
|
67
|
+
return null;
|
|
68
|
+
const raw = value.slice(1);
|
|
69
|
+
if (!/\s/.test(raw))
|
|
70
|
+
return null;
|
|
71
|
+
const parts = splitArgs(raw.trim());
|
|
72
|
+
const command = parts[0] ?? "";
|
|
73
|
+
const rest = parts.slice(1);
|
|
74
|
+
const partial = /\s$/.test(raw) ? "" : (rest.pop() ?? "");
|
|
75
|
+
return { command, tokens: rest, partial };
|
|
76
|
+
}
|
|
77
|
+
export function getSlashArgOptions(value) {
|
|
78
|
+
const ctx = parseSlashArgs(value);
|
|
79
|
+
if (!ctx)
|
|
80
|
+
return [];
|
|
81
|
+
const provider = argProviders.get(ctx.command);
|
|
82
|
+
if (!provider)
|
|
83
|
+
return [];
|
|
84
|
+
const prefix = ctx.partial.toLowerCase();
|
|
85
|
+
return provider(ctx.tokens, ctx.partial).filter((o) => o.value.toLowerCase().startsWith(prefix));
|
|
86
|
+
}
|
|
87
|
+
/** Replace the token being typed with `option`, leaving a trailing space. */
|
|
88
|
+
export function applySlashArgCompletion(value, option) {
|
|
89
|
+
return /\s$/.test(value) ? `${value}${option} ` : `${value.replace(/\S+$/, option)} `;
|
|
90
|
+
}
|