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/MessageList.js
CHANGED
|
@@ -1,33 +1,546 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import React, { useMemo, useEffect, useRef, useState, useCallback } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdin } from "ink";
|
|
3
4
|
import { MarkdownRenderer } from "../markdown.js";
|
|
5
|
+
import { wrapByWidth, charWidth, displayWidth } from "./text-width.js";
|
|
6
|
+
import { scanSgrMouse, WHEEL_UP, WHEEL_DOWN, MOTION } from "./mouse.js";
|
|
7
|
+
import { applySelection, extractText, selectionRanges } from "./selection.js";
|
|
8
|
+
import { createDragMachine, clearDrag, stepDrag } from "./drag-state.js";
|
|
9
|
+
import { writeClipboard } from "../clipboard.js";
|
|
10
|
+
const RESET = "\x1b[0m";
|
|
11
|
+
/** Split a single ANSI-formatted line into physical rows of at most `maxWidth`
|
|
12
|
+
* cells. Each row re-applies the styles active at its start and ends with a
|
|
13
|
+
* reset, so a window sliced in the middle of a message keeps correct colors. */
|
|
14
|
+
function splitAnsiRows(line, maxWidth) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
let current = "";
|
|
17
|
+
let width = 0;
|
|
18
|
+
let prefix = "";
|
|
19
|
+
for (const token of line.split(/(\x1b\[[0-9;]*m)/g)) {
|
|
20
|
+
if (token.startsWith("\x1b[")) {
|
|
21
|
+
if (token === RESET)
|
|
22
|
+
prefix = "";
|
|
23
|
+
else
|
|
24
|
+
prefix += token;
|
|
25
|
+
current += token;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const ch of token) {
|
|
29
|
+
const w = charWidth(ch);
|
|
30
|
+
if (width > 0 && width + w > maxWidth) {
|
|
31
|
+
rows.push(current + RESET);
|
|
32
|
+
current = prefix + ch;
|
|
33
|
+
width = w;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
current += ch;
|
|
37
|
+
width += w;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
rows.push(current + RESET);
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
/** Longest prefix of `text` that fits in `maxWidth` cells. */
|
|
45
|
+
function clipToWidth(text, maxWidth) {
|
|
46
|
+
let shown = "";
|
|
47
|
+
let width = 0;
|
|
48
|
+
for (const ch of text) {
|
|
49
|
+
const w = charWidth(ch);
|
|
50
|
+
if (width + w > maxWidth)
|
|
51
|
+
break;
|
|
52
|
+
shown += ch;
|
|
53
|
+
width += w;
|
|
54
|
+
}
|
|
55
|
+
return shown;
|
|
56
|
+
}
|
|
57
|
+
/** Rows shown for an expanded tool result — enough to read a result list. */
|
|
58
|
+
const TOOL_RESULT_MAX_LINES = 120;
|
|
4
59
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
60
|
+
* Cap an expanded tool result so a 50KB output cannot take over the viewport.
|
|
61
|
+
* Tools already persist their full output to disk when they truncate.
|
|
7
62
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
63
|
+
function resultPreviewLines(result) {
|
|
64
|
+
const lines = result.replace(/\s+$/, "").split("\n");
|
|
65
|
+
if (lines.length <= TOOL_RESULT_MAX_LINES)
|
|
66
|
+
return lines;
|
|
67
|
+
const hidden = lines.length - TOOL_RESULT_MAX_LINES;
|
|
68
|
+
return [...lines.slice(0, TOOL_RESULT_MAX_LINES), `… 结果还有 ${hidden} 行未显示`];
|
|
69
|
+
}
|
|
70
|
+
/** Physical rows for one message. The leading marker ("> " / "⚡ name ") lives
|
|
71
|
+
* in the first row only, mirroring the JSX layout below.
|
|
72
|
+
*
|
|
73
|
+
* Tool calls render as a single row (arguments plus a result summary) by
|
|
74
|
+
* default; clicking the row (see MessageList) toggles `expanded` to reveal the
|
|
75
|
+
* full tool result. */
|
|
76
|
+
export function messageLines(msg, columns, expanded) {
|
|
77
|
+
const textWidth = Math.max(1, columns - 1); // Box paddingLeft
|
|
78
|
+
switch (msg.role) {
|
|
79
|
+
case "user":
|
|
80
|
+
return wrapByWidth(`> ${msg.content}`, textWidth);
|
|
81
|
+
case "assistant": {
|
|
82
|
+
const md = new MarkdownRenderer();
|
|
83
|
+
const rendered = md.write(msg.content) + md.flush();
|
|
84
|
+
const raw = rendered.replace(/\n$/, "");
|
|
85
|
+
if (raw === "")
|
|
86
|
+
return [];
|
|
87
|
+
const lines = raw.split("\n");
|
|
88
|
+
// Drop leading/trailing blank lines (models often pad around tool calls)
|
|
89
|
+
// so a whitespace-only or padded message doesn't leave empty rows.
|
|
90
|
+
let start = 0;
|
|
91
|
+
let end = lines.length;
|
|
92
|
+
while (start < end && lines[start].trim() === "")
|
|
93
|
+
start++;
|
|
94
|
+
while (end > start && lines[end - 1].trim() === "")
|
|
95
|
+
end--;
|
|
96
|
+
if (start === end)
|
|
97
|
+
return [];
|
|
98
|
+
const rows = [];
|
|
99
|
+
for (let i = start; i < end; i++)
|
|
100
|
+
rows.push(...splitAnsiRows(lines[i], textWidth));
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
case "thinking":
|
|
104
|
+
return msg.content && msg.content.trim() ? wrapByWidth(msg.content, textWidth) : [];
|
|
105
|
+
case "tool": {
|
|
106
|
+
const name = msg.toolName ?? "";
|
|
107
|
+
// The call summary is a single logical line; collapse any stray newlines
|
|
108
|
+
// so it cannot push the row layout around.
|
|
109
|
+
const call = msg.content.replace(/\s+/g, " ").trim();
|
|
110
|
+
const label = `⚡ ${name} ${expanded ? "▾" : "▸"} `;
|
|
111
|
+
const contentWidth = Math.max(1, textWidth - displayWidth(label));
|
|
112
|
+
if (!expanded) {
|
|
113
|
+
const tail = msg.toolResultSummary ? ` → ${msg.toolResultSummary}` : "";
|
|
114
|
+
if (displayWidth(call + tail) <= contentWidth)
|
|
115
|
+
return [`${label}${call}${tail}`];
|
|
116
|
+
// The result summary is the more informative half, so the arguments
|
|
117
|
+
// give way first.
|
|
118
|
+
const room = Math.max(1, contentWidth - displayWidth(tail) - 1);
|
|
119
|
+
return [`${label}${clipToWidth(call, room)}…${tail}`];
|
|
120
|
+
}
|
|
121
|
+
const rows = wrapByWidth(`${label}${call}`, textWidth);
|
|
122
|
+
const result = msg.toolResult;
|
|
123
|
+
if (result && result.trim() !== "") {
|
|
124
|
+
for (const line of resultPreviewLines(result)) {
|
|
125
|
+
rows.push(...wrapByWidth(` ${line}`, textWidth));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return rows;
|
|
129
|
+
}
|
|
130
|
+
default: {
|
|
131
|
+
if (!msg.content)
|
|
132
|
+
return [];
|
|
133
|
+
const rows = [];
|
|
134
|
+
for (const line of msg.content.split("\n"))
|
|
135
|
+
rows.push(...wrapByWidth(line, textWidth));
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Streaming appends mutate the last assistant message every frame; keying on
|
|
141
|
+
// id + content means the cache stays valid for the exact content rendered.
|
|
142
|
+
const LINE_CACHE_LIMIT = 256;
|
|
143
|
+
const lineCache = new Map();
|
|
144
|
+
function hashContent(s) {
|
|
145
|
+
let h = 0;
|
|
146
|
+
for (let i = 0; i < s.length; i++)
|
|
147
|
+
h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
148
|
+
return String(h);
|
|
10
149
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
150
|
+
function cacheKey(msg, columns, expanded) {
|
|
151
|
+
return [
|
|
152
|
+
msg.id,
|
|
153
|
+
String(msg.content.length),
|
|
154
|
+
hashContent(msg.content.slice(0, 64) + msg.content.slice(-32)),
|
|
155
|
+
String(msg.toolResult?.length ?? 0),
|
|
156
|
+
msg.toolResult ? hashContent(msg.toolResult.slice(0, 64)) : "",
|
|
157
|
+
String(columns),
|
|
158
|
+
expanded ? "1" : "0",
|
|
159
|
+
msg.toolName ?? "",
|
|
160
|
+
msg.toolResultSummary ?? "",
|
|
161
|
+
].join("\u0000");
|
|
17
162
|
}
|
|
18
|
-
function
|
|
163
|
+
function cachedMessageLines(msg, columns, expanded) {
|
|
164
|
+
const key = cacheKey(msg, columns, expanded);
|
|
165
|
+
const hit = lineCache.get(key);
|
|
166
|
+
if (hit !== undefined)
|
|
167
|
+
return hit;
|
|
168
|
+
const lines = messageLines(msg, columns, expanded);
|
|
169
|
+
if (lineCache.size >= LINE_CACHE_LIMIT) {
|
|
170
|
+
const first = lineCache.keys().next().value;
|
|
171
|
+
if (first !== undefined)
|
|
172
|
+
lineCache.delete(first);
|
|
173
|
+
}
|
|
174
|
+
lineCache.set(key, lines);
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
/** User messages carry a 1-row margin above them. */
|
|
178
|
+
const MARGIN = 1;
|
|
179
|
+
function messageRowCount(msg, columns, expanded) {
|
|
180
|
+
return cachedMessageLines(msg, columns, expanded).length + (msg.role === "user" ? MARGIN : 0);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Compute the visible window of a message list for `maxHeight` rows, given
|
|
184
|
+
* the physical row count of each message and an optional scroll offset
|
|
185
|
+
* (`null` = stick to the bottom). One row is reserved for the hidden
|
|
186
|
+
* indicator when either side has hidden content.
|
|
187
|
+
*/
|
|
188
|
+
export function sliceWindow(messages, rowCounts, maxHeight, top) {
|
|
189
|
+
const total = rowCounts.reduce((a, b) => a + b, 0);
|
|
190
|
+
if (maxHeight <= 0) {
|
|
191
|
+
return {
|
|
192
|
+
winTop: 0,
|
|
193
|
+
winBottom: total,
|
|
194
|
+
above: 0,
|
|
195
|
+
below: 0,
|
|
196
|
+
parts: messages.map((msg, i) => ({ msg, start: 0, count: rowCounts[i] ?? 0 })),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// The indicator row feeds back into the view height; two passes settle.
|
|
200
|
+
let above = 0;
|
|
201
|
+
let below = 0;
|
|
202
|
+
let winTop = 0;
|
|
203
|
+
let viewH = maxHeight;
|
|
204
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
205
|
+
viewH = maxHeight - (above > 0 || below > 0 ? 1 : 0);
|
|
206
|
+
const maxTop = Math.max(0, total - viewH);
|
|
207
|
+
winTop = top === null ? maxTop : Math.min(top, maxTop);
|
|
208
|
+
above = winTop;
|
|
209
|
+
below = Math.max(0, total - (winTop + viewH));
|
|
210
|
+
}
|
|
211
|
+
const winBottom = Math.min(total, winTop + viewH);
|
|
212
|
+
const parts = [];
|
|
213
|
+
let cursor = 0;
|
|
214
|
+
for (let i = 0; i < messages.length; i++) {
|
|
215
|
+
const len = rowCounts[i] ?? 0;
|
|
216
|
+
if (cursor < winBottom && cursor + len > winTop) {
|
|
217
|
+
const start = Math.max(0, winTop - cursor);
|
|
218
|
+
const count = Math.min(len - start, winBottom - cursor - start);
|
|
219
|
+
parts.push({ msg: messages[i], start, count });
|
|
220
|
+
}
|
|
221
|
+
cursor += len;
|
|
222
|
+
}
|
|
223
|
+
return { winTop, winBottom, above, below, parts };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Message list — renders the tail of the conversation with row-level
|
|
227
|
+
* scrolling (PageUp/PageDown and mouse wheel) so trimmed content is never
|
|
228
|
+
* lost. Without `maxHeight` (terminal height unknown) messages flow
|
|
229
|
+
* naturally.
|
|
230
|
+
*/
|
|
231
|
+
export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice }) {
|
|
232
|
+
const [windowTop, setWindowTop] = useState(null);
|
|
233
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
234
|
+
const [selection, setSelection] = useState(null);
|
|
235
|
+
const rowCounts = useMemo(() => messages.map((m) => messageRowCount(m, columns, expanded.has(m.id))), [messages, columns, expanded]);
|
|
236
|
+
const slice = useMemo(() => sliceWindow(messages, rowCounts, maxHeight ?? 0, windowTop), [messages, rowCounts, maxHeight, windowTop]);
|
|
237
|
+
// Mouse wheel needs the current window geometry in the raw stdin listener,
|
|
238
|
+
// which subscribes once per maxHeight change.
|
|
239
|
+
const sliceRef = useRef(slice);
|
|
240
|
+
sliceRef.current = slice;
|
|
241
|
+
// The area mirrors the rendered rows; the same listener also maps drag
|
|
242
|
+
// coordinates to text. Rebuilt every render so streaming/scroll never
|
|
243
|
+
// desync the highlight from what is on screen.
|
|
244
|
+
const area = useMemo(() => buildArea(slice, columns, expanded), [slice, columns, expanded]);
|
|
245
|
+
const areaRef = useRef(area.rows);
|
|
246
|
+
areaRef.current = area.rows;
|
|
247
|
+
const onCopyRef = useRef(onCopyNotice);
|
|
248
|
+
onCopyRef.current = onCopyNotice;
|
|
249
|
+
// Terminal coordinates (1-based) → selection module coordinates: rows index
|
|
250
|
+
// into `area.rows`, columns start at terminal col 2 (paddingLeft=1).
|
|
251
|
+
const toCell = (p) => ({ row: p.row - 1, col: p.col - 2 });
|
|
252
|
+
// Per-part highlight ranges, aligned with each part's shown lines. Null
|
|
253
|
+
// while no selection is active so MessageRow keeps its memo hit.
|
|
254
|
+
const ranges = selection ? selectionRanges(area.rows, toCell(selection.anchor), toCell(selection.cur)) : null;
|
|
255
|
+
const partSelections = (() => {
|
|
256
|
+
if (!ranges)
|
|
257
|
+
return null;
|
|
258
|
+
const map = new Map();
|
|
259
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
260
|
+
const meta = area.partMeta[i];
|
|
261
|
+
const range = ranges[i];
|
|
262
|
+
if (!meta || !range)
|
|
263
|
+
continue;
|
|
264
|
+
let arr = map.get(meta.part);
|
|
265
|
+
if (!arr) {
|
|
266
|
+
arr = [];
|
|
267
|
+
map.set(meta.part, arr);
|
|
268
|
+
}
|
|
269
|
+
arr[meta.shown] = range;
|
|
270
|
+
}
|
|
271
|
+
return map;
|
|
272
|
+
})();
|
|
273
|
+
// Mouse release after a drag: turn the terminal-coordinate selection into
|
|
274
|
+
// the selected text and push it to the clipboard. The highlight stays until
|
|
275
|
+
// the next click/key/wheel.
|
|
276
|
+
const finalizeCopy = useCallback((sel) => {
|
|
277
|
+
const rows = areaRef.current;
|
|
278
|
+
const ranges = selectionRanges(rows, toCell(sel.anchor), toCell(sel.cur));
|
|
279
|
+
const parts = [];
|
|
280
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
281
|
+
const range = ranges[i];
|
|
282
|
+
if (range && rows[i])
|
|
283
|
+
parts.push(extractText(rows[i], range.start, range.end));
|
|
284
|
+
}
|
|
285
|
+
while (parts.length > 0 && parts[parts.length - 1] === "")
|
|
286
|
+
parts.pop();
|
|
287
|
+
const text = parts.join("\n");
|
|
288
|
+
if (text === "")
|
|
289
|
+
onCopyRef.current?.("未选中文本");
|
|
290
|
+
else if (writeClipboard(text))
|
|
291
|
+
onCopyRef.current?.(`已复制 ${Array.from(text).length} 字`);
|
|
292
|
+
else
|
|
293
|
+
onCopyRef.current?.("复制失败");
|
|
294
|
+
}, []);
|
|
295
|
+
const finalizeRef = useRef(finalizeCopy);
|
|
296
|
+
finalizeRef.current = finalizeCopy;
|
|
297
|
+
const toggleExpanded = (id) => {
|
|
298
|
+
setExpanded((prev) => {
|
|
299
|
+
const next = new Set(prev);
|
|
300
|
+
if (next.has(id))
|
|
301
|
+
next.delete(id);
|
|
302
|
+
else
|
|
303
|
+
next.add(id);
|
|
304
|
+
return next;
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
const toggleRef = useRef(toggleExpanded);
|
|
308
|
+
toggleRef.current = toggleExpanded;
|
|
309
|
+
const scrollUp = (step) => {
|
|
310
|
+
setWindowTop((t) => {
|
|
311
|
+
const cur = t ?? sliceRef.current.winTop;
|
|
312
|
+
return Math.max(0, cur - step);
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
const scrollDown = (step) => {
|
|
316
|
+
setWindowTop((t) => {
|
|
317
|
+
if (t === null)
|
|
318
|
+
return t;
|
|
319
|
+
const maxTop = sliceRef.current.winTop + sliceRef.current.below;
|
|
320
|
+
return t + step >= maxTop ? null : t + step;
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const drag = useRef(createDragMachine());
|
|
324
|
+
useInput((_input, key) => {
|
|
325
|
+
if (!_input.startsWith("[<") && !/^[\d;,]+[Mm]$/.test(_input)) {
|
|
326
|
+
clearDrag(drag.current);
|
|
327
|
+
setSelection(null);
|
|
328
|
+
}
|
|
329
|
+
// Enter on a tool row toggles expansion (keyboard alternative to click)
|
|
330
|
+
if (key.return && !_input) {
|
|
331
|
+
// No-op: tool toggle is click-only; keep Enter for app-level shortcuts
|
|
332
|
+
}
|
|
333
|
+
if (key.pageUp) {
|
|
334
|
+
const step = slice.winBottom - slice.winTop;
|
|
335
|
+
scrollUp(step);
|
|
336
|
+
}
|
|
337
|
+
else if (key.pageDown) {
|
|
338
|
+
const step = slice.winBottom - slice.winTop;
|
|
339
|
+
scrollDown(step);
|
|
340
|
+
}
|
|
341
|
+
// Keyboard expand/collapse: when a tool message is at window center, toggle it
|
|
342
|
+
if ((_input === " " || _input === "o") && !key.ctrl && !key.meta) {
|
|
343
|
+
const center = Math.floor((slice.winTop + slice.winBottom) / 2);
|
|
344
|
+
let cursor = 0;
|
|
345
|
+
for (let i = 0; i < messages.length; i++) {
|
|
346
|
+
const len = rowCounts[i] ?? 0;
|
|
347
|
+
if (center >= cursor && center < cursor + len && messages[i].role === "tool") {
|
|
348
|
+
toggleRef.current(messages[i].id);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
cursor += len;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}, { isActive: Boolean(maxHeight && maxHeight > 0) });
|
|
355
|
+
// Scrolling back to the bottom when content shrinks (e.g. /clear, /undo)
|
|
356
|
+
// so the view never hangs at a stale offset.
|
|
357
|
+
const prevMessageCount = useRef(messages.length);
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (messages.length < prevMessageCount.current)
|
|
360
|
+
setWindowTop(null);
|
|
361
|
+
prevMessageCount.current = messages.length;
|
|
362
|
+
}, [messages]);
|
|
363
|
+
// Map a terminal row (1-based) to the visible message row it lands on and
|
|
364
|
+
// toggle its expansion. Only tool rows are clickable; the top indicator row
|
|
365
|
+
// and anything below the window are ignored.
|
|
366
|
+
const handleClick = (row) => {
|
|
367
|
+
const { above, parts } = sliceRef.current;
|
|
368
|
+
let offset = row - 1;
|
|
369
|
+
if (offset < 0)
|
|
370
|
+
return;
|
|
371
|
+
if (above > 0) {
|
|
372
|
+
offset -= 1;
|
|
373
|
+
if (offset < 0)
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
for (const part of parts) {
|
|
377
|
+
if (offset < part.count) {
|
|
378
|
+
if (part.msg.role === "tool")
|
|
379
|
+
toggleRef.current(part.msg.id);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
offset -= part.count;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
// Mouse handling in the message area: drag with the left button selects text
|
|
386
|
+
// and auto-copies on release; a click (press+release without motion) still
|
|
387
|
+
// toggles tool rows; the wheel scrolls. The drag state machine (drag-state)
|
|
388
|
+
// is fed every non-wheel SGR event; presses are accepted anywhere inside the
|
|
389
|
+
// allocated message region (maxHeight), so a drag that starts on the blank
|
|
390
|
+
// gap below a short conversation still works. A drag/release may land
|
|
391
|
+
// outside the region, in which case the coordinates are clamped by the
|
|
392
|
+
// selection math.
|
|
393
|
+
const { stdin } = useStdin();
|
|
394
|
+
const wheelBuffer = useRef("");
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (!stdin || !maxHeight || maxHeight <= 0)
|
|
397
|
+
return;
|
|
398
|
+
const handleData = (data) => {
|
|
399
|
+
wheelBuffer.current += data.toString("utf-8");
|
|
400
|
+
let scanned;
|
|
401
|
+
while ((scanned = scanSgrMouse(wheelBuffer.current))) {
|
|
402
|
+
wheelBuffer.current = scanned.rest;
|
|
403
|
+
const { button, row, col } = scanned.event;
|
|
404
|
+
const areaHeight = areaRef.current.length;
|
|
405
|
+
// Wheel (base 64/65, or with the motion flag while a button is held).
|
|
406
|
+
if (button === WHEEL_UP || button === WHEEL_DOWN || button === WHEEL_UP + MOTION || button === WHEEL_DOWN + MOTION) {
|
|
407
|
+
if (row >= 1 && row <= areaHeight + 2) {
|
|
408
|
+
clearDrag(drag.current);
|
|
409
|
+
setSelection(null);
|
|
410
|
+
if (button === WHEEL_UP || button === WHEEL_UP + MOTION)
|
|
411
|
+
scrollUp(3);
|
|
412
|
+
else
|
|
413
|
+
scrollDown(3);
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const outcome = stepDrag(drag.current, scanned.event, maxHeight);
|
|
418
|
+
if (outcome?.type === "selection")
|
|
419
|
+
setSelection(outcome.selection);
|
|
420
|
+
else if (outcome?.type === "copy")
|
|
421
|
+
finalizeRef.current(outcome.selection);
|
|
422
|
+
else if (outcome?.type === "click")
|
|
423
|
+
handleClick(outcome.row);
|
|
424
|
+
}
|
|
425
|
+
if (wheelBuffer.current.length > 64) {
|
|
426
|
+
const esc = wheelBuffer.current.lastIndexOf("\x1b");
|
|
427
|
+
wheelBuffer.current = esc === -1 ? "" : wheelBuffer.current.slice(esc);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
stdin.on("data", handleData);
|
|
431
|
+
return () => { stdin.off("data", handleData); };
|
|
432
|
+
}, [stdin, maxHeight]);
|
|
433
|
+
if (!maxHeight || maxHeight <= 0) {
|
|
434
|
+
return (_jsxs(Box, { flexDirection: "column", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), messages.map((msg) => (_jsx(MessageRow, { message: msg, lines: cachedMessageLines(msg, columns, expanded.has(msg.id)) }, msg.id)))] }));
|
|
435
|
+
}
|
|
436
|
+
return (_jsxs(Box, { flexDirection: "column", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), slice.above > 0 && (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["\u2026 \u4EE5\u4E0A ", slice.above, " \u884C"] }) })), slice.parts.map(({ msg, start, count }, p) => (_jsx(MessageRow, { message: msg, lines: cachedMessageLines(msg, columns, expanded.has(msg.id)), start: start, count: count, selection: partSelections?.get(p) }, msg.id))), slice.below > 0 && (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "\u2193 \u6309 PageDown \u56DE\u5230\u5E95\u90E8" }) }))] }));
|
|
437
|
+
}
|
|
438
|
+
/** Map a window slice (rows incl. margin) to renderable lines. */
|
|
439
|
+
function sliceLines(msg, lines, start, count = lines.length) {
|
|
440
|
+
if (msg.role !== "user")
|
|
441
|
+
return { lines: lines.slice(start, start + count), marginTop: false };
|
|
442
|
+
const margin = MARGIN;
|
|
443
|
+
if (start === 0) {
|
|
444
|
+
// The window includes the margin row; content rows are one fewer.
|
|
445
|
+
return { lines: lines.slice(0, Math.max(0, count - margin)), marginTop: true };
|
|
446
|
+
}
|
|
447
|
+
const contentStart = start - margin;
|
|
448
|
+
return { lines: lines.slice(contentStart, contentStart + count), marginTop: false };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Rebuild the exact rows the message list renders, one entry per terminal row:
|
|
452
|
+
* chrome/blank rows are null, message rows carry their ANSI line. `partMeta`
|
|
453
|
+
* maps each row back to (part index, shown-line index) so selection ranges can
|
|
454
|
+
* be handed to MessageRow aligned with its own `lines` slicing.
|
|
455
|
+
*/
|
|
456
|
+
function buildArea(slice, columns, expanded) {
|
|
457
|
+
const rows = [];
|
|
458
|
+
const partMeta = [];
|
|
459
|
+
if (slice.above > 0) {
|
|
460
|
+
rows.push(null);
|
|
461
|
+
partMeta.push(null);
|
|
462
|
+
}
|
|
463
|
+
for (let p = 0; p < slice.parts.length; p++) {
|
|
464
|
+
const part = slice.parts[p];
|
|
465
|
+
const lines = cachedMessageLines(part.msg, columns, expanded.has(part.msg.id));
|
|
466
|
+
const { lines: shown, marginTop } = sliceLines(part.msg, lines, part.start, part.count);
|
|
467
|
+
if (marginTop) {
|
|
468
|
+
rows.push(null);
|
|
469
|
+
partMeta.push(null);
|
|
470
|
+
}
|
|
471
|
+
for (let i = 0; i < shown.length; i++) {
|
|
472
|
+
rows.push(shown[i]);
|
|
473
|
+
partMeta.push({ part: p, shown: i });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (slice.below > 0) {
|
|
477
|
+
rows.push(null);
|
|
478
|
+
partMeta.push(null);
|
|
479
|
+
}
|
|
480
|
+
return { rows, partMeta };
|
|
481
|
+
}
|
|
482
|
+
/** Message row — memoized so streaming updates to the last assistant message
|
|
483
|
+
* don't re-render every row. `selection` (aligned with the shown lines) adds
|
|
484
|
+
* inverse-video highlights; it is undefined when no drag is active, keeping
|
|
485
|
+
* the memo hit during streaming. */
|
|
486
|
+
const MessageRow = React.memo(function MessageRow({ message, lines, start = 0, count, selection, }) {
|
|
487
|
+
const { lines: shown, marginTop } = sliceLines(message, lines, start, count);
|
|
488
|
+
if (shown.length === 0 && !marginTop)
|
|
489
|
+
return null;
|
|
490
|
+
const sel = selection && selection.length === shown.length ? selection : undefined;
|
|
491
|
+
const apply = (i, line) => {
|
|
492
|
+
const range = sel?.[i];
|
|
493
|
+
return range && range.end > range.start ? applySelection(line, range.start, range.end) : line;
|
|
494
|
+
};
|
|
495
|
+
// Selection on a decorated first line must be split: the prefix ("> " or
|
|
496
|
+
// "⚡ name ▸ ") is rendered as its own Text before the content slice.
|
|
497
|
+
const splitFirst = (prefixLen) => {
|
|
498
|
+
const range = sel?.[0];
|
|
499
|
+
if (!range)
|
|
500
|
+
return { prefix: null, content: null };
|
|
501
|
+
const headRange = range.start < prefixLen && range.end > 0
|
|
502
|
+
? { start: range.start, end: Math.min(prefixLen, range.end) }
|
|
503
|
+
: null;
|
|
504
|
+
const contentRange = range.end > prefixLen
|
|
505
|
+
? { start: Math.max(0, range.start - prefixLen), end: range.end - prefixLen }
|
|
506
|
+
: null;
|
|
507
|
+
return { prefix: headRange, content: contentRange };
|
|
508
|
+
};
|
|
19
509
|
switch (message.role) {
|
|
20
|
-
case "user":
|
|
21
|
-
|
|
510
|
+
case "user": {
|
|
511
|
+
const first = shown[0] ?? "";
|
|
512
|
+
const rest = shown.slice(1);
|
|
513
|
+
const isHead = first.startsWith("> ");
|
|
514
|
+
const split = isHead ? splitFirst(2) : null;
|
|
515
|
+
return (_jsxs(Box, { paddingLeft: 1, marginTop: marginTop ? 1 : 0, children: [isHead && (_jsx(Text, { color: "cyan", bold: true, children: split.prefix ? applySelection("> ", split.prefix.start, split.prefix.end) : "> " })), _jsxs(Text, { children: [isHead
|
|
516
|
+
? split.content
|
|
517
|
+
? applySelection(first.slice(2), split.content.start, split.content.end)
|
|
518
|
+
: first.slice(2)
|
|
519
|
+
: apply(0, first), rest.length > 0 ? `\n${rest.map((l, k) => apply(k + 1, l)).join("\n")}` : ""] })] }));
|
|
520
|
+
}
|
|
22
521
|
case "assistant":
|
|
23
|
-
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children:
|
|
522
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
|
|
24
523
|
case "thinking":
|
|
25
|
-
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children:
|
|
26
|
-
case "tool":
|
|
27
|
-
|
|
524
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
|
|
525
|
+
case "tool": {
|
|
526
|
+
const first = shown[0] ?? "";
|
|
527
|
+
const rest = shown.slice(1);
|
|
528
|
+
const isHead = first.startsWith("⚡ ");
|
|
529
|
+
const prefixLen = isHead ? first.indexOf(" ", 2) + 1 : 0;
|
|
530
|
+
const split = isHead ? splitFirst(prefixLen) : null;
|
|
531
|
+
return (_jsxs(Box, { paddingLeft: 1, children: [isHead && (_jsx(Text, { color: "yellow", children: split.prefix ? applySelection(first.slice(0, prefixLen), split.prefix.start, split.prefix.end) : first.slice(0, prefixLen) })), _jsxs(Text, { color: isHead ? (message.toolIsError ? "red" : "gray") : undefined, children: [isHead
|
|
532
|
+
? split.content
|
|
533
|
+
? applySelection(first.slice(prefixLen), split.content.start, split.content.end)
|
|
534
|
+
: first.slice(prefixLen)
|
|
535
|
+
: apply(0, first), rest.length > 0 ? `\n${rest.map((l, k) => apply(k + 1, l)).join("\n")}` : ""] })] }));
|
|
536
|
+
}
|
|
28
537
|
case "system":
|
|
29
|
-
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children:
|
|
538
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
|
|
30
539
|
default:
|
|
31
540
|
return null;
|
|
32
541
|
}
|
|
33
|
-
}
|
|
542
|
+
}, (prev, next) => prev.message.id === next.message.id &&
|
|
543
|
+
prev.start === next.start &&
|
|
544
|
+
prev.count === next.count &&
|
|
545
|
+
prev.lines === next.lines &&
|
|
546
|
+
prev.selection === next.selection);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
4
|
+
import { loadConfig, getActiveProvider, fetchModels } from "../config.js";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
/**
|
|
7
|
+
* Worst-case terminal rows the picker occupies (header/filter + status rows +
|
|
8
|
+
* list + footer + borders), used by App so the message area never overlaps it.
|
|
9
|
+
*/
|
|
10
|
+
export function modelPickerRows(terminalRows) {
|
|
11
|
+
const maxListRows = Math.max(4, terminalRows - 11);
|
|
12
|
+
return Math.min(6 + maxListRows + 3, Math.max(6, terminalRows - 2));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Interactive model picker — replaces the input bar while open.
|
|
16
|
+
* - Fetches the provider's model list, falls back to cache
|
|
17
|
+
* - Type to filter, ↑↓ to navigate, Enter to select, Esc to cancel
|
|
18
|
+
* - With a non-matching filter, Enter applies the typed name directly
|
|
19
|
+
* - List is capped so the picker never overflows the terminal
|
|
20
|
+
*/
|
|
21
|
+
export function ModelPicker({ currentModel, onSelect, onCancel }) {
|
|
22
|
+
const [models, setModels] = useState(null);
|
|
23
|
+
const [query, setQuery] = useState("");
|
|
24
|
+
const [index, setIndex] = useState(0);
|
|
25
|
+
const { stdout } = useStdout();
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
const cfg = loadConfig();
|
|
29
|
+
const provider = getActiveProvider(cfg);
|
|
30
|
+
if (provider?.baseURL && provider?.apiKey) {
|
|
31
|
+
fetchModels(provider.baseURL, provider.apiKey)
|
|
32
|
+
.then((m) => {
|
|
33
|
+
if (!cancelled)
|
|
34
|
+
setModels(m);
|
|
35
|
+
})
|
|
36
|
+
.catch(() => {
|
|
37
|
+
if (!cancelled)
|
|
38
|
+
setModels([]);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
setModels([]);
|
|
43
|
+
}
|
|
44
|
+
return () => { cancelled = true; };
|
|
45
|
+
}, []);
|
|
46
|
+
const terminalRows = stdout.rows || 24;
|
|
47
|
+
const maxListRows = Math.max(4, terminalRows - 11); // chrome + status bar + safety
|
|
48
|
+
const q = query.trim().toLowerCase();
|
|
49
|
+
const filtered = (models ?? []).filter((m) => m.toLowerCase().includes(q));
|
|
50
|
+
const shownIndex = Math.min(index, Math.max(0, filtered.length - 1));
|
|
51
|
+
const windowStart = Math.min(shownIndex, Math.max(0, filtered.length - maxListRows));
|
|
52
|
+
const shown = filtered.slice(windowStart, windowStart + maxListRows);
|
|
53
|
+
const extraAbove = windowStart;
|
|
54
|
+
const extraBelow = filtered.length - (windowStart + shown.length);
|
|
55
|
+
useEffect(() => { setIndex(0); }, [q]);
|
|
56
|
+
useInput((input, key) => {
|
|
57
|
+
const cleanInput = input.replace(/[\r\n]+$/, "");
|
|
58
|
+
const isEnter = key.return || /[\r\n]+$/.test(input);
|
|
59
|
+
if (key.escape) {
|
|
60
|
+
onCancel();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (key.pageDown) {
|
|
64
|
+
if (filtered.length > 0)
|
|
65
|
+
setIndex((i) => Math.min(filtered.length - 1, i + maxListRows));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.pageUp) {
|
|
69
|
+
if (filtered.length > 0)
|
|
70
|
+
setIndex((i) => Math.max(0, i - maxListRows));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (filtered.length > 0) {
|
|
74
|
+
if (key.downArrow) {
|
|
75
|
+
setIndex((i) => (i + 1) % filtered.length);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.upArrow) {
|
|
79
|
+
setIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (isEnter) {
|
|
84
|
+
if (filtered.length > 0)
|
|
85
|
+
onSelect(filtered[shownIndex]);
|
|
86
|
+
else if (q !== "")
|
|
87
|
+
onSelect(q);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.backspace || key.delete) {
|
|
91
|
+
setQuery((v) => Array.from(v).slice(0, -1).join(""));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.ctrl && input === "u") {
|
|
95
|
+
setQuery("");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.ctrl || key.meta || key.tab)
|
|
99
|
+
return;
|
|
100
|
+
if (cleanInput)
|
|
101
|
+
setQuery((v) => v + cleanInput);
|
|
102
|
+
});
|
|
103
|
+
const loading = models === null;
|
|
104
|
+
const empty = models !== null && filtered.length === 0 && q === "";
|
|
105
|
+
// Debounce filter: avoid re-filtering on every keystroke burst
|
|
106
|
+
const debouncedQ = q;
|
|
107
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u6A21\u578B" }), _jsx(Text, { color: theme.muted, children: currentModel ? ` (当前: ${currentModel})` : "" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u7B5B\u9009: " }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || "输入字符过滤,↑↓/PgUpDn 导航" })] }), loading && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u6B63\u5728\u83B7\u53D6\u6A21\u578B\u5217\u8868..." }) })), empty && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u65E0\u6CD5\u83B7\u53D6\u6A21\u578B\u5217\u8868\uFF08\u53EF\u8F93\u5165\u6A21\u578B\u540D\u540E\u6309 Enter\uFF09" }) })), models !== null && extraAbove > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0A\u65B9\u8FD8\u6709 ", extraAbove, " \u4E2A\u6A21\u578B"] }) })), models !== null && shown.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex - windowStart ? theme.accent : theme.muted, children: i === shownIndex - windowStart ? "❯ " : " " }), _jsx(Text, { bold: i === shownIndex - windowStart, color: i === shownIndex - windowStart ? theme.accent : undefined, children: m }), m === currentModel && _jsx(Text, { color: theme.success, children: " \u2190 \u5F53\u524D" })] }, m))), extraBelow > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0B\u65B9\u8FD8\u6709 ", extraBelow, " \u4E2A\u6A21\u578B\uFF08\u8F93\u5165\u5B57\u7B26\u8FC7\u6EE4\uFF09"] }) })), models !== null && filtered.length === 0 && debouncedQ !== "" && (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "\u276F " }), _jsxs(Text, { color: theme.accent, bold: true, children: ["\u4F7F\u7528\u81EA\u5B9A\u4E49\u6A21\u578B: ", debouncedQ] })] })), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193/PgUpDn \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
|
|
108
|
+
}
|