@aisa-one/cli 0.3.0 → 0.5.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 (103) hide show
  1. package/CHANGELOG.md +165 -1
  2. package/LICENSE +21 -0
  3. package/README.md +164 -170
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +14 -3
  6. package/dist/catalog.d.ts +1 -18
  7. package/dist/catalog.js +1 -24
  8. package/dist/cli-error.d.ts +17 -0
  9. package/dist/cli-error.js +25 -0
  10. package/dist/cli-guidance.d.ts +14 -0
  11. package/dist/cli-guidance.js +55 -0
  12. package/dist/commands/account.d.ts +17 -0
  13. package/dist/commands/account.js +45 -1
  14. package/dist/commands/api.d.ts +0 -11
  15. package/dist/commands/api.js +6 -175
  16. package/dist/commands/auth.d.ts +2 -1
  17. package/dist/commands/auth.js +45 -11
  18. package/dist/commands/brand-logos.d.ts +6 -0
  19. package/dist/commands/brand-logos.js +21 -0
  20. package/dist/commands/configCmd.js +8 -3
  21. package/dist/commands/connect-shared.d.ts +136 -0
  22. package/dist/commands/connect-shared.js +143 -0
  23. package/dist/commands/connect-t2.d.ts +18 -0
  24. package/dist/commands/connect-t2.js +1599 -0
  25. package/dist/commands/connect-terminal.d.ts +65 -0
  26. package/dist/commands/connect-terminal.js +822 -0
  27. package/dist/commands/connect.d.ts +31 -0
  28. package/dist/commands/connect.js +3181 -0
  29. package/dist/commands/flow.d.ts +685 -0
  30. package/dist/commands/flow.js +697 -0
  31. package/dist/commands/install.d.ts +91 -0
  32. package/dist/commands/install.js +258 -0
  33. package/dist/commands/llm-config.d.ts +103 -0
  34. package/dist/commands/llm-config.js +452 -0
  35. package/dist/commands/manifest.d.ts +68 -0
  36. package/dist/commands/manifest.js +90 -0
  37. package/dist/commands/mcp.d.ts +59 -0
  38. package/dist/commands/mcp.js +122 -44
  39. package/dist/commands/oauth-login.d.ts +33 -0
  40. package/dist/commands/oauth-login.js +333 -0
  41. package/dist/commands/prompt.d.ts +96 -0
  42. package/dist/commands/prompt.js +397 -0
  43. package/dist/commands/serve-results.d.ts +61 -0
  44. package/dist/commands/serve-results.js +245 -0
  45. package/dist/commands/serve-signin.d.ts +28 -0
  46. package/dist/commands/serve-signin.js +90 -0
  47. package/dist/commands/signin-page.d.ts +49 -0
  48. package/dist/commands/signin-page.js +250 -0
  49. package/dist/commands/skills.js +87 -50
  50. package/dist/commands/tool-help.d.ts +69 -0
  51. package/dist/commands/tool-help.js +189 -0
  52. package/dist/commands/tool-input.d.ts +22 -0
  53. package/dist/commands/tool-input.js +220 -0
  54. package/dist/commands/tools.d.ts +12 -0
  55. package/dist/commands/tools.js +306 -0
  56. package/dist/commands/update.d.ts +11 -0
  57. package/dist/commands/update.js +92 -0
  58. package/dist/commands/vscode.d.ts +65 -0
  59. package/dist/commands/vscode.js +173 -0
  60. package/dist/commands/wrappers.d.ts +23 -0
  61. package/dist/commands/wrappers.js +102 -0
  62. package/dist/completion.js +3 -80
  63. package/dist/config.d.ts +3 -0
  64. package/dist/config.js +80 -3
  65. package/dist/constants.d.ts +51 -38
  66. package/dist/constants.js +53 -109
  67. package/dist/index.js +152 -355
  68. package/dist/json-text.d.ts +12 -0
  69. package/dist/json-text.js +60 -0
  70. package/dist/router.d.ts +39 -0
  71. package/dist/router.js +59 -0
  72. package/dist/skills-registry.js +12 -3
  73. package/dist/utils/banner.d.ts +9 -0
  74. package/dist/utils/banner.js +41 -0
  75. package/dist/utils/browser.d.ts +19 -0
  76. package/dist/utils/browser.js +31 -0
  77. package/dist/utils/exec.d.ts +43 -0
  78. package/dist/utils/exec.js +61 -0
  79. package/dist/utils/http.d.ts +53 -0
  80. package/dist/utils/http.js +124 -0
  81. package/dist/utils/install-method.d.ts +36 -0
  82. package/dist/utils/install-method.js +48 -0
  83. package/dist/utils/journal.d.ts +57 -0
  84. package/dist/utils/journal.js +188 -0
  85. package/dist/utils/key-check.d.ts +20 -0
  86. package/dist/utils/key-check.js +24 -0
  87. package/dist/utils/update-check.d.ts +23 -0
  88. package/dist/utils/update-check.js +116 -0
  89. package/dist/utils/width.d.ts +32 -0
  90. package/dist/utils/width.js +73 -0
  91. package/package.json +8 -4
  92. package/vscode-extension/README.md +6 -0
  93. package/vscode-extension/aisa-vscode.vsix +0 -0
  94. package/dist/commands/finance.d.ts +0 -17
  95. package/dist/commands/finance.js +0 -342
  96. package/dist/commands/run.d.ts +0 -28
  97. package/dist/commands/run.js +0 -182
  98. package/dist/commands/search.d.ts +0 -9
  99. package/dist/commands/search.js +0 -81
  100. package/dist/commands/twitter.d.ts +0 -138
  101. package/dist/commands/twitter.js +0 -374
  102. package/dist/commands/video.d.ts +0 -49
  103. package/dist/commands/video.js +0 -311
@@ -0,0 +1,1599 @@
1
+ import { MCP_DEFAULT_SLUGS } from "../constants.js";
2
+ import { INSTALLERS } from "./install.js";
3
+ import { defaultModelsFor } from "./llm-config.js";
4
+ import { stripped } from "./mcp.js";
5
+ import { BRAND_LOGOS } from "./brand-logos.js";
6
+ import { STEP_AGENT, AGENT_ORDER, agentRank, AGENT_BADGE, AGENT_SIDE_TITLES, AGENT_NOTES, AGENT_HAVE_NOTES, installOffer, t, fill, LANGS, LANG_LABEL, NAV, STEP_TITLES, STEP_WELCOME, STEP_MODELS, STEP_CAPS, STEP_INSTALL, STEP_DONE, FILE_MODEL_NOTE, FILE_MODEL_FALLBACK, BACKUP_COPY, CATEGORY_BLURB, runtimeCopy, NEXT_WORD, SUPERSEDED, } from "./flow.js";
7
+ import { RED, RED_CTA, INK, PAPER, I, LOGO, CATEGORY_ICON, EXAMPLES, CLIENT_LOGOS, CODEX_FACE, CLAUDE_BOT, OPENCODE_MARK, } from "./connect-shared.js";
8
+ import { AISA_PROVIDER_ID } from "../constants.js";
9
+ import { VSCODE_MODELS } from "./vscode.js";
10
+ /**
11
+ * T2 — the guided six-step connect flow.
12
+ *
13
+ * One page, six panes, a narrow step rail on the left and the current step
14
+ * filling the rest of the screen. The shape is borrowed from the onboarding
15
+ * flows that feel effortless (Sapiom's six-step rail was the reference): the
16
+ * rail says where you are, the main area has room to actually explain the
17
+ * step instead of cramming everything into one sidebar.
18
+ *
19
+ * 1 Welcome what you are about to get
20
+ * 2 Your agent which tool to connect (install it if missing)
21
+ * 3 Models which models it runs on, and how easy it is to switch
22
+ * 4 Capabilities live data — a grid of capability areas, servers inside
23
+ * 5 Install sign in, install, wire up — animated, one tick at a time
24
+ * 6 Done congratulations, recap, balance, launch, try-it-now
25
+ *
26
+ * The install tab parks on step 5 and the success tab the process opens lands
27
+ * on step 6; both are this same page, hydrated from GET /status, so from
28
+ * either tab every other step remains one click away. Steps 1–4 turn
29
+ * read-only once the run has started.
30
+ *
31
+ * The T1 flow (renderPage / renderDone in connect.ts) is untouched; the
32
+ * server picks a template per run and both share the same endpoints.
33
+ */
34
+ /** Providers shown on the Models step. Model names are from the live
35
+ * `aisa models` catalogue (2026-08-23); a representative few per provider. */
36
+ const PROVIDERS = [
37
+ { id: "claude", name: "Claude", models: "Sonnet 5 · Opus 5 · Haiku 4.5" },
38
+ { id: "openai", name: "OpenAI", models: "GPT-5.5 · GPT-5.3-codex · 5.4-mini" },
39
+ { id: "gemini", name: "Gemini", models: "Gemini 3.5 Flash" },
40
+ { id: "deepseek", name: "DeepSeek", models: "V4 Pro · V4 Flash · R1" },
41
+ { id: "kimi", name: "Kimi", models: "K3 · K2.7-code · K2 thinking" },
42
+ { id: "glm", name: "GLM (Zhipu)", models: "GLM-5.2 · GLM-5.1 · GLM-5" },
43
+ { id: "qwen", name: "Qwen", models: "Qwen3.7 Max · Qwen3 Coder" },
44
+ { id: "grok", name: "Grok (xAI)", models: "Grok 4.6 · Grok 4.5" },
45
+ ];
46
+ /** Clients we do not connect yet but will, shown so the roadmap is visible. */
47
+ const COMING_SOON = [
48
+ { id: "claude-ai", label: "Claude.ai", note: "connector" },
49
+ { id: "chatgpt", label: "ChatGPT", note: "connector" },
50
+ ];
51
+ /** One paragraph per agent on what connecting it means, for the Your-agent
52
+ * step's side column. */
53
+ /** Why the Models step has nothing to offer a file-configured client. */
54
+ /** Backup-mode consent copy, per client — the same contract T1 shows. */
55
+ /** The wordmark recoloured for the paper background: the white "sa" of the
56
+ * dark-background original becomes the ink colour via currentColor. */
57
+ const LOGO_INK = LOGO.replace(/#FFFFFF/g, "currentColor");
58
+ function normCategory(c) {
59
+ return /^search/i.test(c) ? "Search & Research" : c;
60
+ }
61
+ /** lucide:menu — same 2px round-cap line as the rest of the page's marks. */
62
+ const MENU_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">' +
63
+ '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" ' +
64
+ 'stroke-width="2" d="M4 5h16M4 12h16M4 19h16"/></svg>';
65
+ export function renderT2Page(servers, clients, token, keyed, canInstall,
66
+ /**
67
+ * Whether this machine still needs the AIsa CLI installed. Probed by the
68
+ * caller, never in here: this function's output is snapshot-tested byte
69
+ * for byte, and a live probe inside it froze one machine's answer into
70
+ * the baseline — green wherever the CLI happened to be installed, red on
71
+ * every CI runner (runs 74-84, 2026-09-03).
72
+ */
73
+ needsCli, view,
74
+ /**
75
+ * Defaults to English so this signature could grow without moving a byte of
76
+ * the rendered page: the picker that will set it is a later step, and the
77
+ * snapshots guarding this refactor have to stay green in between.
78
+ */
79
+ lang = "en") {
80
+ const T = (x) => t(x, lang);
81
+ /** A per-agent map of Text, flattened to the current language. */
82
+ const resolved = (m) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, t(v, lang)]));
83
+ const totalTools = servers.reduce((n, s) => n + s.toolCount, 0);
84
+ const cats = new Map();
85
+ for (const s of servers) {
86
+ const c = normCategory(s.category);
87
+ cats.set(c, [...(cats.get(c) ?? []), s]);
88
+ }
89
+ const ORDER = ["Search & Research", "Finance", "Social", "Sales"];
90
+ const rank = (c) => (ORDER.indexOf(c) === -1 ? 99 : ORDER.indexOf(c));
91
+ const catList = [...cats.entries()].sort((a, b) => rank(a[0]) - rank(b[0]));
92
+ // ── data handed to the page script ──
93
+ const SERVERS = servers.map((s) => ({
94
+ slug: s.slug,
95
+ endpoint: s.endpoint,
96
+ name: stripped(s.name),
97
+ category: normCategory(s.category),
98
+ toolCount: s.toolCount,
99
+ description: s.description,
100
+ }));
101
+ // Windsurf is not part of this flow (its file path stays in T1). claude.ai
102
+ // on the web is parked until AIsa is in the Connector Directory — the
103
+ // hand-off page below is ready, the card is not offered; see mcp.md T20.
104
+ const CLIENTS = clients
105
+ .map((c) => ({
106
+ id: c.id,
107
+ label: c.label,
108
+ kind: c.kind,
109
+ detected: c.detected,
110
+ detail: c.detail,
111
+ installable: !c.detected && Boolean(INSTALLERS[c.id]) && canInstall,
112
+ command: INSTALLERS[c.id]?.command ?? "",
113
+ }));
114
+ const MODEL_FOR = Object.fromEntries(clients.map((c) => [c.id, defaultModelsFor(c.id).model]));
115
+ // ── step 1: welcome ──
116
+ const logoStrip = PROVIDERS.slice(0, 6).map((p) => `<span class="blogo" title="${p.name}">${BRAND_LOGOS[p.id] ?? ""}</span>`).join("");
117
+ const welcome = `
118
+ <h1>${T(STEP_WELCOME.h1).replace("Every major model", "<em>Every major model</em>")}</h1>
119
+ <p class="lede">${T(STEP_WELCOME.lede).replace("models and the real", "models <b>and</b> the real")}</p>
120
+ <div class="feat">
121
+ <div class="ftile"><div class="fico">${I.sparkles}</div>
122
+ <h3>${T(STEP_WELCOME.tiles[0].h3)}</h3>
123
+ <p>${T(STEP_WELCOME.tiles[0].p)}</p>
124
+ <div class="strip">${logoStrip}</div></div>
125
+ <div class="ftile"><div class="fico">${I.finance}</div>
126
+ <h3>${T(STEP_WELCOME.tiles[1].h3)}</h3>
127
+ <p>${T(STEP_WELCOME.tiles[1].p)}</p></div>
128
+ <div class="ftile"><div class="fico">${I.terminal}</div>
129
+ <h3>${T(STEP_WELCOME.tiles[2].h3)}</h3>
130
+ <p>${T(STEP_WELCOME.tiles[2].p)}</p></div>
131
+ <div class="ftile"><div class="fico">${I.shield}</div>
132
+ <h3>${T(STEP_WELCOME.tiles[3].h3)}</h3>
133
+ <p>${T(STEP_WELCOME.tiles[3].p)}</p></div>
134
+ </div>`;
135
+ // ── step 2: your agent ──
136
+ // Order comes from the flow definition — the terminal numbers its menu from
137
+ // the same list, so "pick 2" cannot mean two different agents.
138
+ void AGENT_ORDER;
139
+ const shown = CLIENTS.filter((c) => c.detected || c.installable).sort((a, b) => agentRank(a.id) - agentRank(b.id));
140
+ const firstPick = shown.find((c) => c.detected) ?? shown[0];
141
+ const rest = CLIENTS.filter((c) => !c.detected && !c.installable);
142
+ const clientCard = (c, checked) => `
143
+ <label class="tile agent${checked ? " on" : ""}" data-cid="${c.id}">
144
+ <input type="radio" class="dot" name="client" value="${c.id}"${checked ? " checked" : ""}${c.installable ? ' data-install="1"' : ""}>
145
+ <span class="tlogo${c.id === "cursor" ? " mono" : ""}">${BRAND_LOGOS[c.id] ?? (c.id === "claude-desktop" ? BRAND_LOGOS["claude-ai"] : undefined) ?? CLIENT_LOGOS[c.id] ?? I.terminal}</span>
146
+ <span class="tbody"><span class="thead"><span class="tname">${c.label}</span></span>
147
+ <span class="tbrief" data-brief>${c.detected ? c.detail : installOffer(c.command ?? "", lang)}</span></span>
148
+ ${c.kind === "web" ? `<span class="badge web end">${T(AGENT_BADGE.web)}</span>` : c.detected ? `<span class="badge ok end">${T(AGENT_BADGE.detected)}</span>` : `<span class="badge todo end" data-badge>${T(AGENT_BADGE.absent)}</span>`}</label>`;
149
+ const agentCards = shown.map((c) => clientCard(c, c === firstPick)).join("");
150
+ // Fixed order: Claude Desktop, ChatGPT, Cursor, VS Code — by how likely a
151
+ // reader is to care, not by which list they come from.
152
+ const chipOrder = ["claude-desktop", "claude-ai", "chatgpt", "cursor", "vscode"];
153
+ // (vscode only lands here when it has never been run on this machine)
154
+ const chips = [
155
+ ...rest.map((c) => ({ id: c.id, html: `<span class="chip">${BRAND_LOGOS[c.id] ?? ""}${c.label} <i>${T(AGENT_BADGE.notFound)}</i></span>` })),
156
+ ...COMING_SOON.map((c) => ({ id: c.id, html: `<span class="chip">${BRAND_LOGOS[c.id] ?? ""}${c.label} <i>${c.note} · ${T(AGENT_BADGE.soon)}</i></span>` })),
157
+ ].sort((a, b) => chipOrder.indexOf(a.id) - chipOrder.indexOf(b.id));
158
+ const restChips = chips.length ? `<div class="soon">${chips.map((c) => c.html).join("")}</div>` : "";
159
+ const agent = `
160
+ <h1>${T(STEP_AGENT.question).replace("plug into", "<em>plug into</em>")}</h1>
161
+ <p class="lede">${T(STEP_AGENT.lede)}</p>
162
+ <div class="grid1">${agentCards}</div>
163
+ ${restChips}
164
+ <div class="side">
165
+ <h3 id="agentNoteTitle">${T(AGENT_SIDE_TITLES.how)}</h3>
166
+ <p id="agentNote"></p>
167
+ <h3 id="agentHaveTitle">${T(AGENT_SIDE_TITLES.have)}</h3>
168
+ <p id="agentHave"></p>
169
+ </div>`;
170
+ // ── step 3: models ──
171
+ const providerTiles = PROVIDERS.map((p) => `<div class="ptile"><span class="blogo lg">${BRAND_LOGOS[p.id] ?? ""}</span>
172
+ <span class="pname">${p.name}</span><span class="pmodels">${p.models}</span></div>`).join("");
173
+ const models = `
174
+ <h1>${T(STEP_MODELS.h1).replace("one account", "<em>one account</em>")}</h1>
175
+ <p class="lede">${T(STEP_MODELS.lede)}</p>
176
+ <div class="pgrid">${providerTiles}</div>
177
+ <div class="callout">${I.sparkles}<div>${T(STEP_MODELS.callout)}</div></div>
178
+ <p class="rerun">${T(STEP_MODELS.rerun)}</p>
179
+
180
+ <h2>${T(STEP_MODELS.h2Prefix)}<span id="mClient">${T(STEP_MODELS.yourAgent)}</span>${T(STEP_MODELS.h2Suffix)}</h2>
181
+ <div id="mFresh" class="grid1 choice" style="display:none">
182
+ <label class="tile on"><input type="radio" class="dot" name="lmodeFresh" value="switch" checked>
183
+ <span class="tbody"><span class="thead"><span class="tname">${T(STEP_MODELS.freshSwitch.name)}</span><span class="badge rec">${T(STEP_MODELS.recommended)}</span></span>
184
+ <span class="tbrief">${T(STEP_MODELS.freshSwitch.brief).replace("{model}", '<b id="mModel"></b>')}</span></span></label>
185
+ <label class="tile"><input type="radio" class="dot" name="lmodeFresh" value="skip">
186
+ <span class="tbody"><span class="thead"><span class="tname">${T(STEP_MODELS.notNow.name)}</span></span>
187
+ <span class="tbrief">${T(STEP_MODELS.notNow.briefFresh)}</span></span></label>
188
+ </div>
189
+ <div id="mDetected" class="grid1 choice" style="display:none">
190
+ <label class="tile on"><input type="radio" class="dot" name="lmodeDet" value="backup" checked>
191
+ <span class="tbody"><span class="thead"><span class="tname">${T(STEP_MODELS.backup.name)}</span><span class="badge rec">${T(STEP_MODELS.recommended)}</span></span>
192
+ <span class="tbrief" id="mBackup"></span></span></label>
193
+ <label class="tile" id="mSwitch"><input type="radio" class="dot" name="lmodeDet" value="switch">
194
+ <span class="tbody"><span class="thead"><span class="tname">${T(STEP_MODELS.switchIt.name)}</span></span>
195
+ <span class="tbrief">${T(STEP_MODELS.switchIt.brief).replace("{model}", '<b id="mModel2"></b>')}</span></span></label>
196
+ <label class="tile"><input type="radio" class="dot" name="lmodeDet" value="skip">
197
+ <span class="tbody"><span class="thead"><span class="tname">${T(STEP_MODELS.notNow.name)}</span></span>
198
+ <span class="tbrief">${T(STEP_MODELS.notNow.briefDetected)}</span></span></label>
199
+ </div>
200
+ <div id="mFile" class="callout" style="display:none">${I.shield}<div id="mFileText"></div></div>
201
+ <div id="modelwarn" class="modelwarn" style="display:none">
202
+ <div class="mw-head">${T(STEP_MODELS.warn.head)}</div>
203
+ <div class="mw-body">${T(STEP_MODELS.warn.body)}</div>
204
+ <button type="button" class="mw-fix" id="modelfix">${T(STEP_MODELS.warn.fix)}</button>
205
+ </div>`;
206
+ // ── step 4: capabilities ──
207
+ const catTiles = catList
208
+ .map(([cat, list]) => {
209
+ const tools = list.reduce((n, s) => n + s.toolCount, 0);
210
+ return `<button type="button" class="ctile" data-cat="${cat}">
211
+ <span class="cico">${CATEGORY_ICON[cat] ?? I.sparkles}</span>
212
+ <span class="cname">${cat}</span>
213
+ <span class="cmeta">${fill(STEP_CAPS.tileMeta, lang, { n: list.length, noun: list.length > 1 ? T(STEP_CAPS.serversWord) : T(STEP_CAPS.serverWord), tools })}</span>
214
+ <span class="cblurb">${CATEGORY_BLURB[cat] ? T(CATEGORY_BLURB[cat]) : ""}</span>
215
+ <span class="csel" data-csel></span></button>`;
216
+ })
217
+ .join("");
218
+ const serverTiles = catList
219
+ .map(([cat, list]) => list
220
+ .map((s) => {
221
+ const checked = MCP_DEFAULT_SLUGS.includes(s.slug);
222
+ return `<label class="stile${checked ? " on" : ""}" data-cat="${cat}">
223
+ <input type="checkbox" name="server" value="${s.slug}"${checked ? " checked" : ""}>
224
+ <span class="tbody"><span class="thead"><span class="tname">${stripped(s.name)}</span>
225
+ <span class="badge">${s.toolCount} tools</span></span>
226
+ <span class="sdesc">${s.description}</span></span></label>`;
227
+ })
228
+ .join(""))
229
+ .join("");
230
+ const caps = `
231
+ <h1>${T(STEP_CAPS.h1).replace("reach", "<em>reach</em>")}</h1>
232
+ <p class="lede">${fill(STEP_CAPS.counts, lang, { areas: catList.length, servers: servers.length, tools: totalTools })} ${T(STEP_CAPS.ledeTail)}</p>
233
+ <div class="cgrid">${catTiles}</div>
234
+ <div class="spanel" id="spanel">
235
+ <div class="sphead"><h2 id="spTitle">${T(STEP_CAPS.pickArea)}</h2><button type="button" class="link" id="spAll">${T(STEP_CAPS.selectAll)}</button></div>
236
+ <div class="sgrid" id="sgrid">${serverTiles}</div>
237
+ </div>
238
+ <div class="tally" id="tally"></div>`;
239
+ // ── step 5: install ──
240
+ const install = `
241
+ <h1 id="inTitle">${T(STEP_INSTALL.h1).replace("connect", "<em>connect</em>").replace("接线", "<em>接线</em>")}</h1>
242
+ <p class="lede" id="inLede">${T(STEP_INSTALL.lede)}</p>
243
+ <div class="backfrom" id="backfrom" hidden></div>
244
+ <div class="plan" id="plan"></div>
245
+ <div class="barwrap" id="barwrap" style="display:none"><div class="barfill" id="barfill"></div></div>
246
+ <div class="barnote" id="barnote"></div>
247
+ <div class="authnote">${I.shield}<div>${keyed ? T(STEP_INSTALL.authKeyed) : T(STEP_INSTALL.authFresh)}</div></div>
248
+ <div id="inResult" class="fine"></div>`;
249
+ // ── step 6: done (filled by the page script from /status) ──
250
+ const done = `<div id="doneBody"><div class="eyebrow">${T(STEP_DONE.eyebrow)}</div>
251
+ <h1>${T(STEP_DONE.h1)}</h1><p class="lede">${T(STEP_DONE.lede)}</p></div>`;
252
+ const rail = STEP_TITLES.map((s) => `<button type="button" class="rstep" data-step="${s.n}">
253
+ <span class="rn">${s.n}</span><span class="rt"><span class="rtitle">${T(s.title)}</span><span class="rsub">${T(s.sub)}</span></span></button>`).join("");
254
+ // Top-right, and it sticks: the choice is posted back to the CLI, which
255
+ // stores it, so the terminal beside this page switches too and the next run
256
+ // opens in the same language. A picker that only changed the page would
257
+ // leave the two surfaces disagreeing — the thing flow.ts exists to prevent.
258
+ // A two-button language toggle sat in the corner of every step, on a page
259
+ // whose whole job is to ask six questions — a seventh control, permanently
260
+ // lit, competing with them. It is a setting, not a step: behind a menu it
261
+ // is one click further away and stops being part of the conversation.
262
+ const langPicker = `<div class="more">
263
+ <button type="button" class="morebtn" id="morebtn" aria-haspopup="true" aria-expanded="false" aria-label="Menu">${MENU_ICON}</button>
264
+ <div class="moremenu" id="moremenu" hidden>${LANGS.map((code) => `<button type="button" class="lang${code === lang ? " on" : ""}" data-lang="${code}">${LANG_LABEL[code]}</button>`).join("")}</div>
265
+ </div>`;
266
+ const body = `
267
+ <div class="wrap">
268
+ <div id="superseded" class="ssup" style="display:none"><div class="ssbox">
269
+ <h2 id="sstitle">${T(SUPERSEDED.title)}</h2>
270
+ <p id="ssbody">${T(SUPERSEDED.body)} <b id="sscount">60</b> ${T(SUPERSEDED.seconds)}.</p>
271
+ <div class="sscta">
272
+ <p>${T(SUPERSEDED.console)} <a href="https://${SUPERSEDED.consoleDomain}?source=aisa_cli_connect" target="_blank" rel="noopener noreferrer">${SUPERSEDED.consoleDomain}</a>.</p>
273
+ <p>${T(SUPERSEDED.againBefore)} <code>${SUPERSEDED.againCmd}</code> ${T(SUPERSEDED.againAfter)}</p>
274
+ </div>
275
+ </div></div>
276
+ <nav class="rail">
277
+ <a class="railhead" href="https://aisa.one/?source=aisa_cli_connect" target="_blank" rel="noopener noreferrer">${LOGO_INK}<span>Connect</span></a>
278
+ <div class="railsteps">${rail}</div>
279
+ </nav>
280
+ <section class="main">
281
+ <div class="content">
282
+ <div class="topnav"><button type="button" class="ghost" id="back">${T(NAV.back)}</button>${langPicker}</div>
283
+ <div class="pane" data-pane="1">${welcome}</div>
284
+ <div class="pane" data-pane="2">${agent}</div>
285
+ <div class="pane" data-pane="3">${models}</div>
286
+ <div class="pane" data-pane="4">${caps}</div>
287
+ <div class="pane" data-pane="5">${install}</div>
288
+ <div class="pane" data-pane="6">${done}</div>
289
+ <div class="navbar">
290
+ <button type="button" class="cta" id="next">${T(NEXT_WORD[1])}${I.arrow}</button>
291
+ <span class="navnote" id="navnote"></span>
292
+ </div>
293
+ </div>
294
+ </section>
295
+ </div>
296
+ <script>
297
+ (function () {
298
+ var TOKEN = ${JSON.stringify(token)};
299
+ // The sign-in tab sends the reader back here with this on the URL. Without
300
+ // it the return reads as an unexplained reload of a page they never left
301
+ // on purpose — they were redirected away by the sign-in and redirected
302
+ // home again, and neither move was theirs.
303
+ var CAME_BACK = /[?&]signedin=1/.test(location.search);
304
+ var VIEW = ${JSON.stringify(view)};
305
+ var SERVERS = ${JSON.stringify(SERVERS)};
306
+ var CLIENTS = ${JSON.stringify(CLIENTS)};
307
+ var MODEL_FOR = ${JSON.stringify(MODEL_FOR)};
308
+ var EXAMPLES = ${JSON.stringify(EXAMPLES)};
309
+ var BACKUP_COPY = ${JSON.stringify(resolved(BACKUP_COPY))};
310
+ var COPY = ${JSON.stringify(runtimeCopy(lang))};
311
+ var AGENT_NOTES = ${JSON.stringify(resolved(AGENT_NOTES))};
312
+ var HAVE_NOTES = ${JSON.stringify(resolved(AGENT_HAVE_NOTES))};
313
+ var FILE_MODEL_NOTE = ${JSON.stringify(resolved(FILE_MODEL_NOTE))};
314
+ var NEEDS_CLI = ${JSON.stringify(needsCli)};
315
+ var PROVIDER_ID = ${JSON.stringify(AISA_PROVIDER_ID)};
316
+ var ART = ${JSON.stringify({ codex: CODEX_FACE, claude: CLAUDE_BOT, opencode: OPENCODE_MARK })};
317
+ var LABEL = {}; CLIENTS.forEach(function (c) { LABEL[c.id] = c.label; });
318
+ var BY_SLUG = {}; SERVERS.forEach(function (s) { BY_SLUG[s.slug] = s; });
319
+ var ICON_COPY = ${JSON.stringify(I.copy)};
320
+ var ICON_CHECK = ${JSON.stringify(I.check)};
321
+ var VSCODE_LOGO = ${JSON.stringify(BRAND_LOGOS.vscode ?? "")};
322
+ var CURSOR_LOGO = ${JSON.stringify(BRAND_LOGOS.cursor ?? "")};
323
+ var CLAUDE_DT_LOGO = ${JSON.stringify(BRAND_LOGOS["claude-ai"] ?? "")};
324
+ var MODEL_COUNT = ${VSCODE_MODELS.length};
325
+
326
+ var $ = function (s, r) { return (r || document).querySelector(s); };
327
+ var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
328
+
329
+ // ── navigation ──
330
+ var current = 1, unlocked = 1, locked = false, phase = "selecting";
331
+ var panes = $$(".pane"), rails = $$(".rstep");
332
+ var nextBtn = $("#next"), backBtn = $("#back"), navnote = $("#navnote");
333
+ var ARROW = nextBtn.innerHTML.replace(/^[^<]*/, "");
334
+ var NEXT_WORD = ${JSON.stringify(resolved(NEXT_WORD))};
335
+
336
+ // Switching reloads: the page is rendered by the CLI, so the new language
337
+ // arrives the same way the first one did. Storing it first means the
338
+ // terminal picks it up too.
339
+ // The menu: opens on click, closes on the next click anywhere else or on
340
+ // Escape. Nothing here is destructive, so it does not need to be modal —
341
+ // it needs to get out of the way, which is the opposite requirement.
342
+ (function () {
343
+ var btn = $("#morebtn"), menu = $("#moremenu");
344
+ if (!btn || !menu) return;
345
+ function setOpen(on) {
346
+ menu.hidden = !on;
347
+ btn.setAttribute("aria-expanded", on ? "true" : "false");
348
+ }
349
+ btn.addEventListener("click", function (e) {
350
+ e.stopPropagation();
351
+ setOpen(menu.hidden);
352
+ });
353
+ document.addEventListener("click", function (e) {
354
+ if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) setOpen(false);
355
+ });
356
+ document.addEventListener("keydown", function (e) {
357
+ if (e.key === "Escape" && !menu.hidden) setOpen(false);
358
+ });
359
+ })();
360
+
361
+ $$(".lang").forEach(function (b) {
362
+ b.addEventListener("click", function () {
363
+ if (b.classList.contains("on")) return;
364
+ fetch("/lang?token=" + TOKEN, { method: "POST",
365
+ headers: { "content-type": "application/json" },
366
+ body: JSON.stringify({ lang: b.dataset.lang }) })
367
+ .then(function () { location.reload(); })
368
+ .catch(function () { location.reload(); });
369
+ });
370
+ });
371
+
372
+ // ── the shared draft ──
373
+ // The terminal is looking at the same run. Publishing every change here is
374
+ // what lets it show what was ticked in this page, and pulling lets this
375
+ // page show what was answered there. Neither is the master; rev decides
376
+ // who was late and hands them the current state instead of accepting a
377
+ // stale copy. (No backticks in here: this whole script is a template
378
+ // literal, and one would end it.)
379
+ // The ready flag gates writing until this page has read what is there.
380
+ // Publishing first was self-defeating: the arrival publish bumped rev to
381
+ // match the server, so the very next poll saw "nothing new" and the page
382
+ // never adopted a run the terminal had already moved along. A surface has
383
+ // to read the shared state before it is allowed to speak for it.
384
+ var rev = 0, pushing = false, adopting = false, ready = false;
385
+ function publish(patch) {
386
+ if (!ready || pushing) return; pushing = true;
387
+ fetch("/select?token=" + TOKEN, { method: "POST",
388
+ headers: { "content-type": "application/json" },
389
+ body: JSON.stringify(Object.assign({ rev: rev }, patch)) })
390
+ .then(function (r) { return r.json(); })
391
+ .then(function (b) { if (typeof b.rev === "number") rev = b.rev; })
392
+ .catch(function () {})
393
+ .then(function () { pushing = false; });
394
+ }
395
+ function publishDraft(step) {
396
+ // Applying someone else's change must not be republished as if it were
397
+ // ours: that bumps rev for no reason and can bounce between surfaces.
398
+ if (adopting) return;
399
+ var c = chosenClient();
400
+ publish({ step: step, draft: {
401
+ clients: c ? [c.value] : [],
402
+ install: c && c.dataset.install ? [c.value] : [],
403
+ llmMode: llmMode(),
404
+ servers: pickedServers(),
405
+ } });
406
+ }
407
+ /** Apply what another surface chose, without echoing it straight back. */
408
+ function adopt(s) {
409
+ if (typeof s.rev !== "number" || s.rev === rev || !s.draft) return;
410
+ rev = s.rev;
411
+ adopting = true;
412
+ try {
413
+ var d = s.draft;
414
+ if (d.clients && d.clients[0]) {
415
+ var el = $('input[name="client"][value="' + d.clients[0] + '"]');
416
+ if (el && !el.checked) { el.checked = true; syncAgent(); }
417
+ }
418
+ if (d.llmMode) {
419
+ var m = $('input[name="lmodeDet"][value="' + d.llmMode + '"]')
420
+ || $('input[name="lmodeFresh"][value="' + d.llmMode + '"]');
421
+ if (m && !m.checked) { m.checked = true; syncModels(); }
422
+ }
423
+ if (d.servers) {
424
+ var want = {}; d.servers.forEach(function (x) { want[x] = 1; });
425
+ $$('input[name="server"]').forEach(function (i) {
426
+ if (!!want[i.value] !== i.checked) { i.checked = !!want[i.value]; }
427
+ });
428
+ syncCaps();
429
+ }
430
+ // Answering in the terminal advances the run, so this page has to unlock
431
+ // as far as the run has gone — gating on the Next button alone meant the
432
+ // terminal could move to step 3 and this side would sit on step 1,
433
+ // ignoring the update as out of range.
434
+ //
435
+ // It moves this page in both directions. Escape over there rewinds the run
436
+ // and pushes the earlier step, and following only forward left that looking
437
+ // broken: the terminal printed "back to step 2" while this side stayed on
438
+ // 3. unlocked is a high-water mark, so arriving from behind never re-locks
439
+ // a step that was already reached — the rail stays clickable.
440
+ if (s.currentStep && s.currentStep !== current) {
441
+ unlocked = Math.max(unlocked, Math.min(s.currentStep, 5));
442
+ renderSteps();
443
+ go(s.currentStep);
444
+ }
445
+ } finally { adopting = false; }
446
+ }
447
+
448
+ // While choosing, watch the shared draft so a choice made in the terminal
449
+ // appears here. The run-progress poll below only starts after /apply, which
450
+ // is far too late: the whole point is to stay in step *before* anything is
451
+ // applied. Slower than that one (1.5s against 250ms) because a person
452
+ // ticking boxes is not a progress bar.
453
+ /**
454
+ * Enter progress mode for a run this page did not start.
455
+ *
456
+ * The install view only ever opened from this page's own apply, so a run
457
+ * driven from the terminal left step 5 unseen entirely — the page sat on
458
+ * step 4 through the whole install and only reappeared at step 6 when it
459
+ * was over. Watching the phase is what makes the step the user is actually
460
+ * living through visible on both surfaces.
461
+ */
462
+ function enterProgress(s) {
463
+ if (locked) return;
464
+ lockSelections();
465
+ serverSteps = s.steps || [];
466
+ shown = {}; lastFlip = 0;
467
+ unlocked = 5;
468
+ renderSteps();
469
+ go(5);
470
+ if (!ticker) ticker = setInterval(tick, 250);
471
+ }
472
+
473
+ // ── one status poller ──
474
+ //
475
+ // There used to be three, two of them running at the same time during
476
+ // selecting, all asking this one endpoint for different reasons. None of
477
+ // them ever stopped: a tab left open on a server that had gone kept asking
478
+ // once a second for as long as the tab stayed open, and kept a live
479
+ // countdown on screen for a page nothing was behind.
480
+ //
481
+ // So: one request, every consumer reads the same answer, the interval
482
+ // slackens when nothing is happening, failures back off, and enough of
483
+ // them in a row is taken to mean the run is over.
484
+ var lastStatus = null;
485
+ var pollFails = 0, pollTimer = null, pollStopped = false;
486
+ var POLL_GIVE_UP = 8;
487
+
488
+ function pollDelay(s) {
489
+ if (!s) return Math.min(30000, 1000 * Math.pow(2, pollFails));
490
+ if (s.phase !== "done" && s.phase !== "failed") return 1000;
491
+ if (s.superseded) return 1000;
492
+ // Idle on the results: the countdown only has to be right while it is
493
+ // on screen, and half an hour at one request a second is 1800 of them.
494
+ var left = s.closingAt ? (s.closingAt - Date.now()) / 1000 : 0;
495
+ return left <= 90 ? 1000 : 5000;
496
+ }
497
+
498
+ function schedule(s) {
499
+ if (pollStopped) return;
500
+ pollTimer = setTimeout(pollOnce, pollDelay(s));
501
+ }
502
+
503
+ /**
504
+ * Everything behind the dialog stops pretending to work.
505
+ *
506
+ * The dialog says the page is over; the button under it was still saying
507
+ * "Signing in…" with its spinner turning, which is the page arguing with
508
+ * itself. Whatever was in flight is not going to finish — the run that
509
+ * would have finished it is gone.
510
+ */
511
+ function settle() {
512
+ if (ticker) { clearInterval(ticker); ticker = null; }
513
+ document.body.classList.add("settled");
514
+ if (nextBtn) { nextBtn.disabled = true; }
515
+ }
516
+
517
+ function giveUp() {
518
+ pollStopped = true;
519
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
520
+ // Say so rather than leaving a countdown ticking against nothing.
521
+ var box = $("#superseded");
522
+ if (box) {
523
+ box.style.display = "flex";
524
+ $("#sstitle").textContent = ${JSON.stringify(T(SUPERSEDED.expiringTitle))};
525
+ $("#ssbody").textContent = ${JSON.stringify(T(SUPERSEDED.closed))};
526
+ settle();
527
+ }
528
+ }
529
+
530
+ function showClosing(s) {
531
+ if (!s.closingAt) return;
532
+ var left = Math.max(0, Math.ceil((s.closingAt - Date.now()) / 1000));
533
+ // Only in the last minute for a page that is simply timing out: a
534
+ // countdown running for half an hour is not a warning, it is furniture.
535
+ // Being superseded says so immediately, because that one is news.
536
+ if (!s.superseded && left > 60) return;
537
+ var box = $("#superseded");
538
+ box.style.display = "flex";
539
+ settle();
540
+ $("#sstitle").textContent = s.superseded
541
+ ? ${JSON.stringify(T(SUPERSEDED.title))}
542
+ : ${JSON.stringify(T(SUPERSEDED.expiringTitle))};
543
+ if (!s.superseded && !box.dataset.reworded) {
544
+ box.dataset.reworded = "1";
545
+ $("#ssbody").innerHTML =
546
+ ${JSON.stringify(T(SUPERSEDED.expiringBody))} +
547
+ ' <b id="sscount"></b> ' + ${JSON.stringify(T(SUPERSEDED.seconds))} + ".";
548
+ }
549
+ var c = $("#sscount");
550
+ if (c) c.textContent = String(left);
551
+ }
552
+
553
+ function pollOnce() {
554
+ fetch("/status?token=" + TOKEN)
555
+ .then(function (r) { if (!r.ok) throw 0; return r.json(); })
556
+ .then(function (s) {
557
+ pollFails = 0;
558
+ lastStatus = s;
559
+ showClosing(s);
560
+ if (s.phase === "selecting") {
561
+ if (!locked) adopt(s);
562
+ } else {
563
+ phase = s.phase;
564
+ serverSteps = s.steps;
565
+ syncClientCard(s.steps);
566
+ // A run this page did not start: show the step being lived through.
567
+ if (!locked) enterProgress(s);
568
+ if (s.phase === "done" || s.phase === "failed") document.title = "✓ AIsa Connected";
569
+ }
570
+ schedule(s);
571
+ })
572
+ .catch(function () {
573
+ // Unreachable, or answering with an error. Either way the thing this
574
+ // page was talking to is not there right now.
575
+ if (++pollFails >= POLL_GIVE_UP) { giveUp(); return; }
576
+ schedule(null);
577
+ });
578
+ }
579
+ pollOnce();
580
+
581
+ /**
582
+ * Nothing to go back to while the machine is being changed.
583
+ *
584
+ * Locking the inputs was not enough: the rail still navigated, and leaving
585
+ * step 5 publishes the step it lands on — which drags the terminal out of a
586
+ * run that is halfway through writing files. The choices stop being choices
587
+ * the moment they are being acted on, and the rail should say so rather
588
+ * than accept a click and quietly do something else.
589
+ *
590
+ * It thaws on its own the moment the run settles, either way: a finished
591
+ * run and a failed one are both things you are allowed to look back at.
592
+ */
593
+ function frozen() {
594
+ // Not the server's phase. In a dry run — and on a fast machine in a real
595
+ // one — the server reports "done" while the checklist here is still
596
+ // playing itself out a row at a time, and that visible stretch is
597
+ // exactly what a person means by "it is installing". Locking on the
598
+ // server's word thawed the rail in the middle of the animation.
599
+ //
600
+ // The honest signal is the results: locked when the run began, thawed
601
+ // when step 6 exists, which is the moment there is something to go back
602
+ // and look at.
603
+ return locked && unlocked < 6;
604
+ }
605
+
606
+ /**
607
+ * Repaint the rail on its own.
608
+ *
609
+ * It used to be painted only inside go(), so freezing after arriving at
610
+ * step 5 changed nothing on screen: the steps kept their pointer cursor
611
+ * and their hover, which is how a rail that refuses clicks still reads as
612
+ * clickable.
613
+ */
614
+ function paintRail() {
615
+ rails.forEach(function (r) {
616
+ var k = Number(r.dataset.step);
617
+ r.classList.toggle("active", k === current);
618
+ r.classList.toggle("done", k < current || (k <= unlocked && k !== current && (locked || k < current)));
619
+ r.classList.toggle("open", k <= unlocked);
620
+ // Says it before the click rather than swallowing it afterwards.
621
+ r.classList.toggle("frozen", frozen() && k < 5);
622
+ });
623
+ }
624
+
625
+ function go(n) {
626
+ if (n < 1 || n > 6 || n > unlocked) return;
627
+ if (frozen() && n < 5) return;
628
+ current = n;
629
+ // Only the step, never the draft. Publishing what is ticked here on mere
630
+ // arrival made opening the page look like answering it: the terminal saw
631
+ // the default agent appear and moved on, crediting a choice the user had
632
+ // not made. A draft is published when someone changes something.
633
+ publish({ step: n });
634
+ panes.forEach(function (p) { p.classList.toggle("show", Number(p.dataset.pane) === n); });
635
+ paintRail();
636
+ backBtn.style.visibility = n === 1 ? "hidden" : "visible";
637
+ if (n === 5) { renderInstallPane(); }
638
+ else if (n === 6) { nextBtn.style.display = "none"; navnote.textContent = ""; revealCheck(); }
639
+ else { nextBtn.style.display = ""; nextBtn.disabled = false; nextBtn.innerHTML = (locked ? "Next " : NEXT_WORD[n]) + ARROW; navnote.textContent = ""; }
640
+ if (n === 5 && unlocked >= 6) { nextBtn.style.display = ""; nextBtn.innerHTML = "See your results " + ARROW; }
641
+ window.scrollTo({ top: 0, behavior: "smooth" });
642
+ if (n === 2) syncAgent(); if (n === 3) syncModels(); if (n === 4) syncCaps();
643
+ }
644
+ var retryInFlight = false;
645
+ nextBtn.addEventListener("click", function (e) {
646
+ if (!nextBtn.dataset.retry) return;
647
+ // Not a step forward — a second attempt at the step that stopped.
648
+ e.stopImmediatePropagation();
649
+ e.preventDefault();
650
+ nextBtn.disabled = true;
651
+ nextBtn.textContent = "Signing in…";
652
+ delete nextBtn.dataset.retry;
653
+ retryInFlight = true;
654
+ // Whatever goes wrong, the button goes back to being a button. The first
655
+ // version turned into the words "Could not retry" and stayed there — a
656
+ // dead end where the whole point of this control is that there is a way
657
+ // out. It also said nothing about which of the two things went wrong.
658
+ var giveBack = function (why) {
659
+ retryInFlight = false;
660
+ nextBtn.disabled = false;
661
+ nextBtn.textContent = "Log in again";
662
+ nextBtn.dataset.retry = "1";
663
+ navnote.textContent = why;
664
+ };
665
+ fetch("/retry-signin?token=" + TOKEN, { method: "POST" })
666
+ .then(function (r) {
667
+ // A run started before this button existed has no such route. Saying
668
+ // "could not retry" for that is true and useless; the fix is a fresh
669
+ // run, and only this page knows to say so.
670
+ if (r.status === 404) throw new Error("old");
671
+ if (!r.ok) throw new Error("http");
672
+ return r.json();
673
+ })
674
+ .then(function (d) {
675
+ if (!d.ok) {
676
+ giveBack("This run is no longer waiting on a sign-in — run aisa connect again.");
677
+ return;
678
+ }
679
+ // Forget only the rows the run just reset. Clearing the whole map
680
+ // replayed the checklist from "Install the AIsa CLI", which is the
681
+ // same "it started over" the return-from-sign-in used to show — and
682
+ // just as untrue: those rows are still done.
683
+ (serverSteps || []).forEach(function (x) {
684
+ if (x.state !== "ok") delete shown[x.id];
685
+ });
686
+ lastFlip = Date.now();
687
+ unlocked = 5;
688
+ // Cleared once the run reports it is no longer waiting; until then the
689
+ // stale poll would keep re-offering the button.
690
+ var release = setInterval(function () {
691
+ if (lastStatus && !lastStatus.needsSignIn) { retryInFlight = false; clearInterval(release); }
692
+ }, 400);
693
+ document.body.classList.remove("settled");
694
+ if (!ticker) ticker = setInterval(tick, 250);
695
+ })
696
+ .catch(function (e) {
697
+ giveBack(e && e.message === "old"
698
+ ? "This page belongs to an older run that cannot retry — run aisa connect again."
699
+ : "Could not reach the run — it may have exited. Run aisa connect again.");
700
+ });
701
+ }, true);
702
+ rails.forEach(function (r) { r.addEventListener("click", function () { go(Number(r.dataset.step)); }); });
703
+ backBtn.addEventListener("click", function () { go(current - 1); });
704
+
705
+ // ── selection state ──
706
+ function chosenClient() { return $('input[name="client"]:checked'); }
707
+ function clientId() { var c = chosenClient(); return c ? c.value : null; }
708
+ function installing() { var c = chosenClient(); return Boolean(c && c.dataset.install === "1"); }
709
+ function clientKind() { var id = clientId(); var c = CLIENTS.filter(function (x) { return x.id === id; })[0]; return c ? c.kind : "file"; }
710
+ function llmMode() {
711
+ if (!clientId()) return "skip";
712
+ if (clientKind() !== "cli" && clientId() !== "vscode") return "skip";
713
+ if (installing()) return ($('input[name="lmodeFresh"]:checked') || {}).value || "skip";
714
+ return ($('input[name="lmodeDet"]:checked') || {}).value || "skip";
715
+ }
716
+ function pickedServers() { return $$('input[name="server"]:checked').map(function (i) { return i.value; }); }
717
+
718
+ // ── step 2 ──
719
+ function syncAgent() {
720
+ var id = clientId();
721
+ $("#agentNoteTitle").textContent = id ? "How " + LABEL[id] + " connects" : "How it connects";
722
+ $("#agentNote").innerHTML = id ? (AGENT_NOTES[id] || "") : "Pick an agent above.";
723
+ var have = id && HAVE_NOTES[id];
724
+ $("#agentHaveTitle").style.display = have ? "" : "none";
725
+ $("#agentHave").style.display = have ? "" : "none";
726
+ if (have) $("#agentHave").innerHTML = have;
727
+ $$(".tile.agent").forEach(function (t) { t.classList.toggle("on", t.querySelector("input").checked); });
728
+ }
729
+ $$('input[name="client"]').forEach(function (r) { r.addEventListener("change", function () { syncAgent(); armed = false; publishDraft(current); }); });
730
+
731
+ // ── step 3 ──
732
+ var armed = false;
733
+ function syncModels() {
734
+ var id = clientId(), fresh = installing(), kind = clientKind();
735
+ $("#mClient").textContent = id ? LABEL[id] : "your agent";
736
+ $("#mModel").textContent = MODEL_FOR[id] || ""; $("#mModel2").textContent = MODEL_FOR[id] || "";
737
+ var vsc = id === "vscode";
738
+ $("#mFresh").style.display = kind === "cli" && fresh ? "" : "none";
739
+ $("#mDetected").style.display = (kind === "cli" && !fresh) || vsc ? "" : "none";
740
+ $("#mFile").style.display = kind !== "cli" && !vsc ? "" : "none";
741
+ // VS Code has no "switch": Copilot's models cannot be replaced, only joined.
742
+ $("#mSwitch").style.display = vsc ? "none" : "";
743
+ if (vsc && $('input[name="lmodeDet"][value="switch"]').checked) $('input[name="lmodeDet"][value="backup"]').checked = true;
744
+ if ((kind === "cli" && !fresh) || vsc) $("#mBackup").innerHTML = BACKUP_COPY[id] || "";
745
+ if (kind !== "cli" && !vsc) $("#mFileText").innerHTML = FILE_MODEL_NOTE[id] || ${JSON.stringify(T(FILE_MODEL_FALLBACK))};
746
+ $$(".choice .tile").forEach(function (t) { t.classList.toggle("on", t.querySelector("input").checked); });
747
+ updateWarn();
748
+ }
749
+ function updateWarn() {
750
+ var need = installing() && llmMode() === "skip";
751
+ $("#modelwarn").style.display = need ? "" : "none";
752
+ if (!need) armed = false;
753
+ }
754
+ $$('input[name="lmodeFresh"], input[name="lmodeDet"]').forEach(function (r) { r.addEventListener("change", function () { syncModels(); publishDraft(current); }); });
755
+ $("#modelfix").addEventListener("click", function () {
756
+ $('input[name="lmodeFresh"][value="switch"]').checked = true; syncModels();
757
+ });
758
+
759
+ // ── step 4 ──
760
+ var activeCat = null;
761
+ function syncCaps() {
762
+ var picked = pickedServers();
763
+ $$(".ctile").forEach(function (t) {
764
+ var cat = t.dataset.cat;
765
+ var n = SERVERS.filter(function (s) { return s.category === cat && picked.indexOf(s.slug) !== -1; }).length;
766
+ var sel = t.querySelector("[data-csel]");
767
+ sel.textContent = n ? n + " selected" : "";
768
+ t.classList.toggle("has", n > 0);
769
+ t.classList.toggle("active", cat === activeCat);
770
+ });
771
+ $$(".stile").forEach(function (t) {
772
+ t.classList.toggle("on", t.querySelector("input").checked);
773
+ t.style.display = t.dataset.cat === activeCat ? "" : "none";
774
+ });
775
+ $("#spanel").classList.toggle("open", Boolean(activeCat));
776
+ $("#spTitle").textContent = activeCat ? activeCat : "Pick an area above";
777
+ var tools = picked.reduce(function (n, s) { return n + (BY_SLUG[s] ? BY_SLUG[s].toolCount : 0); }, 0);
778
+ $("#tally").innerHTML = picked.length
779
+ ? "<b>" + picked.length + " server" + (picked.length > 1 ? "s" : "") + "</b> · " + tools + " tools selected"
780
+ : COPY.nothingSelected;
781
+ }
782
+ $$(".ctile").forEach(function (t) { t.addEventListener("click", function () {
783
+ activeCat = activeCat === t.dataset.cat ? null : t.dataset.cat; syncCaps();
784
+ if (activeCat) $("#spanel").scrollIntoView({ block: "nearest", behavior: "smooth" });
785
+ }); });
786
+ $$('input[name="server"]').forEach(function (c) { c.addEventListener("change", function () { syncCaps(); publishDraft(current); }); });
787
+ $("#spAll").addEventListener("click", function () {
788
+ if (!activeCat) return;
789
+ var boxes = $$('.stile[data-cat="' + activeCat + '"] input');
790
+ var all = boxes.every(function (b) { return b.checked; });
791
+ boxes.forEach(function (b) { b.checked = !all; });
792
+ syncCaps();
793
+ });
794
+ // Open the area holding the default selection so the page never starts blank.
795
+ (function () { var first = $('input[name="server"]:checked'); if (first) activeCat = first.closest(".stile").dataset.cat; })();
796
+
797
+ // ── next button ──
798
+ nextBtn.addEventListener("click", function () {
799
+ if (current === 2 && !clientId()) { navnote.textContent = "Pick an agent first."; return; }
800
+ if (current === 3 && installing() && llmMode() === "skip" && !armed) {
801
+ armed = true; updateWarn();
802
+ var w = $("#modelwarn"); w.scrollIntoView({ block: "center", behavior: "smooth" });
803
+ w.style.animation = "none"; void w.offsetWidth; w.style.animation = "mwshake .45s";
804
+ nextBtn.innerHTML = "Continue without a model " + ARROW; return;
805
+ }
806
+ if (current === 4 && !pickedServers().length) { navnote.textContent = "Pick at least one server."; return; }
807
+ if (current === 5) { if (unlocked >= 6) go(6); else start(); return; }
808
+ unlocked = Math.max(unlocked, current + 1);
809
+ go(current + 1);
810
+ });
811
+
812
+ // ── step 5: plan preview, start, animated progress ──
813
+ var serverSteps = null; // what the server reports
814
+ var shown = {}; // step id -> state we are displaying
815
+ var lastFlip = 0, ticker = null;
816
+ var STATE_WORD = { pending: "waiting", running: "working…", ok: "done", fail: "failed", skip: "skipped" };
817
+ var MIN_DWELL = 1800; // a step stays visibly "working" and then "done" at least this long
818
+ var LOW_DWELL = 3000; // the balance step, when the account is running low
819
+
820
+ function planPreview() {
821
+ var id = clientId(), rows = [];
822
+ if (installing()) rows.push(["Install " + LABEL[id], "through its official installer, no sudo"]);
823
+ rows.push(["Install the AIsa CLI", NEEDS_CLI ? "npm install -g @aisa-one/cli — the aisa command for balance, top-up and key rotation" : "the aisa command is already on this machine"]);
824
+ if (!${keyed}) rows.push(["Sign in to AIsa", "one browser approval — it issues your key"]);
825
+ var n = pickedServers().length;
826
+ rows.push([clientKind() === "web" ? "Prepare " + n + " connector URL" + (n === 1 ? "" : "s") : id === "cursor" ? "Prepare " + n + " Cursor install link" + (n === 1 ? "" : "s") : "Add " + n + " MCP server" + (n === 1 ? "" : "s"), clientKind() === "web" ? "for you to add in claude.ai" : id === "cursor" ? "one click each, confirmed inside Cursor" : "to " + LABEL[id]]);
827
+ var m = llmMode();
828
+ if (m === "switch") rows.push(["Point its models at AIsa", MODEL_FOR[id] + " by default; reversible"]);
829
+ if (m === "backup") rows.push([COPY.planBackup[id] || COPY.planBackup.other, COPY.planBackupNote]);
830
+ rows.push(["Check your AIsa balance", "so an empty account is never a surprise"]);
831
+ return rows.map(function (r) {
832
+ return "<div class='step pending'><span class='mark'></span><span class='body'><span class='lbl'>" + r[0] +
833
+ "</span><span class='det'>" + r[1] + "</span></span><span class='st'>planned</span></div>";
834
+ }).join("");
835
+ }
836
+ function renderInstallPane() {
837
+ if (serverSteps) { renderSteps(); return; }
838
+ $("#plan").innerHTML = planPreview();
839
+ nextBtn.style.display = ""; nextBtn.disabled = true;
840
+ nextBtn.innerHTML = (installing() ? "Install &amp; connect " : "Connect ") + ARROW;
841
+ navnote.textContent = "Starting…";
842
+ // Arriving here is the decision: the plan shows for a beat, then runs.
843
+ // Only auto-start a run this page was navigated into by its own Next
844
+ // button. Arriving at step 5 because another surface said so is not
845
+ // consent: a terminal that publishes the step while still asking the user
846
+ // to confirm would have the run applied out from under it.
847
+ setTimeout(function () { if (current === 5 && !serverSteps && !locked && !adopting) start(); }, 500);
848
+ }
849
+ function renderSteps() {
850
+ var steps = serverSteps || [];
851
+ var rows = steps.map(function (s) {
852
+ var st = shown[s.id] || "pending";
853
+ var det = st === "pending" ? "" : (s.detail || "");
854
+ return "<div class='step " + st + "'><span class='mark'></span><span class='body'><span class='lbl'>" + s.label +
855
+ "</span>" + (det ? "<span class='det'>" + det + "</span>" : "") + "</span><span class='st'>" + STATE_WORD[st] + "</span></div>";
856
+ }).join("");
857
+ $("#plan").innerHTML = rows;
858
+ var settled = steps.filter(function (s) { return /ok|skip|fail/.test(shown[s.id] || ""); }).length;
859
+ var pct = steps.length ? Math.round(settled / steps.length * 100) : 0;
860
+ var anySkipped = steps.some(function (s) { return shown[s.id] === "skip"; });
861
+ $("#barwrap").style.display = "";
862
+ $("#barfill").style.width = (anySkipped ? Math.round((settled - steps.filter(function (s) { return shown[s.id] === "skip"; }).length) / steps.length * 100) : pct) + "%";
863
+ $("#barfill").classList.toggle("halted", anySkipped);
864
+ var running = steps.filter(function (s) { return shown[s.id] === "running"; })[0];
865
+ // Counts what ran, not what was listed: a halted run has rows nobody
866
+ // attempted, and calling those part of "5 of 5 · finished" is the bar
867
+ // congratulating the reader on a setup that did not happen.
868
+ var skipped = steps.filter(function (s) { return shown[s.id] === "skip"; }).length;
869
+ var ran = settled - skipped;
870
+ var total = steps.length - skipped;
871
+ $("#barnote").textContent = ran + " of " + total + " · " +
872
+ (running ? running.label : skipped ? "stopped" : pct === 100 ? "finished" : "…");
873
+ var BTN = { install: "Installing…", signin: "Signing in…", mcp: "Connecting…", llm: "Configuring models…", "llm-backup": "Configuring models…", auth: "Authorizing…", balance: "Finishing…" };
874
+ if (running) { nextBtn.disabled = true; nextBtn.textContent = BTN[running.id.split(":")[0]] || "Working…"; }
875
+ // The run stopped for want of a key, and the one thing that unblocks it
876
+ // is the thing that failed. Offering it here rather than in a line of
877
+ // prose is the difference between a dead end and a retry.
878
+ // retryInFlight, because the status poll that would clear needsSignIn is
879
+ // up to a second behind the click. Without it the button offered itself
880
+ // again in that gap, and the second press met a run that had already
881
+ // started and was told so — an answer that is correct and confusing.
882
+ else if (lastStatus && lastStatus.needsSignIn && !retryInFlight) {
883
+ nextBtn.disabled = false;
884
+ nextBtn.style.display = "";
885
+ nextBtn.textContent = "Log in again";
886
+ nextBtn.dataset.retry = "1";
887
+ } else if (nextBtn.dataset.retry) {
888
+ delete nextBtn.dataset.retry;
889
+ }
890
+ }
891
+ /** Said once: the run waits on it, and repeating it would say nothing new. */
892
+ var toldSignin = false;
893
+ function caughtUp() {
894
+ return (serverSteps || []).every(function (s) { return shown[s.id] === s.state; });
895
+ }
896
+ /** Digest of the visible detail text, so a change to it can be noticed. */
897
+ var lastDetails = "";
898
+ function tick() {
899
+ var now = Date.now();
900
+ var steps = serverSteps || [];
901
+ // A row's state is paced by this function; its detail is not — the run
902
+ // rewrites it whenever it has something to say, and the sign-in countdown
903
+ // is nothing but that. Repainting only on a state flip meant those
904
+ // updates went to a screen that never redrew: four seconds of counting,
905
+ // none of it displayed, and then the row jumped to done.
906
+ var details = steps.map(function (s) {
907
+ return (shown[s.id] || "pending") === "pending" ? "" : (s.detail || "");
908
+ }).join("\u0001");
909
+ if (details !== lastDetails) { lastDetails = details; renderSteps(); }
910
+ for (var i = 0; i < steps.length; i++) {
911
+ var s = steps[i], cur = shown[s.id] || "pending";
912
+ if (cur === s.state) continue;
913
+ var dwell = s.id === "balance" && lastStatus && lastStatus.balanceMicros !== null &&
914
+ lastStatus.balanceMicros !== undefined && lastStatus.balanceMicros <= 5e6 ? LOW_DWELL : MIN_DWELL;
915
+ // A skipped step is work that did not happen, so it does not get the
916
+ // moment of work below. Playing it through "working…" on its way to
917
+ // "skipped" is how a halted run looked like it was still going — the
918
+ // bar climbing to 5 of 5 while the reader had just been told nothing
919
+ // would be written.
920
+ if (cur === "pending" && s.state === "skip") {
921
+ shown[s.id] = "skip"; renderSteps(); continue;
922
+ }
923
+ if (cur === "pending" && (s.state === "running" || /ok|skip|fail/.test(s.state))) {
924
+ // Even an instant step gets a visible moment of work before its tick.
925
+ if (now - lastFlip < MIN_DWELL && lastFlip) break;
926
+ shown[s.id] = "running"; lastFlip = now; renderSteps();
927
+ // The run holds the sign-in here until this arrives. Anything else it
928
+ // does is its own business; this one row is the only one whose next
929
+ // move takes the reader out of the page, so it is the only one worth
930
+ // asking permission to leave from.
931
+ if (s.id === "signin" && !toldSignin) {
932
+ toldSignin = true;
933
+ fetch("/signin-shown?token=" + TOKEN, { method: "POST" }).catch(function () {});
934
+ }
935
+ break;
936
+ }
937
+ if (cur === "running" && /ok|skip|fail/.test(s.state)) {
938
+ if (now - lastFlip < dwell) break;
939
+ shown[s.id] = s.state; lastFlip = now; renderSteps(); break;
940
+ }
941
+ break; // a running step stays running until the server settles it
942
+ }
943
+ if ((phase === "done" || phase === "failed") && caughtUp()) {
944
+ // One last beat on the final tick, then tell the process the checklist
945
+ // has been seen — only now may it open the results tab.
946
+ if (now - lastFlip < MIN_DWELL) return;
947
+ clearInterval(ticker); ticker = null;
948
+ finish();
949
+ fetch("/seen?token=" + TOKEN, { method: "POST" }).catch(function () {});
950
+ }
951
+ }
952
+ function finish() {
953
+ var failed = (serverSteps || []).filter(function (s) { return s.state === "fail"; }).length;
954
+ var waiting = lastStatus && lastStatus.needsSignIn;
955
+ // "Finished, with 1 issue" is the wrong word for a run that did not
956
+ // finish. Nothing after the sign-in was attempted, so the honest
957
+ // headline is what it is waiting for.
958
+ $("#inTitle").innerHTML = waiting
959
+ ? "Waiting on <em>your sign-in</em>"
960
+ : failed ? "Finished, <em>with " + failed + " issue" + (failed > 1 ? "s" : "") + "</em>" : "All <em>connected</em>";
961
+ $("#inLede").textContent = waiting
962
+ ? "AIsa could not sign you in, so nothing was written. Everything else is ready to run the moment it succeeds."
963
+ : failed ? COPY.ledeFailed : COPY.ledeAllRan;
964
+ unlocked = 6; rails.forEach(function (r) { r.classList.add("open"); });
965
+ paintRail(); // thawed: the results exist, so the earlier steps are readable again
966
+ renderSteps();
967
+ nextBtn.disabled = false; nextBtn.style.display = "";
968
+ // A run that stopped for want of a key has not finished, it is waiting.
969
+ // "See your results" would send the reader to a page describing a setup
970
+ // that did not happen; the only useful action is the one that failed.
971
+ if (lastStatus && lastStatus.needsSignIn) {
972
+ nextBtn.textContent = "Log in again";
973
+ nextBtn.dataset.retry = "1";
974
+ navnote.textContent = "Nothing was written — the rest of the setup runs once you are signed in.";
975
+ rails[4].classList.remove("done");
976
+ return;
977
+ }
978
+ nextBtn.innerHTML = "See your results " + ARROW;
979
+ navnote.textContent = VIEW === "start" && lastStatus && lastStatus.doneUrl ? "A results tab also opened on its own." : "";
980
+ renderDone();
981
+ rails[4].classList.add("done");
982
+ }
983
+ function lockSelections() {
984
+ locked = true;
985
+ paintRail();
986
+ $$('.pane[data-pane="2"] input, .pane[data-pane="3"] input, .pane[data-pane="4"] input').forEach(function (i) { i.disabled = true; });
987
+ $$(".ctile, #spAll").forEach(function (b) { b.disabled = false; });
988
+ document.body.classList.add("locked");
989
+ }
990
+ function start() {
991
+ var body = { servers: pickedServers(), clients: [clientId()], install: installing() ? [clientId()] : [], llmMode: llmMode() };
992
+ nextBtn.disabled = true; nextBtn.textContent = installing() ? "Installing…" : "Connecting…";
993
+ navnote.textContent = "";
994
+ lockSelections();
995
+ fetch("/apply", { method: "POST", headers: { "content-type": "application/json", "x-connect-token": TOKEN }, body: JSON.stringify(body) })
996
+ .then(function (r) { return r.json(); })
997
+ .then(function (d) { serverSteps = d.steps; shown = {}; lastFlip = 0; renderSteps(); if (!ticker) ticker = setInterval(tick, 250); })
998
+ .catch(function () {
999
+ // The run could not be started. Give the buttons back rather than
1000
+ // leaving the page frozen on "Connecting…" with nothing behind it.
1001
+ nextBtn.disabled = false;
1002
+ nextBtn.innerHTML = (installing() ? "Install &amp; connect " : "Connect ") + ARROW;
1003
+ navnote.textContent = ${JSON.stringify(T(SUPERSEDED.closed))};
1004
+ });
1005
+ }
1006
+ function syncClientCard(steps) {
1007
+ (steps || []).forEach(function (s) {
1008
+ if (s.id.indexOf("install:") !== 0 || s.state !== "ok") return;
1009
+ var card = $('.tile.agent[data-cid="' + s.id.slice(8) + '"]'); if (!card) return;
1010
+ var b = card.querySelector("[data-badge]"); if (b) { b.className = "badge ok"; b.textContent = "✓ installed"; }
1011
+ var br = card.querySelector("[data-brief]"); if (br) br.innerHTML = "<b>Installed</b> — " + (s.detail || "ready");
1012
+ });
1013
+ }
1014
+
1015
+ // ── step 6 ──
1016
+ var doneRendered = false;
1017
+ // The tick after the headline draws itself only once the results pane is
1018
+ // actually on screen, a beat after it appears.
1019
+ function revealCheck() {
1020
+ if (doneRendered) return;
1021
+ var c = $(".h1check"); if (!c) return;
1022
+ doneRendered = true;
1023
+ setTimeout(function () { c.classList.add("show"); }, 350);
1024
+ }
1025
+ function fmtUsd(m) { return "$" + (m / 1e6).toFixed(2); }
1026
+ function renderDone() {
1027
+ var s = lastStatus; if (!s || !s.selection) return;
1028
+ doneRendered = false;
1029
+ var sel = s.selection, steps = s.steps || [];
1030
+ var id = sel.clients[0], name = LABEL[id] || id;
1031
+ var chosen = sel.servers.map(function (x) { return BY_SLUG[x]; }).filter(Boolean);
1032
+ var tools = chosen.reduce(function (n, x) { return n + x.toolCount; }, 0);
1033
+ var failed = steps.filter(function (x) { return x.state === "fail"; });
1034
+ var mcpFailed = failed.some(function (x) { return x.id === "mcp"; });
1035
+ var installed = steps.filter(function (x) { return x.id.indexOf("install:") === 0 && x.id !== "install:aisa-cli" && x.state === "ok"; })
1036
+ .map(function (x) { return LABEL[x.id.slice(8)] || x.id.slice(8); });
1037
+ var llmOk = steps.some(function (x) { return x.id === "llm" && x.state === "ok"; });
1038
+ var backup = sel.llmMode === "backup";
1039
+ var bin = id === "codex" ? (backup ? "codex-aisa" : "codex") : id === "claude-code" ? (backup ? "claude-aisa" : "claude") : id === "opencode" ? "opencode" : null;
1040
+
1041
+ var head = mcpFailed
1042
+ ? "<div class='eyebrow'>Almost there</div><h1>Your agent is <em>not connected yet</em></h1><p class='lede'>The MCP entries could not be added to <b>" + name + "</b> — details below.</p>"
1043
+ : (installed.length
1044
+ ? "<h1><em>Congratulations!</em> " + installed.join(" & ") + " is installed and armed with " + tools + " powerful tools<span class='h1check'>" + ICON_CHECK + "</span></h1><p class='lede'><b>" + installed.join(" & ") + "</b> is on this machine, signed in to AIsa" + (llmOk ? ", running on <b>" + MODEL_FOR[id] + "</b> through AIsa," : ",") + " with " + chosen.length + " MCP server" + (chosen.length > 1 ? "s" : "") + " wired in — a complete setup, nothing else to configure.</p>"
1045
+ : "<h1><em>Congratulations!</em> Your agent just got " + tools + " powerful new tool" + (tools > 1 ? "s" : "") + ".<span class='h1check'>" + ICON_CHECK + "</span></h1><p class='lede'>" + (id === "claude-ai"
1046
+ ? chosen.length + " connector URL" + (chosen.length > 1 ? "s are" : " is") + " ready for <b>Claude.ai</b> — add them below, it takes about a minute."
1047
+ : id === "cursor" && s.deeplinks && s.deeplinks.length
1048
+ ? chosen.length + " install link" + (chosen.length > 1 ? "s are" : " is") + " ready for <b>Cursor</b> — one click each, below."
1049
+ : chosen.length + " AIsa MCP server" + (chosen.length > 1 ? "s are" : " is") + " now installed and signed in for <b>" + name + "</b> — nothing else to configure.") + "</p>");
1050
+
1051
+ var failBlock = failed.length ? "<div class='authnote warn'><div><b>" + failed.length + " step" + (failed.length > 1 ? "s" : "") + " did not complete:</b><ul>" +
1052
+ failed.map(function (x) { return "<li><b>" + x.label + "</b> — " + (x.detail || "failed") + "</li>"; }).join("") +
1053
+ "</ul>Fix the above, then run <code>npx @aisa-one/cli connect</code> again — it is safe to re-run.</div></div>" : "";
1054
+ var ICON = { ok: "✓", fail: "✕", skip: "–", pending: "·", running: "·" };
1055
+ var recap = "<h2>Everything you just gained</h2><div class='recap'><div class='rsum'>" + chosen.length + " capabilit" + (chosen.length === 1 ? "y" : "ies") + " · " + tools + " tools · " + name + "</div>" +
1056
+ // The balance has its own card right below, so its row stays out of the recap.
1057
+ steps.filter(function (x) { return x.id !== "balance"; }).map(function (x) { return "<div class='rrow " + x.state + "'><span class='rl'>" + x.label + "</span><span class='rd' title=\\"" + (x.detail || "").replace(/"/g, "&quot;") + "\\">" + (x.detail || "") + "</span><span class='ri'>" + ICON[x.state] + "</span></div>"; }).join("") + "</div>";
1058
+ var bal = s.balanceMicros, low = bal !== null && bal !== undefined && bal < 5e6;
1059
+ // The welcome-credit note is shown on every results page, whatever the
1060
+ // balance: it is the one place the top-up is explained.
1061
+ var gift = true;
1062
+ var balCard = "<div class='balcard" + (low || gift ? " low" : "") + "'><div><div class='balnum'>" + (bal === null || bal === undefined ? "—" : fmtUsd(bal)) + "</div><div class='ballbl'>AIsa balance</div></div><div class='balright'>" +
1063
+ (gift ? "<div class='giftnote'><b>AIsa has given you $1 to get started.</b> That covers your first few calls. Top up now so your agent never stops mid-task.</div>" : low ? "<div class='lownote'>Running a little low — a small top-up keeps your first calls flowing.</div>" : (bal === null || bal === undefined ? "<div class='lownote'>Could not read it just now — <code>aisa balance</code> will.</div>" : "")) +
1064
+ "<a class='cta sm' href='https://console.aisa.one/billing?source=aisa_cli' target='_blank' rel='noopener noreferrer'>Top up now →</a></div></div>";
1065
+ var fileNote = "";
1066
+ if (id === "claude-desktop") {
1067
+ fileNote = "<div class='vsccard'><div class='vschead'><span class='blogo lg'>" + CLAUDE_DT_LOGO + "</span><div><b>Claude Desktop is ready</b>" +
1068
+ "<div class='fine' style='margin:0'>" + chosen.length + " MCP server" + (chosen.length > 1 ? "s" : "") + " (plus aisa-docs) are in its config — one restart loads them.</div></div>" +
1069
+ "<div class='termside'><button type='button' class='cta sm' id='launch'>Restart Claude Desktop →</button><span class='fine' id='launchnote'></span></div></div>" +
1070
+ "<div class='vscgrid'>" +
1071
+ "<div class='vscitem'><span class='srv'>Tools</span>After the restart, open the <b>tools menu</b> (🔧) in a chat — the <code>aisa-…</code> servers are there. Each runs as a small bridge that lives and dies with the app; no background service.</div>" +
1072
+ "<div class='vscitem'><span class='srv'>Across devices</span>Prefer one setup on every device? Add the same server URLs as <b>custom Connectors</b> under Settings → Connectors — they sync with claude.ai through your account.</div>" +
1073
+ "</div></div>";
1074
+ }
1075
+ if (id === "vscode") {
1076
+ var llmStep = steps.filter(function (x) { return x.id === "llm-backup"; })[0];
1077
+ var needsPaste = llmStep && /paste/.test(llmStep.detail || "");
1078
+ var modelsOn = sel.llmMode === "backup" && !needsPaste;
1079
+ fileNote = "<div class='vsccard'><div class='vschead'><span class='blogo lg'>" + VSCODE_LOGO + "</span><div><b>VS Code is ready</b>" +
1080
+ "<div class='fine' style='margin:0'>" + chosen.length + " MCP server" + (chosen.length > 1 ? "s" : "") + (modelsOn ? " and " + MODEL_COUNT + " AIsa models" : "") + " are in place — no reload needed.</div></div>" +
1081
+ "<div class='termside'><button type='button' class='cta sm' id='launch'>Open VS Code →</button><span class='fine' id='launchnote'></span></div></div>" +
1082
+ "<div class='vscgrid'>" +
1083
+ (modelsOn ? "<div class='vscitem'><span class='srv'>Models</span>Open <b>Chat</b> (<code>Ctrl+Cmd+I</code>), click the model name at the bottom-right of the input — the <b>AIsa</b> group lists Claude, GPT, DeepSeek, Kimi, GLM and Qwen. The AIsa extension already stored your key.</div>" : "") +
1084
+ "<div class='vscitem'><span class='srv'>Tools</span>Switch Chat to <b>Agent</b> mode and tick the <code>aisa-…</code> servers in the tools picker (🔧). They are listed under <b>Extensions → MCP Servers</b>; if one is missing, run <b>Reload Window</b> once.</div>" +
1085
+ "</div></div>";
1086
+ if (sel.llmMode === "backup" && needsPaste) {
1087
+ fileNote += "<h2>One paste in VS Code — your key</h2><div class='webcard'>" +
1088
+ "<p class='fine' style='margin:0 0 .6rem'>VS Code keeps model keys in its own encrypted store, which only its UI can write — so this is the one step it does not let us do for you.</p>" +
1089
+ "<ol class='websteps'><li>Open Chat (<code>Ctrl+Cmd+I</code>), click the <b>model name</b> at the bottom-right of the input, then <b>Manage Models…</b></li>" +
1090
+ "<li>Pick <b>AIsa</b> and paste your key when asked (it is stored securely by VS Code)</li>" +
1091
+ "<li>Back in the picker, choose any AIsa model — Claude, GPT, DeepSeek, Kimi, GLM, Qwen</li></ol>" +
1092
+ "<button type='button' class='cta sm' id='copykey'>Copy your AIsa key</button> <span class='fine' id='copykeynote'></span></div>";
1093
+ }
1094
+ }
1095
+ if (id === "cursor" && s.deeplinks && s.deeplinks.length) {
1096
+ fileNote = "<div class='vsccard'><div class='vschead'><span class='blogo lg mono'>" + CURSOR_LOGO + "</span><div><b>Cursor is ready</b>" +
1097
+ "<div class='fine' style='margin:0'>" + s.deeplinks.length + " install link" + (s.deeplinks.length > 1 ? "s" : "") + " below — one click each, confirmed inside Cursor.</div></div>" +
1098
+ "<div class='termside'><button type='button' class='cta sm' id='launch'>Open Cursor →</button><span class='fine' id='launchnote'></span></div></div>" +
1099
+ "<div class='vscgrid'>" +
1100
+ "<div class='vscitem'><span class='srv'>Tools</span>Each button opens Cursor with the entry ready; press <b>Install</b> there. Afterwards <b>Cursor Settings → MCP</b> lists them as <code>aisa-…</code>, and Agent mode can use them.</div>" +
1101
+ "<div class='vscitem'><span class='srv'>Models</span>Cursor keeps its own models for Tab and inline edits. For chat and Agent you can point it at AIsa by hand: <b>Settings → Models → Override OpenAI Base URL</b> = <code>https://api.aisa.one/v1</code> with your AIsa key.</div>" +
1102
+ "</div>" +
1103
+ s.deeplinks.map(function (d) {
1104
+ return "<div class='weburl'><div><span class='srv'>" + d.name + "</span><code>" + (BY_SLUG[d.slug] ? BY_SLUG[d.slug].endpoint : "") + "</code></div>" +
1105
+ "<a class='cta sm' href='" + d.url + "'>Add to Cursor →</a></div>";
1106
+ }).join("") + "</div>";
1107
+ }
1108
+ if (id === "claude-ai") {
1109
+ fileNote += "<h2>Finish in claude.ai — about a minute</h2><div class='webcard'>" +
1110
+ "<ol class='websteps'><li>Open <a class='lnk' href='https://claude.ai/settings/connectors' target='_blank' rel='noopener noreferrer'>claude.ai → Settings → Connectors</a></li>" +
1111
+ "<li>Click <b>Add custom connector</b>, paste a name and URL from below, then <b>Add</b></li>" +
1112
+ "<li>Press <b>Connect</b> and approve the AIsa sign-in — once per connector</li></ol>" +
1113
+ chosen.map(function (x) {
1114
+ return "<div class='weburl'><div><span class='srv'>aisa-" + x.slug + "</span><code>" + x.endpoint + "</code></div>" +
1115
+ "<button type='button' data-copy=\\"" + x.endpoint + "\\">" + ICON_COPY + " Copy URL</button></div>";
1116
+ }).join("") + "</div>";
1117
+ }
1118
+ var backupNote = backup && id === "vscode" ? "" : backup ? "<p class='rerun'><b>" + COPY.backupIntact + "</b> " + (id === "opencode" ? COPY.backupOpencode : COPY.backupBin.split("{bin}").join(bin)) + "</p>" : "";
1119
+ var model = MODEL_FOR[id] || "";
1120
+ var preview = id === "opencode"
1121
+ ? "<pre class='termlogo oc'>" + ART.opencode + "</pre><div class='termline'>Welcome to <b>opencode</b></div><div class='termline dim'>model: <b>" + PROVIDER_ID + "/" + model + "</b> · via AIsa</div><div class='termline dim'>config: ~/.config/opencode/opencode.json</div>"
1122
+ : id === "codex"
1123
+ ? "<pre class='termlogo codex'>" + ART.codex + "</pre><div class='termline'>Welcome to <b>Codex</b>, OpenAI's command-line coding agent</div><div class='termline dim'>model: <b>" + model + "</b> · via AIsa</div>"
1124
+ : "<pre class='termlogo claude'>" + ART.claude + "</pre><div class='termline'><b class='ccname'>Claude Code</b></div><div class='termline dim'>" + model + " · via AIsa</div><div class='termline accent'>Using " + model + " (from .claude/settings.json)</div>";
1125
+ var launch = !mcpFailed && bin ? "<div class='termcard'><div class='termwin'><div class='termbar'><span class='tdot r'></span><span class='tdot y'></span><span class='tdot g'></span></div><div class='termbody'>" + preview + "</div></div>" +
1126
+ "<div class='termside'><button type='button' class='cta sm' id='launch'>Launch " + bin + " →</button><span class='fine' id='launchnote'></span></div></div>" : "";
1127
+ var withEx = chosen.filter(function (x) { return EXAMPLES[x.slug]; });
1128
+ var cards = withEx.length === 1 ? EXAMPLES[withEx[0].slug].slice(0, 2).map(function (t) { return { slug: withEx[0].slug, text: t }; })
1129
+ : withEx.slice(0, 4).map(function (x) { return { slug: x.slug, text: EXAMPLES[x.slug][0] }; });
1130
+ var examples = cards.map(function (c) {
1131
+ return "<div class='example'><div><span class='srv'>aisa-" + c.slug + "</span><div class='txt'>" + c.text + "</div></div><button type='button' data-copy=\\"" + c.text.replace(/"/g, "&quot;") + "\\">" + ICON_COPY + " Copy</button></div>";
1132
+ }).join("");
1133
+ var more = SERVERS.length - chosen.length;
1134
+ var rest = mcpFailed ? failBlock + recap + balCard
1135
+ : "<p class='lede'>You are connected to <b>AIsa</b> — one account for all the well-known models and the live data behind them." + (more > 0 ? " " + more + " more MCP server" + (more > 1 ? "s are" : " is") + " one <code>npx @aisa-one/cli connect</code> away." : "") + " See your account dashboard at <a class='lnk' href='https://console.aisa.one' target='_blank' rel='noopener noreferrer'>console.aisa.one</a>.</p>" +
1136
+ failBlock + recap + balCard + fileNote + launch +
1137
+ "<h2>Try it now — paste one of these into " + name + (id === "claude-ai" || id === "cursor" ? " once the servers are added" : id === "claude-desktop" ? " after the restart" : "") + "</h2><div class='examples'>" + (examples || "<p class='fine'>Ask your agent to use any of the aisa-* MCP tools.</p>") + "</div>" +
1138
+ backupNote +
1139
+ "<p class='rerun'>${T(STEP_MODELS.rerun)}</p>";
1140
+ $("#doneBody").innerHTML = head + rest;
1141
+ $$("[data-copy]").forEach(function (b) { b.addEventListener("click", function () {
1142
+ navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
1143
+ b.textContent = "Copied ✓"; setTimeout(function () { b.innerHTML = ICON_COPY + " Copy"; }, 1600); });
1144
+ }); });
1145
+ var ck = $("#copykey"); if (ck) ck.addEventListener("click", function () {
1146
+ fetch("/key?token=" + TOKEN).then(function (r) { return r.json(); }).then(function (d) {
1147
+ if (!d.key) throw 0;
1148
+ return navigator.clipboard.writeText(d.key);
1149
+ }).then(function () { ck.textContent = "Copied ✓"; setTimeout(function () { ck.textContent = "Copy your AIsa key"; }, 1600); })
1150
+ .catch(function () { $("#copykeynote").innerHTML = COPY.noKeyStored; });
1151
+ });
1152
+ var lb = $("#launch"); if (lb) lb.addEventListener("click", function () {
1153
+ lb.disabled = true;
1154
+ fetch("/launch?token=" + TOKEN, { method: "POST" }).then(function (r) { if (!r.ok) throw 0; lb.textContent = id === "vscode" ? "✓ VS Code opened" : id === "cursor" ? "✓ Cursor opened" : id === "claude-desktop" ? "✓ Restarting…" : "✓ Opened in Terminal"; })
1155
+ .catch(function () { lb.style.display = "none"; $("#launchnote").className = "fine told"; $("#launchnote").innerHTML = id === "vscode" || id === "cursor" || id === "claude-desktop" ? COPY.cannotStartApp.split("{name}").join(name) : COPY.cannotOpenTerminal.split("{bin}").join(bin); });
1156
+ });
1157
+ }
1158
+
1159
+ // ── hydrate: a run may already be in flight (the done tab, or a reload) ──
1160
+ function hydrate(s) {
1161
+ var sel = s.selection; if (!sel) return false;
1162
+ var c = $('input[name="client"][value="' + sel.clients[0] + '"]'); if (c) c.checked = true;
1163
+ $$('input[name="server"]').forEach(function (i) { i.checked = sel.servers.indexOf(i.value) !== -1; });
1164
+ var fresh = sel.install.length > 0;
1165
+ var r = $('input[name="' + (fresh ? "lmodeFresh" : "lmodeDet") + '"][value="' + sel.llmMode + '"]'); if (r) r.checked = true;
1166
+ var first = $('input[name="server"]:checked'); if (first) activeCat = first.closest(".stile").dataset.cat;
1167
+ syncAgent(); syncModels(); syncCaps(); lockSelections();
1168
+ phase = s.phase; serverSteps = s.steps; lastStatus = s;
1169
+ var settled = phase === "done" || phase === "failed";
1170
+ // Coming back from the sign-in tab is not the same as arriving late.
1171
+ //
1172
+ // The reader left this page for exactly one row — the sign-in — and that
1173
+ // row's outcome is the only thing they went away to get. Adopting it in
1174
+ // silence with everything else meant the trip they had just taken had no
1175
+ // visible result: the checklist was simply further along than they left
1176
+ // it, and the one line that says the key is theirs went by unread.
1177
+ //
1178
+ // So the return resumes at the sign-in: everything above it is adopted
1179
+ // at once (no replay from the top), the sign-in itself lands with its own
1180
+ // tick and its own sentence, and only then does the run move on. This
1181
+ // holds even for a run that finished while they were away — the rows are
1182
+ // paced out rather than snapped to a finished checklist.
1183
+ var resumeAt = -1;
1184
+ if (CAME_BACK) {
1185
+ (s.steps || []).forEach(function (x, i) { if (resumeAt < 0 && x.id === "signin") resumeAt = i; });
1186
+ // Consume the marker: a manual reload later is an arrival, not a return,
1187
+ // and should not replay a sign-in that happened minutes ago.
1188
+ if (history.replaceState) history.replaceState(null, "", location.pathname);
1189
+ }
1190
+ if ((settled || VIEW === "done") && resumeAt < 0) {
1191
+ serverSteps.forEach(function (x) { shown[x.id] = x.state; });
1192
+ unlocked = 6; renderSteps(); finish();
1193
+ syncClientCard(s.steps);
1194
+ } else {
1195
+ // Catch up to the run; do not re-perform it.
1196
+ //
1197
+ // The shown map starts empty on every page load, so a page arriving in
1198
+ // the middle of a run replayed the whole checklist from the top at one
1199
+ // row every 1.8s — which is what the tab coming back from a sign-in
1200
+ // did: the reader watched Install the AIsa CLI start again and could
1201
+ // only conclude the setup had restarted.
1202
+ //
1203
+ // The pacing exists to make a first viewing legible. A page that
1204
+ // arrives late has missed it, not earned the right to see it — so
1205
+ // everything already settled is adopted at once and the animation picks
1206
+ // up at the row that is actually working. The one exception is the
1207
+ // return from the sign-in (resumeAt), which is not an arrival: see the
1208
+ // note where resumeAt is computed.
1209
+ if (CAME_BACK) {
1210
+ var bf = $("#backfrom");
1211
+ if (bf) {
1212
+ // Says the one thing the checklist cannot: the trip is over and
1213
+ // this is the same setup they left. The receipt — that a key now
1214
+ // exists and where it lives — belongs to the sign-in row a few
1215
+ // lines below, which lands with its own tick and holds still long
1216
+ // enough to be read. Saying it in both places twice over, two
1217
+ // lines apart, is not emphasis; it is noise.
1218
+ bf.innerHTML = "\u2713 Signed in \u2014 picking up where your setup left off.";
1219
+ bf.hidden = false;
1220
+ }
1221
+ }
1222
+ var caughtUp = false;
1223
+ (serverSteps || []).forEach(function (x, i) {
1224
+ if (caughtUp) return;
1225
+ // On a return: adopt down to and including the sign-in, then stop.
1226
+ // The rows after it are the ones still worth watching happen.
1227
+ if (resumeAt >= 0 && i > resumeAt) { caughtUp = true; return; }
1228
+ if (/ok|skip|fail/.test(x.state)) { shown[x.id] = x.state; return; }
1229
+ if (x.state === "running") { shown[x.id] = "running"; caughtUp = true; }
1230
+ });
1231
+ // The sign-in result is on screen the moment the page paints. Start the
1232
+ // dwell clock here so it stays there: the next row cannot begin until a
1233
+ // full MIN_DWELL has passed, which is the beat the reader needs in order
1234
+ // to read that the key is theirs before the checklist moves again.
1235
+ if (resumeAt >= 0) lastFlip = Date.now();
1236
+ unlocked = 5; renderSteps(); if (!ticker) ticker = setInterval(tick, 250);
1237
+ }
1238
+ return true;
1239
+ }
1240
+ fetch("/status?token=" + TOKEN).then(function (r) { return r.json(); }).then(function (s) {
1241
+ if (hydrate(s)) { go(VIEW === "done" || s.phase === "done" || s.phase === "failed" ? (VIEW === "done" ? 6 : 5) : 5); return; }
1242
+ // Land where the run is, not where a run would start. hydrate only knows
1243
+ // about a run already applying; a run still being chosen — in the
1244
+ // terminal, say — was answered here with go(1), throwing away the step
1245
+ // the shared draft had already reached.
1246
+ if (s.phase === "selecting") { adopt(s); ready = true; }
1247
+ go(Math.max(current, 1));
1248
+ }).catch(function () { ready = true; go(1); });
1249
+ })();
1250
+ </script>`;
1251
+ return shellT2(view === "done" ? "✓ AIsa Connected" : "AIsa Connect", body);
1252
+ }
1253
+ // ── T2 shell: same brand tokens as T1, a rail + main layout ─────────────────
1254
+ function shellT2(title, body) {
1255
+ return `<!doctype html>
1256
+ <html><head><meta charset="utf-8"><title>${title}</title>
1257
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1258
+ <!--
1259
+ This page's own address carries the run token, and the page links out to
1260
+ aisa.one and console.aisa.one. rel="noopener" does not suppress a referrer —
1261
+ only noreferrer does — so without this, clicking any of them posted
1262
+ "http://127.0.0.1:PORT/?token=…" into someone else's access log. Same shape
1263
+ as the /cli/auth page handing its authorization code to analytics.
1264
+ -->
1265
+ <meta name="referrer" content="no-referrer">
1266
+ <style>
1267
+ :root { --paper: ${PAPER}; --ink: #1c1b1a; --muted: #6d6a66; --line: #e7e4df; --card: #ffffff;
1268
+ --red: ${RED}; --red-cta: ${RED_CTA}; --bar: ${INK}; --tint: #fdf1ef; --ok: #2e7d43; --warn: #f59e0b; }
1269
+ @media (prefers-color-scheme: dark) {
1270
+ :root { --paper: #141312; --ink: #f0eeeb; --muted: #9b9792; --line: #2c2a27; --card: #1d1c1a;
1271
+ --tint: #2a1917; --ok: #57b06f; }
1272
+ }
1273
+ * { box-sizing: border-box; margin: 0; }
1274
+ html { scroll-behavior: smooth; }
1275
+ body { background: var(--paper); color: var(--ink);
1276
+ font: 18px/1.6 Inter, "Inter Fallback", "PingFang SC", ui-sans-serif, system-ui, sans-serif;
1277
+ background-image: radial-gradient(color-mix(in srgb, var(--muted) 22%, transparent) 1px, transparent 1px);
1278
+ background-size: 22px 22px; }
1279
+ .wrap { display: grid; grid-template-columns: 408px minmax(0, 1fr); min-height: 100vh; }
1280
+ /* The rail: same paper as the main area, one hairline between them. The
1281
+ wordmark sits at the top; the six steps float at the vertical centre so
1282
+ the space above and below them is equal at any window height. */
1283
+ .rail { border-right: 1px solid var(--line); padding: 1.6rem 2.2rem; position: sticky; top: 0;
1284
+ height: 100vh; display: flex; flex-direction: column; }
1285
+ .railhead { display: flex; align-items: flex-end; gap: .6rem; color: var(--ink); padding: 0 .6rem;
1286
+ text-decoration: none; transition: opacity .15s ease; }
1287
+ .railhead:hover { opacity: .72; }
1288
+ .railhead svg { width: 89px; height: auto; display: block; }
1289
+ .railhead span { font-weight: 600; font-size: 1.28rem; line-height: 1; color: var(--muted); padding-bottom: 4px; }
1290
+ /* Sits a little above centre: the gap below is noticeably larger than the gap above. */
1291
+ .railsteps { margin: auto 0; display: flex; flex-direction: column; gap: .3rem; padding-bottom: 22vh; }
1292
+ .rstep { display: flex; gap: .8rem; align-items: center; text-align: left; background: transparent;
1293
+ border: 0; border-radius: 8px; padding: .7rem .7rem; font: inherit; color: var(--muted);
1294
+ cursor: default; opacity: .55; position: relative; }
1295
+ .rstep.open { opacity: 1; cursor: pointer; }
1296
+ .rstep.open:hover { background: color-mix(in srgb, var(--tint) 60%, transparent); }
1297
+ /* While the run is writing to the machine: still legible, plainly not a
1298
+ control. No hover, because a hover that leads nowhere is a small lie. */
1299
+ .backfrom { display: flex; align-items: center; gap: .55rem; margin: 0 0 1rem;
1300
+ padding: .62rem .85rem; border: 1px solid #bfe0c8; border-radius: 10px;
1301
+ background: #f2faf4; color: #216c37; font-size: .9rem; }
1302
+ .backfrom[hidden] { display: none; }
1303
+ .rstep.frozen { cursor: default; opacity: .45; }
1304
+ .rstep.frozen:hover { background: transparent; }
1305
+ .rstep .rn { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--line);
1306
+ display: flex; align-items: center; justify-content: center; font-size: .82rem; font-weight: 700; }
1307
+ .rstep .rtitle { display: block; font-weight: 700; font-size: 1.08rem; color: var(--ink); }
1308
+ .rstep .rsub { display: block; font-size: .86rem; color: var(--muted); }
1309
+ .rstep.active { background: var(--card); box-shadow: 0 1px 0 var(--line), 0 0 0 1px var(--line); }
1310
+ .rstep.active .rn { background: var(--red); border-color: var(--red); color: #fff; }
1311
+ .rstep.done .rn { background: var(--ok); border-color: var(--ok); color: #fff; font-size: 0; }
1312
+ .rstep.done .rn::after { content: "\\2713"; font-size: .85rem; }
1313
+ /* The main area centres its content both ways; panes are capped so lines
1314
+ stay readable on a wide screen. */
1315
+ .main { display: flex; align-items: center; justify-content: center; padding: 2rem 4rem; }
1316
+ .content { width: 100%; max-width: 980px; }
1317
+ .ssup { position: fixed; inset: 0; background: rgba(13,13,11,.72); z-index: 99;
1318
+ display: flex; align-items: center; justify-content: center; backdrop-filter: blur(3px); }
1319
+ .ssbox { background: #fff; border: 1px solid var(--line); border-radius: 14px;
1320
+ padding: 1.8rem 2rem; max-width: 30rem; box-shadow: 0 18px 50px rgba(0,0,0,.25); }
1321
+ .ssbox h2 { margin: 0 0 .6rem; font-size: 1.15rem; }
1322
+ .ssbox p { margin: 0; color: var(--muted); line-height: 1.6; }
1323
+ .ssbox #sscount { color: var(--red); font-variant-numeric: tabular-nums; font-size: 1.1rem; }
1324
+ /* Set apart from the sentence above, because it is not about what just
1325
+ happened — it is the two things still worth knowing afterwards. */
1326
+ .sscta { margin: 1.05rem 0 0; padding: .9rem 0 0; border-top: 1px solid var(--line); }
1327
+ .sscta p { margin: 0 0 .38rem; font-size: .9rem; line-height: 1.55; }
1328
+ .sscta p:last-child { margin-bottom: 0; }
1329
+ .sscta a { color: var(--red-cta); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1330
+ text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--red-cta) 38%, transparent); }
1331
+ .sscta a:hover { border-bottom-color: var(--red-cta); }
1332
+ .sscta code { font-size: .88rem; background: var(--paper); border: 1px solid var(--line);
1333
+ border-radius: 5px; padding: .06rem .34rem; }
1334
+ /* Nothing behind the dialog is still happening. The spinner on a running
1335
+ step is the loudest of these: it says work is under way at the same
1336
+ moment the dialog says the run is gone. */
1337
+ .settled .step.running .mark { animation: none; border-top-color: var(--line); opacity: .5; }
1338
+ .settled .barfill { transition: none; }
1339
+ .settled #next { opacity: .5; }
1340
+ .topnav { min-height: 3rem; margin-bottom: 2.4rem; display: flex; align-items: center; justify-content: space-between; }
1341
+ .more { position: relative; display: inline-flex; }
1342
+ .morebtn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
1343
+ border: 1px solid var(--line); border-radius: 9px; background: #fff; color: var(--muted); cursor: pointer; }
1344
+ .morebtn:hover, .morebtn[aria-expanded="true"] { color: var(--ink); border-color: var(--muted); }
1345
+ .moremenu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
1346
+ display: flex; flex-direction: column; min-width: 132px; padding: 4px;
1347
+ border: 1px solid var(--line); border-radius: 10px; background: #fff;
1348
+ box-shadow: 0 8px 24px rgba(0,0,0,.08); }
1349
+ .moremenu[hidden] { display: none; }
1350
+ /* No border of its own — the menu already draws one, and a box inside a
1351
+ box is the shape nothing else on this page has. A row here is text that
1352
+ lights up under the cursor. */
1353
+ .moremenu .lang { display: flex; justify-content: space-between; align-items: center;
1354
+ border: 0; background: none; cursor: pointer; font: inherit; font-size: .86rem;
1355
+ color: var(--muted); text-align: left; padding: 8px 10px; border-radius: 6px; line-height: 1.4; }
1356
+ .moremenu .lang:hover { background: var(--paper); color: var(--ink); }
1357
+ .moremenu .lang.on { color: var(--ink); font-weight: 600; }
1358
+ .moremenu .lang.on::after { content: "✓"; opacity: .55; font-weight: 400; }
1359
+ .topnav .ghost { padding: .65rem 1.3rem; font-size: 1rem; }
1360
+ .pane { display: none; animation: fade .25s ease; }
1361
+ .pane.show { display: block; }
1362
+ @keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1363
+ @media (max-width: 960px) {
1364
+ .wrap { grid-template-columns: 1fr; }
1365
+ .rail { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
1366
+ .railsteps { flex-direction: row; flex-wrap: wrap; margin: .8rem 0 0; padding: 0; }
1367
+ .rail .rsub { display: none; }
1368
+ .main { padding: 1.6rem 5% 4rem; align-items: flex-start; }
1369
+ }
1370
+ .eyebrow { display: flex; align-items: center; gap: .55rem; color: var(--muted); font-size: .74rem;
1371
+ font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
1372
+ .eyebrow::before { content: ""; width: 26px; height: 3px; background: var(--red); }
1373
+ h1 { font-size: 2.45rem; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; margin: .5rem 0 .6rem; }
1374
+ h1 em { font-style: normal; color: var(--red); }
1375
+ h2 { font-size: 1.15rem; font-weight: 700; margin: 2rem 0 .8rem; }
1376
+ h3 { font-size: 1rem; font-weight: 700; margin: 0 0 .3rem; }
1377
+ .lede { color: var(--muted); font-size: 1.08rem; }
1378
+ .fine { color: var(--muted); font-size: .86rem; margin-top: .9rem; }
1379
+ /* When the launch button fails it is replaced by this, and it stops being
1380
+ fine print: it is the only thing left telling the reader what to do. */
1381
+ #launchnote.told { font-size: 1rem; color: var(--ink); }
1382
+ #launchnote.told code { font-size: .95rem; }
1383
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;
1384
+ background: color-mix(in srgb, var(--muted) 12%, transparent); padding: .1em .35em; border-radius: 4px; }
1385
+ .badge { font-size: .72rem; font-weight: 700; padding: .16rem .6rem; border-radius: 99px;
1386
+ border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
1387
+ .badge.ok { background: var(--ok); border-color: var(--ok); color: #fff; }
1388
+ .badge.todo { background: var(--warn); border-color: var(--warn); color: #fff; }
1389
+ .badge.rec { background: var(--red); border-color: var(--red); color: #fff; }
1390
+ .badge.web { background: var(--ink); border-color: var(--ink); color: #fff; }
1391
+ .webcard { border: 1px solid var(--line); border-radius: 12px; background: var(--card); padding: 1rem 1.2rem; margin: 0 0 1.4rem; }
1392
+ .websteps { margin: 0 0 .9rem 1.2rem; font-size: .98rem; } .websteps li { margin: .25rem 0; }
1393
+ .weburl { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .6rem 0; border-top: 1px dashed var(--line); }
1394
+ .weburl .srv { display: block; color: var(--red); font-weight: 600; font-size: .74rem; letter-spacing: .06em; text-transform: uppercase; }
1395
+ .weburl code { font-size: .9rem; overflow-wrap: anywhere; }
1396
+ .weburl button { flex: none; display: inline-flex; align-items: center; gap: .35rem; font: inherit; font-size: .8rem; font-weight: 600;
1397
+ color: var(--ink); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: .35rem .7rem; cursor: pointer; }
1398
+ .weburl button:hover { border-color: var(--red); color: var(--red); }
1399
+ /* welcome */
1400
+ .feat { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.8rem 0 1rem; }
1401
+ .ftile { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 1.3rem 1.4rem; }
1402
+ .ftile p { color: var(--muted); font-size: .95rem; }
1403
+ .fico { width: 38px; height: 38px; border-radius: 10px; background: var(--tint); color: var(--red);
1404
+ display: flex; align-items: center; justify-content: center; margin-bottom: .8rem; }
1405
+ .fico svg { width: 20px; height: 20px; }
1406
+ .strip { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .9rem; }
1407
+ .blogo { display: inline-flex; width: 26px; height: 26px; align-items: center; justify-content: center; }
1408
+ .blogo svg { width: 100%; height: 100%; }
1409
+ .blogo.lg { width: 36px; height: 36px; }
1410
+ /* Single-colour marks drawn for light backgrounds (Cursor) flip in dark mode. */
1411
+ @media (prefers-color-scheme: dark) { .mono svg { filter: invert(1); } }
1412
+ /* tiles (agents, model choice) */
1413
+ .two { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 2rem; margin-top: 1.6rem; align-items: start; }
1414
+ @media (max-width: 960px) { .two { grid-template-columns: 1fr; } }
1415
+ .side { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 1.2rem 1.4rem;
1416
+ font-size: .95rem; color: var(--muted); margin-top: 1.4rem; }
1417
+ .grid1 { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-top: 1.6rem; }
1418
+ .choice.grid1 { margin-top: .4rem; }
1419
+ .choice .tile { align-items: center; }
1420
+ .choice .tbody { flex: 1; min-width: 0; }
1421
+ .choice .thead { width: 100%; }
1422
+ .badge.rec { margin-left: auto; }
1423
+ .side h3 { color: var(--ink); margin-top: 1rem; } .side h3:first-child { margin-top: 0; }
1424
+ .grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; }
1425
+ .grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; }
1426
+ @media (max-width: 720px) { .grid2, .grid3, .feat { grid-template-columns: 1fr; } }
1427
+ .tile { display: flex; gap: .8rem; align-items: flex-start; background: var(--card); border: 1px solid var(--line);
1428
+ border-left: 3px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; cursor: pointer;
1429
+ transition: border-color .15s, box-shadow .15s; }
1430
+ .tile:hover { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); }
1431
+ .tile.on { border-left-color: var(--red); background: color-mix(in srgb, var(--tint) 55%, var(--card)); }
1432
+ .tile input { position: absolute; opacity: 0; width: 0; height: 0; }
1433
+ .tile input.dot { position: static; opacity: 1; width: 20px; height: 20px; margin-top: .3rem; accent-color: var(--red-cta); flex: none; }
1434
+ .tile.agent { align-items: center; }
1435
+ .badge.end { margin-left: auto; flex: none; }
1436
+ .tlogo { flex: none; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; }
1437
+ .tlogo svg { width: 100%; height: 100%; }
1438
+ .tbody { min-width: 0; } .thead { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
1439
+ .tname { font-weight: 700; } .tbrief { display: block; color: var(--muted); font-size: .9rem; margin-top: .2rem; }
1440
+ .choice { margin-top: .4rem; }
1441
+ .soon { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: 1rem; }
1442
+ .chip { display: inline-flex; align-items: center; gap: .4rem; font-size: .82rem; color: var(--muted);
1443
+ border: 1px dashed var(--line); border-radius: 99px; padding: .3rem .75rem; }
1444
+ .chip svg { width: 16px; height: 16px; }
1445
+ body.locked .tile, body.locked .stile { cursor: default; }
1446
+ body.locked .tile:not(.on), body.locked .stile:not(.on) { opacity: .45; }
1447
+ /* models */
1448
+ .pgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .8rem; margin: 1.6rem 0 1.2rem; }
1449
+ @media (max-width: 720px) { .pgrid { grid-template-columns: repeat(2, 1fr); } }
1450
+ .ptile { display: grid; grid-template-columns: 40px 1fr; grid-template-rows: auto auto; column-gap: .8rem;
1451
+ align-items: center; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; }
1452
+ .ptile .blogo { grid-row: 1 / 3; }
1453
+ .pname { font-weight: 700; } .pmodels { font-size: .8rem; color: var(--muted); }
1454
+ .callout { display: flex; gap: .8rem; align-items: flex-start; background: var(--card); border: 1px solid var(--line);
1455
+ border-radius: 10px; padding: 1rem 1.1rem; color: var(--muted); font-size: .95rem; margin: 1rem 0; }
1456
+ .callout svg { flex: none; color: var(--red); margin-top: .15rem; }
1457
+ .callout b { color: var(--ink); }
1458
+ .rerun { margin: 1rem 0 0; font-size: 1.02rem; color: var(--muted); }
1459
+ .rerun + .rerun { margin-top: .35rem; }
1460
+ .rerun code { font-size: 1.05em; color: var(--ink); }
1461
+ .modelwarn { margin-top: 1rem; border: 2px solid var(--warn); border-radius: 10px;
1462
+ background: color-mix(in srgb, var(--warn) 12%, var(--card)); padding: .9rem 1rem; }
1463
+ .mw-head { font-weight: 800; color: color-mix(in srgb, #b45309 60%, var(--ink)); margin-bottom: .3rem; }
1464
+ .mw-body { font-size: .92rem; color: color-mix(in srgb, #92400e 45%, var(--ink)); }
1465
+ .mw-fix { margin-top: .7rem; background: var(--red-cta); color: #fff; border: 0; border-radius: 6px;
1466
+ padding: .5rem 1.2rem; font: inherit; font-weight: 700; cursor: pointer; }
1467
+ @keyframes mwshake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
1468
+ /* capabilities: the area grid, then the servers of the open area */
1469
+ .cgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .9rem; margin: 1.6rem 0 1rem; }
1470
+ .ctile { position: relative; display: flex; flex-direction: column; gap: .25rem; align-items: flex-start; text-align: left;
1471
+ background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 1.2rem 1.2rem 1.1rem;
1472
+ font: inherit; color: var(--ink); cursor: pointer; min-height: 168px; transition: border-color .15s, transform .15s; }
1473
+ .ctile:hover { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); transform: translateY(-1px); }
1474
+ .ctile.active { border-color: var(--red); box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 18%, transparent); }
1475
+ .ctile.has { border-left: 3px solid var(--ok); }
1476
+ .cico { width: 40px; height: 40px; border-radius: 10px; background: var(--tint); color: var(--red);
1477
+ display: flex; align-items: center; justify-content: center; margin-bottom: .4rem; }
1478
+ .cico svg { width: 20px; height: 20px; }
1479
+ .cname { font-weight: 800; font-size: 1.05rem; }
1480
+ .cmeta { font-size: .78rem; color: var(--muted); }
1481
+ .cblurb { font-size: .86rem; color: var(--muted); margin-top: .3rem; }
1482
+ .csel { position: absolute; top: .8rem; right: .9rem; font-size: .74rem; font-weight: 700; color: var(--ok); }
1483
+ .spanel { display: none; border: 1px solid var(--line); border-radius: 12px; background: color-mix(in srgb, var(--card) 60%, var(--paper));
1484
+ padding: 1rem 1.2rem 1.2rem; }
1485
+ .spanel.open { display: block; animation: fade .2s ease; }
1486
+ .sphead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
1487
+ .sphead h2 { margin: .2rem 0 .8rem; }
1488
+ .link { background: none; border: 0; color: var(--red); font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; }
1489
+ .sgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: .8rem; }
1490
+ .stile { display: flex; gap: .7rem; align-items: flex-start; background: var(--card); border: 1px solid var(--line);
1491
+ border-left: 3px solid var(--line); border-radius: 10px; padding: .9rem 1rem; cursor: pointer; }
1492
+ .stile.on { border-left-color: var(--red); background: color-mix(in srgb, var(--tint) 55%, var(--card)); }
1493
+ .stile input { width: 18px; height: 18px; margin-top: .2rem; accent-color: var(--red-cta); flex: none; }
1494
+ .sdesc { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
1495
+ color: var(--muted); font-size: .88rem; margin-top: .2rem; }
1496
+ .tally { margin-top: 1rem; font-size: .95rem; color: var(--muted); }
1497
+ .tally b { color: var(--ink); }
1498
+ /* install */
1499
+ .plan { margin-top: 1.6rem; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: .4rem 1.2rem; }
1500
+ .step { display: flex; align-items: flex-start; gap: .8rem; padding: .8rem .2rem; border-bottom: 1px dashed var(--line);
1501
+ font-size: 1.02rem; opacity: .5; transition: opacity .3s; }
1502
+ .step:last-child { border-bottom: 0; }
1503
+ .step.running, .step.ok, .step.fail { opacity: 1; }
1504
+ .step .lbl { display: block; font-weight: 600; } .step .det { display: block; color: var(--muted); font-size: .86rem; margin-top: .1rem; }
1505
+ .step .st { margin-left: auto; font-size: .8rem; font-weight: 600; color: var(--muted); white-space: nowrap; padding-left: .6rem; }
1506
+ .step.ok .st { color: var(--ok); } .step.fail .st { color: var(--red); }
1507
+ .step .mark { flex: none; width: 22px; height: 22px; margin-top: .15rem; border-radius: 50%; border: 2px solid var(--line);
1508
+ display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; color: #fff;
1509
+ transition: background .25s, border-color .25s; }
1510
+ .step.running .mark { border-color: var(--red); border-top-color: transparent; animation: r .8s linear infinite; }
1511
+ .step.ok .mark { background: var(--ok); border-color: var(--ok); animation: pop .35s ease; }
1512
+ .step.ok .mark::after { content: "\\2713"; }
1513
+ .step.ok .st, .step.fail .st { animation: fade .4s ease; }
1514
+ .step.fail .mark { background: var(--red); border-color: var(--red); } .step.fail .mark::after { content: "\\2715"; }
1515
+ .step.skip .mark { border-style: dotted; }
1516
+ @keyframes r { to { transform: rotate(360deg); } }
1517
+ @keyframes pop { 0% { transform: scale(.6); } 60% { transform: scale(1.2); } 100% { transform: scale(1); } }
1518
+ .barwrap { height: 5px; background: var(--line); border-radius: 99px; overflow: hidden; margin: 1rem 0 .3rem; }
1519
+ .barfill { height: 100%; width: 0; background: var(--red); border-radius: 99px; transition: width .4s ease; }
1520
+ .barnote { color: var(--muted); font-size: .82rem; min-height: 1.2em; }
1521
+ .authnote { display: flex; gap: .7rem; align-items: flex-start; background: var(--card); border: 1px solid var(--line);
1522
+ border-radius: 10px; padding: 1rem 1.1rem; color: var(--muted); font-size: .92rem; margin-top: 1.2rem; }
1523
+ .authnote svg { flex: none; margin-top: .1rem; color: var(--red); } .authnote b { color: var(--ink); }
1524
+ .authnote.warn { border-color: var(--warn); } .authnote ul { margin: .4rem 0 .4rem 1.1rem; }
1525
+ /* nav */
1526
+ .navbar { display: flex; flex-direction: column; align-items: center; gap: .6rem; margin-top: 2.4rem; }
1527
+ .navnote { color: var(--red); font-size: .9rem; min-height: 1.2em; }
1528
+ .cta { display: inline-flex; align-items: center; justify-content: center; gap: .6rem; background: var(--red-cta); color: #fff;
1529
+ border: none; border-radius: 6px; font: inherit; font-weight: 600; font-size: 1.08rem; padding: .85rem 1.9rem; cursor: pointer;
1530
+ text-decoration: none; }
1531
+ .cta:hover { background: color-mix(in srgb, var(--red-cta) 88%, black); }
1532
+ .cta:disabled { opacity: .55; cursor: default; }
1533
+ .cta.sm { font-size: .95rem; padding: .6rem 1.3rem; }
1534
+ .ghost { background: transparent; border: 1px solid var(--line); color: var(--ink); border-radius: 6px; font: inherit;
1535
+ font-weight: 600; padding: .8rem 1.2rem; cursor: pointer; }
1536
+ .ghost:hover { border-color: var(--red); color: var(--red); }
1537
+ /* done */
1538
+ /* The tick sits at the end of the headline, headline-sized, and pops in
1539
+ once the pane is on screen. */
1540
+ .h1check { display: inline-flex; vertical-align: middle; margin-left: .45rem; width: .85em; height: .85em;
1541
+ border-radius: 50%; background: var(--red); color: #fff; align-items: center; justify-content: center;
1542
+ transform: scale(0); opacity: 0; }
1543
+ .h1check svg { width: .53em; height: .53em; }
1544
+ .h1check.show { animation: tickpop .5s cubic-bezier(.2,1.4,.4,1) forwards; }
1545
+ @keyframes tickpop { 0% { transform: scale(0); opacity: 0; } 70% { transform: scale(1.18); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
1546
+ .recap, .balcard, .launch { border: 1px solid var(--line); border-radius: 12px; background: var(--card); padding: 1rem 1.2rem; margin: 0 0 1.2rem; }
1547
+ .rsum { font-weight: 600; padding-bottom: .5rem; border-bottom: 1px dashed var(--line); margin-bottom: .5rem; }
1548
+ .rrow { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .8rem; align-items: center;
1549
+ min-height: 2.9rem; padding: .3rem 0; border-bottom: 1px solid var(--line); }
1550
+ .rrow:last-child { border-bottom: 0; }
1551
+ /* Every row the same height: the detail is one line, clipped, full text on hover. */
1552
+ .rrow .rl { font-weight: 700; white-space: nowrap; }
1553
+ .rrow .rd { color: var(--muted); font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1554
+ .rrow { height: 2.9rem; }
1555
+ .rrow .ri { font-weight: 800; }
1556
+ .rrow.ok .ri { color: var(--ok); } .rrow.fail .ri { color: var(--red); } .rrow.skip .ri { color: #9ca3af; }
1557
+ .balcard, .launch { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
1558
+ .balcard.low { border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, var(--card)); }
1559
+ .balnum { font-size: 1.6rem; font-weight: 800; } .ballbl { font-size: .8rem; color: var(--muted); }
1560
+ .balright { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
1561
+ .giftnote { font-size: 1rem; color: color-mix(in srgb, #b45309 70%, var(--ink)); max-width: 30rem; }
1562
+ .giftnote b { color: var(--red); }
1563
+ .lownote { font-size: .9rem; color: color-mix(in srgb, #b45309 70%, var(--ink)); max-width: 26rem; }
1564
+ .launch .fine { margin-top: .2rem; }
1565
+ /* The launch card: a believable little terminal with the agent's own art. */
1566
+ .termcard { display: flex; justify-content: space-between; align-items: center; gap: 1.2rem; border: 1px solid var(--line);
1567
+ border-radius: 12px; background: var(--card); padding: 1rem 1.2rem; margin: 0 0 1.4rem; flex-wrap: wrap; }
1568
+ .termwin { background: #0d0d0b; border-radius: 10px; overflow: hidden; flex: 1 1 24rem; min-width: 0; box-shadow: inset 0 0 0 1px #262622; }
1569
+ .termbar { display: flex; gap: .38rem; padding: .5rem .7rem; background: #1a1a17; }
1570
+ .tdot { width: .62rem; height: .62rem; border-radius: 50%; }
1571
+ .tdot.r { background: #ff5f57; } .tdot.y { background: #febc2e; } .tdot.g { background: #28c840; }
1572
+ .termbody { padding: .6rem 1rem .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem; line-height: 1.25; }
1573
+ .termlogo { margin: 0 0 .45rem; font-size: .3rem; line-height: 1.1; overflow-x: auto; }
1574
+ .termlogo.codex { color: #33d17a; }
1575
+ .termlogo.oc { color: #fafafa; font-size: .54rem; line-height: 1.15; }
1576
+ .termlogo.claude { color: #e07b54; font-size: .48rem; line-height: 1; }
1577
+ .termline { color: #d8d8d2; padding: .08rem 0; overflow-wrap: anywhere; } .termline b { color: #fff; }
1578
+ .termline.dim { color: #8a8a82; } .termline.accent { border-left: 2px solid #33d17a; padding-left: .5rem; }
1579
+ .ccname { color: #33d17a !important; }
1580
+ .termside { display: flex; flex-direction: column; align-items: flex-end; gap: .5rem; flex: none; }
1581
+ a.lnk { color: var(--red); text-decoration: none; font-weight: 600; }
1582
+ a.lnk:hover { text-decoration: underline; }
1583
+ .vsccard { border: 1px solid var(--line); border-radius: 12px; background: var(--card); padding: 1.1rem 1.3rem; margin: 0 0 1.4rem; }
1584
+ .vschead { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
1585
+ .vschead > div:nth-child(2) { flex: 1; min-width: 14rem; font-size: 1.05rem; }
1586
+ .vscgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .9rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px dashed var(--line); }
1587
+ .vsccard .weburl:first-of-type { margin-top: .6rem; }
1588
+ .vscitem { font-size: .95rem; color: var(--muted); } .vscitem b, .vscitem code { color: var(--ink); }
1589
+ .vscitem .srv { display: block; color: var(--red); font-weight: 600; font-size: .74rem; letter-spacing: .06em; text-transform: uppercase; margin-bottom: .25rem; }
1590
+ .examples { display: grid; gap: .8rem; }
1591
+ .example { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; display: flex; gap: .9rem; align-items: flex-start; }
1592
+ .example .srv { color: var(--red); font-weight: 600; font-size: .74rem; letter-spacing: .06em; text-transform: uppercase; display: block; margin-bottom: .25rem; }
1593
+ .example button { margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: .35rem; font: inherit; font-size: .8rem;
1594
+ font-weight: 600; color: var(--ink); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: .35rem .7rem; cursor: pointer; }
1595
+ .example button:hover { border-color: var(--red); color: var(--red); }
1596
+ </style></head><body>
1597
+ ${body}
1598
+ </body></html>`;
1599
+ }