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/tools/write.js
CHANGED
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { confirm, isAutoApprove } from "../confirm.js";
|
|
6
|
+
const MAX_WRITE_BYTES = 10 * 1024 * 1024;
|
|
5
7
|
export const writeTool = tool({
|
|
6
|
-
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories as needed.",
|
|
8
|
+
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does (unless `append` is true). Creates parent directories as needed.",
|
|
7
9
|
inputSchema: jsonSchema({
|
|
8
10
|
type: "object",
|
|
9
11
|
properties: {
|
|
10
12
|
filePath: { type: "string", description: "Path to the file to write (relative to cwd or absolute)" },
|
|
11
13
|
content: { type: "string", description: "The content to write to the file" },
|
|
14
|
+
append: { type: "boolean", description: "If true, append to the file instead of overwriting" },
|
|
12
15
|
},
|
|
13
16
|
required: ["filePath", "content"],
|
|
14
17
|
}),
|
|
15
|
-
execute: async ({ filePath, content }) => {
|
|
18
|
+
execute: async ({ filePath, content, append }) => {
|
|
19
|
+
if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
|
|
20
|
+
return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`;
|
|
21
|
+
}
|
|
16
22
|
const resolved = path.resolve(process.cwd(), filePath);
|
|
17
23
|
// Confirm overwriting existing files
|
|
18
|
-
if (!isAutoApprove() && existsSync(resolved)) {
|
|
24
|
+
if (!append && !isAutoApprove() && existsSync(resolved)) {
|
|
19
25
|
const approved = await confirm(`Overwrite existing file: ${filePath}`);
|
|
20
26
|
if (!approved)
|
|
21
27
|
return "Write rejected by user.";
|
|
22
28
|
}
|
|
23
29
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `Written ${content
|
|
30
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
31
|
+
await writeFile(resolved, content, append ? { flag: "a", encoding: "utf-8" } : "utf-8");
|
|
32
|
+
return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`;
|
|
27
33
|
}
|
|
28
34
|
catch (err) {
|
|
29
35
|
return `Error writing file: ${err.message}`;
|
package/dist/tui/App.js
CHANGED
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, useInput, useStdout } from "ink";
|
|
3
4
|
import { Spinner } from "./Spinner.js";
|
|
4
5
|
import { InputBar } from "./InputBar.js";
|
|
5
6
|
import { MessageList } from "./MessageList.js";
|
|
6
|
-
import { ConfirmBar } from "./ConfirmBar.js";
|
|
7
|
+
import { ConfirmBar, confirmBarRows } from "./ConfirmBar.js";
|
|
8
|
+
import { QuestionBar, questionBarRows } from "./QuestionBar.js";
|
|
7
9
|
import { StatusBar } from "./StatusBar.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
import { ModelPicker, modelPickerRows } from "./ModelPicker.js";
|
|
11
|
+
import { setCaretPosition } from "./caret.js";
|
|
12
|
+
import { computeMessageMaxHeight } from "./layout.js";
|
|
13
|
+
export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onExit, onCopyNotice }) {
|
|
14
|
+
// Ink recalculates its own layout on resize without re-rendering React, so
|
|
15
|
+
// track terminal size ourselves to keep heights/widths in sync.
|
|
16
|
+
const { stdout } = useStdout();
|
|
17
|
+
const [terminal, setTerminal] = useState({ columns: stdout.columns || 80, rows: stdout.rows || 0 });
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const onResize = () => setTerminal({ columns: stdout.columns || 80, rows: stdout.rows || 0 });
|
|
20
|
+
stdout.on("resize", onResize);
|
|
21
|
+
return () => { stdout.off("resize", onResize); };
|
|
22
|
+
}, [stdout]);
|
|
23
|
+
useInput((input, key) => {
|
|
24
|
+
if (key.ctrl && input === "c") {
|
|
25
|
+
onExit();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (key.escape && initialState.isRunning && !initialState.confirmMessage && !initialState.question && !initialState.modelPicker) {
|
|
11
29
|
onExit();
|
|
12
30
|
}
|
|
13
31
|
});
|
|
14
|
-
|
|
32
|
+
if (initialState.confirmMessage || initialState.question || initialState.modelPicker)
|
|
33
|
+
setCaretPosition(null);
|
|
34
|
+
const columns = terminal.columns;
|
|
35
|
+
const terminalRows = terminal.rows;
|
|
36
|
+
const confirmActive = Boolean(initialState.confirmMessage);
|
|
37
|
+
const questionActive = Boolean(initialState.question);
|
|
38
|
+
const modelPickerActive = Boolean(initialState.modelPicker);
|
|
39
|
+
const footerRows = confirmActive
|
|
40
|
+
? confirmBarRows(initialState.confirmMessage ?? "", columns)
|
|
41
|
+
: questionActive
|
|
42
|
+
? questionBarRows(initialState.question.prompt, initialState.question.options, columns)
|
|
43
|
+
: modelPickerActive
|
|
44
|
+
? modelPickerRows(terminalRows)
|
|
45
|
+
: 3; // input bar: 2 borders + 1 content row
|
|
46
|
+
const spinnerRows = initialState.isRunning && !confirmActive && !questionActive && !modelPickerActive ? 1 : 0;
|
|
47
|
+
const messageMaxHeight = computeMessageMaxHeight(terminalRows, footerRows, spinnerRows);
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", height: terminalRows || undefined, children: [!modelPickerActive && (_jsx(Box, { flexGrow: 1, minHeight: 0, children: _jsx(MessageList, { messages: initialState.messages, maxHeight: messageMaxHeight, columns: columns, onCopyNotice: onCopyNotice }) })), initialState.isRunning && !confirmActive && !questionActive && !modelPickerActive && (_jsx(Box, { paddingLeft: 1, children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState, columns: columns }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm })) : initialState.question ? (_jsx(QuestionBar, { prompt: initialState.question.prompt, options: initialState.question.options, onAnswer: onQuestionAnswer })) : initialState.modelPicker ? (_jsx(ModelPicker, { currentModel: initialState.model, onSelect: onModelPick, onCancel: onModelCancel })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning, placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)", columns: columns, terminalRows: terminalRows }))] }));
|
|
15
49
|
}
|
package/dist/tui/ConfirmBar.js
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { wrapByWidth } from "./text-width.js";
|
|
4
|
+
import { innerTextWidth } from "./layout.js";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
/**
|
|
7
|
+
* Terminal rows the confirm bar occupies, mirroring the JSX layout below.
|
|
8
|
+
* Needed by App to bound the message area so the frame never exceeds the
|
|
9
|
+
* terminal height while the prompt is up.
|
|
10
|
+
*/
|
|
11
|
+
export function confirmBarRows(message, columns) {
|
|
12
|
+
const textWidth = innerTextWidth(columns);
|
|
13
|
+
let rows = 0;
|
|
14
|
+
for (const line of message.split("\n"))
|
|
15
|
+
rows += wrapByWidth(line, textWidth).length;
|
|
16
|
+
return rows + 5; /* borders2 + title1 + marginTop1 + prompt1 */
|
|
17
|
+
}
|
|
3
18
|
export function ConfirmBar({ message, onConfirm }) {
|
|
4
19
|
useInput((input, key) => {
|
|
5
|
-
if (
|
|
20
|
+
if (key.escape) {
|
|
21
|
+
onConfirm(false);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const hasEnter = key.return || /[\r\n]/.test(input);
|
|
25
|
+
const stripped = input.replace(/[\r\n]+/g, "").trim().toLowerCase();
|
|
26
|
+
if (stripped === "y") {
|
|
6
27
|
onConfirm(true);
|
|
7
28
|
}
|
|
8
|
-
else if (
|
|
29
|
+
else if (stripped === "n" || (stripped === "" && hasEnter)) {
|
|
9
30
|
onConfirm(false);
|
|
10
31
|
}
|
|
11
32
|
});
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor:
|
|
33
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.confirmBorder, paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: theme.confirmBorder, children: "\u26A0 \u8BF7\u6C42\u6267\u884C\u6743\u9650" }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.confirmBorder, children: "? \u662F\u5426\u5141\u8BB8\u6267\u884C\uFF1F " }), _jsx(Text, { color: theme.success, children: "[y]" }), _jsx(Text, { color: theme.muted, children: " / " }), _jsx(Text, { color: theme.error, children: "[N]" })] })] }));
|
|
13
34
|
}
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,83 +1,428 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect } from "react";
|
|
3
|
-
import { Box, Text, useInput, useStdin } from "ink";
|
|
2
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdin, useStdout } from "ink";
|
|
4
|
+
import { setCaretPosition } from "./caret.js";
|
|
5
|
+
import { displayWidth } from "./text-width.js";
|
|
6
|
+
import { visualRows, moveCaretHorizontal, insertAt, backspaceAt, deleteAt, caretIndexFromClick, scanDeleteKeys, moveToLineStart, moveToLineEnd, deleteToLineStart, deleteToLineEnd, deleteWordBefore } from "./caret-pos.js";
|
|
7
|
+
import { searchHistory } from "./input-history.js";
|
|
8
|
+
import { createHistory, pushInput, browseHistory, resetHistory } from "./input-history.js";
|
|
9
|
+
import { filterSlashCommands, getSlashArgOptions, applySlashArgCompletion } from "./slash-commands.js";
|
|
10
|
+
import { MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js";
|
|
11
|
+
import { scanSgrMouse } from "./mouse.js";
|
|
12
|
+
import { TEXT_START_COLUMN, inputTextWidth } from "./layout.js";
|
|
13
|
+
import { theme } from "./theme.js";
|
|
14
|
+
const GUTTER = "❯ ";
|
|
15
|
+
const SHIFT_ENTER_SEQ = "\x1b[13;2u";
|
|
16
|
+
/**
|
|
17
|
+
* True when `input` looks like the Kitty Shift+Enter sequence
|
|
18
|
+
* ("[13;2u" — the leading ESC is stripped by Ink) or a fragment of it.
|
|
19
|
+
* Fragments may arrive split across stdin chunks; they must never be
|
|
20
|
+
* appended to the input value — the raw stdin listener reassembles them.
|
|
21
|
+
*/
|
|
22
|
+
export function isKittySequenceFragment(input) {
|
|
23
|
+
return (/^\[\d{1,2}(;\d{0,2})?u?$/.test(input) || // prefix or complete "[13;2u"
|
|
24
|
+
/^\d{1,2};\d{1,2}u$/.test(input) // suffix after a split ("3;2u")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Accumulate raw stdin bytes and detect the complete Kitty Shift+Enter
|
|
29
|
+
* sequence, which may span multiple chunks. Non-sequence bytes are dropped;
|
|
30
|
+
* the trailing partial prefix (from the last ESC) is kept for the next chunk.
|
|
31
|
+
*/
|
|
32
|
+
export function accumulateKittyInput(buffer, chunk) {
|
|
33
|
+
let buf = buffer + chunk;
|
|
34
|
+
let shiftEnter = false;
|
|
35
|
+
let idx;
|
|
36
|
+
while ((idx = buf.indexOf(SHIFT_ENTER_SEQ)) !== -1) {
|
|
37
|
+
buf = buf.slice(idx + SHIFT_ENTER_SEQ.length);
|
|
38
|
+
shiftEnter = true;
|
|
39
|
+
}
|
|
40
|
+
const escIdx = buf.lastIndexOf("\x1b");
|
|
41
|
+
return { buffer: escIdx === -1 ? "" : buf.slice(escIdx), shiftEnter };
|
|
42
|
+
}
|
|
4
43
|
/**
|
|
5
44
|
* Multi-line input bar.
|
|
6
45
|
* - Enter: submit
|
|
7
46
|
* - Shift+Enter (Kitty protocol terminals): new line
|
|
8
47
|
* - Ctrl+J: new line (universal fallback)
|
|
9
48
|
* - Backslash at end + Enter: continue on next line
|
|
49
|
+
*
|
|
50
|
+
* Wrapping is done here rather than by Ink so the caret's row/column is known
|
|
51
|
+
* exactly; the hardware cursor is parked on that cell so IME composition
|
|
52
|
+
* (Chinese, Japanese, Korean) shows up in the right place.
|
|
10
53
|
*/
|
|
11
|
-
export function InputBar({ onSubmit, disabled, placeholder }) {
|
|
12
|
-
const [
|
|
13
|
-
const
|
|
54
|
+
export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRows }) {
|
|
55
|
+
const [editing, setEditing] = useState({ value: "", caretIndex: 0 });
|
|
56
|
+
const { value, caretIndex } = editing;
|
|
57
|
+
const [menuIndex, setMenuIndex] = useState(0);
|
|
58
|
+
const [menuDismissed, setMenuDismissed] = useState(false);
|
|
14
59
|
const { stdin } = useStdin();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
60
|
+
const { stdout } = useStdout();
|
|
61
|
+
const kittyBuffer = useRef("");
|
|
62
|
+
const mouseBuffer = useRef("");
|
|
63
|
+
const deleteBuffer = useRef("");
|
|
64
|
+
const width = columns || 80;
|
|
65
|
+
const [historyState, setHistoryState] = useState(() => createHistory());
|
|
66
|
+
const historyRef = useRef(historyState);
|
|
67
|
+
// The ref mirrors `editing` synchronously, so edits from separate stdin
|
|
68
|
+
// listeners (Ink's useInput for text, the raw listener for delete keys)
|
|
69
|
+
// always apply to the newest state, in arrival order, even when React has
|
|
70
|
+
// not re-rendered yet (fast key repeats, text + delete in one chunk).
|
|
71
|
+
const editingRef = useRef(editing);
|
|
72
|
+
const applyEdit = useCallback((edit) => {
|
|
73
|
+
const after = edit(editingRef.current.value, editingRef.current.caretIndex);
|
|
74
|
+
editingRef.current = after;
|
|
75
|
+
setEditing(after);
|
|
76
|
+
const nextHistory = resetHistory(historyRef.current, after.value);
|
|
77
|
+
if (nextHistory !== historyRef.current) {
|
|
78
|
+
historyRef.current = nextHistory;
|
|
79
|
+
setHistoryState(nextHistory);
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
const submit = (text) => {
|
|
83
|
+
historyRef.current = pushInput(historyRef.current, text);
|
|
84
|
+
setHistoryState(historyRef.current);
|
|
85
|
+
onSubmit(text);
|
|
86
|
+
};
|
|
87
|
+
const browseTo = (direction) => {
|
|
88
|
+
const { text, state } = browseHistory(historyRef.current, direction);
|
|
89
|
+
historyRef.current = state;
|
|
90
|
+
setHistoryState(state);
|
|
91
|
+
if (text !== editingRef.current.value) {
|
|
92
|
+
const after = { value: text, caretIndex: Array.from(text).length };
|
|
93
|
+
editingRef.current = after;
|
|
94
|
+
setEditing(after);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// Release the caret when the input bar goes away (e.g. confirm prompt).
|
|
98
|
+
useEffect(() => () => { setCaretPosition(null); }, []);
|
|
99
|
+
// Slash command menu — closes once a space follows the command name so the
|
|
100
|
+
// argument menu can take over immediately.
|
|
101
|
+
const matches = useMemo(() => filterSlashCommands(value), [value]);
|
|
102
|
+
const menuOpen = value.startsWith("/") && !/\s/.test(value.slice(1)) && !menuDismissed && matches.length > 0;
|
|
103
|
+
const shownIndex = menuOpen ? Math.min(menuIndex, matches.length - 1) : 0;
|
|
104
|
+
const selected = menuOpen ? matches[shownIndex] : undefined;
|
|
105
|
+
// Argument completion (e.g. `/skills disable <name>`), fed by app-registered providers
|
|
106
|
+
const argOptions = useMemo(() => (menuOpen ? [] : getSlashArgOptions(value)), [value, menuOpen]);
|
|
107
|
+
const argMenuOpen = !menuOpen && !menuDismissed && argOptions.length > 0;
|
|
108
|
+
const argIndex = argMenuOpen ? Math.min(menuIndex, argOptions.length - 1) : 0;
|
|
109
|
+
const argSelected = argMenuOpen ? argOptions[argIndex] : undefined;
|
|
110
|
+
useEffect(() => { setMenuIndex(0); }, [value]);
|
|
111
|
+
const acceptCommand = (cmd) => {
|
|
112
|
+
if (cmd.argHint) {
|
|
113
|
+
applyEdit(() => ({ value: `/${cmd.name} `, caretIndex: cmd.name.length + 2 }));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
submit(`/${cmd.name}`);
|
|
117
|
+
applyEdit(() => ({ value: "", caretIndex: 0 }));
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const acceptArg = (option) => {
|
|
121
|
+
applyEdit((v) => {
|
|
122
|
+
const next = applySlashArgCompletion(v, option.value);
|
|
123
|
+
return { value: next, caretIndex: Array.from(next).length };
|
|
124
|
+
});
|
|
125
|
+
};
|
|
30
126
|
useInput((input, key) => {
|
|
31
127
|
if (disabled)
|
|
32
128
|
return;
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
129
|
+
// Fast typing or paste can deliver "text\r" as a single chunk — treat a
|
|
130
|
+
// trailing Enter as Enter and strip it from the value.
|
|
131
|
+
const cleanInput = input.replace(/[\r\n]+$/, "");
|
|
132
|
+
const pressedEnter = key.return || /[\r\n]+$/.test(input);
|
|
133
|
+
// Esc closes the slash menu; while browsing history it returns to the draft
|
|
134
|
+
if (key.escape) {
|
|
135
|
+
if (menuOpen || argMenuOpen)
|
|
136
|
+
setMenuDismissed(true);
|
|
137
|
+
else if (historyRef.current.index !== -1) {
|
|
138
|
+
const next = resetHistory(historyRef.current, historyRef.current.draft);
|
|
139
|
+
historyRef.current = next;
|
|
140
|
+
setHistoryState(next);
|
|
141
|
+
if (next.draft !== editingRef.current.value) {
|
|
142
|
+
const after = { value: next.draft, caretIndex: Array.from(next.draft).length };
|
|
143
|
+
editingRef.current = after;
|
|
144
|
+
setEditing(after);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Any other key re-opens the menu
|
|
150
|
+
setMenuDismissed(false);
|
|
151
|
+
// Slash menu navigation
|
|
152
|
+
if (menuOpen) {
|
|
153
|
+
if (key.downArrow) {
|
|
154
|
+
setMenuIndex((i) => (i + 1) % matches.length);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key.upArrow) {
|
|
158
|
+
setMenuIndex((i) => (i - 1 + matches.length) % matches.length);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (pressedEnter || key.tab) {
|
|
162
|
+
if (selected)
|
|
163
|
+
acceptCommand(selected);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Left/right are ignored while the menu is open
|
|
167
|
+
if (key.leftArrow || key.rightArrow)
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Argument menu: ↑↓ pick, Tab completes. Enter still submits the line so a
|
|
171
|
+
// fully typed command is never swallowed by the suggestion list.
|
|
172
|
+
if (argMenuOpen) {
|
|
173
|
+
if (key.downArrow) {
|
|
174
|
+
setMenuIndex((i) => (i + 1) % argOptions.length);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (key.upArrow) {
|
|
178
|
+
setMenuIndex((i) => (i - 1 + argOptions.length) % argOptions.length);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (key.tab) {
|
|
182
|
+
if (argSelected)
|
|
183
|
+
acceptArg(argSelected);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Input history: ↑ older, ↓ newer (only when the slash menu is closed)
|
|
188
|
+
if (key.upArrow) {
|
|
189
|
+
browseTo(1);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key.downArrow) {
|
|
193
|
+
browseTo(-1);
|
|
36
194
|
return;
|
|
37
195
|
}
|
|
38
|
-
// Ctrl+J: insert newline
|
|
39
|
-
|
|
40
|
-
|
|
196
|
+
// Ctrl+J: insert newline. The terminal sends LF (0x0A) for Ctrl+J and CR
|
|
197
|
+
// (0x0D) for Enter; Ink parses both as "enter", but key.return is only
|
|
198
|
+
// true for the CR case, so a lone "\n" without key.return is Ctrl+J.
|
|
199
|
+
if (input === "\n" && !key.return) {
|
|
200
|
+
applyEdit((v, c) => insertAt(v, c, "\n"));
|
|
41
201
|
return;
|
|
42
202
|
}
|
|
43
203
|
// Enter: submit or continue if ends with backslash
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
204
|
+
if (pressedEnter) {
|
|
205
|
+
const v = editingRef.current.value;
|
|
206
|
+
if (v.endsWith("\\")) {
|
|
207
|
+
applyEdit((cv, cc) => insertAt(cv.slice(0, -1), cc, "\n"));
|
|
47
208
|
return;
|
|
48
209
|
}
|
|
49
|
-
const text =
|
|
50
|
-
if (text)
|
|
51
|
-
|
|
52
|
-
|
|
210
|
+
const text = v.trim();
|
|
211
|
+
if (text)
|
|
212
|
+
submit(text);
|
|
213
|
+
applyEdit(() => ({ value: "", caretIndex: 0 }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Left / right move the caret through the text
|
|
217
|
+
if (key.leftArrow) {
|
|
218
|
+
applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, -1) }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (key.rightArrow) {
|
|
222
|
+
applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, 1) }));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Backspace (\b, Ctrl+H) — remove the code point before the caret. Most
|
|
226
|
+
// terminals send DEL (0x7F) for the Backspace key and CSI 3 ~ for Delete;
|
|
227
|
+
// Ink reports both of those as key.delete (not key.backspace), so they
|
|
228
|
+
// never reach this branch and are handled by the raw stdin listener,
|
|
229
|
+
// which can tell them apart.
|
|
230
|
+
if (key.backspace) {
|
|
231
|
+
applyEdit((v, c) => backspaceAt(v, c));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (key.escape || key.pageUp || key.pageDown)
|
|
235
|
+
return;
|
|
236
|
+
// Ctrl+R: history search by current input
|
|
237
|
+
if (key.ctrl && input === "r") {
|
|
238
|
+
const hit = searchHistory(historyRef.current, editingRef.current.value);
|
|
239
|
+
if (hit) {
|
|
240
|
+
const after = { value: hit, caretIndex: Array.from(hit).length };
|
|
241
|
+
editingRef.current = after;
|
|
242
|
+
setEditing(after);
|
|
53
243
|
}
|
|
54
244
|
return;
|
|
55
245
|
}
|
|
56
|
-
//
|
|
57
|
-
if (key.
|
|
58
|
-
|
|
246
|
+
// Readline keys
|
|
247
|
+
if (key.ctrl && input === "a") {
|
|
248
|
+
applyEdit((v, c) => ({ value: v, caretIndex: moveToLineStart(v, c) }));
|
|
59
249
|
return;
|
|
60
250
|
}
|
|
61
|
-
|
|
62
|
-
|
|
251
|
+
if (key.ctrl && input === "e") {
|
|
252
|
+
applyEdit((v, c) => ({ value: v, caretIndex: moveToLineEnd(v, c) }));
|
|
63
253
|
return;
|
|
64
|
-
|
|
254
|
+
}
|
|
255
|
+
if (key.ctrl && input === "k") {
|
|
256
|
+
applyEdit((v, c) => deleteToLineEnd(v, c));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (key.ctrl && input === "u") {
|
|
260
|
+
applyEdit((v, c) => deleteToLineStart(v, c));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (key.ctrl && input === "w") {
|
|
264
|
+
applyEdit((v, c) => deleteWordBefore(v, c));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (key.meta && input === "b") {
|
|
268
|
+
applyEdit((v, c) => ({ value: v, caretIndex: Math.max(0, c - 1) }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (key.meta && input === "f") {
|
|
272
|
+
applyEdit((v, c) => ({ value: v, caretIndex: Math.min(Array.from(v).length, c + 1) }));
|
|
65
273
|
return;
|
|
274
|
+
}
|
|
66
275
|
if (key.ctrl || key.meta)
|
|
67
276
|
return;
|
|
277
|
+
// Kitty Shift+Enter sequence (or a fragment of it) — handled by the raw
|
|
278
|
+
// stdin listener, never as text
|
|
279
|
+
if (isKittySequenceFragment(input))
|
|
280
|
+
return;
|
|
281
|
+
if (/^\[<.*[Mm]$/.test(input) || input.startsWith("[<") || /^[\d;,]+[Mm]$/.test(input))
|
|
282
|
+
return;
|
|
283
|
+
// Bracketed paste: ESC[200~ ... ESC[200~ — Ink may deliver without ESC, treat as bulk insert
|
|
284
|
+
if (input.includes("\x1b[200~") || input.includes("[200~")) {
|
|
285
|
+
const cleaned = input.replace(/\x1b?\[200~|\x1b?\[201~/g, "");
|
|
286
|
+
if (cleaned)
|
|
287
|
+
applyEdit((v, c) => insertAt(v, c, cleaned));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
68
290
|
// Tab → 2 spaces
|
|
69
291
|
if (key.tab) {
|
|
70
|
-
|
|
292
|
+
applyEdit((v, c) => insertAt(v, c, " "));
|
|
71
293
|
return;
|
|
72
294
|
}
|
|
73
295
|
// Normal character input (including CJK)
|
|
74
296
|
if (input) {
|
|
75
|
-
|
|
297
|
+
applyEdit((v, c) => insertAt(v, c, cleanInput));
|
|
76
298
|
}
|
|
77
299
|
}, { isActive: !disabled });
|
|
78
300
|
const isEmpty = value === "";
|
|
79
|
-
const
|
|
80
|
-
const isMultiline =
|
|
81
|
-
const borderColor = disabled ?
|
|
82
|
-
|
|
301
|
+
const logicalLines = value.split("\n");
|
|
302
|
+
const isMultiline = logicalLines.length > 1;
|
|
303
|
+
const borderColor = disabled ? theme.inputBorderDisabled : theme.inputBorder;
|
|
304
|
+
const textWidth = inputTextWidth(width);
|
|
305
|
+
// Visual rows of the value, plus the caret position inside them.
|
|
306
|
+
const rows = visualRows(value, textWidth);
|
|
307
|
+
const clampedCaret = Math.min(caretIndex, Array.from(value).length);
|
|
308
|
+
// Locate the caret's visual row and its character offset inside that row.
|
|
309
|
+
let caretRowIndex = rows.length - 1;
|
|
310
|
+
let caretCharOffset = Array.from(rows[caretRowIndex].text).length;
|
|
311
|
+
for (let i = 0; i < rows.length; i++) {
|
|
312
|
+
const row = rows[i];
|
|
313
|
+
const end = row.startIndex + Array.from(row.text).length;
|
|
314
|
+
if (clampedCaret >= row.startIndex && clampedCaret <= end) {
|
|
315
|
+
caretRowIndex = i;
|
|
316
|
+
caretCharOffset = clampedCaret - row.startIndex;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// A caret at the end of a full row has no cell left — move it to the start
|
|
321
|
+
// of the next visual row (pushing an empty one when it is the last row).
|
|
322
|
+
let caretColumnOffset = displayWidth(Array.from(rows[caretRowIndex].text).slice(0, caretCharOffset).join(""));
|
|
323
|
+
if (caretColumnOffset >= textWidth) {
|
|
324
|
+
if (caretRowIndex === rows.length - 1) {
|
|
325
|
+
rows.push({
|
|
326
|
+
text: "",
|
|
327
|
+
startIndex: rows[caretRowIndex].startIndex + Array.from(rows[caretRowIndex].text).length,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
caretRowIndex++;
|
|
331
|
+
caretCharOffset = 0;
|
|
332
|
+
caretColumnOffset = 0;
|
|
333
|
+
}
|
|
334
|
+
// The caret may sit on any content row; the only thing below it is the
|
|
335
|
+
// (single-row) multi-line hint — plus the slash menu when it floats below
|
|
336
|
+
// the input bar.
|
|
337
|
+
const hintRows = isMultiline ? 1 : 0;
|
|
338
|
+
const menuRows = menuOpen ? matches.length + 5 /* margin + borders + header + footer */ : 0;
|
|
339
|
+
// Input bar sits at the bottom of App's footer: its content area starts
|
|
340
|
+
// `terminalRows` rows up minus the bar's own height (menu + bottom border +
|
|
341
|
+
// hint above it in screen order, from the bottom of the terminal up).
|
|
342
|
+
const inputTopRow = terminalRows - rows.length - hintRows - menuRows;
|
|
343
|
+
// Latest geometry for the raw stdin listener, which subscribes only once
|
|
344
|
+
// (independent of re-renders) and needs these when a click arrives.
|
|
345
|
+
const rowsRef = useRef(rows);
|
|
346
|
+
rowsRef.current = rows;
|
|
347
|
+
const inputTopRowRef = useRef(inputTopRow);
|
|
348
|
+
inputTopRowRef.current = inputTopRow;
|
|
349
|
+
// Listen for Kitty protocol Shift+Enter: ESC[13;2u, which may arrive split
|
|
350
|
+
// across stdin chunks — accumulate and reassemble here. Ink's useInput gets
|
|
351
|
+
// each raw chunk and would append fragments to the value, so isKittySequenceFragment
|
|
352
|
+
// below filters them out. Backspace (DEL 0x7F) and Delete (CSI 3 ~ and
|
|
353
|
+
// variants) are also picked out of the same raw stream, since Ink collapses
|
|
354
|
+
// them into one key.delete. SGR mouse click events arrive on the same raw
|
|
355
|
+
// stream; parse them and move the caret to the clicked cell. The listener
|
|
356
|
+
// subscribes once; every edit goes through applyEdit, which reads the
|
|
357
|
+
// synchronous editingRef, so fast key repeats never operate on stale text.
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!stdin || disabled)
|
|
360
|
+
return;
|
|
361
|
+
const handleData = (data) => {
|
|
362
|
+
const chunk = data.toString("utf-8");
|
|
363
|
+
// Kitty Shift+Enter accumulation
|
|
364
|
+
const { buffer: kittyBuf, shiftEnter } = accumulateKittyInput(kittyBuffer.current, chunk);
|
|
365
|
+
kittyBuffer.current = kittyBuf;
|
|
366
|
+
if (shiftEnter)
|
|
367
|
+
applyEdit((v, c) => insertAt(v, c, "\n"));
|
|
368
|
+
// Backspace (DEL 0x7F) and Delete (CSI 3 ~ / variants): Ink reports
|
|
369
|
+
// them all as key.delete, so they are distinguished and applied here.
|
|
370
|
+
// The buffer handles a sequence split across chunks.
|
|
371
|
+
const { buffer: delBuf, actions } = scanDeleteKeys(deleteBuffer.current, chunk);
|
|
372
|
+
deleteBuffer.current = delBuf;
|
|
373
|
+
if (actions.length > 0) {
|
|
374
|
+
for (const action of actions) {
|
|
375
|
+
applyEdit((v, c) => (action.kind === "backspace" ? backspaceAt(v, c) : deleteAt(v, c)));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
mouseBuffer.current += chunk;
|
|
379
|
+
let sgr;
|
|
380
|
+
while ((sgr = scanSgrMouse(mouseBuffer.current))) {
|
|
381
|
+
mouseBuffer.current = sgr.rest;
|
|
382
|
+
if (sgr.event.button === 0 && sgr.event.press) {
|
|
383
|
+
const contentRow = sgr.event.row - inputTopRowRef.current;
|
|
384
|
+
if (contentRow >= 0 && contentRow < rowsRef.current.length) {
|
|
385
|
+
const evCol = sgr.event.col;
|
|
386
|
+
applyEdit((v) => ({
|
|
387
|
+
value: v,
|
|
388
|
+
caretIndex: caretIndexFromClick(rowsRef.current, contentRow, evCol, TEXT_START_COLUMN),
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (mouseBuffer.current.length > 64) {
|
|
394
|
+
const esc = mouseBuffer.current.lastIndexOf("\x1b");
|
|
395
|
+
mouseBuffer.current = esc === -1 ? "" : mouseBuffer.current.slice(esc);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
stdin.on("data", handleData);
|
|
399
|
+
return () => { stdin.off("data", handleData); };
|
|
400
|
+
}, [stdin, disabled, applyEdit]);
|
|
401
|
+
// Enable the SGR mouse protocol so clicks on the input bar can position the
|
|
402
|
+
// caret. Events arrive on raw stdin; parseSgrMouse picks them out of the
|
|
403
|
+
// same chunk stream as the Kitty sequence. Terminals without mouse support
|
|
404
|
+
// simply never send events — keyboard navigation still works.
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
stdout.write(MOUSE_ENABLE);
|
|
407
|
+
return () => { stdout.write(MOUSE_DISABLE); };
|
|
408
|
+
}, [stdout]);
|
|
409
|
+
// Must publish during render (not in effect) — Ink writes the frame in
|
|
410
|
+
// resetAfterCommit before effects run. If delayed to useEffect, the
|
|
411
|
+
// hardware cursor (IME preedit anchor) lags by one frame and appears
|
|
412
|
+
// displaced while composing CJK.
|
|
413
|
+
{
|
|
414
|
+
const rowsBelowCaret = rows.length - 1 - caretRowIndex;
|
|
415
|
+
setCaretPosition({
|
|
416
|
+
rowsAbove: rowsBelowCaret + 1 /* bottom border */ + hintRows + menuRows,
|
|
417
|
+
column: TEXT_START_COLUMN + caretColumnOffset,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, children: [isEmpty && placeholder ? (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.inputBorderDisabled : theme.inputBorder, children: GUTTER }), _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: placeholder })] })) : (rows.map((row, i) => {
|
|
421
|
+
const chars = Array.from(row.text);
|
|
422
|
+
const caretHere = i === caretRowIndex;
|
|
423
|
+
const offset = caretHere ? caretCharOffset : chars.length;
|
|
424
|
+
const before = chars.slice(0, offset).join("");
|
|
425
|
+
const after = chars.slice(offset).join("");
|
|
426
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.inputBorderDisabled : theme.inputBorder, children: i === 0 ? GUTTER : " " }), _jsxs(Text, { wrap: "truncate-end", children: [before, caretHere && _jsx(Text, { inverse: true, children: " " }), after] })] }, i));
|
|
427
|
+
})), isMultiline && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [logicalLines.length, " \u884C | Shift+Enter/Ctrl+J \u6362\u884C | Enter \u53D1\u9001"] }) }))] }), menuOpen && selected && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.inputBorder, paddingX: 1, marginTop: 1, children: [_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: value.trim() === "/" ? "命令" : `/${value.slice(1)} 命令` }) }), matches.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex ? theme.accent : theme.muted, children: i === shownIndex ? "❯ " : " " }), _jsxs(Text, { bold: i === shownIndex, color: i === shownIndex ? theme.accent : undefined, children: ["/", c.name, c.argHint ? ` ${c.argHint}` : ""] }), _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [" ", c.description] })] }, c.name))), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED" }) })] })), argMenuOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.inputBorder, paddingX: 1, marginTop: 1, children: [argOptions.slice(0, 8).map((o, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === argIndex ? theme.accent : theme.muted, children: i === argIndex ? "❯ " : " " }), _jsx(Text, { bold: i === argIndex, color: i === argIndex ? theme.accent : undefined, children: o.value }), o.description && (_jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [" ", o.description] }))] }, o.value))), _jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [argOptions.length > 8 ? `还有 ${argOptions.length - 8} 项 · ` : "", "\u2191\u2193 \u9009\u62E9 \u00B7 Tab \u8865\u5168 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED"] }) })] }))] }));
|
|
83
428
|
}
|