min-agent 0.4.0 → 0.5.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 +169 -284
- package/dist/agent.js +36 -22
- package/dist/cli/commands/chat.js +3 -0
- package/dist/cli/commands/exec.js +3 -0
- package/dist/cli/commands/index.js +22 -5
- package/dist/cli/commands/memory.js +33 -15
- package/dist/cli/commands/think.js +12 -0
- package/dist/cli/commands/write-config.js +22 -0
- package/dist/cli/option-helpers.js +13 -1
- package/dist/cli/program.js +50 -13
- package/dist/code-mode.js +1 -1
- package/dist/config.js +41 -0
- package/dist/context-window.js +8 -28
- package/dist/memory-cli.js +33 -0
- package/dist/memory.js +127 -46
- package/dist/model-catalog.js +285 -0
- package/dist/permission-cli.js +1 -4
- package/dist/provider.js +4 -1
- package/dist/reasoning-stream.js +158 -0
- package/dist/sandbox-cli.js +1 -4
- package/dist/scope.js +23 -0
- package/dist/serve/common.js +22 -1
- package/dist/serve/routes-chat.js +21 -1
- package/dist/serve/routes-memory.js +31 -2
- package/dist/serve/routes-meta.js +34 -6
- package/dist/think-cli.js +36 -0
- package/dist/thinking-wire.js +228 -0
- package/dist/thinking.js +142 -0
- package/dist/token-display.js +10 -7
- package/dist/tools/todo.js +22 -8
- package/dist/tui/App.js +36 -8
- package/dist/tui/InputBar.js +109 -36
- package/dist/tui/MessageList.js +53 -22
- package/dist/tui/StatusBar.js +7 -3
- package/dist/tui/ThinkPicker.js +77 -0
- package/dist/tui/bracketed-paste.js +37 -0
- package/dist/tui/caret-pos.js +10 -8
- package/dist/tui/index.js +7 -1
- package/dist/tui/layout.js +17 -0
- package/dist/tui/overlay-input.js +12 -0
- package/dist/tui/paste-draft.js +173 -0
- package/dist/tui/selection.js +8 -2
- package/dist/tui/slash-commands.js +18 -1
- package/dist/tui/slash-handler.js +61 -17
- package/dist/tui/text-width.js +6 -6
- package/dist/tui-chat.js +63 -7
- package/docs/API.md +50 -4
- package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +475 -0
- package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +450 -0
- package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +174 -0
- package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +140 -0
- package/package.json +1 -1
- package/skills/self-config/SKILL.md +5 -4
- package/skills/self-config/reference.md +10 -5
package/dist/tui/caret-pos.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { displayWidth, wrapByWidth, clustersOf } from "./text-width.js";
|
|
2
2
|
/** Split the value into visual rows, tracking each row's character start index (newlines count as one). */
|
|
3
|
-
export function visualRows(value, maxWidth) {
|
|
3
|
+
export function visualRows(value, maxWidth, lookup) {
|
|
4
4
|
const rows = [];
|
|
5
5
|
let start = 0;
|
|
6
6
|
const lines = value.split("\n");
|
|
7
7
|
for (let i = 0; i < lines.length; i++) {
|
|
8
|
-
for (const row of wrapByWidth(lines[i], maxWidth)) {
|
|
8
|
+
for (const row of wrapByWidth(lines[i], maxWidth, lookup)) {
|
|
9
9
|
rows.push({ text: row, startIndex: start });
|
|
10
10
|
start += Array.from(row).length;
|
|
11
11
|
}
|
|
@@ -23,7 +23,7 @@ export function moveCaretHorizontal(value, caretIndex, direction) {
|
|
|
23
23
|
* Map a click column (1-based terminal column) on a visual row to a character
|
|
24
24
|
* index in the value. Fullwidth characters snap to the nearer boundary.
|
|
25
25
|
*/
|
|
26
|
-
export function caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn) {
|
|
26
|
+
export function caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn, lookup) {
|
|
27
27
|
const row = rows[rowIndex];
|
|
28
28
|
if (!row)
|
|
29
29
|
return 0;
|
|
@@ -32,8 +32,10 @@ export function caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn
|
|
|
32
32
|
return row.startIndex;
|
|
33
33
|
let width = 0;
|
|
34
34
|
let idx = 0;
|
|
35
|
-
for (const { text: g, width: w } of clustersOf(row.text)) {
|
|
35
|
+
for (const { text: g, width: w } of clustersOf(row.text, lookup)) {
|
|
36
36
|
if (width + w > target) {
|
|
37
|
+
if (lookup?.(g) !== undefined)
|
|
38
|
+
return row.startIndex + idx;
|
|
37
39
|
return row.startIndex + idx + (target - width < w / 2 ? 0 : Array.from(g).length);
|
|
38
40
|
}
|
|
39
41
|
width += w;
|
|
@@ -64,8 +66,8 @@ export function deleteAt(value, caretIndex) {
|
|
|
64
66
|
return { value: chars.join(""), caretIndex };
|
|
65
67
|
}
|
|
66
68
|
/** Move the caret one visual row up (`-1`) or down (`1`), keeping the display column. */
|
|
67
|
-
export function moveCaretVertical(value, caretIndex, direction, maxWidth) {
|
|
68
|
-
const rows = visualRows(value, maxWidth);
|
|
69
|
+
export function moveCaretVertical(value, caretIndex, direction, maxWidth, lookup) {
|
|
70
|
+
const rows = visualRows(value, maxWidth, lookup);
|
|
69
71
|
let rowIndex = 0;
|
|
70
72
|
let offset = 0;
|
|
71
73
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -84,8 +86,8 @@ export function moveCaretVertical(value, caretIndex, direction, maxWidth) {
|
|
|
84
86
|
const next = rowIndex + direction;
|
|
85
87
|
if (next < 0 || next >= rows.length)
|
|
86
88
|
return caretIndex;
|
|
87
|
-
const col = displayWidth(Array.from(rows[rowIndex].text).slice(0, offset).join(""));
|
|
88
|
-
return caretIndexFromClick(rows, next, col, 0);
|
|
89
|
+
const col = displayWidth(Array.from(rows[rowIndex].text).slice(0, offset).join(""), lookup);
|
|
90
|
+
return caretIndexFromClick(rows, next, col, 0, lookup);
|
|
89
91
|
}
|
|
90
92
|
export function moveToLineStart(value, caretIndex) {
|
|
91
93
|
const chars = Array.from(value);
|
package/dist/tui/index.js
CHANGED
|
@@ -28,7 +28,7 @@ export class TuiRenderer {
|
|
|
28
28
|
this.callbacks = callbacks;
|
|
29
29
|
}
|
|
30
30
|
appElement() {
|
|
31
|
-
return (_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, onSessionPick: this.callbacks.onSessionPick, onSessionCancel: this.callbacks.onSessionCancel, onToggleSelectionMode: this.callbacks.onToggleSelectionMode, onExitSelectionMode: this.callbacks.onExitSelectionMode, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text) }));
|
|
31
|
+
return (_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, onThinkPick: this.callbacks.onThinkPick, onThinkCancel: this.callbacks.onThinkCancel, onSessionPick: this.callbacks.onSessionPick, onSessionCancel: this.callbacks.onSessionCancel, onToggleSelectionMode: this.callbacks.onToggleSelectionMode, onExitSelectionMode: this.callbacks.onExitSelectionMode, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text), onNotice: this.callbacks.onNotice }));
|
|
32
32
|
}
|
|
33
33
|
start() {
|
|
34
34
|
this.inkInstance = render(this.appElement(), { exitOnCtrlC: false, stdout: createCaretAwareStdout(process.stdout) });
|
|
@@ -191,6 +191,12 @@ export class TuiRenderer {
|
|
|
191
191
|
hideModelPicker() {
|
|
192
192
|
this.update({ modelPicker: false });
|
|
193
193
|
}
|
|
194
|
+
showThinkPicker(scope = "global") {
|
|
195
|
+
this.update({ thinkPicker: { scope }, selectionMode: false });
|
|
196
|
+
}
|
|
197
|
+
hideThinkPicker() {
|
|
198
|
+
this.update({ thinkPicker: undefined });
|
|
199
|
+
}
|
|
194
200
|
showSessionPicker() {
|
|
195
201
|
this.update({ sessionPicker: true, selectionMode: false });
|
|
196
202
|
}
|
package/dist/tui/layout.js
CHANGED
|
@@ -4,6 +4,9 @@ export const TEXT_START_COLUMN = 5;
|
|
|
4
4
|
export const STATUS_BAR_ROWS = 2;
|
|
5
5
|
export const INPUT_BAR_ROWS = 3;
|
|
6
6
|
export const MIN_MESSAGE_ROWS = 2;
|
|
7
|
+
export const SLASH_MENU_MIN_ITEMS = 4;
|
|
8
|
+
export const SLASH_COMMAND_MENU_CHROME = 5;
|
|
9
|
+
export const SLASH_ARG_MENU_CHROME = 4;
|
|
7
10
|
/** Ink paddingLeft on every message row. */
|
|
8
11
|
export const MESSAGE_PAD_LEFT = 1;
|
|
9
12
|
/**
|
|
@@ -51,6 +54,20 @@ export function footerMaxRows(frameRowCount, minChrome) {
|
|
|
51
54
|
return Number.POSITIVE_INFINITY;
|
|
52
55
|
return Math.max(minChrome, frameRowCount - STATUS_BAR_ROWS - MIN_MESSAGE_ROWS);
|
|
53
56
|
}
|
|
57
|
+
export function inputBarBoxRows(contentRows, hintRows) {
|
|
58
|
+
return Math.max(1, contentRows) + Math.max(0, hintRows) + 2;
|
|
59
|
+
}
|
|
60
|
+
export function inputBarPaintRows(contentRows, hintRows, menuShown, argShown) {
|
|
61
|
+
const menu = menuShown > 0 ? menuShown + SLASH_COMMAND_MENU_CHROME : argShown > 0 ? argShown + SLASH_ARG_MENU_CHROME : 0;
|
|
62
|
+
return inputBarBoxRows(contentRows, hintRows) + menu;
|
|
63
|
+
}
|
|
64
|
+
/** How many slash-menu rows fit without covering the status bar and a two-row message peek. */
|
|
65
|
+
export function slashMenuMaxVisible(frameRowCount, contentRows, hintRows, menuChromeRows) {
|
|
66
|
+
const cap = footerMaxRows(frameRowCount, INPUT_BAR_ROWS);
|
|
67
|
+
if (!Number.isFinite(cap))
|
|
68
|
+
return Number.POSITIVE_INFINITY;
|
|
69
|
+
return Math.max(SLASH_MENU_MIN_ITEMS, cap - inputBarBoxRows(contentRows, hintRows) - menuChromeRows);
|
|
70
|
+
}
|
|
54
71
|
/** Rows a line window actually paints: content plus one indicator per hidden side. */
|
|
55
72
|
export function lineWindowPaintRows(window) {
|
|
56
73
|
return window.lines.length + (window.above > 0 ? 1 : 0) + (window.below > 0 ? 1 : 0);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pickers replace the input bar on the same Enter that opened them. Ink may
|
|
3
|
+
* still deliver that key (or a leftover LF after CR) to the newly mounted
|
|
4
|
+
* handler, which would auto-confirm the first row.
|
|
5
|
+
*/
|
|
6
|
+
export function shouldAcceptOverlayConfirm(armed, key, input) {
|
|
7
|
+
if (!armed)
|
|
8
|
+
return false;
|
|
9
|
+
if (input === "\n" && !key.return)
|
|
10
|
+
return false;
|
|
11
|
+
return Boolean(key.return);
|
|
12
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { displayWidth, clustersOf } from "./text-width.js";
|
|
2
|
+
export const PASTE_CHIP_BASE = 0xe000;
|
|
3
|
+
export const SNIPPET_MAX_CHARS = 1_000_000;
|
|
4
|
+
export const IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
export const NOTICE_IMAGE_TOO_LARGE = "图片超过 5MB,未附加";
|
|
6
|
+
export const NOTICE_SNIPPET_TOO_LARGE = "粘贴内容过大,未附加";
|
|
7
|
+
const PUA_START = 0xe000;
|
|
8
|
+
const PUA_END = 0xf8ff;
|
|
9
|
+
const UNMAPPED = "�";
|
|
10
|
+
export function createPasteDraft() {
|
|
11
|
+
return {
|
|
12
|
+
nextCodePoint: PASTE_CHIP_BASE,
|
|
13
|
+
nextImageId: 1,
|
|
14
|
+
nextSnippetId: 1,
|
|
15
|
+
attachments: new Map(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function resetPasteDraft() {
|
|
19
|
+
return createPasteDraft();
|
|
20
|
+
}
|
|
21
|
+
export function imageLabel(id) {
|
|
22
|
+
return `[图片 #${id}]`;
|
|
23
|
+
}
|
|
24
|
+
export function snippetLabel(id, lineCount) {
|
|
25
|
+
return `[粘贴 #${id} · ${lineCount} 行]`;
|
|
26
|
+
}
|
|
27
|
+
export function normalizePastedText(raw) {
|
|
28
|
+
const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
29
|
+
return normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
|
|
30
|
+
}
|
|
31
|
+
export function applyPastedText(draft, raw) {
|
|
32
|
+
const text = normalizePastedText(raw);
|
|
33
|
+
if (text.length > SNIPPET_MAX_CHARS) {
|
|
34
|
+
return { kind: "reject", notice: NOTICE_SNIPPET_TOO_LARGE, draft };
|
|
35
|
+
}
|
|
36
|
+
if (!text.includes("\n")) {
|
|
37
|
+
return { kind: "text", text, draft };
|
|
38
|
+
}
|
|
39
|
+
const { char, draft: next } = allocChip(draft, {
|
|
40
|
+
kind: "snippet",
|
|
41
|
+
id: draft.nextSnippetId,
|
|
42
|
+
text,
|
|
43
|
+
lineCount: text.split("\n").length,
|
|
44
|
+
});
|
|
45
|
+
return { kind: "snippet", char, draft: next };
|
|
46
|
+
}
|
|
47
|
+
export function applyClipboardImage(draft, image) {
|
|
48
|
+
if (image == null)
|
|
49
|
+
return { kind: "none", draft };
|
|
50
|
+
if (image.data.length > IMAGE_MAX_BYTES) {
|
|
51
|
+
return { kind: "reject", notice: NOTICE_IMAGE_TOO_LARGE, draft };
|
|
52
|
+
}
|
|
53
|
+
const { char, draft: next } = allocChip(draft, {
|
|
54
|
+
kind: "image",
|
|
55
|
+
id: draft.nextImageId,
|
|
56
|
+
data: image.data,
|
|
57
|
+
mimeType: image.mimeType,
|
|
58
|
+
});
|
|
59
|
+
return { kind: "image", char, draft: next };
|
|
60
|
+
}
|
|
61
|
+
export function pruneAttachments(draft, value) {
|
|
62
|
+
const present = new Set(Array.from(value));
|
|
63
|
+
const attachments = new Map([...draft.attachments].filter(([char]) => present.has(char)));
|
|
64
|
+
if (attachments.size === draft.attachments.size)
|
|
65
|
+
return draft;
|
|
66
|
+
return { ...draft, attachments };
|
|
67
|
+
}
|
|
68
|
+
export function expandDisplay(value, draft) {
|
|
69
|
+
return Array.from(value)
|
|
70
|
+
.map((char) => {
|
|
71
|
+
const att = draft.attachments.get(char);
|
|
72
|
+
if (att)
|
|
73
|
+
return attachmentLabel(att);
|
|
74
|
+
return isPrivateUse(char) ? UNMAPPED : char;
|
|
75
|
+
})
|
|
76
|
+
.join("");
|
|
77
|
+
}
|
|
78
|
+
export function expandContent(value, draft) {
|
|
79
|
+
const parts = [];
|
|
80
|
+
let textBuf = "";
|
|
81
|
+
const flushText = () => {
|
|
82
|
+
if (!textBuf)
|
|
83
|
+
return;
|
|
84
|
+
parts.push({ type: "text", text: textBuf });
|
|
85
|
+
textBuf = "";
|
|
86
|
+
};
|
|
87
|
+
for (const char of Array.from(value)) {
|
|
88
|
+
const att = draft.attachments.get(char);
|
|
89
|
+
if (att?.kind === "image") {
|
|
90
|
+
flushText();
|
|
91
|
+
parts.push({ type: "image", image: att.data, mimeType: att.mimeType });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (att?.kind === "snippet") {
|
|
95
|
+
flushText();
|
|
96
|
+
parts.push({ type: "text", text: snippetBlock(att.id, att.text, parts) });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
textBuf += isPrivateUse(char) ? UNMAPPED : char;
|
|
100
|
+
}
|
|
101
|
+
flushText();
|
|
102
|
+
if (!parts.some((part) => part.type === "image")) {
|
|
103
|
+
return parts.map((part) => (part.type === "text" ? part.text : "")).join("");
|
|
104
|
+
}
|
|
105
|
+
return parts;
|
|
106
|
+
}
|
|
107
|
+
export function buildSubmittedPrompt(value, draft) {
|
|
108
|
+
return { display: expandDisplay(value, draft), content: expandContent(value, draft) };
|
|
109
|
+
}
|
|
110
|
+
export function chipClusterWidth(cluster, draft, maxWidth) {
|
|
111
|
+
const att = draft.attachments.get(cluster);
|
|
112
|
+
if (!att)
|
|
113
|
+
return undefined;
|
|
114
|
+
return Math.min(displayWidth(attachmentLabel(att)), maxWidth);
|
|
115
|
+
}
|
|
116
|
+
export function paintedChip(cluster, draft, maxWidth) {
|
|
117
|
+
const att = draft.attachments.get(cluster);
|
|
118
|
+
if (!att)
|
|
119
|
+
return undefined;
|
|
120
|
+
return truncateToWidth(attachmentLabel(att), maxWidth);
|
|
121
|
+
}
|
|
122
|
+
export function paintChar(char, draft, maxWidth) {
|
|
123
|
+
return paintedChip(char, draft, maxWidth) ?? (isPrivateUse(char) ? UNMAPPED : char);
|
|
124
|
+
}
|
|
125
|
+
function attachmentLabel(att) {
|
|
126
|
+
return att.kind === "image" ? imageLabel(att.id) : snippetLabel(att.id, att.lineCount);
|
|
127
|
+
}
|
|
128
|
+
function snippetBlock(id, text, previous) {
|
|
129
|
+
const body = `--- 粘贴 #${id} ---\n${text}\n--- 粘贴 #${id} ---`;
|
|
130
|
+
if (previous.length === 0)
|
|
131
|
+
return body;
|
|
132
|
+
const last = previous[previous.length - 1];
|
|
133
|
+
if (last?.type === "text" && last.text.endsWith("\n"))
|
|
134
|
+
return body;
|
|
135
|
+
return `\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
function allocChip(draft, attachment) {
|
|
138
|
+
const char = String.fromCodePoint(draft.nextCodePoint);
|
|
139
|
+
const attachments = new Map(draft.attachments);
|
|
140
|
+
attachments.set(char, attachment);
|
|
141
|
+
return {
|
|
142
|
+
char,
|
|
143
|
+
draft: {
|
|
144
|
+
nextCodePoint: draft.nextCodePoint + 1,
|
|
145
|
+
nextImageId: attachment.kind === "image" ? draft.nextImageId + 1 : draft.nextImageId,
|
|
146
|
+
nextSnippetId: attachment.kind === "snippet" ? draft.nextSnippetId + 1 : draft.nextSnippetId,
|
|
147
|
+
attachments,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function isPrivateUse(char) {
|
|
152
|
+
const cp = char.codePointAt(0);
|
|
153
|
+
return cp !== undefined && cp >= PUA_START && cp <= PUA_END;
|
|
154
|
+
}
|
|
155
|
+
function truncateToWidth(text, maxWidth) {
|
|
156
|
+
if (maxWidth < 1)
|
|
157
|
+
return "";
|
|
158
|
+
if (displayWidth(text) <= maxWidth)
|
|
159
|
+
return text;
|
|
160
|
+
const ellipsis = "…";
|
|
161
|
+
const ellipsisW = displayWidth(ellipsis);
|
|
162
|
+
let out = "";
|
|
163
|
+
let width = 0;
|
|
164
|
+
for (const { text: g, width: w } of clustersOf(text)) {
|
|
165
|
+
if (width + w + ellipsisW > maxWidth)
|
|
166
|
+
break;
|
|
167
|
+
out += g;
|
|
168
|
+
width += w;
|
|
169
|
+
}
|
|
170
|
+
out += ellipsis;
|
|
171
|
+
width += ellipsisW;
|
|
172
|
+
return width < maxWidth ? `${out}${" ".repeat(maxWidth - width)}` : out;
|
|
173
|
+
}
|
package/dist/tui/selection.js
CHANGED
|
@@ -97,11 +97,13 @@ export function stripCopyDecorations(line, kind) {
|
|
|
97
97
|
return line.slice(2);
|
|
98
98
|
if (kind === "tool-head")
|
|
99
99
|
return line.replace(/^⚡ .*?[▸▾] /, "");
|
|
100
|
-
if (kind === "
|
|
100
|
+
if (kind === "thinking-head")
|
|
101
|
+
return line.replace(/^💭 .*?[▸▾] /, "");
|
|
102
|
+
if ((kind === "tool-body" || kind === "thinking-body") && line.startsWith(" "))
|
|
101
103
|
return line.slice(2);
|
|
102
104
|
return line;
|
|
103
105
|
}
|
|
104
|
-
/** Visible prefix rendered in its own Ink Text (user `> `, tool `⚡ name ▸ `). */
|
|
106
|
+
/** Visible prefix rendered in its own Ink Text (user `> `, tool `⚡ name ▸ `, thinking `💭 思考 ▸ `). */
|
|
105
107
|
export function visualPrefix(line, role) {
|
|
106
108
|
if (role === "user" && line.startsWith("> "))
|
|
107
109
|
return "> ";
|
|
@@ -109,6 +111,10 @@ export function visualPrefix(line, role) {
|
|
|
109
111
|
const m = line.match(/^⚡ .*?[▸▾] /);
|
|
110
112
|
return m ? m[0] : "";
|
|
111
113
|
}
|
|
114
|
+
if (role === "thinking") {
|
|
115
|
+
const m = line.match(/^💭 .*?[▸▾] /);
|
|
116
|
+
return m ? m[0] : "";
|
|
117
|
+
}
|
|
112
118
|
return "";
|
|
113
119
|
}
|
|
114
120
|
/** Split a display-column range across a prefix of `at` cells. */
|
|
@@ -4,10 +4,11 @@ export const SLASH_COMMANDS = [
|
|
|
4
4
|
{ name: "model", description: "选择/切换模型" },
|
|
5
5
|
{ name: "models", description: "列出可用模型" },
|
|
6
6
|
{ name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
|
|
7
|
-
{ name: "memory", description: "
|
|
7
|
+
{ name: "memory", description: "查看/开关记忆,或保存一条", argHint: "[on|off|t] [--project]" },
|
|
8
8
|
{ name: "tokens", description: "显示上下文占用、累计用量与成本" },
|
|
9
9
|
{ name: "budget", description: "查看/设置预算上限", argHint: "[n] [--project]" },
|
|
10
10
|
{ name: "permission", description: "查看/设置确认模式", argHint: "[ask|accept-edits|allow-all] [--project]" },
|
|
11
|
+
{ name: "think", description: "选择思考强度", aliases: ["thinking"] },
|
|
11
12
|
{ name: "sandbox", description: "查看/设置隔离", argHint: "[off|workspace|strict] [--project]" },
|
|
12
13
|
{ name: "undo", description: "撤销上一轮" },
|
|
13
14
|
{ name: "redo", description: "重发最后一条消息" },
|
|
@@ -34,6 +35,22 @@ export function filterSlashCommands(input, commands = SLASH_COMMANDS) {
|
|
|
34
35
|
return commands;
|
|
35
36
|
return commands.filter((c) => c.name.startsWith(q) || (c.aliases ?? []).some((a) => a.startsWith(q)));
|
|
36
37
|
}
|
|
38
|
+
/** Keep `selectedIndex` on screen; the highlight stays on the last row once the list scrolls. */
|
|
39
|
+
export function slashMenuWindow(items, selectedIndex, maxVisible) {
|
|
40
|
+
const max = Number.isFinite(maxVisible) ? Math.max(1, Math.floor(maxVisible)) : Math.max(1, items.length);
|
|
41
|
+
const index = items.length === 0 ? 0 : Math.min(Math.max(0, selectedIndex), items.length - 1);
|
|
42
|
+
if (items.length <= max) {
|
|
43
|
+
return { start: 0, shown: [...items], extraAbove: 0, extraBelow: 0 };
|
|
44
|
+
}
|
|
45
|
+
const start = Math.max(0, Math.min(index - max + 1, items.length - max));
|
|
46
|
+
const shown = items.slice(start, start + max);
|
|
47
|
+
return {
|
|
48
|
+
start,
|
|
49
|
+
shown,
|
|
50
|
+
extraAbove: start,
|
|
51
|
+
extraBelow: items.length - (start + shown.length),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
37
54
|
/** True for submitted input that is a known slash command (including aliases). */
|
|
38
55
|
export function isKnownSlashCommandInput(text, commands = SLASH_COMMANDS) {
|
|
39
56
|
if (!text.startsWith("/"))
|
|
@@ -215,34 +215,50 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
215
215
|
break;
|
|
216
216
|
}
|
|
217
217
|
case "memory": {
|
|
218
|
-
const { takeScopeFlags
|
|
218
|
+
const { takeScopeFlags } = await import("../scope.js");
|
|
219
|
+
const { addMemory, loadMemories, defaultMemoryScope, formatMemoryLine, parseMemoryMode, resolveMemoryMode, setMemoryMode, setMemoryOverride, isMemoryEnabled, } = await import("../memory.js");
|
|
219
220
|
const { scope, rest: memRest } = takeScopeFlags(rest);
|
|
221
|
+
const parsedMode = memRest.length === 1 ? parseMemoryMode(memRest[0]) : undefined;
|
|
222
|
+
if (parsedMode) {
|
|
223
|
+
const resolved = scope === "project" ? "project" : "global";
|
|
224
|
+
setMemoryMode(parsedMode, resolved);
|
|
225
|
+
setMemoryOverride(parsedMode);
|
|
226
|
+
sysMsg(tui, `✓ 记忆已${parsedMode === "on" ? "开启" : "关闭"}(${resolved === "project" ? "项目" : "全局"})`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
220
229
|
const text = memRest.join(" ").trim();
|
|
221
230
|
if (text) {
|
|
222
|
-
const { addMemory } = await import("../memory.js");
|
|
223
231
|
const resolved = scope ?? defaultMemoryScope();
|
|
224
232
|
addMemory(text, [], resolved);
|
|
225
|
-
|
|
233
|
+
const hint = isMemoryEnabled() ? "" : "(当前未开启,下一轮对话不会用到;可用 /memory on 开启)";
|
|
234
|
+
sysMsg(tui, `✓ 已保存${resolved === "project" ? "项目" : "全局"}记忆: "${text}"${hint}`);
|
|
226
235
|
}
|
|
227
236
|
else {
|
|
228
|
-
const
|
|
237
|
+
const snap = resolveMemoryMode();
|
|
238
|
+
const sourceLabel = snap.source === "cli"
|
|
239
|
+
? "本次启动参数"
|
|
240
|
+
: snap.source === "project"
|
|
241
|
+
? "项目"
|
|
242
|
+
: snap.source === "global"
|
|
243
|
+
? "全局"
|
|
244
|
+
: "默认";
|
|
245
|
+
const status = `记忆:${snap.memory === "on" ? "开" : "关"}(${sourceLabel})`;
|
|
229
246
|
const scopes = scope ? [scope] : ["project", "global"];
|
|
230
247
|
const sections = scopes.map((s) => {
|
|
231
248
|
const memories = loadMemories(s);
|
|
232
249
|
const label = s === "project" ? "项目记忆" : "全局记忆";
|
|
233
250
|
if (memories.length === 0)
|
|
234
251
|
return `${label}: 无`;
|
|
235
|
-
const lines = memories.map((m, i) =>
|
|
236
|
-
const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : "";
|
|
237
|
-
return ` #${i + 1}: ${m.content}${tags}`;
|
|
238
|
-
});
|
|
252
|
+
const lines = memories.map((m, i) => formatMemoryLine(m, i));
|
|
239
253
|
return `${label} (${memories.length}):\n${lines.join("\n")}`;
|
|
240
254
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
const empty = !scope && loadMemories("project").length === 0 && loadMemories("global").length === 0;
|
|
256
|
+
sysMsg(tui, [
|
|
257
|
+
status,
|
|
258
|
+
empty ? "无已保存的记忆" : sections.join("\n"),
|
|
259
|
+
"设置: /memory on|off [--project]",
|
|
260
|
+
"保存: /memory <文本> [--project]",
|
|
261
|
+
].join("\n"));
|
|
246
262
|
}
|
|
247
263
|
break;
|
|
248
264
|
}
|
|
@@ -288,7 +304,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
288
304
|
break;
|
|
289
305
|
}
|
|
290
306
|
case "budget": {
|
|
291
|
-
const { takeScopeFlags } = await import("../
|
|
307
|
+
const { takeScopeFlags } = await import("../scope.js");
|
|
292
308
|
const { getBudgetSnapshot, setBudgetMaxCost } = await import("../config.js");
|
|
293
309
|
const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
|
|
294
310
|
const { scope, rest: budgetRest } = takeScopeFlags(rest);
|
|
@@ -315,7 +331,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
315
331
|
break;
|
|
316
332
|
}
|
|
317
333
|
case "permission": {
|
|
318
|
-
const { takeScopeFlags } = await import("../
|
|
334
|
+
const { takeScopeFlags } = await import("../scope.js");
|
|
319
335
|
const { getPermissionSnapshot, parsePermissionMode, permissionModeLabel, setPermissionMode } = await import("../config.js");
|
|
320
336
|
const { getPermissionOverride, setPermissionOverride } = await import("../confirm.js");
|
|
321
337
|
const { scope, rest: permRest } = takeScopeFlags(rest);
|
|
@@ -342,6 +358,31 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
342
358
|
sysMsg(tui, `✓ 确认已设为${permissionModeLabel(parsed)}(${resolved === "project" ? "项目" : "全局"})`);
|
|
343
359
|
break;
|
|
344
360
|
}
|
|
361
|
+
case "think":
|
|
362
|
+
case "thinking": {
|
|
363
|
+
const { takeScopeFlags } = await import("../scope.js");
|
|
364
|
+
const { parseThinkingEffort, thinkingEffortLabel, thinkingChoicesForModel, setThinkingEffort, setThinkingOverride, } = await import("../thinking.js");
|
|
365
|
+
const { scope, rest: thinkRest } = takeScopeFlags(rest);
|
|
366
|
+
if (thinkRest.length === 0) {
|
|
367
|
+
tui.showThinkPicker(scope === "project" ? "project" : "global");
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
const parsed = parseThinkingEffort(thinkRest[0]);
|
|
371
|
+
const choices = thinkingChoicesForModel(currentModel);
|
|
372
|
+
if (!parsed || thinkRest.length > 1) {
|
|
373
|
+
sysMsg(tui, `用法: /think [${choices.join("|")}] [--project]`);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
if (!choices.includes(parsed)) {
|
|
377
|
+
sysMsg(tui, `该模型支持: ${choices.join(", ")}`);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
const resolved = scope === "project" ? "project" : "global";
|
|
381
|
+
setThinkingEffort(parsed, resolved);
|
|
382
|
+
setThinkingOverride(parsed);
|
|
383
|
+
sysMsg(tui, `✓ 思考强度已设为 ${thinkingEffortLabel(parsed)}(${resolved === "project" ? "项目" : "全局"})`);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
345
386
|
case "mcp": {
|
|
346
387
|
const { loadMcpConfig } = await import("../mcp.js");
|
|
347
388
|
const { getMcpStatus } = await import("../mcp.js");
|
|
@@ -439,7 +480,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
439
480
|
break;
|
|
440
481
|
}
|
|
441
482
|
case "sandbox": {
|
|
442
|
-
const { takeScopeFlags } = await import("../
|
|
483
|
+
const { takeScopeFlags } = await import("../scope.js");
|
|
443
484
|
const { getSandboxSnapshot, setSandboxConfig } = await import("../config.js");
|
|
444
485
|
const { parseSandboxMode, parseNetworkPolicy, getEffectiveSandboxPolicy, sandboxEnforcementCaveat, sandboxModeLabel, setSandboxOverride, } = await import("../sandbox.js");
|
|
445
486
|
const { scope, rest: sbRest } = takeScopeFlags(rest);
|
|
@@ -501,10 +542,11 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
501
542
|
" /model [n] 交互式选择/切换模型",
|
|
502
543
|
" /models 列出可用模型",
|
|
503
544
|
" /provider [n] 查看/切换 Provider",
|
|
504
|
-
" /memory
|
|
545
|
+
" /memory 查看记忆开关与已保存内容(/memory on|off 开关;也可 /memory 文本 保存)",
|
|
505
546
|
" /tokens 显示上下文占用、累计用量与成本",
|
|
506
547
|
" /budget [n] 查看/设置预算上限(--project 写入当前项目)",
|
|
507
548
|
" /permission 查看/设置确认模式(ask|accept-edits|allow-all,--project 写入当前项目)",
|
|
549
|
+
" /think 交互式选择思考强度(档位随当前模型;也可 /think off|low|medium|high|max)",
|
|
508
550
|
" /sandbox 查看/设置隔离(off|workspace|strict,--project 写入当前项目)",
|
|
509
551
|
" /undo 撤销上一轮",
|
|
510
552
|
" /redo 重发最后一条消息",
|
|
@@ -525,6 +567,8 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
|
|
|
525
567
|
"快捷键:",
|
|
526
568
|
" Esc 取消当前任务",
|
|
527
569
|
" Ctrl+J 输入框换行",
|
|
570
|
+
" Ctrl+V 粘贴剪贴板图片到输入框",
|
|
571
|
+
" 多行粘贴 收成片段,发送时带上全文",
|
|
528
572
|
" Ctrl+C 清空输入(↑↓ 可找回);生成中则中断,空输入连按两次退出",
|
|
529
573
|
" Ctrl+B 消息区选择模式",
|
|
530
574
|
" Home/End 行首/行尾",
|
package/dist/tui/text-width.js
CHANGED
|
@@ -75,15 +75,15 @@ export function clusterWidth(cluster) {
|
|
|
75
75
|
export function charWidth(char) {
|
|
76
76
|
return clusterWidth(char);
|
|
77
77
|
}
|
|
78
|
-
export function* clustersOf(text) {
|
|
78
|
+
export function* clustersOf(text, lookup) {
|
|
79
79
|
for (const { segment } of segmenter.segment(text)) {
|
|
80
|
-
yield { text: segment, width: clusterWidth(segment) };
|
|
80
|
+
yield { text: segment, width: lookup?.(segment) ?? clusterWidth(segment) };
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
/** Display width of a string (single logical line, no ANSI escapes). */
|
|
84
|
-
export function displayWidth(text) {
|
|
84
|
+
export function displayWidth(text, lookup) {
|
|
85
85
|
let width = 0;
|
|
86
|
-
for (const { width: w } of clustersOf(text))
|
|
86
|
+
for (const { width: w } of clustersOf(text, lookup))
|
|
87
87
|
width += w;
|
|
88
88
|
return width;
|
|
89
89
|
}
|
|
@@ -92,14 +92,14 @@ export function displayWidth(text) {
|
|
|
92
92
|
* Breaks on cluster boundaries (not word boundaries) so the caret column can
|
|
93
93
|
* be derived exactly — a fullwidth character or emoji is never split across rows.
|
|
94
94
|
*/
|
|
95
|
-
export function wrapByWidth(line, maxWidth) {
|
|
95
|
+
export function wrapByWidth(line, maxWidth, lookup) {
|
|
96
96
|
const text = typeof line === "string" ? line : line == null ? "" : String(line);
|
|
97
97
|
if (maxWidth < 1)
|
|
98
98
|
return [text];
|
|
99
99
|
const rows = [];
|
|
100
100
|
let current = "";
|
|
101
101
|
let width = 0;
|
|
102
|
-
for (const { text: g, width: w } of clustersOf(text)) {
|
|
102
|
+
for (const { text: g, width: w } of clustersOf(text, lookup)) {
|
|
103
103
|
if (width + w > maxWidth && current.length > 0) {
|
|
104
104
|
rows.push(current);
|
|
105
105
|
current = "";
|