atom-agent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/palette.js +1 -1
- package/dist/ui/status-bar.js +80 -5
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/App.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as os from "node:os";
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { Box, Text, useApp, useInput, usePaste } from "ink";
|
|
9
|
-
import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError,
|
|
9
|
+
import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, normalizeEffort, runAgenticLoopForProvider, } from "./zen.js";
|
|
10
10
|
import { createContextManager, trackHistory, } from "./context-manager.js";
|
|
11
11
|
import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
|
|
12
12
|
import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
|
|
@@ -16,7 +16,9 @@ import { formatRules, parseRuleInput, } from "./permissions.js";
|
|
|
16
16
|
import { decidePolicy, skillGrantsFor } from "./policy.js";
|
|
17
17
|
import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
|
|
18
18
|
import { contextWindowFor } from "./context-windows.js";
|
|
19
|
-
import {
|
|
19
|
+
import { emptyGoalStats, formatGoalForCompact, goalClearNotice, goalFollowUp, goalPauseNotice, goalResumeNotice, goalSetNotice, goalStatusText, goalTokensForUsage, parseGoalCommand, restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
|
|
20
|
+
import { requestGoalVerdict } from "./agent/goal-evaluator.js";
|
|
21
|
+
import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFilesAndGoal, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
|
|
20
22
|
import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
|
|
21
23
|
import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
|
|
22
24
|
import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
|
|
@@ -25,10 +27,14 @@ import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./k
|
|
|
25
27
|
import { getGitInfo, withEnvBlock } from "./env-block.js";
|
|
26
28
|
import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
|
|
27
29
|
import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
|
|
30
|
+
import { discoverExtensionEntries, loadExtensions, resolveExtensionName, } from "./extensions.js";
|
|
31
|
+
import { formatExtensionStatusText } from "./extension-ui.js";
|
|
32
|
+
import { getExtensionCommand, listExtensionCommands, parseExtensionCommandInput, runExtensionCommand, } from "./extension-commands.js";
|
|
28
33
|
import { loadAtomConfig } from "./config.js";
|
|
34
|
+
import { grantProjectTrust, isProjectTrusted, projectTrustQuestion, } from "./project-trust.js";
|
|
29
35
|
import { cancelledTurnLine } from "./rollback.js";
|
|
30
36
|
import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
|
|
31
|
-
import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
37
|
+
import { applyBeforeCompact, beforeCompactInterceptors, forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
32
38
|
import { InputBox } from "./ui/input.js";
|
|
33
39
|
import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
|
|
34
40
|
import { LiveTailHost } from "./ui/live-host.js";
|
|
@@ -51,7 +57,7 @@ export const SLASH_COMMANDS = [
|
|
|
51
57
|
{ name: "/provider", description: "Pick AI provider, paste API key once, chat." },
|
|
52
58
|
{
|
|
53
59
|
name: "/effort",
|
|
54
|
-
description: "Open the reasoning-effort picker (
|
|
60
|
+
description: "Open the reasoning-effort picker (Auto/Low/Medium/High/Max; Auto lets the model decide).",
|
|
55
61
|
},
|
|
56
62
|
{ name: "/tools", description: "List the tools with one-line descriptions." },
|
|
57
63
|
{ name: "/skills", description: "List installed skills (project + global)." },
|
|
@@ -68,7 +74,8 @@ export const SLASH_COMMANDS = [
|
|
|
68
74
|
{ name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
|
|
69
75
|
{ name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
|
|
70
76
|
{ name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
|
|
71
|
-
{ name: "/autoscroll", description: "Toggle following new output (
|
|
77
|
+
{ name: "/autoscroll", description: "Toggle following new output (on by default; bare toggles, on|off sets it; off freezes the view mid-turn)." },
|
|
78
|
+
{ name: "/goal", description: "Set, show, pause, resume, or clear the session goal (/goal <objective>; bare shows it; /goal pause|resume; /goal clear ends it)." },
|
|
72
79
|
{ name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
|
|
73
80
|
{ name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
|
|
74
81
|
{ name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
|
|
@@ -84,12 +91,12 @@ const SLASH_NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
|
|
|
84
91
|
// conversation only. Files-only is the default highlight (safest).
|
|
85
92
|
export const REWIND_SCOPES = ["files only", "files + conversation", "conversation only"];
|
|
86
93
|
// Submit-time pipeline order (ticket 02): submit() below reads as one
|
|
87
|
-
// ordered sequence — permissions → context assembly →
|
|
94
|
+
// ordered sequence — permissions → context assembly → loop
|
|
88
95
|
// entry — so future submit-time work has exactly one home stage. The
|
|
89
96
|
// rollback-scope rule per stage states what a failed turn keeps vs drops.
|
|
90
97
|
// This descriptor is the order test's source of truth:
|
|
91
98
|
// tests/submit-order.test.ts pins both this order and the matching
|
|
92
|
-
// `SUBMIT STAGE n/
|
|
99
|
+
// `SUBMIT STAGE n/3` markers inside submit().
|
|
93
100
|
export const SUBMIT_PIPELINE_STAGES = [
|
|
94
101
|
{
|
|
95
102
|
name: "permissions",
|
|
@@ -99,10 +106,6 @@ export const SUBMIT_PIPELINE_STAGES = [
|
|
|
99
106
|
name: "context-assembly",
|
|
100
107
|
rollbackScope: "pre-rollbackTo: the env-block refresh survives a failed turn (it is not part of the user turn)",
|
|
101
108
|
},
|
|
102
|
-
{
|
|
103
|
-
name: "budget-check",
|
|
104
|
-
rollbackScope: "pre-rollbackTo: the budget trim survives a failed turn (rollback indices are captured after it)",
|
|
105
|
-
},
|
|
106
109
|
{
|
|
107
110
|
name: "loop-entry",
|
|
108
111
|
rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
|
|
@@ -115,9 +118,10 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
|
|
|
115
118
|
export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
|
|
116
119
|
export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
|
|
117
120
|
export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
|
|
118
|
-
export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] —
|
|
121
|
+
export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll toggles between the two.";
|
|
119
122
|
export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: the live block and future rounds show or hide; already-printed blocks stay as printed; the turn, history, and telemetry are untouched).";
|
|
120
123
|
export const RENAME_USAGE = 'usage: /rename <name> — rename the current session (e.g. /rename Build authentication; quotes optional: /rename "name with spaces"). Bare /rename prints this usage.';
|
|
124
|
+
export const GOAL_USAGE = "usage: /goal <objective> (set; replacing resets counters) · /goal (show with cumulative stats) · /goal pause · /goal resume (re-arms; idle starts a turn, busy resumes at turn end) · /goal clear (ends it)";
|
|
121
125
|
// Pure arg parser for /rename (unit-tested): strips the command, trims,
|
|
122
126
|
// then strips one layer of matching outer quotes (single or double) so
|
|
123
127
|
// quoted names work even though the command line has no real parser.
|
|
@@ -187,6 +191,32 @@ export function paletteEntries(query) {
|
|
|
187
191
|
if (c.description.toLowerCase().includes(q))
|
|
188
192
|
out.push({ c, tier: 3, score: 0, idx });
|
|
189
193
|
});
|
|
194
|
+
// Extension slash commands (ticket 04): the same prefix/fuzzy/
|
|
195
|
+
// description tiers over the live registry — a colliding name can never
|
|
196
|
+
// reach here (activation rejects it), and dispatch routes builtins first
|
|
197
|
+
// as backstop, so builtins are never shadowed.
|
|
198
|
+
const extCmds = listExtensionCommands();
|
|
199
|
+
const extNames = new Set(extCmds.map((c) => `/${c.name}`));
|
|
200
|
+
extCmds.forEach((cmd, extIdx) => {
|
|
201
|
+
const c = { name: `/${cmd.name}`, description: cmd.description };
|
|
202
|
+
const idx = SLASH_COMMANDS.length + extIdx;
|
|
203
|
+
const name = cmd.name.toLowerCase();
|
|
204
|
+
if (!q) {
|
|
205
|
+
out.push({ c, tier: 0, score: 0, idx });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (name.startsWith(q)) {
|
|
209
|
+
out.push({ c, tier: 1, score: 0, idx });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const s = fuzzyScore(q, name);
|
|
213
|
+
if (s !== null) {
|
|
214
|
+
out.push({ c, tier: 2, score: s, idx });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (cmd.description.toLowerCase().includes(q))
|
|
218
|
+
out.push({ c, tier: 3, score: 0, idx });
|
|
219
|
+
});
|
|
190
220
|
const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
|
|
191
221
|
out.sort((a, b) => a.tier - b.tier ||
|
|
192
222
|
(a.tier === 0
|
|
@@ -195,13 +225,14 @@ export function paletteEntries(query) {
|
|
|
195
225
|
return out.map(({ c }) => ({
|
|
196
226
|
name: c.name,
|
|
197
227
|
description: c.description,
|
|
198
|
-
category: paletteCategory(c.name),
|
|
228
|
+
category: extNames.has(c.name) ? "Extensions" : paletteCategory(c.name),
|
|
199
229
|
hint: PALETTE_HINTS[c.name] ?? null,
|
|
200
230
|
}));
|
|
201
231
|
}
|
|
202
232
|
// Busy-gate shared by the slash menu and the palette: /compact sets the
|
|
203
233
|
// pending flag for turn-end drain; /queue + /steer manage the running turn;
|
|
204
234
|
// /autoscroll and /thinking only flip view flags (never touch the turn);
|
|
235
|
+
// /goal only flips session goal state (never touches the turn);
|
|
205
236
|
// /rename only renames the store record + title state (the later turn-end
|
|
206
237
|
// persist preserves the title, so it never races the turn).
|
|
207
238
|
// Every other command waits idle.
|
|
@@ -211,6 +242,7 @@ export function slashRunsWhileBusy(name) {
|
|
|
211
242
|
name === "/steer" ||
|
|
212
243
|
name === "/autoscroll" ||
|
|
213
244
|
name === "/thinking" ||
|
|
245
|
+
name === "/goal" ||
|
|
214
246
|
name === "/rename");
|
|
215
247
|
}
|
|
216
248
|
// Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
|
|
@@ -267,14 +299,37 @@ export function sameSkillMenuSnapshot(a, b) {
|
|
|
267
299
|
// name. Skill rows carry a truncated description for discovery. Pure — the
|
|
268
300
|
// App feeds it the cached registry snapshot.
|
|
269
301
|
export const SKILL_MENU_DESC_CHARS = 60;
|
|
270
|
-
export function buildSlashMenu(input, skills) {
|
|
302
|
+
export function buildSlashMenu(input, skills, extensions = []) {
|
|
271
303
|
const items = filterSlashCommands(input).map((c) => ({
|
|
272
304
|
name: c.name,
|
|
273
305
|
description: c.description,
|
|
274
306
|
}));
|
|
275
307
|
if (input.length < 2)
|
|
276
308
|
return { items, moreSkills: 0 };
|
|
309
|
+
// Extension slash commands (ticket 04): prefix tier in registration
|
|
310
|
+
// order (registration order is deterministic), then fuzzy by score —
|
|
311
|
+
// listed after builtins (exact dispatch routes builtins first, so an
|
|
312
|
+
// extension never shadows) and before skills.
|
|
277
313
|
const q = input.slice(1);
|
|
314
|
+
const extPrefix = [];
|
|
315
|
+
const extFuzzy = [];
|
|
316
|
+
for (const e of extensions) {
|
|
317
|
+
const entry = `/${e.name}`;
|
|
318
|
+
if (e.name.startsWith(q) || entry.startsWith(input)) {
|
|
319
|
+
extPrefix.push(e);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const score = fuzzyScore(q, e.name);
|
|
323
|
+
if (score !== null)
|
|
324
|
+
extFuzzy.push({ e, score });
|
|
325
|
+
}
|
|
326
|
+
extFuzzy.sort((a, b) => a.score - b.score || (a.e.name < b.e.name ? -1 : 1));
|
|
327
|
+
for (const e of [...extPrefix, ...extFuzzy.map((f) => f.e)]) {
|
|
328
|
+
const desc = e.description.length > SKILL_MENU_DESC_CHARS
|
|
329
|
+
? `${e.description.slice(0, SKILL_MENU_DESC_CHARS)}…`
|
|
330
|
+
: e.description;
|
|
331
|
+
items.push({ name: `/${e.name}`, description: desc });
|
|
332
|
+
}
|
|
278
333
|
const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
|
|
279
334
|
const pushSkill = (s, shown, more) => {
|
|
280
335
|
const entry = `/skill:${s.name}`;
|
|
@@ -329,6 +384,8 @@ export function commandUsage(name) {
|
|
|
329
384
|
return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
|
|
330
385
|
case "/rename":
|
|
331
386
|
return RENAME_USAGE;
|
|
387
|
+
case "/goal":
|
|
388
|
+
return GOAL_USAGE;
|
|
332
389
|
default:
|
|
333
390
|
return null;
|
|
334
391
|
}
|
|
@@ -543,14 +600,14 @@ export function helpListText() {
|
|
|
543
600
|
`\nPlan mode is the read-only mode for risky work: explore with read/grep/glob/webfetch/websearch/todos/ask_question (all run free) while write/edit/bash are blocked pre-execution with a replan note (never a prompt, never silent — the ⚙ audit line still renders). Scoped /deny rules still win in plan mode; /allow, /trust, yolo, [a]lways, and skill grants cannot punch through it (/trust while in plan stays read-only with a notice — Tab out first). Exiting plan is the human approval: Tab from plan mode returns to normal (never yolo) and the todowrite checklist recorded while planning carries into implementation.` +
|
|
544
601
|
`\n/trust toggles the session trust tier: with trust on, write/edit/bash auto-approve (one approval covers the whole task) without global yolo. Default off, normal mode stays the default; in-memory only, never saved. Every auto-approved call still renders its ⚙ line. The approval prompt also offers [t]rust-all mid-run; [n]/Esc still denies one call, Ctrl+C (or Esc while busy) still cancels the whole turn.` +
|
|
545
602
|
`\n/allow <tool[:glob]> pre-approves matching write/edit/bash calls this session (no prompt; e.g. /allow bash:npm test*, /allow write:src/**; bare /allow bash matches any args). /deny <tool[:glob]> refuses matching calls before execution — the model sees the standard denial result and replans. Deny wins over /trust, yolo, [a]lways, and skill grants. Every auto-approved call still renders its ⚙ line. Rules are in-memory only (like /trust, never saved); /rules lists them, /rules clear wipes them.` +
|
|
546
|
-
`\nToken totals accumulate per session from API-reported usage only: the status line shows \`token: n/a\` until the API reports usage (never estimated, never 0-by-default); with usage it shows \`token: (P%) NK\` — NK is the cumulative session spend in K, P% is the CURRENT context load over the model's verified window (last POST
|
|
547
|
-
`\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~
|
|
603
|
+
`\nToken totals accumulate per session from API-reported usage only: the status line shows \`token: n/a\` until the API reports usage (never estimated, never 0-by-default); with usage it shows \`token: (P%) NK\` — NK is the cumulative session spend in K, P% is the CURRENT context load over the model's verified window (last POST input tokens incl. prefix cache, else the 4ch/token estimate; models with no verified window show a bare \`token: NK\`, never an invented percent). /clear keeps the totals; /new resets them.` +
|
|
604
|
+
`\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~20000 estimated tokens, tool outputs capped at 2000 chars). Tiny history (≤1 user turn) reports \`(nothing to compact)\`. Works for unknown-window models (estimate only for the tail split).` +
|
|
605
|
+
`\n/goal <objective>: pin one session goal (setting one replaces any live goal and resets its counters). Bare /goal shows it with cumulative stats (turns · requests · tokens · work). /goal pause halts the run but keeps the objective and stats; /goal resume re-arms it — idle starts a turn with the continuation text, busy resumes when the current turn ends. /goal clear ends it. The run has no turn cap: it continues turn-to-turn until paused, cleared, a complete/blocked verdict, or a thrown failure. Cancel and spent step/tool-call budgets pause (never clear). The model reports each turn via update_goal (continue with the next action, or complete/blocked with a reason); a report-less turn gets one bounded judge call when configured, otherwise continues — an unclear or failed judge pauses with the goal preserved. Three consecutive repeated tool results redirect with a replan nudge (the goal stays active). A complete with unverified code or open todos continues instead of stopping; blocked stops unconditionally (declared-unverifiable checks print openly in the verdict, never gate). /clear and /new end the goal; the live goal rides every session save with its stats intact (resume and session switches restore it; corrupt data loads as no goal). Compaction appends a Goal: line (text, state, stats, open todos) to the summary as the model's context backstop.` +
|
|
548
606
|
`\nAuto-compact: after every completed turn the load is checked; on known-window models with load/window ≥ ${Math.round(COMPACT_PCT_DEFAULT * 100)}% (env ATOM_COMPACT_PCT percent, clamped 50–95, invalid→default) history auto-compacts before the next turn. Unknown-window models never auto-compact — use /compact manually.` +
|
|
549
607
|
`\nThrash guard: 3 auto-compactions without the load dropping below threshold disables auto for the session with \`(auto-compact thrashing — disabled, use /compact or /clear)\`; manual /compact still works and resets the counter on success.` +
|
|
550
608
|
`\n/provider: pick kilo|opencode-zen|openai|anthropic|deepseek|mistral|google-gemini|openai-compatible, paste a key once (stored in ~/.atom/auth.json, env wins). Kilo is the default: its free :free models (e.g. kilo-auto/free) work with no key; a Kilo key unlocks the full catalog. Switching provider keeps session history text; system prompt stays.` +
|
|
551
|
-
`\n/effort options:
|
|
552
|
-
`\
|
|
553
|
-
`\nGating: reasoning_effort is sent ONLY when effort != Default AND the model is one of ${[...REASONING_EFFORT_SUPPORTED_MODELS].join(", ")} AND the provider is opencode-zen; otherwise omitted (setting kept, warning shown, status shows (unsupported)). Effort persists across /model switches.` +
|
|
609
|
+
`\n/effort options: Auto/Low/Medium/High/Max. Auto omits the knob (the model decides); anything else sends it — reasoning_effort on OpenAI-chat providers (every provider, every model), a thinking budget on Anthropic, a thinkingLevel on Gemini (Max rides high).` +
|
|
610
|
+
`\nUnsupported is server-authoritative, never preemptive: a model that truly lacks the knob fails the POST with a 400 naming it, and the turn retries once without it (warning shown, setting kept, status never invents "(unsupported)"). Effort persists across /model switches.` +
|
|
554
611
|
`\n/resume: restores the last saved session (turns, history, provider/model/effort/mode, usage totals). The conversation never auto-restores — sending a message without /resume starts fresh, and the next completed turn overwrites the save. Your provider/model/effort picks DO persist across restarts automatically (saved on every completed turn and on clean exit; explicit OPENCODE_ZEN_MODEL wins over the saved model). /clear clears the live session only (the save keeps the pre-clear state until the next completed turn overwrites it). /new saves first, then starts a brand-new session (conversation + counters reset, settings kept) — so /resume right after /new restores the pre-/new conversation. Split: /clear = wipe transcript, keep counters; /new = full fresh conversation + counters reset, previous kept for /resume.` +
|
|
555
612
|
`\nSession autosave: every completed turn (and clean exit, plus after each successful compaction) writes ~/.atom/session.json (0600 POSIX, may contain pasted secrets — never commit it); failed/cancelled turns never touch it; a corrupt save loads as "(saved session unreadable — starting fresh)".` +
|
|
556
613
|
`\nBusy status shows the live phase plus elapsed seconds in the status line (· thinking… 4s); >3s without token/tool/phase activity adds a dim waiting… hint (status-bar only, never saved). ` +
|
|
@@ -566,8 +623,8 @@ export function helpListText() {
|
|
|
566
623
|
// as `token: n/a` — never 0, which would imply measurement). The segment
|
|
567
624
|
// itself lives in ./context-windows.js (single source for the exact
|
|
568
625
|
// `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
|
|
569
|
-
// surface. P% tracks CURRENT context load (last
|
|
570
|
-
// 4ch/token estimate); NK tracks cumulative session spend.
|
|
626
|
+
// surface. P% tracks CURRENT context load (last POST input tokens incl.
|
|
627
|
+
// cache, else 4ch/token estimate); NK tracks cumulative session spend.
|
|
571
628
|
function formatKEst(chars) {
|
|
572
629
|
return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
|
|
573
630
|
}
|
|
@@ -582,7 +639,7 @@ const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
|
|
|
582
639
|
// execution (hostile-perf suite asserts token paints and ticks stay out of
|
|
583
640
|
// here — only real state transitions may run the orchestrator).
|
|
584
641
|
export const appRenderProbe = { count: 0 };
|
|
585
|
-
export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
|
|
642
|
+
export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, extensionsLockdown, enableExtensions, disableExtensions, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
|
|
586
643
|
appRenderProbe.count += 1;
|
|
587
644
|
const { exit } = useApp();
|
|
588
645
|
// Saved preferences (provider/model/effort + resolved key/endpoint), loaded
|
|
@@ -777,12 +834,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
777
834
|
}
|
|
778
835
|
// Reasoning-effort picker (/effort): same pattern as the /model picker
|
|
779
836
|
// (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
|
|
780
|
-
// else the atom.json default, else
|
|
837
|
+
// else the atom.json default, else Auto. normalizeEffort keeps pre-auto
|
|
838
|
+
// "default" values (old saves/configs) working.
|
|
781
839
|
const [selectingEffort, setSelectingEffort] = useState(false);
|
|
782
840
|
const [effortIndex, setEffortIndex] = useState(0);
|
|
783
841
|
const effortIndexRef = useRef(0);
|
|
784
|
-
const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "
|
|
785
|
-
const effortRef = useRef(prefs?.effort ?? atomConfig.reasoningEffort ?? "
|
|
842
|
+
const [effort, setEffort] = useState(normalizeEffort(prefs?.effort ?? atomConfig.reasoningEffort ?? "auto"));
|
|
843
|
+
const effortRef = useRef(normalizeEffort(prefs?.effort ?? atomConfig.reasoningEffort ?? "auto"));
|
|
786
844
|
// /provider picker + key/baseURL prompts (same keyboard pattern).
|
|
787
845
|
const [selectingProvider, setSelectingProvider] = useState(false);
|
|
788
846
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
@@ -930,15 +988,97 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
930
988
|
showThinkingRef.current = next;
|
|
931
989
|
setShowThinking(next);
|
|
932
990
|
}
|
|
933
|
-
// /autoscroll (session-only, default
|
|
934
|
-
// first busy append (the `↓ N new`
|
|
935
|
-
//
|
|
936
|
-
const [autoScroll, setAutoScroll] = useState(
|
|
937
|
-
const autoScrollRef = useRef(
|
|
991
|
+
// /autoscroll (session-only, default on). On follows new output as it
|
|
992
|
+
// arrives; off freezes the view at the first busy append (the `↓ N new`
|
|
993
|
+
// indicator offers the jump back). Bare /autoscroll toggles.
|
|
994
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
995
|
+
const autoScrollRef = useRef(true);
|
|
938
996
|
function setAutoScrollBoth(next) {
|
|
939
997
|
autoScrollRef.current = next;
|
|
940
998
|
setAutoScroll(next);
|
|
941
999
|
}
|
|
1000
|
+
// Session goal (ticket 01, in-memory only): at most one active goal per
|
|
1001
|
+
// session. Ordinary chat messages never touch it — only /goal does.
|
|
1002
|
+
const [goal, setGoal] = useState(null);
|
|
1003
|
+
const goalRef = useRef(null);
|
|
1004
|
+
function setGoalBoth(next) {
|
|
1005
|
+
goalRef.current = next;
|
|
1006
|
+
setGoal(next);
|
|
1007
|
+
}
|
|
1008
|
+
// Goal stat patch (ticket 02): accumulates into the LIVE goal's counters,
|
|
1009
|
+
// preserving objective/active. Drops when no goal exists (e.g. cleared
|
|
1010
|
+
// mid-turn — the slice belongs to no goal anymore). Never throws.
|
|
1011
|
+
function patchGoalStats(patch) {
|
|
1012
|
+
try {
|
|
1013
|
+
const g = goalRef.current;
|
|
1014
|
+
if (!g)
|
|
1015
|
+
return;
|
|
1016
|
+
setGoalBoth({ ...g, stats: patch(g.stats ?? emptyGoalStats()) });
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
// accounting never breaks the turn
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Usage accumulator (session totals + goal slice, real reports only): the
|
|
1023
|
+
// turn's onUsage below and the goal-judge runner share it so judge spend
|
|
1024
|
+
// bills exactly like model spend. Every reporting POST accumulates
|
|
1025
|
+
// (tool-round POSTs and successful retries each count once — each was
|
|
1026
|
+
// billed; failed attempts report nothing, so nothing is deduped).
|
|
1027
|
+
// usageTotals drives NK only, never P%.
|
|
1028
|
+
// updateLoad pins the load metric (P% source) to main-context POSTs: the
|
|
1029
|
+
// judge's summary-sized request must not move it (same rule as the
|
|
1030
|
+
// compaction summary POST — load tracks the main context).
|
|
1031
|
+
function accumulateUsage(u, updateLoad = true) {
|
|
1032
|
+
const prev = usageRef.current ?? {};
|
|
1033
|
+
const next = { ...prev };
|
|
1034
|
+
if (u.prompt_tokens !== undefined) {
|
|
1035
|
+
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
1036
|
+
// Load metric source: last POST's reported input-side tokens
|
|
1037
|
+
// (prompt_tokens, cache-inclusive for exclusive-cache providers) —
|
|
1038
|
+
// the per-POST value, NOT the accumulated total.
|
|
1039
|
+
if (updateLoad)
|
|
1040
|
+
lastPromptTokensRef.current = u.prompt_tokens;
|
|
1041
|
+
}
|
|
1042
|
+
if (u.completion_tokens !== undefined) {
|
|
1043
|
+
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
1044
|
+
}
|
|
1045
|
+
if (u.total_tokens !== undefined) {
|
|
1046
|
+
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
1047
|
+
}
|
|
1048
|
+
// Prefix-cache counters accumulate like spend (real reports only;
|
|
1049
|
+
// absent fields mean "not reported", never zero).
|
|
1050
|
+
if (u.cacheReadTokens !== undefined) {
|
|
1051
|
+
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
1052
|
+
}
|
|
1053
|
+
if (u.cacheWriteTokens !== undefined) {
|
|
1054
|
+
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
1055
|
+
}
|
|
1056
|
+
setUsageBoth(next);
|
|
1057
|
+
// Goal token slice (ticket 02): every reporting POST also accrues
|
|
1058
|
+
// to the live goal's spend — real reports only, and the session
|
|
1059
|
+
// totals above are untouched.
|
|
1060
|
+
try {
|
|
1061
|
+
if (goalRef.current) {
|
|
1062
|
+
const slice = goalTokensForUsage(u);
|
|
1063
|
+
if (slice > 0)
|
|
1064
|
+
patchGoalStats((s) => ({ ...s, tokens: s.tokens + slice }));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// accounting never breaks the turn
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// Loop-owned pause (ticket 02): cancel and spent budgets pause with a
|
|
1072
|
+
// visible notice — the objective and stats survive, so /goal resume
|
|
1073
|
+
// continues where the run stopped. No-op when absent/already paused
|
|
1074
|
+
// (pause fires exactly once per run).
|
|
1075
|
+
function pauseGoalWithNotice(notice) {
|
|
1076
|
+
const g = goalRef.current;
|
|
1077
|
+
if (!g || !g.active)
|
|
1078
|
+
return;
|
|
1079
|
+
setGoalBoth({ ...g, active: false });
|
|
1080
|
+
pushInfo(notice);
|
|
1081
|
+
}
|
|
942
1082
|
// Skill registry (cached metadata): one instance per App, scoped to the
|
|
943
1083
|
// same dirs the suite injects via skillDirs. Every discovery path below
|
|
944
1084
|
// reads through it — refresh() revalidates by stat (mtime+size) and only
|
|
@@ -1017,6 +1157,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1017
1157
|
const askSelIndexRef = useRef(0);
|
|
1018
1158
|
const [askCustom, setAskCustom] = useState("");
|
|
1019
1159
|
const askCustomRef = useRef("");
|
|
1160
|
+
// Extension UI surface (ticket 10): the version bump re-renders on every
|
|
1161
|
+
// runtime UI mutation (segment/widget/notice/dialog); the dialog owns its
|
|
1162
|
+
// own select/custom state mirroring the question modal above.
|
|
1163
|
+
const [, bumpExtUI] = useState(0);
|
|
1164
|
+
const [extDlgSel, setExtDlgSel] = useState(0);
|
|
1165
|
+
const extDlgSelRef = useRef(0);
|
|
1166
|
+
const [extDlgCustom, setExtDlgCustom] = useState("");
|
|
1167
|
+
const extDlgCustomRef = useRef("");
|
|
1020
1168
|
const [busy, setBusy] = useState(false);
|
|
1021
1169
|
const busyRef = useRef(false);
|
|
1022
1170
|
// Per-turn cancellation (Ctrl+C mid-loop): abort stops after the current
|
|
@@ -1052,8 +1200,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1052
1200
|
const [usageTotals, setUsageTotals] = useState(null);
|
|
1053
1201
|
// Synchronous mirror of `usageTotals` (same save-time reason as turnsRef).
|
|
1054
1202
|
const usageRef = useRef(null);
|
|
1055
|
-
// Current context load driving status P% (last POST
|
|
1056
|
-
// available, else the 4ch/token estimate). Null until the first turn
|
|
1203
|
+
// Current context load driving status P% (last POST input tokens incl.
|
|
1204
|
+
// cache when available, else the 4ch/token estimate). Null until the first turn
|
|
1057
1205
|
// completes. NK stays cumulative; P must NOT use the cumulative total.
|
|
1058
1206
|
const [contextLoad, setContextLoad] = useState(null);
|
|
1059
1207
|
const contextLoadRef = useRef(null);
|
|
@@ -1069,8 +1217,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1069
1217
|
setGitInfo(null);
|
|
1070
1218
|
}
|
|
1071
1219
|
}
|
|
1072
|
-
// Last POST's reported
|
|
1073
|
-
//
|
|
1220
|
+
// Last POST's reported input-side tokens (prompt_tokens, normalized at
|
|
1221
|
+
// parse time to include exclusive cache counters) — the load metric source.
|
|
1222
|
+
// Summary-request usage never touches this — only main-loop POSTs do.
|
|
1074
1223
|
const lastPromptTokensRef = useRef(undefined);
|
|
1075
1224
|
// Thrash guard: consecutive auto-compactions without the load dropping
|
|
1076
1225
|
// below threshold. At 3, auto disables for the session (manual still
|
|
@@ -1103,9 +1252,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1103
1252
|
});
|
|
1104
1253
|
const sessionTitleRef = useRef(sessionTitle);
|
|
1105
1254
|
// Reasoning label from response metadata (via onReasoning). The status
|
|
1106
|
-
// line shows the session effort when non-
|
|
1107
|
-
//
|
|
1108
|
-
// Default it shows this label, falling back to `default`.
|
|
1255
|
+
// line shows the session effort when non-Auto; when effort is Auto it
|
|
1256
|
+
// shows this label, falling back to `auto`.
|
|
1109
1257
|
const [reasoning, setReasoning] = useState(null);
|
|
1110
1258
|
// Live streaming state: the growing assistant text (onToken) and the
|
|
1111
1259
|
// thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
|
|
@@ -1318,7 +1466,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1318
1466
|
// below re-wrap via trackHistory (never assign a raw array here).
|
|
1319
1467
|
const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
|
|
1320
1468
|
// Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
|
|
1321
|
-
// pinned to history[0] (the
|
|
1469
|
+
// pinned to history[0] (the system prompt), NEVER
|
|
1322
1470
|
// to user content. Refreshed once per turn in submit() + after doResume, so
|
|
1323
1471
|
// the loop's many POSTs reuse one block (no per-POST shell-outs).
|
|
1324
1472
|
// Failure-silent via withEnvBlock (missing git → block shrinks).
|
|
@@ -1474,6 +1622,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1474
1622
|
// in that gap; runLoopWithChat checks the signal before the first POST).
|
|
1475
1623
|
useEffect(() => {
|
|
1476
1624
|
return () => {
|
|
1625
|
+
// Extension host teardown: subscribers observe the shutdown, then the
|
|
1626
|
+
// runtime is dropped. Best-effort and synchronous from React's side
|
|
1627
|
+
// (emit records its own errors, never rejects).
|
|
1628
|
+
// Extension UI teardown (ticket 10): remove every contribution with
|
|
1629
|
+
// zero residue first, then let subscribers observe the shutdown —
|
|
1630
|
+
// anything a shutdown handler re-registers lands in a dropped
|
|
1631
|
+
// runtime (ref nulled below) and never paints.
|
|
1632
|
+
try {
|
|
1633
|
+
extRuntimeRef.current?.disposeUI();
|
|
1634
|
+
}
|
|
1635
|
+
catch {
|
|
1636
|
+
// ignore
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
void extRuntimeRef.current?.emit("session_shutdown", { reason: "quit" });
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
// ignore
|
|
1643
|
+
}
|
|
1644
|
+
extRuntimeRef.current = null;
|
|
1477
1645
|
// Local observability: close the session trace on unmount (covers
|
|
1478
1646
|
// every exit path — /exit, Ctrl+C idle, test teardown) and flush.
|
|
1479
1647
|
// Best-effort, never throws; idempotent with closeTelemetry callers.
|
|
@@ -1606,9 +1774,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1606
1774
|
askCustomRef.current = next;
|
|
1607
1775
|
setAskCustom(next);
|
|
1608
1776
|
}
|
|
1777
|
+
function setExtDlgSelBoth(next) {
|
|
1778
|
+
extDlgSelRef.current = next;
|
|
1779
|
+
setExtDlgSel(next);
|
|
1780
|
+
}
|
|
1781
|
+
function setExtDlgCustomBoth(next) {
|
|
1782
|
+
extDlgCustomRef.current = next;
|
|
1783
|
+
setExtDlgCustom(next);
|
|
1784
|
+
}
|
|
1609
1785
|
function setEffortBoth(next) {
|
|
1610
|
-
|
|
1611
|
-
|
|
1786
|
+
// Central normalization point: every restore path (saved prefs, session
|
|
1787
|
+
// switch, picker) funnels through here, so a legacy "default" can never
|
|
1788
|
+
// linger in live state.
|
|
1789
|
+
const canonical = normalizeEffort(next);
|
|
1790
|
+
effortRef.current = canonical;
|
|
1791
|
+
setEffort(canonical);
|
|
1612
1792
|
}
|
|
1613
1793
|
function setEffortIndexBoth(next) {
|
|
1614
1794
|
effortIndexRef.current = next;
|
|
@@ -1830,7 +2010,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1830
2010
|
: def.defaultModel);
|
|
1831
2011
|
setModelBoth(nextModel);
|
|
1832
2012
|
setProviderBoth(pickedId);
|
|
1833
|
-
// Provider switch resets the load latch: the last reported
|
|
2013
|
+
// Provider switch resets the load latch: the last reported input tokens
|
|
1834
2014
|
// belonged to the old provider/model tokenizer, so the estimate applies
|
|
1835
2015
|
// until the new provider reports (usageTotals spend is untouched).
|
|
1836
2016
|
resetContextLoadToEstimate();
|
|
@@ -1881,7 +2061,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1881
2061
|
setAutoDisabled(next);
|
|
1882
2062
|
}
|
|
1883
2063
|
// Recompute contextLoad after a committed turn (or compaction): last
|
|
1884
|
-
// POST
|
|
2064
|
+
// POST input tokens (incl. cache) when available, else the 4ch/token estimate.
|
|
1885
2065
|
function refreshContextLoad() {
|
|
1886
2066
|
// No usage yet → no load (status keeps `token: n/a`).
|
|
1887
2067
|
if (!usageRef.current) {
|
|
@@ -1892,7 +2072,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1892
2072
|
setContextLoadBoth(load);
|
|
1893
2073
|
return load;
|
|
1894
2074
|
}
|
|
1895
|
-
// Load-reset contract (hold-last-known): the reported
|
|
2075
|
+
// Load-reset contract (hold-last-known): the reported input tokens survive
|
|
1896
2076
|
// silent POSTs — estimates never override a fresher report — and reset ONLY
|
|
1897
2077
|
// here: compaction, /clear, resume, and model/provider switch. After a reset
|
|
1898
2078
|
// the chars/4 estimate applies until the next report arrives.
|
|
@@ -1908,9 +2088,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1908
2088
|
function pushInfo(content) {
|
|
1909
2089
|
appendTurns({ role: "tool", content });
|
|
1910
2090
|
}
|
|
2091
|
+
// Extension slash-command execution (ticket 04): handlers run outside
|
|
2092
|
+
// the model turn loop — no history writes, no telemetry turn, no busy
|
|
2093
|
+
// flag. say() posts transcript turns only (pushInfo), so a throwing
|
|
2094
|
+
// handler surfaces one clean error line and leaves the model session
|
|
2095
|
+
// untouched. The context is bound to the runtime generation at launch:
|
|
2096
|
+
// a session replacement mid-command makes further ctx use throw into
|
|
2097
|
+
// the same clean-error path.
|
|
2098
|
+
async function runExtensionCommandFromApp(name, args) {
|
|
2099
|
+
if (extCommandRunningRef.current) {
|
|
2100
|
+
pushInfo("(an extension command is already running — wait for its prompt)");
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
extCommandRunningRef.current = true;
|
|
2104
|
+
try {
|
|
2105
|
+
const runtime = extRuntimeRef.current;
|
|
2106
|
+
const gen = runtime?.generation ?? 0;
|
|
2107
|
+
const result = await runExtensionCommand(name, args, {
|
|
2108
|
+
cwd: storeCwd(),
|
|
2109
|
+
askUser,
|
|
2110
|
+
getSession: () => ({
|
|
2111
|
+
id: activeSessionIdRef.current,
|
|
2112
|
+
title: sessionTitleRef.current,
|
|
2113
|
+
turnCount: turnsRef.current.length,
|
|
2114
|
+
}),
|
|
2115
|
+
say: (message) => {
|
|
2116
|
+
pushInfo(message);
|
|
2117
|
+
},
|
|
2118
|
+
checkStale: () => {
|
|
2119
|
+
if (runtime && runtime.generation !== gen) {
|
|
2120
|
+
throw new Error("extension context is stale after a session replacement — rerun the command for fresh state");
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
});
|
|
2124
|
+
if (!result.ok)
|
|
2125
|
+
pushInfo(result.error);
|
|
2126
|
+
}
|
|
2127
|
+
finally {
|
|
2128
|
+
extCommandRunningRef.current = false;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
1911
2131
|
// The session's ContextManager: window-derived budgets for the active
|
|
1912
|
-
// model
|
|
1913
|
-
// (pure math, no I/O beyond
|
|
2132
|
+
// model plus measured tool schemas. Built fresh per call
|
|
2133
|
+
// (pure math, no I/O beyond compactPct) so it always sees
|
|
1914
2134
|
// the current model; history is measured live on every use. The schema size
|
|
1915
2135
|
// is memoized once — TOOL_DEFINITIONS never changes at runtime, so every
|
|
1916
2136
|
// turn must not re-serialize 15KB to ask.
|
|
@@ -1941,6 +2161,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1941
2161
|
const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
|
|
1942
2162
|
? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
|
|
1943
2163
|
: `load: ${formatKEst(u.historyChars)} (no verified window — auto-compact off, use /compact manually)`;
|
|
2164
|
+
// Window-derived allowance is informational only: history is never
|
|
2165
|
+
// truncated, compaction is the only pressure valve.
|
|
2166
|
+
const allowanceLine = b.historyChars !== undefined && b.windowTokens !== undefined
|
|
2167
|
+
? `allowance: ~${(b.historyChars / 1000).toFixed(0)}K chars of history fit the ${(b.windowTokens / 1000).toFixed(0)}K verified window`
|
|
2168
|
+
: `allowance: no verified window — history uncapped, use /compact manually`;
|
|
1944
2169
|
const cfg = atomConfigLoad;
|
|
1945
2170
|
const cfgSources = cfg.sources.project && cfg.sources.global
|
|
1946
2171
|
? "project + global"
|
|
@@ -1976,7 +2201,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1976
2201
|
`skill injections live in history: ${skillLoads}\n` +
|
|
1977
2202
|
`${cfgLine}\n` +
|
|
1978
2203
|
`${cacheLine}\n` +
|
|
1979
|
-
`${loadLine}
|
|
2204
|
+
`${loadLine}\n` +
|
|
2205
|
+
`${allowanceLine}`);
|
|
1980
2206
|
}
|
|
1981
2207
|
// Load a resolved skill into the session (tickets 03/06) with
|
|
1982
2208
|
// progressive-disclosure tiers (Claude-Code-style):
|
|
@@ -2054,6 +2280,37 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2054
2280
|
// Failures/cancels never reach here (same rollback rule as legacy).
|
|
2055
2281
|
// Active id lives in a ref only — never a session list in runtime state.
|
|
2056
2282
|
const activeSessionIdRef = useRef(null);
|
|
2283
|
+
// Extension host (ticket: extension system 01): the loaded runtime lives
|
|
2284
|
+
// in a ref so session replacements can invalidate it without re-render.
|
|
2285
|
+
// Null until the mount-time load completes (or when nothing is installed).
|
|
2286
|
+
const extRuntimeRef = useRef(null);
|
|
2287
|
+
// An extension slash command in flight owns the question modal while
|
|
2288
|
+
// prompting (single slot shared with ask_question): submit routes new
|
|
2289
|
+
// model turns and nested extension commands aside with a notice until
|
|
2290
|
+
// this clears, so resolvers can never clobber each other.
|
|
2291
|
+
const extCommandRunningRef = useRef(false);
|
|
2292
|
+
/**
|
|
2293
|
+
* Session-replacement boundary for extensions: previously handed-out API
|
|
2294
|
+
* objects go stale (loud on use), then session_start fires for the new
|
|
2295
|
+
* lineage. This is the sanctioned post-replacement continuation — work
|
|
2296
|
+
* that must continue after a replacement runs in session_start handlers
|
|
2297
|
+
* via their fresh API, never via a captured pre-replacement handle (which
|
|
2298
|
+
* throws). Never throws; a missing runtime is a no-op. Callers bind the
|
|
2299
|
+
* new session id via runtime.setSessionId BEFORE calling, so start
|
|
2300
|
+
* handlers observe the new session's extension state.
|
|
2301
|
+
*/
|
|
2302
|
+
function replaceExtensionContext(reason) {
|
|
2303
|
+
const runtime = extRuntimeRef.current;
|
|
2304
|
+
if (!runtime)
|
|
2305
|
+
return;
|
|
2306
|
+
try {
|
|
2307
|
+
runtime.invalidate(`extension context is stale after session ${reason} — use the fresh API passed to your session_start handler`);
|
|
2308
|
+
}
|
|
2309
|
+
catch {
|
|
2310
|
+
// invalidate never throws by contract; defensive only.
|
|
2311
|
+
}
|
|
2312
|
+
void runtime.emit("session_start", { reason });
|
|
2313
|
+
}
|
|
2057
2314
|
function storeCwd() {
|
|
2058
2315
|
try {
|
|
2059
2316
|
return process.cwd();
|
|
@@ -2113,6 +2370,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2113
2370
|
return rest;
|
|
2114
2371
|
}),
|
|
2115
2372
|
usageTotals: usageRef.current,
|
|
2373
|
+
// Piggyback: the live goal rides every store persist (same call
|
|
2374
|
+
// as every completed turn — no new save cadence). Switching away
|
|
2375
|
+
// snapshots this session's goal into its own record first, so a
|
|
2376
|
+
// switch back restores it and sessions never leak goals.
|
|
2377
|
+
goal: serializeGoalForPersist(goalRef.current),
|
|
2116
2378
|
provider: providerRef.current,
|
|
2117
2379
|
model: modelRef.current,
|
|
2118
2380
|
effort: effortRef.current,
|
|
@@ -2124,12 +2386,130 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2124
2386
|
// ignore disk errors (in-memory session still applies)
|
|
2125
2387
|
}
|
|
2126
2388
|
}
|
|
2389
|
+
// One boot notice for the extension host (ticket 07): the existing
|
|
2390
|
+
// loaded/failed line, plus one line per skip reason so declined/untrusted/
|
|
2391
|
+
// locked-down/disabled extensions stay visible with how to enable them.
|
|
2392
|
+
// Silent when nothing loaded, failed, or skipped.
|
|
2393
|
+
function announceExtensionRuntime(runtime) {
|
|
2394
|
+
if (runtime.loaded.length > 0 || runtime.errors.length > 0) {
|
|
2395
|
+
const names = runtime.loaded.map((e) => e.name).join(", ");
|
|
2396
|
+
const problems = runtime.errors.map((e) => `${e.path}: ${e.error}`).join("; ");
|
|
2397
|
+
pushInfo(`(extensions: ${runtime.loaded.length} loaded${names ? ` (${names})` : ""}` +
|
|
2398
|
+
`${problems ? `; ${runtime.errors.length} failed: ${problems}` : ""})`);
|
|
2399
|
+
}
|
|
2400
|
+
const byReason = new Map();
|
|
2401
|
+
for (const s of runtime.skipped) {
|
|
2402
|
+
const list = byReason.get(s.reason);
|
|
2403
|
+
if (list)
|
|
2404
|
+
list.push(s.name);
|
|
2405
|
+
else
|
|
2406
|
+
byReason.set(s.reason, [s.name]);
|
|
2407
|
+
}
|
|
2408
|
+
const whyFor = (reason) => {
|
|
2409
|
+
switch (reason) {
|
|
2410
|
+
case "lockdown":
|
|
2411
|
+
return "lockdown is on (--no-extensions)";
|
|
2412
|
+
case "untrusted-project":
|
|
2413
|
+
return "the project is not trusted";
|
|
2414
|
+
case "disabled":
|
|
2415
|
+
return "they match a disable pattern";
|
|
2416
|
+
case "not-enabled":
|
|
2417
|
+
return "they match no enable pattern";
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2420
|
+
const hintFor = (reason) => {
|
|
2421
|
+
switch (reason) {
|
|
2422
|
+
case "lockdown":
|
|
2423
|
+
return "start without --no-extensions to load them";
|
|
2424
|
+
case "untrusted-project":
|
|
2425
|
+
return "trust the project when asked on next startup to load them";
|
|
2426
|
+
case "disabled":
|
|
2427
|
+
return 'remove the --disable-extension / atom.json "extensions.disabled" pattern to load them';
|
|
2428
|
+
case "not-enabled":
|
|
2429
|
+
return 'match them with --enable-extension / atom.json "extensions.enabled" to load them';
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
for (const [reason, names] of byReason) {
|
|
2433
|
+
const unique = [...new Set(names)];
|
|
2434
|
+
pushInfo(`(extensions: ${unique.length} skipped (${unique.join(", ")}) — ${whyFor(reason)}; ${hintFor(reason)})`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2127
2437
|
// Mount bootstrap: claim/create the active session before any turn can
|
|
2128
2438
|
// persist, so every normal conversation belongs to a durable session.
|
|
2129
2439
|
// Startup never auto-restores conversation state (fresh + legacy hint,
|
|
2130
2440
|
// matching current UX) — this only ensures the record exists.
|
|
2131
2441
|
useEffect(() => {
|
|
2132
2442
|
ensureStoreSession();
|
|
2443
|
+
// Extension host boot (best-effort, never blocks render): trust-gated
|
|
2444
|
+
// (ticket 07). Global-scope extensions are user-owned (implicitly
|
|
2445
|
+
// trusted, like the user's own config); project-scope + explicit-path
|
|
2446
|
+
// extensions never execute until the project is trusted — the user is
|
|
2447
|
+
// asked once via the question modal (declining, or Esc, leaves them fully
|
|
2448
|
+
// inert with a visible notice; the grant persists per project dir, so a
|
|
2449
|
+
// decline simply asks again next boot). Lockdown (--no-extensions) skips
|
|
2450
|
+
// the prompt and boots with zero third-party extensions. A loaded runtime
|
|
2451
|
+
// still records per-extension errors — loadExtensions never throws here.
|
|
2452
|
+
void (async () => {
|
|
2453
|
+
const cwd = storeCwd();
|
|
2454
|
+
const cfgExtensions = atomConfig.extensions;
|
|
2455
|
+
// CLI patterns win over atom.json when set (same CLI-over-config
|
|
2456
|
+
// layering as every other value); the project/global config merge
|
|
2457
|
+
// already applied inside loadAtomConfig.
|
|
2458
|
+
const enabled = enableExtensions !== undefined && enableExtensions.length > 0
|
|
2459
|
+
? enableExtensions
|
|
2460
|
+
: (cfgExtensions?.enabled ?? []);
|
|
2461
|
+
const disabled = disableExtensions !== undefined && disableExtensions.length > 0
|
|
2462
|
+
? disableExtensions
|
|
2463
|
+
: (cfgExtensions?.disabled ?? []);
|
|
2464
|
+
const lockdown = extensionsLockdown === true;
|
|
2465
|
+
const finish = async (trusted) => {
|
|
2466
|
+
const runtime = await loadExtensions({
|
|
2467
|
+
home: authHome,
|
|
2468
|
+
cwd,
|
|
2469
|
+
builtinSlashCommands: SLASH_COMMANDS.map((c) => c.name),
|
|
2470
|
+
projectTrusted: trusted,
|
|
2471
|
+
lockdown,
|
|
2472
|
+
enabledPatterns: enabled,
|
|
2473
|
+
disabledPatterns: disabled,
|
|
2474
|
+
// The TUI fulfills extension dialogs (ticket 10); headless modes
|
|
2475
|
+
// never load extensions, so they stay inert by construction.
|
|
2476
|
+
interactive: true,
|
|
2477
|
+
});
|
|
2478
|
+
extRuntimeRef.current = runtime;
|
|
2479
|
+
// Live UI surface: every segment/widget/notice/dialog mutation
|
|
2480
|
+
// re-renders (segments update across turns with no other trigger).
|
|
2481
|
+
runtime.subscribeUI(() => {
|
|
2482
|
+
bumpExtUI((v) => v + 1);
|
|
2483
|
+
});
|
|
2484
|
+
// Bind the store session BEFORE the startup emit, so session_start
|
|
2485
|
+
// handlers observe the reloaded session's extension state (ticket 05:
|
|
2486
|
+
// per-session state restores on reload through the record metadata).
|
|
2487
|
+
runtime.setSessionId(activeSessionIdRef.current);
|
|
2488
|
+
announceExtensionRuntime(runtime);
|
|
2489
|
+
return runtime.emit("session_start", { reason: "startup" });
|
|
2490
|
+
};
|
|
2491
|
+
if (!lockdown) {
|
|
2492
|
+
const gated = discoverExtensionEntries({ home: authHome, cwd }).filter((e) => e.scope !== "global");
|
|
2493
|
+
if (gated.length > 0 && !isProjectTrusted(cwd, authHome)) {
|
|
2494
|
+
const names = [...new Set(gated.map((e) => resolveExtensionName(e.path)))];
|
|
2495
|
+
let answer;
|
|
2496
|
+
try {
|
|
2497
|
+
answer = await askUser(projectTrustQuestion(names), ["Trust and load", "Keep disabled"]);
|
|
2498
|
+
}
|
|
2499
|
+
catch {
|
|
2500
|
+
answer = "Keep disabled"; // Esc declines: inert + visible, asked again next boot
|
|
2501
|
+
}
|
|
2502
|
+
const trusted = answer === "Trust and load";
|
|
2503
|
+
if (trusted)
|
|
2504
|
+
grantProjectTrust(cwd, authHome);
|
|
2505
|
+
await finish(trusted);
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
await finish(isProjectTrusted(cwd, authHome));
|
|
2510
|
+
})().catch(() => {
|
|
2511
|
+
// loadExtensions never rejects by contract; defensive only.
|
|
2512
|
+
});
|
|
2133
2513
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2134
2514
|
}, []);
|
|
2135
2515
|
function persistSession() {
|
|
@@ -2140,6 +2520,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2140
2520
|
effort: effortRef.current,
|
|
2141
2521
|
mode: modeRef.current,
|
|
2142
2522
|
usageTotals: usageRef.current,
|
|
2523
|
+
// Piggyback: the live goal rides the legacy save too, so /resume
|
|
2524
|
+
// and restarts bring it back with its cumulative stats intact.
|
|
2525
|
+
goal: goalRef.current,
|
|
2143
2526
|
history: historyRef.current,
|
|
2144
2527
|
turns: turnsRef.current.map((t) => {
|
|
2145
2528
|
const { diff: _dropped, ...rest } = t;
|
|
@@ -2208,6 +2591,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2208
2591
|
// failure: old history untouched + inline error (oversize retry-once
|
|
2209
2592
|
// already handled inside requestCompactSummary, which appends /clear).
|
|
2210
2593
|
// isAuto drives the thrash guard; manual resets the counter on success.
|
|
2594
|
+
// A non-reducing auto outcome (load still at/above threshold, or an
|
|
2595
|
+
// extension veto that changed nothing) counts toward the guard via
|
|
2596
|
+
// bumpAutoStreak, so a standing veto disables auto with the standard
|
|
2597
|
+
// notice instead of re-firing (and re-notifying) after every turn.
|
|
2598
|
+
function bumpAutoStreak() {
|
|
2599
|
+
autoStreakRef.current += 1;
|
|
2600
|
+
if (isThrashDisabled(autoStreakRef.current)) {
|
|
2601
|
+
setAutoDisabledBoth(true);
|
|
2602
|
+
appendTurns({
|
|
2603
|
+
role: "tool",
|
|
2604
|
+
content: "(auto-compact thrashing — disabled, use /compact or /clear)",
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2211
2608
|
async function doCompact(focusText, isAuto) {
|
|
2212
2609
|
if (countUserTurns(historyRef.current) <= 1) {
|
|
2213
2610
|
if (!isAuto)
|
|
@@ -2238,56 +2635,108 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2238
2635
|
}
|
|
2239
2636
|
const baseURL = chatBaseURL(providerRef.current);
|
|
2240
2637
|
try {
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
if (
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2638
|
+
// Extension gate (ticket 09): consulted BEFORE the builtin summary
|
|
2639
|
+
// POST with the reason and the pending head/tail split as read-only
|
|
2640
|
+
// deep copies — a mutating hook cannot corrupt the split below, and a
|
|
2641
|
+
// cancelled attempt returns before EVERY write (history, snapshots,
|
|
2642
|
+
// totals, ledger stay byte-identical). Zero-cost when no hooks are
|
|
2643
|
+
// registered: the snapshot is a single spread and no await runs, so
|
|
2644
|
+
// hook-free auto-compact keeps its timing (the zen.ts context-hook
|
|
2645
|
+
// precedent). Fail-open: a throwing hook records a visible error and
|
|
2646
|
+
// compaction falls back to the builtin summary, never half-compacted.
|
|
2647
|
+
let customSummary = null;
|
|
2648
|
+
const compactHooks = beforeCompactInterceptors();
|
|
2649
|
+
if (compactHooks.length > 0) {
|
|
2650
|
+
const verdict = await applyBeforeCompact(compactHooks, {
|
|
2651
|
+
reason: isAuto ? "auto" : "manual",
|
|
2652
|
+
focusText,
|
|
2653
|
+
head: split.head,
|
|
2654
|
+
tail: split.tail,
|
|
2655
|
+
olderTurnCount: split.olderTurnCount,
|
|
2656
|
+
});
|
|
2657
|
+
if (verdict.cancelled) {
|
|
2658
|
+
pushInfo(verdict.cancelReason
|
|
2659
|
+
? `(compaction cancelled: ${verdict.cancelReason})`
|
|
2660
|
+
: "(compaction cancelled by an extension)");
|
|
2661
|
+
// A vetoed auto-compaction changes nothing, so the load that
|
|
2662
|
+
// triggered it is still above threshold — count it toward the
|
|
2663
|
+
// thrash guard (manual cancels are deliberate one-shots, untouched).
|
|
2664
|
+
if (isAuto)
|
|
2665
|
+
bumpAutoStreak();
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
if (verdict.errors.length > 0) {
|
|
2669
|
+
pushInfo(`(extension compact hook failed — using builtin summary: ${verdict.errors.join("; ")})`);
|
|
2670
|
+
}
|
|
2671
|
+
if (verdict.summary !== null)
|
|
2672
|
+
customSummary = verdict.summary;
|
|
2673
|
+
}
|
|
2674
|
+
// Single injection point: a custom summary replaces the builtin text
|
|
2675
|
+
// here and flows through the SAME post-processing below (goal block +
|
|
2676
|
+
// touched-files append/fit, boundary marker, atomic swap, save,
|
|
2677
|
+
// snapshot clearing) exactly like builtin output — never a parallel
|
|
2678
|
+
// pipeline.
|
|
2679
|
+
const summary = customSummary ??
|
|
2680
|
+
(await requestCompactSummary({
|
|
2681
|
+
provider: providerRef.current,
|
|
2682
|
+
apiKey: submitKey,
|
|
2683
|
+
model: modelRef.current,
|
|
2684
|
+
systemContent,
|
|
2685
|
+
head: split.head,
|
|
2686
|
+
focusText,
|
|
2687
|
+
// Ticket 08: the live goal objective (active or paused) hints the
|
|
2688
|
+
// summarizer to preserve goal-relevant content; undefined keeps
|
|
2689
|
+
// the legacy instruction byte-identical. goalRef survives the
|
|
2690
|
+
// swap below untouched, so post-compact turns read the same live
|
|
2691
|
+
// goal through the loop's getGoal seam.
|
|
2692
|
+
goalObjective: goalRef.current?.objective,
|
|
2693
|
+
baseURL,
|
|
2694
|
+
endpointOverride: activeEndpoint,
|
|
2695
|
+
onUsage: (u) => {
|
|
2696
|
+
// Totals keep accumulating (real summary spend); load source
|
|
2697
|
+
// untouched (summary prompt reflects head size, not new context).
|
|
2698
|
+
const prev = usageRef.current ?? {};
|
|
2699
|
+
const next = { ...prev };
|
|
2700
|
+
if (u.prompt_tokens !== undefined) {
|
|
2701
|
+
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
2702
|
+
}
|
|
2703
|
+
if (u.completion_tokens !== undefined) {
|
|
2704
|
+
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
2705
|
+
}
|
|
2706
|
+
if (u.total_tokens !== undefined) {
|
|
2707
|
+
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
2708
|
+
}
|
|
2709
|
+
if (u.cacheReadTokens !== undefined) {
|
|
2710
|
+
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
2711
|
+
}
|
|
2712
|
+
if (u.cacheWriteTokens !== undefined) {
|
|
2713
|
+
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
2714
|
+
}
|
|
2715
|
+
// Only accumulate when the summary actually reported usage;
|
|
2716
|
+
// an empty onUsage keeps totals byte-identical.
|
|
2717
|
+
if (u.prompt_tokens !== undefined ||
|
|
2718
|
+
u.completion_tokens !== undefined ||
|
|
2719
|
+
u.total_tokens !== undefined ||
|
|
2720
|
+
u.cacheReadTokens !== undefined ||
|
|
2721
|
+
u.cacheWriteTokens !== undefined) {
|
|
2722
|
+
setUsageBoth(next);
|
|
2723
|
+
}
|
|
2724
|
+
// Local observability: compaction spend is session-level (it
|
|
2725
|
+
// summarizes many turns and often lands after its turn ended), so
|
|
2726
|
+
// it is kept separate from per-turn usage. No-op when empty.
|
|
2727
|
+
telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
|
|
2728
|
+
},
|
|
2729
|
+
}));
|
|
2285
2730
|
// Atomic swap: build the new history first, then replace. The head's
|
|
2286
2731
|
// touched files (collected from the committed tool_calls the loop
|
|
2287
|
-
// already recorded — no new tracking)
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
|
|
2732
|
+
// already recorded — no new tracking) plus the canonical `Goal:` block
|
|
2733
|
+
// (live text, state, cumulative stats, open checklist — the model's
|
|
2734
|
+
// context backstop; record restore stays the resume path) ride inside
|
|
2735
|
+
// the summary within budget, so compacted and resumed sessions continue
|
|
2736
|
+
// the same goal without re-exploring; over-budget lists shrink instead
|
|
2737
|
+
// of failing compaction (model text + goal block are never cut).
|
|
2738
|
+
const goalBlock = formatGoalForCompact(goalRef.current, getTodos().map((t) => ({ content: t.content, status: t.status })));
|
|
2739
|
+
const fitted = fitSummaryWithFilesAndGoal(summary, collectTouchedFiles(split.head), goalBlock);
|
|
2291
2740
|
const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
|
|
2292
2741
|
// Replacement: re-wrap so the ledger restarts from the compacted array
|
|
2293
2742
|
// (the old ledger is discarded with the old array).
|
|
@@ -2301,7 +2750,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2301
2750
|
pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
|
|
2302
2751
|
}
|
|
2303
2752
|
// P% must drop immediately: the old lastPromptTokens reflects the
|
|
2304
|
-
// pre-compact context, so clear it and use
|
|
2753
|
+
// pre-compact context (and its cache counters), so clear it and use
|
|
2754
|
+
// the new-history estimate.
|
|
2305
2755
|
lastPromptTokensRef.current = undefined;
|
|
2306
2756
|
const newLoad = estimateTokensForChars(historyChars(historyRef.current));
|
|
2307
2757
|
setContextLoadBoth(newLoad);
|
|
@@ -2312,14 +2762,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2312
2762
|
autoStreakRef.current = 0;
|
|
2313
2763
|
}
|
|
2314
2764
|
else {
|
|
2315
|
-
|
|
2316
|
-
if (isThrashDisabled(autoStreakRef.current)) {
|
|
2317
|
-
setAutoDisabledBoth(true);
|
|
2318
|
-
appendTurns({
|
|
2319
|
-
role: "tool",
|
|
2320
|
-
content: "(auto-compact thrashing — disabled, use /compact or /clear)",
|
|
2321
|
-
});
|
|
2322
|
-
}
|
|
2765
|
+
bumpAutoStreak();
|
|
2323
2766
|
}
|
|
2324
2767
|
}
|
|
2325
2768
|
else {
|
|
@@ -2390,6 +2833,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2390
2833
|
setEffortBoth(s.effort);
|
|
2391
2834
|
setModeBoth(s.mode);
|
|
2392
2835
|
setUsageBoth(s.usageTotals);
|
|
2836
|
+
// Ticket 07: the saved goal restores verbatim (text, flag, cumulative
|
|
2837
|
+
// stats — never reset) and mirrors into the store record below, so the
|
|
2838
|
+
// resumed session continues the goal on its next turn. Corrupt/absent
|
|
2839
|
+
// goal data restores as no-goal without touching the conversation.
|
|
2840
|
+
setGoalBoth(restoreGoalFromPersist(s.goal));
|
|
2393
2841
|
// Replacement: wrap the restored array (see the init comment).
|
|
2394
2842
|
historyRef.current = trackHistory([...s.history]);
|
|
2395
2843
|
// Task 6: refresh the pinned env block on the restored system line
|
|
@@ -2419,14 +2867,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2419
2867
|
content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
|
|
2420
2868
|
});
|
|
2421
2869
|
}
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
contextManager().trimForSend(historyRef.current, (msg) => {
|
|
2425
|
-
pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
|
|
2426
|
-
}, undefined, openTodoNeedles());
|
|
2427
|
-
// Surface the touched-file lists stored in compacted summaries, verbatim
|
|
2428
|
-
// in the stored format — a resumed session knows what was touched
|
|
2429
|
-
// without re-exploring the tree.
|
|
2870
|
+
// Restored history is used whole: no caps, no trimming. A resumed
|
|
2871
|
+
// session knows what was touched without re-exploring the tree.
|
|
2430
2872
|
for (const section of collectStoredTouchedFiles(historyRef.current)) {
|
|
2431
2873
|
pendingNotices.push({ role: "tool", content: section });
|
|
2432
2874
|
}
|
|
@@ -2451,6 +2893,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2451
2893
|
// (create+activate when none exists). Startup itself never auto-restores
|
|
2452
2894
|
// the store — only this explicit /resume does.
|
|
2453
2895
|
persistStoreSession();
|
|
2896
|
+
// Same record stays active across a legacy resume — re-bind it so start
|
|
2897
|
+
// handlers observe the restored session's extension state (ticket 05).
|
|
2898
|
+
extRuntimeRef.current?.setSessionId(activeSessionIdRef.current);
|
|
2899
|
+
replaceExtensionContext("resume");
|
|
2454
2900
|
}
|
|
2455
2901
|
// /session switch: make the picked record the live conversation. Exactly
|
|
2456
2902
|
// one history replacement (never merge, never duplicate): the target's
|
|
@@ -2467,7 +2913,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2467
2913
|
// - a missing/unreadable target errors WITHOUT touching the live session.
|
|
2468
2914
|
// The legacy session.json follows the switch (same persistSession path as
|
|
2469
2915
|
// every completed turn) so /resume stays coherent with the live view.
|
|
2470
|
-
function switchToSession(id) {
|
|
2916
|
+
async function switchToSession(id) {
|
|
2471
2917
|
let target = null;
|
|
2472
2918
|
try {
|
|
2473
2919
|
target = getSession(id, authHome);
|
|
@@ -2484,6 +2930,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2484
2930
|
pushInfo(`(already on "${target.title}")`);
|
|
2485
2931
|
return;
|
|
2486
2932
|
}
|
|
2933
|
+
// Cancellable gate FIRST (ticket 05): before_switch handlers run before
|
|
2934
|
+
// ANY snapshot/persist/mutate step (outgoing persist, active-pointer
|
|
2935
|
+
// write, ref swaps, legacy save), so a cancelled switch is a pure no-op
|
|
2936
|
+
// — the live session is byte-identical to before the call. Everything
|
|
2937
|
+
// below this point mutates, so nothing above it may.
|
|
2938
|
+
const gateRuntime = extRuntimeRef.current;
|
|
2939
|
+
if (gateRuntime) {
|
|
2940
|
+
let verdict;
|
|
2941
|
+
try {
|
|
2942
|
+
verdict = await gateRuntime.requestSwitch({ fromSessionId: currentId, toSessionId: target.id, reason: "switch" });
|
|
2943
|
+
}
|
|
2944
|
+
catch {
|
|
2945
|
+
// requestSwitch never rejects by contract; defensive only.
|
|
2946
|
+
verdict = { cancelled: false };
|
|
2947
|
+
}
|
|
2948
|
+
if (verdict.cancelled) {
|
|
2949
|
+
pushInfo(verdict.reason
|
|
2950
|
+
? `(session switch cancelled: ${verdict.reason})`
|
|
2951
|
+
: "(session switch cancelled by an extension — staying on the current session)");
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2487
2955
|
// Snapshot the outgoing conversation into its own record first (same
|
|
2488
2956
|
// rule as /new's pre-reset save). Guarded: only when the live state
|
|
2489
2957
|
// actually holds turns of the outgoing record.
|
|
@@ -2512,6 +2980,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2512
2980
|
setEffortBoth(target.effort);
|
|
2513
2981
|
setModeBoth(target.mode);
|
|
2514
2982
|
setUsageBoth(target.usageTotals);
|
|
2983
|
+
// Ticket 07: the target's goal replaces the live one wholesale (never
|
|
2984
|
+
// merged) — a session without a saved goal lands on no-goal, so one
|
|
2985
|
+
// session's goal can never leak into another. The outgoing goal was
|
|
2986
|
+
// snapshotted into its own record above, so switching back restores it.
|
|
2987
|
+
setGoalBoth(restoreGoalFromPersist(target.goal));
|
|
2515
2988
|
// Replacement: the target's arrays replace the live ones wholesale (a
|
|
2516
2989
|
// fresh-created record carries empty history — fall back to a fresh
|
|
2517
2990
|
// system line so the system-first invariant always holds).
|
|
@@ -2558,9 +3031,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2558
3031
|
clearTodos();
|
|
2559
3032
|
setTodoSnap([]);
|
|
2560
3033
|
}
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
}, undefined, openTodoNeedles());
|
|
3034
|
+
// The target's history/turns REPLACE the live arrays wholesale — used
|
|
3035
|
+
// whole, never trimmed.
|
|
2564
3036
|
for (const section of collectStoredTouchedFiles(historyRef.current)) {
|
|
2565
3037
|
pendingNotices.push({ role: "tool", content: section });
|
|
2566
3038
|
}
|
|
@@ -2581,7 +3053,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2581
3053
|
persistTelemetry();
|
|
2582
3054
|
// Legacy single-file save follows the switch so /resume restores what
|
|
2583
3055
|
// the live view shows (same path/format as every completed turn).
|
|
3056
|
+
// Bind the new record BEFORE the boundary emit, so session_start
|
|
3057
|
+
// handlers observe the new session's extension state (ticket 05).
|
|
3058
|
+
extRuntimeRef.current?.setSessionId(target.id);
|
|
2584
3059
|
persistSession();
|
|
3060
|
+
replaceExtensionContext("switch");
|
|
2585
3061
|
}
|
|
2586
3062
|
// /rewind conversation scope (ticket 01): truncate history + transcript to
|
|
2587
3063
|
// the checkpoint's turn. The cut drops the whole containing turn (submit's
|
|
@@ -2642,9 +3118,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2642
3118
|
}
|
|
2643
3119
|
pushInfo(filesMsg);
|
|
2644
3120
|
}
|
|
2645
|
-
function warnEffortUnsupported(modelName) {
|
|
2646
|
-
pushInfo(`reasoning effort is not known to be supported by ${modelName} — setting kept, not sent`);
|
|
2647
|
-
}
|
|
2648
3121
|
// Manual /compact entry: busy → set pending flag, run at turn end (drain
|
|
2649
3122
|
// boundary, never mid-turn); idle → run now under the busy guard so a
|
|
2650
3123
|
// concurrent submit cannot interleave. Works for unknown-window models.
|
|
@@ -2848,6 +3321,60 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2848
3321
|
}
|
|
2849
3322
|
pushInfo(AUTOSCROLL_USAGE);
|
|
2850
3323
|
}
|
|
3324
|
+
// /goal [<objective>|pause|resume|clear]: session goal state.
|
|
3325
|
+
// View/state-only — safe while busy (never touches the turn, like
|
|
3326
|
+
// /autoscroll). Bare shows the active goal (text, state, cumulative
|
|
3327
|
+
// stats) or the none-hint; `/goal <objective>` sets with fresh stats (a
|
|
3328
|
+
// second set replaces with a notice); `/goal pause` flips the active flag
|
|
3329
|
+
// (pausing mid-turn stops continuation at the next turn end — the loop
|
|
3330
|
+
// reads the flag live — while todos, evidence, and history stay intact);
|
|
3331
|
+
// `/goal resume` re-arms the flag AND starts a continuation turn through
|
|
3332
|
+
// the normal submit path when idle (cumulative stats carry over — nothing
|
|
3333
|
+
// resets), or says so loudly without injecting a turn when busy (the
|
|
3334
|
+
// running turn's next turn end picks the live flag up on its own);
|
|
3335
|
+
// `/goal clear` ends it (harmless notice when absent). Every mutation
|
|
3336
|
+
// persists through the normal save path immediately (no new cadence).
|
|
3337
|
+
function runGoalCommand(raw) {
|
|
3338
|
+
const cmd = parseGoalCommand(raw);
|
|
3339
|
+
if (cmd.kind === "status") {
|
|
3340
|
+
pushInfo(goalStatusText(goalRef.current));
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (cmd.kind === "clear") {
|
|
3344
|
+
pushInfo(goalClearNotice(goalRef.current));
|
|
3345
|
+
if (!goalRef.current)
|
|
3346
|
+
return;
|
|
3347
|
+
setGoalBoth(null);
|
|
3348
|
+
persistSession();
|
|
3349
|
+
return;
|
|
3350
|
+
}
|
|
3351
|
+
if (cmd.kind === "pause") {
|
|
3352
|
+
const g = goalRef.current;
|
|
3353
|
+
pushInfo(goalPauseNotice(g));
|
|
3354
|
+
if (!g || !g.active)
|
|
3355
|
+
return;
|
|
3356
|
+
setGoalBoth({ ...g, active: false });
|
|
3357
|
+
persistSession();
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
if (cmd.kind === "resume") {
|
|
3361
|
+
const g = goalRef.current;
|
|
3362
|
+
pushInfo(goalResumeNotice(g));
|
|
3363
|
+
if (!g || g.active)
|
|
3364
|
+
return;
|
|
3365
|
+
setGoalBoth({ ...g, active: true });
|
|
3366
|
+
persistSession();
|
|
3367
|
+
if (busyRef.current) {
|
|
3368
|
+
pushInfo("(goal resumes when the current turn ends — no new turn started while busy)");
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
void submit(goalFollowUp(g.objective));
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
pushInfo(goalSetNotice(cmd.objective, goalRef.current));
|
|
3375
|
+
setGoalBoth({ objective: cmd.objective, active: true, stats: emptyGoalStats() });
|
|
3376
|
+
persistSession();
|
|
3377
|
+
}
|
|
2851
3378
|
// /models: local-discovery status + refresh. Bare `/models` reports the
|
|
2852
3379
|
// last snapshot (kicking a first probe when discovery never ran);
|
|
2853
3380
|
// `/models refresh` re-probes all three runtimes, then reports. Results
|
|
@@ -2935,6 +3462,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2935
3462
|
if (clearedDrops > 0) {
|
|
2936
3463
|
pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
|
|
2937
3464
|
}
|
|
3465
|
+
// /clear wipes the conversation, so the active goal ends here with
|
|
3466
|
+
// a visible notice (same lazy persist as the transcript above: the
|
|
3467
|
+
// cleared state — goal included — persists on the next completed
|
|
3468
|
+
// turn, and the save keeps the pre-clear state until then).
|
|
3469
|
+
const clearedGoal = goalRef.current;
|
|
3470
|
+
setGoalBoth(null);
|
|
3471
|
+
if (clearedGoal) {
|
|
3472
|
+
pushInfo(`(goal cleared — "${clearedGoal.objective}" — /clear wipes the conversation)`);
|
|
3473
|
+
}
|
|
2938
3474
|
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
2939
3475
|
lastPromptTokensRef.current = undefined;
|
|
2940
3476
|
setContextLoadBoth(null);
|
|
@@ -2969,6 +3505,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2969
3505
|
setActiveSession(created.id, authHome);
|
|
2970
3506
|
activeSessionIdRef.current = created.id;
|
|
2971
3507
|
setSessionTitleBoth(created.title);
|
|
3508
|
+
// Bind the new record BEFORE the boundary emit (ticket 05).
|
|
3509
|
+
extRuntimeRef.current?.setSessionId(created.id);
|
|
2972
3510
|
}
|
|
2973
3511
|
catch {
|
|
2974
3512
|
// ignore disk errors (in-memory reset below still applies)
|
|
@@ -2984,6 +3522,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2984
3522
|
content: "(new session started — previous conversation kept, /resume to restore it)",
|
|
2985
3523
|
},
|
|
2986
3524
|
]);
|
|
3525
|
+
// /new replaces the conversation lineage, so the active goal ends
|
|
3526
|
+
// here with a visible notice — a fresh conversation must not inherit
|
|
3527
|
+
// an auto-continuing goal. The pre-/new goal stays in the OLD record
|
|
3528
|
+
// (persisted above), so /resume still brings it back with its stats.
|
|
3529
|
+
const droppedGoal = goalRef.current;
|
|
3530
|
+
setGoalBoth(null);
|
|
3531
|
+
if (droppedGoal) {
|
|
3532
|
+
pushInfo(`(goal cleared — "${droppedGoal.objective}" — /new starts a fresh conversation with no goal)`);
|
|
3533
|
+
}
|
|
2987
3534
|
// Fresh list: a held view has nothing to hold onto — re-follow.
|
|
2988
3535
|
setScrollEndBoth(null);
|
|
2989
3536
|
setClearGen((g) => g + 1);
|
|
@@ -3017,6 +3564,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3017
3564
|
telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
|
|
3018
3565
|
persistTelemetry();
|
|
3019
3566
|
void refreshSkillMenu();
|
|
3567
|
+
replaceExtensionContext("new");
|
|
3020
3568
|
return;
|
|
3021
3569
|
case "/compact":
|
|
3022
3570
|
// Bare /compact with no focus text (slash-menu path). Free-text
|
|
@@ -3080,6 +3628,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3080
3628
|
case "/autoscroll":
|
|
3081
3629
|
runAutoScrollCommand("/autoscroll");
|
|
3082
3630
|
return;
|
|
3631
|
+
case "/goal":
|
|
3632
|
+
runGoalCommand("/goal");
|
|
3633
|
+
return;
|
|
3083
3634
|
case "/mode":
|
|
3084
3635
|
if (modeRef.current === "plan") {
|
|
3085
3636
|
pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
|
|
@@ -3163,7 +3714,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3163
3714
|
return;
|
|
3164
3715
|
}
|
|
3165
3716
|
default:
|
|
3166
|
-
|
|
3717
|
+
// Extension slash commands (ticket 04, bare form from the menu or
|
|
3718
|
+
// palette): typed-args forms route through submit/menu above with
|
|
3719
|
+
// args intact, so only the bare exact name lands here.
|
|
3720
|
+
{
|
|
3721
|
+
const target = parseExtensionCommandInput(cmd);
|
|
3722
|
+
if (target && target.args === "" && getExtensionCommand(target.name)) {
|
|
3723
|
+
void runExtensionCommandFromApp(target.name, "");
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3167
3728
|
}
|
|
3168
3729
|
}
|
|
3169
3730
|
// approve hook for runAgenticLoop: scoped rules first (deny refuses as a
|
|
@@ -3319,7 +3880,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3319
3880
|
h?.reject(new Error("question cancelled by user"));
|
|
3320
3881
|
}
|
|
3321
3882
|
// Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
|
|
3322
|
-
// above; each `SUBMIT STAGE n/
|
|
3883
|
+
// above; each `SUBMIT STAGE n/3` marker below names its stage plus its
|
|
3323
3884
|
// rollback-scope rule). Local "/" routing precedes the pipeline: exact
|
|
3324
3885
|
// slash commands, /allow-/deny-/rules, and skill invocations never enter
|
|
3325
3886
|
// it (no turn, no history, nothing to roll back).
|
|
@@ -3346,11 +3907,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3346
3907
|
runRenameCommand(text);
|
|
3347
3908
|
return;
|
|
3348
3909
|
}
|
|
3349
|
-
// SUBMIT STAGE 1/
|
|
3910
|
+
// SUBMIT STAGE 1/3 — permissions (rollback scope: pre-turn, appends
|
|
3350
3911
|
// nothing). Busy guard + API-key check: rejections return before any
|
|
3351
3912
|
// history mutation, so there is nothing to roll back.
|
|
3352
3913
|
if (!text)
|
|
3353
3914
|
return;
|
|
3915
|
+
// An extension command in flight owns the question modal (single slot
|
|
3916
|
+
// shared with ask_question): plain follow-ups and nested extension
|
|
3917
|
+
// commands wait with a notice; view/state slash commands still run
|
|
3918
|
+
// (they never touch the modal or the turn).
|
|
3919
|
+
if (extCommandRunningRef.current) {
|
|
3920
|
+
const nested = parseExtensionCommandInput(text);
|
|
3921
|
+
if ((nested && getExtensionCommand(nested.name)) || !text.startsWith("/")) {
|
|
3922
|
+
pushInfo("(an extension command is already running — wait for its prompt)");
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3354
3926
|
// Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
|
|
3355
3927
|
// the thought is never lost); /queue + /steer manage and inject. Other
|
|
3356
3928
|
// "/" input still needs idle (pickers/modals would race the turn), so it
|
|
@@ -3361,13 +3933,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3361
3933
|
runQueueCommand(text);
|
|
3362
3934
|
return;
|
|
3363
3935
|
}
|
|
3364
|
-
// /autoscroll and /
|
|
3365
|
-
// turn), so they run while busy like /queue + /steer (see
|
|
3936
|
+
// /autoscroll, /thinking, and /goal are view/state-only (never touch
|
|
3937
|
+
// the turn), so they run while busy like /queue + /steer (see
|
|
3366
3938
|
// slashRunsWhileBusy).
|
|
3367
3939
|
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
3368
3940
|
runAutoScrollCommand(text);
|
|
3369
3941
|
return;
|
|
3370
3942
|
}
|
|
3943
|
+
if (text === "/goal" || text.startsWith("/goal ")) {
|
|
3944
|
+
runGoalCommand(text);
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3371
3947
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
3372
3948
|
runThinkingCommand(text);
|
|
3373
3949
|
return;
|
|
@@ -3389,12 +3965,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3389
3965
|
return;
|
|
3390
3966
|
}
|
|
3391
3967
|
// /autoscroll takes an optional subcommand (/autoscroll on|off), like the
|
|
3392
|
-
// /queue family — SLASH_NAMES only holds the exact command. /
|
|
3393
|
-
//
|
|
3968
|
+
// /queue family — SLASH_NAMES only holds the exact command. /goal takes
|
|
3969
|
+
// free-text args (/goal <objective>, /goal clear) the same way.
|
|
3970
|
+
// /thinking is bare-toggle-only; anything appended prints its usage.
|
|
3394
3971
|
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
3395
3972
|
runAutoScrollCommand(text);
|
|
3396
3973
|
return;
|
|
3397
3974
|
}
|
|
3975
|
+
if (text === "/goal" || text.startsWith("/goal ")) {
|
|
3976
|
+
runGoalCommand(text);
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3398
3979
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
3399
3980
|
runThinkingCommand(text);
|
|
3400
3981
|
return;
|
|
@@ -3447,6 +4028,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3447
4028
|
void invokeSkillByName(namespaced);
|
|
3448
4029
|
return;
|
|
3449
4030
|
}
|
|
4031
|
+
// Extension slash commands (ticket 04): "/name args" runs extension
|
|
4032
|
+
// code outside the model turn loop (no history, no telemetry turn —
|
|
4033
|
+
// say() posts transcript turns only). Builtins and /skill: keep
|
|
4034
|
+
// precedence above, so an extension never shadows them; the legacy
|
|
4035
|
+
// /name skill form below yields to extensions deterministically.
|
|
4036
|
+
const extTarget = parseExtensionCommandInput(text);
|
|
4037
|
+
if (extTarget && getExtensionCommand(extTarget.name)) {
|
|
4038
|
+
void runExtensionCommandFromApp(extTarget.name, extTarget.args);
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
3450
4041
|
const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
3451
4042
|
if (skillName !== undefined) {
|
|
3452
4043
|
void invokeSkillByName(skillName);
|
|
@@ -3497,25 +4088,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3497
4088
|
pendingDiffRef.current = null;
|
|
3498
4089
|
lastPartialRef.current = "";
|
|
3499
4090
|
refreshGitInfo();
|
|
3500
|
-
// SUBMIT STAGE 2/
|
|
4091
|
+
// SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
|
|
3501
4092
|
// survives failure). Refresh the pinned env block ONCE per turn (not per
|
|
3502
4093
|
// POST — the loop reuses history[0] for all its POSTs, so this is the
|
|
3503
|
-
// only git call for the turn). Before
|
|
3504
|
-
// accounts for the fresh block size; before rollbackTo so the refresh
|
|
4094
|
+
// only git call for the turn). Before rollbackTo so the refresh
|
|
3505
4095
|
// survives a failed-turn rollback (it is not part of the user turn).
|
|
4096
|
+
// History is uncapped: the full conversation rides every turn.
|
|
3506
4097
|
refreshSystemEnv();
|
|
3507
|
-
// SUBMIT STAGE 3/
|
|
3508
|
-
// survives failure). History budget at turn start, BEFORE the push +
|
|
3509
|
-
// rollbackTo capture below (so the existing splice-rollback indices stay
|
|
3510
|
-
// valid): drop oldest user-turns first, reserving room for the incoming user message
|
|
3511
|
-
// so the loop core's own budget check stays a no-op on entry - exactly
|
|
3512
|
-
// one dim notice per truncating turn. /clear drops the notice with the
|
|
3513
|
-
// transcript (usage totals still survive). Caps come from the session
|
|
3514
|
-
// ContextManager (window-derived), with the same live todo pinning.
|
|
3515
|
-
contextManager().trimForSend(historyRef.current, (msg) => {
|
|
3516
|
-
appendTurns({ role: "tool", content: `? ${msg}` });
|
|
3517
|
-
}, { messages: 1, chars: text.length }, openTodoNeedles());
|
|
3518
|
-
// SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
4098
|
+
// SUBMIT STAGE 3/3 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
3519
4099
|
// back on failure). Turn boundary: on POST failure (HTTP/network/empty/
|
|
3520
4100
|
// truncated) the whole user turn (user message plus any partial
|
|
3521
4101
|
// assistant/tool loop entries) is removed, so the next request starts
|
|
@@ -3524,15 +4104,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3524
4104
|
// catch). Cancellation (LoopCancelledError) shares the same splice
|
|
3525
4105
|
// contract.
|
|
3526
4106
|
const rollbackTo = historyRef.current.length;
|
|
4107
|
+
// Goal work time (ticket 02): wall clock for this submit accrues to the
|
|
4108
|
+
// live goal in the turn finally (all outcomes — success, failure, and
|
|
4109
|
+
// cancel all did work). Pinned to the starting objective so a mid-turn
|
|
4110
|
+
// replacement keeps its own stats.
|
|
4111
|
+
const goalWorkStartMs = goalRef.current ? Date.now() : null;
|
|
4112
|
+
const goalWorkObjective = goalRef.current?.objective ?? null;
|
|
3527
4113
|
// Local observability: open this turn's trace (no-op when disabled).
|
|
3528
4114
|
// Provider/model switches surface here per turn; session-level switches
|
|
3529
4115
|
// are derived from the same updates (see setSessionMeta).
|
|
3530
4116
|
telemetry.setSessionMeta({ provider: providerRef.current, model: modelRef.current });
|
|
4117
|
+
// Goal snapshot for the trace (ticket 09): the live goal as this turn
|
|
4118
|
+
// opens it (objective, flag, cumulative counters so far). Absent reads
|
|
4119
|
+
// as no-goal; the recorder caps and copies it, never aliasing live state.
|
|
4120
|
+
const turnGoal = goalRef.current
|
|
4121
|
+
? {
|
|
4122
|
+
objective: goalRef.current.objective,
|
|
4123
|
+
active: goalRef.current.active === true,
|
|
4124
|
+
...(goalRef.current.stats
|
|
4125
|
+
? {
|
|
4126
|
+
turns: goalRef.current.stats.turns,
|
|
4127
|
+
requests: goalRef.current.stats.requests,
|
|
4128
|
+
tokens: goalRef.current.stats.tokens,
|
|
4129
|
+
workMs: goalRef.current.stats.workMs,
|
|
4130
|
+
}
|
|
4131
|
+
: {}),
|
|
4132
|
+
}
|
|
4133
|
+
: undefined;
|
|
3531
4134
|
const telemetryTurnId = telemetry.startTurn(text, {
|
|
3532
4135
|
provider: providerRef.current,
|
|
3533
4136
|
model: modelRef.current,
|
|
3534
4137
|
effort: effortRef.current,
|
|
3535
4138
|
mode: modeRef.current,
|
|
4139
|
+
...(turnGoal ? { goal: turnGoal } : {}),
|
|
3536
4140
|
});
|
|
3537
4141
|
const telemetrySink = {
|
|
3538
4142
|
onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
|
|
@@ -3572,6 +4176,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3572
4176
|
const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
|
|
3573
4177
|
approve,
|
|
3574
4178
|
askUser,
|
|
4179
|
+
// Goal auto-continue (ticket 02): the loop reads the live goal
|
|
4180
|
+
// through getGoal (never imports App state), pauses with a notice
|
|
4181
|
+
// via pauseGoal, and reports slice counters. Turns/requests land
|
|
4182
|
+
// here; tokens accrue in onUsage below; work time in the finally.
|
|
4183
|
+
goal: {
|
|
4184
|
+
getGoal: () => goalRef.current,
|
|
4185
|
+
pauseGoal: (notice) => {
|
|
4186
|
+
pauseGoalWithNotice(notice);
|
|
4187
|
+
},
|
|
4188
|
+
onGoalRequest: () => {
|
|
4189
|
+
patchGoalStats((s) => ({ ...s, requests: s.requests + 1 }));
|
|
4190
|
+
},
|
|
4191
|
+
onGoalTurn: () => {
|
|
4192
|
+
patchGoalStats((s) => ({ ...s, turns: s.turns + 1 }));
|
|
4193
|
+
},
|
|
4194
|
+
},
|
|
4195
|
+
// Evaluator fallback (ticket 04): report-less goal turns get one
|
|
4196
|
+
// bounded, read-only judge call (same provider/model, tools disabled,
|
|
4197
|
+
// 256-token cap — see src/agent/goal-evaluator.ts). Built from live
|
|
4198
|
+
// refs at call time so a mid-run provider/model/key switch applies;
|
|
4199
|
+
// judge spend accumulates exactly like model spend. Transport
|
|
4200
|
+
// failures throw (the loop pauses on them, never crashes); an
|
|
4201
|
+
// unclear verdict resolves null (the loop pauses with a notice).
|
|
4202
|
+
goalJudge: async ({ goal: objective, turns }) => {
|
|
4203
|
+
return requestGoalVerdict({
|
|
4204
|
+
provider: providerRef.current,
|
|
4205
|
+
apiKey: keyForProvider(providerRef.current),
|
|
4206
|
+
model: modelRef.current,
|
|
4207
|
+
systemContent: systemPrompt,
|
|
4208
|
+
goal: objective,
|
|
4209
|
+
turns,
|
|
4210
|
+
baseURL: chatBaseURL(providerRef.current),
|
|
4211
|
+
endpointOverride: activeEndpoint,
|
|
4212
|
+
signal: controller.signal,
|
|
4213
|
+
onUsage: (u) => {
|
|
4214
|
+
accumulateUsage(u, false);
|
|
4215
|
+
},
|
|
4216
|
+
});
|
|
4217
|
+
},
|
|
3575
4218
|
// Local observability sink: the loop reports completed model/tool
|
|
3576
4219
|
// calls (iterations, durations, usage) into the open turn trace.
|
|
3577
4220
|
telemetry: telemetrySink,
|
|
@@ -3647,29 +4290,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3647
4290
|
// POST accumulates (tool-round POSTs and successful retries each
|
|
3648
4291
|
// count once — each was billed; failed attempts report nothing, so
|
|
3649
4292
|
// nothing is deduped). usageTotals drives NK only, never P%.
|
|
3650
|
-
|
|
3651
|
-
const next = { ...prev };
|
|
3652
|
-
if (u.prompt_tokens !== undefined) {
|
|
3653
|
-
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
3654
|
-
// Load metric source: last POST's reported prompt_tokens (the
|
|
3655
|
-
// per-POST value, NOT the accumulated total).
|
|
3656
|
-
lastPromptTokensRef.current = u.prompt_tokens;
|
|
3657
|
-
}
|
|
3658
|
-
if (u.completion_tokens !== undefined) {
|
|
3659
|
-
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
3660
|
-
}
|
|
3661
|
-
if (u.total_tokens !== undefined) {
|
|
3662
|
-
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
3663
|
-
}
|
|
3664
|
-
// Prefix-cache counters accumulate like spend (real reports only;
|
|
3665
|
-
// absent fields mean "not reported", never zero).
|
|
3666
|
-
if (u.cacheReadTokens !== undefined) {
|
|
3667
|
-
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
3668
|
-
}
|
|
3669
|
-
if (u.cacheWriteTokens !== undefined) {
|
|
3670
|
-
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
3671
|
-
}
|
|
3672
|
-
setUsageBoth(next);
|
|
4293
|
+
accumulateUsage(u);
|
|
3673
4294
|
},
|
|
3674
4295
|
onReasoning: (label) => {
|
|
3675
4296
|
setReasoning(label);
|
|
@@ -3759,12 +4380,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3759
4380
|
noteTurnActivity();
|
|
3760
4381
|
},
|
|
3761
4382
|
signal: controller.signal,
|
|
3762
|
-
// Window-aware trim caps for this model's real window (the loop
|
|
3763
|
-
// falls back to legacy caps without it — see AgenticOpts.context).
|
|
3764
|
-
context: {
|
|
3765
|
-
model: modelRef.current,
|
|
3766
|
-
toolsChars: TOOLS_SCHEMA_CHARS,
|
|
3767
|
-
},
|
|
3768
4383
|
});
|
|
3769
4384
|
// Turn-end flush: any trailing throttled partial paints before the
|
|
3770
4385
|
// commit replaces the draft (byte-exact via `reply` regardless). The
|
|
@@ -3861,6 +4476,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3861
4476
|
}
|
|
3862
4477
|
}
|
|
3863
4478
|
finally {
|
|
4479
|
+
// Goal work time (ticket 02): this submit's wall clock accrues once,
|
|
4480
|
+
// for every outcome (success, failure, and cancel all did work), when
|
|
4481
|
+
// the same goal is still live. A mid-turn replacement keeps its own
|
|
4482
|
+
// stats — we accrue only while the objective still matches.
|
|
4483
|
+
try {
|
|
4484
|
+
if (goalWorkStartMs !== null &&
|
|
4485
|
+
goalRef.current !== null &&
|
|
4486
|
+
goalRef.current.objective === goalWorkObjective) {
|
|
4487
|
+
const workedMs = Math.max(0, Date.now() - goalWorkStartMs);
|
|
4488
|
+
patchGoalStats((s) => ({ ...s, workMs: s.workMs + workedMs }));
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
catch {
|
|
4492
|
+
// accounting never breaks turn teardown
|
|
4493
|
+
}
|
|
3864
4494
|
turnCancelRef.current = null;
|
|
3865
4495
|
approvalResolveRef.current = null;
|
|
3866
4496
|
setPendingApproval(null);
|
|
@@ -4018,6 +4648,53 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4018
4648
|
}
|
|
4019
4649
|
return;
|
|
4020
4650
|
}
|
|
4651
|
+
// 2a. Extension dialog (ticket 10): same keys as the question modal —
|
|
4652
|
+
// arrows + Enter picks, typing + Enter submits custom text (allowCustom
|
|
4653
|
+
// only), Esc cancels with a clean error. Owns the keyboard while open
|
|
4654
|
+
// (like every modal above): resolvers can never clobber each other, and
|
|
4655
|
+
// a dialog stranded by a session switch is already rejected by
|
|
4656
|
+
// invalidate, so resolve/cancel here always hits the live request.
|
|
4657
|
+
const extDlg = extRuntimeRef.current?.getPendingDialog() ?? null;
|
|
4658
|
+
if (extDlg) {
|
|
4659
|
+
const len = Math.max(extDlg.options.length, 1);
|
|
4660
|
+
if (key.upArrow) {
|
|
4661
|
+
setExtDlgSelBoth((extDlgSelRef.current - 1 + len) % len);
|
|
4662
|
+
}
|
|
4663
|
+
else if (key.downArrow) {
|
|
4664
|
+
setExtDlgSelBoth((extDlgSelRef.current + 1) % len);
|
|
4665
|
+
}
|
|
4666
|
+
else if (key.escape) {
|
|
4667
|
+
extRuntimeRef.current?.cancelPendingDialog(`extension "${extDlg.owner}" dialog was cancelled by user`);
|
|
4668
|
+
setExtDlgSelBoth(0);
|
|
4669
|
+
setExtDlgCustomBoth("");
|
|
4670
|
+
}
|
|
4671
|
+
else if (key.return || key.tab) {
|
|
4672
|
+
if (extDlg.allowCustom && extDlgCustomRef.current.trim().length > 0) {
|
|
4673
|
+
if (extRuntimeRef.current?.resolvePendingDialog(extDlgCustomRef.current) === true) {
|
|
4674
|
+
setExtDlgSelBoth(0);
|
|
4675
|
+
setExtDlgCustomBoth("");
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
else {
|
|
4679
|
+
const picked = extDlg.options[extDlgSelRef.current];
|
|
4680
|
+
if (picked !== undefined && extRuntimeRef.current?.resolvePendingDialog(picked) === true) {
|
|
4681
|
+
setExtDlgSelBoth(0);
|
|
4682
|
+
setExtDlgCustomBoth("");
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
else if (key.backspace || key.delete) {
|
|
4687
|
+
if (extDlg.allowCustom) {
|
|
4688
|
+
setExtDlgCustomBoth(extDlgCustomRef.current.slice(0, -1));
|
|
4689
|
+
}
|
|
4690
|
+
}
|
|
4691
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
4692
|
+
if (extDlg.allowCustom) {
|
|
4693
|
+
setExtDlgCustomBoth(extDlgCustomRef.current + ch);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
return;
|
|
4697
|
+
}
|
|
4021
4698
|
// 2b. Provider key prompt (masked • per char, paste works, Esc safe).
|
|
4022
4699
|
const kp = keyPromptRef.current;
|
|
4023
4700
|
if (kp && keyPrompt) {
|
|
@@ -4209,28 +4886,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4209
4886
|
// reported prompt_tokens no longer measures this context); the
|
|
4210
4887
|
// estimate applies until the new model reports.
|
|
4211
4888
|
resetContextLoadToEstimate();
|
|
4212
|
-
//
|
|
4213
|
-
//
|
|
4214
|
-
if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
|
|
4215
|
-
warnEffortUnsupported(picked.model);
|
|
4216
|
-
}
|
|
4889
|
+
// Effort needs no re-gating: it is assumed for every model and
|
|
4890
|
+
// only a server 400 can veto it (the POST retries without it).
|
|
4217
4891
|
}
|
|
4218
4892
|
else {
|
|
4219
4893
|
// Cross-provider pick: switch with the resolved key (env wins,
|
|
4220
4894
|
// else stored — remote sections only render for keyed providers;
|
|
4221
4895
|
// local sections need no key) and keep the picked model; the
|
|
4222
4896
|
// live refresh lands in the background via the standard switch
|
|
4223
|
-
// path.
|
|
4224
|
-
//
|
|
4897
|
+
// path. Effort carries over untouched — it is valid on every
|
|
4898
|
+
// provider kind.
|
|
4225
4899
|
const switchedKey = keyForProvider(picked.providerId);
|
|
4226
4900
|
if (switchedKey || !providerNeedsKey(picked.providerId)) {
|
|
4227
4901
|
const pickedProvider = picked.providerId;
|
|
4228
4902
|
const pickedModel = picked.model;
|
|
4229
4903
|
void (async () => {
|
|
4230
4904
|
await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
|
|
4231
|
-
if (effortRef.current !== "default") {
|
|
4232
|
-
warnEffortUnsupported(pickedModel);
|
|
4233
|
-
}
|
|
4234
4905
|
})();
|
|
4235
4906
|
}
|
|
4236
4907
|
}
|
|
@@ -4322,7 +4993,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4322
4993
|
setSelectingSession(false);
|
|
4323
4994
|
if (picked) {
|
|
4324
4995
|
exitHistoryBrowse();
|
|
4325
|
-
switchToSession(picked.id);
|
|
4996
|
+
void switchToSession(picked.id);
|
|
4326
4997
|
}
|
|
4327
4998
|
else {
|
|
4328
4999
|
pushInfo("(no sessions match — backspace to widen the filter.)");
|
|
@@ -4353,10 +5024,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4353
5024
|
else if (key.return) {
|
|
4354
5025
|
const picked = EFFORT_OPTIONS[effortIndexRef.current];
|
|
4355
5026
|
if (picked) {
|
|
5027
|
+
// No support warning: every model on every provider accepts the
|
|
5028
|
+
// knob; only a server 400 vetoes it (retried without, warned).
|
|
4356
5029
|
setEffortBoth(picked);
|
|
4357
|
-
if (picked !== "default" && !isEffortSupported(modelRef.current)) {
|
|
4358
|
-
warnEffortUnsupported(modelRef.current);
|
|
4359
|
-
}
|
|
4360
5030
|
}
|
|
4361
5031
|
setSelectingEffort(false);
|
|
4362
5032
|
}
|
|
@@ -4417,7 +5087,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4417
5087
|
// a newline anywhere means free text (slash commands never span lines).
|
|
4418
5088
|
const cur = inputRef.current;
|
|
4419
5089
|
const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
|
|
4420
|
-
? buildSlashMenu(cur, skillMenu)
|
|
5090
|
+
? buildSlashMenu(cur, skillMenu, listExtensionCommands())
|
|
4421
5091
|
: { items: [], moreSkills: 0 };
|
|
4422
5092
|
const matches = menu.items;
|
|
4423
5093
|
if (matches.length > 0) {
|
|
@@ -4444,8 +5114,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4444
5114
|
}
|
|
4445
5115
|
else if (key.return || key.tab) {
|
|
4446
5116
|
const pick = matches[slashIndexRef.current % matches.length];
|
|
4447
|
-
// /compact, /queue, /steer, and /
|
|
4448
|
-
// slashRunsWhileBusy); every other entry still waits idle.
|
|
5117
|
+
// /compact, /queue, /steer, /autoscroll, and /goal run while busy
|
|
5118
|
+
// (see slashRunsWhileBusy); every other entry still waits idle.
|
|
4449
5119
|
if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
|
|
4450
5120
|
if (pick.skill) {
|
|
4451
5121
|
// Skill entries stage for confirm (opencode-style): Enter/Tab
|
|
@@ -4476,6 +5146,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4476
5146
|
setInputBoth("");
|
|
4477
5147
|
runAutoScrollCommand(raw);
|
|
4478
5148
|
}
|
|
5149
|
+
else if (pick.name === "/goal" &&
|
|
5150
|
+
(inputRef.current === "/goal" || inputRef.current.startsWith("/goal "))) {
|
|
5151
|
+
// Preserve the typed objective (e.g. "/goal Ship v2"); a bare
|
|
5152
|
+
// highlighted name falls through to status.
|
|
5153
|
+
const raw = inputRef.current;
|
|
5154
|
+
setInputBoth("");
|
|
5155
|
+
runGoalCommand(raw);
|
|
5156
|
+
}
|
|
4479
5157
|
else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
|
|
4480
5158
|
inputRef.current.startsWith(pick.name)) {
|
|
4481
5159
|
// Preserve the typed rule args (e.g. "/allow bash:npm test*");
|
|
@@ -4492,6 +5170,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4492
5170
|
setInputBoth("");
|
|
4493
5171
|
runRenameCommand(raw);
|
|
4494
5172
|
}
|
|
5173
|
+
else if (!pick.skill &&
|
|
5174
|
+
getExtensionCommand(pick.name.slice(1)) &&
|
|
5175
|
+
(inputRef.current === pick.name || inputRef.current.startsWith(`${pick.name} `))) {
|
|
5176
|
+
// Extension slash command (ticket 04): preserve the typed args
|
|
5177
|
+
// like /rename — a bare highlighted name runs with empty args.
|
|
5178
|
+
// Builtins keep precedence (an extension name can never equal a
|
|
5179
|
+
// builtin — activation rejects the collision).
|
|
5180
|
+
const raw = inputRef.current;
|
|
5181
|
+
setInputBoth("");
|
|
5182
|
+
const target = parseExtensionCommandInput(raw);
|
|
5183
|
+
if (target)
|
|
5184
|
+
void runExtensionCommandFromApp(target.name, target.args);
|
|
5185
|
+
}
|
|
4495
5186
|
else {
|
|
4496
5187
|
runSlashCommand(pick.name);
|
|
4497
5188
|
}
|
|
@@ -4524,7 +5215,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4524
5215
|
// input, and input keys never reach the inspector.
|
|
4525
5216
|
if (key.ctrl && (ch === "o" || ch === "O")) {
|
|
4526
5217
|
if (!busyRef.current && !turnCancelRef.current &&
|
|
4527
|
-
!pendingApproval && !pendingQuestion &&
|
|
5218
|
+
!pendingApproval && !pendingQuestion && !extDialogOpen &&
|
|
4528
5219
|
!selecting && !selectingSkills && !selectingProvider &&
|
|
4529
5220
|
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
4530
5221
|
!selectingRewind && !selectingRewindScope) {
|
|
@@ -4589,7 +5280,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4589
5280
|
// open); Enter runs through the shared busy-gate, so only
|
|
4590
5281
|
// compact/queue/steer fire while busy.
|
|
4591
5282
|
if (key.ctrl && (ch === "p" || ch === "P")) {
|
|
4592
|
-
if (!pendingApproval && !pendingQuestion &&
|
|
5283
|
+
if (!pendingApproval && !pendingQuestion && !extDialogOpen &&
|
|
4593
5284
|
!selecting && !selectingSkills && !selectingSession && !selectingProvider &&
|
|
4594
5285
|
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
4595
5286
|
!selectingRewind && !selectingRewindScope && !inspecting) {
|
|
@@ -4735,6 +5426,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4735
5426
|
insertAtCursor(ch);
|
|
4736
5427
|
}
|
|
4737
5428
|
});
|
|
5429
|
+
// Extension UI surface reads (ticket 10): fresh copies per render,
|
|
5430
|
+
// repainted via the bumpExtUI subscription at boot. Unknown widget
|
|
5431
|
+
// placements never reach here (the host validates fail-closed). Declared
|
|
5432
|
+
// before the paste/memo guards below (render-execution order matters).
|
|
5433
|
+
const extRuntime = extRuntimeRef.current;
|
|
5434
|
+
const extSegments = extRuntime?.getStatusSegments().map((s) => s.text) ?? [];
|
|
5435
|
+
const extWidgets = extRuntime?.getWidgets().filter((w) => w.placement === "panel") ?? [];
|
|
5436
|
+
const extPendingDialog = extRuntime?.getPendingDialog() ?? null;
|
|
5437
|
+
const extDialogOpen = extPendingDialog !== null;
|
|
5438
|
+
const extStatusText = formatExtensionStatusText(extSegments);
|
|
5439
|
+
const extDialogId = extPendingDialog?.id ?? null;
|
|
5440
|
+
// Extension notices: drain the runtime queue into the transcript as
|
|
5441
|
+
// `(owner) message` info lines. Runs every render; drain-then-clear is
|
|
5442
|
+
// idempotent, so re-renders post nothing twice.
|
|
5443
|
+
useEffect(() => {
|
|
5444
|
+
const runtime = extRuntimeRef.current;
|
|
5445
|
+
if (!runtime)
|
|
5446
|
+
return;
|
|
5447
|
+
const notes = runtime.drainNotifications();
|
|
5448
|
+
for (const n of notes)
|
|
5449
|
+
pushInfo(`(${n.owner}) ${n.message}`);
|
|
5450
|
+
});
|
|
5451
|
+
// A new dialog starts with a fresh selection (a session switch that
|
|
5452
|
+
// rejects the old request also drops the modal — see invalidate).
|
|
5453
|
+
useEffect(() => {
|
|
5454
|
+
setExtDlgSelBoth(0);
|
|
5455
|
+
setExtDlgCustomBoth("");
|
|
5456
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5457
|
+
}, [extDialogId]);
|
|
4738
5458
|
// Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
|
|
4739
5459
|
// including newlines — inserts at the cursor verbatim and NEVER submits,
|
|
4740
5460
|
// so multiline pastes can't fire mid-paste. Separate channel from
|
|
@@ -4745,6 +5465,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4745
5465
|
}, {
|
|
4746
5466
|
isActive: !pendingApproval &&
|
|
4747
5467
|
!pendingQuestion &&
|
|
5468
|
+
!extDialogOpen &&
|
|
4748
5469
|
!selecting &&
|
|
4749
5470
|
!selectingSkills &&
|
|
4750
5471
|
!selectingSession &&
|
|
@@ -4784,6 +5505,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4784
5505
|
!baseURLPrompt &&
|
|
4785
5506
|
!pendingApproval &&
|
|
4786
5507
|
!pendingQuestion &&
|
|
5508
|
+
!extDialogOpen &&
|
|
4787
5509
|
!selectingRewind &&
|
|
4788
5510
|
!selectingRewindScope &&
|
|
4789
5511
|
!slashDismissed &&
|
|
@@ -4801,6 +5523,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4801
5523
|
baseURLPrompt,
|
|
4802
5524
|
pendingApproval,
|
|
4803
5525
|
pendingQuestion,
|
|
5526
|
+
extDialogOpen,
|
|
4804
5527
|
selectingRewind,
|
|
4805
5528
|
selectingRewindScope,
|
|
4806
5529
|
slashDismissed,
|
|
@@ -4886,25 +5609,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4886
5609
|
const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
|
|
4887
5610
|
// (The cursor clamp lives inside the memoized InputBox now, next to its
|
|
4888
5611
|
// only use — App body no longer reads cursor state for paint.)
|
|
4889
|
-
// Status-line reasoning segment wired to the effort session state:
|
|
4890
|
-
// non-
|
|
4891
|
-
//
|
|
4892
|
-
//
|
|
4893
|
-
//
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
const reasoningDisplay = effort !== "
|
|
5612
|
+
// Status-line reasoning segment wired to the effort session state: a
|
|
5613
|
+
// non-Auto effort always shows the effort (the knob is sent for every
|
|
5614
|
+
// model on every provider kind); Auto shows response metadata or "auto".
|
|
5615
|
+
// "(unsupported)" survives only as a safety net for an unknown provider —
|
|
5616
|
+
// support is otherwise assumed, with the server as the authority (a 400
|
|
5617
|
+
// naming the knob retries the POST without it and warns).
|
|
5618
|
+
const effortSupportedNow = effort === "auto" || isEffortSupported(model, provider);
|
|
5619
|
+
const reasoningDisplay = effort !== "auto"
|
|
4897
5620
|
? effortSupportedNow
|
|
4898
5621
|
? effort
|
|
4899
5622
|
: `${effort} (unsupported)`
|
|
4900
|
-
: (reasoning ?? "
|
|
5623
|
+
: (reasoning ?? "auto");
|
|
4901
5624
|
// Display-only live tool elapsed: wall-clock now ≈ turn start + elapsed
|
|
4902
5625
|
// ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
|
|
4903
5626
|
// tool is running — the running line then paints with no duration.
|
|
4904
5627
|
const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
|
|
4905
5628
|
? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
|
|
4906
5629
|
: null;
|
|
4907
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
|
|
5630
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, extPendingDialog && !pendingApproval && !pendingQuestion ? (_jsx(QuestionBox, { question: `[${extPendingDialog.owner}] ${extPendingDialog.question}`, options: extPendingDialog.options, allowCustom: extPendingDialog.allowCustom, askCustom: extDlgCustom, askSelIndex: extDlgSel }, `ext-dialog-${extPendingDialog.id}`)) : null, _jsx(TodoPanel, { items: todoSnap }), extWidgets.map((w) => (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["[", w.owner, "] ", w.title] }), _jsx(Text, { children: w.text })] }, `${w.owner}-${w.id}`))), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
|
|
4908
5631
|
const i = modelWin.start + k;
|
|
4909
5632
|
const entryLocal = e.local === true;
|
|
4910
5633
|
const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
|
|
@@ -4935,7 +5658,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4935
5658
|
? `${theme.symbol.descSeparator} key optional — free models need none`
|
|
4936
5659
|
: `${theme.symbol.descSeparator} no key`;
|
|
4937
5660
|
return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
|
|
4938
|
-
}) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "
|
|
5661
|
+
}) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "auto" ? "Auto" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Auto lets the model decide; Low\u2192Max raise reasoning depth on every model." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
|
|
4939
5662
|
// The input is the one boxed, prominent surface (see the memoized
|
|
4940
5663
|
// InputBox above): a quiet gray frame sets it apart from the
|
|
4941
5664
|
// transcript above and the status line below. Pickers and modals
|
|
@@ -4943,5 +5666,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4943
5666
|
// border color.
|
|
4944
5667
|
_jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
|
|
4945
5668
|
? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
|
|
4946
|
-
: `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null })] }));
|
|
5669
|
+
: `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, extensionStatus: extStatusText, goal: goal ? { objective: goal.objective, active: goal.active === true } : null })] }));
|
|
4947
5670
|
}
|