min-agent 0.3.0 → 0.4.1
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 +154 -216
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { summarizeToolCall, summarizeToolResult, toolResultText } from "../tool-display.js";
|
|
2
|
+
import { extractXmlSearch } from "../xml-search.js";
|
|
3
|
+
function isRecord(v) {
|
|
4
|
+
return typeof v === "object" && v !== null;
|
|
5
|
+
}
|
|
6
|
+
function textFromContent(content) {
|
|
7
|
+
if (typeof content === "string")
|
|
8
|
+
return content;
|
|
9
|
+
if (!Array.isArray(content))
|
|
10
|
+
return "";
|
|
11
|
+
const parts = [];
|
|
12
|
+
for (const part of content) {
|
|
13
|
+
if (isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
14
|
+
parts.push(part.text);
|
|
15
|
+
}
|
|
16
|
+
return parts.join("");
|
|
17
|
+
}
|
|
18
|
+
function hasMediaPart(content) {
|
|
19
|
+
if (!Array.isArray(content))
|
|
20
|
+
return false;
|
|
21
|
+
return content.some((part) => isRecord(part) && (part.type === "image" || part.type === "file"));
|
|
22
|
+
}
|
|
23
|
+
function unwrapToolOutput(output) {
|
|
24
|
+
if (!isRecord(output))
|
|
25
|
+
return output;
|
|
26
|
+
if ((output.type === "text" || output.type === "json") && "value" in output)
|
|
27
|
+
return output.value;
|
|
28
|
+
return output;
|
|
29
|
+
}
|
|
30
|
+
function userText(content) {
|
|
31
|
+
const text = textFromContent(content).trim();
|
|
32
|
+
if (text)
|
|
33
|
+
return textFromContent(content);
|
|
34
|
+
if (hasMediaPart(content))
|
|
35
|
+
return "[📎 图片]";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export function lastUserText(messages) {
|
|
39
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
40
|
+
const msg = messages[i];
|
|
41
|
+
if (msg?.role !== "user")
|
|
42
|
+
continue;
|
|
43
|
+
const text = textFromContent(msg.content).trim();
|
|
44
|
+
if (text)
|
|
45
|
+
return textFromContent(msg.content);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export function hydrateMessages(messages) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < messages.length; i++) {
|
|
52
|
+
const msg = messages[i];
|
|
53
|
+
if (msg.role === "assistant") {
|
|
54
|
+
out.push(...hydrateAssistant(msg, i));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const mapped = mapMessage(msg, i);
|
|
58
|
+
if (mapped)
|
|
59
|
+
out.push(mapped);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function hydrateAssistant(msg, index) {
|
|
64
|
+
const content = textFromContent(msg.content);
|
|
65
|
+
if (!content)
|
|
66
|
+
return [];
|
|
67
|
+
const { slices } = extractXmlSearch(content);
|
|
68
|
+
if (slices.every((sl) => sl.type === "display")) {
|
|
69
|
+
return [{ id: `hyd-${index}-assistant`, role: "assistant", content, timestamp: 0 }];
|
|
70
|
+
}
|
|
71
|
+
const out = [];
|
|
72
|
+
let part = 0;
|
|
73
|
+
for (const sl of slices) {
|
|
74
|
+
if (sl.type === "display") {
|
|
75
|
+
if (!sl.text.trim())
|
|
76
|
+
continue;
|
|
77
|
+
out.push({ id: `hyd-${index}-assistant-${part++}`, role: "assistant", content: sl.text, timestamp: 0 });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const query = sl.block.query;
|
|
81
|
+
const result = sl.block.kind === "results" ? sl.block.body : sl.block.query;
|
|
82
|
+
const input = { query };
|
|
83
|
+
out.push({
|
|
84
|
+
id: `hyd-${index}-search-${part++}`,
|
|
85
|
+
role: "tool",
|
|
86
|
+
content: summarizeToolCall("search_web", input),
|
|
87
|
+
toolName: "search_web",
|
|
88
|
+
toolCallId: `xml-search-hyd-${index}-${part}`,
|
|
89
|
+
toolResult: result,
|
|
90
|
+
toolResultSummary: summarizeToolResult("search_web", result),
|
|
91
|
+
timestamp: 0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function mapMessage(msg, index) {
|
|
97
|
+
if (msg.role === "user") {
|
|
98
|
+
const content = userText(msg.content);
|
|
99
|
+
if (content == null)
|
|
100
|
+
return null;
|
|
101
|
+
return { id: `hyd-${index}-user`, role: "user", content, timestamp: 0 };
|
|
102
|
+
}
|
|
103
|
+
if (msg.role === "tool") {
|
|
104
|
+
if (!Array.isArray(msg.content))
|
|
105
|
+
return null;
|
|
106
|
+
for (const part of msg.content) {
|
|
107
|
+
if (!isRecord(part) || part.type !== "tool-result")
|
|
108
|
+
continue;
|
|
109
|
+
const toolName = typeof part.toolName === "string" ? part.toolName : "";
|
|
110
|
+
if (!toolName)
|
|
111
|
+
continue;
|
|
112
|
+
const output = unwrapToolOutput(part.output);
|
|
113
|
+
const result = toolResultText(output);
|
|
114
|
+
const toolCallId = typeof part.toolCallId === "string" ? part.toolCallId : undefined;
|
|
115
|
+
return {
|
|
116
|
+
id: `hyd-${index}-tool`,
|
|
117
|
+
role: "tool",
|
|
118
|
+
content: toolName,
|
|
119
|
+
toolName,
|
|
120
|
+
toolCallId,
|
|
121
|
+
toolResult: result,
|
|
122
|
+
toolResultSummary: summarizeToolResult(toolName, output),
|
|
123
|
+
timestamp: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { App } from "./App.js";
|
|
4
4
|
import { createCaretAwareStdout, setCaretPosition } from "./caret.js";
|
|
5
|
+
import { mergeToolChild } from "./tool-children.js";
|
|
6
|
+
import { normalizeQuestionOptions, normalizeQuestionPrompt } from "../question-format.js";
|
|
5
7
|
/**
|
|
6
8
|
* TUI renderer — manages the Ink app lifecycle and provides
|
|
7
9
|
* imperative methods to update state from the agent loop.
|
|
@@ -16,23 +18,26 @@ export class TuiRenderer {
|
|
|
16
18
|
pendingThinking = "";
|
|
17
19
|
thinkingTimer = null;
|
|
18
20
|
copyNoticeTimer = null;
|
|
19
|
-
constructor(callbacks, model) {
|
|
21
|
+
constructor(callbacks, model, sessionId) {
|
|
20
22
|
this.state = {
|
|
21
23
|
messages: [],
|
|
22
24
|
isRunning: false,
|
|
23
25
|
model,
|
|
26
|
+
sessionId,
|
|
24
27
|
};
|
|
25
28
|
this.callbacks = callbacks;
|
|
26
29
|
}
|
|
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) }));
|
|
32
|
+
}
|
|
27
33
|
start() {
|
|
28
|
-
this.inkInstance = render(
|
|
34
|
+
this.inkInstance = render(this.appElement(), { exitOnCtrlC: false, stdout: createCaretAwareStdout(process.stdout) });
|
|
29
35
|
}
|
|
30
36
|
/** Update state and trigger re-render — always replaces the reference so React sees a new prop. */
|
|
31
37
|
update(partial) {
|
|
32
38
|
this.state = { ...this.state, ...partial };
|
|
33
|
-
if (this.inkInstance)
|
|
34
|
-
this.inkInstance.rerender(
|
|
35
|
-
}
|
|
39
|
+
if (this.inkInstance)
|
|
40
|
+
this.inkInstance.rerender(this.appElement());
|
|
36
41
|
}
|
|
37
42
|
addMessage(msg) {
|
|
38
43
|
this.flushPendingDeltas();
|
|
@@ -128,8 +133,6 @@ export class TuiRenderer {
|
|
|
128
133
|
this.pendingDelta = "";
|
|
129
134
|
if (!accumulated)
|
|
130
135
|
return;
|
|
131
|
-
if (accumulated.trim() === "")
|
|
132
|
-
return;
|
|
133
136
|
const msgs = [...this.state.messages];
|
|
134
137
|
const last = msgs[msgs.length - 1];
|
|
135
138
|
if (last && last.role === "assistant") {
|
|
@@ -137,7 +140,7 @@ export class TuiRenderer {
|
|
|
137
140
|
this.state.messages = msgs;
|
|
138
141
|
this.update({});
|
|
139
142
|
}
|
|
140
|
-
else {
|
|
143
|
+
else if (accumulated.trim() !== "") {
|
|
141
144
|
this.addMessage({
|
|
142
145
|
id: `msg-${Date.now()}`,
|
|
143
146
|
role: "assistant",
|
|
@@ -164,28 +167,54 @@ export class TuiRenderer {
|
|
|
164
167
|
this.update({ isRunning: running, spinnerText });
|
|
165
168
|
}
|
|
166
169
|
showConfirm(message) {
|
|
167
|
-
this.update({ confirmMessage: message });
|
|
170
|
+
this.update({ confirmMessage: message, selectionMode: false });
|
|
168
171
|
}
|
|
169
172
|
hideConfirm() {
|
|
170
173
|
this.update({ confirmMessage: undefined });
|
|
171
174
|
}
|
|
172
175
|
showQuestion(prompt, options) {
|
|
173
|
-
|
|
176
|
+
const choices = normalizeQuestionOptions(options);
|
|
177
|
+
this.update({
|
|
178
|
+
question: {
|
|
179
|
+
prompt: normalizeQuestionPrompt(prompt),
|
|
180
|
+
...(choices.length > 0 ? { options: choices } : {}),
|
|
181
|
+
},
|
|
182
|
+
selectionMode: false,
|
|
183
|
+
});
|
|
174
184
|
}
|
|
175
185
|
hideQuestion() {
|
|
176
186
|
this.update({ question: undefined });
|
|
177
187
|
}
|
|
178
188
|
showModelPicker() {
|
|
179
|
-
this.update({ modelPicker: true });
|
|
189
|
+
this.update({ modelPicker: true, selectionMode: false });
|
|
180
190
|
}
|
|
181
191
|
hideModelPicker() {
|
|
182
192
|
this.update({ modelPicker: false });
|
|
183
193
|
}
|
|
194
|
+
showSessionPicker() {
|
|
195
|
+
this.update({ sessionPicker: true, selectionMode: false });
|
|
196
|
+
}
|
|
197
|
+
hideSessionPicker() {
|
|
198
|
+
this.update({ sessionPicker: false });
|
|
199
|
+
}
|
|
200
|
+
setSessionId(id) {
|
|
201
|
+
this.update({ sessionId: id });
|
|
202
|
+
}
|
|
203
|
+
setSelectionMode(on) {
|
|
204
|
+
this.update({ selectionMode: on });
|
|
205
|
+
}
|
|
206
|
+
toggleSelectionMode() {
|
|
207
|
+
this.update({ selectionMode: !this.state.selectionMode });
|
|
208
|
+
}
|
|
209
|
+
appendToolChild(parentToolCallId, child) {
|
|
210
|
+
this.state.messages = mergeToolChild(this.state.messages, parentToolCallId, child);
|
|
211
|
+
this.update({});
|
|
212
|
+
}
|
|
184
213
|
setModel(model) {
|
|
185
214
|
this.update({ model });
|
|
186
215
|
}
|
|
187
|
-
setTokenInfo(
|
|
188
|
-
this.update({ tokenInfo:
|
|
216
|
+
setTokenInfo(info) {
|
|
217
|
+
this.update({ tokenInfo: info });
|
|
189
218
|
}
|
|
190
219
|
/** Flash a transient copy confirmation in the status bar (2.5s). */
|
|
191
220
|
showCopyNotice(text) {
|
|
@@ -1,7 +1,64 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { getConfigDir } from "../config.js";
|
|
4
|
+
import { atomicWriteFileSync } from "../tools/atomic-file.js";
|
|
5
|
+
export const HISTORY_LIMIT = 1000;
|
|
6
|
+
function historyFile() {
|
|
7
|
+
return path.join(getConfigDir(), "input-history.json");
|
|
8
|
+
}
|
|
9
|
+
export function createHistory(entries = []) {
|
|
10
|
+
return { entries, index: -1, draft: "" };
|
|
11
|
+
}
|
|
12
|
+
export function parseHistoryFile(raw) {
|
|
13
|
+
let data;
|
|
14
|
+
try {
|
|
15
|
+
data = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
21
|
+
return null;
|
|
22
|
+
const entries = data.entries;
|
|
23
|
+
if (!Array.isArray(entries) || entries.some((e) => typeof e !== "string"))
|
|
24
|
+
return null;
|
|
25
|
+
return entries;
|
|
26
|
+
}
|
|
27
|
+
export function serializeHistoryFile(entries) {
|
|
28
|
+
return JSON.stringify({ entries }, null, 2);
|
|
29
|
+
}
|
|
30
|
+
export function loadInputHistoryFrom(file) {
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return [];
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = readFileSync(file, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const parsed = parseHistoryFile(raw);
|
|
41
|
+
if (parsed)
|
|
42
|
+
return parsed;
|
|
43
|
+
try {
|
|
44
|
+
renameSync(file, `${file}.corrupt-${Date.now()}`);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
export function saveInputHistoryTo(file, entries) {
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
atomicWriteFileSync(file, serializeHistoryFile(entries));
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
export function loadInputHistory() {
|
|
57
|
+
return loadInputHistoryFrom(historyFile());
|
|
58
|
+
}
|
|
59
|
+
export function saveInputHistory(entries) {
|
|
60
|
+
saveInputHistoryTo(historyFile(), entries);
|
|
3
61
|
}
|
|
4
|
-
const HISTORY_LIMIT = 500;
|
|
5
62
|
export function pushInput(state, text) {
|
|
6
63
|
if (!text)
|
|
7
64
|
return state;
|
|
@@ -22,23 +79,47 @@ export function searchHistory(state, query) {
|
|
|
22
79
|
}
|
|
23
80
|
return null;
|
|
24
81
|
}
|
|
25
|
-
export function browseHistory(state, direction) {
|
|
82
|
+
export function browseHistory(state, direction, skip) {
|
|
26
83
|
const count = state.entries.length;
|
|
27
84
|
if (count === 0)
|
|
28
85
|
return { text: state.draft, state };
|
|
86
|
+
const last = count - 1;
|
|
87
|
+
const usable = (i) => i >= 0 && i <= last && !skip?.(state.entries[i]);
|
|
88
|
+
const current = state.index === -1 ? -1 : Math.max(0, Math.min(last, state.index));
|
|
29
89
|
if (direction === 1) {
|
|
30
|
-
const
|
|
31
|
-
|
|
90
|
+
const start = current === -1 ? last : current - 1;
|
|
91
|
+
for (let i = start; i >= 0; i--) {
|
|
92
|
+
if (usable(i))
|
|
93
|
+
return { text: state.entries[i], state: { ...state, index: i } };
|
|
94
|
+
}
|
|
95
|
+
if (current !== -1)
|
|
96
|
+
return { text: state.entries[current], state };
|
|
97
|
+
return { text: state.draft, state };
|
|
32
98
|
}
|
|
33
|
-
if (
|
|
99
|
+
if (current === -1)
|
|
34
100
|
return { text: state.draft, state };
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
101
|
+
for (let i = current + 1; i <= last; i++) {
|
|
102
|
+
if (usable(i))
|
|
103
|
+
return { text: state.entries[i], state: { ...state, index: i } };
|
|
104
|
+
}
|
|
105
|
+
return { text: state.draft, state: { ...state, index: -1 } };
|
|
39
106
|
}
|
|
40
107
|
export function resetHistory(state, text) {
|
|
41
108
|
if (state.index === -1 && state.draft === text)
|
|
42
109
|
return state;
|
|
43
110
|
return { entries: state.entries, index: -1, draft: text };
|
|
44
111
|
}
|
|
112
|
+
export function isCtrlC(input, key) {
|
|
113
|
+
return (key.ctrl === true && input === "c") || input === "\x03";
|
|
114
|
+
}
|
|
115
|
+
export const EXIT_CTRL_C_WINDOW_MS = 2000;
|
|
116
|
+
export function clearInputOnCtrlC(state, value, armedAt = null, now = 0) {
|
|
117
|
+
if (value === "") {
|
|
118
|
+
if (armedAt !== null && now - armedAt <= EXIT_CTRL_C_WINDOW_MS) {
|
|
119
|
+
return { state, value: "", exit: true, armedAt: null };
|
|
120
|
+
}
|
|
121
|
+
return { state, value: "", exit: false, armedAt: now };
|
|
122
|
+
}
|
|
123
|
+
const next = value.trim() ? pushInput(state, value) : { entries: state.entries, index: -1, draft: "" };
|
|
124
|
+
return { state: next, value: "", exit: false, armedAt: null };
|
|
125
|
+
}
|
package/dist/tui/layout.js
CHANGED
|
@@ -2,16 +2,87 @@ export const BOX_CHROME = 4;
|
|
|
2
2
|
export const GUTTER_WIDTH = 2;
|
|
3
3
|
export const TEXT_START_COLUMN = 5;
|
|
4
4
|
export const STATUS_BAR_ROWS = 2;
|
|
5
|
-
export const SAFETY_MARGIN = 2;
|
|
6
5
|
export const INPUT_BAR_ROWS = 3;
|
|
6
|
+
export const MIN_MESSAGE_ROWS = 2;
|
|
7
|
+
/** Ink paddingLeft on every message row. */
|
|
8
|
+
export const MESSAGE_PAD_LEFT = 1;
|
|
9
|
+
/**
|
|
10
|
+
* Cells reserved at the right of a message row. Filling the last terminal
|
|
11
|
+
* column wraps the cursor; East-Asian-ambiguous glyphs (`→`, `▸`) may also
|
|
12
|
+
* occupy one more cell than `displayWidth` reports.
|
|
13
|
+
*/
|
|
14
|
+
export const COL_WRAP_GUARD = 2;
|
|
15
|
+
/** Pinned chrome of ConfirmBar: borders + title + gap + [y]/[N] prompt. */
|
|
16
|
+
export const CONFIRM_CHROME_ROWS = 5;
|
|
17
|
+
/** Pinned chrome of QuestionBar: borders + gap + input + hint. */
|
|
18
|
+
export const QUESTION_CHROME_ROWS = 5;
|
|
19
|
+
/**
|
|
20
|
+
* Ink's log-update writes the frame plus a trailing newline. A root box that
|
|
21
|
+
* uses the full terminal height therefore places the input bar's bottom
|
|
22
|
+
* border on the last row, which that extra newline clips.
|
|
23
|
+
*/
|
|
24
|
+
export const LAST_ROW_GUARD = 1;
|
|
25
|
+
export function frameRows(terminalRows) {
|
|
26
|
+
if (terminalRows <= 0)
|
|
27
|
+
return 0;
|
|
28
|
+
return Math.max(1, terminalRows - LAST_ROW_GUARD);
|
|
29
|
+
}
|
|
7
30
|
export function innerTextWidth(columns) {
|
|
8
31
|
return Math.max(1, columns - 5);
|
|
9
32
|
}
|
|
33
|
+
export function messageTextWidth(columns) {
|
|
34
|
+
return Math.max(1, columns - MESSAGE_PAD_LEFT - COL_WRAP_GUARD);
|
|
35
|
+
}
|
|
10
36
|
export function inputTextWidth(columns) {
|
|
11
37
|
return Math.max(8, columns - BOX_CHROME - GUTTER_WIDTH);
|
|
12
38
|
}
|
|
13
|
-
export function computeMessageMaxHeight(
|
|
14
|
-
if (
|
|
39
|
+
export function computeMessageMaxHeight(frameRowCount, footerRows, spinnerRows) {
|
|
40
|
+
if (frameRowCount <= 0)
|
|
15
41
|
return undefined;
|
|
16
|
-
return Math.max(
|
|
42
|
+
return Math.max(MIN_MESSAGE_ROWS, frameRowCount - footerRows - STATUS_BAR_ROWS - spinnerRows);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Max rows a confirm/question footer may occupy while leaving the status bar
|
|
46
|
+
* and a two-row peek of the conversation. `minChrome` is the pinned action
|
|
47
|
+
* chrome (title + prompt) that must stay on screen.
|
|
48
|
+
*/
|
|
49
|
+
export function footerMaxRows(frameRowCount, minChrome) {
|
|
50
|
+
if (frameRowCount <= 0)
|
|
51
|
+
return Number.POSITIVE_INFINITY;
|
|
52
|
+
return Math.max(minChrome, frameRowCount - STATUS_BAR_ROWS - MIN_MESSAGE_ROWS);
|
|
53
|
+
}
|
|
54
|
+
/** Rows a line window actually paints: content plus one indicator per hidden side. */
|
|
55
|
+
export function lineWindowPaintRows(window) {
|
|
56
|
+
return window.lines.length + (window.above > 0 ? 1 : 0) + (window.below > 0 ? 1 : 0);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Visible slice of wrapped lines for a max-height body. Hidden sides take an
|
|
60
|
+
* indicator row each, subtracted from the content budget so the painted
|
|
61
|
+
* window never exceeds `maxHeight`.
|
|
62
|
+
*/
|
|
63
|
+
export function sliceLineWindow(lines, maxHeight, top) {
|
|
64
|
+
if (maxHeight <= 0) {
|
|
65
|
+
return { start: 0, lines: [], above: 0, below: lines.length };
|
|
66
|
+
}
|
|
67
|
+
if (lines.length <= maxHeight) {
|
|
68
|
+
return { start: 0, lines, above: 0, below: 0 };
|
|
69
|
+
}
|
|
70
|
+
let above = 0;
|
|
71
|
+
let below = 0;
|
|
72
|
+
let start = 0;
|
|
73
|
+
let viewH = maxHeight;
|
|
74
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
75
|
+
const chrome = (above > 0 ? 1 : 0) + (below > 0 ? 1 : 0);
|
|
76
|
+
viewH = Math.max(1, maxHeight - chrome);
|
|
77
|
+
const maxTop = Math.max(0, lines.length - viewH);
|
|
78
|
+
start = Math.max(0, Math.min(top, maxTop));
|
|
79
|
+
above = start;
|
|
80
|
+
below = Math.max(0, lines.length - (start + viewH));
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
start,
|
|
84
|
+
lines: lines.slice(start, start + viewH),
|
|
85
|
+
above,
|
|
86
|
+
below,
|
|
87
|
+
};
|
|
17
88
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createPromptQueue() {
|
|
2
|
+
let tail = Promise.resolve();
|
|
3
|
+
let generation = 0;
|
|
4
|
+
let size = 0;
|
|
5
|
+
const settle = () => undefined;
|
|
6
|
+
return {
|
|
7
|
+
run(job, onCancelled) {
|
|
8
|
+
const gen = generation;
|
|
9
|
+
size++;
|
|
10
|
+
const start = () => (gen !== generation ? Promise.resolve(onCancelled()) : job());
|
|
11
|
+
const result = tail.then(start, start);
|
|
12
|
+
tail = result.then(settle, settle);
|
|
13
|
+
return result.finally(() => {
|
|
14
|
+
size--;
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
cancelAll() {
|
|
18
|
+
generation++;
|
|
19
|
+
},
|
|
20
|
+
get size() {
|
|
21
|
+
return size;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/tui/selection.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clustersOf, displayWidth } from "./text-width.js";
|
|
2
2
|
const RESET = "\x1b[0m";
|
|
3
3
|
const INVERSE_ON = "\x1b[7m";
|
|
4
4
|
const INVERSE_OFF = "\x1b[27m";
|
|
@@ -20,9 +20,6 @@ export function applySelection(line, start, end) {
|
|
|
20
20
|
let inSel = false;
|
|
21
21
|
for (const token of line.split(/(\x1b\[[0-9;]*m)/g)) {
|
|
22
22
|
if (token.startsWith("\x1b[")) {
|
|
23
|
-
// A reset inside the selection would cancel the highlight, so it is
|
|
24
|
-
// re-applied right after; a reset at/after the selection end closes the
|
|
25
|
-
// highlight first, keeping the boundary between the two styles.
|
|
26
23
|
if (inSel && token === RESET) {
|
|
27
24
|
if (col < end)
|
|
28
25
|
out += token + INVERSE_ON;
|
|
@@ -35,9 +32,9 @@ export function applySelection(line, start, end) {
|
|
|
35
32
|
out += token;
|
|
36
33
|
continue;
|
|
37
34
|
}
|
|
38
|
-
for (const
|
|
35
|
+
for (const { text: g, width: w } of clustersOf(token)) {
|
|
39
36
|
const cellStart = col;
|
|
40
|
-
const cellEnd = col +
|
|
37
|
+
const cellEnd = col + w;
|
|
41
38
|
const selected = cellEnd > start && cellStart < end;
|
|
42
39
|
if (selected && !inSel) {
|
|
43
40
|
out += INVERSE_ON;
|
|
@@ -47,7 +44,7 @@ export function applySelection(line, start, end) {
|
|
|
47
44
|
out += INVERSE_OFF;
|
|
48
45
|
inSel = false;
|
|
49
46
|
}
|
|
50
|
-
out +=
|
|
47
|
+
out += g;
|
|
51
48
|
col = cellEnd;
|
|
52
49
|
}
|
|
53
50
|
}
|
|
@@ -61,23 +58,16 @@ export function extractText(line, start, end) {
|
|
|
61
58
|
return "";
|
|
62
59
|
let out = "";
|
|
63
60
|
let col = 0;
|
|
64
|
-
for (const
|
|
65
|
-
const cellEnd = col +
|
|
61
|
+
for (const { text: g, width: w } of clustersOf(cachedStrip(line))) {
|
|
62
|
+
const cellEnd = col + w;
|
|
66
63
|
if (cellEnd > start && col < end)
|
|
67
|
-
out +=
|
|
64
|
+
out += g;
|
|
68
65
|
col = cellEnd;
|
|
69
66
|
if (col >= end)
|
|
70
67
|
break;
|
|
71
68
|
}
|
|
72
69
|
return out;
|
|
73
70
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Flow-style selection over the message area. `rows` mirrors the rendered
|
|
76
|
-
* rows (null = blank/chrome row, no text); anchor/current are 0-based
|
|
77
|
-
* (row = index into `rows`, col = display column). Dragging down selects the
|
|
78
|
-
* anchor to end-of-line, full middle rows, then start to current; dragging up
|
|
79
|
-
* is symmetric. Returns one range per row (null = nothing selected there).
|
|
80
|
-
*/
|
|
81
71
|
const strippedCache = new Map();
|
|
82
72
|
function cachedStrip(row) {
|
|
83
73
|
const hit = strippedCache.get(row);
|
|
@@ -89,6 +79,106 @@ function cachedStrip(row) {
|
|
|
89
79
|
strippedCache.set(row, s);
|
|
90
80
|
return s;
|
|
91
81
|
}
|
|
82
|
+
export function textFromSelection(rows, kinds, ranges) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
85
|
+
const range = ranges[i];
|
|
86
|
+
const row = rows[i];
|
|
87
|
+
if (!range || !row)
|
|
88
|
+
continue;
|
|
89
|
+
parts.push(stripCopyDecorations(extractText(row, range.start, range.end), kinds[i] ?? "plain"));
|
|
90
|
+
}
|
|
91
|
+
while (parts.length > 0 && parts[parts.length - 1] === "")
|
|
92
|
+
parts.pop();
|
|
93
|
+
return parts.join("\n");
|
|
94
|
+
}
|
|
95
|
+
export function stripCopyDecorations(line, kind) {
|
|
96
|
+
if (kind === "user-head" && line.startsWith("> "))
|
|
97
|
+
return line.slice(2);
|
|
98
|
+
if (kind === "tool-head")
|
|
99
|
+
return line.replace(/^⚡ .*?[▸▾] /, "");
|
|
100
|
+
if (kind === "tool-body" && line.startsWith(" "))
|
|
101
|
+
return line.slice(2);
|
|
102
|
+
return line;
|
|
103
|
+
}
|
|
104
|
+
/** Visible prefix rendered in its own Ink Text (user `> `, tool `⚡ name ▸ `). */
|
|
105
|
+
export function visualPrefix(line, role) {
|
|
106
|
+
if (role === "user" && line.startsWith("> "))
|
|
107
|
+
return "> ";
|
|
108
|
+
if (role === "tool") {
|
|
109
|
+
const m = line.match(/^⚡ .*?[▸▾] /);
|
|
110
|
+
return m ? m[0] : "";
|
|
111
|
+
}
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
/** Split a display-column range across a prefix of `at` cells. */
|
|
115
|
+
export function splitRangeAt(range, at) {
|
|
116
|
+
const head = range.start < at && range.end > 0 ? { start: range.start, end: Math.min(at, range.end) } : null;
|
|
117
|
+
const tail = range.end > at ? { start: Math.max(0, range.start - at), end: range.end - at } : null;
|
|
118
|
+
return {
|
|
119
|
+
head: head && head.end > head.start ? head : null,
|
|
120
|
+
tail: tail && tail.end > tail.start ? tail : null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function wordRangeAt(line, col) {
|
|
124
|
+
const cells = [];
|
|
125
|
+
let x = 0;
|
|
126
|
+
for (const { text: g, width: w } of clustersOf(line)) {
|
|
127
|
+
cells.push({ ch: g, start: x, end: x + w });
|
|
128
|
+
x += w;
|
|
129
|
+
}
|
|
130
|
+
const hit = cells.find((c) => col >= c.start && col < c.end);
|
|
131
|
+
if (!hit || /\s/.test(hit.ch))
|
|
132
|
+
return { start: col, end: col };
|
|
133
|
+
let left = cells.indexOf(hit);
|
|
134
|
+
let right = left;
|
|
135
|
+
while (left > 0 && !/\s/.test(cells[left - 1].ch))
|
|
136
|
+
left--;
|
|
137
|
+
while (right < cells.length - 1 && !/\s/.test(cells[right + 1].ch))
|
|
138
|
+
right++;
|
|
139
|
+
return { start: cells[left].start, end: cells[right].end };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Map a 1-based SGR mouse position to a cell in `rows`.
|
|
143
|
+
* `padLeft` is the Ink paddingLeft of every message row (first text cell is
|
|
144
|
+
* terminal column padLeft+1).
|
|
145
|
+
*/
|
|
146
|
+
export function mouseToCell(rows, termRow, termCol, padLeft = 1) {
|
|
147
|
+
const maxRow = Math.max(0, rows.length - 1);
|
|
148
|
+
return {
|
|
149
|
+
row: Math.min(maxRow, Math.max(0, termRow - 1)),
|
|
150
|
+
col: Math.max(0, termCol - 1 - padLeft),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Distribute viewport ranges onto each message part's shown-line index.
|
|
155
|
+
* The resulting arrays are sparse: missing indices mean that shown line is
|
|
156
|
+
* not highlighted. Callers must not require `arr.length === shown.length`.
|
|
157
|
+
*/
|
|
158
|
+
export function alignPartSelections(ranges, partMeta) {
|
|
159
|
+
const map = new Map();
|
|
160
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
161
|
+
const meta = partMeta[i];
|
|
162
|
+
const range = ranges[i];
|
|
163
|
+
if (!meta || !range || range.end <= range.start)
|
|
164
|
+
continue;
|
|
165
|
+
let arr = map.get(meta.part);
|
|
166
|
+
if (!arr) {
|
|
167
|
+
arr = [];
|
|
168
|
+
map.set(meta.part, arr);
|
|
169
|
+
}
|
|
170
|
+
arr[meta.shown] = range;
|
|
171
|
+
}
|
|
172
|
+
return map;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Flow-style selection over the message area. `rows` mirrors the rendered
|
|
176
|
+
* rows (null = blank/chrome row, no text); anchor/current are 0-based
|
|
177
|
+
* (row = index into `rows`, col = display column of the cell under the
|
|
178
|
+
* pointer). Both endpoints are inclusive: dragging onto a character selects
|
|
179
|
+
* it. Dragging down selects the anchor through end-of-line, full middle rows,
|
|
180
|
+
* then start through current; dragging up is symmetric.
|
|
181
|
+
*/
|
|
92
182
|
export function selectionRanges(rows, anchor, current) {
|
|
93
183
|
const result = rows.map(() => null);
|
|
94
184
|
const top = Math.min(anchor.row, current.row);
|
|
@@ -102,7 +192,7 @@ export function selectionRanges(rows, anchor, current) {
|
|
|
102
192
|
let end;
|
|
103
193
|
if (anchor.row === current.row) {
|
|
104
194
|
start = Math.min(anchor.col, current.col);
|
|
105
|
-
end = Math.max(anchor.col, current.col);
|
|
195
|
+
end = Math.max(anchor.col, current.col) + 1;
|
|
106
196
|
}
|
|
107
197
|
else if (r === anchor.row) {
|
|
108
198
|
if (anchor.row < current.row) {
|
|
@@ -111,13 +201,13 @@ export function selectionRanges(rows, anchor, current) {
|
|
|
111
201
|
}
|
|
112
202
|
else {
|
|
113
203
|
start = 0;
|
|
114
|
-
end = anchor.col;
|
|
204
|
+
end = anchor.col + 1;
|
|
115
205
|
}
|
|
116
206
|
}
|
|
117
207
|
else if (r === current.row) {
|
|
118
208
|
if (anchor.row < current.row) {
|
|
119
209
|
start = 0;
|
|
120
|
-
end = current.col;
|
|
210
|
+
end = current.col + 1;
|
|
121
211
|
}
|
|
122
212
|
else {
|
|
123
213
|
start = current.col;
|
|
@@ -128,7 +218,9 @@ export function selectionRanges(rows, anchor, current) {
|
|
|
128
218
|
start = 0;
|
|
129
219
|
end = width;
|
|
130
220
|
}
|
|
131
|
-
|
|
221
|
+
start = Math.max(0, start);
|
|
222
|
+
if (end > start)
|
|
223
|
+
result[r] = { start, end };
|
|
132
224
|
}
|
|
133
225
|
return result;
|
|
134
226
|
}
|