min-agent 0.2.1 → 0.4.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 +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- 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 +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- 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 +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- 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/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-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/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 +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/tui/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { App } from "./App.js";
|
|
4
|
+
import { createCaretAwareStdout, setCaretPosition } from "./caret.js";
|
|
5
|
+
import { mergeToolChild } from "./tool-children.js";
|
|
6
|
+
import { normalizeQuestionOptions, normalizeQuestionPrompt } from "../question-format.js";
|
|
4
7
|
/**
|
|
5
8
|
* TUI renderer — manages the Ink app lifecycle and provides
|
|
6
9
|
* imperative methods to update state from the agent loop.
|
|
@@ -10,84 +13,239 @@ export class TuiRenderer {
|
|
|
10
13
|
rerender = null;
|
|
11
14
|
inkInstance = null;
|
|
12
15
|
callbacks;
|
|
13
|
-
|
|
16
|
+
pendingDelta = "";
|
|
17
|
+
deltaTimer = null;
|
|
18
|
+
pendingThinking = "";
|
|
19
|
+
thinkingTimer = null;
|
|
20
|
+
copyNoticeTimer = null;
|
|
21
|
+
constructor(callbacks, model, sessionId) {
|
|
14
22
|
this.state = {
|
|
15
23
|
messages: [],
|
|
16
24
|
isRunning: false,
|
|
17
25
|
model,
|
|
26
|
+
sessionId,
|
|
18
27
|
};
|
|
19
28
|
this.callbacks = callbacks;
|
|
20
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
|
+
}
|
|
21
33
|
start() {
|
|
22
|
-
this.inkInstance = render(
|
|
23
|
-
// Position cursor at bottom-left for IME
|
|
24
|
-
const rows = process.stdout.rows ?? 24;
|
|
25
|
-
process.stdout.write(`\x1b[${rows};3H`);
|
|
34
|
+
this.inkInstance = render(this.appElement(), { exitOnCtrlC: false, stdout: createCaretAwareStdout(process.stdout) });
|
|
26
35
|
}
|
|
27
|
-
/** Update state and trigger re-render */
|
|
36
|
+
/** Update state and trigger re-render — always replaces the reference so React sees a new prop. */
|
|
28
37
|
update(partial) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.inkInstance.rerender(_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onExit: this.callbacks.onExit }));
|
|
33
|
-
// Move terminal cursor to bottom-left (input bar area) for IME positioning
|
|
34
|
-
const rows = process.stdout.rows ?? 24;
|
|
35
|
-
process.stdout.write(`\x1b[${rows};3H`);
|
|
36
|
-
}
|
|
38
|
+
this.state = { ...this.state, ...partial };
|
|
39
|
+
if (this.inkInstance)
|
|
40
|
+
this.inkInstance.rerender(this.appElement());
|
|
37
41
|
}
|
|
38
42
|
addMessage(msg) {
|
|
43
|
+
this.flushPendingDeltas();
|
|
44
|
+
this.flushPendingThinking();
|
|
39
45
|
this.state.messages = [...this.state.messages, msg];
|
|
40
46
|
this.update({});
|
|
41
47
|
}
|
|
42
|
-
/**
|
|
48
|
+
/** Number of messages currently rendered (used by /undo). */
|
|
49
|
+
messageCount() {
|
|
50
|
+
return this.state.messages.length;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Attach a tool result to the row created by the matching tool call. Falls
|
|
54
|
+
* back to the newest still-pending row of the same tool when the id is
|
|
55
|
+
* unknown (e.g. a provider that does not echo it back).
|
|
56
|
+
*/
|
|
57
|
+
setToolResult(toolCallId, toolName, summary, full, isError) {
|
|
58
|
+
const msgs = [...this.state.messages];
|
|
59
|
+
let idx = msgs.findIndex((m) => m.role === "tool" && m.toolCallId === toolCallId);
|
|
60
|
+
if (idx === -1) {
|
|
61
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
62
|
+
const m = msgs[i];
|
|
63
|
+
if (m.role === "tool" && m.toolName === toolName && m.toolResult === undefined) {
|
|
64
|
+
idx = i;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (idx === -1)
|
|
70
|
+
return;
|
|
71
|
+
msgs[idx] = { ...msgs[idx], toolResultSummary: summary, toolResult: full, toolIsError: isError };
|
|
72
|
+
this.state.messages = msgs;
|
|
73
|
+
this.update({});
|
|
74
|
+
}
|
|
75
|
+
/** Keep only the first `count` messages (used by /undo). */
|
|
76
|
+
truncateMessages(count) {
|
|
77
|
+
this.state.messages = this.state.messages.slice(0, count);
|
|
78
|
+
this.update({});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Append text to the last assistant message (for streaming).
|
|
82
|
+
* Deltas are coalesced (50ms) so a burst of stream tokens renders as a
|
|
83
|
+
* handful of frames instead of one per token; flushed when the run ends.
|
|
84
|
+
*/
|
|
43
85
|
appendToLast(delta) {
|
|
86
|
+
this.pendingDelta += delta;
|
|
87
|
+
if (this.deltaTimer !== null)
|
|
88
|
+
return;
|
|
89
|
+
this.deltaTimer = setTimeout(() => {
|
|
90
|
+
this.deltaTimer = null;
|
|
91
|
+
this.applyPendingDelta();
|
|
92
|
+
}, 16);
|
|
93
|
+
}
|
|
94
|
+
appendToThinking(delta) {
|
|
95
|
+
this.pendingThinking += delta;
|
|
96
|
+
if (this.thinkingTimer !== null)
|
|
97
|
+
return;
|
|
98
|
+
this.thinkingTimer = setTimeout(() => {
|
|
99
|
+
this.thinkingTimer = null;
|
|
100
|
+
this.applyPendingThinking();
|
|
101
|
+
}, 16);
|
|
102
|
+
}
|
|
103
|
+
applyPendingThinking() {
|
|
104
|
+
const accumulated = this.pendingThinking;
|
|
105
|
+
this.pendingThinking = "";
|
|
106
|
+
if (!accumulated)
|
|
107
|
+
return;
|
|
44
108
|
const msgs = [...this.state.messages];
|
|
45
109
|
const last = msgs[msgs.length - 1];
|
|
46
|
-
if (last && last.role === "
|
|
47
|
-
last.content
|
|
110
|
+
if (last && last.role === "thinking") {
|
|
111
|
+
msgs[msgs.length - 1] = { ...last, content: last.content + accumulated };
|
|
48
112
|
this.state.messages = msgs;
|
|
49
113
|
this.update({});
|
|
50
114
|
}
|
|
51
115
|
else {
|
|
52
116
|
this.addMessage({
|
|
53
|
-
id: `
|
|
54
|
-
role: "
|
|
55
|
-
content:
|
|
117
|
+
id: `thinking-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
118
|
+
role: "thinking",
|
|
119
|
+
content: accumulated,
|
|
56
120
|
timestamp: Date.now(),
|
|
57
121
|
});
|
|
58
122
|
}
|
|
59
123
|
}
|
|
60
|
-
|
|
61
|
-
|
|
124
|
+
flushPendingThinking() {
|
|
125
|
+
if (this.thinkingTimer !== null) {
|
|
126
|
+
clearTimeout(this.thinkingTimer);
|
|
127
|
+
this.thinkingTimer = null;
|
|
128
|
+
}
|
|
129
|
+
this.applyPendingThinking();
|
|
130
|
+
}
|
|
131
|
+
applyPendingDelta() {
|
|
132
|
+
const accumulated = this.pendingDelta;
|
|
133
|
+
this.pendingDelta = "";
|
|
134
|
+
if (!accumulated)
|
|
135
|
+
return;
|
|
62
136
|
const msgs = [...this.state.messages];
|
|
63
137
|
const last = msgs[msgs.length - 1];
|
|
64
|
-
if (last && last.role === "
|
|
65
|
-
last.content
|
|
138
|
+
if (last && last.role === "assistant") {
|
|
139
|
+
msgs[msgs.length - 1] = { ...last, content: last.content + accumulated };
|
|
66
140
|
this.state.messages = msgs;
|
|
67
141
|
this.update({});
|
|
68
142
|
}
|
|
69
|
-
else {
|
|
143
|
+
else if (accumulated.trim() !== "") {
|
|
70
144
|
this.addMessage({
|
|
71
|
-
id: `
|
|
72
|
-
role: "
|
|
73
|
-
content:
|
|
145
|
+
id: `msg-${Date.now()}`,
|
|
146
|
+
role: "assistant",
|
|
147
|
+
content: accumulated,
|
|
74
148
|
timestamp: Date.now(),
|
|
75
149
|
});
|
|
76
150
|
}
|
|
77
151
|
}
|
|
152
|
+
/** Apply any coalesced delta immediately (run end, new message, exit). */
|
|
153
|
+
flushPendingDeltas() {
|
|
154
|
+
if (this.deltaTimer !== null) {
|
|
155
|
+
clearTimeout(this.deltaTimer);
|
|
156
|
+
this.deltaTimer = null;
|
|
157
|
+
}
|
|
158
|
+
this.applyPendingDelta();
|
|
159
|
+
}
|
|
78
160
|
setRunning(running, spinnerText) {
|
|
161
|
+
if (!running) {
|
|
162
|
+
this.flushPendingDeltas();
|
|
163
|
+
this.flushPendingThinking();
|
|
164
|
+
}
|
|
165
|
+
if (this.state.isRunning === running && this.state.spinnerText === spinnerText)
|
|
166
|
+
return;
|
|
79
167
|
this.update({ isRunning: running, spinnerText });
|
|
80
168
|
}
|
|
81
169
|
showConfirm(message) {
|
|
82
|
-
this.update({ confirmMessage: message });
|
|
170
|
+
this.update({ confirmMessage: message, selectionMode: false });
|
|
83
171
|
}
|
|
84
172
|
hideConfirm() {
|
|
85
173
|
this.update({ confirmMessage: undefined });
|
|
86
174
|
}
|
|
87
|
-
|
|
88
|
-
|
|
175
|
+
showQuestion(prompt, options) {
|
|
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
|
+
});
|
|
184
|
+
}
|
|
185
|
+
hideQuestion() {
|
|
186
|
+
this.update({ question: undefined });
|
|
187
|
+
}
|
|
188
|
+
showModelPicker() {
|
|
189
|
+
this.update({ modelPicker: true, selectionMode: false });
|
|
190
|
+
}
|
|
191
|
+
hideModelPicker() {
|
|
192
|
+
this.update({ modelPicker: false });
|
|
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
|
+
}
|
|
213
|
+
setModel(model) {
|
|
214
|
+
this.update({ model });
|
|
215
|
+
}
|
|
216
|
+
setTokenInfo(info) {
|
|
217
|
+
this.update({ tokenInfo: info });
|
|
218
|
+
}
|
|
219
|
+
/** Flash a transient copy confirmation in the status bar (2.5s). */
|
|
220
|
+
showCopyNotice(text) {
|
|
221
|
+
this.update({ copyNotice: text });
|
|
222
|
+
if (this.copyNoticeTimer !== null)
|
|
223
|
+
clearTimeout(this.copyNoticeTimer);
|
|
224
|
+
this.copyNoticeTimer = setTimeout(() => {
|
|
225
|
+
this.copyNoticeTimer = null;
|
|
226
|
+
this.update({ copyNotice: undefined });
|
|
227
|
+
}, 2500);
|
|
89
228
|
}
|
|
90
229
|
destroy() {
|
|
230
|
+
this.flushPendingDeltas();
|
|
231
|
+
this.flushPendingThinking();
|
|
232
|
+
if (this.copyNoticeTimer !== null) {
|
|
233
|
+
clearTimeout(this.copyNoticeTimer);
|
|
234
|
+
this.copyNoticeTimer = null;
|
|
235
|
+
}
|
|
236
|
+
if (this.deltaTimer !== null) {
|
|
237
|
+
clearTimeout(this.deltaTimer);
|
|
238
|
+
this.deltaTimer = null;
|
|
239
|
+
}
|
|
240
|
+
if (this.thinkingTimer !== null) {
|
|
241
|
+
clearTimeout(this.thinkingTimer);
|
|
242
|
+
this.thinkingTimer = null;
|
|
243
|
+
}
|
|
244
|
+
setCaretPosition(null);
|
|
245
|
+
try {
|
|
246
|
+
process.stdout.write("\x1b[?1000l\x1b[?1002l\x1b[?1006l");
|
|
247
|
+
}
|
|
248
|
+
catch { }
|
|
91
249
|
this.inkInstance?.unmount();
|
|
92
250
|
this.inkInstance = null;
|
|
93
251
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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);
|
|
61
|
+
}
|
|
62
|
+
export function pushInput(state, text) {
|
|
63
|
+
if (!text)
|
|
64
|
+
return state;
|
|
65
|
+
if (state.entries.length > 0 && state.entries[state.entries.length - 1] === text)
|
|
66
|
+
return state;
|
|
67
|
+
const entries = [...state.entries, text];
|
|
68
|
+
if (entries.length > HISTORY_LIMIT)
|
|
69
|
+
entries.splice(0, entries.length - HISTORY_LIMIT);
|
|
70
|
+
return { entries, index: -1, draft: "" };
|
|
71
|
+
}
|
|
72
|
+
export function searchHistory(state, query) {
|
|
73
|
+
const q = query.toLowerCase();
|
|
74
|
+
if (!q)
|
|
75
|
+
return null;
|
|
76
|
+
for (let i = state.entries.length - 1; i >= 0; i--) {
|
|
77
|
+
if (state.entries[i].toLowerCase().includes(q))
|
|
78
|
+
return state.entries[i];
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function browseHistory(state, direction, skip) {
|
|
83
|
+
const count = state.entries.length;
|
|
84
|
+
if (count === 0)
|
|
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));
|
|
89
|
+
if (direction === 1) {
|
|
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 };
|
|
98
|
+
}
|
|
99
|
+
if (current === -1)
|
|
100
|
+
return { text: state.draft, state };
|
|
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 } };
|
|
106
|
+
}
|
|
107
|
+
export function resetHistory(state, text) {
|
|
108
|
+
if (state.index === -1 && state.draft === text)
|
|
109
|
+
return state;
|
|
110
|
+
return { entries: state.entries, index: -1, draft: text };
|
|
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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const BOX_CHROME = 4;
|
|
2
|
+
export const GUTTER_WIDTH = 2;
|
|
3
|
+
export const TEXT_START_COLUMN = 5;
|
|
4
|
+
export const STATUS_BAR_ROWS = 2;
|
|
5
|
+
export const 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
|
+
}
|
|
30
|
+
export function innerTextWidth(columns) {
|
|
31
|
+
return Math.max(1, columns - 5);
|
|
32
|
+
}
|
|
33
|
+
export function messageTextWidth(columns) {
|
|
34
|
+
return Math.max(1, columns - MESSAGE_PAD_LEFT - COL_WRAP_GUARD);
|
|
35
|
+
}
|
|
36
|
+
export function inputTextWidth(columns) {
|
|
37
|
+
return Math.max(8, columns - BOX_CHROME - GUTTER_WIDTH);
|
|
38
|
+
}
|
|
39
|
+
export function computeMessageMaxHeight(frameRowCount, footerRows, spinnerRows) {
|
|
40
|
+
if (frameRowCount <= 0)
|
|
41
|
+
return undefined;
|
|
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
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enable button-event tracking (1000), drag motion while a button is held
|
|
3
|
+
* (1002) with SGR coordinates (1006). 1002 reports press/release plus motion
|
|
4
|
+
* events while dragging, which the message list uses for text selection.
|
|
5
|
+
*/
|
|
6
|
+
export const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
7
|
+
/** Disable button-event tracking. */
|
|
8
|
+
export const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1002l\x1b[?1006l";
|
|
9
|
+
const SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
10
|
+
export function parseSgrMouse(input) {
|
|
11
|
+
const match = input.match(SGR_RE);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
button: parseInt(match[1], 10),
|
|
16
|
+
col: parseInt(match[2], 10),
|
|
17
|
+
row: parseInt(match[3], 10),
|
|
18
|
+
press: match[4] === "M",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Wheel-up / wheel-down button codes in xterm button-event tracking (1000/1006). */
|
|
22
|
+
export const WHEEL_UP = 64;
|
|
23
|
+
export const WHEEL_DOWN = 65;
|
|
24
|
+
/** Motion events (drag) add this to the button code (SGR, mode 1002/1003). */
|
|
25
|
+
export const MOTION = 32;
|
|
26
|
+
/** Modifier flags share the button code's low bits; Shift=4, Meta=8, Ctrl=16. Keep Ctrl so drag isn't mis-classified as "plain". */
|
|
27
|
+
export const MOD_MASK = 31;
|
|
28
|
+
/**
|
|
29
|
+
* Parse the first complete SGR mouse event in a raw stdin buffer, returning
|
|
30
|
+
* the event and the buffer remainder so the caller can process a stream of
|
|
31
|
+
* events without re-reading consumed bytes.
|
|
32
|
+
*/
|
|
33
|
+
export function scanSgrMouse(buffer) {
|
|
34
|
+
const match = buffer.match(SGR_RE);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
event: {
|
|
39
|
+
button: parseInt(match[1], 10),
|
|
40
|
+
col: parseInt(match[2], 10),
|
|
41
|
+
row: parseInt(match[3], 10),
|
|
42
|
+
press: match[4] === "M",
|
|
43
|
+
},
|
|
44
|
+
rest: buffer.slice((match.index ?? 0) + match[0].length),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,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
|
+
}
|