howtofixcli 0.7.7 → 0.7.9

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 (152) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
package/dist/ui.js DELETED
@@ -1,4751 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { basename, join, resolve } from "node:path";
5
- import { catchInterrupt, isInterrupt } from "./interrupt.js";
6
- import { explainMiss, summarizeCacheUsage } from "./cache-report.js";
7
- import { copyToClipboard, SessionManager } from "@netrodev/hxf-coding-agent";
8
- import { CombinedAutocompleteProvider, Container, Editor, matchesKey, ProcessTerminal, ScrollView, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS, SelectList, TuiAltScreen, truncateToWidth, VStack, visibleWidth, wrapTextWithAnsi, } from "@netrodev/hxf-tui";
9
- import { APP_TITLE, APP_VERSION, INIT_TEMPLATE } from "./brand.js";
10
- import { dropDcpState, dropSnapshot, runDcpCommand } from "./dcp/index.js";
11
- import { stripDcpMarkers } from "./dcp/ids.js";
12
- import { readClipboardText } from "./clipboard.js";
13
- import { TreeSelectorComponent, initTheme } from "@netrodev/hxf-coding-agent";
14
- import { getKeybindings } from "@netrodev/hxf-tui";
15
- import { Spring, pulse, ripple, shimmer, spectrum, waitTone } from "./anim.js";
16
- import { checkpointDiff, createCheckpoint, dropSession, listCheckpoints, openStore, restoreCheckpoint, sweepExpired } from "./checkpoint.js";
17
- import { expandFileReferences } from "./at-file.js";
18
- import { FeedView, itemsFromMessages, upsertNote, upsertUserTurn, userTextOf } from "./feed.js";
19
- import { deadChannelModel, diagnoseGatewayFailure } from "./gateway-errors.js";
20
- import { createSpendCap, spendCapMessage } from "./spend-cap.js";
21
- import { flashLevel, LEVEL_RAMP, levelFlash, levelInk } from "./level-style.js";
22
- import { fg, ink } from "./theme.js";
23
- import { levelCeiling, nextLevel, prevLevel, THINKING_LEVELS } from "./thinking.js";
24
- import { loadUsageSegment } from "./usage-segment.js";
25
- import { loginWithBrowser, PANEL_URL } from "./oauth-login.js";
26
- import { loadAccountCredential, loginWithCredential, parseCredentialInput, saveAccountCredential, } from "./account-credential.js";
27
- import { checkCliCheckin, claimCliCheckin, formatQuotaUsd } from "./cli-checkin.js";
28
- import { accessTokenIsFresh, cliCredentialsPath, readCliCredential, refreshTokenIsUsable, renewCliCredentialIfNeeded, writeCliCredential, } from "./cli-credentials.js";
29
- import { contextLine, costSegment, paintLevel, quotaLines, sessionLine, withSpinner } from "./hud-line.js";
30
- import { listRuns, runs, sendMail, setRunFinishedHandler, showRun, summarizeRun } from "./agents.js";
31
- import { setAsker } from "./tools.js";
32
- import { formatToolCall, formatToolResult, detailsOf, spinnerFrame, toolResultDetail, toolResultFull } from "./tool-render.js";
33
- import { orderModelsForDefault } from "./model-resolve.js";
34
- import { activityText, emptyActivity, firstLine, withLayer, withRetry } from "./activity.js";
35
- import { groupPhases, POP_MS, formatProgressView } from "./workflow-progress.js";
36
- import { setWorkflowControls } from "./workflow.js";
37
- import { HOWTOFIX_CONSOLE_URL, HOWTOFIX_PROVIDERS, applyCompat, checkAccount, checkUsage, countNewModels, findLiveModels, isHowtofixProvider, looksLikeToken, newProviderEntry, probeModel, loadCatalog, maskToken, mergeModels, } from "./howtofix-account.js";
38
- import { openInBrowser } from "./browser.js";
39
- import { HOOKS_FILE, loadHooks, runHooksFor } from "./hooks.js";
40
- import { AGENT_DIR, WORKFLOWS_DIR } from "./paths.js";
41
- import { readWorkbenchSettings, rightClickPastesHere as rightClickPasteEnabled, writeAgentsSetting, writeRightClickPasteSetting, writeStringSetting, writeWorkbenchSetting } from "./settings.js";
42
- import { agentConcurrency, MAX_AGENT_CONCURRENCY } from "./agent-limiter.js";
43
- import { PROXY_SETTING_KEY, configuredProxy, environmentProxy, installProxy, validateProxyUrl } from "./proxy.js";
44
- import { autoUpdateEnabled } from "./update.js";
45
- import { formatSkillList, skillInvocation } from "./skills.js";
46
- import { bundledPresetsDir, categoriesOf, defaultInstallRoot, hubCategories, hubFind, hubIn, installFrom, installFromDirectory, installFromHub, isPresetDisabled, listCatalog, presetSize, presetsIn, readHub, removeInstalled, } from "./presets.js";
47
- import { addMcpServers, describeMcpServer, mcpHubCategories, mcpHubFind, mcpHubIn, missingEnvFor, removeMcpServers, readMcpHub, serverSpecFromEntry, } from "./preset-mcp.js";
48
- import { readGlobalMcpServers } from "./mcp-config.js";
49
- import { McpLifecycle } from "./mcp-lifecycle.js";
50
- import { applyView, createWorkflowView, filterAgents, filterLabel, saveScript, viewFooter, WORKFLOW_KEY_TOGGLE, workflowAction, } from "./workflow-view.js";
51
- import { clipDiff, commitAll, fullDiff, hasChanges, isGitRepo, undoLastCommit } from "./git.js";
52
- import { APPROVAL_LABELS, APPROVAL_MODES, isDangerousBypass, setApprovalMode, setApprovalPrompt, setDangerousBypass } from "./permissions.js";
53
- import { resetLoopGuard } from "./loop-guard.js";
54
- import { GOAL_ENTRY, applyGoalOp, currentGoal, formatGoal, goalSegment, setMirror } from "./goal.js";
55
- import { currentPlanFile, currentPlanSnapshot, enterPending, planActive, planSegment, transition, userExit } from "./plan-mode.js";
56
- import { workbenchUI } from "./extensions.js";
57
- import { writeFileAtomicSync } from "./fs-atomic.js";
58
- const editorTheme = {
59
- borderColor: () => "",
60
- selectList: {
61
- selectedPrefix: (text) => fg(text, ink.accent),
62
- selectedText: (text) => fg(text, ink.text, { bold: true }),
63
- description: (text) => fg(text, ink.mute),
64
- scrollInfo: (text) => fg(text, ink.dim),
65
- noMatch: (text) => fg(text, ink.mute),
66
- },
67
- };
68
- /**
69
- * The key the viewport walks the layout tree by.
70
- *
71
- * `hxf-tui` does not re-export it from the package root, and importing the module
72
- * path directly fails at runtime (`dist/` ships `.js`; only the `.d.ts` exists
73
- * under that name). It is defined with `Symbol.for`, so the global symbol
74
- * registry hands back the same symbol the SDK uses — no internal path, no
75
- * duplicated protocol.
76
- */
77
- const LAYOUT_NODE = Symbol.for("@netrodev/hxf-tui/layout-node");
78
- /**
79
- * The layout tree of `component`, or `undefined` when it is a leaf.
80
- *
81
- * `hxf-tui` reaches these through a non-exported `Symbol.for` key, so the read is
82
- * narrowed by checking the shape rather than by asserting it.
83
- */
84
- function layoutNodeOf(component) {
85
- if (typeof component !== "object" || component === null)
86
- return undefined;
87
- const reader = component[LAYOUT_NODE];
88
- return typeof reader === "function" ? reader.call(component) : undefined;
89
- }
90
- const execFileAsync = promisify(execFile);
91
- /**
92
- * The branch and dirty marker for the HUD.
93
- *
94
- * Asynchronous on purpose. Each call spawns `git` twice, and this used to run
95
- * synchronously from the event handler on every tool start and tool end: on
96
- * Windows a process spawn costs an order of magnitude more than on Linux
97
- * (CreateProcess plus a Defender scan of the image), so a run with many tool
98
- * calls spent its time blocked in `execFileSync` — the screen arrived in bursts,
99
- * which is the "patah-patah" a Windows user sees. Measured on Linux: 1.3 ms for
100
- * `rev-parse` and 6.5 ms for `status` per spawn, with the same work far costlier
101
- * on Windows. Nothing in the HUD needs the value synchronously.
102
- *
103
- * Returns the *fresh* marker or null when the spawn fails, so a caller can leave
104
- * the previous value alone instead of blanking the footer on a transient error.
105
- */
106
- async function readGit(cwd) {
107
- try {
108
- const { stdout: branchOut } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
109
- encoding: "utf8",
110
- });
111
- const branch = branchOut.trim();
112
- if (!branch || branch === "HEAD")
113
- return "";
114
- const { stdout: statusOut } = await execFileAsync("git", ["-C", cwd, "status", "--porcelain"], {
115
- encoding: "utf8",
116
- });
117
- return `git:(${branch}${statusOut.trim() ? "*" : ""})`;
118
- }
119
- catch {
120
- return null;
121
- }
122
- }
123
- export class Hud {
124
- folder;
125
- model;
126
- git;
127
- /**
128
- * The meter's target. It is what `/stats` and the transcript say; the drawn
129
- * value lags it by a spring so a jump from 8% to 40% reads as movement rather
130
- * than as a different number in the same place.
131
- */
132
- percent = 0;
133
- meter = new Spring(0);
134
- /**
135
- * The layered activity line; see `activity.ts` for why it is not a string.
136
- *
137
- * The branch this was merged from replaced it with a plain string written from
138
- * sixteen places, each owning the whole row — the exact shape `activity.ts`
139
- * exists to prevent (a finished run erasing a prompt that was waiting for the
140
- * user). The layers are kept; the animation is theirs.
141
- */
142
- activity = emptyActivity();
143
- /**
144
- * Whether a run is in flight. The spinner and the seconds counter show only
145
- * while this is true: they are a clock, and a clock that is not running is a
146
- * frozen glyph.
147
- */
148
- live = false;
149
- /**
150
- * When the current run began, for the seconds counter beside the model.
151
- * `beginRun()` sets it, so it never depends on comparing labels — every run
152
- * starts with the same one.
153
- */
154
- activitySince = Date.now();
155
- thinking = "medium";
156
- /**
157
- * The highest level the current model actually reaches the provider with
158
- * (`levelCeiling`), or `undefined` when it reports none — then `atCeiling()`
159
- * is false for every level and the shimmer stays dark.
160
- */
161
- ceiling = undefined;
162
- usage = "";
163
- /**
164
- * What this session has cost so far, in USD - the number `/stats` prints,
165
- * mirrored onto the row so the answer is visible without asking. It is
166
- * written by the sampler below rather than read during a draw: a frame is
167
- * rendered many times a second while the spinner runs, and the stats behind
168
- * this number aggregate every entry in the session.
169
- */
170
- cost = 0;
171
- /**
172
- * True while the permission gate is off for this session. Drawn on its own
173
- * HUD row, in warning colour, because a run with no refusals must not look
174
- * like an ordinary one.
175
- */
176
- skipPermissions = false;
177
- constructor(cwd, modelLabel) {
178
- this.folder = basename(cwd);
179
- this.model = modelLabel.split("/").at(-1) || "no-model";
180
- // Filled in asynchronously; see `readGit`. The constructor runs before the
181
- // first frame, and blocking it on two git spawns delayed startup on Windows.
182
- this.git = "";
183
- }
184
- invalidate() { }
185
- /** True while the meter is still settling, so the clock can outlive the run. */
186
- settling() {
187
- return this.meter.moving;
188
- }
189
- /**
190
- * True while the HUD sits on the model's highest supported level — the only
191
- * moment the ceiling shimmer may run. An `undefined` ceiling means the model
192
- * reported no supported level at all: the feature is off for it, not pinned
193
- * to `max`.
194
- */
195
- atCeiling() {
196
- return this.ceiling !== undefined && this.thinking === this.ceiling;
197
- }
198
- /**
199
- * When the level last changed, so the new one can flash once as it appears.
200
- *
201
- * A level change is often a single keypress and the level is a word: without a
202
- * flash, raising it from `low` to `high` in a run of presses is invisible until
203
- * you stop and read. `0` means "never changed in this session", which is not the
204
- * same as "changed a long time ago" — the first frame must not flash.
205
- */
206
- levelChangedAt = 0;
207
- /** Record a level change. Called wherever `thinking` is assigned. */
208
- setThinking(level) {
209
- if (level === this.thinking)
210
- return;
211
- this.thinking = level;
212
- this.levelChangedAt = Date.now();
213
- }
214
- /** How far through the level's entry flash we are, 0 when it has settled. */
215
- levelFlashAmount() {
216
- if (this.levelChangedAt === 0)
217
- return 0;
218
- return flashLevel(Date.now() - this.levelChangedAt);
219
- }
220
- /**
221
- * True while a level change is still flashing.
222
- *
223
- * The clock in `renderHud` keeps asking for frames while this holds, because the
224
- * flash is the only thing moving and a frame that is never requested is a flash
225
- * that never plays. It is deliberately *not* `atCeiling()`-style permanent: a
226
- * level that is merely set does not animate, so an idle session still costs
227
- * nothing.
228
- */
229
- flashing() {
230
- return this.levelFlashAmount() > 0;
231
- }
232
- /** The level styled for this frame: its colour, rippled on a fresh change. */
233
- levelText(now) {
234
- // At the ceiling the word carries the whole ramp as a moving spectrum. The
235
- // band runs across every level's colour, so the top of the ladder is drawn as
236
- // the ladder rather than as a badge for its top rung — and it is the one
237
- // effect here that keeps running, because being at the ceiling is a standing
238
- // fact rather than an event.
239
- //
240
- // It used to sweep between the level's own colour and its lighter twin, which
241
- // was measured to be wrong in two ways. On a model whose ceiling equals the
242
- // default level the word was drawn grey on every frame, so a user who never
243
- // changed the level never saw a colour at all; and a two-colour sweep says
244
- // nothing about the rungs below, which is the thing a ceiling is.
245
- if (this.atCeiling())
246
- return spectrum(this.thinking, LEVEL_RAMP, now);
247
- // Below the ceiling a change is an event: one band of light crosses the word
248
- // and leaves it in its own colour. `flashLevel` owns the curve — the ripple
249
- // takes progress, not a clock — so the effect and the frame tick that drives
250
- // it stay in agreement about when it is over.
251
- const age = this.levelChangedAt === 0 ? Number.POSITIVE_INFINITY : now - this.levelChangedAt;
252
- if (flashLevel(age) <= 0)
253
- return paintLevel(this.thinking, this.thinking);
254
- return ripple(this.thinking, { rest: levelInk(this.thinking), lit: levelFlash(this.thinking) }, 1 - flashLevel(age));
255
- }
256
- /** The drawn value springs toward whatever `percent` was last set to. */
257
- meterValue() {
258
- if (this.meter.value !== this.percent)
259
- this.meter.set(this.percent);
260
- return this.meter.step();
261
- }
262
- /**
263
- * Begin a run: the spinner starts turning and the seconds start from zero.
264
- * Called by the workbench when a prompt is submitted or a run starts, so the
265
- * clock never depends on the label text happening to differ.
266
- */
267
- beginRun() {
268
- this.live = true;
269
- this.activitySince = Date.now();
270
- }
271
- /**
272
- * How long the current run has been going, for the spinner beside the model.
273
- * This is also what the activity label's colour ramps on: a run that has
274
- * produced nothing for a minute is worth a different colour from one that just
275
- * started.
276
- */
277
- waitedMs() {
278
- return Date.now() - this.activitySince;
279
- }
280
- /**
281
- * Snap to the current target. Used when the session behind the HUD is
282
- * replaced: the new session's numbers are a new baseline, not a destination,
283
- * so sliding from the old session's meter would be a lie.
284
- */
285
- abandonAnimations() {
286
- this.meter.snap(this.percent);
287
- }
288
- render(width) {
289
- const now = Date.now();
290
- // A session that has not used any context yet draws a plain zero: springing
291
- // to zero on the first frame would make a fresh HUD animate for no reason.
292
- const shown = this.percent === 0 && this.meter.value === 0 ? 0 : this.meterValue();
293
- // One line carries the whole footer: the spinner and how long it has been
294
- // waiting, the model, the thinking level, then the folder, branch and meter.
295
- // There is no separate settings row and no standing activity row — both were
296
- // a second and third line saying less than this one.
297
- // At the ceiling the level word is swept by the band — colours only, so
298
- // the row keeps exactly the width it has below the ceiling.
299
- const level = this.levelText(now);
300
- const session = sessionLine(this.model, level, this.folder, this.git);
301
- const lines = [
302
- truncateToWidth(`${withSpinner(session, this.live ? spinnerFrame() : "", this.live ? this.waitedMs() : undefined)} ${contextLine(shown, this.usage, costSegment(this.cost))}`, width),
303
- ];
304
- // The plan and goal sit under the session line: they explain what the run is
305
- // for, and both outlive a compaction that would drop the original message.
306
- // Plan comes first — it gates what the run is allowed to do.
307
- // Above the plan and goal: it changes what every run is allowed to do, so it
308
- // is read before what the run is for.
309
- if (this.skipPermissions) {
310
- lines.push(truncateToWidth(fg("Permissions OFF — critical commands and the repeat guard are not enforced", ink.bad), width));
311
- }
312
- const plan = planSegment();
313
- if (plan)
314
- lines.push(truncateToWidth(fg(plan, ink.warn), width));
315
- const goal = goalSegment(currentGoal());
316
- if (goal)
317
- lines.push(truncateToWidth(fg(goal, ink.accent), width));
318
- // The activity row is ALWAYS rendered, even when it is empty. The feed is
319
- // given `rows - footerHeight` lines, so a row that appears when a turn starts
320
- // and disappears when it ends resizes the transcript twice per turn — the
321
- // answer the user is reading visibly shrinks by a line and grows back.
322
- // The plan and goal rows above are not reserved: they change on a deliberate
323
- // command, not mid-turn.
324
- const activityLine = activityText(this.activity, spinnerFrame(), now);
325
- const painted = this.live
326
- ? shimmer(activityLine, { base: waitTone(this.waitedMs(), ink.mute, ink.warn, ink.bad), bright: ink.text, charMs: 45 }, now)
327
- : fg(activityLine, waitTone(this.waitedMs(), ink.mute, ink.warn, ink.bad));
328
- lines.push(truncateToWidth(painted, width));
329
- return lines;
330
- }
331
- }
332
- /**
333
- * A pinned block, wired live.
334
- *
335
- * The draw function has to be called on every paint: a selector's selection
336
- * moves without the block changing shape, so a snapshot of the lines would
337
- * freeze the highlight where it first drew it.
338
- */
339
- export class PinnedBlock {
340
- draw;
341
- constructor(draw) {
342
- this.draw = draw;
343
- }
344
- render(width) {
345
- return this.draw(width);
346
- }
347
- invalidate() { }
348
- }
349
- export class Dock {
350
- editor;
351
- hud;
352
- focused = false;
353
- constructor(editor, hud) {
354
- this.editor = editor;
355
- this.hud = hud;
356
- }
357
- invalidate() {
358
- this.editor.invalidate();
359
- }
360
- render(width) {
361
- const inner = Math.max(10, width - 4);
362
- const body = this.editor.render(inner).filter((line) => visibleWidth(line) > 0);
363
- const rows = body.length > 0 ? body : [""];
364
- const bar = "─".repeat(Math.max(0, width - 2));
365
- const boxed = rows.map((line) => {
366
- const gap = Math.max(0, inner - visibleWidth(line));
367
- return `│ ${line}${" ".repeat(gap)} │`;
368
- });
369
- // The pinned widgets are not drawn here: they are their own component in the
370
- // footer, above this dock. Drawing them here as well put every pinned panel
371
- // on screen twice — and the copy added by `rebuildFooter` was a *snapshot*
372
- // taken when the footer shape last changed, so a workflow panel appeared
373
- // frozen at its first live frame (`0/2 agents · idle 0s`) next to the live
374
- // one. One owner, one copy.
375
- return [`┌${bar}┐`, ...boxed, `└${bar}┘`, ...this.hud.render(width)];
376
- }
377
- handleInput(data) {
378
- this.editor.handleInput(data);
379
- }
380
- }
381
- // The default-model ordering lives in `model-resolve.ts` now: the session needs
382
- // it too, to start on a model the account can actually reach, and the session
383
- // importing the workbench would be the wrong direction.
384
- export { orderModelsForDefault } from "./model-resolve.js";
385
- /**
386
- * A picker shown in place of the input dock: full width, same frame as the dock.
387
- *
388
- * It used to be capped at 76 columns, so on a wide terminal the dialog sat in the
389
- * middle as a narrow stamp with most of the row empty, and a long command in it
390
-
391
- * The picker box. `urgent` is the approval dialog: the one picker whose frame
392
- * breathes, because it is the one that is blocking the run.
393
- *
394
- * `detail` is for a dialog whose body is the decision itself — the approval
395
- * prompt. The select list renders one line per row and truncates a description
396
- * to whatever fits beside the label, which silently cut a shell command in half:
397
- * the user was asked to approve something they could not read. A detail block is
398
- * printed under the rows instead, wrapped to the box.
399
- *
400
- * Exported so `scripts/check-approval-pulse.mjs` can assert that the animated
401
- * frame keeps every line at the same visible width as the still one.
402
- */
403
- export function pickerPanel(list, title, width, urgent = false, detail = []) {
404
- // The box spans the terminal: a long command belongs on one line when the
405
- // screen has room, and a centred 72-column box made the user read a wrapped
406
- // command in a dialog that was already asking for a decision.
407
- const box = Math.max(24, width);
408
- const inner = box - 4;
409
- // Every row is built at exactly `inner + 4` columns: `│ ` + content + padding
410
- // + ` │`. Rows are padded, never sliced — slicing assumed one border glyph at
411
- // each end and quietly walked the right border in by a column per row.
412
- const line = (content, paint) => {
413
- const clipped = visibleWidth(content) > inner ? truncateToWidth(content, inner, "…") : content;
414
- const padded = `${clipped}${" ".repeat(Math.max(0, inner - visibleWidth(clipped)))}`;
415
- return `│ ${paint ? paint(padded) : padded} │`;
416
- };
417
- const bar = "─".repeat(box - 2);
418
- const rows = [line(` ${title}`), ...list.render(inner).map((row) => line(row))];
419
- // The keys, spelled out. A picker that can be typed into has to say so: a
420
- // list of rows invites the arrow keys, and nothing about it suggests that a
421
- // word would narrow it. Left unpainted on purpose — the still panel adds no
422
- // escape sequences of its own, which is what lets it be diffed per frame.
423
- rows.push(line(" type to filter · ↑↓ move · enter select · esc cancel"));
424
- // The detail block is wrapped here, to this box's inner width, so its rows are
425
- // the same width as the ones above it. A blank row separates the choice from
426
- // the evidence for it, and the reason is muted so the command stays the
427
- // loudest thing on the panel.
428
- const wrapped = detail.flatMap((entry) => (entry === "" ? [""] : wrapTextWithAnsi(entry, inner)));
429
- const body = wrapped.length > 0 ? [line(""), ...wrapped.map((row) => line(row, (text) => fg(text, ink.mute)))] : [];
430
- if (!urgent)
431
- return ["", `┌${bar}┐`, ...rows, ...body, `└${bar}┘`];
432
- // The approval dialog is the one surface that blocks the run, so its frame
433
- // breathes: the motion is the thing that says "this one is waiting on you".
434
- // Only the two border columns and the rules are repainted — the content keeps
435
- // its own colours, so the selected option does not lose its emphasis.
436
- const frame = pulse(ink.line, ink.warn, Date.now(), 1400);
437
- const edge = fg("│", frame);
438
- const rule = (left, right) => fg(`${left}${bar}${right}`, frame);
439
- const content = [...rows, ...body].map((row) => `${edge}${row.slice(1, -1)}${edge}`);
440
- return ["", rule("┌", "┐"), ...content, rule("└", "┘")];
441
- }
442
- /**
443
- * The approval prompt's body, in the order the decision is made: why the call
444
- * needs approval, then what it actually is. The title already names the tool, so
445
- * a second `Allow tool: bash` line is dropped — the tool was named twice and the
446
- * command, which is the decision, sat furthest down.
447
- *
448
- * It returns *content* lines, not box rows: the panel is the only thing that
449
- * knows the box width. A body that formatted its own rows used a slightly
450
- * different inner width (two columns short) and walked the right border in —
451
- * which is what put a ragged edge on the dialog.
452
- */
453
- export function approvalDetail(title, message) {
454
- const lines = message.split("\n").filter(Boolean);
455
- const reason = lines.filter((line) => line.startsWith("Reason:"));
456
- const rest = lines.filter((line) => !line.startsWith("Reason:") && !line.startsWith("Allow tool:"));
457
- // A non-approval title is not already on the panel, so it heads the block.
458
- const heading = title.endsWith(" wants to run") ? [] : [title];
459
- return [...heading, ...reason, ...(rest.length > 0 ? ["", ...rest] : [])];
460
- }
461
- function contextPercent(session) {
462
- const usage = session.getContextUsage();
463
- if (usage?.percent !== null && usage?.percent !== undefined)
464
- return Math.min(100, Math.round(usage.percent));
465
- const model = session.model;
466
- const last = [...session.state.messages].reverse().find((message) => message.role === "assistant")?.usage;
467
- // An assistant entry can carry a partial or absent `usage` — a stream that was
468
- // interrupted, or a provider that reports nothing. `undefined / n` is `NaN`,
469
- // which used to reach the meter as `NaN%`. Treat an unusable number as "no
470
- // reading" rather than rendering it.
471
- const total = last?.totalTokens;
472
- if (!model || typeof total !== "number" || !Number.isFinite(total) || model.contextWindow <= 0)
473
- return 0;
474
- return Math.min(100, Math.round((total / model.contextWindow) * 100));
475
- }
476
- export function slashCommands(session) {
477
- const names = [
478
- ["init", "write project notes to AGENTS.md"],
479
- ["model", "choose a model"],
480
- ["providers", "list providers"],
481
- ["thinking", "set thinking level"],
482
- ["approval", "set what needs approval"],
483
- ["settings", "turn permission checks on or off"],
484
- ["connect", "sign in with email:password (or a base64 credential), or paste a key"],
485
- ["sync", "re-sync models from howtofix.id"],
486
- ["usage", "show the account's quota and what it has spent"],
487
- ["update", "install the newest release"],
488
- ["login", "save a provider API key"],
489
- ["logout", "remove a provider"],
490
- ["new", "start a new session"],
491
- ["resume", "resume another session"],
492
- ["compact", "compact the session"],
493
- ["name", "name this session"],
494
- ["stats", "session tokens and cost, cache hit rate and misses"],
495
- ["rtk", "compress long tool output before the model reads it"],
496
- ["diff", "show the working tree diff"],
497
- ["commit", "stage everything and commit"],
498
- ["undo", "undo the last commit"],
499
- ["rewind", "go back to an earlier message (Esc Esc)"],
500
- ["review", "ask the agent to review the diff"],
501
- ["tools", "list active tools"],
502
- ["skills", "list loaded skills"],
503
- ["preset", "browse, install and remove preset skill bundles"],
504
- ["roblox", "show RoToolbox activation, MCP and manager status"],
505
- ["proxy", "route requests through a proxy, or show the current one"],
506
- ["export", "write the transcript to a markdown file"],
507
- ["copy", "copy the last answer to the clipboard"],
508
- ["agents", "show spawned agents"],
509
- ["goal", "set or view this session's goal"],
510
- ["plan", "toggle plan mode (design before editing)"],
511
- ["workflow", "show or clear the pinned workflow panel"],
512
- ["dcp", "dynamic context pruning: on | off | status | blocks | stats | context | decompress <bN> | sweep"],
513
- ["context", "what the next request carries, and what pruning saved"],
514
- ["decompress", "reopen one compressed block, e.g. /decompress b2"],
515
- ["sweep", "close compressed blocks no ref resolves"],
516
- ["session", "show the current model"],
517
- ["help", "list commands"],
518
- ["quit", "quit"],
519
- ["exit", "quit (alias)"],
520
- ];
521
- const commands = names.map(([name, description]) => ({ name, description }));
522
- for (const prompt of session.promptTemplates)
523
- commands.push({ name: prompt.name, description: prompt.description });
524
- for (const command of session.extensionRunner.getRegisteredCommands()) {
525
- commands.push({ name: command.invocationName, description: command.description });
526
- }
527
- return commands;
528
- }
529
- /**
530
- * Build the live two-pane panel for a workflow call from its partial result.
531
- *
532
- * Returns `undefined` for any other tool, so the feed keeps its normal `└`
533
- * summary. The panel is a closure over the render width because the pane layout
534
- * is width-dependent and the feed only learns the width at draw time.
535
- *
536
- * Exported so `scripts/check-workflow-panel.mjs` can assert the wiring against
537
- * the real function rather than a copy of it.
538
- */
539
- /**
540
- * Build the live two-pane panel for a workflow call from its partial result.
541
- *
542
- * Returns `undefined` for any other tool, so the feed keeps its normal `└`
543
- * summary. The panel is a closure over the render width because the pane layout
544
- * is width-dependent and the feed only learns the width at draw time.
545
- *
546
- * Exported so `scripts/check-workflow-panel.mjs` can assert the wiring against
547
- * the real function rather than a copy of it.
548
- */
549
- export function workflowRunOf(toolName, result) {
550
- if (toolName !== "workflow")
551
- return undefined;
552
- const details = detailsOf(result);
553
- if (typeof details.name !== "string" || !Array.isArray(details.steps))
554
- return undefined;
555
- return details;
556
- }
557
- export function workflowPanel(toolName, result) {
558
- const run = workflowRunOf(toolName, result);
559
- return run ? workflowPanelFromRun(run) : undefined;
560
- }
561
- /**
562
- * The pinned form of a workflow panel: the same two-pane view the feed keeps in
563
- * its scrollback, rendered for the live surface above the editor.
564
- *
565
- * This is the answer to "does the panel get buried when I type something else?".
566
- * A panel drawn only in the feed scrolls away with the transcript — that is what
567
- * a transcript is. Claude Code solves it the same way: the running agents live in
568
- * a surface that is not a message. So the feed copy stays (it scrolls, and can be
569
- * read back later) and this copy stays pinned until the next run replaces it.
570
- */
571
- export function workflowPanelFromRun(run, view, keysActive = false) {
572
- return (width) => formatProgressView(run, [], width, view?.selectedPhase, view ? panelOptions(run, view, keysActive) : undefined);
573
- }
574
- /** The view-driven options the two-pane renderer takes. */
575
- function panelOptions(run, view, keysActive) {
576
- return {
577
- filter: (agents) => filterAgents(agents, view.filter),
578
- footerText: viewFooter(view, run.endedAt === undefined, keysActive),
579
- filterLabel: filterLabel(view.filter),
580
- drill: view.expanded,
581
- };
582
- }
583
- /**
584
- * Take the transcript's scroll keys away from the viewport.
585
- *
586
- * `TuiAltScreen` registers its own input listener in the constructor and consumes
587
- * `pageUp`, `pageDown`, `home` and `end` before any listener the app adds — its
588
- * listener is registered first, and a consumed event is not passed on. It scrolls
589
- * the *primary scroll view*, which here is the pane: the pane draws exactly one
590
- * viewport of rows, so the viewport has nothing to scroll and the keys did
591
- * nothing at all. Measured: PageUp and PageDown moved the view by zero while the
592
- * wheel worked, because the wheel reaches `pane.handleMouse` instead.
593
- *
594
- * The keys are rebound to `[]` so the viewport no longer matches them, and the
595
- * workbench's own listener handles them against `feed.scrollOffset` — which is
596
- * what actually moves the transcript. `escape` is deliberately not rebound:
597
- * transcript search uses it.
598
- */
599
- function claimTranscriptScrollKeys() {
600
- const manager = new KeybindingsManager(TUI_KEYBINDINGS, {
601
- "tui.altScreen.pageUp": [],
602
- "tui.altScreen.pageDown": [],
603
- "tui.altScreen.halfPageUp": [],
604
- "tui.altScreen.halfPageDown": [],
605
- "tui.altScreen.top": [],
606
- "tui.altScreen.bottom": [],
607
- });
608
- setKeybindings(manager);
609
- }
610
- export async function runWorkbench(options) {
611
- const terminal = new ProcessTerminal();
612
- /**
613
- * The SDK's own right-click hook, filled in below.
614
- *
615
- * `TuiAltScreen` only calls it on Windows, where the terminal has already
616
- * decided that a right click means paste: the callback is how the clipboard
617
- * text reaches the editor. Without it the click is consumed by the SDK's
618
- * handler and nothing happens — which is the "right click does not paste on
619
- * Windows" report this exists to answer.
620
- *
621
- * It is assigned from inside `runWorkbench` because the editor does not exist
622
- * yet here; the SDK only invokes it from a mouse event, long after this
623
- * returns.
624
- */
625
- let pasteFromRightClick;
626
- const tui = new TuiAltScreen(terminal, true, undefined, {
627
- mouse: true,
628
- onRightClickPaste: () => pasteFromRightClick?.(),
629
- });
630
- const feed = new FeedView();
631
- const hud = new Hud(options.cwd, options.modelLabel);
632
- // The gate is read at tool-call time, but the row is drawn every frame, so
633
- // the HUD mirrors the setting instead of querying it on every render.
634
- hud.skipPermissions = isDangerousBypass();
635
- /**
636
- * Take the checkpoint for the *next* turn, off the critical path.
637
- *
638
- * It used to run inside the Enter handler, so pressing Enter waited for a walk
639
- * of the whole worktree before the prompt was even sent (measured 1.25s on a
640
- * 60k-file directory, and the clock sat at the second it was pressed because
641
- * `session.isStreaming` was still false). The state a checkpoint has to capture
642
- * is "the worktree before the agent touches anything", and the moment the
643
- * previous run settled is exactly that state — so the snapshot is taken at
644
- * startup and again when each run settles, and Enter never waits for it.
645
- */
646
- const takeSnapshot = (prompt) => {
647
- const at = Date.now();
648
- void (async () => {
649
- try {
650
- const store = checkpointStore();
651
- const sessionId = session.getSessionStats().sessionId;
652
- // Retention sweep, once per process and off the critical path: sessions
653
- // whose checkpoints are older than RETENTION_DAYS are dropped so the
654
- // shadow repo does not grow for the life of the machine. The current
655
- // session is kept whatever its age — see sweepExpired.
656
- if (!retentionSwept) {
657
- retentionSwept = true;
658
- try {
659
- await sweepExpired(store, { keep: sessionId });
660
- }
661
- catch {
662
- // Housekeeping; a failed sweep leaves the store merely larger.
663
- }
664
- }
665
- const snapshot = await createCheckpoint(store, sessionId, prompt);
666
- // Guarded so a slow snapshot cannot replace a newer turn's checkpoint.
667
- if (snapshot && at >= latestCheckpointAt) {
668
- latestCheckpoint = snapshot;
669
- latestCheckpointAt = at;
670
- }
671
- }
672
- catch {
673
- // No shadow store (unwritable state dir): the session runs without
674
- // checkpoints rather than failing the prompt.
675
- }
676
- })();
677
- };
678
- /**
679
- * Refresh the branch/dirty marker without blocking, and only when a run is
680
- * actually in flight — the marker is invisible while idle, so spawning git for
681
- * it would be pure cost.
682
- */
683
- const refreshGitMarker = (force = false) => {
684
- if (!force && !hud.live)
685
- return;
686
- void readGit(options.cwd).then((marker) => {
687
- if (marker === null)
688
- return;
689
- hud.git = marker;
690
- refresh();
691
- });
692
- };
693
- const editor = new Editor(tui, editorTheme, { paddingX: 1 });
694
- // Widgets pinned above the editor. A workflow panel is published here while a
695
- // run is live so it cannot be buried by the next message, and the same block
696
- // is still pushed into the feed so it can be read back in the scrollback.
697
- const widgets = new Map();
698
- const setWidget = (key, content) => {
699
- if (content === undefined)
700
- widgets.delete(key);
701
- else if (typeof content === "function")
702
- widgets.set(key, content);
703
- else
704
- widgets.set(key, () => content);
705
- };
706
- // Pinned content may be pre-rendered (a plain block, laid out for the width it
707
- // was handed) or a draw function (the workflow panel, whose marks and elapsed
708
- // time are functions of the clock). A draw function is called per frame with
709
- // the live width, so it is the one form that resizes and animates.
710
- const widgetWidth = () => Math.max(20, terminal.columns);
711
- /**
712
- * The run whose phase marks may still be popping. The panel is drawn from the
713
- * clock, so the workbench has to know when the last mark is done: it asks the
714
- * run record, not the painted text, whether a phase finished moments ago.
715
- */
716
- let poppingRun;
717
- let poppingUntil = 0;
718
- /**
719
- * The workflow panel's interactive state. It is created here rather than in
720
- * `workflow-view.ts` so it survives a run ending: the user keeps looking at the
721
- * finished panel, and their phase/filter choice is still there.
722
- */
723
- const workflowView = createWorkflowView();
724
- let currentRun;
725
- let stopWorkflow = null;
726
- let workflowPinned = false;
727
- /** The run the user dismissed with Esc, so a late event cannot pin it back. */
728
- let workflowDismissed;
729
- /** True only while the user has handed the panel the keyboard with Ctrl+F. */
730
- let workflowKeysActive = false;
731
- /** The script behind the current run, kept so `s` can write it to disk.
732
- * Only set when the run was started from inline `code`: a run started from a
733
- * `path` is already a file the user chose, and copying it would drift. */
734
- let savedScript;
735
- /** Redraw the pinned panel from the current run and view. */
736
- const drawWorkflow = () => {
737
- if (!currentRun || !workflowPinned)
738
- return;
739
- setWidget("workflow", (width) => workflowPanelFromRun(currentRun, workflowView, workflowKeysActive)(width));
740
- };
741
- /** Give the keyboard back to the editor and drop the panel. */
742
- const unpinWorkflow = () => {
743
- // Remember *which* run was dismissed. A run's progress arrives as a stream of
744
- // events, so an update can land after the user has already pressed Esc — and
745
- // re-pinning then made the panel they just dismissed reappear. A new run has
746
- // a new name, so this clears itself.
747
- if (currentRun)
748
- workflowDismissed = currentRun.name;
749
- workflowPinned = false;
750
- workflowKeysActive = false;
751
- setWidget("workflow", undefined);
752
- };
753
- // The tool is built during session construction, before this screen exists, so
754
- // the panel registers its hooks here instead of taking constructor arguments.
755
- setWorkflowControls({
756
- onStop: (stop) => {
757
- stopWorkflow = stop;
758
- },
759
- isPaused: () => workflowView.paused,
760
- });
761
- const pinWorkflow = (run) => {
762
- if (workflowDismissed === run.name)
763
- return;
764
- poppingRun = run;
765
- const finishedAt = run.endedAt ?? Date.now();
766
- poppingUntil = Math.max(poppingUntil, finishedAt + POP_MS);
767
- currentRun = run;
768
- workflowPinned = true;
769
- setWidget("workflow", (width) => workflowPanelFromRun(run, workflowView, workflowKeysActive)(width));
770
- };
771
- const dock = new Dock(editor, hud);
772
- let session = options.session;
773
- let setCompressToolOutput = options.setCompressToolOutput;
774
- let closed = false;
775
- /**
776
- * Sample the session's spend: mirror it onto the HUD row and hand it back to
777
- * the caller that needs the same number.
778
- *
779
- * Called where the number changes - every session event (the same stream the
780
- * budget cap and `--print` sample) and the binding of a session at startup
781
- * and on `/new` / `/resume` - and deliberately nowhere else: the stats walk
782
- * every entry of the session, which is affordable on an event and not on
783
- * every frame of the spinner.
784
- */
785
- const syncCost = () => {
786
- const cost = session.getSessionStats().cost;
787
- hud.cost = cost;
788
- return cost;
789
- };
790
- /**
791
- * `--max-budget-usd`, live for as long as this workbench is.
792
- *
793
- * Sampled at the end of every session event — the same stream the print path
794
- * samples, so both modes stop at the same dollar — and at the top of
795
- * `sendTask`, which is where a cap that fired between turns refuses the next
796
- * one. `capAnnounced` keeps the notice singular: `check()` is called on every
797
- * event, and a note per event would bury the transcript in copies.
798
- */
799
- const spendCap = createSpendCap(options.maxBudgetUsd);
800
- let capAnnounced = false;
801
- /** Announce the cap once and cut the run that crossed it. */
802
- const enforceSpendCap = () => {
803
- const hit = spendCap.check(syncCost());
804
- if (hit === undefined || capAnnounced)
805
- return;
806
- capAnnounced = true;
807
- // Mid-turn: stop spending. Between turns there is nothing to abort, and the
808
- // refusal in `sendTask` is what stops the next prompt.
809
- if (session.isStreaming)
810
- void session.abort();
811
- note("budget", spendCapMessage(hit.spent, hit.limit));
812
- };
813
- /**
814
- * The ceiling belongs to the model, so it is recomputed wherever the model
815
- * can change: session start, a swap, and every path that calls `setModel`.
816
- * A missed site would leave the shimmer decided by the previous model, which
817
- * is exactly the lie the ceiling exists to prevent.
818
- */
819
- const syncCeiling = () => {
820
- hud.ceiling = levelCeiling(session.model);
821
- };
822
- /**
823
- * The credential pair behind the current sign-in, when this install has one.
824
- *
825
- * Read once at startup and kept in memory: every renewal rewrites the file, so
826
- * re-reading it would only ever see what this process just wrote. `undefined`
827
- * means the install predates the pair (a hand-pasted key), in which case there
828
- * is nothing to renew and the old behaviour stands.
829
- */
830
- let cliCredential = readCliCredential(AGENT_DIR);
831
- /**
832
- * Guards against two prompts refreshing at once.
833
- *
834
- * The server rotates the refresh secret on every use, so a second concurrent
835
- * renewal would present a spent token and — because the server cannot tell a
836
- * race from theft — revoke the device. Serialising here is what keeps a busy
837
- * session from logging itself out.
838
- */
839
- let refreshInFlight;
840
- /**
841
- * Renew the access token if it is about to expire, and hand the fresh key to
842
- * the runtime.
843
- *
844
- * Called before each prompt rather than on a timer: a prompt is exactly when a
845
- * dead key hurts, and a timer would fire during idle time when the renewal
846
- * cannot help. A failed renewal is deliberately silent — the stored access
847
- * token may still be valid, the relay is the authority on whether it works, and
848
- * an error on every prompt would be noise. Only a terminal refusal (the panel
849
- * says the sign-in is gone) is reported, once, because then nothing will work
850
- * until the user signs in again.
851
- */
852
- const ensureFreshCredential = async () => {
853
- if (!cliCredential || !refreshTokenIsUsable(cliCredential))
854
- return false;
855
- if (accessTokenIsFresh(cliCredential))
856
- return false;
857
- refreshInFlight ??= (async () => {
858
- try {
859
- // The shared helper owns the order (renew, then persist the rotated
860
- // secret before anything else can happen); this only adds the
861
- // serialisation above and the runtime override below.
862
- const outcome = await renewCliCredentialIfNeeded(AGENT_DIR, cliCredential);
863
- if (!outcome.renewed) {
864
- if (outcome.message)
865
- note("connect", outcome.message);
866
- return false;
867
- }
868
- cliCredential = outcome.credential;
869
- if (outcome.message)
870
- note("connect", outcome.message);
871
- await session.modelRuntime.setRuntimeApiKey("howtofix", outcome.credential.accessToken);
872
- return true;
873
- }
874
- catch {
875
- // A network failure is not a reason to stop the turn: the key in hand may
876
- // still work, and the next prompt tries again.
877
- return false;
878
- }
879
- finally {
880
- refreshInFlight = undefined;
881
- }
882
- })();
883
- return await refreshInFlight;
884
- };
885
- // One checkpoint per submitted prompt, in a shadow git repo beside the user's
886
- // own history. A read-only session never pays for it: the store is created
887
- // lazily on the first prompt that is actually snapshotted.
888
- let checkpoints;
889
- const checkpointStore = () => (checkpoints ??= openStore(options.cwd));
890
- /** Newest checkpoint, so the last turn can be offered without reading meta. */
891
- let latestCheckpoint;
892
- /** The prompt of the turn in flight, so the settled snapshot can be named. */
893
- let lastSubmitted = "";
894
- /** When the newest checkpoint was *asked for*, so a slow one cannot win a race. */
895
- let latestCheckpointAt = 0;
896
- /** Whether this process has already run the one-time retention sweep. */
897
- let retentionSwept = false;
898
- /**
899
- * The rewind selector: a box over the transcript that moves through the
900
- * session's own tree.
901
- *
902
- * This is the SDK's `TreeSelectorComponent`, not a menu of my own. It already
903
- * has what a session tree needs — the active path marked, branch folding, a
904
- * filter, a search — and it is what the harness itself uses for `/tree`. A
905
- * hand-rolled list of prompts could only ever be a worse version of it, and
906
- * the tree is the honest model of a session that has already been rewound
907
- * once (its branches are still there).
908
- */
909
- let treeSelector;
910
- /**
911
- * The entry ids the selector is showing, top to bottom.
912
- *
913
- * Built by walking the tree and applying the same rule the selector's default
914
- * view does: a `session_info`, `usage`, label, model-change or thinking-level
915
- * row is bookkeeping and is hidden. An index built from `getTree()` alone
916
- * counts those rows, so it is offset from the list the user sees — which is
917
- * what made a one-step `↑` look like a wrap.
918
- */
919
- const shownIds = () => {
920
- const hidden = new Set(["session_info", "usage", "label", "context_edit", "custom", "model_change", "thinking_level_change"]);
921
- const ids = [];
922
- const walk = (nodes) => {
923
- for (const node of nodes) {
924
- if (!hidden.has(node.entry.type))
925
- ids.push(node.entry.id);
926
- walk(node.children);
927
- }
928
- };
929
- walk(session.sessionManager.getTree());
930
- return ids;
931
- };
932
- /**
933
- * `TreeSelectorComponent` paints through the SDK's theme registry, which has to
934
- * be initialised or its `theme.get` throws. HowToFix paints with its own
935
- * palette in `theme.ts`, so this is the one place the SDK's theme is needed —
936
- * and `initTheme()` with no argument falls back to the system theme rather than
937
- * failing when a theme file is missing.
938
- */
939
- /**
940
- * The selector's keybindings.
941
- *
942
- * `TreeSelectorComponent` matches `app.tree.*` and `app.message.copy`, which
943
- * are defined in the SDK's own keybinding table — a table the published
944
- * package does not export and never installs into `hxf-tui`'s global registry.
945
- * So every one of them resolved to no key at all: page-up/down, the label and
946
- * copy keys, and all six filters were dead, and the header advertised
947
- * `←/→ page` while nothing listened for it.
948
- *
949
- * These are registered here with keys the workbench has not already claimed.
950
- * The SDK's defaults collide with global actions — its `ctrl+d` is "filter:
951
- * default" but ours is "quit", and its `ctrl+l` is "filter: labeled-only" but
952
- * ours is "scroll to bottom" — so the filter keys move to `alt+f`/`alt+1..5`,
953
- * and folding keeps the arrow keys the header already promises.
954
- */
955
- let selectorKeysReady = false;
956
- const ensureSelectorKeys = () => {
957
- if (selectorKeysReady)
958
- return;
959
- selectorKeysReady = true;
960
- const current = getKeybindings().getResolvedBindings();
961
- setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, {
962
- ...current,
963
- "app.tree.foldOrUp": ["alt+left", "ctrl+left"],
964
- "app.tree.unfoldOrDown": ["alt+right", "ctrl+right"],
965
- "app.tree.editLabel": ["shift+l"],
966
- "app.tree.toggleLabelTimestamp": ["shift+t"],
967
- "app.message.copy": ["ctrl+x"],
968
- "app.tree.filter.default": ["alt+0"],
969
- "app.tree.filter.noTools": ["alt+1"],
970
- "app.tree.filter.userOnly": ["alt+2"],
971
- "app.tree.filter.labeledOnly": ["alt+3"],
972
- "app.tree.filter.all": ["alt+4"],
973
- "app.tree.filter.cycleForward": ["alt+f"],
974
- "app.tree.filter.cycleBackward": ["alt+shift+f"],
975
- }));
976
- };
977
- /** The SDK theme, initialised once, on the first selector. */
978
- let themeReady = false;
979
- const ensureTheme = () => {
980
- if (themeReady)
981
- return;
982
- themeReady = true;
983
- initTheme();
984
- };
985
- /**
986
- * Open the selector; the pane is pinned like the workflow panel.
987
- *
988
- * The guard is on the *filtered* tree, not on `getTree().length`. A fresh
989
- * session still has a `session_info` node, so a length check passes and the
990
- * user is shown an empty `No entries found (0/0)` box that Enter cannot act on
991
- * (the selected node is `undefined`). The selector hides bookkeeping entries
992
- * in its default view, so "has a visible node" is the only honest predicate —
993
- * and it is only knowable after the component is built.
994
- */
995
- const openRewind = () => {
996
- const tree = session.sessionManager.getTree();
997
- if (tree.length === 0) {
998
- note("rewind", "nothing to rewind to yet");
999
- return;
1000
- }
1001
- if (checkpointStore().disabled) {
1002
- // Say why, instead of opening a selector whose "code too" choice cannot
1003
- // keep its promise: checkpoints are off outside a git work tree, where
1004
- // there is no uncommitted work to restore.
1005
- note("rewind", "conversation rewind only; this directory is not a git repository");
1006
- }
1007
- ensureTheme();
1008
- ensureSelectorKeys();
1009
- // The component derives its visible row count as `floor(terminalHeight / 2)`,
1010
- // and that count is also the page step for `←`/`→`. Passing the panel height
1011
- // directly made the step half the panel — so paging jumped about eleven rows
1012
- // at a time and felt like it was skidding past the entry you wanted. Passing
1013
- // twice the height makes the step match what is on screen.
1014
- const panelRows = Math.max(8, Math.floor(terminal.rows * 0.6));
1015
- const selector = new TreeSelectorComponent(tree, session.sessionManager.getLeafId(), panelRows * 2, (entryId) => {
1016
- closeRewind();
1017
- void onPickEntry(entryId);
1018
- }, () => closeRewind());
1019
- if (!selector.getTreeList().getSelectedNode()) {
1020
- note("rewind", "nothing to rewind to yet");
1021
- return;
1022
- }
1023
- treeSelector = selector;
1024
- treeSelector.focused = true;
1025
- setWidget("rewind", (width) => treeSelector?.render(width) ?? []);
1026
- refresh();
1027
- };
1028
- const closeRewind = () => {
1029
- treeSelector = undefined;
1030
- setWidget("rewind", undefined);
1031
- refresh();
1032
- };
1033
- /**
1034
- * What picking an entry does.
1035
- *
1036
- * A message carries the turn; anything else in the tree (a tool result, a
1037
- * model change) is navigable too, since the selector offers the whole tree.
1038
- * When the entry is a user message it is paired with its checkpoint by
1039
- * position, which is what makes "restore the code too" possible.
1040
- */
1041
- const onPickEntry = async (entryId) => {
1042
- const store = checkpointStore();
1043
- const prompts = session.getUserMessagesForForking();
1044
- const index = prompts.findIndex((prompt) => prompt.entryId === entryId);
1045
- const checkpoint = index >= 0 ? listCheckpoints(store, session.getSessionStats().sessionId)[index] : undefined;
1046
- const changed = checkpoint ? (await checkpointDiff(store, checkpoint)).length : 0;
1047
- if (changed === 0) {
1048
- await runRewind(entryId, undefined, "conversation");
1049
- return;
1050
- }
1051
- // Code is only offered when there is code to take back, so the question is
1052
- // never asked with one possible answer.
1053
- choose(`Rewind to this point? ${changed} file(s) differ`, [
1054
- { value: "both", label: "conversation and code", description: `drop the messages after this point and restore ${changed} file(s)` },
1055
- { value: "conversation", label: "conversation only", description: "drop the messages after this point, leave the files alone" },
1056
- { value: "code", label: "code only", description: `restore ${changed} file(s), keep the conversation` },
1057
- ], (action) => void runRewind(entryId, checkpoint, action));
1058
- };
1059
- /** Apply one rewind choice, then report what actually happened. */
1060
- const runRewind = async (entryId, checkpoint, action) => {
1061
- const parts = [];
1062
- if ((action === "both" || action === "conversation") && !session.isStreaming) {
1063
- try {
1064
- // `navigateTree` moves the session's leaf, so the next turn continues from
1065
- // that point rather than from the end of the transcript.
1066
- const result = await session.navigateTree(entryId);
1067
- if (result.cancelled) {
1068
- note("rewind", "conversation rewind cancelled");
1069
- return;
1070
- }
1071
- parts.push("conversation rewound");
1072
- // The feed mirrors the session, so it is rebuilt from the new branch.
1073
- feed.items = itemsFromMessages(session.state.messages);
1074
- if (result.editorText) {
1075
- // The original prompt goes back in the editor: the usual next step is to
1076
- // re-send or edit it.
1077
- editor.setText(result.editorText);
1078
- parts.push("prompt restored to the editor");
1079
- }
1080
- }
1081
- catch (cause) {
1082
- error(`conversation rewind failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1083
- return;
1084
- }
1085
- }
1086
- if ((action === "both" || action === "code") && checkpoint) {
1087
- const restored = await restoreCheckpoint(checkpointStore(), checkpoint);
1088
- if (!restored.ok) {
1089
- error(`code rewind failed: ${restored.output}`);
1090
- return;
1091
- }
1092
- parts.push(restored.output);
1093
- if (restored.removed.length > 0)
1094
- parts.push(`removed ${restored.removed.length} file(s)`);
1095
- // The tree under the agent changed, so the branch/dirty marker is stale.
1096
- refreshGitMarker();
1097
- }
1098
- note("rewind", parts.join("\n") || "nothing to do");
1099
- refresh();
1100
- };
1101
- // The constructor could not await, so the marker starts empty and lands here.
1102
- refreshGitMarker(true);
1103
- // The first prompt has no previous run to settle, so its checkpoint is taken
1104
- // here, while the session is still starting up.
1105
- takeSnapshot("session start");
1106
- if (options.note)
1107
- feed.items.push({ id: "note", kind: "note", title: "note", body: options.note });
1108
- feed.items.push(...itemsFromMessages(session.state.messages));
1109
- hud.percent = contextPercent(session);
1110
- syncCost();
1111
- hud.abandonAnimations();
1112
- hud.setThinking(session.thinkingLevel);
1113
- syncCeiling();
1114
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands(session), options.cwd));
1115
- void loadUsageSegment(session).then((segment) => {
1116
- if (!segment)
1117
- return;
1118
- hud.usage = segment;
1119
- tui.requestRender();
1120
- });
1121
- /**
1122
- * Tell the reader about a newer release, and install it when they asked.
1123
- *
1124
- * Silence is the default for one case only: the check answered and the copy is
1125
- * current, where "you are up to date" on every start is noise. A check that
1126
- * *failed* now speaks, because staying silent about it made a broken lookup look
1127
- * exactly like a working one — the update simply never appeared and nothing said
1128
- * why. An install the reader asked for always speaks, including when it failed.
1129
- */
1130
- const checkAndMaybeInstallUpdate = async (request) => {
1131
- const { checkForUpdate, checkFailedNote, installUpdate } = await import("./update.js");
1132
- const check = await checkForUpdate(request.current);
1133
- if (!check.available) {
1134
- const failed = checkFailedNote(check);
1135
- if (failed)
1136
- note("update", failed);
1137
- else if (request.install)
1138
- note("update", check.message);
1139
- return;
1140
- }
1141
- if (!request.install) {
1142
- note("update", `${check.message}. run /update to install it.`);
1143
- return;
1144
- }
1145
- activity("note", `installing HowToFix ${check.latest}…`);
1146
- const result = await installUpdate({ version: check.latest });
1147
- activity("note", undefined);
1148
- note("update", result.message);
1149
- refresh();
1150
- };
1151
- // Ask about a newer release only after the workbench is on screen: the check
1152
- // is a network call, and nothing about opening a session should wait on it.
1153
- if (options.update)
1154
- void checkAndMaybeInstallUpdate(options.update);
1155
- let picker = null;
1156
- let pickerTitle = "";
1157
- /** True for the approval dialog: the one picker whose frame breathes. */
1158
- let pickerUrgent = false;
1159
- /** Body lines shown under the picker rows — the command being approved. */
1160
- let pickerDetail = [];
1161
- /**
1162
- * How to close the open picker as "dismissed".
1163
- *
1164
- * `/new` and `/resume` call `session.dispose()` on the session being left while
1165
- * a picker can still be open on it (an approval dialog whose promise is held by
1166
- * the tool that asked). Dismissing first runs the picker's own `onDismiss`, so
1167
- * the tool sees a cancel instead of a promise that will never settle, and the
1168
- * stale list does not stay painted over the new session.
1169
- */
1170
- let dismissPicker;
1171
- /**
1172
- * The footer is rebuilt from components, not from rendered lines, so anything
1173
- * that changes which parts show (a picker opening, a workflow panel pinning)
1174
- * goes through here before the redraw.
1175
- */
1176
- let rebuildFooter = () => { };
1177
- const refresh = () => {
1178
- rebuildFooter();
1179
- tui.requestRender();
1180
- };
1181
- /** Set one layer of the activity line. See `activity.ts` for the layering. */
1182
- const activity = (layer, text) => {
1183
- hud.activity = withLayer(hud.activity, layer, text);
1184
- };
1185
- /** Set the retry layer: which attempt is waiting, and when it fires. */
1186
- const activityRetry = (retry) => {
1187
- hud.activity = withRetry(hud.activity, retry);
1188
- };
1189
- const toolCounts = new Map();
1190
- const failedTools = new Set();
1191
- const toolLine = () => [...toolCounts].map(([name, count]) => `${failedTools.has(name) ? "!" : "✓"} ${name} ×${count}`).join(" | ");
1192
- const showTools = () => {
1193
- const text = toolLine();
1194
- // No tools yet is not an empty tally: it is the absence of one, so the run
1195
- // layer shows through instead of an empty floor.
1196
- activity("tally", text || undefined);
1197
- };
1198
- const choose = (title, items, onPick, onDismiss, urgent = false, detail = []) => {
1199
- const list = new SelectList(items, 8, editorTheme.selectList);
1200
- list.onSelect = (item) => {
1201
- picker = null;
1202
- dismissPicker = undefined;
1203
- onPick(item.value);
1204
- };
1205
- list.onCancel = () => {
1206
- picker = null;
1207
- dismissPicker = undefined;
1208
- onDismiss?.();
1209
- refresh();
1210
- };
1211
- dismissPicker = () => list.onCancel?.();
1212
- pickerTitle = title;
1213
- pickerUrgent = urgent;
1214
- pickerDetail = detail;
1215
- picker = list;
1216
- pickerSeq += 1;
1217
- refresh();
1218
- };
1219
- const note = (title, body) => {
1220
- // `upsertNote` replaces this note's own predecessor instead of stacking a
1221
- // copy: asking `/dcp status` twice used to draw the same line twice.
1222
- feed.items = upsertNote(feed.items, { id: `cmd-${Date.now()}-${feed.items.length}`, kind: "note", title, body });
1223
- activity("tally", undefined);
1224
- refresh();
1225
- };
1226
- // A run started with `background: true` has no caller left to report to, so it
1227
- // announces itself here when it settles. Foreground runs are already in the
1228
- // tool result, and announcing those too would print everything twice.
1229
- setRunFinishedHandler((run) => {
1230
- if (!run.background)
1231
- return;
1232
- note("agent", `${summarizeRun(run)}\nread it with /agents ${run.id}`);
1233
- });
1234
- /**
1235
- * The channel behind one id is down; find a channel that is up and hand the
1236
- * user the picker instead of a list to guess from.
1237
- *
1238
- * The hint under the failure tells the user `/model` lists the models that
1239
- * answer. That claim is only useful once the CLI has checked it: on a
1240
- * gateway-wide outage the picker would just be a wall of the same dead ids,
1241
- * so the probes decide whether to open it at all. They cost one token each,
1242
- * run four at a time in parallel, and skip the id that just failed — its
1243
- * answer is already in.
1244
- */
1245
- let channelOfferInFlight = false;
1246
- const offerLiveChannel = (deadId) => {
1247
- if (channelOfferInFlight)
1248
- return;
1249
- channelOfferInFlight = true;
1250
- void (async () => {
1251
- try {
1252
- const available = (await session.modelRuntime.getAvailable()).filter((model) => isHowtofixProvider(model.provider));
1253
- const configured = new Set(available.map((model) => model.provider));
1254
- const target = HOWTOFIX_PROVIDERS.find((provider) => configured.has(provider));
1255
- // Only a howtofix run can be probed with the gateway's own probes, and
1256
- // without the stored key there is nothing to probe *with*.
1257
- const key = target ? (await session.modelRuntime.getAuth(target))?.auth?.apiKey : undefined;
1258
- if (!key)
1259
- return;
1260
- const ordered = orderModelsForDefault(available.map((model) => model.id));
1261
- const { live, probed } = await findLiveModels(ordered, key, { exclude: deadId, limit: 4 });
1262
- if (live.length === 0)
1263
- return; // gateway-wide outage: the hint above is all there is to say
1264
- const openable = picker === null;
1265
- note("gateway", `${live.length} of ${probed.length} probed models answer right now (${live.join(", ")}); ${openable ? "the model picker is open below" : "switch with /model"}.`);
1266
- if (openable)
1267
- void runSlash("/model");
1268
- }
1269
- catch {
1270
- // Offering a live model is a courtesy, never a second failure.
1271
- }
1272
- finally {
1273
- channelOfferInFlight = false;
1274
- }
1275
- })();
1276
- };
1277
- const error = (body) => {
1278
- feed.items.push({ id: `err-${Date.now()}-${feed.items.length}`, kind: "error", title: "failed", body });
1279
- // A gateway sentence the README's Troubleshooting section already explains
1280
- // is worth showing as the fix, next to the failure: in the TUI that README
1281
- // is not open, so the raw sentence is all the user gets. An unrecognized
1282
- // message diagnoses to nothing and this stays exactly what it was.
1283
- const fix = diagnoseGatewayFailure(body);
1284
- if (fix !== undefined)
1285
- note("gateway", fix);
1286
- // "no live channel" is the one failure whose fix can be *done* for the
1287
- // user: probe the other candidates, and open the picker if any answer.
1288
- const deadId = deadChannelModel(body);
1289
- if (deadId !== undefined)
1290
- offerLiveChannel(deadId);
1291
- activity("tally", undefined);
1292
- refresh();
1293
- };
1294
- /**
1295
- * The one path that changes the thinking level: `/thinking` (picker or
1296
- * argument) and the ctrl+t / ctrl+shift+t hotkeys all come through it, so
1297
- * the HUD segment, the activity line and the ceiling shimmer cannot diverge
1298
- * between the two ways of asking.
1299
- */
1300
- const setThinking = (level) => {
1301
- // Persist: the level is a preference, not a property of this conversation.
1302
- // Without it the choice lived only in the session transcript, so the next
1303
- // start came up on the default and the user's "high" was gone.
1304
- session.setThinkingLevel(level, { persist: true });
1305
- hud.setThinking(level);
1306
- syncCeiling();
1307
- activity("note", `thinking ${level}`);
1308
- refresh();
1309
- };
1310
- /** Approval dialog: block the tool call until the user picks allow or deny. */
1311
- setApprovalPrompt((title, message) => {
1312
- const { promise, resolve: settle } = Promise.withResolvers();
1313
- // The prompt body carries the decision: which tool, where it came from, why
1314
- // it needs approval, and the concrete arguments. It used to be flattened
1315
- // into the selected row's description, which the select list truncates to
1316
- // whatever fits — so a long shell command was cut down to "this is a..." and
1317
- // the user approved something they could not read. It is printed under the
1318
- // rows instead, in full and in decision order.
1319
- choose(title, [
1320
- { value: "allow", label: "allow" },
1321
- { value: "deny", label: "deny" },
1322
- ], (value) => settle(value === "allow"), () => settle(false), true, approvalDetail(title, message));
1323
- return promise;
1324
- });
1325
- const extensionStatuses = new Map();
1326
- const bindUI = (target) => {
1327
- const uiContext = workbenchUI({
1328
- select: (title, options) => {
1329
- const { promise, resolve: settle } = Promise.withResolvers();
1330
- choose(title, options.map((option) => ({ value: option, label: option })), settle,
1331
- // ESC must settle the promise, or the extension waits forever.
1332
- () => settle(undefined));
1333
- return promise;
1334
- },
1335
- confirm: (title, message) => {
1336
- const { promise, resolve: settle } = Promise.withResolvers();
1337
- choose(`${title} — ${message}`, [
1338
- { value: "yes", label: "yes" },
1339
- { value: "no", label: "no" },
1340
- ], (value) => settle(value === "yes"), () => settle(false));
1341
- return promise;
1342
- },
1343
- input: (title) => {
1344
- const { promise, resolve: settle } = Promise.withResolvers();
1345
- activity("prompt", title);
1346
- refresh();
1347
- editorSubmit = (value) => {
1348
- editorSubmit = undefined;
1349
- activity("prompt", undefined);
1350
- settle(value);
1351
- refresh();
1352
- };
1353
- return promise;
1354
- },
1355
- notify: (message, type) => {
1356
- if (type === "error")
1357
- error(message);
1358
- else
1359
- note("mcp", message);
1360
- },
1361
- setStatus: (key, text) => {
1362
- if (text === undefined)
1363
- extensionStatuses.delete(key);
1364
- else
1365
- extensionStatuses.set(key, text);
1366
- activity("note", [...extensionStatuses.values()].join(" | ") || undefined);
1367
- refresh();
1368
- },
1369
- // Extensions may pin a block above the editor. HowToFix does not need this
1370
- // for its own panels (the workflow pins itself), but it is the documented
1371
- // way for a plugin to keep something on screen, so it is routed rather
1372
- // than stubbed out.
1373
- setWidget: (key, content) => {
1374
- setWidget(key, content);
1375
- refresh();
1376
- },
1377
- });
1378
- // `bindExtensions` — not `extensionRunner.setUIContext` — is what emits
1379
- // `session_start`, and that is where the MCP extension connects its
1380
- // servers. Without it MCP never connected and codemode stayed inactive.
1381
- // The mode stays "print" so extensions use text commands instead of
1382
- // opening overlays the workbench cannot host.
1383
- void target
1384
- .bindExtensions({
1385
- uiContext,
1386
- mode: "print",
1387
- onError: (failure) => error(`${failure.event}: ${failure.error}`),
1388
- })
1389
- .catch((cause) => error(cause instanceof Error ? cause.message : String(cause)));
1390
- };
1391
- bindUI(session);
1392
- // The screen is a real layout tree, not a hand-sliced frame: the feed lives in
1393
- // a ScrollView so the viewport owns scrolling (wheel, PageUp/PageDown, `f`/`b`)
1394
- // and the dock is a sibling below it. The old `render` sliced the feed to the
1395
- // terminal height and returned exactly one screenful, so the TUI had nothing
1396
- // to scroll and the transcript could not be read back.
1397
- /** Everything below the feed: the pinned workflow panel (dock) and the HUD. */
1398
- const footer = new Container();
1399
- /**
1400
- * The transcript pane: the feed, clipped to the rows the footer leaves.
1401
- *
1402
- * `VStack.render` renders every child and then trims, and the layout hands a
1403
- * component a width but never a height — so the feed has to learn its own
1404
- * height from the terminal. Without it the feed returns the whole transcript,
1405
- * the scroll view's content is taller than its viewport, and the viewport
1406
- * scrolls the entire frame (footer included) instead of the transcript.
1407
- *
1408
- * It also owns the wheel. The viewport's own input listener runs first (it is
1409
- * registered in `TuiAltScreen`'s constructor) and consumes every wheel event,
1410
- * scrolling the frame; `handleMouse` returning `handled` is the documented way
1411
- * to stop that, and it is what keeps the dock and HUD fixed while the
1412
- * transcript moves.
1413
- */
1414
- const pane = {
1415
- invalidate: () => feed.invalidate(),
1416
- render: (width) => {
1417
- const footerRows = footer.render(Math.max(20, width)).length;
1418
- feed.viewportHeight = Math.max(1, Math.max(8, terminal.rows) - footerRows);
1419
- return feed.render(width);
1420
- },
1421
- handleMouse: (event) => {
1422
- if (rightClickPastesHere(event)) {
1423
- pasteFromClipboard();
1424
- return { handled: true };
1425
- }
1426
- // `wheelDelta` is already normalised: negative scrolls up (back in time).
1427
- const delta = event.type === "wheel" ? (event.wheelDelta ?? 0) : 0;
1428
- if (delta === 0)
1429
- return undefined;
1430
- feed.scrollOffset = Math.max(0, feed.scrollOffset - delta * 3);
1431
- refresh();
1432
- return { handled: true };
1433
- },
1434
- };
1435
- /**
1436
- * The scroll view is only a container here: it gives the pane a layout node
1437
- * the viewport can measure, while the scrolling itself is the feed's, driven
1438
- * by `pane.handleMouse`. Leaving `follow: "end"` on made the viewport snap the
1439
- * whole frame back to the bottom on every layout pass, so the transcript moved
1440
- * and the footer went with it.
1441
- */
1442
- const body = new ScrollView(pane, { follow: "none", overscroll: "contain" });
1443
- /**
1444
- * The feed grows into the rows the footer leaves; the footer keeps its own
1445
- * height.
1446
- *
1447
- * `grow` (not a fixed basis) is what keeps the footer pinned to the bottom of
1448
- * the frame: with numeric bases the footer floats up to sit right under the
1449
- * transcript while the transcript is short. The scroll view only scrolls the
1450
- * frame when its content is taller than its viewport, and `pane` renders
1451
- * exactly the viewport's worth of rows, so the frame stays put.
1452
- */
1453
- const layout = new VStack([
1454
- { component: body, grow: 1, shrink: 1 },
1455
- { component: footer, grow: 0, shrink: 0, basis: "auto" },
1456
- ]);
1457
- /**
1458
- * Which parts the footer currently holds. The footer is a component tree, so
1459
- * rebuilding it throws away the children and re-adds them; doing that on every
1460
- * `refresh()` (now 30 times a second while streaming) churned the layout for
1461
- * no reason and could blank the footer for a frame. The shape only changes when
1462
- * a picker opens or closes, or a workflow panel is pinned, so the rebuild is
1463
- * keyed on exactly that.
1464
- */
1465
- let footerShape = "";
1466
- /**
1467
- * Which picker is open, not merely that one is.
1468
- *
1469
- * A picker that opens another picker (`/settings` choosing a setting) replaces
1470
- * `picker` while it is already set. Keying on `picker ? "picker" : "dock"` left
1471
- * the shape unchanged, so `rebuildFooter` returned early and the footer kept
1472
- * rendering the list it captured before — the child picker received the keys
1473
- * while the parent stayed on screen. A sequence number makes the swap a new
1474
- * shape.
1475
- */
1476
- let pickerSeq = 0;
1477
- const footerKey = () => `${picker ? `picker:${pickerSeq}` : "dock"}|${[...widgets.keys()].join(",")}`;
1478
- rebuildFooter = () => {
1479
- const key = footerKey();
1480
- if (key === footerShape)
1481
- return;
1482
- footerShape = key;
1483
- footer.clear();
1484
- // A live component, not a `staticLine` snapshot: the pinned panel's phase
1485
- // marks and elapsed times are functions of the clock, so a captured copy
1486
- // freezes mid-animation — and a moved selection in the session tree repaints
1487
- // nothing. `PinnedBlock` calls the draw function on every paint.
1488
- for (const draw of widgets.values())
1489
- footer.addChild(new PinnedBlock(draw));
1490
- // Capture the list itself: closing the picker sets `picker = null`, and a
1491
- // component that closed over the *variable* then rendered `pickerPanel(null)`
1492
- // — which is the `Cannot read properties of null (reading 'render')` crash
1493
- // that hit every `ask` picker and every approval dialog.
1494
- const list = picker;
1495
- if (list) {
1496
- const title = pickerTitle;
1497
- const urgent = pickerUrgent;
1498
- const detail = pickerDetail;
1499
- footer.addChild({ render: (width) => pickerPanel(list, title, width, urgent, detail), invalidate: () => { } });
1500
- }
1501
- else
1502
- footer.addChild(dock);
1503
- // The footer's height is part of the layout, so a new shape is a new frame.
1504
- layout.invalidate();
1505
- };
1506
- rebuildFooter();
1507
- // The viewport reads the layout tree, not the rendered lines: it finds the
1508
- // scroll view under the cursor through `getLayoutNode`, and it sizes the frame
1509
- // from the same tree. A plain wrapper component with a `render()` returning
1510
- // lines is a *leaf* to that walker — no node means the whole screen is one
1511
- // flat component sized by its own content, the primary scroll view is never
1512
- // registered, and the wheel has nothing to scroll. So the root forwards its
1513
- // layout node to the stack it renders.
1514
- const screen = {
1515
- focused: false,
1516
- invalidate() {
1517
- feed.invalidate();
1518
- dock.invalidate();
1519
- picker?.invalidate();
1520
- layout.invalidate();
1521
- },
1522
- // The viewport walks the layout tree to find the scroll view under the
1523
- // cursor; a leaf component would leave the whole screen flat and the wheel
1524
- // with no target. `layout` is read per call because a new split replaces the
1525
- // stack instance.
1526
- [LAYOUT_NODE]: () => layoutNodeOf(layout),
1527
- render: (width) => layout.render(width),
1528
- handleMouse(event) {
1529
- if (!rightClickPastesHere(event))
1530
- return undefined;
1531
- pasteFromClipboard();
1532
- return { handled: true };
1533
- },
1534
- handleInput(data) {
1535
- // The selector and the picker are driven from the input listener, which is
1536
- // the only place that sees a key before the alt-screen viewport does. This
1537
- // root would double-handle every one of them: a forwarded `Escape` closed
1538
- // the selector, then this forwarded the same byte to the editor, which is
1539
- // what made `Esc Esc` flash the tree open and shut.
1540
- editor.handleInput(data);
1541
- },
1542
- };
1543
- tui.setLayoutRoot(screen);
1544
- tui.setFocus(screen);
1545
- let liveId = 0;
1546
- let liveBody = "";
1547
- /**
1548
- * The user asked for this run to stop.
1549
- *
1550
- * `agent_settled` carries no stop reason, so the only reliable record of "the
1551
- * user cancelled this" is the intent captured when the key was pressed. Without
1552
- * it the run stops and the screen says nothing, which reads as "the cancel did
1553
- * not work" even though it did.
1554
- */
1555
- let userAborted = false;
1556
- /** What the gateway has said so far this run; see the stall guard below. */
1557
- let liveSawAnything = false;
1558
- /**
1559
- * The tool currently executing, so a stall notice can name it.
1560
- *
1561
- * A `task` call is silent by nature: its children run in the background and the
1562
- * parent's transcript shows one row. When one of those children is blocked on a
1563
- * provider limit, the parent shows a spinner and no progress, and a notice that
1564
- * only blames "the provider" sends the user to the wrong place.
1565
- */
1566
- let runningToolName;
1567
- /** Reasoning accumulated for the turn in flight, so it can be summarised. */
1568
- let thinkingBody = "";
1569
- let thinkingStartedAt = 0;
1570
- let liveStallWarned = false;
1571
- let liveRunStartedAt = Date.now();
1572
- /**
1573
- * True from the moment Enter is pressed until `session.prompt` is actually
1574
- * sent.
1575
- *
1576
- * The snapshot is awaited before the prompt, and `session.isStreaming` stays
1577
- * false for that whole window, so the repaint condition saw "nothing is
1578
- * happening" and stopped drawing: the clock froze at the second it was pressed
1579
- * and the screen stopped updating, which is exactly the "stuck at 1s and I
1580
- * cannot do anything" report. On a 60k-file directory that window is 16.4s.
1581
- */
1582
- let submitting = false;
1583
- const toolArgs = new Map();
1584
- /**
1585
- * Retire the "queued" marker on every message that has now been read.
1586
- *
1587
- * Called when the run picks the queue up. Marking by *content* rather than by
1588
- * the id the feed generated, because the same text can legitimately be sent
1589
- * twice and the older, already-read copy must not have its marker cleared by
1590
- * the newer one.
1591
- */
1592
- const markUserMessagesRead = () => {
1593
- const queued = new Set(session
1594
- .getSteeringMessages()
1595
- .map((message) => message.trim())
1596
- .filter(Boolean));
1597
- for (const item of feed.items) {
1598
- if (item.kind !== "user" || !item.pending)
1599
- continue;
1600
- if (!queued.has(item.body.trim()))
1601
- item.pending = undefined;
1602
- }
1603
- };
1604
- const handleEvent = (event) => {
1605
- // The queue is the only honest signal that a mid-run message has not been
1606
- // read yet. `agent_start` fires when the message is handed over, so the
1607
- // "queued" marker must be cleared before that branch runs — it lands here,
1608
- // at the top, and not inside `agent_start`, because `agent_start` also
1609
- // fires for a plain first prompt where nothing was ever queued.
1610
- if (event.type === "agent_start")
1611
- markUserMessagesRead();
1612
- if (event.type === "agent_start" || event.type === "turn_start") {
1613
- // A new run starts with a clean loop guard: repeating a call across two
1614
- // prompts is legitimate, and the guard only ever speaks for one run.
1615
- if (event.type === "agent_start")
1616
- resetLoopGuard();
1617
- // A fresh run start (a resumed agent, a follow-up, a retry) is a fresh
1618
- // wait: the spinner turns and the seconds count from zero.
1619
- if (!hud.live)
1620
- hud.beginRun();
1621
- liveBody = "";
1622
- liveSawAnything = false;
1623
- liveStallWarned = false;
1624
- liveRunStartedAt = Date.now();
1625
- thinkingBody = "";
1626
- }
1627
- else if (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") {
1628
- // The SDK streams the model's reasoning, and nothing used to read it: for a
1629
- // model that thinks for ten seconds the screen showed a spinner and the word
1630
- // "thinking" and nothing else, which reads as a hang. Show the tail of the
1631
- // stream so there is visible progress, and count it as progress so the stall
1632
- // notice does not fire while the model is genuinely working.
1633
- liveSawAnything = true;
1634
- if (thinkingBody === "")
1635
- thinkingStartedAt = Date.now();
1636
- thinkingBody += event.assistantMessageEvent.delta;
1637
- const id = `think-${liveId}`;
1638
- const current = feed.items.find((item) => item.id === id);
1639
- const tail = thinkingBody.split("\n").slice(-6).join("\n");
1640
- if (current)
1641
- current.body = tail;
1642
- else
1643
- feed.items.push({ id, kind: "note", title: "thinking", body: tail });
1644
- }
1645
- else if (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_end") {
1646
- // Collapse it: reasoning is scaffolding, not the answer, and leaving it in
1647
- // full would push the transcript off the screen. One line says it happened
1648
- // and how much there was, the way Claude Code collapses it by default.
1649
- const id = `think-${liveId}`;
1650
- const item = feed.items.find((entry) => entry.id === id);
1651
- if (item) {
1652
- const seconds = Math.max(1, Math.round((Date.now() - thinkingStartedAt) / 1000));
1653
- item.title = "thought";
1654
- item.body = `${seconds}s · ${thinkingBody.length} chars of reasoning`;
1655
- }
1656
- thinkingBody = "";
1657
- }
1658
- else if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1659
- liveSawAnything = true;
1660
- liveBody += event.assistantMessageEvent.delta;
1661
- // Stripped as it streams, not only when the turn ends: a marker the model
1662
- // imitates would otherwise be painted and then disappear, which reads as a
1663
- // glitch rather than as machinery being hidden.
1664
- const shown = stripDcpMarkers(liveBody);
1665
- const id = `live-${liveId}`;
1666
- const current = feed.items.find((item) => item.id === id);
1667
- if (current)
1668
- current.body = shown;
1669
- else
1670
- feed.items.push({ id, kind: "assistant", title: "HowToFix", body: shown });
1671
- }
1672
- else if (event.type === "tool_execution_start") {
1673
- // Show the call as soon as it starts, the way Claude Code does, instead
1674
- // of waiting for the transcript to be replayed.
1675
- toolArgs.set(event.toolCallId, event.args);
1676
- // A tool call is a new silent window. Without this, `liveSawAnything` was
1677
- // already true from the text the model streamed before the call, so a tool
1678
- // that runs for minutes — `task`, a long `bash` — produced no events and no
1679
- // notice: the screen sat on a live spinner with nothing moving, which is
1680
- // exactly the "parent looks stuck while the child is blocked" report a
1681
- // sibling harness shipped. Reset the flag and the clock so the stall notice
1682
- // speaks for the call itself.
1683
- liveSawAnything = false;
1684
- liveStallWarned = false;
1685
- liveRunStartedAt = Date.now();
1686
- // Upsert, not push: a long-running tool can publish an update before its
1687
- // start event is delivered, and that update already created this item.
1688
- // Pushing a second one gave the same call two rows — the workflow panel
1689
- // drawn twice, the older copy frozen at its last live frame (`0/2 agents
1690
- // · idle 0s`) because the end event settles only the first match.
1691
- const id = `tool-${event.toolCallId}`;
1692
- const body = formatToolCall(event.toolName, event.args);
1693
- const existing = feed.items.find((entry) => entry.id === id);
1694
- if (existing) {
1695
- existing.kind = "tool";
1696
- existing.title = event.toolName;
1697
- existing.body = body;
1698
- existing.live = true;
1699
- }
1700
- else {
1701
- feed.items.push({ id, kind: "tool", title: event.toolName, body, live: true });
1702
- }
1703
- runningToolName = event.toolName;
1704
- if (event.toolName === "task") {
1705
- const live = runs.filter((run) => run.status === "queued" || run.status === "running");
1706
- activity("note", live.map((run) => `${run.agent} ${run.status}`).join(" | ") || "agent running");
1707
- }
1708
- }
1709
- else if (event.type === "tool_execution_update") {
1710
- // A long-running tool publishes partial results here. The workflow tool
1711
- // sends its run record, so the two-pane view can be built from the live
1712
- // details instead of waiting for the run to finish.
1713
- const panel = workflowPanel(event.toolName, event.partialResult);
1714
- if (panel) {
1715
- // The item `tool_execution_start` created, not a second one: the end
1716
- // event looks up `tool-<id>` to settle the call, so an update that
1717
- // pushed `res-<id>` left an orphan behind — every workflow run drew its
1718
- // panel twice, the older copy frozen at whatever the last live update
1719
- // said (`0/2 agents · idle 0s`) while the run was already finished.
1720
- // The body stays the call line: the end event moves it to `call` and
1721
- // writes the result over `body`.
1722
- const id = `tool-${event.toolCallId}`;
1723
- const existing = feed.items.find((entry) => entry.id === id);
1724
- if (existing) {
1725
- existing.panel = panel;
1726
- }
1727
- else {
1728
- feed.items.push({
1729
- id,
1730
- kind: "tool",
1731
- title: event.toolName,
1732
- body: formatToolCall(event.toolName, toolArgs.get(event.toolCallId)),
1733
- panel,
1734
- live: true,
1735
- });
1736
- }
1737
- // Pin the same view above the editor for as long as the run is live, so
1738
- // a message typed mid-run cannot bury it in the transcript. The pinned
1739
- // copy is a closure, not pre-rendered lines: the phase marks and the
1740
- // elapsed time are functions of the clock, and a frozen array would
1741
- // freeze them mid-animation.
1742
- const run = workflowRunOf(event.toolName, event.partialResult);
1743
- if (run && workflowDismissed !== run.name)
1744
- pinWorkflow(run);
1745
- refresh();
1746
- }
1747
- }
1748
- else if (event.type === "tool_execution_end") {
1749
- hud.percent = contextPercent(session);
1750
- // Settle the live call, then hang the `⎿` summary under it.
1751
- const pending = feed.items.find((entry) => entry.id === `tool-${event.toolCallId}`);
1752
- if (pending) {
1753
- pending.live = false;
1754
- pending.body = formatToolCall(event.toolName, toolArgs.get(event.toolCallId));
1755
- }
1756
- toolArgs.delete(event.toolCallId);
1757
- // The call is over, so the next silent stretch is the provider's, not this
1758
- // tool's. Clearing it also stops a later stall notice from naming a tool
1759
- // that has already returned.
1760
- runningToolName = undefined;
1761
- liveSawAnything = true;
1762
- liveStallWarned = false;
1763
- // The tally is the floor of the activity line: it survives the run ending,
1764
- // so the turn's work stays readable after the spinner stops.
1765
- toolCounts.set(event.toolName, (toolCounts.get(event.toolName) ?? 0) + 1);
1766
- if (event.isError)
1767
- failedTools.add(event.toolName);
1768
- showTools();
1769
- const detail = toolResultDetail(event.toolName, event.result);
1770
- // The whole output, kept for the ctrl+e toggle. `detail` is the default
1771
- // slice; this is what the call actually returned.
1772
- const full = toolResultFull(event.toolName, event.result);
1773
- // A finished workflow replaces its own live panel with the final run, in
1774
- // both places: the feed copy and the pinned copy. The pinned copy staying
1775
- // up after the run ends is deliberate — it is the result the user was
1776
- // waiting for, and `/workflow` clears it when it is no longer wanted.
1777
- // The finished panel is pinned the same way as the live one: a draw
1778
- // function, so the completion mark animates even though the run is over.
1779
- const finishedRun = workflowRunOf(event.toolName, event.result);
1780
- const finishedPanel = finishedRun ? workflowPanelFromRun(finishedRun) : undefined;
1781
- if (finishedRun && workflowDismissed !== finishedRun.name)
1782
- pinWorkflow(finishedRun);
1783
- // The result belongs to the call it came from, in the same feed item.
1784
- // It used to be pushed as its own row, so a run of parallel reads drew
1785
- // every head first and every summary under them, and a bare `232 lines`
1786
- // sat below an unrelated `Read`: the reader could not tell which call a
1787
- // count belonged to. One item per call, head then summary, fixes the
1788
- // attribution — and the call keeps its place in the transcript instead of
1789
- // being reordered by when it finished.
1790
- const settled = feed.items.find((entry) => entry.id === `tool-${event.toolCallId}`);
1791
- const body = formatToolResult(event.toolName, event.result, event.isError);
1792
- if (settled) {
1793
- // The item becomes a result in place: `Block` draws the call head from
1794
- // `call` and the `└` summary plus detail from the body, so one item
1795
- // carries both lines and the summary cannot drift under another call.
1796
- settled.kind = "result";
1797
- settled.call = settled.body;
1798
- settled.body = body;
1799
- settled.live = false;
1800
- if (detail.length > 0)
1801
- settled.detail = detail;
1802
- if (full.length > 0)
1803
- settled.full = full;
1804
- if (finishedPanel)
1805
- settled.panel = finishedPanel;
1806
- }
1807
- else {
1808
- feed.items.push({
1809
- id: `tool-${event.toolCallId}`,
1810
- kind: "result",
1811
- title: event.toolName,
1812
- body,
1813
- ...(detail.length > 0 ? { detail } : {}),
1814
- ...(full.length > 0 ? { full } : {}),
1815
- ...(finishedPanel ? { panel: finishedPanel } : {}),
1816
- });
1817
- }
1818
- // Shell and file tools change the working tree; keep the HUD honest.
1819
- // Only these four can, so the other tools do not pay for a git spawn.
1820
- if (event.toolName === "bash" || event.toolName === "powershell" || event.toolName === "edit" || event.toolName === "write") {
1821
- refreshGitMarker();
1822
- }
1823
- }
1824
- else if (event.type === "compaction_start") {
1825
- // The SDK squeezes the context here, and the screen used to say nothing
1826
- // at all: the context meter dropped from 62% to 26% with no explanation,
1827
- // which reads as the assistant losing its memory. Say it is happening,
1828
- // and say why — a threshold squeeze and a manual `/compact` are the same
1829
- // event with different reasons.
1830
- liveSawAnything = true;
1831
- const why = event.reason === "manual" ? "on request" : "context is nearly full";
1832
- const item = feed.items.find((entry) => entry.id === `compact-${liveId}`);
1833
- const title = "compacting context";
1834
- const body = `${why} · summarizing the older turns`;
1835
- if (item) {
1836
- item.title = title;
1837
- item.body = body;
1838
- }
1839
- else {
1840
- feed.items.push({ id: `compact-${liveId}`, kind: "note", title, body, live: true });
1841
- }
1842
- activity("note", "compacting context");
1843
- }
1844
- else if (event.type === "compaction_end") {
1845
- // Settle the note with the real numbers: how much was in the window
1846
- // before, and how much the summary squeezed it to. This is the receipt
1847
- // the user never got, and it is the difference between "it forgot" and
1848
- // "it compressed".
1849
- const item = feed.items.find((entry) => entry.id === `compact-${liveId}`);
1850
- const result = event.result;
1851
- const settledTitle = "compacted";
1852
- let settledBody;
1853
- if (event.aborted) {
1854
- settledBody = "cancelled";
1855
- }
1856
- else if (event.errorMessage) {
1857
- settledBody = `failed: ${event.errorMessage}`;
1858
- }
1859
- else if (result) {
1860
- const before = Math.round(result.tokensBefore / 1000);
1861
- const after = result.estimatedTokensAfter !== undefined ? Math.round(result.estimatedTokensAfter / 1000) : undefined;
1862
- // A squeeze that frees almost nothing is worse than no squeeze: the
1863
- // window is still full, so the very next turn crosses the threshold and
1864
- // compacts again — the loop a sibling harness reported as "compaction
1865
- // freed too little context to make progress". Say when the receipt is
1866
- // barely a receipt, so a session that keeps compacting reads as the
1867
- // window being too small for the work instead of as a stuck agent.
1868
- const freed = result.estimatedTokensAfter !== undefined ? result.tokensBefore - result.estimatedTokensAfter : undefined;
1869
- const barely = freed !== undefined && freed < 2000;
1870
- settledBody = after !== undefined
1871
- ? `${before}k → ${after}k tokens · older turns summarized${barely ? "\nthis freed almost nothing; the window may be too small for this work" : ""}`
1872
- : `${before}k tokens · older turns summarized`;
1873
- }
1874
- else {
1875
- settledBody = "nothing to compact";
1876
- }
1877
- if (item) {
1878
- item.title = settledTitle;
1879
- item.body = settledBody;
1880
- item.live = false;
1881
- }
1882
- else {
1883
- feed.items.push({ id: `compact-${liveId}`, kind: "note", title: settledTitle, body: settledBody });
1884
- }
1885
- hud.percent = contextPercent(session);
1886
- activity("note", undefined);
1887
- }
1888
- else if (event.type === "message_start" && event.message.role === "user") {
1889
- // The session is the authority on what the model has actually been given,
1890
- // and this event is the moment the turn reaches it. Rebuild the feed's view
1891
- // of that message from it: the feed drew its own copy at submit time with
1892
- // its own id, so without this the message is drawn twice — once as the
1893
- // optimistic row and once as the replayed turn — and the row that stays
1894
- // carries no queue state at all.
1895
- //
1896
- // The copy is identified by content and only ever retired once, which is
1897
- // why the id is not matched: the two ids are generated by different code.
1898
- const text = userTextOf(event.message);
1899
- if (text)
1900
- feed.items = upsertUserTurn(feed.items, text, `u-${event.message.timestamp}`);
1901
- // Whatever is still in the steering queue has not been read; everything
1902
- // else has. This is the same moment `agent_start` fires, so it runs here
1903
- // rather than there — a first prompt has no queue and nothing to clear.
1904
- markUserMessagesRead();
1905
- }
1906
- else if (event.type === "message_end" && event.message.role === "assistant") {
1907
- const live = feed.items.find((item) => item.id === `live-${liveId}`);
1908
- if (live)
1909
- live.id = `done-${liveId}`;
1910
- // A turn that failed before it produced a word leaves an empty row, and an
1911
- // empty row reads as a run that is still thinking. The provider said why —
1912
- // a 401, a missing key, a model this account cannot reach — so say it: a
1913
- // prompt that answers nothing and explains nothing is the worst version of
1914
- // this bug.
1915
- const failure = event.message.errorMessage;
1916
- if (failure && live) {
1917
- live.title = "failed";
1918
- live.kind = "error";
1919
- live.body = liveBody.trim() ? `${failure}\n\n${liveBody.trim()}` : failure;
1920
- }
1921
- else if (failure) {
1922
- feed.items.push({ id: `e-${event.message.timestamp}`, kind: "error", title: "failed", body: failure });
1923
- }
1924
- liveId += 1;
1925
- liveBody = "";
1926
- hud.percent = contextPercent(session);
1927
- }
1928
- else if (event.type === "agent_settled") {
1929
- // The run is over: the sweep stops with it, so the row settles on its
1930
- // static colour instead of freezing mid-band.
1931
- hud.live = false;
1932
- // A queue that is still non-empty here was never delivered — the run ended
1933
- // first. Leaving the marker on would say "the agent will read this" about a
1934
- // run that is no longer running, so the text goes back to the editor and
1935
- // the marker goes with it.
1936
- const undelivered = [...session.getSteeringMessages()].map((message) => message.trim()).filter(Boolean);
1937
- if (undelivered.length > 0) {
1938
- const draft = editor.getText().trim();
1939
- editor.setText(draft ? `${undelivered.join("\n")}\n${draft}` : undelivered.join("\n"));
1940
- const dropped = new Set(undelivered);
1941
- for (const item of feed.items) {
1942
- if (item.kind === "user" && item.pending && dropped.has(item.body.trim()))
1943
- item.pending = undefined;
1944
- }
1945
- note("queued", `${undelivered.length} unread message${undelivered.length === 1 ? "" : "s"} were never read and are back in the editor`);
1946
- }
1947
- for (const item of feed.items) {
1948
- if (item.kind === "user" && item.pending)
1949
- item.pending = undefined;
1950
- }
1951
- // The worktree is quiet again, so this is the state the *next* prompt
1952
- // starts from. Taking it here keeps the Enter path free of git.
1953
- takeSnapshot(lastSubmitted);
1954
- lastSubmitted = "";
1955
- activity("run", undefined);
1956
- if (userAborted) {
1957
- // Say it in the transcript, not only in the activity row that has just
1958
- // been cleared: the feed is the record, and "stopped" is the answer the
1959
- // user pressed the key for.
1960
- const live = feed.items.find((item) => item.id === `live-${liveId}`);
1961
- if (live) {
1962
- live.id = `stopped-${liveId}`;
1963
- live.title = "stopped";
1964
- live.body = liveBody.trim() || "(no output)";
1965
- live.live = false;
1966
- }
1967
- else {
1968
- feed.items.push({ id: `stopped-${liveId}`, kind: "note", title: "stopped", body: "run cancelled" });
1969
- }
1970
- liveId += 1;
1971
- liveBody = "";
1972
- note("stopped", "run cancelled by Esc");
1973
- userAborted = false;
1974
- }
1975
- hud.percent = contextPercent(session);
1976
- // The run is over, so its note goes with it. `activityText` gives a note
1977
- // priority over the run, and the note layer was never cleared here — so the
1978
- // stall notice ("no answer yet after 45s — the provider may be stalled")
1979
- // stayed pinned under the finished answer, contradicting it, until the next
1980
- // prompt cleared it. Clearing the layer this event owns is the fix; a note
1981
- // that belongs to a *waiting user* (a prompt) lives in its own layer and
1982
- // still survives, which is the property `check-activity` guards.
1983
- activity("note", undefined);
1984
- // A retry the run is still waiting on must not outlive it either.
1985
- activityRetry(undefined);
1986
- // The agent may have edited files, so the branch/dirty marker is stale.
1987
- // `hud.live` is cleared just above, so refresh it directly rather than
1988
- // through the idle-guarded helper.
1989
- void readGit(options.cwd).then((marker) => {
1990
- if (marker === null)
1991
- return;
1992
- hud.git = marker;
1993
- refresh();
1994
- });
1995
- editor.disableSubmit = false;
1996
- }
1997
- else if (event.type === "thinking_level_changed") {
1998
- hud.setThinking(event.level);
1999
- }
2000
- else if (event.type === "auto_retry_start") {
2001
- // The stall notice and a retry describe the same silence — a provider that
2002
- // has not answered — and the retry is the one that says what is being done
2003
- // about it, so the notice it supersedes goes instead of sitting under it.
2004
- activity("note", undefined);
2005
- activityRetry({
2006
- attempt: event.attempt,
2007
- maxAttempts: event.maxAttempts,
2008
- error: event.errorMessage,
2009
- until: Date.now() + event.delayMs,
2010
- });
2011
- }
2012
- else if (event.type === "auto_retry_end") {
2013
- // The line has to stop saying "retry" the moment that stops being true.
2014
- // It used to be a note, set on the start and cleared only when the whole
2015
- // run ended — so an answer that came back on attempt 2 sat under a
2016
- // `retry 1/3` that never went away, for the rest of a run that could last
2017
- // minutes. The attempt count moves to the transcript, which is where a
2018
- // record of what happened belongs; the line goes back to the run.
2019
- activityRetry(undefined);
2020
- if (event.success) {
2021
- note("retry", `answered on attempt ${event.attempt} after a retry`);
2022
- }
2023
- else {
2024
- activity("note", `retry ${event.attempt} gave up${event.finalError ? ` · ${firstLine(event.finalError)}` : ""}`);
2025
- }
2026
- }
2027
- // Usage events are what move the total, so this is the sample that catches
2028
- // the crossing mid-turn — same stream, same dollar, as `--print`.
2029
- enforceSpendCap();
2030
- refresh();
2031
- };
2032
- let unsubscribe = session.subscribe(handleEvent);
2033
- // A braille spinner needs the clock to advance, so keep a timer running while
2034
- // the agent works; `refresh()` is idempotent and cheap when idle. The meter
2035
- // finishes its move after the run ends, so the clock outlives the stream by
2036
- // the length of that move rather than cutting it off mid-slide.
2037
- const spinner = setInterval(() => {
2038
- const popping = poppingRun !== undefined && Date.now() < poppingUntil;
2039
- // The ceiling shimmer is the one thing still asking for frames while the
2040
- // user is idle at the top level: without this clause the band would freeze
2041
- // exactly where it is supposed to move.
2042
- // The retry layer is here for the same reason the ceiling is: it is the only
2043
- // thing still asking for frames while the model is not streaming. A retry
2044
- // sleeps between attempts, so nothing else would tick and the countdown
2045
- // would freeze at whatever second the failure happened on.
2046
- if (submitting || session.isStreaming || hud.settling() || feed.entering() || picker || popping || hud.atCeiling() || hud.flashing() || hud.activity.retry !== undefined)
2047
- refresh();
2048
- // A gateway that accepts the request and never answers produces no event at
2049
- // all, so nothing above ever fires: the spinner turns, the clock climbs and
2050
- // the context meter sits at 0% forever. Measured against a server that holds
2051
- // the connection open: 45s in, still `thinking`, no error, no answer. Say so
2052
- // once, so a frozen screen reads as a stalled connection instead of a bug.
2053
- if ((submitting || session.isStreaming) &&
2054
- !liveSawAnything &&
2055
- !liveStallWarned &&
2056
- Date.now() - liveRunStartedAt > STALL_NOTICE_MS) {
2057
- liveStallWarned = true;
2058
- const waiting = runningToolName;
2059
- const seconds = Math.round((Date.now() - liveRunStartedAt) / 1000);
2060
- activity("note", waiting
2061
- ? `${waiting} has been running for ${seconds}s — it may be blocked; esc cancels`
2062
- : `no answer yet after ${seconds}s — the provider may be stalled; ctrl+c cancels`);
2063
- refresh();
2064
- }
2065
- }, ANIMATION_TICK_MS);
2066
- spinner.unref?.();
2067
- const swap = async (next) => {
2068
- // A picker left open belongs to the session being replaced. Dismiss it first:
2069
- // its promise is held by whatever asked the question, and disposing the
2070
- // session under it would leave that caller waiting on a promise nobody can
2071
- // settle. The tool then reports a cancel, which is the truth.
2072
- dismissPicker?.();
2073
- dismissPicker = undefined;
2074
- unsubscribe();
2075
- // The checkpoints of the session being left are unreachable from here on —
2076
- // the menu lists the current session's turns — so their refs go with it.
2077
- // The objects stay until git decides otherwise; nothing is deleted eagerly.
2078
- if (checkpoints)
2079
- void dropSession(checkpoints, session.getSessionStats().sessionId);
2080
- latestCheckpoint = undefined;
2081
- latestCheckpointAt = 0;
2082
- // The outgoing session's DCP state and its compress snapshot are keyed by
2083
- // session id in process-wide maps that nothing else clears, so each `/new`
2084
- // and `/resume` would otherwise keep the previous session's ref maps and a
2085
- // copy of its transcript until the process exits.
2086
- const leaving = session.getSessionStats().sessionId;
2087
- dropDcpState(leaving);
2088
- dropSnapshot(leaving);
2089
- // Every MCP server the outgoing session held is released before it goes.
2090
- // Repeated `/new` and `/resume` would otherwise accumulate one set of server
2091
- // processes per session for the life of the CLI.
2092
- await mcpLifecycle.dispose();
2093
- // The outgoing session's final cost dies with it, so the cap has to be
2094
- // handed that number before disposal: `/new` must not reset a spend total
2095
- // that is supposed to span the whole workbench.
2096
- session.dispose();
2097
- session = next.session;
2098
- // The compression toggle belongs to the session, like the mcp seam below it: the
2099
- // outgoing session's setter would write a flag nothing reads any more.
2100
- setCompressToolOutput = next.setCompressToolOutput;
2101
- // The seam belongs to the session, so it is replaced with it rather than
2102
- // reused: a fresh session owns no servers until it starts them.
2103
- mcpLifecycle = new McpLifecycle({
2104
- initial: Object.keys(readConfigForMenu().mcpServers),
2105
- apply: async (added, removed) => {
2106
- await reloadPresetResources();
2107
- if (added.length > 0 || removed.length > 0) {
2108
- const parts = [];
2109
- if (added.length > 0)
2110
- parts.push(`connected ${added.join(", ")}`);
2111
- if (removed.length > 0)
2112
- parts.push(`stopped ${removed.join(", ")}`);
2113
- note(added.length > 0 ? "mcp connected" : "mcp stopped", parts.join("; "));
2114
- }
2115
- },
2116
- report: (message) => error(message),
2117
- });
2118
- liveId = 0;
2119
- liveBody = "";
2120
- feed.items = [];
2121
- // A different session is a different transcript: the reader's position in the
2122
- // old one means nothing here, so this is the one place the intent is reset on
2123
- // purpose. `render` no longer clears it (that made a single short frame —
2124
- // a compaction, a rebuild — destroy the position for good), and the content
2125
- // anchor goes with it.
2126
- feed.scrollOffset = 0;
2127
- if (next.note)
2128
- feed.items.push({ id: "note", kind: "note", title: "note", body: next.note });
2129
- feed.items.push(...itemsFromMessages(session.state.messages));
2130
- hud.model = next.modelLabel.split("/").at(-1) || "no-model";
2131
- hud.setThinking(session.thinkingLevel);
2132
- syncCeiling();
2133
- hud.percent = contextPercent(session);
2134
- syncCost();
2135
- hud.abandonAnimations();
2136
- activity("tally", undefined);
2137
- activity("run", undefined);
2138
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands(session), options.cwd));
2139
- editor.disableSubmit = false;
2140
- bindUI(session);
2141
- unsubscribe = session.subscribe(handleEvent);
2142
- refresh();
2143
- };
2144
- const shutdown = async () => {
2145
- if (closed)
2146
- return;
2147
- closed = true;
2148
- clearInterval(spinner);
2149
- unsubscribe();
2150
- // `SessionEnd` has to be run here, on the way out, and awaited.
2151
- //
2152
- // Measured: the SDK's `session_shutdown` does not fire when the process
2153
- // exits — `/quit` and double Ctrl+C both produced zero events, even for a
2154
- // TypeScript extension writing synchronously. It fires for a session being
2155
- // *replaced* (`new`/`resume`/`fork`), which is a different moment. So the
2156
- // documented "fires when the session is quit" can only be honoured by the
2157
- // CLI's own exit path, and it must complete before `process.exit`.
2158
- try {
2159
- const loaded = loadHooks(HOOKS_FILE);
2160
- if (loaded.config.hooks.SessionEnd) {
2161
- const { outcome } = await runHooksFor(loaded.config, "SessionEnd", { hook_event_name: "SessionEnd", cwd: process.cwd(), reason: "quit" }, { cwd: process.cwd() });
2162
- if (outcome.error)
2163
- note("hooks", `SessionEnd hook: ${outcome.error}`);
2164
- }
2165
- }
2166
- catch {
2167
- // A hook is a courtesy at exit: a broken one must not stop the CLI from
2168
- // closing, and there is no screen left to complain on anyway.
2169
- }
2170
- // Before the session goes, release every MCP server it started. A teardown
2171
- // failure is already reported by the seam rather than thrown, so exit is
2172
- // never blocked by a server that will not close.
2173
- await mcpLifecycle.dispose();
2174
- // The outgoing session's final cost dies with it, so the cap has to be
2175
- // handed that number before disposal: `/new` must not reset a spend total
2176
- // that is supposed to span the whole workbench.
2177
- const leaving = session.getSessionStats().sessionId;
2178
- spendCap.close(session.getSessionStats().cost);
2179
- dropDcpState(leaving);
2180
- dropSnapshot(leaving);
2181
- session.dispose();
2182
- tui.stop();
2183
- process.stdout.write("\x1b[0m");
2184
- process.exit(0);
2185
- };
2186
- /**
2187
- * Everything `submit()` does after its slash/shell checks: record the turn,
2188
- * reset the run state, and send the prompt. `/skill:name` arrives from
2189
- * `runSlash` and needs this exact path — calling `submit()` back would loop
2190
- * on the leading slash — so the tail lives here rather than being copied.
2191
- */
2192
- // The daily check-in, claimed on the user's behalf: once at start, and again
2193
- // after a turn settles, because a terminal left open across midnight would
2194
- // otherwise miss the new day. The server keeps the rule that matters — one
2195
- // claim per account per day — so repeating the call costs nothing.
2196
- //
2197
- // Silent by design: a gateway that cannot be reached must not interrupt the
2198
- // work. HOWTOFIX_CHECKIN_DEBUG=1 records which branch was taken, so a silent
2199
- // skip can be told apart from a silent failure.
2200
- let lastClaimCheck = 0;
2201
- const maybeClaimDaily = async (force = false) => {
2202
- const now = Date.now();
2203
- if (!force && now - lastClaimCheck < 10 * 60 * 1000)
2204
- return;
2205
- lastClaimCheck = now;
2206
- const trace = (line) => {
2207
- if (process.env.HOWTOFIX_CHECKIN_DEBUG !== "1")
2208
- return;
2209
- try {
2210
- appendFileSync(`${AGENT_DIR}/checkin-debug.log`, `${new Date().toISOString()} ${line}\n`);
2211
- }
2212
- catch {
2213
- // Diagnostics must never break the session.
2214
- }
2215
- };
2216
- // Returns true when it got far enough to know the answer (claimed, already
2217
- // claimed, or refused). False means the runtime was not ready yet.
2218
- const runOnce = async () => {
2219
- try {
2220
- const configured = new Set((await session.modelRuntime.getAvailable())
2221
- .map((model) => model.provider)
2222
- .filter((provider) => isHowtofixProvider(provider)));
2223
- const target = HOWTOFIX_PROVIDERS.find((provider) => configured.has(provider));
2224
- trace(`providers=${[...configured].join(",") || "(none)"} target=${target ?? "(none)"}`);
2225
- if (!target)
2226
- return false;
2227
- const raw = (await session.modelRuntime.getAuth(target))?.auth?.apiKey;
2228
- // The provider entry can hold an environment reference (`$HOWTOFIX_VIP_API_KEY`)
2229
- // rather than the key itself. The SDK substitutes it for chat calls; this
2230
- // call is ours, so it has to resolve the reference or it sends the literal
2231
- // string and the gateway answers 401.
2232
- const reference = raw?.trim().match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$|^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/);
2233
- const fromEnv = (name) => (name ? (process.env[name] ?? "").trim() : "");
2234
- let key = reference ? fromEnv(reference[1] ?? reference[2]) || undefined : raw?.trim() || undefined;
2235
- if (!key) {
2236
- for (const name of ["HOWTOFIX_API_KEY", "HOWTOFIX_VIP_API_KEY", "HOWTOFIX_FREE_API_KEY"]) {
2237
- const candidate = fromEnv(name);
2238
- if (candidate) {
2239
- key = candidate;
2240
- trace(`key dari fallback ${name}`);
2241
- break;
2242
- }
2243
- }
2244
- }
2245
- trace(`key=${key ? `ada(${key.length})` : "(kosong)"}${reference ? " dari env" : ""}`);
2246
- if (!key)
2247
- return false;
2248
- const status = await checkCliCheckin(key);
2249
- trace(`status ok=${status.ok} enabled=${status.enabled} claimed=${status.checkedInToday} msg=${status.message}`);
2250
- if (!status.ok || !status.enabled || status.checkedInToday)
2251
- return true;
2252
- const claim = await claimCliCheckin(key);
2253
- trace(`claim ok=${claim.ok} awarded=${claim.quotaAwarded} msg=${claim.message}`);
2254
- if (claim.ok) {
2255
- note("check-in", `daily check-in claimed: +${formatQuotaUsd(claim.quotaAwarded)} credited to your howtofix.id account`);
2256
- refresh();
2257
- }
2258
- return true;
2259
- }
2260
- catch (e) {
2261
- trace(`error ${e.message}`);
2262
- return false;
2263
- }
2264
- };
2265
- if (await runOnce())
2266
- return;
2267
- // The model runtime can still be loading when the workbench opens; one
2268
- // retry after it settles is what turns a silent skip into a claim.
2269
- await new Promise((resolve) => setTimeout(resolve, 4000));
2270
- await runOnce();
2271
- };
2272
- const sendTask = async (task) => {
2273
- // A cap that has fired is a stop, not a warning: the next turn is refused
2274
- // before it can spend anything. Slash commands and `!sh` do not come
2275
- // through here, so `/stats`, `/model` and `/quit` still answer after it —
2276
- // which is how a user reads what they spent and gets out.
2277
- const capHit = spendCap.check(session.getSessionStats().cost);
2278
- if (capHit !== undefined) {
2279
- error(`budget reached: $${capHit.spent.toFixed(4)} of $${capHit.limit.toFixed(2)} — raise it with ` +
2280
- `--max-budget-usd, or start without the flag`);
2281
- return;
2282
- }
2283
- // A message typed while the run is working does not start a turn: it is
2284
- // handed to the run in flight and delivered with the next tool result. So it
2285
- // must not reset what belongs to that turn. It used to: the tally and the run
2286
- // clock were cleared on every submit, so steering a long run zeroed the count
2287
- // of tools it had already called and restarted the timer, which is why the
2288
- // elapsed time jumped backwards after a mid-run message. The HUD's spinner
2289
- // was already immune — `beginRun` keeps its start when the label matches —
2290
- // which is exactly why the bug hid: only the seconds and the tally moved.
2291
- const steering = session.isStreaming;
2292
- // The item's id is captured so the turn can be recognised when the real
2293
- // message arrives from the session: the id below is not reused, and the two
2294
- // would otherwise be two rows for one message. See the `message_start` case.
2295
- const optimisticId = `you-${Date.now()}`;
2296
- feed.items.push({
2297
- id: optimisticId,
2298
- kind: "user",
2299
- title: "you",
2300
- body: task,
2301
- // Marked pending for a steer only. A fresh turn is read immediately, and a
2302
- // marker on it would be a claim that is wrong from the first frame.
2303
- pending: steering ? true : undefined,
2304
- });
2305
- // Snapshot the worktree *before* the agent touches anything: that is what
2306
- // "go back to before this prompt" has to mean. The snapshot is of the tree
2307
- // as it stands, so the checkpoint named for this prompt is the state it
2308
- // started from, and rewinding to it undoes this turn and every turn after.
2309
- if (!steering) {
2310
- // A fresh turn starts with a fresh tally: the previous turn's counts are
2311
- // not this turn's work. The run layer takes over the line while the agent
2312
- // works.
2313
- activity("tally", undefined);
2314
- toolCounts.clear();
2315
- failedTools.clear();
2316
- activity("run", "thinking");
2317
- // The clock starts here, at submit, not at the first event: the wait the
2318
- // user experiences is the wait after pressing enter, and starting it in the
2319
- // event handler both lagged and — because a second run resets it only when
2320
- // its label differs — carried the previous run's seconds into the new one.
2321
- hud.beginRun();
2322
- }
2323
- // Not `requestRender(true)`: forcing resets the painter's previous frame, so
2324
- // the screen is erased (`ESC[2J`) and fully redrawn — a visible blink on
2325
- // every submit. A plain request diffs against the previous frame and still
2326
- // lands within one frame interval (the throttle is 16 ms).
2327
- refresh();
2328
- // The editor stays open mid-run on purpose: typing while the agent works is
2329
- // how a steer is sent. Locking it here made the steering path unreachable.
2330
- editor.disableSubmit = false;
2331
- const expanded = expandFileReferences(task, options.cwd);
2332
- // No snapshot here: it was taken when the previous run settled (or at
2333
- // startup), which is the same worktree state and costs this keypress nothing.
2334
- lastSubmitted = task;
2335
- // A short-lived access token has to be renewed before it is used, and the
2336
- // prompt is the moment it is used. This is awaited: a renewal that landed
2337
- // after the request went out would be a failed turn, and the wait is a single
2338
- // HTTPS round trip every few hours.
2339
- await ensureFreshCredential();
2340
- submitting = true;
2341
- // A message sent mid-run steers the run instead of queueing behind it: it is
2342
- // delivered with the next tool result, so the agent can change course
2343
- // without finishing the work it was about to abandon. `followUp` would wait
2344
- // for the whole turn, which for a long run is the wrong answer to "wait, do
2345
- // this instead".
2346
- const run = steering ? session.prompt(expanded, { streamingBehavior: "steer" }) : session.prompt(expanded);
2347
- submitting = false;
2348
- // The claim rides along with real use: a terminal left open past midnight
2349
- // claims the new day on the next turn instead of waiting for a restart.
2350
- void run
2351
- .then(() => maybeClaimDaily())
2352
- .catch((cause) => error(cause instanceof Error ? cause.message : String(cause)));
2353
- };
2354
- const submit = (text) => {
2355
- const task = text.trim();
2356
- if (!task)
2357
- return;
2358
- if (editorSubmit) {
2359
- const take = editorSubmit;
2360
- editorSubmit = undefined;
2361
- editor.setText("");
2362
- take(task);
2363
- return;
2364
- }
2365
- editor.setText("");
2366
- editor.addToHistory(task);
2367
- if (task.startsWith("!")) {
2368
- const command = task.slice(1).trim();
2369
- if (!command)
2370
- return;
2371
- feed.items.push({ id: `sh-${Date.now()}`, kind: "tool", title: "$", body: command });
2372
- activity("run", "shell");
2373
- // Same reason as the prompt path: a forced render wipes the screen.
2374
- refresh();
2375
- void session
2376
- .executeBash(command, (chunk) => {
2377
- activity("run", `shell · ${chunk.length} bytes`);
2378
- refresh();
2379
- })
2380
- .then((result) => {
2381
- const output = result.output.trim();
2382
- feed.items.push({
2383
- id: `sh-out-${Date.now()}`,
2384
- kind: result.exitCode === 0 ? "result" : "error",
2385
- title: `exit ${result.exitCode ?? "?"}`,
2386
- body: output || "(no output)",
2387
- });
2388
- activity("tally", undefined);
2389
- refresh();
2390
- })
2391
- .catch((cause) => error(cause instanceof Error ? cause.message : String(cause)));
2392
- return;
2393
- }
2394
- if (task.startsWith("/")) {
2395
- void runSlash(task);
2396
- return;
2397
- }
2398
- void sendTask(task);
2399
- };
2400
- editor.onSubmit = submit;
2401
- /**
2402
- * Esc Esc with an empty editor opens the rewind menu, the way `omp` and
2403
- * Claude Code do it. The window is short (600 ms) because a second Esc is
2404
- * otherwise a normal thing to press; Esc with text in the editor clears the
2405
- * draft, as it always did, and is not a rewind gesture.
2406
- */
2407
- let lastEscapeAt = 0;
2408
- // Idle Ctrl+C quits on the second press, not the first: one keystroke is too
2409
- // easy to send by accident in a terminal where the same key is the copy
2410
- // shortcut, and quitting takes the transcript with it.
2411
- let lastCtrlCAt = 0;
2412
- /**
2413
- * Insert the clipboard at the cursor, saying why when it cannot be read.
2414
- *
2415
- * Shared by `ctrl+v` and a right click, because they are the same intent and
2416
- * a user who reaches for one should not get a different answer than the other.
2417
- */
2418
- const pasteFromClipboard = () => {
2419
- void readClipboardText()
2420
- .then((result) => {
2421
- if (!result.ok) {
2422
- note("paste", result.reason);
2423
- }
2424
- else {
2425
- editor.insertTextAtCursor(result.text);
2426
- }
2427
- refresh();
2428
- })
2429
- .catch((cause) => {
2430
- note("paste", `could not read the clipboard: ${cause instanceof Error ? cause.message : String(cause)}`);
2431
- refresh();
2432
- });
2433
- };
2434
- // The SDK's right-click hook resolves to this. On Windows the terminal has
2435
- // already decided the click means paste, so this is the whole of the handling
2436
- // there — the pane's own predicate deliberately declines on that platform.
2437
- pasteFromRightClick = pasteFromClipboard;
2438
- /**
2439
- * A right click pastes.
2440
- *
2441
- * This cannot live in the input listener: `TuiAltScreen` registers its own
2442
- * listener in its constructor, `Set` iterates in insertion order, and that
2443
- * listener consumes every SGR mouse sequence before ours is reached — an
2444
- * instrumented run showed the bytes never arriving. The mouse event that the
2445
- * alt-screen synthesises from those bytes is the hook that is actually
2446
- * reachable, and the pane's `handleMouse` is proof it arrives: the wheel
2447
- * already scrolls the transcript through it.
2448
- *
2449
- * On every platform but Windows the alt-screen routes the click into the
2450
- * layout, where nothing claimed it, so mouse tracking had already stopped the
2451
- * terminal from pasting and the click did nothing at all. On Windows the
2452
- * terminal pastes on its own and the SDK hands the click to it, so handling it
2453
- * here as well pasted the clipboard twice — `rightClickPastesHere` owns that
2454
- * decision, and the user can overrule it from `/settings`.
2455
- */
2456
- const rightClickPastesHere = (event) => rightClickPasteEnabled(readWorkbenchSettings(AGENT_DIR).rightClickPaste) &&
2457
- event.type === "press" &&
2458
- event.button === "right";
2459
- tui.addInputListener((data) => {
2460
- // The selector is open: it owns every key, and this listener sees them before
2461
- // the viewport does — `TuiAltScreen` binds arrow keys to scrolling in a
2462
- // listener it registers in its own constructor, so without this the tree
2463
- // selector never receives `up`/`down` and the selection cannot move.
2464
- if (treeSelector) {
2465
- if (matchesKey(data, "escape")) {
2466
- closeRewind();
2467
- return { consume: true };
2468
- }
2469
- // No wrap-around. `TreeList` turns a vertical move at either end into a
2470
- // jump to the opposite end, so one `↑` past the top seized the newest
2471
- // entry and one `↓` past the bottom seized `[system]`.
2472
- //
2473
- // The move is refused *before* it is delivered. An earlier version let the
2474
- // key through and then replayed the opposite key to undo the wrap, which
2475
- // does not return to where you were: from the wrapped position the opposite
2476
- // key steps to the next entry, so after a wrap the selection was stranded
2477
- // and onward presses looked stuck. It also measured adjacency against
2478
- // `getTree()`, which still contains the rows the selector hides
2479
- // (`session_info`, usage), so the index was offset and an ordinary one-step
2480
- // `↑` was misread as a wrap.
2481
- const kb = getKeybindings();
2482
- const isUp = kb.matches(data, "tui.select.up");
2483
- const isDown = kb.matches(data, "tui.select.down");
2484
- if (isUp || isDown) {
2485
- const list = treeSelector.getTreeList();
2486
- const before = list.getSelectedNode()?.entry.id;
2487
- const shown = shownIds();
2488
- const at = before === undefined ? -1 : shown.indexOf(before);
2489
- const blocked = isUp ? at === 0 : at === shown.length - 1;
2490
- if (blocked) {
2491
- refresh();
2492
- return { consume: true };
2493
- }
2494
- }
2495
- treeSelector.handleInput(data);
2496
- refresh();
2497
- return { consume: true };
2498
- }
2499
- // A pinned workflow panel gets Esc before the rewind gesture below, which
2500
- // would otherwise swallow it (`return undefined` without consuming) so the
2501
- // panel's own Esc — release the keys, collapse the drill-in, unpin — never
2502
- // ran. The selector above still takes precedence, because when it is open it
2503
- // owns every key.
2504
- if (workflowPinned && currentRun && matchesKey(data, "escape")) {
2505
- if (workflowKeysActive) {
2506
- workflowKeysActive = false;
2507
- note("workflow", "panel keys off; the editor has the keyboard");
2508
- }
2509
- else if (workflowView.expanded) {
2510
- workflowView.expanded = false;
2511
- note("workflow", "collapsed the drill-in");
2512
- }
2513
- else {
2514
- unpinWorkflow();
2515
- note("workflow", "panel unpinned; the run is still in the transcript");
2516
- }
2517
- drawWorkflow();
2518
- refresh();
2519
- return { consume: true };
2520
- }
2521
- // A picker takes the keyboard before the escape gestures below: inside a
2522
- // dialog `Escape` means "dismiss this question", never "abort the run", and
2523
- // the SDK's own interrupt handling is superseded while one is open.
2524
- if (picker) {
2525
- picker.handleInput(data);
2526
- refresh();
2527
- return { consume: true };
2528
- }
2529
- if (matchesKey(data, "escape")) {
2530
- // Streaming: `Escape` cancels the run. This is `app.interrupt` in the SDK's
2531
- // own keybindings ("Cancel or abort"), and it is what a user reaches for
2532
- // when a turn runs long — `Ctrl+C` did the same job, but nobody looks for
2533
- // it when the screen is busy.
2534
- if (session.isStreaming) {
2535
- userAborted = true;
2536
- // A message that was queued but never read is the user's work, and
2537
- // cancelling must not swallow it: it goes back into the editor, the same
2538
- // place Claude Code puts it, so the next prompt is not a retyping.
2539
- const unread = [...session.getSteeringMessages()].map((message) => message.trim()).filter(Boolean);
2540
- if (unread.length > 0) {
2541
- const draft = editor.getText().trim();
2542
- editor.setText(draft ? `${unread.join("\n")}\n${draft}` : unread.join("\n"));
2543
- const dropped = new Set(unread);
2544
- for (const item of feed.items) {
2545
- if (item.kind === "user" && item.pending && dropped.has(item.body.trim()))
2546
- item.pending = undefined;
2547
- }
2548
- note("queued", `${unread.length} unread message${unread.length === 1 ? "" : "s"} returned to the editor`);
2549
- }
2550
- void session.abort();
2551
- activity("note", "stopping");
2552
- refresh();
2553
- return { consume: true };
2554
- }
2555
- const at = Date.now();
2556
- if (editor.getText().length === 0 && at - lastEscapeAt < 600) {
2557
- lastEscapeAt = 0;
2558
- openRewind();
2559
- return { consume: true };
2560
- }
2561
- lastEscapeAt = at;
2562
- return undefined;
2563
- }
2564
- if (matchesKey(data, "ctrl+c") || isInterrupt(data)) {
2565
- if (session.isStreaming) {
2566
- void session.abort();
2567
- activity("note", "stopping");
2568
- refresh();
2569
- }
2570
- else if (editor.getText().length > 0) {
2571
- // A draft in the box is work the user can lose by accident: Ctrl+C used
2572
- // to quit outright and take it with it. First press clears the draft and
2573
- // says so, the second quits — the same shape Claude Code uses, and the
2574
- // same one the Esc-Esc rewind already relies on.
2575
- editor.setText("");
2576
- activity("note", "draft cleared; ctrl+c again quits");
2577
- refresh();
2578
- }
2579
- else {
2580
- // Idle with an empty box: the first press only warns, so a stray
2581
- // Ctrl+C (or a terminal where Ctrl+C is "copy") cannot end the session.
2582
- const now = Date.now();
2583
- if (now - lastCtrlCAt < 2000) {
2584
- void shutdown();
2585
- }
2586
- else {
2587
- lastCtrlCAt = now;
2588
- activity("note", "press ctrl+c again to quit");
2589
- refresh();
2590
- }
2591
- }
2592
- return { consume: true };
2593
- }
2594
- if (matchesKey(data, "ctrl+d") && editor.getText().length === 0) {
2595
- void shutdown();
2596
- return { consume: true };
2597
- }
2598
- // Keyboard scrolling for the transcript.
2599
- //
2600
- // The wheel is the pane's (`handleMouse`), because the viewport consumes
2601
- // wheel events before the app sees them. The page keys are not consumed
2602
- // anywhere: the viewport's own handler scrolls the *frame*, and the frame is
2603
- // exactly one viewport tall here, so it has nothing to scroll and the keys
2604
- // did nothing at all. The transcript's own offset is the thing to move.
2605
- const page = Math.max(1, feed.viewportHeight - 1);
2606
- if (matchesKey(data, "pageUp")) {
2607
- feed.scrollOffset += page;
2608
- refresh();
2609
- return { consume: true };
2610
- }
2611
- if (matchesKey(data, "pageDown")) {
2612
- feed.scrollOffset = Math.max(0, feed.scrollOffset - page);
2613
- refresh();
2614
- return { consume: true };
2615
- }
2616
- // Home/End are rebound from the viewport too (`claimTranscriptScrollKeys`), so
2617
- // they reach here. The offset is clamped inside `render`, so asking for the
2618
- // whole transcript is safe.
2619
- if (matchesKey(data, "home")) {
2620
- feed.scrollOffset = Number.MAX_SAFE_INTEGER;
2621
- refresh();
2622
- return { consume: true };
2623
- }
2624
- if (matchesKey(data, "end")) {
2625
- feed.scrollOffset = 0;
2626
- refresh();
2627
- return { consume: true };
2628
- }
2629
- // Paste. The SDK's mouse paste is Windows-only, and with mouse tracking on
2630
- // the terminal no longer handles a right click itself, so on Linux there was
2631
- // no way to paste at all. `getNativeClipboard` shells out to the platform's
2632
- // clipboard tool, so this works wherever one is installed.
2633
- if (matchesKey(data, "ctrl+v")) {
2634
- pasteFromClipboard();
2635
- return { consume: true };
2636
- }
2637
- if (matchesKey(data, "ctrl+l")) {
2638
- // Jump to the latest line: the transcript's own offset, not the viewport's.
2639
- feed.scrollOffset = 0;
2640
- tui.scrollToBottom();
2641
- refresh();
2642
- return { consume: true };
2643
- }
2644
- if (matchesKey(data, "ctrl+e")) {
2645
- // Expand the most recent result; if that one is already open, collapse it.
2646
- // A single key that toggles the thing you are looking at is the whole
2647
- // interaction — no picker, no mode.
2648
- const target = [...feed.items].reverse().find((item) => (item.full?.length ?? 0) > 0);
2649
- if (!target) {
2650
- note("expand", "nothing to expand in this transcript yet");
2651
- return { consume: true };
2652
- }
2653
- target.expanded = target.expanded !== true;
2654
- refresh();
2655
- return { consume: true };
2656
- }
2657
- if (matchesKey(data, "ctrl+o") && !session.isStreaming) {
2658
- feed.items = [{ id: "cleared", kind: "note", title: "bench", body: "screen cleared. session history is still stored." }];
2659
- refresh();
2660
- return { consume: true };
2661
- }
2662
- // Thinking level, raise and lower, each wrapping so neither end of the
2663
- // range leaves a dead key. `ctrl+shift+t` IS distinguishable from `ctrl+t`
2664
- // in this key table: hxf-tui parses the kitty CSI-u and modifyOtherKeys
2665
- // forms (`\x1b[116;6u` vs the legacy `\x14`) into different key ids, so
2666
- // both halves survive on a terminal that reports modifiers. On a terminal
2667
- // that sends only the legacy byte the shift variant collapses into
2668
- // `ctrl+t` (raise), and `/thinking` remains the path that always works.
2669
- if (matchesKey(data, "ctrl+t")) {
2670
- setThinking(nextLevel(hud.thinking));
2671
- return { consume: true };
2672
- }
2673
- if (matchesKey(data, "ctrl+shift+t")) {
2674
- setThinking(prevLevel(hud.thinking));
2675
- return { consume: true };
2676
- }
2677
- // Hand the panel the keyboard, and take it back. Nothing else gives the
2678
- // panel input: while it is pinned but passive, every letter and Enter reach
2679
- // the editor, which is the only way the user can type or send a message
2680
- // while a panel is on screen.
2681
- if (matchesKey(data, WORKFLOW_KEY_TOGGLE) && workflowPinned && currentRun) {
2682
- workflowKeysActive = !workflowKeysActive;
2683
- note("workflow", workflowKeysActive ? "panel keys active; Esc gives the keyboard back" : "panel keys off; the editor has the keyboard");
2684
- drawWorkflow();
2685
- refresh();
2686
- return { consume: true };
2687
- }
2688
- if (workflowKeysActive && workflowPinned && currentRun) {
2689
- const action = workflowAction(data);
2690
- if (action) {
2691
- const groups = groupPhases(currentRun, Date.now());
2692
- if (action === "stop") {
2693
- if (stopWorkflow) {
2694
- stopWorkflow();
2695
- stopWorkflow = null;
2696
- note("workflow", "stopping the run; in-flight agents resolve as null");
2697
- }
2698
- else {
2699
- note("workflow", "the run has already finished");
2700
- }
2701
- return { consume: true };
2702
- }
2703
- if (action === "save") {
2704
- // The footer advertises this key, so it has to do the thing it says.
2705
- // What is saved is the script the model wrote, named exactly as its
2706
- // own `meta.name`, because the tool refuses to run a script whose
2707
- // `meta.name` does not match its filename.
2708
- const saved = saveScript(savedScript, WORKFLOWS_DIR);
2709
- note("workflow", saved.ok ? `saved ${saved.path}` : saved.reason);
2710
- refresh();
2711
- return { consume: true };
2712
- }
2713
- Object.assign(workflowView, applyView(workflowView, action, groups));
2714
- drawWorkflow();
2715
- refresh();
2716
- return { consume: true };
2717
- }
2718
- }
2719
- return undefined;
2720
- });
2721
- const loginHere = (requested) => {
2722
- const askKey = (provider) => {
2723
- activity("prompt", `API key for ${provider}. Paste it, then press enter.`);
2724
- refresh();
2725
- const previous = editorSubmit;
2726
- editorSubmit = (value) => {
2727
- editorSubmit = previous;
2728
- const key = value.trim();
2729
- if (!key) {
2730
- activity("prompt", undefined);
2731
- refresh();
2732
- return;
2733
- }
2734
- // `setRuntimeApiKey` is an in-memory override: the SDK calls it
2735
- // "non-persistent" — nothing writes auth.json, and the next start sees
2736
- // no key at all (the same silent loss `/connect` documents below). So
2737
- // the override first, to keep the key usable for the rest of *this*
2738
- // session, then `login(provider, "api_key", ...)` — the SDK's supported
2739
- // persist path. If persistence fails, the note says so instead of
2740
- // claiming a save that did not happen.
2741
- void session.modelRuntime.setRuntimeApiKey(provider, key).then(() => session.modelRuntime.login(provider, "api_key", { prompt: async () => key, notify: () => undefined }).then(() => note("login", `saved API key for ${provider}`), (cause) => note("login", `key set for this session, but it could not be saved (${cause instanceof Error ? cause.message : String(cause)}); run /login again after restarting`)), (cause) => note("login", cause instanceof Error ? cause.message : String(cause)));
2742
- };
2743
- };
2744
- if (requested) {
2745
- askKey(requested);
2746
- return;
2747
- }
2748
- // Same source as `/providers`: an empty picker here told the user there was
2749
- // nothing to log into, which was wrong.
2750
- void session.modelRuntime.getAvailable().then((available) => {
2751
- const providers = [...new Set(available.map((model) => model.provider))].sort();
2752
- if (providers.length === 0) {
2753
- note("login", "no providers are configured; pass one explicitly, e.g. /login nrimo");
2754
- return;
2755
- }
2756
- choose("Login", providers.map((provider) => ({ value: provider, label: provider })), askKey);
2757
- }, (cause) => note("login", cause instanceof Error ? cause.message : String(cause)));
2758
- };
2759
- const syncModelsFor = async (provider, ids) => {
2760
- const path = join(AGENT_DIR, "models.json");
2761
- let parsed;
2762
- let createdFile = false;
2763
- try {
2764
- parsed = JSON.parse(readFileSync(path, "utf8"));
2765
- }
2766
- catch (cause) {
2767
- // A missing file is the normal first-run state, not a failure. Without this
2768
- // branch `/connect` on a fresh install verified the token, said "connected",
2769
- // and stored nothing — the first thing a new user does left them with no
2770
- // models at all. Any other read error is still reported, because overwriting
2771
- // a file we cannot parse would throw the user's own config away.
2772
- const missing = cause instanceof Error && "code" in cause && cause.code === "ENOENT";
2773
- if (!missing)
2774
- return `could not read ${path}; the models were not added`;
2775
- parsed = { providers: {} };
2776
- createdFile = true;
2777
- }
2778
- if (!parsed.providers || typeof parsed.providers !== "object")
2779
- parsed.providers = {};
2780
- let entry = parsed.providers[provider];
2781
- if (!entry) {
2782
- // Same story for the entry: a file that exists but has no howtofix provider
2783
- // is a state a new user can be in, and refusing to write there leaves them
2784
- // stuck with no way forward from the CLI.
2785
- entry = newProviderEntry();
2786
- parsed.providers[provider] = entry;
2787
- createdFile = true;
2788
- }
2789
- const existing = Array.isArray(entry.models) ? entry.models : [];
2790
- const added = countNewModels(existing, ids);
2791
- // Read the catalogue before merging so a model added here is written with its
2792
- // real window and reasoning flag in the same pass — otherwise `/connect`
2793
- // would store the 128k guess and only the next start would correct it, which
2794
- // is a wrong number the user sees first.
2795
- const catalog = await loadCatalog(AGENT_DIR).catch(() => ({ index: new Map() }));
2796
- const repaired = applyCompat(mergeModels(existing, ids, catalog.index));
2797
- entry.models = repaired.models;
2798
- try {
2799
- mkdirSync(AGENT_DIR, { recursive: true });
2800
- writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
2801
- }
2802
- catch {
2803
- return `could not write ${path}; the models were not added`;
2804
- }
2805
- let reloaded = false;
2806
- try {
2807
- await session.modelRuntime.refresh();
2808
- reloaded = true;
2809
- }
2810
- catch {
2811
- reloaded = false;
2812
- }
2813
- // Saying only "added N" would hide the repair: a user whose existing models
2814
- // were all broken by the developer-role bug needs to know they work now.
2815
- const fixed = repaired.changed > 0 ? `, repaired ${repaired.changed}` : "";
2816
- const restart = reloaded ? "" : "; restart the CLI to see them all";
2817
- const where = createdFile ? ` (created ${path})` : "";
2818
- return added === 0
2819
- ? `all ${ids.length} models were already configured${fixed}${where}${restart}`
2820
- : `added ${added} model(s) to "${provider}"${fixed}${where}${restart}`;
2821
- };
2822
- /**
2823
- * Connect a howtofix.id account: verify the token, store it, pull the model
2824
- * list, and pick a model — in that order, because storing an unverified token
2825
- * is how a user ends up with a session that fails on the first prompt.
2826
- *
2827
- * The order also matters for the paid providers: `howtofix` is the paid
2828
- * account and `howtofix-free` is the free tier. A token is verified against
2829
- * both, and the first one that accepts it is where the models land.
2830
- */
2831
- const connectHere = (token, credential) => {
2832
- void (async () => {
2833
- if (!token) {
2834
- // A browser login first: it is one click, and it is what people expect
2835
- // from a coding CLI. The token path stays behind it for a panel that
2836
- // cannot complete a login (see `oauth-login.ts`), and for a user who
2837
- // already has a key.
2838
- const result = await loginWithBrowser({
2839
- onProgress: (progress) => {
2840
- // The URL and the one-time code are the only things the user has to
2841
- // move out of this screen, and a terminal that does not copy on
2842
- // selection makes that impossible. Write them somewhere a shell can
2843
- // read instead: `cat ~/.howtofix/connect-url.txt`.
2844
- let savedPath = "";
2845
- try {
2846
- const dir = join(AGENT_DIR, ".howtofix");
2847
- mkdirSync(dir, { recursive: true, mode: 0o700 });
2848
- const target = join(dir, "connect-url.txt");
2849
- const lines = progress.kind === "browser"
2850
- ? [progress.url]
2851
- : [progress.url, `code: ${progress.code}`];
2852
- writeFileSync(target, lines.join("\n") + "\n", { mode: 0o600 });
2853
- savedPath = target;
2854
- }
2855
- catch {
2856
- savedPath = "";
2857
- }
2858
- const saved = savedPath ? [` saved to ${savedPath}`] : [];
2859
- if (progress.kind === "browser") {
2860
- void openInBrowser(progress.url);
2861
- note("connect", [
2862
- "finish signing in to howtofix.id:",
2863
- " a browser window is opening — approve the sign-in there",
2864
- ` if it did not open, visit ${progress.url}`,
2865
- ...saved,
2866
- ].join("\n"));
2867
- }
2868
- else {
2869
- void openInBrowser(progress.url);
2870
- note("connect", [
2871
- "finish signing in to howtofix.id:",
2872
- ` 1. go to ${progress.url}`,
2873
- ` 2. enter the code: ${progress.code}`,
2874
- " waiting…",
2875
- ...saved,
2876
- ].join("\n"));
2877
- }
2878
- refresh();
2879
- },
2880
- });
2881
- if (result.ok) {
2882
- // Everything after this point is the token path's job, so the browser
2883
- // flow ends by handing it the key rather than duplicating the work.
2884
- // The paste prompt armed by `/connect` with no argument is disarmed
2885
- // first: the user has already signed in, and leaving "paste your token"
2886
- // on screen would ask them for something they no longer need.
2887
- activity("prompt", undefined);
2888
- editorSubmit = undefined;
2889
- connectHere(result.token, result.credential);
2890
- return;
2891
- }
2892
- // Say why, then fall back to the token: a failed login must leave the
2893
- // user with a way in, not just an error.
2894
- const opened = await openInBrowser(HOWTOFIX_CONSOLE_URL);
2895
- note("connect", [
2896
- `browser sign-in: ${result.message}`,
2897
- "",
2898
- "connect with a token instead:",
2899
- ` 1. ${opened ? "the keys page is opening in your browser" : `open ${HOWTOFIX_CONSOLE_URL}`}`,
2900
- " 2. sign in if asked, then create a key (starts with sk-)",
2901
- " 3. run /connect <token>",
2902
- " or /connect again, and paste the key when asked",
2903
- ].join("\n"));
2904
- refresh();
2905
- return;
2906
- }
2907
- if (!looksLikeToken(token)) {
2908
- note("connect", "that does not look like a howtofix token; the Console issues tokens that start with sk-");
2909
- return;
2910
- }
2911
- activity("note", `connecting to howtofix.id (${maskToken(token)})`);
2912
- refresh();
2913
- const check = await checkAccount(token);
2914
- if (!check.ok) {
2915
- activity("note", undefined);
2916
- note("connect", check.message);
2917
- refresh();
2918
- return;
2919
- }
2920
- // Order matters here, and getting it wrong is silent.
2921
- //
2922
- // The provider is not registered with the runtime until `models.json` names
2923
- // it and the runtime reloads, and `login()` refuses an unregistered
2924
- // provider with "Unknown provider: howtofix". So the model list is written
2925
- // *first* (syncModelsFor refreshes the runtime as part of its job), and the
2926
- // credential is persisted after — otherwise the sign-in is lost and the
2927
- // only clue is a note saying it could not be saved.
2928
- const stored = await syncModelsFor("howtofix", check.models);
2929
- await session.modelRuntime.setRuntimeApiKey("howtofix", token);
2930
- // `setRuntimeApiKey` is an in-memory override: the SDK calls it
2931
- // "non-persistent", nothing writes auth.json, and the next start reports
2932
- // the provider unconfigured — the first prompt then dies with "Failed to
2933
- // resolve API key for provider \"howtofix\" from environment variable:
2934
- // HOWTOFIX_VIP_API_KEY", advice for a variable a signed-in user never had.
2935
- // Measured: the key was set, listCredentials showed it, and a fresh runtime
2936
- // saw nothing.
2937
- //
2938
- // `login(provider, "api_key", ...)` is the SDK's supported persist path — it
2939
- // runs the provider's own key login and stores the credential. The prompt is
2940
- // answered with the key we already verified, so the user sees nothing. The
2941
- // override above still matters: it makes the key usable for the rest of
2942
- // *this* session, while login() is what keeps it across sessions.
2943
- let persistenceNote;
2944
- try {
2945
- await session.modelRuntime.login("howtofix", "api_key", {
2946
- prompt: async () => token,
2947
- notify: () => undefined,
2948
- });
2949
- }
2950
- catch (cause) {
2951
- // Falling back is honest, not silent: the session works either way, and
2952
- // the note tells the user the sign-in will not survive a restart so they
2953
- // can act on it.
2954
- persistenceNote = `this sign-in could not be saved (${cause instanceof Error ? cause.message : String(cause)}); run /connect again after restarting`;
2955
- }
2956
- // A sign-in that came with a refresh token is a device session: the access
2957
- // token above expires in hours, and this pair is what renews it without
2958
- // sending the user back to the browser. It is written after the key is
2959
- // persisted, so a failure to store the credential pair cannot leave a
2960
- // session that works now but has nothing to renew with.
2961
- if (credential) {
2962
- if (writeCliCredential(AGENT_DIR, credential)) {
2963
- cliCredential = credential;
2964
- }
2965
- else {
2966
- persistenceNote = `this sign-in renews itself, but the pair could not be saved to ${cliCredentialsPath(AGENT_DIR)}; it will expire in hours and need /connect again`;
2967
- }
2968
- }
2969
- // Finish the job: a verified token with no model selected leaves the
2970
- // workbench on `[unknown]`, so the first prompt the user sends goes nowhere.
2971
- // Pick the best model the account can reach and say which one, unless the
2972
- // user already has a working model — replacing a chosen model would be rude.
2973
- const alreadyUsable = Boolean(session.model) && session.model?.provider !== undefined && hud.model !== "unknown";
2974
- let picked;
2975
- let pickFailure;
2976
- if (!alreadyUsable) {
2977
- try {
2978
- const available = (await session.modelRuntime.getAvailable()).filter((model) => isHowtofixProvider(model.provider));
2979
- // "Listed" is not "working": try the preferred families in order and
2980
- // take the first that actually answers. Without this the first prompt a
2981
- // new user sends hits a dead channel and the install looks broken.
2982
- const ordered = orderModelsForDefault(available.map((model) => model.id));
2983
- const attempts = ordered.slice(0, 4);
2984
- for (const id of attempts) {
2985
- const probe = await probeModel(id, token);
2986
- if (!probe.ok)
2987
- continue;
2988
- const chosen = available.find((model) => model.id === id);
2989
- if (!chosen)
2990
- continue;
2991
- await session.setModel(chosen);
2992
- hud.model = chosen.id;
2993
- syncCeiling();
2994
- picked = `${chosen.provider}/${chosen.id}`;
2995
- break;
2996
- }
2997
- if (!picked) {
2998
- pickFailure =
2999
- attempts.length > 0
3000
- ? `none of the first ${attempts.length} models answered (${attempts[0]} did not)`
3001
- : "the account lists no chat model";
3002
- }
3003
- }
3004
- catch (cause) {
3005
- // A model that will not load is not a reason to fail the connection —
3006
- // the key and the catalog are stored — but the reason is reported
3007
- // rather than swallowed, because a silent failure here is what left the
3008
- // workbench on `[unknown]` with no explanation.
3009
- pickFailure = cause instanceof Error ? cause.message : String(cause);
3010
- }
3011
- }
3012
- activity("note", undefined);
3013
- // Say what the account has left while we are here: it is one more request
3014
- // against a key we just verified, and "connected" without a balance leaves
3015
- // the user to discover an empty quota from a failed prompt.
3016
- const usage = await checkUsage(token);
3017
- note("connect", [
3018
- `connected ${maskToken(token)} — ${check.message}`,
3019
- stored,
3020
- formatUsage("howtofix", usage),
3021
- picked
3022
- ? `using ${picked} (change it with /model)`
3023
- : `pick a model with /model, then send a prompt${pickFailure ? ` (auto-select failed: ${pickFailure})` : ""}`,
3024
- persistenceNote,
3025
- ]
3026
- .filter((line) => Boolean(line))
3027
- .join("\n"));
3028
- refresh();
3029
- })().catch((cause) => {
3030
- activity("note", undefined);
3031
- note("connect", cause instanceof Error ? cause.message : String(cause));
3032
- refresh();
3033
- });
3034
- };
3035
- /**
3036
- * Connect with the account itself instead of a key made for the occasion.
3037
- *
3038
- * The credential is `email:password`, base64-encoded, and it is stored so a
3039
- * later `/connect` needs nothing typed. The key the relay uses is the one the
3040
- * account already has: a reconnect reuses it rather than adding another row to
3041
- * the panel. Everything after the key is obtained is the ordinary connect path,
3042
- * so verification, model sync and model choice do not fork here.
3043
- */
3044
- const connectWithCredential = (raw) => {
3045
- void (async () => {
3046
- const parsed = parseCredentialInput(raw);
3047
- if (!parsed) {
3048
- note("connect", [
3049
- "that is not an account credential.",
3050
- " /connect <email>:<password>",
3051
- " /connect <base64 of email:password>",
3052
- ].join("\n"));
3053
- refresh();
3054
- return;
3055
- }
3056
- activity("note", `signing in as ${parsed.username}`);
3057
- refresh();
3058
- const { token } = await loginWithCredential(PANEL_URL, parsed.username, parsed.password, APP_VERSION);
3059
- const saved = saveAccountCredential(AGENT_DIR, parsed.username, parsed.password);
3060
- activity("note", undefined);
3061
- note("connect", [
3062
- `signed in as ${parsed.username}`,
3063
- "using the CLI key this account owns — no new key was made",
3064
- saved
3065
- ? "credential stored — /connect will not ask again"
3066
- : "could not store the credential, so the next connect will ask again",
3067
- ].join("\n"));
3068
- refresh();
3069
- connectHere(token);
3070
- })().catch((cause) => {
3071
- activity("note", undefined);
3072
- note("connect", cause instanceof Error ? cause.message : String(cause));
3073
- refresh();
3074
- });
3075
- };
3076
- /**
3077
- * Render a usage check the way a reader scans it: a label column, then the
3078
- * figures. Prose ("15.00 USD left of 20.00") buries the number that matters.
3079
- */
3080
- const formatUsage = (provider, usage) => {
3081
- if (!usage.ok)
3082
- return [`${provider} account`, ` ${usage.message}`].join("\n");
3083
- if (usage.unlimited)
3084
- return [`${provider} account`, " quota no limit on this token"].join("\n");
3085
- // The bar needs all three figures; a gateway that answers with a subset
3086
- // still gets a readable panel rather than a row of blanks.
3087
- if (usage.limit === undefined || usage.used === undefined) {
3088
- const rows = [`${provider} account`];
3089
- const money = (value) => `${value.toFixed(2)} ${usage.unit}`;
3090
- if (usage.limit !== undefined)
3091
- rows.push(` granted ${money(usage.limit)}`);
3092
- if (usage.used !== undefined)
3093
- rows.push(` spent ${money(usage.used)}`);
3094
- if (usage.remaining !== undefined)
3095
- rows.push(` remaining ${money(usage.remaining)}`);
3096
- return rows.join("\n");
3097
- }
3098
- return quotaLines(provider, usage.limit, usage.used, usage.unit).join("\n");
3099
- };
3100
- /**
3101
- * `/preset` — the developer-provided preset catalogue.
3102
- *
3103
- * The menu is deliberately two levels deep and nothing installs by itself. A
3104
- * preset puts skill descriptions into every request, so the user is shown what a
3105
- * preset contains, and what it costs, before anything is copied — and the
3106
- * "view" action exists because reading what will enter your prompt is the only
3107
- * real defence against a preset that asks the agent to do something harmful.
3108
- *
3109
- * Argument forms are kept as well as the menu, because a menu is the wrong tool
3110
- * for a scripted install and the wrong tool for a user who already knows the
3111
- * name:
3112
- *
3113
- * /preset menu (installed, then categories, then everything)
3114
- * /preset list what is installed
3115
- * /preset show <name> full contents, installs nothing
3116
- * /preset add <src> install from a git URL or a local directory
3117
- * /preset remove <name> uninstall
3118
- */
3119
- /**
3120
- * The servers already in `mcp.json`, for the menu to mark them and to offer
3121
- * remove instead of install.
3122
- *
3123
- * Read through a tolerant path: a config that cannot be parsed is an empty
3124
- * list here, not a crash in a picker. The install path is the one that refuses
3125
- * to write over such a file, and it says so there.
3126
- */
3127
- const readConfigForMenu = () => ({
3128
- mcpServers: readGlobalMcpServers(),
3129
- });
3130
- /** The MCP servers a preset contributes, as lines for `show` and the summary. */
3131
- const mcpLines = (mcp) => {
3132
- const names = Object.keys(mcp ?? {});
3133
- if (names.length === 0)
3134
- return [];
3135
- return names.map((name) => ` mcp ${name} ${describeMcpServer(mcp[name])}`);
3136
- };
3137
- const presetSummary = (entry) => {
3138
- const size = presetSize(entry.dir, entry.manifest);
3139
- const skills = entry.manifest.skills.join(", ");
3140
- return [
3141
- entry.manifest.description,
3142
- ` category ${entry.manifest.category}`,
3143
- ` version ${entry.manifest.version}`,
3144
- ` skills ${skills}`,
3145
- entry.manifest.prompt ? ` prompt ${entry.manifest.prompt}` : " prompt (none)",
3146
- ` size ${size} bytes of skill text`,
3147
- ` source ${entry.source}`,
3148
- ...mcpLines(entry.manifest.mcp),
3149
- ].join("\n");
3150
- };
3151
- const presetContents = (entry) => {
3152
- const parts = [presetSummary(entry), ""];
3153
- if (entry.manifest.prompt) {
3154
- const file = join(entry.dir, entry.manifest.prompt);
3155
- if (existsSync(file))
3156
- parts.push(`--- ${entry.manifest.prompt} ---`, readFileSync(file, "utf8").trim(), "");
3157
- }
3158
- for (const skill of entry.manifest.skills) {
3159
- const file = join(entry.dir, "skills", skill, "SKILL.md");
3160
- if (existsSync(file))
3161
- parts.push(`--- skills/${skill}/SKILL.md ---`, readFileSync(file, "utf8").trim(), "");
3162
- }
3163
- return parts.join("\n");
3164
- };
3165
- const reloadPresetResources = async () => {
3166
- // `session.reload()` re-emits `resources_discover` and `session_start`, which is
3167
- // what makes the MCP extension rebuild its server set from the file just written.
3168
- // A removal only takes effect through that rebuild.
3169
- //
3170
- // The failure is propagated, not swallowed. An earlier version caught it and
3171
- // returned, so a reload that never ran still reported the server as stopped:
3172
- // the message and the session disagreed and the process kept running. A caller
3173
- // that cannot apply a change must be able to say so.
3174
- await session.reload();
3175
- };
3176
- /**
3177
- * Serialises installs. A picker row can be entered twice before the first
3178
- * install finishes — a fast double Enter, or an impatient second press — and two
3179
- * copies racing over one directory is what produced `ENOENT: lstat` and killed
3180
- * the session before this existed. The second press is told what is happening
3181
- * rather than silently ignored.
3182
- */
3183
- let presetBusy = false;
3184
- /** One install path for both a local directory and a registry entry. */
3185
- const runInstall = async (run) => {
3186
- if (presetBusy) {
3187
- note("preset", "an install is already running; wait for it to finish");
3188
- return;
3189
- }
3190
- presetBusy = true;
3191
- try {
3192
- // Installing touches the filesystem — a download, a copy, a replace — and
3193
- // this runs from a `void` call inside a picker handler. A throw here would
3194
- // be an unhandled rejection that takes the whole session down, so every
3195
- // failure is reported instead.
3196
- let result;
3197
- try {
3198
- result = await run();
3199
- }
3200
- catch (err) {
3201
- error(`preset not installed: ${err instanceof Error ? err.message : String(err)}`);
3202
- return;
3203
- }
3204
- if (!result.ok || !result.name) {
3205
- error(`preset not installed:\n ${result.problems.join("\n ")}`);
3206
- return;
3207
- }
3208
- await reloadPresetResources();
3209
- // A preset can bring servers as well as skills. Say which, because a
3210
- // server that starts on the next session is a visible change to the
3211
- // machine and the user should hear it from the install, not from /mcp.
3212
- const installedEntry = listCatalog(options.cwd).entries.find((entry) => entry.manifest.name === result.name);
3213
- const integration = installedEntry?.manifest.integrates || installedEntry?.manifest.mcp;
3214
- const mcpLine = integration
3215
- ? `\n integration disabled by default; run /preset enable ${result.name} when you want to use it`
3216
- : await registerPresetMcpServers(result.name);
3217
- if (integration && installedEntry)
3218
- writeFileSync(join(installedEntry.dir, ".disabled"), "installed\n");
3219
- // Two different outcomes wear the same "installed" otherwise. A skill the
3220
- // model cannot see still installs correctly, and the user would reasonably
3221
- // expect the model to reach for it afterwards.
3222
- const how = result.modelInvocable === false
3223
- ? `its skill ${result.skill} is invoke-only (disable-model-invocation): the model cannot choose it, run /skill:${result.skill} yourself`
3224
- : "its skill is in the model's catalogue from the next message";
3225
- note("preset", `installed ${result.name} into ${result.target}\n${how}${mcpLine}`);
3226
- }
3227
- finally {
3228
- presetBusy = false;
3229
- }
3230
- };
3231
- /**
3232
- * The MCP lifecycle for the current session.
3233
- *
3234
- * Editing `mcp.json` and making the running session match it are different
3235
- * things, and only the first used to happen: removing a server left its
3236
- * transport, its process, and its tools live until the CLI exited. This is the
3237
- * one seam every MCP change goes through, so no path can bypass the teardown.
3238
- * Rebuilt on `swap` alongside the session it belongs to.
3239
- */
3240
- // Seeded from the file the session actually started with. An empty initial
3241
- // set made the first reconcile a no-op — `desired` and `active` were both
3242
- // empty, so `apply` never ran and a removal was never applied — which is the
3243
- // whole failure this seam exists to prevent.
3244
- let mcpLifecycle = new McpLifecycle({
3245
- initial: Object.keys(readConfigForMenu().mcpServers),
3246
- apply: async (added, removed) => {
3247
- // A reload re-emits `resources_discover`, which is what rebuilds the MCP
3248
- // extension's server set from the file just written. Removal only takes
3249
- // effect through that rebuild, so it runs for both directions.
3250
- await reloadPresetResources();
3251
- if (added.length > 0 || removed.length > 0) {
3252
- const parts = [];
3253
- if (added.length > 0)
3254
- parts.push(`connected ${added.join(", ")}`);
3255
- if (removed.length > 0)
3256
- parts.push(`stopped ${removed.join(", ")}`);
3257
- note(added.length > 0 ? "mcp connected" : "mcp stopped", parts.join("; "));
3258
- }
3259
- },
3260
- report: (message) => error(message),
3261
- });
3262
- /**
3263
- * Bring the live session in line with `mcp.json`.
3264
- *
3265
- * Single-flight and coalescing live in the seam; this only reads the file and
3266
- * hands over the desired set, so two quick presses cannot interleave a write
3267
- * with a reload.
3268
- */
3269
- const reconcileMcp = async () => {
3270
- const desired = Object.keys(readConfigForMenu().mcpServers);
3271
- return await mcpLifecycle.reconcile(desired);
3272
- };
3273
- /**
3274
- * Merge a freshly installed preset's MCP servers into `mcp.json`.
3275
- *
3276
- * Returns the line to append to the install message. An existing server of the
3277
- * same name is never replaced — it may be one the user configured by hand, and
3278
- * silently repointing it is the kind of change nobody notices until it breaks.
3279
- */
3280
- const registerPresetMcpServers = async (name) => {
3281
- const { entries } = listCatalog(options.cwd);
3282
- const entry = entries.find((candidate) => candidate.manifest.name === name);
3283
- const servers = entry?.manifest.mcp;
3284
- if (!servers || Object.keys(servers).length === 0)
3285
- return "";
3286
- const result = addMcpServers(servers);
3287
- if (result.problems.length > 0)
3288
- return `\n MCP: ${result.problems.join("; ")}`;
3289
- const lines = [];
3290
- if (result.added.length > 0) {
3291
- // The server is registered before the session is told about it, so the
3292
- // wording matches the order the work happens in: the config is written,
3293
- // then reconciliation connects it. Saying "next session" was wrong — the
3294
- // reload that follows brings it up in this one.
3295
- const outcome = await reconcileMcp();
3296
- const connected = result.added.filter((server) => outcome.added.includes(server));
3297
- lines.push(connected.length > 0
3298
- ? `MCP servers connected: ${connected.join(", ")}`
3299
- : `MCP servers added to mcp.json: ${result.added.join(", ")}`);
3300
- }
3301
- if (result.skipped.length > 0) {
3302
- lines.push(`MCP servers kept as they were (already configured): ${result.skipped.join(", ")}`);
3303
- }
3304
- return lines.length > 0 ? `\n ${lines.join("\n ")}` : "";
3305
- };
3306
- const installPreset = (source) => runInstall(async () => installFrom(source, defaultInstallRoot(options.cwd)));
3307
- /**
3308
- * Install a registry entry.
3309
- *
3310
- * The download is announced before it starts: the archive can be tens of
3311
- * megabytes, and a picker that appears to hang is worse than one that says what
3312
- * it is doing.
3313
- */
3314
- const installHubPreset = (entry) => {
3315
- const mb = entry.sizeKb >= 1024 ? `${(entry.sizeKb / 1024).toFixed(1)} MB` : `${entry.sizeKb} KB`;
3316
- // Say what is about to happen and what will arrive. A download that can take
3317
- // seconds to minutes and shows one line reads as a hang; naming the source,
3318
- // the pinned commit and the size is what makes the wait legible.
3319
- note("preset", [
3320
- `downloading ${entry.title}`,
3321
- ` from ${entry.repo} @ ${entry.sha.slice(0, 7)}`,
3322
- ` skill ${entry.skill}${entry.modelInvocable === false ? " (invoke-only: the model cannot choose it)" : ""}`,
3323
- ` size ~${mb} · ${entry.license}`,
3324
- ` checking the pinned commit and the contents the registry recorded…`,
3325
- ].join("\n"));
3326
- return runInstall(() => installFromHub(entry, defaultInstallRoot(options.cwd)));
3327
- };
3328
- /** Rows for one preset: what it does, where it came from, what it costs. */
3329
- const hubPick = (entries, title) => {
3330
- choose(title, entries.map((entry) => ({
3331
- value: entry.name,
3332
- label: entry.title,
3333
- description: entry.description,
3334
- })), (value) => {
3335
- const entry = entries.find((candidate) => candidate.name === value);
3336
- if (!entry)
3337
- return;
3338
- const size = entry.sizeKb >= 1024 ? `${(entry.sizeKb / 1024).toFixed(1)} MB` : `${entry.sizeKb} KB`;
3339
- choose(entry.title, [
3340
- { value: "install", label: "Install", description: `download ~${size} and add its skill to this project` },
3341
- { value: "view", label: "View contents", description: "read what it contains before downloading" },
3342
- { value: "back", label: "Back", description: "return to the menu" },
3343
- ], (action) => {
3344
- if (action === "install") {
3345
- void installHubPreset(entry);
3346
- return;
3347
- }
3348
- if (action === "view") {
3349
- // Local files only: viewing must not fetch tens of megabytes.
3350
- note(`preset ${entry.name}`, [
3351
- `${entry.description}`,
3352
- "",
3353
- `skill ${entry.skill}`,
3354
- `source ${entry.repo} @ ${entry.sha.slice(0, 7)}`,
3355
- `licence ${entry.license}`,
3356
- `size ~${size}`,
3357
- `category ${entry.category}`,
3358
- `model ${entry.modelInvocable === false ? "invoke-only — the model cannot choose it, only /skill:" + entry.skill : "the model can choose it from its catalogue"}`,
3359
- "",
3360
- "Install downloads the skill and adds it to this project.",
3361
- ].join("\n"));
3362
- return;
3363
- }
3364
- presetMenu();
3365
- });
3366
- }, () => refresh());
3367
- };
3368
- const presetPick = (entries, title) => {
3369
- choose(title, entries.map((entry) => ({
3370
- value: entry.manifest.name,
3371
- label: entry.manifest.title,
3372
- // Name the skills the preset provides. The menu is where a user finds out
3373
- // what this build ships, and a title alone ("Web UI") does not say what
3374
- // arrives; the skill name is what gets loaded and what `/skills` will list.
3375
- description: entry.manifest.skills.length > 0
3376
- ? `${entry.manifest.skills.join(", ")} — ${entry.manifest.description}`
3377
- : entry.manifest.description,
3378
- })), (value) => {
3379
- const entry = entries.find((candidate) => candidate.manifest.name === value);
3380
- if (!entry)
3381
- return;
3382
- choose(entry.manifest.title, [
3383
- { value: "install", label: "Install", description: "add its skills and rules to this project" },
3384
- { value: "view", label: "View contents", description: "read the skills and prompt first" },
3385
- { value: "back", label: "Back", description: "return to the menu" },
3386
- ], (action) => {
3387
- if (action === "install") {
3388
- void installPreset(entry.dir);
3389
- return;
3390
- }
3391
- if (action === "view") {
3392
- note(`preset ${entry.manifest.name}`, presetContents(entry));
3393
- return;
3394
- }
3395
- presetCommand("");
3396
- });
3397
- }, () => refresh());
3398
- };
3399
- /**
3400
- * Presets that install a tool rather than text.
3401
- *
3402
- * A preset can be a bundle of prompt text, or it can be a program: RoToolbox
3403
- * writes an MCP server into `mcp.json`, and the server is useless until its
3404
- * other half has been set up somewhere this CLI cannot reach (a Studio plugin,
3405
- * an editor extension). Filed under Skills — where it used to be, because a
3406
- * preset carries a skill either way — it looked like text, and the setup it
3407
- * needs never came up until the tools silently did nothing.
3408
- *
3409
- * `integrates` is how a preset declares that: the fact is in the manifest, so
3410
- * the menu is derived from it rather than from a list of names kept here.
3411
- * Removing the flag removes the category, and no second copy of the truth
3412
- * exists to drift.
3413
- */
3414
- const integrating = (entries) => entries.filter((entry) => entry.manifest.integrates !== undefined);
3415
- const integrationsMenu = () => {
3416
- const { entries } = listCatalog(options.cwd);
3417
- const hits = integrating(entries);
3418
- if (hits.length === 0) {
3419
- note("integrations", "nothing installed that drives another application. /preset lists the rest.");
3420
- return;
3421
- }
3422
- presetPick(hits, "Integrations");
3423
- };
3424
- const presetMenu = () => {
3425
- // Three kinds of thing are on offer and they behave differently. A skill is
3426
- // text that reaches the prompt; an MCP entry is a server the session
3427
- // connects to; an integration is a tool that only works once something
3428
- // outside this CLI has been set up. A single flat list hid the last two
3429
- // behind a category name, so the menu opens by asking which one — and filing
3430
- // an integration under Skills is what made installing one look like pasting
3431
- // text, when it starts a program that will not work until the other half is
3432
- // configured.
3433
- const { entries } = listCatalog(options.cwd);
3434
- const items = [
3435
- { value: "skills", label: "Skills", description: "bundles of skills and prompt text, installed into the project" },
3436
- { value: "mcp", label: "MCP", description: "Model Context Protocol servers, added to mcp.json" },
3437
- ];
3438
- const count = integrating(entries).length;
3439
- if (count > 0) {
3440
- items.push({
3441
- value: "integrations",
3442
- label: "Integrations",
3443
- description: `${count} that drive another application, and need setup there first`,
3444
- });
3445
- }
3446
- choose("Presets", items, (value) => value === "mcp" ? mcpMenu() : value === "integrations" ? integrationsMenu() : skillsMenu());
3447
- };
3448
- /** The MCP side: servers, by category, installed into `mcp.json`. */
3449
- const mcpMenu = () => {
3450
- const { entries, problems } = readMcpHub();
3451
- if (problems.length > 0)
3452
- note("mcp problems", problems.join("\n"));
3453
- if (entries.length === 0) {
3454
- note("mcp catalogue", "no servers in the catalogue. run /mcp to inspect the ones already configured.");
3455
- return;
3456
- }
3457
- const installed = new Set(Object.keys(readConfigForMenu().mcpServers));
3458
- const actions = new Map();
3459
- const items = [];
3460
- for (const category of mcpHubCategories(entries)) {
3461
- const inCategory = mcpHubIn(entries, category);
3462
- actions.set(category, () => mcpPick(inCategory, category));
3463
- const ready = inCategory.filter((entry) => installed.has(entry.name)).length;
3464
- items.push({
3465
- value: category,
3466
- label: category,
3467
- description: `${inCategory.map((entry) => entry.title).join(", ")}${ready > 0 ? ` · ${ready} configured` : ""}`,
3468
- });
3469
- }
3470
- actions.set("all-mcp", () => mcpPick(entries, "All MCP servers"));
3471
- items.push({
3472
- value: "all-mcp",
3473
- label: "All servers",
3474
- description: `${entries.length} available${installed.size > 0 ? `, ${installed.size} already configured` : ""}`,
3475
- });
3476
- choose("MCP servers", items, (value) => actions.get(value)?.());
3477
- };
3478
- /** One MCP entry: what it is, what it needs, install or remove. */
3479
- const mcpPick = (entries, title) => {
3480
- const configured = readConfigForMenu().mcpServers;
3481
- choose(title, entries.map((entry) => {
3482
- const state = configured[entry.name] !== undefined ? "configured · " : "";
3483
- return { value: entry.name, label: entry.title, description: `${state}${entry.description}` };
3484
- }), (value) => {
3485
- const entry = mcpHubFind(entries, value);
3486
- if (!entry)
3487
- return;
3488
- const isConfigured = configured[entry.name] !== undefined;
3489
- const missing = missingEnvFor(entry);
3490
- const detail = [
3491
- entry.description,
3492
- "",
3493
- `server ${entry.name}`,
3494
- `runs ${describeMcpServer(serverSpecFromEntry(entry))}`,
3495
- ...(entry.needs && entry.needs.length > 0
3496
- ? [
3497
- `needs ${entry.needs.join(", ")}`,
3498
- missing.length > 0
3499
- ? ` ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not set in this shell — the value is read at connect time, so export it before the next session`
3500
- : " all set in this shell",
3501
- ]
3502
- : []),
3503
- ...(entry.docs ? [`docs ${entry.docs}`] : []),
3504
- "",
3505
- isConfigured
3506
- ? "already in mcp.json — removing it takes the entry out; nothing else changes"
3507
- : "install writes this server into mcp.json. nothing is downloaded, and it connects on the next session",
3508
- ].join("\n");
3509
- const choices = isConfigured
3510
- ? [
3511
- { value: "remove", label: "Remove", description: "take the server out of mcp.json" },
3512
- { value: "view", label: "Details", description: "what it runs and what it needs" },
3513
- { value: "back", label: "Back", description: "return to the list" },
3514
- ]
3515
- : [
3516
- { value: "install", label: "Install", description: "add the server to mcp.json" },
3517
- { value: "view", label: "Details", description: "what it runs and what it needs" },
3518
- { value: "back", label: "Back", description: "return to the list" },
3519
- ];
3520
- choose(entry.title, choices, (action) => {
3521
- if (action === "view") {
3522
- note(`mcp ${entry.name}`, detail);
3523
- return;
3524
- }
3525
- if (action === "install") {
3526
- // A bundled entry ships a preset too — its skill and its prompt.
3527
- // Install that first, so one action gives both the server and the
3528
- // guidance for using it, and so the two cannot drift apart.
3529
- const problems = [];
3530
- if (entry.preset) {
3531
- const bundled = join(bundledPresetsDir(), "mcp", entry.preset.replace(/^mcp-/, ""));
3532
- if (existsSync(join(bundled, "preset.json"))) {
3533
- const installed = installFromDirectory(bundled, defaultInstallRoot(options.cwd));
3534
- if (!installed.ok)
3535
- problems.push(...installed.problems);
3536
- }
3537
- }
3538
- const result = addMcpServers({ [entry.name]: serverSpecFromEntry(entry) });
3539
- problems.push(...result.problems);
3540
- if (problems.length > 0) {
3541
- error(`mcp not installed:\n ${problems.join("\n ")}`);
3542
- return;
3543
- }
3544
- if (result.skipped.length > 0) {
3545
- note("mcp install", `${entry.name} is already in mcp.json; it was left as it is.`);
3546
- return;
3547
- }
3548
- const warn = missing.length > 0
3549
- ? `\n ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not set in this shell — the value is read when the server connects, so export ${missing.length === 1 ? "it" : "them"} and reload, or it will fail every call`
3550
- : "";
3551
- // A title of its own. The session's own MCP notice is titled `mcp`,
3552
- // and `upsertNote` replaces the last note that shares a title — so
3553
- // writing under `mcp` meant one of the two messages silently ate
3554
- // the other, whichever landed second.
3555
- note("mcp installed", [
3556
- `added ${entry.name} to mcp.json${entry.preset ? ` and installed its skill preset` : ""}`,
3557
- ` runs ${describeMcpServer(serverSpecFromEntry(entry))}`,
3558
- ` connecting now; /mcp shows its state and its tools${warn}`,
3559
- ].join("\n"));
3560
- // Through the seam, not a bare reload: a `void` reload racing a
3561
- // second install is what let two config writes and two session
3562
- // reloads interleave. The seam serialises them and connects the
3563
- // server once.
3564
- void reconcileMcp().catch((cause) => {
3565
- error(`mcp installed but the session was not updated: ${cause instanceof Error ? cause.message : String(cause)}`);
3566
- });
3567
- return;
3568
- }
3569
- if (action === "remove") {
3570
- const result = removeMcpServers([entry.name]);
3571
- if (result.problems.length > 0) {
3572
- error(`mcp not removed:\n ${result.problems.join("\n ")}`);
3573
- return;
3574
- }
3575
- // Editing the file is only half of a removal. Without the
3576
- // reconcile the server's process, transport, and tools stay live
3577
- // in the session, so the agent can still call a server the user
3578
- // just removed. `choose` takes a synchronous handler, so this is
3579
- // launched and every failure reported inside, never left to
3580
- // surface as an unhandled rejection.
3581
- void reconcileMcp()
3582
- .then((outcome) => {
3583
- note("mcp remove", outcome.removed.length > 0
3584
- ? `removed ${outcome.removed.join(", ")} from mcp.json and stopped ${outcome.removed.length === 1 ? "it" : "them"}`
3585
- : result.removed.length > 0
3586
- ? `removed ${entry.name} from mcp.json`
3587
- : `${entry.name} was not in mcp.json`);
3588
- })
3589
- .catch((cause) => {
3590
- error(`mcp removed from config but the session was not updated: ${cause instanceof Error ? cause.message : String(cause)}`);
3591
- });
3592
- return;
3593
- }
3594
- mcpMenu();
3595
- });
3596
- }, () => mcpMenu());
3597
- };
3598
- /** The skills side: the preset catalogue as it was, minus the server entries. */
3599
- const skillsMenu = () => {
3600
- const { entries, problems } = listCatalog(options.cwd);
3601
- const { entries: hub, problems: hubProblems } = readHub();
3602
- if (problems.length > 0)
3603
- note("preset problems", problems.join("\n"));
3604
- if (hubProblems.length > 0)
3605
- note("preset problems", hubProblems.join("\n"));
3606
- const installed = entries.filter((entry) => entry.source === "installed");
3607
- // Each row's value is what the picker filters on (`SelectList.setFilter`
3608
- // matches `value.startsWith(input)`), so it has to be the word a user would
3609
- // type. A `cat:` prefix made the category rows unfindable by typing — the
3610
- // filter matched nothing and the menu looked broken. The value doubles as the
3611
- // dispatch key through this map instead of being parsed back out.
3612
- const actions = new Map();
3613
- const items = [];
3614
- if (installed.length > 0) {
3615
- actions.set("installed", () => presetPick(installed, "Installed presets"));
3616
- items.push({ value: "installed", label: "Installed", description: `${installed.length} active` });
3617
- }
3618
- // Bundled and installed presets first: they are already on disk, so they work
3619
- // without a download.
3620
- for (const category of categoriesOf(entries)) {
3621
- const inCategory = presetsIn(entries, category);
3622
- const fromHub = hubIn(hub, category);
3623
- actions.set(category, () => fromHub.length > 0 ? presetCategoryPick(inCategory, category, fromHub) : presetPick(inCategory, category));
3624
- // Name the local presets and their skills on the row itself. This is the
3625
- // list of what development provides; a bare category name and a count make
3626
- // the user open every one to find out, which is the complaint this fixes.
3627
- const local = inCategory
3628
- .map((entry) => `${entry.manifest.title} (${entry.manifest.skills.join(", ")})`)
3629
- .join(", ");
3630
- items.push({
3631
- value: category,
3632
- label: category,
3633
- description: `${local}${fromHub.length > 0 ? ` · ${fromHub.length} more to download` : ""}`,
3634
- });
3635
- }
3636
- // Categories that exist only in the registry.
3637
- for (const category of hubCategories(hub)) {
3638
- if (entries.some((entry) => entry.manifest.category === category))
3639
- continue;
3640
- const inCategory = hubIn(hub, category);
3641
- actions.set(category, () => hubPick(inCategory, category));
3642
- items.push({ value: category, label: category, description: `${inCategory.length} to download` });
3643
- }
3644
- actions.set("all", () => presetPick(entries, "All presets"));
3645
- items.push({ value: "all", label: "All presets", description: `${entries.length} ready to install` });
3646
- if (hub.length > 0) {
3647
- // Passed the whole registry: the caller supplies the rows, this helper
3648
- // never re-filters by a category name.
3649
- actions.set("hub", () => presetCategoryPick([], "Community presets", hub));
3650
- const projects = new Set(hub.map((entry) => entry.repo)).size;
3651
- items.push({
3652
- value: "hub",
3653
- label: "Community",
3654
- description: `${hub.length} to download, from ${projects} projects`,
3655
- });
3656
- }
3657
- choose("Skill presets", items, (value) => actions.get(value)?.());
3658
- };
3659
- /**
3660
- * One category, with what is local and what has to be downloaded.
3661
- *
3662
- * `local` is the bundled/installed entries of that category and `inHub` the
3663
- * registry's — already filtered by the caller, never re-filtered here by
3664
- * category name: a "Community" list passed with a title instead of a category
3665
- * matched nothing and rendered an empty picker.
3666
- */
3667
- const presetCategoryPick = (local, category, inHub) => {
3668
- if (inHub.length === 0) {
3669
- presetPick(local, category);
3670
- return;
3671
- }
3672
- if (local.length === 0) {
3673
- hubPick(inHub, `${category} (download)`);
3674
- return;
3675
- }
3676
- choose(category, [
3677
- { value: "local", label: `Included (${local.length})`, description: "already on disk, no download" },
3678
- { value: "hub", label: `Downloadable (${inHub.length})`, description: "fetched when installed" },
3679
- ], (value) => (value === "local" ? presetPick(local, category) : hubPick(inHub, category)));
3680
- };
3681
- const presetCommand = (arg) => {
3682
- const [sub, ...rest] = arg.trim().split(/\s+/);
3683
- const value = rest.join(" ").trim();
3684
- if (!sub) {
3685
- presetMenu();
3686
- return;
3687
- }
3688
- if (sub === "list") {
3689
- const { entries } = listCatalog(options.cwd);
3690
- const installed = entries.filter((entry) => entry.source === "installed");
3691
- if (installed.length === 0) {
3692
- note("preset list", "nothing installed. run /preset to browse the catalogue.");
3693
- return;
3694
- }
3695
- note("preset list", installed
3696
- .map((entry) => `${entry.manifest.name} ${entry.manifest.version} [${isPresetDisabled(entry.dir) ? "disabled" : "active"}]\n ${entry.manifest.description}\n ${entry.dir}`)
3697
- .join("\n"));
3698
- return;
3699
- }
3700
- if (sub === "show") {
3701
- if (!value) {
3702
- note("preset show", "usage: /preset show <name>");
3703
- return;
3704
- }
3705
- const { entries } = listCatalog(options.cwd);
3706
- const entry = entries.find((candidate) => candidate.manifest.name === value);
3707
- if (entry) {
3708
- note(`preset ${value}`, presetContents(entry));
3709
- return;
3710
- }
3711
- // Not on disk: the registry can still describe it, which is what makes
3712
- // `/preset show impeccable` useful before committing to a download.
3713
- const { entries: hub } = readHub();
3714
- const hubEntry = hubFind(hub, value);
3715
- if (!hubEntry) {
3716
- note("preset show", `no preset named "${value}". run /preset to list them.`);
3717
- return;
3718
- }
3719
- const size = hubEntry.sizeKb >= 1024 ? `${(hubEntry.sizeKb / 1024).toFixed(1)} MB` : `${hubEntry.sizeKb} KB`;
3720
- note(`preset ${value}`, [
3721
- hubEntry.description,
3722
- "",
3723
- `skill ${hubEntry.skill}`,
3724
- `source ${hubEntry.repo} @ ${hubEntry.sha.slice(0, 7)}`,
3725
- `licence ${hubEntry.license}`,
3726
- `size ~${size}`,
3727
- `category ${hubEntry.category}`,
3728
- "",
3729
- "not installed. install it from /preset, or: /preset add-hub " + hubEntry.name,
3730
- ].join("\n"));
3731
- return;
3732
- }
3733
- if (sub === "add") {
3734
- if (!value) {
3735
- note("preset add", "usage: /preset add <git-url | directory>");
3736
- return;
3737
- }
3738
- void installPreset(value);
3739
- return;
3740
- }
3741
- if (sub === "add-hub") {
3742
- if (!value) {
3743
- note("preset add-hub", "usage: /preset add-hub <name> (see /preset for names)");
3744
- return;
3745
- }
3746
- const { entries: hub } = readHub();
3747
- const hubEntry = hubFind(hub, value);
3748
- if (!hubEntry) {
3749
- note("preset add-hub", `"${value}" is not in the community registry. run /preset to browse it.`);
3750
- return;
3751
- }
3752
- void installHubPreset(hubEntry);
3753
- return;
3754
- }
3755
- if (sub === "enable" || sub === "disable") {
3756
- if (!value) {
3757
- note(`preset ${sub}`, `usage: /preset ${sub} <name>`);
3758
- return;
3759
- }
3760
- const entry = listCatalog(options.cwd).entries.find((candidate) => candidate.manifest.name === value && candidate.source === "installed");
3761
- if (!entry) {
3762
- note(`preset ${sub}`, `no installed preset named "${value}"`);
3763
- return;
3764
- }
3765
- const marker = join(entry.dir, ".disabled");
3766
- if (sub === "disable") {
3767
- writeFileSync(marker, "disabled\n");
3768
- const mcp = entry.manifest.mcp ? removeMcpServers(Object.keys(entry.manifest.mcp)) : { removed: [], kept: [], problems: [] };
3769
- void reconcileMcp()
3770
- .then(() => reloadPresetResources())
3771
- .then(() => note("preset", `disabled ${value}${mcp.removed.length ? `; stopped ${mcp.removed.join(", ")}` : ""}`))
3772
- .catch((cause) => error(`preset disable failed: ${cause instanceof Error ? cause.message : String(cause)}`));
3773
- return;
3774
- }
3775
- rmSync(marker, { force: true });
3776
- void registerPresetMcpServers(value)
3777
- .then((mcpLine) => reloadPresetResources().then(() => note("preset", `enabled ${value}${mcpLine}`)))
3778
- .catch((cause) => error(`preset enable failed: ${cause instanceof Error ? cause.message : String(cause)}`));
3779
- return;
3780
- }
3781
- if (sub === "remove") {
3782
- if (!value) {
3783
- note("preset remove", "usage: /preset remove <name>");
3784
- return;
3785
- }
3786
- const result = removeInstalled(value, options.cwd);
3787
- if (result.problems.length > 0) {
3788
- note("preset remove", result.problems.join("\n"));
3789
- return;
3790
- }
3791
- // Take back the servers this preset added, but only the ones no other
3792
- // installed preset still declares. Removing one another preset needs would
3793
- // break that preset silently, the next time somebody uses it.
3794
- const before = listCatalog(options.cwd).entries;
3795
- const wasInstalled = before.find((entry) => entry.manifest.name === value);
3796
- const claimed = new Set();
3797
- for (const entry of listCatalog(options.cwd).entries) {
3798
- if (entry.manifest.name === value)
3799
- continue;
3800
- for (const name of Object.keys(entry.manifest.mcp ?? {}))
3801
- claimed.add(name);
3802
- }
3803
- const mcp = wasInstalled?.manifest.mcp ? removeMcpServers(Object.keys(wasInstalled.manifest.mcp), claimed) : { removed: [], kept: [], problems: [] };
3804
- const mcpLine = mcp.removed.length > 0
3805
- ? `\n MCP servers removed from mcp.json: ${mcp.removed.join(", ")}`
3806
- : mcp.kept.length > 0
3807
- ? `\n MCP servers kept (still used by another preset): ${mcp.kept.join(", ")}`
3808
- : "";
3809
- const mcpProblem = mcp.problems.length > 0 ? `\n MCP: ${mcp.problems.join("; ")}` : "";
3810
- void reloadPresetResources().then(() => {
3811
- note("preset", `removed ${value}\n ${result.removed.join("\n ")}${mcpLine}${mcpProblem}`);
3812
- });
3813
- return;
3814
- }
3815
- note("preset", `unknown subcommand "${sub}". try: list, show, add, add-hub, remove.`);
3816
- };
3817
- const runSlash = async (text) => {
3818
- const [name, ...rest] = text.slice(1).split(/\s+/);
3819
- const arg = rest.join(" ");
3820
- if (name === "quit" || name === "exit") {
3821
- void shutdown();
3822
- return;
3823
- }
3824
- if (name === "help") {
3825
- note("help", slashCommands(session).map((command) => `/${command.name} ${command.description ?? ""}`).join("\n"));
3826
- return;
3827
- }
3828
- if (name === "init") {
3829
- const target = join(options.cwd, "AGENTS.md");
3830
- if (existsSync(target) && arg !== "force") {
3831
- note("init", `AGENTS.md already exists at ${target}. run /init force to overwrite.`);
3832
- return;
3833
- }
3834
- writeFileAtomicSync(target, INIT_TEMPLATE);
3835
- // Without this the command looked like it did nothing: the file was
3836
- // written and a model turn started, but the screen showed no trace.
3837
- note("init", `wrote ${target}; asking the agent to fill it in`);
3838
- await session.prompt("Read AGENTS.md, then fill it in with the real build, test, and layout facts of this repo. Keep it short.");
3839
- return;
3840
- }
3841
- if (name === "login") {
3842
- loginHere(arg);
3843
- return;
3844
- }
3845
- if (name === "connect") {
3846
- // With an argument the token is used directly. Without one, show how to get
3847
- // a token and where to make one, then take the next line typed into the
3848
- // editor as the token. The guide runs first because "paste your token" on
3849
- // its own leaves the user with no idea where a token comes from.
3850
- //
3851
- // A relay key always starts with `sk-`. Anything else that reads as a
3852
- // credential — `email:password`, or the base64 of it — is the account
3853
- // itself, and takes the path that reuses the key the account already owns
3854
- // instead of minting another one.
3855
- const raw = arg.trim();
3856
- if (raw && !raw.startsWith("sk-") && parseCredentialInput(raw)) {
3857
- connectWithCredential(raw);
3858
- return;
3859
- }
3860
- if (raw) {
3861
- connectHere(raw);
3862
- return;
3863
- }
3864
- // No argument and a credential on disk: sign in with it rather than
3865
- // sending the user through a browser they have already been through.
3866
- const stored = loadAccountCredential(AGENT_DIR);
3867
- if (stored) {
3868
- connectWithCredential(stored.credential);
3869
- return;
3870
- }
3871
- void connectHere("");
3872
- activity("prompt", "paste your howtofix.id token, then press enter");
3873
- refresh();
3874
- const previous = editorSubmit;
3875
- editorSubmit = (value) => {
3876
- editorSubmit = previous;
3877
- activity("prompt", undefined);
3878
- connectHere(value.trim());
3879
- };
3880
- return;
3881
- }
3882
- if (name === "sync") {
3883
- // Re-pull the gateway's catalogue without asking for the token again.
3884
- //
3885
- // The gateway adds and retires models, and `models.json` is a snapshot: a
3886
- // user who connected last month still sees last month's list, and the only
3887
- // way to refresh it was `/connect <token>` — re-pasting a credential to do
3888
- // something that needs no new credential at all.
3889
- //
3890
- // The key is already configured (as a literal or a `$ENV` reference), so
3891
- // the runtime can resolve it for us; nothing is printed and nothing is
3892
- // written back to the file.
3893
- const configured = new Set((await session.modelRuntime.getAvailable())
3894
- .map((model) => model.provider)
3895
- .filter((provider) => isHowtofixProvider(provider)));
3896
- const target = HOWTOFIX_PROVIDERS.find((provider) => configured.has(provider));
3897
- if (!target) {
3898
- note("sync", "no howtofix.id account is connected; run /connect first");
3899
- return;
3900
- }
3901
- activity("note", `re-syncing ${target} from howtofix.id`);
3902
- refresh();
3903
- const auth = await session.modelRuntime.getAuth(target);
3904
- const key = auth?.auth?.apiKey;
3905
- if (!key) {
3906
- activity("note", undefined);
3907
- note("sync", `${target} has no usable key; run /connect to sign in again`);
3908
- refresh();
3909
- return;
3910
- }
3911
- const check = await checkAccount(key);
3912
- if (!check.ok) {
3913
- activity("note", undefined);
3914
- note("sync", check.message);
3915
- refresh();
3916
- return;
3917
- }
3918
- const outcome = await syncModelsFor(target, check.models);
3919
- const usage = await checkUsage(key);
3920
- activity("note", undefined);
3921
- note("sync", `${outcome}\n${formatUsage(target, usage)}`);
3922
- // A newly synced model is not in the runtime's catalogue until it reloads.
3923
- await session.modelRuntime.refresh();
3924
- refresh();
3925
- return;
3926
- }
3927
- if (name === "usage") {
3928
- // What the account has left. The gateway answers this to the API key alone
3929
- // (`/v1/dashboard/billing/…`), so there is no session and no OAuth here.
3930
- const configured = new Set((await session.modelRuntime.getAvailable())
3931
- .map((model) => model.provider)
3932
- .filter((provider) => isHowtofixProvider(provider)));
3933
- const target = HOWTOFIX_PROVIDERS.find((provider) => configured.has(provider));
3934
- if (!target) {
3935
- note("usage", "no howtofix.id account is connected; run /connect first");
3936
- return;
3937
- }
3938
- activity("note", `asking howtofix.id for the account usage`);
3939
- refresh();
3940
- const auth = await session.modelRuntime.getAuth(target);
3941
- const key = auth?.auth?.apiKey;
3942
- if (!key) {
3943
- activity("note", undefined);
3944
- note("usage", `${target} has no usable key; run /connect to sign in again`);
3945
- refresh();
3946
- return;
3947
- }
3948
- const usage = await checkUsage(key);
3949
- // The daily check-in rides along: it is money on the same account, and
3950
- // asking for it here is one request the user would otherwise make in a
3951
- // browser. A status that cannot be read simply leaves the line off.
3952
- const checkin = await checkCliCheckin(key);
3953
- activity("note", undefined);
3954
- const checkinLine = checkin.ok && checkin.enabled
3955
- ? checkin.checkedInToday
3956
- ? `check-in: claimed today (+${formatQuotaUsd(checkin.quotaToday)})`
3957
- : "check-in: not claimed yet today (it claims itself on the next start)"
3958
- : "";
3959
- note("usage", checkinLine ? `${formatUsage(target, usage)}\n${checkinLine}` : formatUsage(target, usage));
3960
- refresh();
3961
- return;
3962
- }
3963
- if (name === "logout") {
3964
- // Same empty-list bug as `/providers`: the registered-ids set does not
3965
- // contain the configured providers, so the picker had nothing to pick.
3966
- const available = await session.modelRuntime.getAvailable();
3967
- const providers = [...new Set(available.map((model) => model.provider))].sort();
3968
- if (providers.length === 0) {
3969
- note("logout", "no providers are configured");
3970
- return;
3971
- }
3972
- choose("Logout", providers.map((provider) => ({ value: provider, label: provider })), (provider) => {
3973
- void session.modelRuntime.logout(provider).then(() => note("logout", `logged out ${provider}`), (cause) => note("logout", cause instanceof Error ? cause.message : String(cause)));
3974
- });
3975
- return;
3976
- }
3977
- if (name === "providers") {
3978
- // Providers that actually have models. `getRegisteredProviderIds()` is
3979
- // empty unless a provider was registered at runtime by a login, so the
3980
- // picker used to open with no rows even though `/model` listed four
3981
- // providers. Ask the same source `/model` asks.
3982
- const available = await session.modelRuntime.getAvailable();
3983
- const providers = [...new Set(available.map((model) => model.provider))].sort();
3984
- // The product's own gateway first: this CLI is meant to be usable through
3985
- // howtofix.id alone, so its providers must not be buried under others.
3986
- providers.sort((a, b) => Number(isHowtofixProvider(b)) - Number(isHowtofixProvider(a)));
3987
- choose("Providers", providers.map((provider) => ({ value: provider, label: provider })), (provider) => {
3988
- const models = session.modelRuntime.getModels(provider);
3989
- if (models.length === 0) {
3990
- loginHere(provider);
3991
- return;
3992
- }
3993
- choose(provider, models.map((model) => ({ value: model.id, label: model.id })), (id) => {
3994
- const picked = models.find((model) => model.id === id);
3995
- if (!picked)
3996
- return;
3997
- void session.setModel(picked, { persist: true }).then(() => {
3998
- hud.model = picked.id;
3999
- syncCeiling();
4000
- note("providers", `${provider}/${picked.id}`);
4001
- }, () => loginHere(provider));
4002
- });
4003
- });
4004
- return;
4005
- }
4006
- if (name === "name") {
4007
- if (!arg) {
4008
- note("name", `usage: /name <session name>`);
4009
- return;
4010
- }
4011
- session.setSessionName(arg);
4012
- note("name", `session name: ${arg}`);
4013
- return;
4014
- }
4015
- if (name === "update") {
4016
- // Same path the startup check takes, but asked for: so it always answers.
4017
- //
4018
- // The check is a network call, and this one had no busy signal at all —
4019
- // `/sync` and `/usage` both say what they are doing, so `/update` read as
4020
- // a dead key on a slow link until the answer finally landed.
4021
- activity("note", "checking for a newer release");
4022
- try {
4023
- await checkAndMaybeInstallUpdate({ current: APP_VERSION, install: true });
4024
- }
4025
- finally {
4026
- activity("note", undefined);
4027
- }
4028
- return;
4029
- }
4030
- if (name === "stats") {
4031
- const stats = session.getSessionStats();
4032
- const usage = session.getContextUsage();
4033
- // Same absent-usage class as the context meter: a message whose usage never
4034
- // arrived makes the SDK's aggregate `NaN`, and `$${NaN.toFixed(4)}` prints
4035
- // "$NaN". Print a dash for a reading that does not exist.
4036
- const n = (value) => (Number.isFinite(value) ? String(value) : ";");
4037
- // A high `cacheRead` alone does not say whether we are paying twice for the same
4038
- // context; the rate across cached AND billed prompt tokens does, and a miss is
4039
- // worth naming because it is almost always attributable to a cause.
4040
- const cache = summarizeCacheUsage(session.sessionManager.getEntries());
4041
- const cacheLine = cache.hitRate === null
4042
- ? "cache hit rate unknown (no prompt billed yet)"
4043
- : `cache hit ${cache.hitRate.toFixed(1)}% of ${cache.cachedTokens + cache.billedTokens} prompt tokens`;
4044
- note("stats", [
4045
- `session ${stats.sessionId}`,
4046
- stats.sessionFile ? `file ${stats.sessionFile}` : "not persisted",
4047
- `messages ${n(stats.totalMessages)} (user ${n(stats.userMessages)}, assistant ${n(stats.assistantMessages)}, tools ${n(stats.toolCalls)})`,
4048
- `tokens in ${n(stats.tokens.input)} out ${n(stats.tokens.output)} cache ${n(stats.tokens.cacheRead)}/${n(stats.tokens.cacheWrite)}`,
4049
- cacheLine,
4050
- cache.misses.length > 0
4051
- ? `cache misses ${cache.misses.length} (${n(cache.missedTokens)} tokens re-billed) — last: ${explainMiss(cache.misses[cache.misses.length - 1])}`
4052
- : "cache misses none counted",
4053
- `cost $${Number.isFinite(stats.cost) ? stats.cost.toFixed(4) : ";"}`,
4054
- usage ? `context ${usage.percent === null || !Number.isFinite(usage.percent) ? "?" : `${Math.round(usage.percent)}%`} of ${usage.contextWindow}` : "context unknown",
4055
- ].join("\n"));
4056
- return;
4057
- }
4058
- if (name === "diff") {
4059
- // Async: `git diff` on a large change set is slow enough to be felt, and a
4060
- // synchronous spawn blocks the whole event loop — the same reason every
4061
- // other git call on an interactive path was moved off `execFileSync`.
4062
- const run = async (args) => {
4063
- try {
4064
- const { stdout } = await execFileAsync("git", args, { cwd: options.cwd, encoding: "utf8" });
4065
- return stdout.trim();
4066
- }
4067
- catch {
4068
- return "";
4069
- }
4070
- };
4071
- const body = (await run(["diff", "--stat"])) + "\n\n" + (await run(["diff", "--", "."]));
4072
- // The summary stays whole; the patch body is capped so one large change
4073
- // cannot push the rest of the transcript off the screen.
4074
- const [summary = "", patch = ""] = body.split("\n\n", 2);
4075
- const clipped = clipDiff(patch);
4076
- note("diff", [summary.trim(), clipped].filter(Boolean).join("\n\n") || "working tree clean");
4077
- return;
4078
- }
4079
- if (name === "copy") {
4080
- const last = [...session.state.messages].reverse().find((message) => message.role === "assistant");
4081
- const parts = Array.isArray(last?.content) ? last.content : [];
4082
- const textOut = parts
4083
- .filter((part) => part.type === "text")
4084
- .map((part) => part.text)
4085
- .join("");
4086
- if (!textOut) {
4087
- note("copy", "nothing to copy");
4088
- return;
4089
- }
4090
- try {
4091
- await copyToClipboard(textOut);
4092
- note("copy", `copied ${textOut.length} characters`);
4093
- }
4094
- catch (cause) {
4095
- note("copy", cause instanceof Error ? cause.message : String(cause));
4096
- }
4097
- return;
4098
- }
4099
- if (name === "export") {
4100
- // An empty transcript exported to a file and reported "wrote ..." — the
4101
- // user gets a file with a header and nothing in it, and no hint that the
4102
- // session was empty. Say so instead of writing a misleading artifact.
4103
- const body = feed.items.filter((item) => item.body.trim().length > 0);
4104
- if (body.length === 0) {
4105
- note("export", "nothing to export yet; the transcript is empty");
4106
- return;
4107
- }
4108
- const target = resolve(options.cwd, arg || `howtofix-session-${Date.now()}.md`);
4109
- const lines = [`# ${APP_TITLE} session`, "", `model ${hud.model}`, ""];
4110
- for (const item of body) {
4111
- lines.push(`## ${item.title || item.kind}`, "", item.body, "");
4112
- }
4113
- try {
4114
- writeFileAtomicSync(target, lines.join("\n"));
4115
- }
4116
- catch (cause) {
4117
- // A bad path or a read-only target used to throw out of the input
4118
- // handler; the workbench stayed up but the command died silently.
4119
- note("export", `could not write ${target}: ${cause instanceof Error ? cause.message : String(cause)}`);
4120
- return;
4121
- }
4122
- note("export", `wrote ${target}`);
4123
- return;
4124
- }
4125
- if (name === "agents") {
4126
- // `/agents <id> <message>` talks to a parked agent. The capability existed
4127
- // (sendMail) but nothing called it, so a parked agent could only be listed,
4128
- // never resumed with new instructions — the point of parking it.
4129
- const [id = "", ...rest] = arg.split(/\s+/).filter(Boolean);
4130
- const message = rest.join(" ").trim();
4131
- if (id && message) {
4132
- note("agents", await sendMail(id, message));
4133
- return;
4134
- }
4135
- note("agents", id ? showRun(id) : listRuns());
4136
- return;
4137
- }
4138
- if (name === "new") {
4139
- const next = await options.reopen({ noSession: false });
4140
- await swap(next);
4141
- note("new", "started a new session");
4142
- return;
4143
- }
4144
- if (name === "resume") {
4145
- const sessions = await SessionManager.list(options.cwd);
4146
- if (sessions.length === 0) {
4147
- note("resume", "no saved sessions for this folder");
4148
- return;
4149
- }
4150
- choose("Resume session", sessions.slice(0, 30).map((info) => ({
4151
- value: info.path,
4152
- label: info.name || `${info.id.slice(0, 8)} ${info.firstMessage.slice(0, 40)}`,
4153
- description: `${info.modified.toISOString().slice(0, 16)} ${info.messageCount} messages`,
4154
- })), (path) => {
4155
- void options.reopen({ sessionPath: path }).then(swap, (cause) => error(cause instanceof Error ? cause.message : String(cause)));
4156
- });
4157
- return;
4158
- }
4159
- if (name === "model") {
4160
- const models = await session.modelRuntime.getAvailable();
4161
- const match = arg ? models.find((model) => `${model.provider}/${model.id}` === arg || model.id === arg) : undefined;
4162
- if (!arg) {
4163
- choose("Select model", models.map((model) => ({ value: `${model.provider}/${model.id}`, label: model.id, description: model.provider })), (value) => {
4164
- const picked = models.find((model) => `${model.provider}/${model.id}` === value);
4165
- if (!picked)
4166
- return;
4167
- void session.setModel(picked, { persist: true }).then(() => {
4168
- hud.model = picked.id;
4169
- syncCeiling();
4170
- note("model", `model ${value}`);
4171
- });
4172
- });
4173
- return;
4174
- }
4175
- if (!match) {
4176
- note("model", `unknown model ${arg}`);
4177
- return;
4178
- }
4179
- await session.setModel(match, { persist: true });
4180
- hud.model = match.id;
4181
- syncCeiling();
4182
- note("model", `model ${match.provider}/${match.id}`);
4183
- return;
4184
- }
4185
- if (name === "thinking") {
4186
- if (!arg) {
4187
- choose("Thinking level", THINKING_LEVELS.map((level) => ({ value: level, label: level })), (value) => setThinking(value));
4188
- return;
4189
- }
4190
- if (!THINKING_LEVELS.includes(arg)) {
4191
- note("thinking", `thinking: ${THINKING_LEVELS.join(", ")}`);
4192
- return;
4193
- }
4194
- setThinking(arg);
4195
- return;
4196
- }
4197
- if (name === "rtk") {
4198
- // Reads the setting each time so `/rtk` reflects what is actually on disk — the
4199
- // user may have flipped it from another terminal, and reporting a cached value
4200
- // would make the command a liar.
4201
- const current = readWorkbenchSettings(AGENT_DIR).compressToolOutput;
4202
- if (!arg) {
4203
- note("rtk", `compress tool output: ${current ? "on" : "off"}\n/rtk on | /rtk off`);
4204
- return;
4205
- }
4206
- const wanted = arg.toLowerCase();
4207
- if (wanted !== "on" && wanted !== "off") {
4208
- note("rtk", "rtk: on | off");
4209
- return;
4210
- }
4211
- const next = wanted === "on";
4212
- if (next === current) {
4213
- note("rtk", `already ${wanted}`);
4214
- return;
4215
- }
4216
- if (!writeWorkbenchSetting(AGENT_DIR, "compressToolOutput", next)) {
4217
- note("rtk", "could not write the setting");
4218
- return;
4219
- }
4220
- // The extension reads the flag per tool result, so this takes effect on the next
4221
- // tool call rather than on the next process — which is what a toggle should do.
4222
- setCompressToolOutput(next);
4223
- note("rtk", `compress tool output ${wanted}\nlong results are ${next ? "summarised before the model reads them" : "sent in full"}`);
4224
- return;
4225
- }
4226
- if (name === "compact") {
4227
- // The SDK throws when there is nothing to compact, and an uncaught
4228
- // rejection here killed the whole workbench (Node exits on it). A failed
4229
- // compaction is a normal outcome, not a reason to lose the session.
4230
- try {
4231
- await session.compact(arg || undefined);
4232
- note("compact", "session compacted");
4233
- }
4234
- catch (cause) {
4235
- note("compact", cause instanceof Error ? cause.message : String(cause));
4236
- }
4237
- return;
4238
- }
4239
- if (name === "approval") {
4240
- const apply = (mode) => {
4241
- setApprovalMode(mode);
4242
- note("approval", APPROVAL_LABELS[mode]);
4243
- };
4244
- if (!arg) {
4245
- choose("Approval mode", APPROVAL_MODES.map((mode) => ({ value: mode, label: mode, description: APPROVAL_LABELS[mode] })), (value) => apply(value));
4246
- return;
4247
- }
4248
- if (!APPROVAL_MODES.includes(arg)) {
4249
- note("approval", `modes: ${APPROVAL_MODES.join(", ")}`);
4250
- return;
4251
- }
4252
- apply(arg);
4253
- return;
4254
- }
4255
- if (name === "settings") {
4256
- /**
4257
- * A settings hub, not one toggle.
4258
- *
4259
- * Each entry opens the same picker its own slash command opens, so there is
4260
- * one implementation per setting and the hub is only a way in. The current
4261
- * value is in the picker's detail line, which is the part a user actually
4262
- * needs before choosing.
4263
- */
4264
- const permissionLabel = () => (isDangerousBypass() ? "off — everything runs" : "on — critical commands are refused");
4265
- const openPermission = () => {
4266
- choose("Permission checks", [
4267
- { value: "on", label: "on", description: "refuse critical commands and repeated calls (the default)" },
4268
- { value: "off", label: "off", description: "run everything without asking — for unattended runs" },
4269
- ], (value) => {
4270
- // The row says "off" about the *checks*, and the stored flag says
4271
- // whether to skip them — the two are opposites, so this is the one
4272
- // place the polarity is flipped, and it is flipped exactly once.
4273
- const bypassOn = value === "off";
4274
- const stored = writeWorkbenchSetting(AGENT_DIR, "skipPermissions", bypassOn);
4275
- // Applied now, not only on the next start: a setting the user changed
4276
- // and cannot feel is a setting they will change twice.
4277
- setDangerousBypass(bypassOn);
4278
- hud.skipPermissions = bypassOn;
4279
- refresh();
4280
- if (!stored) {
4281
- note("settings", "changed for this session, but settings.json could not be written (is it valid JSON?)");
4282
- return;
4283
- }
4284
- note("settings", bypassOn
4285
- ? "permission checks are OFF — critical commands and the repeat guard will not stop anything. Stored for future sessions."
4286
- : "permission checks are ON — critical commands and repeated calls are refused again.");
4287
- }, undefined, false, [`Currently ${permissionLabel()}.`]);
4288
- };
4289
- const openThinking = () => {
4290
- choose("Thinking level", THINKING_LEVELS.map((level) => ({
4291
- value: level,
4292
- label: level,
4293
- description: level === hud.thinking ? "current" : undefined,
4294
- })), (value) => setThinking(value), undefined, false, [`Currently ${hud.thinking}.`]);
4295
- };
4296
- const openAutoUpdate = () => {
4297
- choose("Auto-update", [
4298
- { value: "on", label: "on", description: "install a newer release on start (the default)" },
4299
- { value: "off", label: "off", description: "stay on this version until you update by hand" },
4300
- ], (value) => {
4301
- const on = value !== "off";
4302
- const stored = writeWorkbenchSetting(AGENT_DIR, "autoUpdate", on);
4303
- note("settings", stored
4304
- ? `auto-update ${on ? "on" : "off"} — takes effect on the next start.`
4305
- : "auto-update changed for this session, but settings.json could not be written (is it valid JSON?)");
4306
- }, undefined, false, [`Currently ${autoUpdateEnabled(`${AGENT_DIR}/settings.json`) ? "on" : "off"}.`]);
4307
- };
4308
- const openDcp = () => {
4309
- const status = runDcpCommand("status", options.cwd, session.getSessionStats().sessionId);
4310
- choose("Dynamic context pruning", [
4311
- { value: "on", label: "on", description: "fold stale ranges so long sessions stay affordable" },
4312
- { value: "off", label: "off", description: "send the full transcript, unpruned" },
4313
- { value: "status", label: "status", description: "what it has pruned so far" },
4314
- ], (value) => note("dcp", runDcpCommand(value, options.cwd, session.getSessionStats().sessionId)), undefined, false, status.split("\n").slice(0, 2));
4315
- };
4316
- const openModel = () => {
4317
- // `/model` owns its own picker; this only routes to it so the hub has the
4318
- // entry without a second copy of the catalogue logic.
4319
- void runSlash("/model");
4320
- };
4321
- const openProxy = () => {
4322
- // `/proxy` owns the whole interaction — there is a URL to type, which a
4323
- // picker cannot take — so this only routes to it and says what is set.
4324
- void runSlash("/proxy");
4325
- };
4326
- const openRightClickPaste = () => {
4327
- const current = readWorkbenchSettings(AGENT_DIR).rightClickPaste;
4328
- const platformDefault = process.platform === "win32" ? "off" : "on";
4329
- choose("Right click", [
4330
- { value: "auto", label: "auto", description: `follow the terminal (${platformDefault} on ${process.platform})` },
4331
- { value: "on", label: "on", description: "the CLI pastes the clipboard itself" },
4332
- { value: "off", label: "off", description: "the terminal handles it, or the click does nothing" },
4333
- ], (value) => {
4334
- const stored = writeRightClickPasteSetting(AGENT_DIR, value);
4335
- note("settings", stored
4336
- ? value === "auto"
4337
- ? `right click follows the terminal — ${rightClickPasteEnabled("auto") ? "the CLI pastes" : "the terminal pastes"} here.`
4338
- : `right click ${value === "on" ? "pastes from the CLI" : "is left to the terminal"}.`
4339
- : "right click changed for this session, but settings.json could not be written (is it valid JSON?)");
4340
- }, undefined, false, [`Currently ${current}.`]);
4341
- };
4342
- /**
4343
- * How many agents may run at once, across `task` and `workflow`.
4344
- *
4345
- * One budget rather than two is the point: both paths open sessions, and
4346
- * before this each had its own width, so the two together could put more
4347
- * streams in flight than the machine is sized for.
4348
- */
4349
- const openAgents = () => {
4350
- const current = readWorkbenchSettings(AGENT_DIR).agents;
4351
- const choices = [1, 2, 3, 4].map((value) => ({
4352
- value: String(value),
4353
- label: String(value),
4354
- description: value === 1
4355
- ? "one child at a time"
4356
- : value === agentConcurrency()
4357
- ? `current — sized to this machine`
4358
- : `${value} children at once`,
4359
- }));
4360
- choose("Agent concurrency", [
4361
- { value: "auto", label: "auto", description: `size it to the machine (now ${agentConcurrency()}, max ${MAX_AGENT_CONCURRENCY})` },
4362
- ...choices,
4363
- ], (value) => {
4364
- const wanted = value === "auto" ? undefined : Number(value);
4365
- const stored = writeAgentsSetting(AGENT_DIR, wanted);
4366
- note("settings", stored
4367
- ? wanted === undefined
4368
- ? `agent concurrency follows the machine — ${agentConcurrency()} at a time.`
4369
- : `agent concurrency set to ${wanted} — shared by task and workflow.`
4370
- : "agent concurrency changed for this session, but settings.json could not be written (is it valid JSON?)");
4371
- refresh();
4372
- }, undefined, false);
4373
- };
4374
- const entries = [
4375
- { value: "permissions", label: "permissions", description: permissionLabel() },
4376
- { value: "thinking", label: "thinking", description: `${hud.thinking} — how hard the model reasons` },
4377
- { value: "model", label: "model", description: hud.model },
4378
- { value: "dcp", label: "context pruning", description: "fold stale ranges to save tokens" },
4379
- { value: "proxy", label: "proxy", description: configuredProxy(AGENT_DIR) ?? environmentProxy() ?? "none — direct to the API" },
4380
- { value: "rightclick", label: "right click", description: `paste: ${readWorkbenchSettings(AGENT_DIR).rightClickPaste}` },
4381
- { value: "agents", label: "agents", description: `${readWorkbenchSettings(AGENT_DIR).agents ?? `auto (${agentConcurrency()})`} at once, shared by task and workflow` },
4382
- { value: "autoupdate", label: "auto-update", description: "install a newer release on start" },
4383
- ];
4384
- if (!arg) {
4385
- choose("Settings", entries, (value) => {
4386
- if (value === "permissions")
4387
- openPermission();
4388
- else if (value === "thinking")
4389
- openThinking();
4390
- else if (value === "dcp")
4391
- openDcp();
4392
- else if (value === "autoupdate")
4393
- openAutoUpdate();
4394
- else if (value === "model")
4395
- openModel();
4396
- else if (value === "proxy")
4397
- openProxy();
4398
- else if (value === "rightclick")
4399
- openRightClickPaste();
4400
- else if (value === "agents")
4401
- openAgents();
4402
- });
4403
- return;
4404
- }
4405
- // Direct access, so a script or a user who knows the name skips the menu.
4406
- const direct = entries.find((entry) => entry.value === arg || entry.label === arg);
4407
- if (direct) {
4408
- if (direct.value === "permissions")
4409
- openPermission();
4410
- else if (direct.value === "thinking")
4411
- openThinking();
4412
- else if (direct.value === "dcp")
4413
- openDcp();
4414
- else if (direct.value === "autoupdate")
4415
- openAutoUpdate();
4416
- else if (direct.value === "model")
4417
- openModel();
4418
- else if (direct.value === "proxy")
4419
- openProxy();
4420
- else if (direct.value === "rightclick")
4421
- openRightClickPaste();
4422
- else if (direct.value === "agents")
4423
- openAgents();
4424
- return;
4425
- }
4426
- if (arg === "on" || arg === "off") {
4427
- // Kept from the first version: the most-wanted setting, without the menu.
4428
- const bypassOn = arg === "off";
4429
- const stored = writeWorkbenchSetting(AGENT_DIR, "skipPermissions", bypassOn);
4430
- setDangerousBypass(bypassOn);
4431
- hud.skipPermissions = bypassOn;
4432
- refresh();
4433
- note("settings", stored
4434
- ? bypassOn
4435
- ? "permission checks are OFF — critical commands and the repeat guard will not stop anything."
4436
- : "permission checks are ON."
4437
- : "changed for this session, but settings.json could not be written (is it valid JSON?)");
4438
- return;
4439
- }
4440
- note("settings", `settings: ${entries.map((entry) => entry.value).join(", ")} | on, off (permission checks)\n` +
4441
- `usage: /settings <name>, or /settings to choose`);
4442
- return;
4443
- }
4444
- if (name === "proxy") {
4445
- /**
4446
- * The proxy this CLI dials through.
4447
- *
4448
- * Three outcomes, because a user needs to tell them apart: what is set now,
4449
- * what is set now but came from the environment (so editing it here will
4450
- * not change it), and nothing at all. The value is shown in full — a proxy
4451
- * URL is not a secret, and a masked host is one nobody can check.
4452
- */
4453
- const current = configuredProxy(AGENT_DIR);
4454
- const fromEnv = environmentProxy();
4455
- const describe = () => {
4456
- if (current)
4457
- return `proxy: ${current} (set here, in settings.json)`;
4458
- if (fromEnv)
4459
- return `proxy: ${fromEnv} (from the environment, not this file)`;
4460
- return "proxy: none — requests go straight to the API";
4461
- };
4462
- if (!arg) {
4463
- note("proxy", `${describe()}\n` +
4464
- "usage: /proxy <url> route requests through a proxy\n" +
4465
- " /proxy off stop using one\n" +
4466
- " /proxy env use HTTPS_PROXY / HTTP_PROXY / ALL_PROXY\n" +
4467
- "Only traffic to the HowToFix API goes through it; a change applies on the next start.");
4468
- return;
4469
- }
4470
- const value = arg.trim();
4471
- if (value === "off" || value === "none") {
4472
- const stored = writeStringSetting(AGENT_DIR, PROXY_SETTING_KEY, "");
4473
- // Applied now, and this is the half that matters: clearing the setting
4474
- // alone left the session dialling the proxy it had just removed.
4475
- const applied = installProxy(AGENT_DIR);
4476
- const now = applied.source === "env" ? `the environment still sets one (${applied.url})` : "requests go straight to the API";
4477
- note("proxy", stored
4478
- ? `proxy off — ${now} from the next message.`
4479
- : "proxy changed for this session, but settings.json could not be written (is it valid JSON?)");
4480
- return;
4481
- }
4482
- if (value === "env") {
4483
- // Clearing the setting hands control back to the environment, and the
4484
- // install applies it now so the change is felt without a restart.
4485
- const stored = writeStringSetting(AGENT_DIR, PROXY_SETTING_KEY, "");
4486
- const applied = installProxy(AGENT_DIR);
4487
- const found = applied.source === "env" ? applied.url : undefined;
4488
- note("proxy", stored
4489
- ? found
4490
- ? `using the environment from the next message: ${found}`
4491
- : "settings cleared, but no HTTPS_PROXY / HTTP_PROXY / ALL_PROXY is set — requests go straight to the API."
4492
- : "settings.json could not be written (is it valid JSON?)");
4493
- return;
4494
- }
4495
- const valid = validateProxyUrl(value);
4496
- if (!valid.ok) {
4497
- note("proxy", `not set: ${valid.problem}`);
4498
- return;
4499
- }
4500
- const stored = writeStringSetting(AGENT_DIR, PROXY_SETTING_KEY, valid.url);
4501
- if (!stored) {
4502
- note("proxy", "settings.json could not be written (is it valid JSON?)");
4503
- return;
4504
- }
4505
- // Applied to this session too, so a user who sets it can see it take effect
4506
- // without restarting — the same reason the permission toggle applies now.
4507
- const applied = installProxy(AGENT_DIR);
4508
- note("proxy", applied.url
4509
- ? `proxy set to ${valid.url} — requests go through it from the next message.`
4510
- : `proxy saved as ${valid.url}, but it could not be applied this session; it will be tried on the next start.`);
4511
- return;
4512
- }
4513
- if (name === "tools") {
4514
- const active = session.getActiveToolNames().sort();
4515
- note("tools", active.length ? active.join("\n") : "no tools enabled");
4516
- return;
4517
- }
4518
- if (name === "dcp") {
4519
- // The state file is keyed by session id, and the project config lives in
4520
- // the session's cwd, so both come from the live session rather than being
4521
- // read from a context the workbench does not have.
4522
- note("dcp", runDcpCommand(arg, options.cwd, session.getSessionStats().sessionId));
4523
- return;
4524
- }
4525
- // `/context`, `/decompress` and `/sweep` are the spec's own command names
4526
- // for what `/dcp` reaches as subcommands. Both spellings dispatch through
4527
- // one function so there is one implementation, and the subcommand form
4528
- // stays the discoverable one.
4529
- if (name === "context" || name === "decompress" || name === "sweep") {
4530
- note(name, runDcpCommand(`${name} ${arg}`.trim(), options.cwd, session.getSessionStats().sessionId));
4531
- return;
4532
- }
4533
- // `/rewind` is the session-tree picker; `/undo` is the git commit undo below.
4534
- // They used to share this branch, so `/undo` returned here and the git
4535
- // implementation below was unreachable dead code — `/undo` opened the rewind
4536
- // picker and answered "nothing to rewind to yet" instead of undoing the
4537
- // commit the README and `--help` promise.
4538
- if (name === "rewind") {
4539
- openRewind();
4540
- return;
4541
- }
4542
- if (name === "undo") {
4543
- // `/undo` is the git inverse of `/commit`. It used to share a branch with
4544
- // `/rewind`, so it opened the session-tree picker and answered "nothing to
4545
- // rewind to yet" instead of dropping the commit the README promises.
4546
- if (!(await isGitRepo(options.cwd))) {
4547
- note("undo", "not a git repository");
4548
- return;
4549
- }
4550
- const result = await undoLastCommit(options.cwd);
4551
- note("undo", result.ok ? `undone: ${result.output}` : `undo failed: ${result.output}`);
4552
- refreshGitMarker(true);
4553
- return;
4554
- }
4555
- if (name === "commit") {
4556
- if (!isGitRepo(options.cwd)) {
4557
- note("commit", "not a git repository");
4558
- return;
4559
- }
4560
- if (!(await hasChanges(options.cwd))) {
4561
- note("commit", "nothing to commit");
4562
- return;
4563
- }
4564
- const message = arg || `howtofix: session update ${new Date().toISOString().slice(0, 16)}`;
4565
- const result = await commitAll(options.cwd, message);
4566
- note("commit", result.ok ? result.output : `commit failed: ${result.output}`);
4567
- refreshGitMarker(true);
4568
- return;
4569
- }
4570
- if (name === "review") {
4571
- const diff = await fullDiff(options.cwd);
4572
- if (!diff.trim()) {
4573
- note("review", "no changes to review");
4574
- return;
4575
- }
4576
- submit("Review the uncommitted changes. Report concrete issues with file evidence. Do not edit files.");
4577
- return;
4578
- }
4579
- if (name === "workflow") {
4580
- // The panel is pinned above the editor and also left in the scrollback, so
4581
- // there is nothing to "open". The useful actions are: show it again here,
4582
- // or clear the pinned copy so it stops taking rows.
4583
- if (arg.trim() === "clear") {
4584
- unpinWorkflow();
4585
- note("workflow", "cleared the pinned workflow panel");
4586
- return;
4587
- }
4588
- if (!currentRun) {
4589
- note("workflow", "no workflow run in this session yet");
4590
- return;
4591
- }
4592
- const groups = groupPhases(currentRun, Date.now());
4593
- const selected = groups[workflowView.selectedPhase];
4594
- note("workflow", [
4595
- `panel ${workflowPinned ? "pinned above the editor" : "unpinned"}`,
4596
- `phase ${workflowView.selectedPhase + 1}/${groups.length}: ${selected?.title ?? "(none)"}`,
4597
- `filter: ${workflowView.filter}${workflowView.paused ? " · paused" : ""}`,
4598
- "keys: ↑/↓ phase · Enter drill in · Esc unpin · f filter · p pause · x stop · s save",
4599
- "clear it with /workflow clear",
4600
- ].join("\n"));
4601
- return;
4602
- }
4603
- if (name === "plan") {
4604
- // Toggling is the user's half of plan mode; the agent's half is the
4605
- // `enter_plan_mode` tool, which asks before it switches anything on.
4606
- if (planActive() || currentPlanSnapshot().state === "pending") {
4607
- transition(userExit(currentPlanSnapshot()));
4608
- note("plan", "plan mode off; edits are allowed again");
4609
- return;
4610
- }
4611
- transition(enterPending(currentPlanSnapshot()));
4612
- note("plan", `plan mode armed — send your next prompt to start planning\nplan file: ${currentPlanFile() ?? "(unknown)"}`);
4613
- return;
4614
- }
4615
- if (name === "goal") {
4616
- // Setting a goal is the one thing the user does here; the agent drives
4617
- // everything else through the tool. With no argument, show the goal.
4618
- const text = arg.trim();
4619
- if (!text) {
4620
- note("goal", formatGoal(currentGoal()));
4621
- return;
4622
- }
4623
- const { goal, error: goalError } = applyGoalOp(currentGoal(), "set", text, undefined, undefined);
4624
- if (goalError) {
4625
- error(goalError);
4626
- return;
4627
- }
4628
- setMirror(goal);
4629
- // Persist it: the HUD mirror is memory only, so a resume would otherwise
4630
- // lose a goal the user set from the command line.
4631
- await session.sendCustomMessage({ customType: GOAL_ENTRY, content: text, display: false, details: goal });
4632
- note("goal", `goal set\n${formatGoal(goal)}`);
4633
- return;
4634
- }
4635
- if (name === "session") {
4636
- const model = session.model;
4637
- note("session", model ? `${model.provider}/${model.id}` : "no model");
4638
- return;
4639
- }
4640
- if (name === "skills") {
4641
- note("skills", formatSkillList(session.resourceLoader.getSkills().skills));
4642
- return;
4643
- }
4644
- if (name === "preset") {
4645
- presetCommand(arg);
4646
- return;
4647
- }
4648
- if (name === "roblox") {
4649
- const entry = listCatalog(options.cwd).entries.find((candidate) => candidate.manifest.name === "rotoolbox");
4650
- if (!entry || entry.source !== "installed") {
4651
- note("roblox", "RoToolbox is not installed. Run /preset and install RoToolbox first.");
4652
- return;
4653
- }
4654
- const disabled = isPresetDisabled(entry.dir);
4655
- // Count every tool that names this server, not just the ones shaped
4656
- // `mcp__<server>__<tool>`. A server that runs in code mode is reached
4657
- // through a single surface rather than one tool per capability, so a
4658
- // prefix match reported "not loaded yet" while the server was connected
4659
- // and answering — the status line was wrong, not the connection.
4660
- const tools = session
4661
- .getActiveToolNames()
4662
- .filter((tool) => /roblox[-_]studio/i.test(tool));
4663
- const connected = tools.length > 0;
4664
- note("roblox", disabled
4665
- ? "RoToolbox: disabled\nEnable with /preset enable rotoolbox\nThe MCP server and manager stay stopped while disabled."
4666
- : [
4667
- "RoToolbox: active",
4668
- `MCP server: ${connected ? `connected — ${tools.length} tool${tools.length === 1 ? "" : "s"} registered` : "not connected yet"}`,
4669
- "Open Roblox Studio with a place, then use a prompt to inspect it.",
4670
- "Disable with /preset disable rotoolbox",
4671
- ].join("\n"));
4672
- return;
4673
- }
4674
- const invocation = skillInvocation(text, session.resourceLoader.getSkills().skills.map((skill) => skill.name));
4675
- if (invocation) {
4676
- // Reach the SDK expander: submit() would bounce back into runSlash on the
4677
- // leading slash, so this goes straight through the same post-slash path a
4678
- // normal prompt takes.
4679
- void sendTask(invocation);
4680
- return;
4681
- }
4682
- const extensionCommand = session.extensionRunner.getCommand(name ?? "");
4683
- if (extensionCommand) {
4684
- try {
4685
- await extensionCommand.handler(arg, session.extensionRunner.createCommandContext());
4686
- }
4687
- catch (cause) {
4688
- error(cause instanceof Error ? cause.message : String(cause));
4689
- }
4690
- return;
4691
- }
4692
- note("command", `/${name ?? ""} is not available in this screen yet`);
4693
- };
4694
- setAsker((question, choices) => {
4695
- const { promise, resolve: settle } = Promise.withResolvers();
4696
- // Escape on a question must answer it, not leave it hanging. Without
4697
- // `onDismiss` the picker closed and the promise was never settled, so the
4698
- // `ask` tool waited forever and the run never resumed — the model had asked
4699
- // a question and the user had dismissed it, and neither side could move.
4700
- // "cancelled" is the same answer the tool gives when no screen can ask.
4701
- choose(question, choices.map((choice) => ({ value: choice, label: choice })), settle, () => settle("cancelled"));
4702
- return promise;
4703
- });
4704
- process.on("SIGTERM", shutdown);
4705
- // A SIGINT that still arrives — a Windows console, or a terminal that reset
4706
- // its mode — must take the same path as the key, not Node's default exit.
4707
- catchInterrupt(() => void shutdown());
4708
- terminal.setTitle(`${APP_TITLE} ${APP_VERSION}`);
4709
- claimTranscriptScrollKeys();
4710
- tui.start();
4711
- tui.requestRender(true);
4712
- /**
4713
- * The first-run greeting, shown before the daily check-in below so its "you
4714
- * earned X" note lands underneath the welcome rather than being buried by it.
4715
- * Awaited so the picker is on screen before anything else claims the rows.
4716
- */
4717
- // The daily check-in: claimed when the workbench opens, and again after any
4718
- // turn, so a session left running past midnight still claims the new day.
4719
- void maybeClaimDaily(true);
4720
- {
4721
- const { maybeShowWelcome } = await import("./welcome.js");
4722
- await maybeShowWelcome({
4723
- cwd: options.cwd,
4724
- note: options.note,
4725
- signedIn: cliCredential !== undefined,
4726
- runSlash: (command) => void runSlash(command),
4727
- openExternal: (url) => void openInBrowser(url),
4728
- }, choose, note);
4729
- }
4730
- if (options.initialPrompt)
4731
- submit(options.initialPrompt);
4732
- await new Promise(() => { });
4733
- }
4734
- let editorSubmit;
4735
- /**
4736
- * How often the animated parts are asked to repaint, in milliseconds.
4737
- *
4738
- * 30 fps (`1000 / 30`). The spinner, the seconds clock and the context meter
4739
- * spring all advance on this tick, and at the previous 90 ms — 11 fps — the
4740
- * motion read as stepping rather than turning. The SDK coalesces requests and
4741
- * drops anything inside its own 16 ms window, so this is the ceiling, not a
4742
- * guaranteed rate, and it is only requested while something is actually
4743
- * animating.
4744
- */
4745
- const ANIMATION_TICK_MS = 1000 / 30;
4746
- /**
4747
- * How long a run may go without a single byte of response before the UI says so.
4748
- * Long enough not to fire on a slow first token (reasoning models can take tens
4749
- * of seconds), short enough that a stalled connection does not look like a hang.
4750
- */
4751
- const STALL_NOTICE_MS = 45_000;