@aisa-one/cli 0.2.4 → 0.5.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/CHANGELOG.md +172 -1
- package/LICENSE +21 -0
- package/README.md +163 -169
- package/dist/api.d.ts +10 -0
- package/dist/api.js +14 -3
- package/dist/catalog.d.ts +1 -18
- package/dist/catalog.js +1 -24
- package/dist/cli-error.d.ts +17 -0
- package/dist/cli-error.js +25 -0
- package/dist/cli-guidance.d.ts +14 -0
- package/dist/cli-guidance.js +55 -0
- package/dist/commands/account.d.ts +17 -0
- package/dist/commands/account.js +45 -1
- package/dist/commands/api.d.ts +0 -11
- package/dist/commands/api.js +6 -175
- package/dist/commands/auth.d.ts +2 -1
- package/dist/commands/auth.js +43 -9
- package/dist/commands/brand-logos.d.ts +6 -0
- package/dist/commands/brand-logos.js +21 -0
- package/dist/commands/configCmd.js +8 -3
- package/dist/commands/connect-shared.d.ts +136 -0
- package/dist/commands/connect-shared.js +143 -0
- package/dist/commands/connect-t2.d.ts +18 -0
- package/dist/commands/connect-t2.js +1599 -0
- package/dist/commands/connect-terminal.d.ts +65 -0
- package/dist/commands/connect-terminal.js +822 -0
- package/dist/commands/connect.d.ts +31 -0
- package/dist/commands/connect.js +3181 -0
- package/dist/commands/flow.d.ts +685 -0
- package/dist/commands/flow.js +697 -0
- package/dist/commands/install.d.ts +91 -0
- package/dist/commands/install.js +258 -0
- package/dist/commands/llm-config.d.ts +103 -0
- package/dist/commands/llm-config.js +452 -0
- package/dist/commands/manifest.d.ts +68 -0
- package/dist/commands/manifest.js +90 -0
- package/dist/commands/mcp.d.ts +79 -2
- package/dist/commands/mcp.js +262 -48
- package/dist/commands/oauth-login.d.ts +33 -0
- package/dist/commands/oauth-login.js +333 -0
- package/dist/commands/prompt.d.ts +96 -0
- package/dist/commands/prompt.js +397 -0
- package/dist/commands/serve-results.d.ts +61 -0
- package/dist/commands/serve-results.js +245 -0
- package/dist/commands/serve-signin.d.ts +28 -0
- package/dist/commands/serve-signin.js +90 -0
- package/dist/commands/signin-page.d.ts +49 -0
- package/dist/commands/signin-page.js +250 -0
- package/dist/commands/skills.js +87 -50
- package/dist/commands/tool-help.d.ts +69 -0
- package/dist/commands/tool-help.js +189 -0
- package/dist/commands/tool-input.d.ts +22 -0
- package/dist/commands/tool-input.js +220 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.js +307 -0
- package/dist/commands/update.d.ts +11 -0
- package/dist/commands/update.js +92 -0
- package/dist/commands/vscode.d.ts +65 -0
- package/dist/commands/vscode.js +173 -0
- package/dist/commands/wrappers.d.ts +23 -0
- package/dist/commands/wrappers.js +102 -0
- package/dist/completion.js +3 -80
- package/dist/config.js +76 -2
- package/dist/constants.d.ts +78 -38
- package/dist/constants.js +80 -102
- package/dist/index.js +155 -357
- package/dist/json-text.d.ts +12 -0
- package/dist/json-text.js +60 -0
- package/dist/router.d.ts +39 -0
- package/dist/router.js +59 -0
- package/dist/skills-registry.js +12 -3
- package/dist/utils/banner.d.ts +9 -0
- package/dist/utils/banner.js +41 -0
- package/dist/utils/browser.d.ts +19 -0
- package/dist/utils/browser.js +31 -0
- package/dist/utils/exec.d.ts +43 -0
- package/dist/utils/exec.js +61 -0
- package/dist/utils/http.d.ts +53 -0
- package/dist/utils/http.js +124 -0
- package/dist/utils/install-method.d.ts +36 -0
- package/dist/utils/install-method.js +48 -0
- package/dist/utils/journal.d.ts +57 -0
- package/dist/utils/journal.js +188 -0
- package/dist/utils/key-check.d.ts +20 -0
- package/dist/utils/key-check.js +24 -0
- package/dist/utils/update-check.d.ts +23 -0
- package/dist/utils/update-check.js +116 -0
- package/dist/utils/width.d.ts +32 -0
- package/dist/utils/width.js +73 -0
- package/package.json +8 -4
- package/vscode-extension/README.md +6 -0
- package/vscode-extension/aisa-vscode.vsix +0 -0
- package/dist/commands/finance.d.ts +0 -17
- package/dist/commands/finance.js +0 -342
- package/dist/commands/run.d.ts +0 -28
- package/dist/commands/run.js +0 -182
- package/dist/commands/search.d.ts +0 -9
- package/dist/commands/search.js +0 -81
- package/dist/commands/twitter.d.ts +0 -138
- package/dist/commands/twitter.js +0 -374
- package/dist/commands/video.d.ts +0 -49
- package/dist/commands/video.js +0 -311
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { AGENT_BADGE, AGENT_NOTES, BACKUP_COPY, CONFIRM, FILE_MODEL_FALLBACK, FILE_MODEL_NOTE, STEP_AGENT, STEP_CAPS, STEP_MODELS, STEP_TITLES, STEP_WELCOME, agentRank, fill, t, } from "./flow.js";
|
|
4
|
+
import { INSTALLERS } from "./install.js";
|
|
5
|
+
import { defaultModelsFor } from "./llm-config.js";
|
|
6
|
+
import { httpFetch } from "../utils/http.js";
|
|
7
|
+
import { cells } from "../utils/width.js";
|
|
8
|
+
import { pick, interactive, restoreTerminal } from "./prompt.js";
|
|
9
|
+
/** Same rule the page applies, so both offer the same rows. */
|
|
10
|
+
export function flowClients(clients, canInstall) {
|
|
11
|
+
return clients.map((c) => ({
|
|
12
|
+
...c,
|
|
13
|
+
installable: !c.detected && Boolean(INSTALLERS[c.id]) && canInstall,
|
|
14
|
+
command: INSTALLERS[c.id]?.command,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The model options for an agent, by the rule the page already applies.
|
|
19
|
+
*
|
|
20
|
+
* There are three shapes over there and the terminal offered one, so half
|
|
21
|
+
* the agents were asked a question the page does not ask them. A CLI agent
|
|
22
|
+
* yet to be installed chooses between switching and not; an installed one
|
|
23
|
+
* can also keep its own model and put AIsa beside it. VS Code cannot switch:
|
|
24
|
+
* Copilot's models can be joined, not replaced. An app configured through a
|
|
25
|
+
* file is not asked at all — there is nothing here to point at a model, so
|
|
26
|
+
* it gets the note the page shows instead of a menu.
|
|
27
|
+
*/
|
|
28
|
+
function modelChoices(client) {
|
|
29
|
+
if (client.kind === "cli") {
|
|
30
|
+
return client.detected ? ["backup", "switch", "skip"] : ["switch", "skip"];
|
|
31
|
+
}
|
|
32
|
+
return client.id === "vscode" ? ["backup", "skip"] : [];
|
|
33
|
+
}
|
|
34
|
+
const dim = chalk.gray;
|
|
35
|
+
const bold = chalk.bold;
|
|
36
|
+
/** A framed step header, so the terminal has the page's sense of place. */
|
|
37
|
+
function header(n, lang) {
|
|
38
|
+
const step = STEP_TITLES.find((s) => s.n === n);
|
|
39
|
+
// After the number, before the words: the number says where you are, the
|
|
40
|
+
// glyph says what this one is, and a reader scrolling back finds the glyph
|
|
41
|
+
// first. Terminal only — see the note on STEP_TITLES.
|
|
42
|
+
const title = `${n}/6 ${step.icon} ${t(step.title, lang)}`;
|
|
43
|
+
return `\n${bold.cyan("┌─ " + title)} ${dim("· " + t(step.sub, lang))}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ask for a line, on an interface that exists only for this question.
|
|
47
|
+
*
|
|
48
|
+
* A readline kept open across the whole flow does not survive the arrow-key
|
|
49
|
+
* pickers beside it: those put stdin in raw mode, and the interface that was
|
|
50
|
+
* listening before is left attached to a stdin whose mode changed underneath
|
|
51
|
+
* it. The result was a prompt that echoed nothing and accepted nothing, with
|
|
52
|
+
* no way out but killing the terminal.
|
|
53
|
+
*
|
|
54
|
+
* One question, one interface, closed before anything else touches stdin.
|
|
55
|
+
*/
|
|
56
|
+
async function askLine(prompt) {
|
|
57
|
+
// Cooked mode first: a picker may have just been open, and readline on a raw
|
|
58
|
+
// stdin accepts nothing.
|
|
59
|
+
restoreTerminal();
|
|
60
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
try {
|
|
62
|
+
return (await rl.question(prompt)).trim();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
rl.close();
|
|
66
|
+
// Whatever happened, the terminal goes back cooked. A flow that threw
|
|
67
|
+
// while a picker was open used to leave it raw.
|
|
68
|
+
restoreTerminal();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Strip the markup the page needs and the terminal cannot show. */
|
|
72
|
+
export function plain(html) {
|
|
73
|
+
return html
|
|
74
|
+
.replace(/<br\s*\/?>/gi, " ")
|
|
75
|
+
.replace(/<[^>]+>/g, "")
|
|
76
|
+
.replace(/</g, "<")
|
|
77
|
+
.replace(/>/g, ">")
|
|
78
|
+
.replace(/&/g, "&")
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
/** Two columns wide in a terminal: CJK, and the punctuation that comes with it. */
|
|
83
|
+
/** Kept as a name because wrap() reads better with it; the maths is shared. */
|
|
84
|
+
export const displayWidth = cells;
|
|
85
|
+
/**
|
|
86
|
+
* Wrap to the terminal width.
|
|
87
|
+
*
|
|
88
|
+
* Splitting on spaces alone is wrong for Chinese, which has none: a whole
|
|
89
|
+
* sentence arrives as one "word" and the line runs far past the margin —
|
|
90
|
+
* observed on the welcome step, where a paragraph overflowed by half a line.
|
|
91
|
+
* So a run that is itself too wide is broken between characters, and width is
|
|
92
|
+
* measured in cells rather than characters because CJK glyphs take two.
|
|
93
|
+
*/
|
|
94
|
+
export function wrap(text, width = Math.min(process.stdout.columns || 80, 84)) {
|
|
95
|
+
const out = [];
|
|
96
|
+
let line = "";
|
|
97
|
+
const flush = () => { if (line) {
|
|
98
|
+
out.push(line);
|
|
99
|
+
line = "";
|
|
100
|
+
} };
|
|
101
|
+
const breakWide = (word) => {
|
|
102
|
+
let chunk = "";
|
|
103
|
+
for (const ch of word) {
|
|
104
|
+
if (displayWidth(chunk + ch) > width) {
|
|
105
|
+
out.push(chunk);
|
|
106
|
+
chunk = "";
|
|
107
|
+
}
|
|
108
|
+
chunk += ch;
|
|
109
|
+
}
|
|
110
|
+
return chunk;
|
|
111
|
+
};
|
|
112
|
+
for (const word of text.split(" ")) {
|
|
113
|
+
const sep = line ? 1 : 0;
|
|
114
|
+
if (line && displayWidth(line) + sep + displayWidth(word) > width)
|
|
115
|
+
flush();
|
|
116
|
+
if (displayWidth(word) > width) {
|
|
117
|
+
flush();
|
|
118
|
+
line = breakWide(word);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
line = line ? `${line} ${word}` : word;
|
|
122
|
+
}
|
|
123
|
+
flush();
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
function say(text, indent = "│ ") {
|
|
127
|
+
for (const line of wrap(plain(text)))
|
|
128
|
+
console.log(dim(indent) + line);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Thrown by a step when the run has to resume from an earlier one.
|
|
132
|
+
*
|
|
133
|
+
* The steps are written straight down the page, which reads well and cannot
|
|
134
|
+
* jump. Going back is rare enough that unwinding to the loop and starting
|
|
135
|
+
* again from a named step is clearer than turning the whole thing into a
|
|
136
|
+
* state machine for the sake of it.
|
|
137
|
+
*/
|
|
138
|
+
class Rewind {
|
|
139
|
+
to;
|
|
140
|
+
echo;
|
|
141
|
+
constructor(to,
|
|
142
|
+
/** False when the page already knows — see Answer's `back`. */
|
|
143
|
+
echo = true) {
|
|
144
|
+
this.to = to;
|
|
145
|
+
this.echo = echo;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Where the terminal should stand when the page is on `pageStep`.
|
|
150
|
+
*
|
|
151
|
+
* Step 1 is the welcome, which this side prints on its way past rather than
|
|
152
|
+
* stopping on — so a click on it lands here at the first question.
|
|
153
|
+
*/
|
|
154
|
+
function terminalStepFor(pageStep) {
|
|
155
|
+
return Math.max(2, pageStep);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ask one question, while watching for the page to answer it instead.
|
|
159
|
+
*
|
|
160
|
+
* Waiting on readline alone is what made the terminal look dead: a person who
|
|
161
|
+
* ticked a card in the browser saw nothing happen here, because this side
|
|
162
|
+
* only looked at the shared draft between questions. So the prompt races
|
|
163
|
+
* against a poll, and whichever resolves first wins the step.
|
|
164
|
+
*
|
|
165
|
+
* The abandoned readline promise cannot be cancelled — it stays pending on a
|
|
166
|
+
* closed interface, which is harmless — but the caller must not ask again on
|
|
167
|
+
* the same interface after the page wins, so each step reads at most once.
|
|
168
|
+
*/
|
|
169
|
+
async function askOrWatch(o, count, fallback, seenRev,
|
|
170
|
+
/** The step being asked; moving past it is what answers it. */
|
|
171
|
+
step) {
|
|
172
|
+
let stop = false;
|
|
173
|
+
const typed = (async () => {
|
|
174
|
+
for (;;) {
|
|
175
|
+
const answer = await askLine(bold.cyan("│ > "));
|
|
176
|
+
if (stop)
|
|
177
|
+
return { by: "user", index: fallback };
|
|
178
|
+
if (answer === "")
|
|
179
|
+
return { by: "user", index: fallback };
|
|
180
|
+
const n = Number(answer);
|
|
181
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
182
|
+
return { by: "user", index: n - 1 };
|
|
183
|
+
console.log(dim("│ ") + chalk.yellow(`1–${count}`));
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
const watched = (async () => {
|
|
187
|
+
let misses = 0;
|
|
188
|
+
for (;;) {
|
|
189
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
190
|
+
if (stop)
|
|
191
|
+
return { by: "user", index: fallback };
|
|
192
|
+
const s = await pull(o);
|
|
193
|
+
if (!s) {
|
|
194
|
+
// Never resolves once it gives up, which is right: the typed answer
|
|
195
|
+
// is still coming and this side was never waiting on the page.
|
|
196
|
+
if (++misses >= WATCH_GIVE_UP)
|
|
197
|
+
return await new Promise(() => { });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
misses = 0;
|
|
201
|
+
if (s.rev === seenRev || !s.draft)
|
|
202
|
+
continue;
|
|
203
|
+
// Moving past the step is the only thing that answers it. Treating a
|
|
204
|
+
// changed value as the answer meant picking an agent in the page —
|
|
205
|
+
// without pressing Next — carried both sides straight to the next step.
|
|
206
|
+
if ((s.currentStep ?? 0) > step) {
|
|
207
|
+
return { by: "page", draft: s.draft };
|
|
208
|
+
}
|
|
209
|
+
// And going back is an answer too: clicking an earlier step in the rail
|
|
210
|
+
// means "I want to choose that again", which is not something this side
|
|
211
|
+
// can honour by staying where it is.
|
|
212
|
+
// Compared after translating, not before. The page's step 1 is this
|
|
213
|
+
// side's step 2, so a page sitting on the welcome reads as "behind"
|
|
214
|
+
// a terminal that is already standing exactly where it should — and
|
|
215
|
+
// the question got reprinted every time somebody opened the page.
|
|
216
|
+
if (s.currentStep && terminalStepFor(s.currentStep) < step) {
|
|
217
|
+
return { by: "back", to: terminalStepFor(s.currentStep), from: "page" };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
const winner = await Promise.race([typed, watched]);
|
|
222
|
+
stop = true;
|
|
223
|
+
return winner;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The arrow-key picker, racing the same watch the typed prompt races.
|
|
227
|
+
*
|
|
228
|
+
* Returns the same Answer shape either way, so a step does not care which
|
|
229
|
+
* input the terminal was able to offer.
|
|
230
|
+
*/
|
|
231
|
+
async function pickOrWatch(o, seenRev, step, choices, multi, initial, hint,
|
|
232
|
+
/** Where Escape lands. Without it Escape is ignored, as on step 2. */
|
|
233
|
+
backTo,
|
|
234
|
+
/**
|
|
235
|
+
* Mirror a checklist instead of ending on its first change.
|
|
236
|
+
*
|
|
237
|
+
* A single choice is answered the moment the page makes it, so those steps
|
|
238
|
+
* leave this out and end on `changed`. A checklist is not: ticking a box
|
|
239
|
+
* over there closed this picker and moved the run on, which is why the ✓
|
|
240
|
+
* marks here never followed the page. With `live` the two sides show the
|
|
241
|
+
* same ticks and the step ends only when someone presses Next.
|
|
242
|
+
*/
|
|
243
|
+
live) {
|
|
244
|
+
// The two sides write to the same draft, so a poll that overtakes our own
|
|
245
|
+
// write would paint the state from before it — the box you just ticked
|
|
246
|
+
// un-ticks itself half a second later. Ignore anything older than our last
|
|
247
|
+
// write, and anything at all while one is in flight.
|
|
248
|
+
let floor = seenRev;
|
|
249
|
+
let shown = initial.join(",");
|
|
250
|
+
/** Set by the watcher when the page moved to an earlier step. */
|
|
251
|
+
let back;
|
|
252
|
+
// Holding an arrow key down produces a keystroke every few milliseconds,
|
|
253
|
+
// and one request each would put the page behind the terminal rather than
|
|
254
|
+
// beside it. Only the latest selection is worth sending, so a move made
|
|
255
|
+
// while a write is in flight replaces the one waiting for it.
|
|
256
|
+
let pending;
|
|
257
|
+
let flushing = false;
|
|
258
|
+
const flush = async () => {
|
|
259
|
+
if (flushing || !live?.write)
|
|
260
|
+
return;
|
|
261
|
+
flushing = true;
|
|
262
|
+
try {
|
|
263
|
+
while (pending) {
|
|
264
|
+
const next = pending;
|
|
265
|
+
pending = undefined;
|
|
266
|
+
floor = Math.max(floor, await live.write(next));
|
|
267
|
+
shown = next.join(",");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
/* the page falls behind; this terminal still finishes the run */
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
flushing = false;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const r = await pick({
|
|
278
|
+
title: "",
|
|
279
|
+
choices,
|
|
280
|
+
multi,
|
|
281
|
+
initial,
|
|
282
|
+
hint,
|
|
283
|
+
onToggle: live?.write
|
|
284
|
+
? (indexes) => {
|
|
285
|
+
pending = indexes;
|
|
286
|
+
void flush();
|
|
287
|
+
}
|
|
288
|
+
: undefined,
|
|
289
|
+
watch: async (signal, apply) => {
|
|
290
|
+
let misses = 0;
|
|
291
|
+
while (!signal.aborted) {
|
|
292
|
+
await new Promise((x) => setTimeout(x, 900));
|
|
293
|
+
if (signal.aborted)
|
|
294
|
+
return undefined;
|
|
295
|
+
if (flushing || pending)
|
|
296
|
+
continue;
|
|
297
|
+
const s = await pull(o);
|
|
298
|
+
if (!s) {
|
|
299
|
+
// Stop watching, keep the picker. The keyboard still answers this
|
|
300
|
+
// question — that is the whole point of the two sides being
|
|
301
|
+
// independent — so nothing here should end because a page went.
|
|
302
|
+
if (++misses >= WATCH_GIVE_UP)
|
|
303
|
+
return undefined;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
misses = 0;
|
|
307
|
+
if (s.rev === seenRev || !s.draft)
|
|
308
|
+
continue;
|
|
309
|
+
if ((s.currentStep ?? 0) > step)
|
|
310
|
+
return s.draft;
|
|
311
|
+
if (s.currentStep && terminalStepFor(s.currentStep) < step) {
|
|
312
|
+
back = terminalStepFor(s.currentStep);
|
|
313
|
+
return s.draft; // ends the picker; the caller reads `back`
|
|
314
|
+
}
|
|
315
|
+
if (!live || s.rev < floor)
|
|
316
|
+
continue;
|
|
317
|
+
const indexes = live.read(s.draft);
|
|
318
|
+
if (!indexes)
|
|
319
|
+
continue;
|
|
320
|
+
const next = indexes.join(",");
|
|
321
|
+
if (next === shown)
|
|
322
|
+
continue;
|
|
323
|
+
shown = next;
|
|
324
|
+
apply(indexes);
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
if (r.interrupted) {
|
|
330
|
+
if (back !== undefined)
|
|
331
|
+
return { by: "back", to: back, from: "page" };
|
|
332
|
+
return { by: "page", draft: r.interrupted };
|
|
333
|
+
}
|
|
334
|
+
if (r.escaped) {
|
|
335
|
+
if (backTo === undefined)
|
|
336
|
+
return { by: "user", index: initial[0] ?? 0, picked: initial };
|
|
337
|
+
return { by: "back", to: backTo, from: "here" };
|
|
338
|
+
}
|
|
339
|
+
return { by: "user", index: r.picked?.[0] ?? initial[0] ?? 0, picked: r.picked };
|
|
340
|
+
}
|
|
341
|
+
/** Push a change to the shared draft. Failure is not fatal: the terminal can
|
|
342
|
+
* still finish the run, it just stops mirroring into a page nobody may have
|
|
343
|
+
* open. */
|
|
344
|
+
async function push(o, rev, patch) {
|
|
345
|
+
try {
|
|
346
|
+
const res = await httpFetch(`${o.baseUrl}/select?token=${o.token}`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: { "content-type": "application/json" },
|
|
349
|
+
body: JSON.stringify({ rev, ...patch }),
|
|
350
|
+
timeoutMs: 5_000,
|
|
351
|
+
});
|
|
352
|
+
const body = (await res.json());
|
|
353
|
+
// A stale rev means someone wrote while this was being typed — but they
|
|
354
|
+
// wrote a different field, and dropping this write silently is worse than
|
|
355
|
+
// the conflict it was meant to catch. Retry once against what is now
|
|
356
|
+
// current. Without this the terminal's step never landed: the page
|
|
357
|
+
// publishes its step on load, and every later terminal write was refused.
|
|
358
|
+
if (body.ok === false) {
|
|
359
|
+
const retry = await httpFetch(`${o.baseUrl}/select?token=${o.token}`, {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { "content-type": "application/json" },
|
|
362
|
+
body: JSON.stringify({ rev: body.rev, ...patch }),
|
|
363
|
+
timeoutMs: 5_000,
|
|
364
|
+
});
|
|
365
|
+
return (await retry.json());
|
|
366
|
+
}
|
|
367
|
+
return body;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return { rev };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* How many polls in a row may fail before the terminal stops watching.
|
|
375
|
+
*
|
|
376
|
+
* Not an error: the page is optional, and this side can finish the whole run
|
|
377
|
+
* on its own. But a watcher that keeps asking a port nothing is listening on,
|
|
378
|
+
* every 900ms for as long as the question is open, is spending the user's
|
|
379
|
+
* machine on a conversation that ended.
|
|
380
|
+
*/
|
|
381
|
+
const WATCH_GIVE_UP = 8;
|
|
382
|
+
/** Read the shared draft, to pick up whatever the page did. */
|
|
383
|
+
async function pull(o) {
|
|
384
|
+
try {
|
|
385
|
+
const res = await httpFetch(`${o.baseUrl}/status?token=${o.token}`, {
|
|
386
|
+
idempotent: true,
|
|
387
|
+
maxAttempts: 1,
|
|
388
|
+
timeoutMs: 3_000,
|
|
389
|
+
});
|
|
390
|
+
const s = (await res.json());
|
|
391
|
+
return { rev: s.rev ?? 0, draft: s.draft, currentStep: s.currentStep };
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Walk the flow in the terminal and return what was chosen.
|
|
399
|
+
*
|
|
400
|
+
* Returns undefined when the user abandons it, which the caller treats the
|
|
401
|
+
* same way as closing the page.
|
|
402
|
+
*/
|
|
403
|
+
export async function runTerminalFlow(o) {
|
|
404
|
+
let rev = 0;
|
|
405
|
+
const draft = { servers: [], clients: [], install: [], llmMode: "backup" };
|
|
406
|
+
// Start from whatever the shared draft already holds — a page may have been
|
|
407
|
+
// open first, and its ticks are the starting point here rather than being
|
|
408
|
+
// silently discarded.
|
|
409
|
+
//
|
|
410
|
+
// Its *position* is part of that state, not just its answers. Adopting the
|
|
411
|
+
// ticks while always restarting at step 2 meant a page sitting on step 4 got
|
|
412
|
+
// asked its first two questions again by a terminal that already knew both
|
|
413
|
+
// answers — and, now that the page follows this side backwards, got dragged
|
|
414
|
+
// back to 2 to watch. Resuming where the run actually stands is the whole
|
|
415
|
+
// difference between two views of one run and two runs that happen to share
|
|
416
|
+
// a draft.
|
|
417
|
+
const seed = await pull(o);
|
|
418
|
+
let resumeAt = 2;
|
|
419
|
+
if (seed) {
|
|
420
|
+
rev = seed.rev;
|
|
421
|
+
if (seed.draft)
|
|
422
|
+
Object.assign(draft, seed.draft);
|
|
423
|
+
// Cap at the confirmation: 6 is the finished view, and there is nothing
|
|
424
|
+
// for this side to ask once a run has been applied.
|
|
425
|
+
if (seed.currentStep)
|
|
426
|
+
resumeAt = Math.min(terminalStepFor(seed.currentStep), 5);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
// Steps 2–5 are one loop, because "n" at the confirmation means go back
|
|
430
|
+
// and change something — not abandon the run. Answering no used to end
|
|
431
|
+
// the process, which is the opposite of what the word offers.
|
|
432
|
+
// ── step 1: welcome ──
|
|
433
|
+
// The page opens on this and the terminal used to skip straight past it,
|
|
434
|
+
// which left the one surface that cannot be scrolled back through with no
|
|
435
|
+
// answer to "what am I about to get". Same four points, without the tiles.
|
|
436
|
+
console.log(header(1, o.lang));
|
|
437
|
+
say(t(STEP_WELCOME.h1, o.lang));
|
|
438
|
+
console.log(dim("│"));
|
|
439
|
+
say(t(STEP_WELCOME.lede, o.lang));
|
|
440
|
+
console.log(dim("│"));
|
|
441
|
+
for (const tile of STEP_WELCOME.tiles) {
|
|
442
|
+
console.log(dim("│ ") + chalk.bold("· " + plain(t(tile.h3, o.lang))));
|
|
443
|
+
for (const line of wrap(plain(t(tile.p, o.lang)), 74)) {
|
|
444
|
+
console.log(dim("│ ") + dim(line));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
console.log(dim("└─"));
|
|
448
|
+
// Steps 2 through the confirmation, repeatable — and resumable from any
|
|
449
|
+
// of them. Answering n at the end comes back here, and so does clicking
|
|
450
|
+
// an earlier step in the page's rail: both say "I want to choose that
|
|
451
|
+
// again", and the difference is only which step they land on.
|
|
452
|
+
//
|
|
453
|
+
// What a later step needs from an earlier one lives out here, so
|
|
454
|
+
// resuming at 3 still knows which agent step 2 settled on.
|
|
455
|
+
let confirmed = "again";
|
|
456
|
+
let from = resumeAt;
|
|
457
|
+
let client;
|
|
458
|
+
let modeLabel = "";
|
|
459
|
+
for (;;) {
|
|
460
|
+
try {
|
|
461
|
+
// ── step 2: your agent ──
|
|
462
|
+
if (from <= 2) {
|
|
463
|
+
console.log(header(2, o.lang));
|
|
464
|
+
say(t(STEP_AGENT.question, o.lang));
|
|
465
|
+
console.log(dim("│"));
|
|
466
|
+
const shown = o.clients
|
|
467
|
+
.filter((c) => c.detected || c.installable)
|
|
468
|
+
.sort((a, b) => agentRank(a.id) - agentRank(b.id));
|
|
469
|
+
if (shown.length === 0)
|
|
470
|
+
return undefined;
|
|
471
|
+
shown.forEach((c, i) => {
|
|
472
|
+
const badge = c.detected
|
|
473
|
+
? chalk.green(t(AGENT_BADGE.detected, o.lang))
|
|
474
|
+
: chalk.yellow(t(AGENT_BADGE.absent, o.lang));
|
|
475
|
+
console.log(`${dim("│")} ${bold(String(i + 1) + ")")} ${c.label.padEnd(16)} ${badge} ${dim(c.detail ?? "")}`);
|
|
476
|
+
});
|
|
477
|
+
console.log(dim("│"));
|
|
478
|
+
const preferred = Math.max(0, shown.findIndex((c) => draft.clients[0] === c.id));
|
|
479
|
+
const a1 = interactive()
|
|
480
|
+
? await pickOrWatch(o, rev, 2, shown.map((c) => ({
|
|
481
|
+
label: c.label,
|
|
482
|
+
meta: c.detected ? t(AGENT_BADGE.detected, o.lang) : t(AGENT_BADGE.absent, o.lang),
|
|
483
|
+
})), false, [preferred], o.lang === "zh" ? "↑↓ 选择 · 回车确认" : "↑↓ move · enter to confirm", undefined, // step 2 has nothing behind it
|
|
484
|
+
{
|
|
485
|
+
read: (d) => {
|
|
486
|
+
const i = shown.findIndex((c) => c.id === d.clients[0]);
|
|
487
|
+
return i < 0 ? undefined : [i];
|
|
488
|
+
},
|
|
489
|
+
write: async ([i]) => {
|
|
490
|
+
const c = shown[i];
|
|
491
|
+
({ rev } = await push(o, rev, {
|
|
492
|
+
draft: { clients: [c.id], install: c.detected ? [] : [c.id] },
|
|
493
|
+
}));
|
|
494
|
+
return rev;
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
: await askOrWatch(o, shown.length, preferred, rev, 2);
|
|
498
|
+
if (a1.by === "back")
|
|
499
|
+
throw new Rewind(a1.to, a1.from === "here");
|
|
500
|
+
if (a1.by === "page") {
|
|
501
|
+
// Answered in the browser. Say so rather than redrawing silently —
|
|
502
|
+
// seeing why the prompt moved on is the whole point.
|
|
503
|
+
client = shown.find((c) => c.id === a1.draft.clients[0]) ?? shown[preferred];
|
|
504
|
+
Object.assign(draft, a1.draft);
|
|
505
|
+
const s = await pull(o);
|
|
506
|
+
if (s)
|
|
507
|
+
rev = s.rev;
|
|
508
|
+
console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
client = shown[a1.index];
|
|
512
|
+
}
|
|
513
|
+
draft.clients = [client.id];
|
|
514
|
+
draft.install = client.detected ? [] : [client.id];
|
|
515
|
+
console.log(dim("└─ ") + chalk.green(client.label) + " ✓");
|
|
516
|
+
if (AGENT_NOTES[client.id])
|
|
517
|
+
say(t(AGENT_NOTES[client.id], o.lang), " ");
|
|
518
|
+
({ rev } = await push(o, rev, { step: 3, draft: { clients: draft.clients, install: draft.install } }));
|
|
519
|
+
}
|
|
520
|
+
// Resuming at 3 or later: the agent is whatever step 2 settled on, or
|
|
521
|
+
// whatever the shared draft says if this side never ran that step.
|
|
522
|
+
if (!client) {
|
|
523
|
+
client = o.clients.find((c) => c.id === draft.clients[0]) ?? o.clients.find((c) => c.detected);
|
|
524
|
+
if (!client)
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
// ── step 3: models ──
|
|
528
|
+
if (from <= 3) {
|
|
529
|
+
console.log(header(3, o.lang));
|
|
530
|
+
say(`${t(STEP_MODELS.h2Prefix, o.lang)}${client.label}${t(STEP_MODELS.h2Suffix, o.lang)}`);
|
|
531
|
+
console.log(dim("│"));
|
|
532
|
+
// The copy carries {model}; the page swaps in an element its script
|
|
533
|
+
// fills, and here the name goes straight in.
|
|
534
|
+
const modelName = defaultModelsFor(client.id).model;
|
|
535
|
+
const brief = (x) => plain(t(x, o.lang)).split("{model}").join(modelName);
|
|
536
|
+
const unpicked = !client.detected;
|
|
537
|
+
const copy = {
|
|
538
|
+
backup: {
|
|
539
|
+
label: t(STEP_MODELS.backup.name, o.lang),
|
|
540
|
+
// The page fills this tile with the agent's own backup wording;
|
|
541
|
+
// leaving it blank here described the recommended option in no
|
|
542
|
+
// words at all.
|
|
543
|
+
brief: BACKUP_COPY[client.id] ? brief(BACKUP_COPY[client.id]) : "",
|
|
544
|
+
},
|
|
545
|
+
switch: unpicked
|
|
546
|
+
? { label: t(STEP_MODELS.freshSwitch.name, o.lang), brief: brief(STEP_MODELS.freshSwitch.brief) }
|
|
547
|
+
: { label: t(STEP_MODELS.switchIt.name, o.lang), brief: brief(STEP_MODELS.switchIt.brief) },
|
|
548
|
+
skip: {
|
|
549
|
+
label: t(STEP_MODELS.notNow.name, o.lang),
|
|
550
|
+
brief: brief(unpicked ? STEP_MODELS.notNow.briefFresh : STEP_MODELS.notNow.briefDetected),
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
const modes = modelChoices(client).map((id) => ({ id, ...copy[id] }));
|
|
554
|
+
let mode = modes[0] ?? { id: "backup", ...copy.backup };
|
|
555
|
+
if (modes.length === 0) {
|
|
556
|
+
// Nothing to choose. Say why in the page's own words and move on,
|
|
557
|
+
// rather than offering a menu it does not offer.
|
|
558
|
+
for (const l of wrap(brief(FILE_MODEL_NOTE[client.id] ?? FILE_MODEL_FALLBACK), 68)) {
|
|
559
|
+
console.log(dim("│ ") + dim(l));
|
|
560
|
+
}
|
|
561
|
+
console.log(dim("└─ ") + dim(o.lang === "zh" ? "这一步没有要选的" : "nothing to choose here"));
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
modes.forEach((m, i) => {
|
|
565
|
+
const rec = i === 0 ? dim(` (${t(STEP_MODELS.recommended, o.lang)})`) : "";
|
|
566
|
+
console.log(`${dim("│")} ${bold(String(i + 1) + ")")} ${m.label}${rec}`);
|
|
567
|
+
if (m.brief)
|
|
568
|
+
for (const l of wrap(m.brief, 68))
|
|
569
|
+
console.log(dim("│ ") + dim(l));
|
|
570
|
+
});
|
|
571
|
+
console.log(dim("│"));
|
|
572
|
+
const a2 = interactive()
|
|
573
|
+
? await pickOrWatch(o, rev, 3, modes
|
|
574
|
+
.map((m, i) => ({
|
|
575
|
+
label: m.label + (i === 0 ? dim(` (${t(STEP_MODELS.recommended, o.lang)})`) : ""),
|
|
576
|
+
}))
|
|
577
|
+
.concat([{ label: dim(t(CONFIRM.goBack, o.lang)) }]), false, [0], o.lang === "zh" ? "↑↓ 选择 · 回车确认 · esc 返回" : "↑↓ move · enter to confirm · esc to go back", 2, {
|
|
578
|
+
read: (d) => {
|
|
579
|
+
const i = modes.findIndex((m) => m.id === d.llmMode);
|
|
580
|
+
return i < 0 ? undefined : [i];
|
|
581
|
+
},
|
|
582
|
+
write: async ([i]) => {
|
|
583
|
+
({ rev } = await push(o, rev, { draft: { llmMode: modes[i].id } }));
|
|
584
|
+
return rev;
|
|
585
|
+
},
|
|
586
|
+
})
|
|
587
|
+
: await askOrWatch(o, modes.length, 0, rev, 3);
|
|
588
|
+
if (a2.by === "back")
|
|
589
|
+
throw new Rewind(a2.to, a2.from === "here");
|
|
590
|
+
// The extra row is the way back. Steps 3 and 4 are the two where a
|
|
591
|
+
// person can realise they picked the wrong thing a moment ago; 2 has
|
|
592
|
+
// nothing behind it and 5 already asks.
|
|
593
|
+
if (a2.by === "user" && a2.index === modes.length)
|
|
594
|
+
throw new Rewind(2);
|
|
595
|
+
if (a2.by === "page") {
|
|
596
|
+
mode = modes.find((m) => m.id === a2.draft.llmMode) ?? modes[0];
|
|
597
|
+
Object.assign(draft, a2.draft);
|
|
598
|
+
const s = await pull(o);
|
|
599
|
+
if (s)
|
|
600
|
+
rev = s.rev;
|
|
601
|
+
console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
mode = modes[a2.index];
|
|
605
|
+
}
|
|
606
|
+
console.log(dim("└─ ") + chalk.green(mode.label) + " ✓");
|
|
607
|
+
}
|
|
608
|
+
draft.llmMode = mode.id;
|
|
609
|
+
modeLabel = mode.label;
|
|
610
|
+
({ rev } = await push(o, rev, { step: 4, draft: { llmMode: draft.llmMode } }));
|
|
611
|
+
}
|
|
612
|
+
// ── step 4: capabilities ──
|
|
613
|
+
if (from <= 4) {
|
|
614
|
+
console.log(header(4, o.lang));
|
|
615
|
+
const totalTools = o.servers.reduce((n, s) => n + s.toolCount, 0);
|
|
616
|
+
const cats = [...new Set(o.servers.map((s) => s.category))];
|
|
617
|
+
say(fill(STEP_CAPS.counts, o.lang, {
|
|
618
|
+
areas: cats.length, servers: o.servers.length, tools: totalTools,
|
|
619
|
+
}));
|
|
620
|
+
console.log(dim("│"));
|
|
621
|
+
// Pull once more: the capability list is long, and a page open beside
|
|
622
|
+
// this one is the likelier place to have ticked through it.
|
|
623
|
+
const fresh = await pull(o);
|
|
624
|
+
if (fresh?.draft?.servers?.length)
|
|
625
|
+
draft.servers = fresh.draft.servers;
|
|
626
|
+
if (fresh)
|
|
627
|
+
rev = fresh.rev;
|
|
628
|
+
const chosen = new Set(draft.servers);
|
|
629
|
+
if (interactive()) {
|
|
630
|
+
// Sorted by area, with the area named once where it changes.
|
|
631
|
+
//
|
|
632
|
+
// Twenty-five servers over eight areas — and five of those areas hold
|
|
633
|
+
// exactly one server, so a heading per area produced "heading, row,
|
|
634
|
+
// heading, row", which is a hat on every line rather than a grouping.
|
|
635
|
+
// The name on the first row of each run says the same thing and costs
|
|
636
|
+
// no rows: what follows inherits it from the blank space beside it.
|
|
637
|
+
const ordered = [...o.servers].sort((a, b) => a.category.localeCompare(b.category) || a.slug.localeCompare(b.slug));
|
|
638
|
+
const rowFor = (slug) => ordered.findIndex((s) => s.slug === slug);
|
|
639
|
+
const initial = [...chosen].map(rowFor).filter((i) => i >= 0);
|
|
640
|
+
const a3 = await pickOrWatch(o, rev, 4, ordered.map((srv) => ({
|
|
641
|
+
label: srv.slug,
|
|
642
|
+
meta: `${srv.toolCount} ${t(STEP_CAPS.toolsWord, o.lang)}`,
|
|
643
|
+
tag: srv.category,
|
|
644
|
+
// Straight from the host, the same sentence the page shows. A
|
|
645
|
+
// hand-written table here would be a second source of truth for
|
|
646
|
+
// something that ships with the server.
|
|
647
|
+
detail: srv.description,
|
|
648
|
+
})), true, initial, o.lang === "zh"
|
|
649
|
+
? "↑↓ 移动 · 空格勾选 · a 全选/全不选 · 回车确认 · esc 返回上一步"
|
|
650
|
+
: "↑↓ move · space to tick · a for all · enter to confirm · esc to go back", 3, {
|
|
651
|
+
read: (d) => d.servers === undefined ? undefined : d.servers.map(rowFor).filter((i) => i >= 0),
|
|
652
|
+
write: async (indexes) => {
|
|
653
|
+
const next = indexes.map((i) => ordered[i]?.slug).filter((x) => Boolean(x));
|
|
654
|
+
({ rev } = await push(o, rev, { draft: { servers: next } }));
|
|
655
|
+
return rev;
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
// In a checklist the way back cannot be another row — a row is a
|
|
659
|
+
// thing you tick — so Escape is the gesture, and the hint says so.
|
|
660
|
+
if (a3.by === "back")
|
|
661
|
+
throw new Rewind(a3.to, a3.from === "here");
|
|
662
|
+
if (a3.by === "page") {
|
|
663
|
+
chosen.clear();
|
|
664
|
+
for (const slug of a3.draft.servers ?? [])
|
|
665
|
+
chosen.add(slug);
|
|
666
|
+
const s2 = await pull(o);
|
|
667
|
+
if (s2)
|
|
668
|
+
rev = s2.rev;
|
|
669
|
+
console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
chosen.clear();
|
|
673
|
+
for (const i of a3.picked ?? []) {
|
|
674
|
+
const srv = ordered[i];
|
|
675
|
+
if (srv)
|
|
676
|
+
chosen.add(srv.slug);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
o.servers.forEach((s, i) => {
|
|
682
|
+
const mark = chosen.has(s.slug) ? chalk.green("[x]") : dim("[ ]");
|
|
683
|
+
console.log(`${dim("│")} ${mark} ${bold(String(i + 1).padStart(2))}) ${s.slug.padEnd(24)} ${dim(String(s.toolCount) + " " + t(STEP_CAPS.toolsWord, o.lang))}`);
|
|
684
|
+
});
|
|
685
|
+
console.log(dim("│"));
|
|
686
|
+
say(o.lang === "zh"
|
|
687
|
+
? "输入编号切换选中(空格分隔),直接回车确认。"
|
|
688
|
+
: "Type numbers to toggle (space-separated), or press enter to confirm.");
|
|
689
|
+
for (;;) {
|
|
690
|
+
const answer = await askLine(bold.cyan("│ > "));
|
|
691
|
+
if (answer === "")
|
|
692
|
+
break;
|
|
693
|
+
for (const tok of answer.split(/[\s,]+/)) {
|
|
694
|
+
const n = Number(tok);
|
|
695
|
+
if (!Number.isInteger(n) || n < 1 || n > o.servers.length)
|
|
696
|
+
continue;
|
|
697
|
+
const slug = o.servers[n - 1].slug;
|
|
698
|
+
if (chosen.has(slug))
|
|
699
|
+
chosen.delete(slug);
|
|
700
|
+
else
|
|
701
|
+
chosen.add(slug);
|
|
702
|
+
}
|
|
703
|
+
console.log(dim("│ ") + chalk.green(`${chosen.size} selected: `) + dim([...chosen].join(", ")));
|
|
704
|
+
({ rev } = await push(o, rev, { draft: { servers: [...chosen] } }));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
draft.servers = [...chosen];
|
|
708
|
+
if (draft.servers.length === 0) {
|
|
709
|
+
console.log(dim("└─ ") + chalk.yellow(o.lang === "zh" ? "至少选一个" : "Pick at least one"));
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
console.log(dim("└─ ") + chalk.green(`${draft.servers.length} ✓`));
|
|
713
|
+
// Publish the choice but NOT the step: arriving at step 5 is what makes
|
|
714
|
+
// the page start the run by itself, and the user has not confirmed yet.
|
|
715
|
+
// Announcing the step here applied everything without being asked.
|
|
716
|
+
({ rev } = await push(o, rev, { draft: { servers: draft.servers } }));
|
|
717
|
+
}
|
|
718
|
+
// ── step 5: confirm ──
|
|
719
|
+
// Everything above was browsing and could be undone by closing the
|
|
720
|
+
// window. This is where the machine changes, so it asks for a word rather
|
|
721
|
+
// than a keystroke — enter alone is too easy to hit on the way past.
|
|
722
|
+
console.log(header(5, o.lang));
|
|
723
|
+
say(t(CONFIRM.heading, o.lang));
|
|
724
|
+
console.log(dim("│"));
|
|
725
|
+
if (!modeLabel)
|
|
726
|
+
modeLabel = draft.llmMode;
|
|
727
|
+
console.log(`${dim("│")} ${t(CONFIRM.agent, o.lang)}: ${chalk.bold(client.label)}`);
|
|
728
|
+
console.log(`${dim("│")} ${t(CONFIRM.models, o.lang)}: ${chalk.bold(modeLabel)}`);
|
|
729
|
+
console.log(`${dim("│")} ${t(CONFIRM.capabilities, o.lang)}: ${chalk.bold(String(draft.servers.length))} ${dim(draft.servers.join(", "))}`);
|
|
730
|
+
console.log(dim("│"));
|
|
731
|
+
if (interactive()) {
|
|
732
|
+
const r = await pick({
|
|
733
|
+
title: "",
|
|
734
|
+
// Applying is first and where the cursor starts. It was the other
|
|
735
|
+
// way round, to keep a stray return from writing anything — but by
|
|
736
|
+
// this point the summary above says exactly what will happen, and
|
|
737
|
+
// making the common answer cost two keystrokes is its own kind of
|
|
738
|
+
// wrong. Escape still leaves without applying.
|
|
739
|
+
choices: [
|
|
740
|
+
{ label: chalk.green.bold(t(CONFIRM.apply, o.lang)) },
|
|
741
|
+
// Dim, like the way-back row on the models step. A way out of a
|
|
742
|
+
// screen is not one of the things the screen offers, and rendering
|
|
743
|
+
// it as brightly as the action it declines reads as a second offer.
|
|
744
|
+
{ label: dim(t(CONFIRM.goBack, o.lang)) },
|
|
745
|
+
],
|
|
746
|
+
multi: false,
|
|
747
|
+
initial: [0],
|
|
748
|
+
// Escape does the same as picking the way-back row - `r.aborted`
|
|
749
|
+
// falls through to "again" below - so the hint says so. Steps 3
|
|
750
|
+
// and 4 both advertise it and this one did not, which made the
|
|
751
|
+
// key look like it did nothing here.
|
|
752
|
+
hint: o.lang === "zh" ? "↑↓ 选择 · 回车确认 · esc 返回修改"
|
|
753
|
+
: "↑↓ move · enter to confirm · esc to go back",
|
|
754
|
+
});
|
|
755
|
+
confirmed = !r.aborted && r.picked?.[0] === 0 ? "go" : "again";
|
|
756
|
+
console.log(dim("└─ ") + (confirmed === "go"
|
|
757
|
+
? chalk.green(t(CONFIRM.apply, o.lang) + " ✓")
|
|
758
|
+
: chalk.yellow(t(CONFIRM.backToEdit, o.lang))));
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
say(t(CONFIRM.ask, o.lang) + " — ok / n");
|
|
762
|
+
// Bounded, and empty means no. A closed pipe returns "" forever, and
|
|
763
|
+
// asking again each time printed the prompt down the screen until it
|
|
764
|
+
// ran into whatever came next. Three tries is a person mistyping; more
|
|
765
|
+
// than that is not a person.
|
|
766
|
+
let tries = 0;
|
|
767
|
+
for (;;) {
|
|
768
|
+
const said = (await askLine(bold.cyan("│ > "))).toLowerCase();
|
|
769
|
+
if (["ok", "yes", "y", "是", "好"].includes(said)) {
|
|
770
|
+
confirmed = "go";
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
// Empty means no here, unlike the picker above where enter on the
|
|
774
|
+
// highlighted row means yes. Deliberately different: with no
|
|
775
|
+
// terminal, an empty line is usually a closed pipe rather than
|
|
776
|
+
// someone agreeing, and EOF must not write to a machine.
|
|
777
|
+
if (said === "" || ["n", "no", "否"].includes(said)) {
|
|
778
|
+
confirmed = "again";
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
if (++tries >= 3) {
|
|
782
|
+
confirmed = "again";
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
console.log(dim("│ ") + chalk.yellow("ok / n"));
|
|
786
|
+
}
|
|
787
|
+
console.log(dim("└─ ") + (confirmed === "go"
|
|
788
|
+
? chalk.green("ok ✓")
|
|
789
|
+
: chalk.yellow(t(CONFIRM.backToEdit, o.lang))));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
if (!(e instanceof Rewind))
|
|
794
|
+
throw e;
|
|
795
|
+
// Somebody asked for an earlier step — the page's rail, or a way-back
|
|
796
|
+
// row here. Start again from there with everything else still in hand.
|
|
797
|
+
from = e.to;
|
|
798
|
+
console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? `↩ 回到第 ${from} 步` : `↩ back to step ${from}`));
|
|
799
|
+
if (e.echo)
|
|
800
|
+
({ rev } = await push(o, rev, { step: from }));
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
// "Go back and change something" at the confirmation lands on the model
|
|
804
|
+
// step, not the agent: an agent you already picked is rarely the thing
|
|
805
|
+
// you came back to change, and making you pick it again to reach the
|
|
806
|
+
// rest is a toll rather than a choice.
|
|
807
|
+
if (confirmed === "again") {
|
|
808
|
+
from = 3;
|
|
809
|
+
({ rev } = await push(o, rev, { step: 3 }));
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
from = 2;
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
return draft;
|
|
816
|
+
}
|
|
817
|
+
finally {
|
|
818
|
+
// Whatever happened, the terminal goes back cooked. A flow that threw
|
|
819
|
+
// while a picker was open used to leave it raw.
|
|
820
|
+
restoreTerminal();
|
|
821
|
+
}
|
|
822
|
+
}
|