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.
- package/dist/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -116
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4751
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- 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;
|