@unscientificjszhai/howto 1.0.0 → 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.
Files changed (40) hide show
  1. package/README.CN.md +32 -3
  2. package/README.md +32 -3
  3. package/THIRD_PARTY_NOTICES.md +5 -4
  4. package/dist/ai/errors.js +7 -23
  5. package/dist/ai/gemini.js +67 -27
  6. package/dist/ai/openai.js +35 -27
  7. package/dist/cli.js +12 -1
  8. package/dist/config-file.js +97 -51
  9. package/dist/config.js +7 -6
  10. package/dist/errors.js +17 -16
  11. package/dist/execute.js +12 -25
  12. package/dist/index.js +84 -95
  13. package/dist/init/InitializationApp.js +175 -32
  14. package/dist/init/index.js +35 -33
  15. package/dist/init/state.js +46 -23
  16. package/dist/prompt.js +4 -2
  17. package/dist/safety/dangerous-command.js +262 -93
  18. package/dist/shell/command-analysis.js +373 -0
  19. package/dist/terminal-text.js +33 -0
  20. package/dist/ui/App.js +64 -25
  21. package/dist/ui/ConfirmView.js +163 -67
  22. package/dist/ui/InteractiveSessionProvider.js +19 -0
  23. package/dist/ui/ResolvePlaceholdersView.js +107 -27
  24. package/dist/ui/SelectCommandView.js +73 -23
  25. package/dist/ui/SelectedCommandDisplay.js +11 -9
  26. package/dist/ui/interactive-session.js +407 -0
  27. package/dist/ui/paste-framing.js +105 -0
  28. package/dist/ui/placeholder-logic.js +28 -16
  29. package/dist/ui/resize-safe-output.js +73 -0
  30. package/dist/ui/run-interactive-command.js +55 -0
  31. package/dist/ui/single-line-preview.js +28 -0
  32. package/dist/ui/text-input.js +53 -0
  33. package/dist/ui/use-keyboard-input.js +12 -0
  34. package/dist/ui/use-paste-aware-input.js +7 -0
  35. package/dist/user-visible-error.js +19 -0
  36. package/dist/validation/ai-response.js +11 -4
  37. package/dist/validation/command-tool.js +25 -127
  38. package/dist/validation/generated-commands.js +5 -16
  39. package/dist/version.js +38 -0
  40. package/package.json +18 -15
@@ -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,4 +1,5 @@
1
- const UNRESOLVED_PLACEHOLDER_PATTERN = /{{[^{}]*}}/;
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);
@@ -41,12 +42,18 @@ export function applyPlaceholderResolutionInput(state, input) {
41
42
  case "append":
42
43
  return {
43
44
  type: "editing",
44
- state: Object.assign(Object.assign({}, state), { buffer: state.buffer + input.value }),
45
+ state: {
46
+ ...state,
47
+ buffer: state.buffer + input.value,
48
+ },
45
49
  };
46
50
  case "delete":
47
51
  return {
48
52
  type: "editing",
49
- state: Object.assign(Object.assign({}, state), { buffer: state.buffer.slice(0, -1) }),
53
+ state: {
54
+ ...state,
55
+ buffer: deleteLastGrapheme(state.buffer),
56
+ },
50
57
  };
51
58
  case "escape":
52
59
  return applyEscape(state);
@@ -56,7 +63,6 @@ export function applyPlaceholderResolutionInput(state, input) {
56
63
  }
57
64
  export function resolveCandidatePlaceholders(candidate, values) {
58
65
  const command = replaceCommandPlaceholders(candidate.command, values);
59
- assertNoUnresolvedPlaceholders(command);
60
66
  return {
61
67
  candidate,
62
68
  values,
@@ -64,16 +70,12 @@ export function resolveCandidatePlaceholders(candidate, values) {
64
70
  };
65
71
  }
66
72
  export function replaceCommandPlaceholders(command, values) {
67
- let resolvedCommand = command;
68
- for (const [name, value] of values) {
69
- resolvedCommand = resolvedCommand.split(`{{${name}}}`).join(value);
70
- }
71
- return resolvedCommand;
72
- }
73
- export function assertNoUnresolvedPlaceholders(command) {
74
- if (UNRESOLVED_PLACEHOLDER_PATTERN.test(command)) {
75
- throw new PlaceholderResolutionError("final command contains unresolved placeholders");
76
- }
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
+ });
77
79
  }
78
80
  function applyEscape(state) {
79
81
  if (state.activeIndex === 0) {
@@ -84,7 +86,12 @@ function applyEscape(state) {
84
86
  values[state.activeIndex] = "";
85
87
  return {
86
88
  type: "editing",
87
- state: Object.assign(Object.assign({}, state), { activeIndex: previousIndex, values, buffer: values[previousIndex] }),
89
+ state: {
90
+ ...state,
91
+ activeIndex: previousIndex,
92
+ values,
93
+ buffer: values[previousIndex],
94
+ },
88
95
  };
89
96
  }
90
97
  function applyCommit(state) {
@@ -103,6 +110,11 @@ function applyCommit(state) {
103
110
  const nextIndex = state.activeIndex + 1;
104
111
  return {
105
112
  type: "editing",
106
- state: Object.assign(Object.assign({}, state), { activeIndex: nextIndex, values, buffer: values[nextIndex] }),
113
+ state: {
114
+ ...state,
115
+ activeIndex: nextIndex,
116
+ values,
117
+ buffer: values[nextIndex],
118
+ },
107
119
  };
108
120
  }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useStdout } from "ink";
3
+ const SAFE_INK_ROWS = Number.MAX_SAFE_INTEGER;
4
+ const DEFAULT_PHYSICAL_ROWS = 24;
5
+ const physicalToProxy = new WeakMap();
6
+ const proxyToPhysical = new WeakMap();
7
+ export function toResizeSafeOutput(output) {
8
+ if (proxyToPhysical.has(output)) {
9
+ return output;
10
+ }
11
+ const existingProxy = physicalToProxy.get(output);
12
+ if (existingProxy) {
13
+ return existingProxy;
14
+ }
15
+ const wrappedFunctions = new Map();
16
+ const proxy = new Proxy(output, {
17
+ get(target, property) {
18
+ if (property === "rows") {
19
+ return SAFE_INK_ROWS;
20
+ }
21
+ if (property === "constructor") {
22
+ const constructor = Reflect.get(target, property, target);
23
+ return constructor;
24
+ }
25
+ const value = Reflect.get(target, property, target);
26
+ if (typeof value !== "function") {
27
+ return value;
28
+ }
29
+ if (wrappedFunctions.has(property)) {
30
+ return wrappedFunctions.get(property);
31
+ }
32
+ // Stream 方法必须绑定物理流,确保 EventEmitter 的 this 与监听移除语义保持不变。
33
+ const streamMethod = value;
34
+ const wrappedFunction = (...arguments_) => {
35
+ const result = Reflect.apply(streamMethod, target, arguments_);
36
+ return result === target ? proxy : result;
37
+ };
38
+ wrappedFunctions.set(property, wrappedFunction);
39
+ return wrappedFunction;
40
+ },
41
+ });
42
+ physicalToProxy.set(output, proxy);
43
+ proxyToPhysical.set(proxy, output);
44
+ return proxy;
45
+ }
46
+ export function unwrapResizeSafeOutput(output) {
47
+ return proxyToPhysical.get(output) ?? output;
48
+ }
49
+ export function usePhysicalStdoutRows() {
50
+ const { stdout } = useStdout();
51
+ const physicalStdout = unwrapResizeSafeOutput(stdout);
52
+ const [rows, setRows] = useState(() => readPhysicalRows(physicalStdout));
53
+ useEffect(() => {
54
+ const updateRows = () => {
55
+ setRows(readPhysicalRows(physicalStdout));
56
+ };
57
+ physicalStdout.on("resize", updateRows);
58
+ updateRows();
59
+ return () => {
60
+ physicalStdout.off("resize", updateRows);
61
+ };
62
+ }, [physicalStdout]);
63
+ return rows;
64
+ }
65
+ export function normalizePhysicalRows(value) {
66
+ if (typeof value !== "number" || !Number.isFinite(value)) {
67
+ return DEFAULT_PHYSICAL_ROWS;
68
+ }
69
+ return Math.max(0, Math.floor(value));
70
+ }
71
+ function readPhysicalRows(output) {
72
+ return normalizePhysicalRows(output.rows);
73
+ }