@unscientificjszhai/howto 1.0.0-alpha.3 → 1.0.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.CN.md +37 -5
- package/README.md +37 -5
- package/THIRD_PARTY_NOTICES.md +18 -0
- package/dist/ai/command-schema.js +2 -0
- package/dist/ai/errors.js +7 -23
- package/dist/ai/gemini.js +67 -27
- package/dist/ai/openai.js +35 -27
- package/dist/cli.js +12 -1
- package/dist/config-file.js +97 -51
- package/dist/config.js +7 -6
- package/dist/errors.js +17 -16
- package/dist/execute.js +12 -25
- package/dist/index.js +85 -97
- package/dist/init/InitializationApp.js +175 -32
- package/dist/init/index.js +35 -33
- package/dist/init/state.js +46 -23
- package/dist/prompt.js +5 -2
- package/dist/safety/dangerous-command.js +262 -93
- package/dist/shell/command-analysis.js +373 -0
- package/dist/terminal-text.js +33 -0
- package/dist/ui/App.js +84 -41
- package/dist/ui/ConfirmView.js +166 -61
- package/dist/ui/InteractiveSessionProvider.js +19 -0
- package/dist/ui/ResolvePlaceholdersView.js +131 -72
- package/dist/ui/SelectCommandView.js +73 -23
- package/dist/ui/SelectedCommandDisplay.js +11 -9
- package/dist/ui/interactive-session.js +407 -0
- package/dist/ui/paste-framing.js +105 -0
- package/dist/ui/placeholder-logic.js +109 -8
- package/dist/ui/resize-safe-output.js +73 -0
- package/dist/ui/run-interactive-command.js +55 -0
- package/dist/ui/single-line-preview.js +28 -0
- package/dist/ui/text-input.js +53 -0
- package/dist/ui/use-keyboard-input.js +12 -0
- package/dist/ui/use-paste-aware-input.js +7 -0
- package/dist/user-visible-error.js +19 -0
- package/dist/validation/ai-response.js +11 -4
- package/dist/validation/command-tool.js +25 -127
- package/dist/validation/generated-commands.js +8 -0
- package/dist/version.js +38 -0
- package/package.json +21 -16
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { toSingleLinePreview } from "./single-line-preview.js";
|
|
3
4
|
export const SelectedCommandDisplay = ({ candidate, resolvedValues, currentBuffer, }) => {
|
|
4
5
|
// Split command by {{placeholder}} pattern, keeping the delimiters
|
|
5
6
|
const parts = candidate.command.split(/({{.*?}})/);
|
|
6
7
|
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1 },
|
|
7
8
|
React.createElement(Box, null,
|
|
8
9
|
React.createElement(Text, { color: "cyan", bold: true }, "> "),
|
|
9
|
-
React.createElement(
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
React.createElement(Box, { flexGrow: 1, overflowX: "hidden" },
|
|
11
|
+
React.createElement(Text, { bold: true, wrap: "truncate-end" }, toSingleLinePreview(candidate.title)))),
|
|
12
|
+
React.createElement(Box, { marginLeft: 2, overflowX: "hidden" },
|
|
13
|
+
React.createElement(Text, { wrap: "truncate-middle" }, parts.map((part, index) => {
|
|
12
14
|
const match = part.match(/^{{(.*)}}$/);
|
|
13
15
|
if (match) {
|
|
14
16
|
const placeholderName = match[1];
|
|
15
17
|
// If it's the current buffer, show the buffer in yellow
|
|
16
18
|
if (currentBuffer && currentBuffer.name === placeholderName) {
|
|
17
|
-
return (React.createElement(Text, { key: index, color: "yellow" }, currentBuffer.value || part));
|
|
19
|
+
return (React.createElement(Text, { key: index, color: "yellow" }, toSingleLinePreview(currentBuffer.value || part)));
|
|
18
20
|
}
|
|
19
21
|
// If it's already resolved, show the value in yellow
|
|
20
22
|
if (resolvedValues.has(placeholderName)) {
|
|
21
|
-
return (React.createElement(Text, { key: index, color: "yellow" }, resolvedValues.get(placeholderName)));
|
|
23
|
+
return (React.createElement(Text, { key: index, color: "yellow" }, toSingleLinePreview(resolvedValues.get(placeholderName) ?? "")));
|
|
22
24
|
}
|
|
23
25
|
// Otherwise show the original {{placeholder}} in default gray
|
|
24
|
-
return (React.createElement(Text, { key: index, color: "gray" }, part));
|
|
26
|
+
return (React.createElement(Text, { key: index, color: "gray" }, toSingleLinePreview(part)));
|
|
25
27
|
}
|
|
26
28
|
// Normal text
|
|
27
|
-
return React.createElement(Text, { key: index }, part);
|
|
29
|
+
return React.createElement(Text, { key: index }, toSingleLinePreview(part));
|
|
28
30
|
}))),
|
|
29
|
-
React.createElement(Box, { marginLeft: 2 },
|
|
30
|
-
React.createElement(Text, { italic: true, color: "dim" }, candidate.description))));
|
|
31
|
+
React.createElement(Box, { marginLeft: 2, overflowX: "hidden" },
|
|
32
|
+
React.createElement(Text, { italic: true, color: "dim", wrap: "truncate-end" }, toSingleLinePreview(candidate.description)))));
|
|
31
33
|
};
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { ReadStream } from "node:tty";
|
|
4
|
+
import { normalizePhysicalRows, toResizeSafeOutput } from "./resize-safe-output.js";
|
|
5
|
+
import { PasteFramingFilter, PasteStartObserver } from "./paste-framing.js";
|
|
6
|
+
export const ENABLE_PASTE = "\u001b[?2004h";
|
|
7
|
+
export const DISABLE_PASTE = "\u001b[?2004l";
|
|
8
|
+
export class InteractiveSessionError extends Error {
|
|
9
|
+
constructor(message = "交互终端输入不可用。") {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "InteractiveSessionError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function inputIsUnavailable(input, expectedClose = false) {
|
|
15
|
+
return (input.errored !== null ||
|
|
16
|
+
(!expectedClose &&
|
|
17
|
+
(input.destroyed || input.closed || input.readableEnded || input.readable === false)));
|
|
18
|
+
}
|
|
19
|
+
function outputIsUnavailable(output) {
|
|
20
|
+
return (output.errored !== null ||
|
|
21
|
+
output.destroyed ||
|
|
22
|
+
output.closed ||
|
|
23
|
+
output.writableEnded ||
|
|
24
|
+
output.writableFinished ||
|
|
25
|
+
output.writable === false);
|
|
26
|
+
}
|
|
27
|
+
function assertAvailableInput(input, output) {
|
|
28
|
+
if (!input.isTTY ||
|
|
29
|
+
!output.isTTY ||
|
|
30
|
+
inputIsUnavailable(input) ||
|
|
31
|
+
outputIsUnavailable(output) ||
|
|
32
|
+
input.listenerCount("readable") > 0 ||
|
|
33
|
+
input.listenerCount("data") > 0) {
|
|
34
|
+
throw new InteractiveSessionError();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
class SessionInput extends Readable {
|
|
38
|
+
isTTY = true;
|
|
39
|
+
isRaw = true;
|
|
40
|
+
_read() { }
|
|
41
|
+
setRawMode(enabled) {
|
|
42
|
+
this.isRaw = enabled;
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
ref() {
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
unref() {
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class InteractiveSession {
|
|
53
|
+
physicalInput;
|
|
54
|
+
physicalOutput;
|
|
55
|
+
resources;
|
|
56
|
+
input;
|
|
57
|
+
output;
|
|
58
|
+
bridge = new SessionInput();
|
|
59
|
+
observer = new PasteStartObserver();
|
|
60
|
+
filter = new PasteFramingFilter();
|
|
61
|
+
decoder = new StringDecoder("utf8");
|
|
62
|
+
policyListeners = new Set();
|
|
63
|
+
failureListeners = new Set();
|
|
64
|
+
policy = "execute";
|
|
65
|
+
failure;
|
|
66
|
+
closed = false;
|
|
67
|
+
disposing = false;
|
|
68
|
+
inputAcquired = false;
|
|
69
|
+
inputCloseExpected = false;
|
|
70
|
+
inputCloseSeen = false;
|
|
71
|
+
outputCloseSeen = false;
|
|
72
|
+
inputActive = false;
|
|
73
|
+
rawAcquired = false;
|
|
74
|
+
refAcquired = false;
|
|
75
|
+
pasteAcquired = false;
|
|
76
|
+
originalRaw;
|
|
77
|
+
view;
|
|
78
|
+
viewEnabled = false;
|
|
79
|
+
prefixTimer;
|
|
80
|
+
constructor(physicalInput, physicalOutput, resources = {}) {
|
|
81
|
+
this.physicalInput = physicalInput;
|
|
82
|
+
this.physicalOutput = physicalOutput;
|
|
83
|
+
this.resources = resources;
|
|
84
|
+
this.input = this.bridge;
|
|
85
|
+
this.output = toResizeSafeOutput(this.wrapOutput());
|
|
86
|
+
this.originalRaw = resources.restoreRaw ?? physicalInput.isRaw === true;
|
|
87
|
+
try {
|
|
88
|
+
assertAvailableInput(physicalInput, physicalOutput);
|
|
89
|
+
// 校验拒绝时不能读取仍由原消费者持有的缓冲。
|
|
90
|
+
this.inputAcquired = true;
|
|
91
|
+
physicalInput.on("readable", this.drain);
|
|
92
|
+
physicalInput.on("error", this.handleFailure);
|
|
93
|
+
physicalInput.on("end", this.handleFailure);
|
|
94
|
+
physicalInput.on("close", this.handleInputClose);
|
|
95
|
+
physicalOutput.on("error", this.handleFailure);
|
|
96
|
+
physicalOutput.on("close", this.handleOutputClose);
|
|
97
|
+
physicalOutput.on("finish", this.handleFailure);
|
|
98
|
+
physicalOutput.on("resize", this.updateVisibility);
|
|
99
|
+
if (!physicalInput.isRaw) {
|
|
100
|
+
this.rawAcquired = true;
|
|
101
|
+
physicalInput.setRawMode(true);
|
|
102
|
+
this.checkTerminalState();
|
|
103
|
+
this.throwIfFailed();
|
|
104
|
+
this.refAcquired = true;
|
|
105
|
+
physicalInput.ref();
|
|
106
|
+
this.checkTerminalState();
|
|
107
|
+
this.throwIfFailed();
|
|
108
|
+
}
|
|
109
|
+
this.pasteAcquired = true;
|
|
110
|
+
physicalOutput.write(ENABLE_PASTE);
|
|
111
|
+
this.checkTerminalState();
|
|
112
|
+
this.throwIfFailed();
|
|
113
|
+
this.drain();
|
|
114
|
+
this.throwIfFailed();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
this.fail(new InteractiveSessionError());
|
|
118
|
+
throw this.failure ?? new InteractiveSessionError();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
getExecutionPolicy = () => this.policy;
|
|
122
|
+
subscribeExecutionPolicy = (listener) => {
|
|
123
|
+
this.policyListeners.add(listener);
|
|
124
|
+
return () => {
|
|
125
|
+
this.policyListeners.delete(listener);
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
subscribeFailure(listener) {
|
|
129
|
+
if (this.failure)
|
|
130
|
+
listener(this.failure);
|
|
131
|
+
else
|
|
132
|
+
this.failureListeners.add(listener);
|
|
133
|
+
return () => {
|
|
134
|
+
this.failureListeners.delete(listener);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
beginView() {
|
|
138
|
+
this.throwIfFailed();
|
|
139
|
+
if (this.closed)
|
|
140
|
+
throw new InteractiveSessionError();
|
|
141
|
+
const token = {};
|
|
142
|
+
this.suspendViewInput();
|
|
143
|
+
this.view = token;
|
|
144
|
+
this.viewEnabled = true;
|
|
145
|
+
this.updateVisibility();
|
|
146
|
+
return () => {
|
|
147
|
+
if (this.view === token) {
|
|
148
|
+
this.suspendViewInput();
|
|
149
|
+
this.view = undefined;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
suspendViewInput() {
|
|
154
|
+
this.viewEnabled = false;
|
|
155
|
+
this.setInputActive(false);
|
|
156
|
+
this.drain();
|
|
157
|
+
while (this.bridge.read() !== null) {
|
|
158
|
+
/* 丢弃旧 UI 尚未消费的输入。 */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
handoff(actions) {
|
|
162
|
+
this.throwIfFailed();
|
|
163
|
+
if (this.closed)
|
|
164
|
+
throw new InteractiveSessionError();
|
|
165
|
+
this.drain();
|
|
166
|
+
this.throwIfFailed();
|
|
167
|
+
this.dispose();
|
|
168
|
+
this.throwIfFailed();
|
|
169
|
+
// 本次检查后不经过 await,执行 action 必须同步到达实际 spawn。
|
|
170
|
+
return this.getExecutionPolicy() === "print" ? actions.print() : actions.execute();
|
|
171
|
+
}
|
|
172
|
+
dispose() {
|
|
173
|
+
if (this.closed || this.disposing)
|
|
174
|
+
return;
|
|
175
|
+
this.disposing = true;
|
|
176
|
+
const previousFailure = this.failure;
|
|
177
|
+
this.checkTerminalState();
|
|
178
|
+
this.viewEnabled = false;
|
|
179
|
+
this.setInputActive(false);
|
|
180
|
+
this.clearPrefixTimer();
|
|
181
|
+
this.filter.clear();
|
|
182
|
+
let cleanupFailed = false;
|
|
183
|
+
const cleanup = (action) => {
|
|
184
|
+
try {
|
|
185
|
+
action();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
cleanupFailed = true;
|
|
189
|
+
}
|
|
190
|
+
this.checkTerminalState();
|
|
191
|
+
};
|
|
192
|
+
if (this.pasteAcquired && !this.physicalOutput.destroyed && !this.physicalOutput.writableEnded)
|
|
193
|
+
cleanup(() => {
|
|
194
|
+
this.physicalOutput.write(DISABLE_PASTE);
|
|
195
|
+
});
|
|
196
|
+
if (this.rawAcquired)
|
|
197
|
+
cleanup(() => {
|
|
198
|
+
this.physicalInput.setRawMode(this.originalRaw);
|
|
199
|
+
});
|
|
200
|
+
if (this.refAcquired)
|
|
201
|
+
cleanup(() => {
|
|
202
|
+
this.physicalInput.unref();
|
|
203
|
+
});
|
|
204
|
+
// 恢复终端时仍保留原始观察,最后再同步排空并撤下监听。
|
|
205
|
+
this.drain();
|
|
206
|
+
this.checkTerminalState();
|
|
207
|
+
this.physicalInput.off("readable", this.drain);
|
|
208
|
+
this.physicalInput.off("end", this.handleFailure);
|
|
209
|
+
this.physicalOutput.off("resize", this.updateVisibility);
|
|
210
|
+
this.physicalOutput.off("finish", this.handleFailure);
|
|
211
|
+
if (this.resources.releaseInput) {
|
|
212
|
+
// 构造尚未取得消费权时,自有 reader 的关闭仍需要错误接收者。
|
|
213
|
+
if (!this.inputAcquired) {
|
|
214
|
+
this.physicalInput.on("error", this.handleFailure);
|
|
215
|
+
this.physicalInput.on("close", this.handleInputClose);
|
|
216
|
+
}
|
|
217
|
+
this.checkTerminalState();
|
|
218
|
+
this.inputCloseExpected = true;
|
|
219
|
+
}
|
|
220
|
+
// 只关闭会话自己的读者,同步停止物理读取后才允许 handoff 检查权限并 spawn。
|
|
221
|
+
cleanup(() => {
|
|
222
|
+
this.resources.releaseInput?.();
|
|
223
|
+
});
|
|
224
|
+
this.closed = true;
|
|
225
|
+
// destroyed/closed 可以先于排队中的 error/close 变为 true,必须等真实 close 收尾。
|
|
226
|
+
if (this.inputCloseSeen ||
|
|
227
|
+
(!this.inputCloseExpected && (!this.inputAcquired || !inputIsUnavailable(this.physicalInput)))) {
|
|
228
|
+
this.physicalInput.off("error", this.handleFailure);
|
|
229
|
+
this.physicalInput.off("close", this.handleInputClose);
|
|
230
|
+
}
|
|
231
|
+
if (this.outputCloseSeen || !this.inputAcquired || !outputIsUnavailable(this.physicalOutput)) {
|
|
232
|
+
this.physicalOutput.off("error", this.handleFailure);
|
|
233
|
+
this.physicalOutput.off("close", this.handleOutputClose);
|
|
234
|
+
}
|
|
235
|
+
this.bridge.destroy();
|
|
236
|
+
this.policyListeners.clear();
|
|
237
|
+
this.failureListeners.clear();
|
|
238
|
+
if (cleanupFailed && !this.failure) {
|
|
239
|
+
this.failure = new InteractiveSessionError("无法恢复交互终端状态。");
|
|
240
|
+
}
|
|
241
|
+
if (!previousFailure && this.failure)
|
|
242
|
+
throw this.failure;
|
|
243
|
+
}
|
|
244
|
+
throwIfFailed() {
|
|
245
|
+
if (this.failure)
|
|
246
|
+
throw this.failure;
|
|
247
|
+
}
|
|
248
|
+
fail(error) {
|
|
249
|
+
if (this.closed || this.failure)
|
|
250
|
+
return;
|
|
251
|
+
this.failure = error;
|
|
252
|
+
this.policy = "print";
|
|
253
|
+
const listeners = [...this.failureListeners];
|
|
254
|
+
this.failureListeners.clear();
|
|
255
|
+
this.dispose();
|
|
256
|
+
for (const listener of listeners)
|
|
257
|
+
listener(error);
|
|
258
|
+
}
|
|
259
|
+
handleFailure = () => {
|
|
260
|
+
this.fail(new InteractiveSessionError());
|
|
261
|
+
};
|
|
262
|
+
checkTerminalState() {
|
|
263
|
+
if (this.inputAcquired &&
|
|
264
|
+
(inputIsUnavailable(this.physicalInput, this.inputCloseExpected) ||
|
|
265
|
+
outputIsUnavailable(this.physicalOutput))) {
|
|
266
|
+
this.handleFailure();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
handleInputClose = () => {
|
|
270
|
+
this.inputCloseSeen = true;
|
|
271
|
+
if (this.closed) {
|
|
272
|
+
this.physicalInput.off("error", this.handleFailure);
|
|
273
|
+
this.physicalInput.off("close", this.handleInputClose);
|
|
274
|
+
}
|
|
275
|
+
else if (!this.inputCloseExpected)
|
|
276
|
+
this.handleFailure();
|
|
277
|
+
};
|
|
278
|
+
handleOutputClose = () => {
|
|
279
|
+
this.outputCloseSeen = true;
|
|
280
|
+
if (this.closed) {
|
|
281
|
+
this.physicalOutput.off("error", this.handleFailure);
|
|
282
|
+
this.physicalOutput.off("close", this.handleOutputClose);
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
this.handleFailure();
|
|
286
|
+
};
|
|
287
|
+
setInputActive(active) {
|
|
288
|
+
if (active !== this.inputActive) {
|
|
289
|
+
// 隐藏边界不能把未完成的 UTF-8 字符带入下一个可见输入阶段。
|
|
290
|
+
this.decoder = new StringDecoder("utf8");
|
|
291
|
+
}
|
|
292
|
+
this.inputActive = active;
|
|
293
|
+
this.filter.setActive(active);
|
|
294
|
+
}
|
|
295
|
+
updateVisibility = () => {
|
|
296
|
+
const active = this.viewEnabled && normalizePhysicalRows(this.physicalOutput.rows) > 1;
|
|
297
|
+
if (active && !this.inputActive)
|
|
298
|
+
this.drain();
|
|
299
|
+
this.setInputActive(active);
|
|
300
|
+
};
|
|
301
|
+
clearPrefixTimer() {
|
|
302
|
+
if (this.prefixTimer !== undefined)
|
|
303
|
+
clearTimeout(this.prefixTimer);
|
|
304
|
+
this.prefixTimer = undefined;
|
|
305
|
+
}
|
|
306
|
+
drain = () => {
|
|
307
|
+
if (this.closed || !this.inputAcquired)
|
|
308
|
+
return;
|
|
309
|
+
try {
|
|
310
|
+
this.clearPrefixTimer();
|
|
311
|
+
let chunk;
|
|
312
|
+
while ((chunk = this.physicalInput.read()) !== null) {
|
|
313
|
+
if (typeof chunk !== "string" && !Buffer.isBuffer(chunk))
|
|
314
|
+
throw new InteractiveSessionError();
|
|
315
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
316
|
+
if (this.observer.observe(bytes) && this.policy !== "print") {
|
|
317
|
+
this.policy = "print";
|
|
318
|
+
for (const listener of this.policyListeners)
|
|
319
|
+
listener();
|
|
320
|
+
}
|
|
321
|
+
if (normalizePhysicalRows(this.physicalOutput.rows) <= 1)
|
|
322
|
+
this.setInputActive(false);
|
|
323
|
+
const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
324
|
+
const filtered = this.filter.push(text);
|
|
325
|
+
if (filtered !== "")
|
|
326
|
+
this.bridge.push(filtered);
|
|
327
|
+
}
|
|
328
|
+
if (!this.disposing && this.filter.hasPendingKeyboardPrefix()) {
|
|
329
|
+
// 此延迟仅保留独立 Esc 的键盘语义,绝不用于恢复执行权限。
|
|
330
|
+
this.prefixTimer = setTimeout(() => {
|
|
331
|
+
this.prefixTimer = undefined;
|
|
332
|
+
if (this.closed)
|
|
333
|
+
return;
|
|
334
|
+
const text = this.filter.flushKeyboardPrefix();
|
|
335
|
+
if (text !== "")
|
|
336
|
+
this.bridge.push(text);
|
|
337
|
+
}, 20);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
this.handleFailure();
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
wrapOutput() {
|
|
345
|
+
const physical = this.physicalOutput;
|
|
346
|
+
const methods = new Map();
|
|
347
|
+
const proxy = new Proxy(physical, {
|
|
348
|
+
get: (target, property) => {
|
|
349
|
+
if (property === "write") {
|
|
350
|
+
return (chunk, ...args) => {
|
|
351
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
352
|
+
if (this.failure ||
|
|
353
|
+
target.destroyed ||
|
|
354
|
+
target.writableEnded ||
|
|
355
|
+
(!this.closed && (text === ENABLE_PASTE || text === DISABLE_PASTE))) {
|
|
356
|
+
const callback = args.find((arg) => typeof arg === "function");
|
|
357
|
+
callback?.();
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
return Reflect.apply(target.write.bind(target), target, [chunk, ...args]);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const value = Reflect.get(target, property, target);
|
|
364
|
+
if (property === "constructor" || typeof value !== "function")
|
|
365
|
+
return value;
|
|
366
|
+
if (!methods.has(property))
|
|
367
|
+
methods.set(property, (...args) => {
|
|
368
|
+
const result = Reflect.apply(value, target, args);
|
|
369
|
+
return result === target ? proxy : result;
|
|
370
|
+
});
|
|
371
|
+
return methods.get(property);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
return proxy;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function createInteractiveSession(options) {
|
|
378
|
+
if (options.input !== process.stdin) {
|
|
379
|
+
return new InteractiveSession(options.input, options.output);
|
|
380
|
+
}
|
|
381
|
+
// 仅供内部 CLI 的冷 stdin 路径;这些状态不能认证任意外部代码的完整读取历史。
|
|
382
|
+
assertAvailableInput(options.input, options.output);
|
|
383
|
+
if (process.stdin.fd !== 0 ||
|
|
384
|
+
options.input.readableFlowing !== null ||
|
|
385
|
+
options.input.readableDidRead ||
|
|
386
|
+
options.input.readableLength > 0) {
|
|
387
|
+
throw new InteractiveSessionError();
|
|
388
|
+
}
|
|
389
|
+
const restoreRaw = options.input.isRaw === true;
|
|
390
|
+
let reader;
|
|
391
|
+
let released = false;
|
|
392
|
+
const releaseInput = () => {
|
|
393
|
+
if (released)
|
|
394
|
+
return;
|
|
395
|
+
released = true;
|
|
396
|
+
reader?.destroy();
|
|
397
|
+
};
|
|
398
|
+
try {
|
|
399
|
+
// 标准 fd 0 保留给原 stdin 和子进程;该读者的生命周期只属于本会话。
|
|
400
|
+
reader = new ReadStream(0);
|
|
401
|
+
return new InteractiveSession(reader, options.output, { restoreRaw, releaseInput });
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
releaseInput();
|
|
405
|
+
throw new InteractiveSessionError();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
|
|
2
|
+
export const PASTE_START = "\u001b[200~";
|
|
3
|
+
export const PASTE_END = "\u001b[201~";
|
|
4
|
+
/** 权限观察不受键盘解析、隐藏状态或组件生命周期影响。 */
|
|
5
|
+
export class PasteStartObserver {
|
|
6
|
+
suffix = Buffer.alloc(0);
|
|
7
|
+
observe(chunk) {
|
|
8
|
+
const combined = Buffer.concat([this.suffix, chunk]);
|
|
9
|
+
const found = combined.includes(Buffer.from(PASTE_START));
|
|
10
|
+
this.suffix = Buffer.from(combined.subarray(Math.max(0, combined.length - 5)));
|
|
11
|
+
return found;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** 只交付完整的可见粘贴;按解码后的字符检查 C1,避免误拒绝 UTF-8 延续字节。 */
|
|
15
|
+
export class PasteFramingFilter {
|
|
16
|
+
active = false;
|
|
17
|
+
pending = "";
|
|
18
|
+
flushedPrefix = 0;
|
|
19
|
+
invalidPrefix = false;
|
|
20
|
+
collecting = false;
|
|
21
|
+
discardPaste = false;
|
|
22
|
+
paste = "";
|
|
23
|
+
setActive(active) {
|
|
24
|
+
this.active = active;
|
|
25
|
+
if (!active) {
|
|
26
|
+
this.invalidPrefix = true;
|
|
27
|
+
if (this.collecting) {
|
|
28
|
+
this.discardPaste = true;
|
|
29
|
+
this.paste = "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
push(text) {
|
|
34
|
+
let output = "";
|
|
35
|
+
for (const character of text) {
|
|
36
|
+
if (this.pending === "" && !this.collecting)
|
|
37
|
+
this.invalidPrefix = !this.active;
|
|
38
|
+
this.pending += character;
|
|
39
|
+
if (!this.active)
|
|
40
|
+
this.invalidPrefix = true;
|
|
41
|
+
if (this.collecting) {
|
|
42
|
+
while (this.pending !== "" && !PASTE_END.startsWith(this.pending)) {
|
|
43
|
+
if (!this.discardPaste)
|
|
44
|
+
this.paste += this.pending[0];
|
|
45
|
+
this.pending = this.pending.slice(1);
|
|
46
|
+
}
|
|
47
|
+
if (this.pending === PASTE_END) {
|
|
48
|
+
if (this.active &&
|
|
49
|
+
!this.discardPaste &&
|
|
50
|
+
!hasUnsafeTerminalControlCharacters(this.paste)) {
|
|
51
|
+
output += PASTE_START + this.paste + PASTE_END;
|
|
52
|
+
}
|
|
53
|
+
this.collecting = false;
|
|
54
|
+
this.paste = "";
|
|
55
|
+
this.pending = "";
|
|
56
|
+
this.invalidPrefix = false;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
while (this.pending !== "" &&
|
|
61
|
+
!PASTE_START.startsWith(this.pending) &&
|
|
62
|
+
!PASTE_END.startsWith(this.pending)) {
|
|
63
|
+
if (this.flushedPrefix > 0)
|
|
64
|
+
this.flushedPrefix--;
|
|
65
|
+
else if (this.active && !this.invalidPrefix)
|
|
66
|
+
output += this.pending[0];
|
|
67
|
+
this.pending = this.pending.slice(1);
|
|
68
|
+
if (this.pending === "")
|
|
69
|
+
this.invalidPrefix = !this.active;
|
|
70
|
+
}
|
|
71
|
+
if (this.pending === PASTE_START) {
|
|
72
|
+
this.collecting = true;
|
|
73
|
+
this.discardPaste = !this.active || this.invalidPrefix || this.flushedPrefix > 0;
|
|
74
|
+
this.pending = "";
|
|
75
|
+
this.flushedPrefix = 0;
|
|
76
|
+
this.paste = "";
|
|
77
|
+
}
|
|
78
|
+
else if (this.pending === PASTE_END) {
|
|
79
|
+
// 孤立的协议结束标记不作为表单文字交付。
|
|
80
|
+
this.pending = "";
|
|
81
|
+
this.flushedPrefix = 0;
|
|
82
|
+
this.invalidPrefix = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return output;
|
|
86
|
+
}
|
|
87
|
+
hasPendingKeyboardPrefix() {
|
|
88
|
+
return !this.collecting && this.pending.length > this.flushedPrefix;
|
|
89
|
+
}
|
|
90
|
+
flushKeyboardPrefix() {
|
|
91
|
+
if (this.collecting)
|
|
92
|
+
return "";
|
|
93
|
+
const result = this.active && !this.invalidPrefix ? this.pending.slice(this.flushedPrefix) : "";
|
|
94
|
+
// 刷新只影响键盘体验,保留完整标记匹配状态;权限观察器也完全独立。
|
|
95
|
+
this.flushedPrefix = this.pending.length;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
clear() {
|
|
99
|
+
this.pending = "";
|
|
100
|
+
this.paste = "";
|
|
101
|
+
this.flushedPrefix = 0;
|
|
102
|
+
this.collecting = false;
|
|
103
|
+
this.active = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,19 +1,120 @@
|
|
|
1
|
-
|
|
1
|
+
import { deleteLastGrapheme } from "./text-input.js";
|
|
2
|
+
const PLACEHOLDER_REFERENCE_PATTERN = /{{([^{}]*)}}/g;
|
|
2
3
|
export class PlaceholderResolutionError extends Error {
|
|
3
4
|
constructor(message) {
|
|
4
5
|
super(message);
|
|
5
6
|
this.name = "PlaceholderResolutionError";
|
|
6
7
|
}
|
|
7
8
|
}
|
|
9
|
+
export function createPlaceholderResolution(candidate) {
|
|
10
|
+
if (candidate.placeholders.length === 0) {
|
|
11
|
+
throw new PlaceholderResolutionError("cannot resolve command without placeholders");
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
candidate,
|
|
15
|
+
activeIndex: 0,
|
|
16
|
+
values: candidate.placeholders.map(() => ""),
|
|
17
|
+
buffer: "",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function getPlaceholderResolutionView(state) {
|
|
21
|
+
const currentPlaceholder = state.candidate.placeholders[state.activeIndex];
|
|
22
|
+
if (!currentPlaceholder) {
|
|
23
|
+
throw new PlaceholderResolutionError("placeholder resolution state is out of bounds");
|
|
24
|
+
}
|
|
25
|
+
const resolvedValues = new Map();
|
|
26
|
+
state.candidate.placeholders.forEach((placeholder, index) => {
|
|
27
|
+
if (index < state.activeIndex) {
|
|
28
|
+
resolvedValues.set(placeholder.name, state.values[index]);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
currentPlaceholder,
|
|
33
|
+
currentBuffer: {
|
|
34
|
+
name: currentPlaceholder.name,
|
|
35
|
+
value: state.buffer,
|
|
36
|
+
},
|
|
37
|
+
resolvedValues,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function applyPlaceholderResolutionInput(state, input) {
|
|
41
|
+
switch (input.type) {
|
|
42
|
+
case "append":
|
|
43
|
+
return {
|
|
44
|
+
type: "editing",
|
|
45
|
+
state: {
|
|
46
|
+
...state,
|
|
47
|
+
buffer: state.buffer + input.value,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
case "delete":
|
|
51
|
+
return {
|
|
52
|
+
type: "editing",
|
|
53
|
+
state: {
|
|
54
|
+
...state,
|
|
55
|
+
buffer: deleteLastGrapheme(state.buffer),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
case "escape":
|
|
59
|
+
return applyEscape(state);
|
|
60
|
+
case "commit":
|
|
61
|
+
return applyCommit(state);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function resolveCandidatePlaceholders(candidate, values) {
|
|
65
|
+
const command = replaceCommandPlaceholders(candidate.command, values);
|
|
66
|
+
return {
|
|
67
|
+
candidate,
|
|
68
|
+
values,
|
|
69
|
+
command,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
8
72
|
export function replaceCommandPlaceholders(command, values) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
73
|
+
return command.replace(PLACEHOLDER_REFERENCE_PATTERN, (_reference, name) => {
|
|
74
|
+
if (!values.has(name)) {
|
|
75
|
+
throw new PlaceholderResolutionError("final command contains unresolved placeholders");
|
|
76
|
+
}
|
|
77
|
+
return values.get(name) ?? "";
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function applyEscape(state) {
|
|
81
|
+
if (state.activeIndex === 0) {
|
|
82
|
+
return { type: "back-to-selection" };
|
|
12
83
|
}
|
|
13
|
-
|
|
84
|
+
const previousIndex = state.activeIndex - 1;
|
|
85
|
+
const values = [...state.values];
|
|
86
|
+
values[state.activeIndex] = "";
|
|
87
|
+
return {
|
|
88
|
+
type: "editing",
|
|
89
|
+
state: {
|
|
90
|
+
...state,
|
|
91
|
+
activeIndex: previousIndex,
|
|
92
|
+
values,
|
|
93
|
+
buffer: values[previousIndex],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
14
96
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
97
|
+
function applyCommit(state) {
|
|
98
|
+
const values = [...state.values];
|
|
99
|
+
values[state.activeIndex] = state.buffer;
|
|
100
|
+
if (state.activeIndex === state.candidate.placeholders.length - 1) {
|
|
101
|
+
const resolvedValues = new Map();
|
|
102
|
+
state.candidate.placeholders.forEach((placeholder, index) => {
|
|
103
|
+
resolvedValues.set(placeholder.name, values[index]);
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
type: "complete",
|
|
107
|
+
resolved: resolveCandidatePlaceholders(state.candidate, resolvedValues),
|
|
108
|
+
};
|
|
18
109
|
}
|
|
110
|
+
const nextIndex = state.activeIndex + 1;
|
|
111
|
+
return {
|
|
112
|
+
type: "editing",
|
|
113
|
+
state: {
|
|
114
|
+
...state,
|
|
115
|
+
activeIndex: nextIndex,
|
|
116
|
+
values,
|
|
117
|
+
buffer: values[nextIndex],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
19
120
|
}
|