@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.
- package/README.CN.md +32 -3
- package/README.md +32 -3
- package/THIRD_PARTY_NOTICES.md +5 -4
- 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 +84 -95
- 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 +4 -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 +64 -25
- package/dist/ui/ConfirmView.js +163 -67
- package/dist/ui/InteractiveSessionProvider.js +19 -0
- package/dist/ui/ResolvePlaceholdersView.js +107 -27
- 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 +28 -16
- 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 +5 -16
- package/dist/version.js +38 -0
- package/package.json +18 -15
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
3
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "fish", "csh", "tcsh", "ksh", "dash"]);
|
|
4
|
+
const REINTERPRETING_PREFIXES = new Set([
|
|
5
|
+
"command",
|
|
6
|
+
"exec",
|
|
7
|
+
"builtin",
|
|
8
|
+
"time",
|
|
9
|
+
"noglob",
|
|
10
|
+
"nocorrect",
|
|
11
|
+
"eval",
|
|
12
|
+
"source",
|
|
13
|
+
".",
|
|
14
|
+
]);
|
|
15
|
+
const RESERVED_WORDS = new Set([
|
|
16
|
+
"if",
|
|
17
|
+
"then",
|
|
18
|
+
"elif",
|
|
19
|
+
"else",
|
|
20
|
+
"fi",
|
|
21
|
+
"for",
|
|
22
|
+
"while",
|
|
23
|
+
"until",
|
|
24
|
+
"do",
|
|
25
|
+
"done",
|
|
26
|
+
"case",
|
|
27
|
+
"esac",
|
|
28
|
+
"select",
|
|
29
|
+
"function",
|
|
30
|
+
"coproc",
|
|
31
|
+
"repeat",
|
|
32
|
+
"and",
|
|
33
|
+
"or",
|
|
34
|
+
"not",
|
|
35
|
+
"!",
|
|
36
|
+
]);
|
|
37
|
+
const WRAPPER_PATHS = new Set([
|
|
38
|
+
"sudo",
|
|
39
|
+
"env",
|
|
40
|
+
"/usr/bin/sudo",
|
|
41
|
+
"/bin/sudo",
|
|
42
|
+
"/usr/bin/env",
|
|
43
|
+
"/bin/env",
|
|
44
|
+
]);
|
|
45
|
+
const SUDO_ZERO_SHORT = new Set("ABbEHknPS");
|
|
46
|
+
const SUDO_ONE_SHORT = new Set("CDghpRTu");
|
|
47
|
+
const SUDO_ZERO_LONG = new Set([
|
|
48
|
+
"--askpass",
|
|
49
|
+
"--bell",
|
|
50
|
+
"--background",
|
|
51
|
+
"--preserve-env",
|
|
52
|
+
"--set-home",
|
|
53
|
+
"--reset-timestamp",
|
|
54
|
+
"--non-interactive",
|
|
55
|
+
"--preserve-groups",
|
|
56
|
+
"--stdin",
|
|
57
|
+
]);
|
|
58
|
+
const SUDO_ONE_LONG = new Set([
|
|
59
|
+
"--close-from",
|
|
60
|
+
"--chdir",
|
|
61
|
+
"--group",
|
|
62
|
+
"--host",
|
|
63
|
+
"--prompt",
|
|
64
|
+
"--chroot",
|
|
65
|
+
"--command-timeout",
|
|
66
|
+
"--user",
|
|
67
|
+
]);
|
|
68
|
+
const ENV_ZERO_SHORT = new Set("iv");
|
|
69
|
+
const ENV_ONE_SHORT = new Set("uCP");
|
|
70
|
+
const ENV_ZERO_LONG = new Set(["--ignore-environment", "--debug"]);
|
|
71
|
+
const ENV_ONE_LONG = new Set(["--unset", "--chdir"]);
|
|
72
|
+
export function isShellExecutable(value) {
|
|
73
|
+
return SHELLS.has(posix.basename(value));
|
|
74
|
+
}
|
|
75
|
+
export function parseShellCommand(command) {
|
|
76
|
+
const lexed = lexShellCommand(command);
|
|
77
|
+
if (lexed.kind === "unsupported")
|
|
78
|
+
return lexed;
|
|
79
|
+
const commands = [];
|
|
80
|
+
let words = [];
|
|
81
|
+
let needsCommand = false;
|
|
82
|
+
let hasRedirection = false;
|
|
83
|
+
for (let index = 0; index < lexed.tokens.length; index += 1) {
|
|
84
|
+
const token = lexed.tokens[index];
|
|
85
|
+
if (token.kind === "word") {
|
|
86
|
+
words.push(token.word);
|
|
87
|
+
needsCommand = false;
|
|
88
|
+
}
|
|
89
|
+
else if (token.kind === "redirect") {
|
|
90
|
+
hasRedirection = true;
|
|
91
|
+
if (token.needsTarget) {
|
|
92
|
+
const target = lexed.tokens[index + 1];
|
|
93
|
+
if (target?.kind !== "word")
|
|
94
|
+
return unsupported("syntax");
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
if (words.length === 0) {
|
|
100
|
+
if (hasRedirection)
|
|
101
|
+
return unsupported("syntax");
|
|
102
|
+
if (token.separator === "\n")
|
|
103
|
+
continue;
|
|
104
|
+
return unsupported("syntax");
|
|
105
|
+
}
|
|
106
|
+
commands.push({ words, separator: token.separator });
|
|
107
|
+
words = [];
|
|
108
|
+
hasRedirection = false;
|
|
109
|
+
needsCommand =
|
|
110
|
+
token.separator === "&&" || token.separator === "||" || token.separator === "|";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (needsCommand)
|
|
114
|
+
return unsupported("syntax");
|
|
115
|
+
if (words.length === 0 && hasRedirection)
|
|
116
|
+
return unsupported("syntax");
|
|
117
|
+
if (words.length > 0)
|
|
118
|
+
commands.push({ words });
|
|
119
|
+
return commands.length > 0 ? { kind: "parsed", commands } : unsupported("missing-executable");
|
|
120
|
+
}
|
|
121
|
+
export function resolveCommandPrefix(words) {
|
|
122
|
+
let index = 0;
|
|
123
|
+
while (words[index]?.assignmentOperator === "=")
|
|
124
|
+
index += 1;
|
|
125
|
+
while (index < words.length) {
|
|
126
|
+
const word = words[index];
|
|
127
|
+
// += 在 Bash/zsh 与 dash 中语义不同;无法确定 shell 时不能跳过它证明工具身份。
|
|
128
|
+
if (word.assignmentOperator === "+=" ||
|
|
129
|
+
word.hasExpansion ||
|
|
130
|
+
word.value === "" ||
|
|
131
|
+
word.value.startsWith("-"))
|
|
132
|
+
return unsupported("prefix");
|
|
133
|
+
const name = posix.basename(word.value);
|
|
134
|
+
if (REINTERPRETING_PREFIXES.has(name) || RESERVED_WORDS.has(word.value))
|
|
135
|
+
return unsupported("prefix");
|
|
136
|
+
if (name !== "sudo" && name !== "env") {
|
|
137
|
+
return { kind: "parsed", executable: word, args: words.slice(index + 1) };
|
|
138
|
+
}
|
|
139
|
+
if (!WRAPPER_PATHS.has(word.value))
|
|
140
|
+
return unsupported("prefix");
|
|
141
|
+
const skipped = skipWrapperOptions(words, index + 1, name);
|
|
142
|
+
if (typeof skipped !== "number")
|
|
143
|
+
return skipped;
|
|
144
|
+
index = skipped;
|
|
145
|
+
// wrapper 按 argv 识别赋值;env 的非空名称不受 shell 标识符规则限制。
|
|
146
|
+
// sudo 保留 NAME= 白名单,其他含等号形式及动态赋值不推测为工具。
|
|
147
|
+
while (words[index] !== undefined) {
|
|
148
|
+
const assignment = words[index];
|
|
149
|
+
const equals = assignment.value.indexOf("=");
|
|
150
|
+
if (equals < 0)
|
|
151
|
+
break;
|
|
152
|
+
if (assignment.hasExpansion ||
|
|
153
|
+
(name === "env" ? equals === 0 : !ASSIGNMENT.test(assignment.value)))
|
|
154
|
+
return unsupported("prefix");
|
|
155
|
+
index += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return unsupported("missing-executable");
|
|
159
|
+
}
|
|
160
|
+
function skipWrapperOptions(words, start, wrapper) {
|
|
161
|
+
const zeroShort = wrapper === "sudo" ? SUDO_ZERO_SHORT : ENV_ZERO_SHORT;
|
|
162
|
+
const oneShort = wrapper === "sudo" ? SUDO_ONE_SHORT : ENV_ONE_SHORT;
|
|
163
|
+
const zeroLong = wrapper === "sudo" ? SUDO_ZERO_LONG : ENV_ZERO_LONG;
|
|
164
|
+
const oneLong = wrapper === "sudo" ? SUDO_ONE_LONG : ENV_ONE_LONG;
|
|
165
|
+
let index = start;
|
|
166
|
+
while (index < words.length) {
|
|
167
|
+
const word = words[index];
|
|
168
|
+
if (word.hasExpansion)
|
|
169
|
+
return unsupported("prefix");
|
|
170
|
+
const option = word.value;
|
|
171
|
+
if (option === "--")
|
|
172
|
+
return index + 1;
|
|
173
|
+
if (option === "-" && wrapper === "env")
|
|
174
|
+
return index + 1;
|
|
175
|
+
if (!option.startsWith("-") || option === "-")
|
|
176
|
+
return index;
|
|
177
|
+
let needsArgument = false;
|
|
178
|
+
if (option.startsWith("--")) {
|
|
179
|
+
const equals = option.indexOf("=");
|
|
180
|
+
const name = equals < 0 ? option : option.slice(0, equals);
|
|
181
|
+
if (zeroLong.has(name)) {
|
|
182
|
+
if (equals >= 0 && !(wrapper === "sudo" && name === "--preserve-env"))
|
|
183
|
+
return unsupported("option");
|
|
184
|
+
}
|
|
185
|
+
else if (oneLong.has(name)) {
|
|
186
|
+
if (equals >= 0) {
|
|
187
|
+
if (option.slice(equals + 1) === "")
|
|
188
|
+
return unsupported("missing-argument");
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
needsArgument = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
return unsupported("option");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
for (let offset = 1; offset < option.length; offset += 1) {
|
|
200
|
+
const flag = option[offset];
|
|
201
|
+
if (zeroShort.has(flag))
|
|
202
|
+
continue;
|
|
203
|
+
if (!oneShort.has(flag))
|
|
204
|
+
return unsupported("option");
|
|
205
|
+
needsArgument = offset === option.length - 1;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
index += 1;
|
|
210
|
+
if (needsArgument) {
|
|
211
|
+
const argument = words[index];
|
|
212
|
+
if (argument === undefined || argument.value === "")
|
|
213
|
+
return unsupported("missing-argument");
|
|
214
|
+
if (argument.hasExpansion)
|
|
215
|
+
return unsupported("prefix");
|
|
216
|
+
index += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return index;
|
|
220
|
+
}
|
|
221
|
+
function lexShellCommand(command) {
|
|
222
|
+
const tokens = [];
|
|
223
|
+
let index = 0;
|
|
224
|
+
while (index < command.length) {
|
|
225
|
+
const char = command[index];
|
|
226
|
+
if (char === " " || char === "\t") {
|
|
227
|
+
index += 1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (char === "\\" && command[index + 1] === "\n") {
|
|
231
|
+
index += 2;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (char === "#") {
|
|
235
|
+
while (index < command.length && command[index] !== "\n")
|
|
236
|
+
index += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (";\n&|".includes(char)) {
|
|
240
|
+
if (char === "&" && command[index + 1] === ">")
|
|
241
|
+
return unsupported("syntax");
|
|
242
|
+
const pair = command.slice(index, index + 2);
|
|
243
|
+
const separator = pair === "&&" || pair === "||" ? pair : char;
|
|
244
|
+
tokens.push({ kind: "separator", separator });
|
|
245
|
+
index += separator.length;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if ("(){}".includes(char))
|
|
249
|
+
return unsupported("syntax");
|
|
250
|
+
const redirect = /^(?:\d+)?(?:>>|[<>])/.exec(command.slice(index));
|
|
251
|
+
if (redirect !== null) {
|
|
252
|
+
index += redirect[0].length;
|
|
253
|
+
if (command[index] === "<" || command[index] === ">" || command[index] === "(")
|
|
254
|
+
return unsupported("syntax");
|
|
255
|
+
if (command[index] === "&") {
|
|
256
|
+
const descriptor = /^&(?:\d+|-)(?=$|[ \t\n;&|<>])/.exec(command.slice(index));
|
|
257
|
+
if (descriptor === null)
|
|
258
|
+
return unsupported("syntax");
|
|
259
|
+
index += descriptor[0].length;
|
|
260
|
+
tokens.push({ kind: "redirect", needsTarget: false });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
tokens.push({ kind: "redirect", needsTarget: true });
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const word = readWord(command, index);
|
|
268
|
+
if (word.kind === "unsupported")
|
|
269
|
+
return word;
|
|
270
|
+
const rawWord = command.slice(index, word.word.end);
|
|
271
|
+
if ((command[word.word.end] === "<" || command[word.word.end] === ">") &&
|
|
272
|
+
/^\d+$/.test(word.word.value) &&
|
|
273
|
+
rawWord.includes("\\\n") &&
|
|
274
|
+
rawWord.replaceAll("\\\n", "") === word.word.value) {
|
|
275
|
+
// 数字 fd 内的续行在 sh/bash 与 zsh 中分词不同,不能把它当作确定的工具名。
|
|
276
|
+
return unsupported("syntax");
|
|
277
|
+
}
|
|
278
|
+
tokens.push({ kind: "word", word: word.word });
|
|
279
|
+
index = word.word.end;
|
|
280
|
+
}
|
|
281
|
+
return { kind: "parsed", tokens };
|
|
282
|
+
}
|
|
283
|
+
function readWord(command, start) {
|
|
284
|
+
let index = start;
|
|
285
|
+
let value = "";
|
|
286
|
+
let quote;
|
|
287
|
+
let hasExpansion = false;
|
|
288
|
+
let assignmentEligible = true;
|
|
289
|
+
let assignmentOperator;
|
|
290
|
+
while (index < command.length) {
|
|
291
|
+
const char = command[index];
|
|
292
|
+
if (quote === "'") {
|
|
293
|
+
if (char === "'")
|
|
294
|
+
quote = undefined;
|
|
295
|
+
else
|
|
296
|
+
value += char;
|
|
297
|
+
index += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (char === "\\") {
|
|
301
|
+
const next = command[index + 1];
|
|
302
|
+
if (next === undefined)
|
|
303
|
+
return unsupported("syntax");
|
|
304
|
+
if (next === "\n") {
|
|
305
|
+
index += 2;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (quote === undefined || '$`"\\'.includes(next)) {
|
|
309
|
+
value += next;
|
|
310
|
+
assignmentEligible = false;
|
|
311
|
+
index += 2;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
value += char;
|
|
315
|
+
index += 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (quote === '"' && char === '"') {
|
|
319
|
+
quote = undefined;
|
|
320
|
+
index += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (quote === undefined && (char === "'" || char === '"')) {
|
|
324
|
+
quote = char;
|
|
325
|
+
assignmentEligible = false;
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (char === "`")
|
|
330
|
+
return unsupported("expansion");
|
|
331
|
+
if (char === "$") {
|
|
332
|
+
const next = command[index + 1];
|
|
333
|
+
if (next === "(" || next === "'" || next === '"')
|
|
334
|
+
return unsupported("expansion");
|
|
335
|
+
if (next === "{") {
|
|
336
|
+
const parameter = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}/.exec(command.slice(index));
|
|
337
|
+
if (parameter === null)
|
|
338
|
+
return unsupported("expansion");
|
|
339
|
+
value += parameter[0];
|
|
340
|
+
hasExpansion = true;
|
|
341
|
+
assignmentEligible = false;
|
|
342
|
+
index += parameter[0].length;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// 未引用或转义的美元字符保守视为动态,不能用不完整白名单排除 shell 扩展。
|
|
346
|
+
hasExpansion = true;
|
|
347
|
+
}
|
|
348
|
+
if (quote === undefined) {
|
|
349
|
+
if (" \t\n;&|<>".includes(char))
|
|
350
|
+
break;
|
|
351
|
+
if ("(){}".includes(char))
|
|
352
|
+
return unsupported("syntax");
|
|
353
|
+
if ("*?[".includes(char) ||
|
|
354
|
+
(char === "~" && value === "") ||
|
|
355
|
+
(char === "=" && value === "" && assignmentEligible))
|
|
356
|
+
hasExpansion = true;
|
|
357
|
+
if (char === "=" && assignmentEligible) {
|
|
358
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
359
|
+
assignmentOperator = "=";
|
|
360
|
+
else if (/^[A-Za-z_][A-Za-z0-9_]*\+$/.test(value))
|
|
361
|
+
assignmentOperator = "+=";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
value += char;
|
|
365
|
+
index += 1;
|
|
366
|
+
}
|
|
367
|
+
if (quote !== undefined)
|
|
368
|
+
return unsupported("syntax");
|
|
369
|
+
return { kind: "parsed", word: { value, start, end: index, hasExpansion, assignmentOperator } };
|
|
370
|
+
}
|
|
371
|
+
function unsupported(reason) {
|
|
372
|
+
return { kind: "unsupported", reason };
|
|
373
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function hasUnsafeTerminalControlCharacters(value) {
|
|
2
|
+
for (let index = 0; index < value.length; index++) {
|
|
3
|
+
if (isUnsafeTerminalControlCodeUnit(value.charCodeAt(index))) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
export function renderTerminalSafeText(value) {
|
|
10
|
+
let rendered = "";
|
|
11
|
+
for (let index = 0; index < value.length; index++) {
|
|
12
|
+
const codeUnit = value.charCodeAt(index);
|
|
13
|
+
if (codeUnit === 0x0d) {
|
|
14
|
+
rendered += "␍";
|
|
15
|
+
}
|
|
16
|
+
else if (codeUnit === 0x0a) {
|
|
17
|
+
rendered += "␊";
|
|
18
|
+
}
|
|
19
|
+
else if (isUnsafeTerminalControlCodeUnit(codeUnit)) {
|
|
20
|
+
rendered += "�";
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
rendered += value[index];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return rendered;
|
|
27
|
+
}
|
|
28
|
+
function isUnsafeTerminalControlCodeUnit(codeUnit) {
|
|
29
|
+
return (codeUnit <= 0x09 ||
|
|
30
|
+
(codeUnit >= 0x0b && codeUnit <= 0x0c) ||
|
|
31
|
+
(codeUnit >= 0x0e && codeUnit <= 0x1f) ||
|
|
32
|
+
(codeUnit >= 0x7f && codeUnit <= 0x9f));
|
|
33
|
+
}
|
package/dist/ui/App.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { Box, Text, useWindowSize } from "ink";
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, usePaste, useWindowSize } from "ink";
|
|
3
|
+
import { useKeyboardInput } from "./use-keyboard-input.js";
|
|
4
|
+
import { useInteractiveSession } from "./InteractiveSessionProvider.js";
|
|
3
5
|
import { LoadingView } from "./LoadingView.js";
|
|
4
6
|
import { SelectCommandView } from "./SelectCommandView.js";
|
|
5
7
|
import { ResolvePlaceholdersView } from "./ResolvePlaceholdersView.js";
|
|
@@ -8,8 +10,12 @@ import { generateValidatedCommandCandidates } from "../validation/generated-comm
|
|
|
8
10
|
import { detectDangerousCommand } from "../safety/dangerous-command.js";
|
|
9
11
|
import { resolveCandidatePlaceholders } from "./placeholder-logic.js";
|
|
10
12
|
import { InteractionCancelledError } from "./tty.js";
|
|
13
|
+
import { usePhysicalStdoutRows } from "./resize-safe-output.js";
|
|
14
|
+
import { getUserVisibleErrorMessage } from "../errors.js";
|
|
11
15
|
export const App = ({ provider, request, onSuccess, onError }) => {
|
|
12
|
-
const
|
|
16
|
+
const session = useInteractiveSession();
|
|
17
|
+
const rows = usePhysicalStdoutRows();
|
|
18
|
+
const { columns } = useWindowSize();
|
|
13
19
|
const [status, setStatus] = useState("loading");
|
|
14
20
|
const [candidates, setCandidates] = useState([]);
|
|
15
21
|
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
|
@@ -17,20 +23,56 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
17
23
|
const [finalCommand, setFinalCommand] = useState("");
|
|
18
24
|
const [danger, setDanger] = useState(undefined);
|
|
19
25
|
const [errorMessage, setErrorMessage] = useState("");
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const activeRequestControllerRef = useRef(null);
|
|
27
|
+
const cancellationReportedRef = useRef(false);
|
|
28
|
+
const frameRows = Math.max(0, rows - 1);
|
|
29
|
+
const isFrameVisible = frameRows > 0;
|
|
30
|
+
usePaste(() => { }, { isActive: isFrameVisible });
|
|
31
|
+
const handleCancel = useCallback(() => {
|
|
32
|
+
session.suspendViewInput();
|
|
33
|
+
activeRequestControllerRef.current?.abort();
|
|
34
|
+
if (cancellationReportedRef.current)
|
|
35
|
+
return;
|
|
36
|
+
cancellationReportedRef.current = true;
|
|
37
|
+
onError(new InteractionCancelledError());
|
|
38
|
+
}, [onError, session]);
|
|
39
|
+
useKeyboardInput((input, key) => {
|
|
40
|
+
if (frameRows === 0)
|
|
41
|
+
return;
|
|
42
|
+
if (status !== "loading")
|
|
43
|
+
return;
|
|
44
|
+
if (key.ctrl && input === "c") {
|
|
45
|
+
handleCancel();
|
|
33
46
|
}
|
|
47
|
+
});
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (status !== "loading")
|
|
50
|
+
return;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
let isCurrent = true;
|
|
53
|
+
activeRequestControllerRef.current = controller;
|
|
54
|
+
void generateValidatedCommandCandidates(provider, request, controller.signal)
|
|
55
|
+
.then((generatedCandidates) => {
|
|
56
|
+
if (!isCurrent || controller.signal.aborted)
|
|
57
|
+
return;
|
|
58
|
+
setCandidates(generatedCandidates);
|
|
59
|
+
setStatus("selecting");
|
|
60
|
+
})
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
if (!isCurrent || controller.signal.aborted)
|
|
63
|
+
return;
|
|
64
|
+
const appError = normalizeError(error);
|
|
65
|
+
setErrorMessage(getUserVisibleErrorMessage(appError));
|
|
66
|
+
setStatus("error");
|
|
67
|
+
onError(appError);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
isCurrent = false;
|
|
71
|
+
controller.abort();
|
|
72
|
+
if (activeRequestControllerRef.current === controller) {
|
|
73
|
+
activeRequestControllerRef.current = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
34
76
|
}, [status, provider, request, onError]);
|
|
35
77
|
const handleSelect = (candidate) => {
|
|
36
78
|
setSelectedCandidate(candidate);
|
|
@@ -48,7 +90,7 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
48
90
|
}
|
|
49
91
|
catch (error) {
|
|
50
92
|
const appError = normalizeError(error);
|
|
51
|
-
setErrorMessage(appError
|
|
93
|
+
setErrorMessage(getUserVisibleErrorMessage(appError));
|
|
52
94
|
setStatus("error");
|
|
53
95
|
onError(appError);
|
|
54
96
|
}
|
|
@@ -68,20 +110,17 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
68
110
|
}
|
|
69
111
|
}, [status, finalCommand, onSuccess]);
|
|
70
112
|
const handleConfirm = () => {
|
|
113
|
+
session.suspendViewInput();
|
|
71
114
|
setStatus("done");
|
|
72
115
|
};
|
|
73
|
-
const handleCancel = () => {
|
|
74
|
-
onError(new InteractionCancelledError());
|
|
75
|
-
};
|
|
76
116
|
const handleBackToSelection = () => {
|
|
77
117
|
setStatus("selecting");
|
|
78
118
|
};
|
|
79
|
-
|
|
80
|
-
return (React.createElement(Box, { flexDirection: "column", maxHeight: maxFrameRows, overflowY: "hidden" },
|
|
119
|
+
return (React.createElement(Box, { flexDirection: "column", display: isFrameVisible ? "flex" : "none", maxHeight: frameRows, overflowY: "hidden" },
|
|
81
120
|
status === "loading" && React.createElement(LoadingView, null),
|
|
82
|
-
status === "selecting" && (React.createElement(SelectCommandView, { candidates: candidates, onSelect: handleSelect, onCancel: handleCancel })),
|
|
83
|
-
status === "resolving" && selectedCandidate && (React.createElement(ResolvePlaceholdersView, { candidate: selectedCandidate, onResolve: handleResolve, onBack: handleBackToSelection, onCancel: handleCancel })),
|
|
84
|
-
(status === "confirming" || status === "done") && selectedCandidate && (React.createElement(ConfirmView, { candidate: selectedCandidate, command: finalCommand, resolvedValues: resolvedValues, danger: danger, onConfirm: handleConfirm, onCancel: handleCancel, isDone: status === "done" })),
|
|
121
|
+
status === "selecting" && (React.createElement(SelectCommandView, { candidates: candidates, availableRows: frameRows, isInputActive: isFrameVisible, onSelect: handleSelect, onCancel: handleCancel })),
|
|
122
|
+
status === "resolving" && selectedCandidate && (React.createElement(ResolvePlaceholdersView, { candidate: selectedCandidate, availableRows: frameRows, availableColumns: columns, isInputActive: isFrameVisible, onResolve: handleResolve, onBack: handleBackToSelection, onCancel: handleCancel })),
|
|
123
|
+
(status === "confirming" || status === "done") && selectedCandidate && (React.createElement(ConfirmView, { candidate: selectedCandidate, command: finalCommand, resolvedValues: resolvedValues, danger: danger, availableRows: frameRows, availableColumns: columns, isInputActive: isFrameVisible, onConfirm: handleConfirm, onCancel: handleCancel, isDone: status === "done" })),
|
|
85
124
|
status === "error" && React.createElement(Text, { color: "red" },
|
|
86
125
|
"Error: ",
|
|
87
126
|
errorMessage)));
|