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.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. 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
- Object.assign(this.state, partial);
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
- /** Append text to the last assistant message (for streaming) */
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 === "assistant") {
47
- last.content += delta;
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: `msg-${Date.now()}`,
54
- role: "assistant",
55
- content: delta,
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
- /** Append text to the current thinking message (for streaming thinking) */
61
- appendToThinking(delta) {
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 === "thinking") {
65
- last.content += delta;
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: `thinking-${Date.now()}`,
72
- role: "thinking",
73
- content: delta,
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
+ }