atom-agent 1.1.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 +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- 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 +46 -8
- 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 +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- 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/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- 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 +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- 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
|
@@ -5,8 +5,8 @@ import * as fs from "node:fs";
|
|
|
5
5
|
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
|
-
import { Box, Text, useApp, useInput, usePaste
|
|
9
|
-
import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError,
|
|
8
|
+
import { Box, Text, useApp, useInput, usePaste } from "ink";
|
|
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";
|
|
@@ -24,20 +26,27 @@ import { validateProviderKey } from "./adapters.js";
|
|
|
24
26
|
import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
|
|
25
27
|
import { getGitInfo, withEnvBlock } from "./env-block.js";
|
|
26
28
|
import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
|
|
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";
|
|
27
33
|
import { loadAtomConfig } from "./config.js";
|
|
34
|
+
import { grantProjectTrust, isProjectTrusted, projectTrustQuestion, } from "./project-trust.js";
|
|
28
35
|
import { cancelledTurnLine } from "./rollback.js";
|
|
29
36
|
import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
|
|
30
|
-
import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
37
|
+
import { applyBeforeCompact, beforeCompactInterceptors, forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
31
38
|
import { InputBox } from "./ui/input.js";
|
|
32
39
|
import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
|
|
33
|
-
import {
|
|
40
|
+
import { LiveTailHost } from "./ui/live-host.js";
|
|
34
41
|
import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
|
|
35
42
|
import { activityText } from "./ui/activity.js";
|
|
36
43
|
import { ApprovalBox, QuestionBox } from "./ui/modals.js";
|
|
37
44
|
import { PalettePanel } from "./ui/palette.js";
|
|
38
45
|
import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
|
|
39
46
|
import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
|
|
40
|
-
import {
|
|
47
|
+
import { shortenCwd } from "./ui/status-bar.js";
|
|
48
|
+
import { StatusBarHost } from "./ui/status-host.js";
|
|
49
|
+
import { createStreamStore } from "./ui/stream-store.js";
|
|
41
50
|
import { theme } from "./ui/theme.js";
|
|
42
51
|
import { TodoPanel } from "./ui/todo-panel.js";
|
|
43
52
|
import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
|
|
@@ -48,7 +57,7 @@ export const SLASH_COMMANDS = [
|
|
|
48
57
|
{ name: "/provider", description: "Pick AI provider, paste API key once, chat." },
|
|
49
58
|
{
|
|
50
59
|
name: "/effort",
|
|
51
|
-
description: "Open the reasoning-effort picker (
|
|
60
|
+
description: "Open the reasoning-effort picker (Auto/Low/Medium/High/Max; Auto lets the model decide).",
|
|
52
61
|
},
|
|
53
62
|
{ name: "/tools", description: "List the tools with one-line descriptions." },
|
|
54
63
|
{ name: "/skills", description: "List installed skills (project + global)." },
|
|
@@ -60,13 +69,16 @@ export const SLASH_COMMANDS = [
|
|
|
60
69
|
{ name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
|
|
61
70
|
{ name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
|
|
62
71
|
{ name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
|
|
72
|
+
{ name: "/rename", description: "Rename the current session (/rename <name>)." },
|
|
63
73
|
{ name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
|
|
64
74
|
{ name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
|
|
65
75
|
{ name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
|
|
66
76
|
{ name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
|
|
67
|
-
{ name: "/autoscroll", description: "
|
|
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)." },
|
|
68
79
|
{ name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
|
|
69
80
|
{ name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
|
|
81
|
+
{ name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
|
|
70
82
|
{ name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
|
|
71
83
|
{ name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
|
|
72
84
|
{ name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
|
|
@@ -79,12 +91,12 @@ const SLASH_NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
|
|
|
79
91
|
// conversation only. Files-only is the default highlight (safest).
|
|
80
92
|
export const REWIND_SCOPES = ["files only", "files + conversation", "conversation only"];
|
|
81
93
|
// Submit-time pipeline order (ticket 02): submit() below reads as one
|
|
82
|
-
// ordered sequence — permissions → context assembly →
|
|
94
|
+
// ordered sequence — permissions → context assembly → loop
|
|
83
95
|
// entry — so future submit-time work has exactly one home stage. The
|
|
84
96
|
// rollback-scope rule per stage states what a failed turn keeps vs drops.
|
|
85
97
|
// This descriptor is the order test's source of truth:
|
|
86
98
|
// tests/submit-order.test.ts pins both this order and the matching
|
|
87
|
-
// `SUBMIT STAGE n/
|
|
99
|
+
// `SUBMIT STAGE n/3` markers inside submit().
|
|
88
100
|
export const SUBMIT_PIPELINE_STAGES = [
|
|
89
101
|
{
|
|
90
102
|
name: "permissions",
|
|
@@ -94,10 +106,6 @@ export const SUBMIT_PIPELINE_STAGES = [
|
|
|
94
106
|
name: "context-assembly",
|
|
95
107
|
rollbackScope: "pre-rollbackTo: the env-block refresh survives a failed turn (it is not part of the user turn)",
|
|
96
108
|
},
|
|
97
|
-
{
|
|
98
|
-
name: "budget-check",
|
|
99
|
-
rollbackScope: "pre-rollbackTo: the budget trim survives a failed turn (rollback indices are captured after it)",
|
|
100
|
-
},
|
|
101
109
|
{
|
|
102
110
|
name: "loop-entry",
|
|
103
111
|
rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
|
|
@@ -110,8 +118,27 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
|
|
|
110
118
|
export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
|
|
111
119
|
export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
|
|
112
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";
|
|
113
|
-
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
|
|
114
|
-
export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only:
|
|
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.";
|
|
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).";
|
|
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)";
|
|
125
|
+
// Pure arg parser for /rename (unit-tested): strips the command, trims,
|
|
126
|
+
// then strips one layer of matching outer quotes (single or double) so
|
|
127
|
+
// quoted names work even though the command line has no real parser.
|
|
128
|
+
// Unquoted multi-word names work as-is (everything after /rename is the
|
|
129
|
+
// name). Empty/whitespace-only input yields "" (caller prints usage).
|
|
130
|
+
export function parseRenameArg(raw) {
|
|
131
|
+
const text = raw.trim();
|
|
132
|
+
const arg = text === "/rename" ? "" : text.slice("/rename".length).trim();
|
|
133
|
+
if (arg.length >= 2) {
|
|
134
|
+
const first = arg[0];
|
|
135
|
+
const last = arg[arg.length - 1];
|
|
136
|
+
if ((first === '"' || first === "'") && last === first) {
|
|
137
|
+
return arg.slice(1, -1).trim();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return arg;
|
|
141
|
+
}
|
|
115
142
|
export function filterSlashCommands(prefix) {
|
|
116
143
|
const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
|
|
117
144
|
// Exact match wins outright: a fully-typed command collapses the menu
|
|
@@ -164,6 +191,32 @@ export function paletteEntries(query) {
|
|
|
164
191
|
if (c.description.toLowerCase().includes(q))
|
|
165
192
|
out.push({ c, tier: 3, score: 0, idx });
|
|
166
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
|
+
});
|
|
167
220
|
const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
|
|
168
221
|
out.sort((a, b) => a.tier - b.tier ||
|
|
169
222
|
(a.tier === 0
|
|
@@ -172,20 +225,25 @@ export function paletteEntries(query) {
|
|
|
172
225
|
return out.map(({ c }) => ({
|
|
173
226
|
name: c.name,
|
|
174
227
|
description: c.description,
|
|
175
|
-
category: paletteCategory(c.name),
|
|
228
|
+
category: extNames.has(c.name) ? "Extensions" : paletteCategory(c.name),
|
|
176
229
|
hint: PALETTE_HINTS[c.name] ?? null,
|
|
177
230
|
}));
|
|
178
231
|
}
|
|
179
232
|
// Busy-gate shared by the slash menu and the palette: /compact sets the
|
|
180
233
|
// pending flag for turn-end drain; /queue + /steer manage the running turn;
|
|
181
|
-
// /autoscroll and /thinking only flip view flags (never touch the turn)
|
|
234
|
+
// /autoscroll and /thinking only flip view flags (never touch the turn);
|
|
235
|
+
// /goal only flips session goal state (never touches the turn);
|
|
236
|
+
// /rename only renames the store record + title state (the later turn-end
|
|
237
|
+
// persist preserves the title, so it never races the turn).
|
|
182
238
|
// Every other command waits idle.
|
|
183
239
|
export function slashRunsWhileBusy(name) {
|
|
184
240
|
return (name === "/compact" ||
|
|
185
241
|
name === "/queue" ||
|
|
186
242
|
name === "/steer" ||
|
|
187
243
|
name === "/autoscroll" ||
|
|
188
|
-
name === "/thinking"
|
|
244
|
+
name === "/thinking" ||
|
|
245
|
+
name === "/goal" ||
|
|
246
|
+
name === "/rename");
|
|
189
247
|
}
|
|
190
248
|
// Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
|
|
191
249
|
// better; null = no match). Pure — shared by the command filter, the skill
|
|
@@ -224,6 +282,15 @@ export function filterSkillPicker(entries, query) {
|
|
|
224
282
|
return entries;
|
|
225
283
|
return entries.filter((e) => e.name.toLowerCase().includes(q));
|
|
226
284
|
}
|
|
285
|
+
export function sameSkillMenuSnapshot(a, b) {
|
|
286
|
+
if (a.length !== b.length)
|
|
287
|
+
return false;
|
|
288
|
+
for (let i = 0; i < a.length; i++) {
|
|
289
|
+
if (a[i].name !== b[i].name || a[i].description !== b[i].description)
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
227
294
|
// Pure menu builder (unit-tested): matching commands first (prefix tier in
|
|
228
295
|
// stable order, then fuzzy by score), then matching skills as `/skill:name`
|
|
229
296
|
// entries (prefix tier stable, then fuzzy). Skills join only once the query
|
|
@@ -232,14 +299,37 @@ export function filterSkillPicker(entries, query) {
|
|
|
232
299
|
// name. Skill rows carry a truncated description for discovery. Pure — the
|
|
233
300
|
// App feeds it the cached registry snapshot.
|
|
234
301
|
export const SKILL_MENU_DESC_CHARS = 60;
|
|
235
|
-
export function buildSlashMenu(input, skills) {
|
|
302
|
+
export function buildSlashMenu(input, skills, extensions = []) {
|
|
236
303
|
const items = filterSlashCommands(input).map((c) => ({
|
|
237
304
|
name: c.name,
|
|
238
305
|
description: c.description,
|
|
239
306
|
}));
|
|
240
307
|
if (input.length < 2)
|
|
241
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.
|
|
242
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
|
+
}
|
|
243
333
|
const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
|
|
244
334
|
const pushSkill = (s, shown, more) => {
|
|
245
335
|
const entry = `/skill:${s.name}`;
|
|
@@ -292,6 +382,10 @@ export function commandUsage(name) {
|
|
|
292
382
|
return SKILL_USAGE;
|
|
293
383
|
case "/compact":
|
|
294
384
|
return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
|
|
385
|
+
case "/rename":
|
|
386
|
+
return RENAME_USAGE;
|
|
387
|
+
case "/goal":
|
|
388
|
+
return GOAL_USAGE;
|
|
295
389
|
default:
|
|
296
390
|
return null;
|
|
297
391
|
}
|
|
@@ -311,6 +405,41 @@ export function modelsCacheKey(providerId, baseURL) {
|
|
|
311
405
|
}
|
|
312
406
|
return providerId;
|
|
313
407
|
}
|
|
408
|
+
export function filterSessionEntries(entries, query) {
|
|
409
|
+
const q = query.trim().toLowerCase();
|
|
410
|
+
if (!q)
|
|
411
|
+
return entries;
|
|
412
|
+
const scored = [];
|
|
413
|
+
entries.forEach((e, idx) => {
|
|
414
|
+
const titleScore = fuzzyScore(q, e.title);
|
|
415
|
+
const idScore = fuzzyScore(q, e.id);
|
|
416
|
+
let best = titleScore;
|
|
417
|
+
if (idScore !== null && (best === null || idScore + 5 < best)) {
|
|
418
|
+
best = idScore + 5;
|
|
419
|
+
}
|
|
420
|
+
if (best !== null)
|
|
421
|
+
scored.push({ e, score: best, idx });
|
|
422
|
+
});
|
|
423
|
+
scored.sort((a, b) => a.score - b.score || a.idx - b.idx);
|
|
424
|
+
return scored.map((s) => s.e);
|
|
425
|
+
}
|
|
426
|
+
// Relative age for the picker secondary line (unit-tested). Invalid dates
|
|
427
|
+
// say "unknown" (never throw, never invent).
|
|
428
|
+
export function formatSessionAge(nowMs, updatedAt) {
|
|
429
|
+
const t = Date.parse(updatedAt);
|
|
430
|
+
if (!Number.isFinite(t))
|
|
431
|
+
return "unknown";
|
|
432
|
+
const secs = Math.max(0, Math.floor((nowMs - t) / 1000));
|
|
433
|
+
if (secs < 60)
|
|
434
|
+
return "just now";
|
|
435
|
+
const mins = Math.floor(secs / 60);
|
|
436
|
+
if (mins < 60)
|
|
437
|
+
return `${mins}m ago`;
|
|
438
|
+
const hours = Math.floor(mins / 60);
|
|
439
|
+
if (hours < 24)
|
|
440
|
+
return `${hours}h ago`;
|
|
441
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
442
|
+
}
|
|
314
443
|
// Pure helpers for the elapsed/stall indicator (injectable now for tests).
|
|
315
444
|
export function elapsedSecsSince(startMs, nowMs) {
|
|
316
445
|
return Math.max(0, Math.floor((nowMs - startMs) / 1000));
|
|
@@ -471,14 +600,14 @@ export function helpListText() {
|
|
|
471
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.` +
|
|
472
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.` +
|
|
473
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.` +
|
|
474
|
-
`\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
|
|
475
|
-
`\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.` +
|
|
476
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.` +
|
|
477
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.` +
|
|
478
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.` +
|
|
479
|
-
`\n/effort options:
|
|
480
|
-
`\
|
|
481
|
-
`\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.` +
|
|
482
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.` +
|
|
483
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)".` +
|
|
484
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). ` +
|
|
@@ -494,8 +623,8 @@ export function helpListText() {
|
|
|
494
623
|
// as `token: n/a` — never 0, which would imply measurement). The segment
|
|
495
624
|
// itself lives in ./context-windows.js (single source for the exact
|
|
496
625
|
// `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
|
|
497
|
-
// surface. P% tracks CURRENT context load (last
|
|
498
|
-
// 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.
|
|
499
628
|
function formatKEst(chars) {
|
|
500
629
|
return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
|
|
501
630
|
}
|
|
@@ -506,17 +635,13 @@ export function MissingKey() {
|
|
|
506
635
|
// Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
|
|
507
636
|
// size is a constant (avoids re-serializing ~15KB on every manager build).
|
|
508
637
|
const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
|
|
509
|
-
|
|
638
|
+
// Render-count probe for propagation audits: incremented on every App body
|
|
639
|
+
// execution (hostile-perf suite asserts token paints and ticks stay out of
|
|
640
|
+
// here — only real state transitions may run the orchestrator).
|
|
641
|
+
export const appRenderProbe = { count: 0 };
|
|
642
|
+
export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, extensionsLockdown, enableExtensions, disableExtensions, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
|
|
643
|
+
appRenderProbe.count += 1;
|
|
510
644
|
const { exit } = useApp();
|
|
511
|
-
// Measured terminal width for the status bar's fit-or-drop branch logic.
|
|
512
|
-
// Unknown (piped output) falls back to the bar's own default.
|
|
513
|
-
let termColumns;
|
|
514
|
-
try {
|
|
515
|
-
termColumns = useStdout()?.stdout?.columns;
|
|
516
|
-
}
|
|
517
|
-
catch {
|
|
518
|
-
termColumns = undefined;
|
|
519
|
-
}
|
|
520
645
|
// Saved preferences (provider/model/effort + resolved key/endpoint), loaded
|
|
521
646
|
// once when restorePrefs is on (prod). Explicit props always win; without
|
|
522
647
|
// prefs the CLI defaults apply. Null in tests (flag off) and on any
|
|
@@ -689,14 +814,33 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
689
814
|
skillFilterRef.current = next;
|
|
690
815
|
setSkillFilter(next);
|
|
691
816
|
}
|
|
817
|
+
// /session picker (interactive switcher): snapshot state loaded ONCE per
|
|
818
|
+
// open (listSessions reads each record a single time), filtered in memory
|
|
819
|
+
// per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
|
|
820
|
+
// untouched. Same keyboard/window pattern as the /skills picker.
|
|
821
|
+
const [selectingSession, setSelectingSession] = useState(false);
|
|
822
|
+
const [sessionItems, setSessionItems] = useState([]);
|
|
823
|
+
const [sessionIndex, setSessionIndex] = useState(0);
|
|
824
|
+
const sessionIndexRef = useRef(0);
|
|
825
|
+
const [sessionFilter, setSessionFilter] = useState("");
|
|
826
|
+
const sessionFilterRef = useRef("");
|
|
827
|
+
function setSessionIndexBoth(next) {
|
|
828
|
+
sessionIndexRef.current = next;
|
|
829
|
+
setSessionIndex(next);
|
|
830
|
+
}
|
|
831
|
+
function setSessionFilterBoth(next) {
|
|
832
|
+
sessionFilterRef.current = next;
|
|
833
|
+
setSessionFilter(next);
|
|
834
|
+
}
|
|
692
835
|
// Reasoning-effort picker (/effort): same pattern as the /model picker
|
|
693
836
|
// (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
|
|
694
|
-
// 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.
|
|
695
839
|
const [selectingEffort, setSelectingEffort] = useState(false);
|
|
696
840
|
const [effortIndex, setEffortIndex] = useState(0);
|
|
697
841
|
const effortIndexRef = useRef(0);
|
|
698
|
-
const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "
|
|
699
|
-
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"));
|
|
700
844
|
// /provider picker + key/baseURL prompts (same keyboard pattern).
|
|
701
845
|
const [selectingProvider, setSelectingProvider] = useState(false);
|
|
702
846
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
@@ -844,17 +988,97 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
844
988
|
showThinkingRef.current = next;
|
|
845
989
|
setShowThinking(next);
|
|
846
990
|
}
|
|
847
|
-
// /autoscroll (session-only, default on). On
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
// it (the `↓ N new` indicator offers the jump back; End resumes). Idle
|
|
851
|
-
// appends always follow — freezing only matters while output streams.
|
|
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.
|
|
852
994
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
853
995
|
const autoScrollRef = useRef(true);
|
|
854
996
|
function setAutoScrollBoth(next) {
|
|
855
997
|
autoScrollRef.current = next;
|
|
856
998
|
setAutoScroll(next);
|
|
857
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
|
+
}
|
|
858
1082
|
// Skill registry (cached metadata): one instance per App, scoped to the
|
|
859
1083
|
// same dirs the suite injects via skillDirs. Every discovery path below
|
|
860
1084
|
// reads through it — refresh() revalidates by stat (mtime+size) and only
|
|
@@ -871,9 +1095,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
871
1095
|
try {
|
|
872
1096
|
const found = await skillRegistry.refresh();
|
|
873
1097
|
const { skills } = resolveSkills(found.skills);
|
|
874
|
-
|
|
1098
|
+
const next = skills
|
|
875
1099
|
.filter((s) => s.userInvocable)
|
|
876
|
-
.map((s) => ({ name: s.name, description: s.description }))
|
|
1100
|
+
.map((s) => ({ name: s.name, description: s.description }));
|
|
1101
|
+
// Install only on change: the mount refresh routinely rediscovers the
|
|
1102
|
+
// identical set mid-turn, and a fresh array identity would schedule a
|
|
1103
|
+
// full App render for zero new information.
|
|
1104
|
+
setSkillMenu((prev) => (sameSkillMenuSnapshot(prev, next) ? prev : next));
|
|
877
1105
|
}
|
|
878
1106
|
catch {
|
|
879
1107
|
// menu keeps its previous snapshot (a hiccup must never break input)
|
|
@@ -929,6 +1157,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
929
1157
|
const askSelIndexRef = useRef(0);
|
|
930
1158
|
const [askCustom, setAskCustom] = useState("");
|
|
931
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("");
|
|
932
1168
|
const [busy, setBusy] = useState(false);
|
|
933
1169
|
const busyRef = useRef(false);
|
|
934
1170
|
// Per-turn cancellation (Ctrl+C mid-loop): abort stops after the current
|
|
@@ -964,8 +1200,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
964
1200
|
const [usageTotals, setUsageTotals] = useState(null);
|
|
965
1201
|
// Synchronous mirror of `usageTotals` (same save-time reason as turnsRef).
|
|
966
1202
|
const usageRef = useRef(null);
|
|
967
|
-
// Current context load driving status P% (last POST
|
|
968
|
-
// 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
|
|
969
1205
|
// completes. NK stays cumulative; P must NOT use the cumulative total.
|
|
970
1206
|
const [contextLoad, setContextLoad] = useState(null);
|
|
971
1207
|
const contextLoadRef = useRef(null);
|
|
@@ -981,8 +1217,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
981
1217
|
setGitInfo(null);
|
|
982
1218
|
}
|
|
983
1219
|
}
|
|
984
|
-
// Last POST's reported
|
|
985
|
-
//
|
|
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.
|
|
986
1223
|
const lastPromptTokensRef = useRef(undefined);
|
|
987
1224
|
// Thrash guard: consecutive auto-compactions without the load dropping
|
|
988
1225
|
// below threshold. At 3, auto disables for the session (manual still
|
|
@@ -997,22 +1234,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
997
1234
|
// as a dim line while the transcript is empty; the conversation itself
|
|
998
1235
|
// never auto-restores (only provider/model/effort do, via restorePrefs).
|
|
999
1236
|
const [sessionHint] = useState(() => sessionExists(authHome));
|
|
1237
|
+
// Active session display title (rename feedback + failure messages).
|
|
1238
|
+
// Initialized from the store record when one already exists (sync disk
|
|
1239
|
+
// read, no write — creation stays in the mount effect); thereafter the
|
|
1240
|
+
// store is the source of truth and every sync point below refreshes it.
|
|
1241
|
+
// Deliberately NOT in the status bar: the sole info bar has a fixed width
|
|
1242
|
+
// budget and a ~30-char title wraps `mode: X` onto its own line. Session
|
|
1243
|
+
// identity surfaces instead in the /session picker rows, the rename
|
|
1244
|
+
// confirmation, and the switch/new notices.
|
|
1245
|
+
const [sessionTitle, setSessionTitle] = useState(() => {
|
|
1246
|
+
try {
|
|
1247
|
+
return getActiveSession(authHome)?.title ?? "";
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
return "";
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
const sessionTitleRef = useRef(sessionTitle);
|
|
1000
1254
|
// Reasoning label from response metadata (via onReasoning). The status
|
|
1001
|
-
// line shows the session effort when non-
|
|
1002
|
-
//
|
|
1003
|
-
// 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`.
|
|
1004
1257
|
const [reasoning, setReasoning] = useState(null);
|
|
1005
|
-
// Live streaming state:
|
|
1006
|
-
//
|
|
1007
|
-
// (
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1258
|
+
// Live streaming state: the growing assistant text (onToken) and the
|
|
1259
|
+
// thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
|
|
1260
|
+
// useState. Token paints (up to ~15/sec) notify only the subscribed
|
|
1261
|
+
// LiveTailHost — App's body and every other leaf skip them entirely.
|
|
1262
|
+
// `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
|
|
1263
|
+
// few times per turn (low frequency, and StatusBar legitimately needs them).
|
|
1264
|
+
const [streamStore] = useState(() => createStreamStore());
|
|
1010
1265
|
// Thinking channel (onThinking): reasoning text streamed apart from the
|
|
1011
1266
|
// answer, rendered in its own dim block below. The live value is transient
|
|
1012
|
-
// like
|
|
1267
|
+
// like the draft — cleared on every turn boundary below — but each completed
|
|
1013
1268
|
// round commits to the transcript via commitThinking (stays in the TUI,
|
|
1014
1269
|
// never the model history) instead of being replaced and lost.
|
|
1015
|
-
const [thinking, setThinking] = useState(null);
|
|
1016
1270
|
const thinkingRef = useRef(null);
|
|
1017
1271
|
// Move the accumulated round thinking into the transcript as a quiet
|
|
1018
1272
|
// annotation turn (no-op when empty). Called when a new POST starts and at
|
|
@@ -1021,18 +1275,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1021
1275
|
function commitThinking() {
|
|
1022
1276
|
const text = thinkingRef.current;
|
|
1023
1277
|
thinkingRef.current = null;
|
|
1024
|
-
|
|
1278
|
+
try {
|
|
1279
|
+
// Drop any trailing paint: the commit carries the full text, and a
|
|
1280
|
+
// late flush must never resurrect stale reasoning after the clear.
|
|
1281
|
+
thinkingThrottleRef.current?.cancel();
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
// ignore (the store clear below still wins)
|
|
1285
|
+
}
|
|
1286
|
+
streamStore.setThinking(null);
|
|
1025
1287
|
if (typeof text === "string" && text.length > 0) {
|
|
1026
1288
|
appendTurns({ role: "assistant", content: text, thinking: true });
|
|
1027
1289
|
}
|
|
1028
1290
|
}
|
|
1029
1291
|
function clearThinking() {
|
|
1030
1292
|
thinkingRef.current = null;
|
|
1031
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
thinkingThrottleRef.current?.cancel();
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
// ignore (the store clear below still wins)
|
|
1298
|
+
}
|
|
1299
|
+
streamStore.setThinking(null);
|
|
1032
1300
|
}
|
|
1033
1301
|
const [phase, setPhase] = useState("idle");
|
|
1034
1302
|
const [phaseDetail, setPhaseDetail] = useState("");
|
|
1035
1303
|
const [toolHint, setToolHint] = useState(null);
|
|
1304
|
+
// Synchronous mirrors for the hot loop callbacks: zen fires
|
|
1305
|
+
// onPhase("streaming") on EVERY content chunk, so the handler must not
|
|
1306
|
+
// issue same-value setStates per token (React re-invokes the component
|
|
1307
|
+
// before bailing out — 15 App-body executions/sec for nothing).
|
|
1308
|
+
// setPhaseBoth is the only writer; equal values skip setState entirely.
|
|
1309
|
+
const phaseRef = useRef("idle");
|
|
1310
|
+
const phaseDetailRef = useRef("");
|
|
1311
|
+
function setPhaseBoth(next, detail) {
|
|
1312
|
+
if (phaseRef.current !== next) {
|
|
1313
|
+
phaseRef.current = next;
|
|
1314
|
+
setPhase(next);
|
|
1315
|
+
}
|
|
1316
|
+
if (phaseDetailRef.current !== detail) {
|
|
1317
|
+
phaseDetailRef.current = detail;
|
|
1318
|
+
setPhaseDetail(detail);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1036
1321
|
// Task B smoothness (a): throttled streaming draft. onToken pushes every
|
|
1037
1322
|
// partial (activity/stall tracking stays per-token); paints coalesce to one
|
|
1038
1323
|
// per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
|
|
@@ -1045,7 +1330,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1045
1330
|
setTimeoutFn,
|
|
1046
1331
|
clearTimeoutFn,
|
|
1047
1332
|
onFlush: (text) => {
|
|
1048
|
-
|
|
1333
|
+
// Paint path only: the commit carries the byte-exact full text.
|
|
1334
|
+
// Writing the store notifies LiveTailHost alone — never App.
|
|
1335
|
+
streamStore.setDraft(text);
|
|
1049
1336
|
},
|
|
1050
1337
|
});
|
|
1051
1338
|
draftThrottleRef.current = th;
|
|
@@ -1060,6 +1347,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1060
1347
|
// ignore (draft stays as-is; the commit carries the full text)
|
|
1061
1348
|
}
|
|
1062
1349
|
}
|
|
1350
|
+
// Thinking paint coalescing: reasoning chunks arrive at token rate but
|
|
1351
|
+
// paint through the same trailing window into the store (producer/consumer
|
|
1352
|
+
// symmetry with the draft). thinkingRef stays synchronous per chunk so
|
|
1353
|
+
// commitThinking can never lose reasoning to a pending trailing paint.
|
|
1354
|
+
const thinkingThrottleRef = useRef(null);
|
|
1355
|
+
function thinkingThrottler() {
|
|
1356
|
+
let th = thinkingThrottleRef.current;
|
|
1357
|
+
if (!th) {
|
|
1358
|
+
th = createDraftThrottler({
|
|
1359
|
+
now,
|
|
1360
|
+
setTimeoutFn,
|
|
1361
|
+
clearTimeoutFn,
|
|
1362
|
+
onFlush: (text) => {
|
|
1363
|
+
streamStore.setThinking(text);
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
thinkingThrottleRef.current = th;
|
|
1367
|
+
}
|
|
1368
|
+
return th;
|
|
1369
|
+
}
|
|
1063
1370
|
// Phase 5: models-list session cache (successful live lists only, keyed
|
|
1064
1371
|
// by modelsCacheKey). Failures fall back uncached, exactly as before.
|
|
1065
1372
|
const modelsCacheRef = useRef(new Map());
|
|
@@ -1135,6 +1442,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1135
1442
|
// on the next activity.
|
|
1136
1443
|
const [elapsedSecs, setElapsedSecs] = useState(0);
|
|
1137
1444
|
const [stalled, setStalled] = useState(false);
|
|
1445
|
+
// Mirror for the per-token stall reset (same same-value-setState hazard
|
|
1446
|
+
// as phase above — noteTurnActivity runs on every chunk).
|
|
1447
|
+
const stalledRef = useRef(false);
|
|
1448
|
+
function setStalledBoth(next) {
|
|
1449
|
+
if (stalledRef.current !== next) {
|
|
1450
|
+
stalledRef.current = next;
|
|
1451
|
+
setStalled(next);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1138
1454
|
const turnStartRef = useRef(0);
|
|
1139
1455
|
const lastActivityRef = useRef(0);
|
|
1140
1456
|
const turnTimerRef = useRef(null);
|
|
@@ -1150,7 +1466,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1150
1466
|
// below re-wrap via trackHistory (never assign a raw array here).
|
|
1151
1467
|
const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
|
|
1152
1468
|
// Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
|
|
1153
|
-
// pinned to history[0] (the
|
|
1469
|
+
// pinned to history[0] (the system prompt), NEVER
|
|
1154
1470
|
// to user content. Refreshed once per turn in submit() + after doResume, so
|
|
1155
1471
|
// the loop's many POSTs reuse one block (no per-POST shell-outs).
|
|
1156
1472
|
// Failure-silent via withEnvBlock (missing git → block shrinks).
|
|
@@ -1260,7 +1576,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1260
1576
|
catch {
|
|
1261
1577
|
// ignore clock errors (stall hint just won't trigger)
|
|
1262
1578
|
}
|
|
1263
|
-
|
|
1579
|
+
// Guarded: runs on every token/thinking/phase event, but only a
|
|
1580
|
+
// true→false transition may schedule a render.
|
|
1581
|
+
setStalledBoth(false);
|
|
1264
1582
|
}
|
|
1265
1583
|
function startTurnTimer() {
|
|
1266
1584
|
clearTurnTimer();
|
|
@@ -1274,7 +1592,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1274
1592
|
turnStartRef.current = start;
|
|
1275
1593
|
lastActivityRef.current = start;
|
|
1276
1594
|
setElapsedSecs(0);
|
|
1277
|
-
|
|
1595
|
+
setStalledBoth(false);
|
|
1278
1596
|
try {
|
|
1279
1597
|
turnTimerRef.current = (setIntervalFn ?? setInterval)(() => {
|
|
1280
1598
|
let t;
|
|
@@ -1286,7 +1604,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1286
1604
|
}
|
|
1287
1605
|
setElapsedSecs(elapsedSecsSince(turnStartRef.current, t));
|
|
1288
1606
|
if (isStalledSince(lastActivityRef.current, t)) {
|
|
1289
|
-
|
|
1607
|
+
// Guarded: the stalled flag flips false→true once per silence
|
|
1608
|
+
// window, not on every tick within it.
|
|
1609
|
+
if (!stalledRef.current)
|
|
1610
|
+
setStalledBoth(true);
|
|
1290
1611
|
}
|
|
1291
1612
|
}, TURN_TICK_MS);
|
|
1292
1613
|
}
|
|
@@ -1301,6 +1622,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1301
1622
|
// in that gap; runLoopWithChat checks the signal before the first POST).
|
|
1302
1623
|
useEffect(() => {
|
|
1303
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;
|
|
1304
1645
|
// Local observability: close the session trace on unmount (covers
|
|
1305
1646
|
// every exit path — /exit, Ctrl+C idle, test teardown) and flush.
|
|
1306
1647
|
// Best-effort, never throws; idempotent with closeTelemetry callers.
|
|
@@ -1329,6 +1670,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1329
1670
|
}
|
|
1330
1671
|
try {
|
|
1331
1672
|
draftThrottleRef.current?.cancel();
|
|
1673
|
+
thinkingThrottleRef.current?.cancel();
|
|
1332
1674
|
}
|
|
1333
1675
|
catch {
|
|
1334
1676
|
// ignore
|
|
@@ -1432,9 +1774,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1432
1774
|
askCustomRef.current = next;
|
|
1433
1775
|
setAskCustom(next);
|
|
1434
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
|
+
}
|
|
1435
1785
|
function setEffortBoth(next) {
|
|
1436
|
-
|
|
1437
|
-
|
|
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);
|
|
1438
1792
|
}
|
|
1439
1793
|
function setEffortIndexBoth(next) {
|
|
1440
1794
|
effortIndexRef.current = next;
|
|
@@ -1499,6 +1853,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1499
1853
|
setModelFilterBoth("");
|
|
1500
1854
|
setSelectingSkills(false);
|
|
1501
1855
|
setSkillFilterBoth("");
|
|
1856
|
+
setSelectingSession(false);
|
|
1857
|
+
setSessionFilterBoth("");
|
|
1502
1858
|
setSelectingEffort(false);
|
|
1503
1859
|
setSelectingProvider(false);
|
|
1504
1860
|
setKeyPromptBoth(null);
|
|
@@ -1531,6 +1887,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1531
1887
|
pushInfo("(skill discovery failed — no skills listed)");
|
|
1532
1888
|
});
|
|
1533
1889
|
}
|
|
1890
|
+
// /session picker open: snapshot the store list ONCE (most-recent-first
|
|
1891
|
+
// from listSessions) with the active record marked, then filter in memory
|
|
1892
|
+
// per keystroke (no disk reads while typing). Idle-only (callers gate on
|
|
1893
|
+
// busy like every picker — a mid-turn switch would race the loop's own
|
|
1894
|
+
// history writes). Empty store degrades to a notice (unreachable while
|
|
1895
|
+
// the mount bootstrap holds, but never a blank popup).
|
|
1896
|
+
function openSessionPicker(initialFilter) {
|
|
1897
|
+
setInputBoth("");
|
|
1898
|
+
closeAllPickers();
|
|
1899
|
+
let records;
|
|
1900
|
+
try {
|
|
1901
|
+
records = listSessions(authHome);
|
|
1902
|
+
}
|
|
1903
|
+
catch {
|
|
1904
|
+
pushInfo("(could not list sessions — store unreadable)");
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (records.length === 0) {
|
|
1908
|
+
pushInfo("(no sessions yet — your current conversation is saved automatically)");
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
let activeId = null;
|
|
1912
|
+
try {
|
|
1913
|
+
activeId = getActiveSessionId(authHome);
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
activeId = null;
|
|
1917
|
+
}
|
|
1918
|
+
setSessionItems(records.map((s) => ({
|
|
1919
|
+
id: s.id,
|
|
1920
|
+
title: s.title,
|
|
1921
|
+
updatedAt: s.updatedAt,
|
|
1922
|
+
createdAt: s.createdAt,
|
|
1923
|
+
turnCount: s.turns.length,
|
|
1924
|
+
active: s.id === activeId,
|
|
1925
|
+
})));
|
|
1926
|
+
setSessionFilterBoth(initialFilter);
|
|
1927
|
+
setSessionIndexBoth(0);
|
|
1928
|
+
setSelectingSession(true);
|
|
1929
|
+
}
|
|
1534
1930
|
// Unified /model entries for this render: active provider's current list
|
|
1535
1931
|
// first, then every other keyed provider's cached-or-fallback list (pure,
|
|
1536
1932
|
// local-only — see modelPickerEntries). Called from the /model open path,
|
|
@@ -1614,7 +2010,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1614
2010
|
: def.defaultModel);
|
|
1615
2011
|
setModelBoth(nextModel);
|
|
1616
2012
|
setProviderBoth(pickedId);
|
|
1617
|
-
// Provider switch resets the load latch: the last reported
|
|
2013
|
+
// Provider switch resets the load latch: the last reported input tokens
|
|
1618
2014
|
// belonged to the old provider/model tokenizer, so the estimate applies
|
|
1619
2015
|
// until the new provider reports (usageTotals spend is untouched).
|
|
1620
2016
|
resetContextLoadToEstimate();
|
|
@@ -1652,6 +2048,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1652
2048
|
usageRef.current = next;
|
|
1653
2049
|
setUsageTotals(next);
|
|
1654
2050
|
}
|
|
2051
|
+
function setSessionTitleBoth(next) {
|
|
2052
|
+
sessionTitleRef.current = next;
|
|
2053
|
+
setSessionTitle(next);
|
|
2054
|
+
}
|
|
1655
2055
|
function setContextLoadBoth(next) {
|
|
1656
2056
|
contextLoadRef.current = next;
|
|
1657
2057
|
setContextLoad(next);
|
|
@@ -1661,7 +2061,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1661
2061
|
setAutoDisabled(next);
|
|
1662
2062
|
}
|
|
1663
2063
|
// Recompute contextLoad after a committed turn (or compaction): last
|
|
1664
|
-
// POST
|
|
2064
|
+
// POST input tokens (incl. cache) when available, else the 4ch/token estimate.
|
|
1665
2065
|
function refreshContextLoad() {
|
|
1666
2066
|
// No usage yet → no load (status keeps `token: n/a`).
|
|
1667
2067
|
if (!usageRef.current) {
|
|
@@ -1672,7 +2072,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1672
2072
|
setContextLoadBoth(load);
|
|
1673
2073
|
return load;
|
|
1674
2074
|
}
|
|
1675
|
-
// Load-reset contract (hold-last-known): the reported
|
|
2075
|
+
// Load-reset contract (hold-last-known): the reported input tokens survive
|
|
1676
2076
|
// silent POSTs — estimates never override a fresher report — and reset ONLY
|
|
1677
2077
|
// here: compaction, /clear, resume, and model/provider switch. After a reset
|
|
1678
2078
|
// the chars/4 estimate applies until the next report arrives.
|
|
@@ -1688,9 +2088,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1688
2088
|
function pushInfo(content) {
|
|
1689
2089
|
appendTurns({ role: "tool", content });
|
|
1690
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
|
+
}
|
|
1691
2131
|
// The session's ContextManager: window-derived budgets for the active
|
|
1692
|
-
// model
|
|
1693
|
-
// (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
|
|
1694
2134
|
// the current model; history is measured live on every use. The schema size
|
|
1695
2135
|
// is memoized once — TOOL_DEFINITIONS never changes at runtime, so every
|
|
1696
2136
|
// turn must not re-serialize 15KB to ask.
|
|
@@ -1721,6 +2161,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1721
2161
|
const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
|
|
1722
2162
|
? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
|
|
1723
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`;
|
|
1724
2169
|
const cfg = atomConfigLoad;
|
|
1725
2170
|
const cfgSources = cfg.sources.project && cfg.sources.global
|
|
1726
2171
|
? "project + global"
|
|
@@ -1756,7 +2201,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1756
2201
|
`skill injections live in history: ${skillLoads}\n` +
|
|
1757
2202
|
`${cfgLine}\n` +
|
|
1758
2203
|
`${cacheLine}\n` +
|
|
1759
|
-
`${loadLine}
|
|
2204
|
+
`${loadLine}\n` +
|
|
2205
|
+
`${allowanceLine}`);
|
|
1760
2206
|
}
|
|
1761
2207
|
// Load a resolved skill into the session (tickets 03/06) with
|
|
1762
2208
|
// progressive-disclosure tiers (Claude-Code-style):
|
|
@@ -1825,6 +2271,247 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1825
2271
|
// committed diff previews (Turn.diff) are display-only and never saved:
|
|
1826
2272
|
// they can hold whole file contents (bloat) and would render stale
|
|
1827
2273
|
// after later edits, so /resume restores label-only turns.
|
|
2274
|
+
//
|
|
2275
|
+
// Multi-session mirror (src/sessions.ts): every conversation auto-belongs
|
|
2276
|
+
// to a durable session record (~/.atom/sessions/<id>.json + active
|
|
2277
|
+
// pointer). persistSession() also mirrors the same committed snapshot
|
|
2278
|
+
// into the active record via persistStoreSession() below, so completed
|
|
2279
|
+
// turns, clean exits, and successful compactions bump updatedAt there too.
|
|
2280
|
+
// Failures/cancels never reach here (same rollback rule as legacy).
|
|
2281
|
+
// Active id lives in a ref only — never a session list in runtime state.
|
|
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
|
+
}
|
|
2314
|
+
function storeCwd() {
|
|
2315
|
+
try {
|
|
2316
|
+
return process.cwd();
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
return "";
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
// Ensure exactly one active persistent session exists (fresh mount creates
|
|
2323
|
+
// one with the current provider/model/effort/mode + cwd). Never throws,
|
|
2324
|
+
// never blocks render — disk errors leave the ref null and the next
|
|
2325
|
+
// persist retries.
|
|
2326
|
+
function ensureStoreSession() {
|
|
2327
|
+
try {
|
|
2328
|
+
const existing = activeSessionIdRef.current;
|
|
2329
|
+
// The record may vanish under a running process (external delete,
|
|
2330
|
+
// corrupted file): a stale cached id must re-ensure instead of
|
|
2331
|
+
// persisting into the void (every later turn would silently skip).
|
|
2332
|
+
if (existing) {
|
|
2333
|
+
try {
|
|
2334
|
+
if (getSession(existing, authHome))
|
|
2335
|
+
return existing;
|
|
2336
|
+
}
|
|
2337
|
+
catch {
|
|
2338
|
+
// fall through to re-ensure below
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
const s = ensureActiveSession({
|
|
2342
|
+
cwd: storeCwd(),
|
|
2343
|
+
provider: providerRef.current,
|
|
2344
|
+
model: modelRef.current,
|
|
2345
|
+
effort: effortRef.current,
|
|
2346
|
+
mode: modeRef.current,
|
|
2347
|
+
}, authHome);
|
|
2348
|
+
activeSessionIdRef.current = s.id;
|
|
2349
|
+
// The store owns the title (a /rename from an earlier mount must show
|
|
2350
|
+
// after restart); sync the display state on every ensure.
|
|
2351
|
+
setSessionTitleBoth(s.title);
|
|
2352
|
+
return s.id;
|
|
2353
|
+
}
|
|
2354
|
+
catch {
|
|
2355
|
+
return null;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
// Mirror the committed snapshot into the active multi-session record
|
|
2359
|
+
// (single updateSession so updatedAt bumps on every committed turn).
|
|
2360
|
+
// Disk errors ignored, like the legacy save above.
|
|
2361
|
+
function persistStoreSession() {
|
|
2362
|
+
try {
|
|
2363
|
+
const id = ensureStoreSession();
|
|
2364
|
+
if (!id)
|
|
2365
|
+
return;
|
|
2366
|
+
updateSession(id, {
|
|
2367
|
+
history: historyRef.current,
|
|
2368
|
+
turns: turnsRef.current.map((t) => {
|
|
2369
|
+
const { diff: _dropped, ...rest } = t;
|
|
2370
|
+
return rest;
|
|
2371
|
+
}),
|
|
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),
|
|
2378
|
+
provider: providerRef.current,
|
|
2379
|
+
model: modelRef.current,
|
|
2380
|
+
effort: effortRef.current,
|
|
2381
|
+
mode: modeRef.current,
|
|
2382
|
+
cwd: storeCwd(),
|
|
2383
|
+
}, authHome);
|
|
2384
|
+
}
|
|
2385
|
+
catch {
|
|
2386
|
+
// ignore disk errors (in-memory session still applies)
|
|
2387
|
+
}
|
|
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
|
+
}
|
|
2437
|
+
// Mount bootstrap: claim/create the active session before any turn can
|
|
2438
|
+
// persist, so every normal conversation belongs to a durable session.
|
|
2439
|
+
// Startup never auto-restores conversation state (fresh + legacy hint,
|
|
2440
|
+
// matching current UX) — this only ensures the record exists.
|
|
2441
|
+
useEffect(() => {
|
|
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
|
+
});
|
|
2513
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2514
|
+
}, []);
|
|
1828
2515
|
function persistSession() {
|
|
1829
2516
|
try {
|
|
1830
2517
|
saveSession({
|
|
@@ -1833,6 +2520,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1833
2520
|
effort: effortRef.current,
|
|
1834
2521
|
mode: modeRef.current,
|
|
1835
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,
|
|
1836
2526
|
history: historyRef.current,
|
|
1837
2527
|
turns: turnsRef.current.map((t) => {
|
|
1838
2528
|
const { diff: _dropped, ...rest } = t;
|
|
@@ -1843,6 +2533,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1843
2533
|
catch {
|
|
1844
2534
|
// ignore disk errors (in-memory session still applies)
|
|
1845
2535
|
}
|
|
2536
|
+
persistStoreSession();
|
|
1846
2537
|
}
|
|
1847
2538
|
// Local observability persistence: flush the current telemetry session
|
|
1848
2539
|
// file (atomic, best-effort). Called on turn boundaries and session events —
|
|
@@ -1900,6 +2591,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1900
2591
|
// failure: old history untouched + inline error (oversize retry-once
|
|
1901
2592
|
// already handled inside requestCompactSummary, which appends /clear).
|
|
1902
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
|
+
}
|
|
1903
2608
|
async function doCompact(focusText, isAuto) {
|
|
1904
2609
|
if (countUserTurns(historyRef.current) <= 1) {
|
|
1905
2610
|
if (!isAuto)
|
|
@@ -1930,52 +2635,109 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1930
2635
|
}
|
|
1931
2636
|
const baseURL = chatBaseURL(providerRef.current);
|
|
1932
2637
|
try {
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
if (
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
+
}));
|
|
2730
|
+
// Atomic swap: build the new history first, then replace. The head's
|
|
2731
|
+
// touched files (collected from the committed tool_calls the loop
|
|
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);
|
|
2740
|
+
const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
|
|
1979
2741
|
// Replacement: re-wrap so the ledger restarts from the compacted array
|
|
1980
2742
|
// (the old ledger is discarded with the old array).
|
|
1981
2743
|
historyRef.current = trackHistory(next);
|
|
@@ -1988,7 +2750,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1988
2750
|
pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
|
|
1989
2751
|
}
|
|
1990
2752
|
// P% must drop immediately: the old lastPromptTokens reflects the
|
|
1991
|
-
// 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.
|
|
1992
2755
|
lastPromptTokensRef.current = undefined;
|
|
1993
2756
|
const newLoad = estimateTokensForChars(historyChars(historyRef.current));
|
|
1994
2757
|
setContextLoadBoth(newLoad);
|
|
@@ -1999,14 +2762,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1999
2762
|
autoStreakRef.current = 0;
|
|
2000
2763
|
}
|
|
2001
2764
|
else {
|
|
2002
|
-
|
|
2003
|
-
if (isThrashDisabled(autoStreakRef.current)) {
|
|
2004
|
-
setAutoDisabledBoth(true);
|
|
2005
|
-
appendTurns({
|
|
2006
|
-
role: "tool",
|
|
2007
|
-
content: "(auto-compact thrashing — disabled, use /compact or /clear)",
|
|
2008
|
-
});
|
|
2009
|
-
}
|
|
2765
|
+
bumpAutoStreak();
|
|
2010
2766
|
}
|
|
2011
2767
|
}
|
|
2012
2768
|
else {
|
|
@@ -2077,6 +2833,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2077
2833
|
setEffortBoth(s.effort);
|
|
2078
2834
|
setModeBoth(s.mode);
|
|
2079
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));
|
|
2080
2841
|
// Replacement: wrap the restored array (see the init comment).
|
|
2081
2842
|
historyRef.current = trackHistory([...s.history]);
|
|
2082
2843
|
// Task 6: refresh the pinned env block on the restored system line
|
|
@@ -2106,11 +2867,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2106
2867
|
content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
|
|
2107
2868
|
});
|
|
2108
2869
|
}
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
|
|
2112
|
-
pendingNotices.push({ role: "tool", content:
|
|
2113
|
-
}
|
|
2870
|
+
// Restored history is used whole: no caps, no trimming. A resumed
|
|
2871
|
+
// session knows what was touched without re-exploring the tree.
|
|
2872
|
+
for (const section of collectStoredTouchedFiles(historyRef.current)) {
|
|
2873
|
+
pendingNotices.push({ role: "tool", content: section });
|
|
2874
|
+
}
|
|
2114
2875
|
// Remount the turns <Static> (same mechanism as /clear and /new): Ink's
|
|
2115
2876
|
// Static only renders newly appended indices, so restoring a transcript
|
|
2116
2877
|
// over a non-empty rendered buffer (e.g. the /new boundary line) would
|
|
@@ -2128,6 +2889,175 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2128
2889
|
]);
|
|
2129
2890
|
telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
|
|
2130
2891
|
persistTelemetry();
|
|
2892
|
+
// Mirror the restored legacy state into the active multi-session record
|
|
2893
|
+
// (create+activate when none exists). Startup itself never auto-restores
|
|
2894
|
+
// the store — only this explicit /resume does.
|
|
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");
|
|
2900
|
+
}
|
|
2901
|
+
// /session switch: make the picked record the live conversation. Exactly
|
|
2902
|
+
// one history replacement (never merge, never duplicate): the target's
|
|
2903
|
+
// history/turns REPLACE the live arrays wholesale, following the doResume
|
|
2904
|
+
// precedent (endpoint recompute, env-block refresh, budget trim, lineage
|
|
2905
|
+
// drop, Static remount, title sync). Differences from /resume:
|
|
2906
|
+
// - the outgoing live state snapshots into the OLD record first, but only
|
|
2907
|
+
// when it holds turns (a fresh mount's system-only live state is NOT the
|
|
2908
|
+
// old record's content — persisting it would wipe that record);
|
|
2909
|
+
// - re-picking the current session is a no-op (reloading from disk would
|
|
2910
|
+
// drop unpersisted live turns);
|
|
2911
|
+
// - updatedAt is NOT bumped (switching is navigation, not a mutation —
|
|
2912
|
+
// listings keep true recency order);
|
|
2913
|
+
// - a missing/unreadable target errors WITHOUT touching the live session.
|
|
2914
|
+
// The legacy session.json follows the switch (same persistSession path as
|
|
2915
|
+
// every completed turn) so /resume stays coherent with the live view.
|
|
2916
|
+
async function switchToSession(id) {
|
|
2917
|
+
let target = null;
|
|
2918
|
+
try {
|
|
2919
|
+
target = getSession(id, authHome);
|
|
2920
|
+
}
|
|
2921
|
+
catch {
|
|
2922
|
+
target = null;
|
|
2923
|
+
}
|
|
2924
|
+
if (!target) {
|
|
2925
|
+
pushInfo("(session no longer available — staying on the current session)");
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
const currentId = activeSessionIdRef.current;
|
|
2929
|
+
if (currentId !== null && currentId === target.id) {
|
|
2930
|
+
pushInfo(`(already on "${target.title}")`);
|
|
2931
|
+
return;
|
|
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
|
+
}
|
|
2955
|
+
// Snapshot the outgoing conversation into its own record first (same
|
|
2956
|
+
// rule as /new's pre-reset save). Guarded: only when the live state
|
|
2957
|
+
// actually holds turns of the outgoing record.
|
|
2958
|
+
if (currentId !== null && currentId !== target.id && turnsRef.current.length > 0) {
|
|
2959
|
+
persistStoreSession();
|
|
2960
|
+
}
|
|
2961
|
+
try {
|
|
2962
|
+
setActiveSession(target.id, authHome);
|
|
2963
|
+
}
|
|
2964
|
+
catch {
|
|
2965
|
+
// setActiveSession never throws by contract; defensive only.
|
|
2966
|
+
}
|
|
2967
|
+
activeSessionIdRef.current = target.id;
|
|
2968
|
+
setProviderBoth(target.provider);
|
|
2969
|
+
const baseURL = chatBaseURL(target.provider);
|
|
2970
|
+
if (target.provider === "openai-compatible") {
|
|
2971
|
+
setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
|
|
2972
|
+
}
|
|
2973
|
+
else if (target.provider === "opencode-zen") {
|
|
2974
|
+
setActiveEndpoint(endpoint);
|
|
2975
|
+
}
|
|
2976
|
+
else {
|
|
2977
|
+
setActiveEndpoint(chatEndpointFor(target.provider, baseURL));
|
|
2978
|
+
}
|
|
2979
|
+
setModelBoth(target.model);
|
|
2980
|
+
setEffortBoth(target.effort);
|
|
2981
|
+
setModeBoth(target.mode);
|
|
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));
|
|
2988
|
+
// Replacement: the target's arrays replace the live ones wholesale (a
|
|
2989
|
+
// fresh-created record carries empty history — fall back to a fresh
|
|
2990
|
+
// system line so the system-first invariant always holds).
|
|
2991
|
+
historyRef.current =
|
|
2992
|
+
target.history.length > 0
|
|
2993
|
+
? trackHistory([...target.history])
|
|
2994
|
+
: trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]);
|
|
2995
|
+
if (target.history.length > 0) {
|
|
2996
|
+
refreshSystemEnv();
|
|
2997
|
+
}
|
|
2998
|
+
lastPromptTokensRef.current = undefined;
|
|
2999
|
+
if (!usageRef.current) {
|
|
3000
|
+
setContextLoadBoth(null);
|
|
3001
|
+
}
|
|
3002
|
+
else {
|
|
3003
|
+
setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
|
|
3004
|
+
}
|
|
3005
|
+
autoStreakRef.current = 0;
|
|
3006
|
+
setAutoDisabledBoth(false);
|
|
3007
|
+
pendingCompactRef.current = null;
|
|
3008
|
+
const pendingNotices = [];
|
|
3009
|
+
// New lineage (see src/rollback.ts): checkpoint marks index the old
|
|
3010
|
+
// history — drop them, loudly when non-empty. Disk files untouched.
|
|
3011
|
+
const switchDrops = clearSnapshots();
|
|
3012
|
+
if (switchDrops > 0) {
|
|
3013
|
+
pendingNotices.push({
|
|
3014
|
+
role: "tool",
|
|
3015
|
+
content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
// TODO isolation (mirrors /new): the checklist is process-global memory
|
|
3019
|
+
// that is never persisted — carrying it across sessions would show the
|
|
3020
|
+
// new session the old session's plan, and the agent would act on it.
|
|
3021
|
+
// Reset it, loudly when non-empty.
|
|
3022
|
+
if (getTodos().length > 0) {
|
|
3023
|
+
clearTodos();
|
|
3024
|
+
setTodoSnap([]);
|
|
3025
|
+
pendingNotices.push({
|
|
3026
|
+
role: "tool",
|
|
3027
|
+
content: "(/session — checklist reset; TODOs are per-conversation and do not cross sessions)",
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
else {
|
|
3031
|
+
clearTodos();
|
|
3032
|
+
setTodoSnap([]);
|
|
3033
|
+
}
|
|
3034
|
+
// The target's history/turns REPLACE the live arrays wholesale — used
|
|
3035
|
+
// whole, never trimmed.
|
|
3036
|
+
for (const section of collectStoredTouchedFiles(historyRef.current)) {
|
|
3037
|
+
pendingNotices.push({ role: "tool", content: section });
|
|
3038
|
+
}
|
|
3039
|
+
// Same Static remount as /clear, /resume, and /new: the replaced list
|
|
3040
|
+
// must not reuse the old buffer.
|
|
3041
|
+
setScrollEndBoth(null);
|
|
3042
|
+
setClearGen((g) => g + 1);
|
|
3043
|
+
setTurnsBoth([
|
|
3044
|
+
...target.turns,
|
|
3045
|
+
{
|
|
3046
|
+
role: "tool",
|
|
3047
|
+
content: `(switched to session "${target.title}" — ${target.turns.length} turns)`,
|
|
3048
|
+
},
|
|
3049
|
+
...pendingNotices,
|
|
3050
|
+
]);
|
|
3051
|
+
setSessionTitleBoth(target.title);
|
|
3052
|
+
telemetry.recordEvent("info", `session switched to ${target.id} (${target.turns.length} turns)`);
|
|
3053
|
+
persistTelemetry();
|
|
3054
|
+
// Legacy single-file save follows the switch so /resume restores what
|
|
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);
|
|
3059
|
+
persistSession();
|
|
3060
|
+
replaceExtensionContext("switch");
|
|
2131
3061
|
}
|
|
2132
3062
|
// /rewind conversation scope (ticket 01): truncate history + transcript to
|
|
2133
3063
|
// the checkpoint's turn. The cut drops the whole containing turn (submit's
|
|
@@ -2188,9 +3118,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2188
3118
|
}
|
|
2189
3119
|
pushInfo(filesMsg);
|
|
2190
3120
|
}
|
|
2191
|
-
function warnEffortUnsupported(modelName) {
|
|
2192
|
-
pushInfo(`reasoning effort is not known to be supported by ${modelName} — setting kept, not sent`);
|
|
2193
|
-
}
|
|
2194
3121
|
// Manual /compact entry: busy → set pending flag, run at turn end (drain
|
|
2195
3122
|
// boundary, never mid-turn); idle → run now under the busy guard so a
|
|
2196
3123
|
// concurrent submit cannot interleave. Works for unknown-window models.
|
|
@@ -2322,19 +3249,54 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2322
3249
|
? "(thinking shown — model reasoning stays visible in the transcript)"
|
|
2323
3250
|
: "(thinking hidden — reasoning still runs, it just isn't rendered)");
|
|
2324
3251
|
}
|
|
3252
|
+
// /rename for the CURRENT session only (never creates one: renameSession
|
|
3253
|
+
// touches exactly the active record). Bare /rename prints usage — ATOM has
|
|
3254
|
+
// no generic text-prompt overlay (key/baseURL prompts are
|
|
3255
|
+
// provider-specific), so an interactive flow would be a new UI system.
|
|
3256
|
+
// Empty/whitespace-only names are rejected safely (previous name kept).
|
|
3257
|
+
// On failure the previous name is preserved (title state only changes on
|
|
3258
|
+
// success); history/turns are never part of the write.
|
|
3259
|
+
function runRenameCommand(raw) {
|
|
3260
|
+
const name = parseRenameArg(raw);
|
|
3261
|
+
if (!name) {
|
|
3262
|
+
pushInfo(RENAME_USAGE);
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
let id = null;
|
|
3266
|
+
try {
|
|
3267
|
+
id = ensureStoreSession();
|
|
3268
|
+
}
|
|
3269
|
+
catch {
|
|
3270
|
+
id = null;
|
|
3271
|
+
}
|
|
3272
|
+
if (!id) {
|
|
3273
|
+
pushInfo("(rename failed — session store unavailable; name unchanged)");
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
let renamed = null;
|
|
3277
|
+
try {
|
|
3278
|
+
renamed = renameSession(id, name, authHome);
|
|
3279
|
+
}
|
|
3280
|
+
catch {
|
|
3281
|
+
renamed = null;
|
|
3282
|
+
}
|
|
3283
|
+
if (!renamed) {
|
|
3284
|
+
const current = sessionTitleRef.current || "untitled";
|
|
3285
|
+
pushInfo(`(rename failed — still "${current}")`);
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
setSessionTitleBoth(renamed.title);
|
|
3289
|
+
pushInfo(`(renamed session to "${renamed.title}")`);
|
|
3290
|
+
}
|
|
2325
3291
|
// /autoscroll [on|off]: follow switch for the scrollback viewport. View-
|
|
2326
3292
|
// only state — safe while busy (never touches the turn, like /queue).
|
|
2327
|
-
// Bare
|
|
3293
|
+
// Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
|
|
2328
3294
|
// view at its current end (mid-turn appends then accumulate below).
|
|
2329
3295
|
function runAutoScrollCommand(raw) {
|
|
2330
3296
|
const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
: "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
|
|
2335
|
-
return;
|
|
2336
|
-
}
|
|
2337
|
-
if (arg === "on") {
|
|
3297
|
+
// Bare command toggles; explicit on|off sets directly.
|
|
3298
|
+
const effective = arg === "" ? (autoScrollRef.current ? "off" : "on") : arg;
|
|
3299
|
+
if (effective === "on") {
|
|
2338
3300
|
if (autoScrollRef.current) {
|
|
2339
3301
|
pushInfo("(autoscroll already on)");
|
|
2340
3302
|
return;
|
|
@@ -2344,7 +3306,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2344
3306
|
pushInfo("(autoscroll on — following the latest)");
|
|
2345
3307
|
return;
|
|
2346
3308
|
}
|
|
2347
|
-
if (
|
|
3309
|
+
if (effective === "off") {
|
|
2348
3310
|
if (!autoScrollRef.current) {
|
|
2349
3311
|
pushInfo("(autoscroll already off)");
|
|
2350
3312
|
return;
|
|
@@ -2359,6 +3321,60 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2359
3321
|
}
|
|
2360
3322
|
pushInfo(AUTOSCROLL_USAGE);
|
|
2361
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
|
+
}
|
|
2362
3378
|
// /models: local-discovery status + refresh. Bare `/models` reports the
|
|
2363
3379
|
// last snapshot (kicking a first probe when discovery never ran);
|
|
2364
3380
|
// `/models refresh` re-probes all three runtimes, then reports. Results
|
|
@@ -2414,6 +3430,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2414
3430
|
case "/clear":
|
|
2415
3431
|
// Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
|
|
2416
3432
|
// fresh env block. Fresh array → fresh ledger via trackHistory.
|
|
3433
|
+
// Store mirror: LAZY, matching legacy — /clear wipes the live
|
|
3434
|
+
// transcript only and writes nothing to the store here; the cleared
|
|
3435
|
+
// (empty) conversation persists on the next completed turn via
|
|
3436
|
+
// persistSession()/persistStoreSession().
|
|
2417
3437
|
historyRef.current = trackHistory([
|
|
2418
3438
|
{ role: "system", content: withEnvBlock(systemPrompt) },
|
|
2419
3439
|
]);
|
|
@@ -2424,11 +3444,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2424
3444
|
setScrollEndBoth(null);
|
|
2425
3445
|
setClearGen((g) => g + 1);
|
|
2426
3446
|
setError(null);
|
|
2427
|
-
setDraft(null);
|
|
3447
|
+
streamStore.setDraft(null);
|
|
2428
3448
|
clearThinking();
|
|
2429
3449
|
setToolHint(null);
|
|
2430
|
-
|
|
2431
|
-
setPhaseDetail("");
|
|
3450
|
+
setPhaseBoth("idle", "");
|
|
2432
3451
|
// /clear drops the transcript: load resets (no context), streak
|
|
2433
3452
|
// resets, pending compact drains, and turn-scoped skill grants go
|
|
2434
3453
|
// with it (no invisible auto-approvals survive a wiped transcript).
|
|
@@ -2443,6 +3462,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2443
3462
|
if (clearedDrops > 0) {
|
|
2444
3463
|
pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
|
|
2445
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
|
+
}
|
|
2446
3474
|
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
2447
3475
|
lastPromptTokensRef.current = undefined;
|
|
2448
3476
|
setContextLoadBoth(null);
|
|
@@ -2459,7 +3487,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2459
3487
|
// restores — same path/format as every completed turn, no new
|
|
2460
3488
|
// schema). No sessions/ archive step: session.ts only has
|
|
2461
3489
|
// session.json, so no archiving is invented here.
|
|
3490
|
+
// persistSession() also mirrors the pre-/new conversation into the
|
|
3491
|
+
// OLD active store record before the reset below.
|
|
2462
3492
|
persistSession();
|
|
3493
|
+
// Fresh store record for the new conversation (settings carried over,
|
|
3494
|
+
// empty history/turns/usage; default title = formatSessionTitle(now)
|
|
3495
|
+
// via createSession). Explicit setActiveSession: createSession only
|
|
3496
|
+
// claims the pointer when none is set.
|
|
3497
|
+
try {
|
|
3498
|
+
const created = createSession({
|
|
3499
|
+
cwd: storeCwd(),
|
|
3500
|
+
provider: providerRef.current,
|
|
3501
|
+
model: modelRef.current,
|
|
3502
|
+
effort: effortRef.current,
|
|
3503
|
+
mode: modeRef.current,
|
|
3504
|
+
}, authHome);
|
|
3505
|
+
setActiveSession(created.id, authHome);
|
|
3506
|
+
activeSessionIdRef.current = created.id;
|
|
3507
|
+
setSessionTitleBoth(created.title);
|
|
3508
|
+
// Bind the new record BEFORE the boundary emit (ticket 05).
|
|
3509
|
+
extRuntimeRef.current?.setSessionId(created.id);
|
|
3510
|
+
}
|
|
3511
|
+
catch {
|
|
3512
|
+
// ignore disk errors (in-memory reset below still applies)
|
|
3513
|
+
}
|
|
2463
3514
|
// Fresh system re-read (system.ts base + current AGENTS.md overlay)
|
|
2464
3515
|
// plus a fresh Task 6 env block. Fresh array → fresh ledger.
|
|
2465
3516
|
historyRef.current = trackHistory([
|
|
@@ -2471,15 +3522,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2471
3522
|
content: "(new session started — previous conversation kept, /resume to restore it)",
|
|
2472
3523
|
},
|
|
2473
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
|
+
}
|
|
2474
3534
|
// Fresh list: a held view has nothing to hold onto — re-follow.
|
|
2475
3535
|
setScrollEndBoth(null);
|
|
2476
3536
|
setClearGen((g) => g + 1);
|
|
2477
3537
|
setError(null);
|
|
2478
|
-
setDraft(null);
|
|
3538
|
+
streamStore.setDraft(null);
|
|
2479
3539
|
clearThinking();
|
|
2480
3540
|
setToolHint(null);
|
|
2481
|
-
|
|
2482
|
-
setPhaseDetail("");
|
|
3541
|
+
setPhaseBoth("idle", "");
|
|
2483
3542
|
// /new-vs-/clear split: /clear wipes the transcript but KEEPS usage
|
|
2484
3543
|
// totals; /new resets the counters too (fresh conversation). Session
|
|
2485
3544
|
// SETTINGS (effort/mode/provider/model) are kept — only the
|
|
@@ -2505,6 +3564,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2505
3564
|
telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
|
|
2506
3565
|
persistTelemetry();
|
|
2507
3566
|
void refreshSkillMenu();
|
|
3567
|
+
replaceExtensionContext("new");
|
|
2508
3568
|
return;
|
|
2509
3569
|
case "/compact":
|
|
2510
3570
|
// Bare /compact with no focus text (slash-menu path). Free-text
|
|
@@ -2568,6 +3628,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2568
3628
|
case "/autoscroll":
|
|
2569
3629
|
runAutoScrollCommand("/autoscroll");
|
|
2570
3630
|
return;
|
|
3631
|
+
case "/goal":
|
|
3632
|
+
runGoalCommand("/goal");
|
|
3633
|
+
return;
|
|
2571
3634
|
case "/mode":
|
|
2572
3635
|
if (modeRef.current === "plan") {
|
|
2573
3636
|
pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
|
|
@@ -2612,6 +3675,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2612
3675
|
case "/resume":
|
|
2613
3676
|
doResume();
|
|
2614
3677
|
return;
|
|
3678
|
+
case "/session":
|
|
3679
|
+
openSessionPicker("");
|
|
3680
|
+
return;
|
|
3681
|
+
case "/rename":
|
|
3682
|
+
// Bare exact match (slash-menu Enter on the highlighted name):
|
|
3683
|
+
// usage — the typed-args form is preserved by the menu branch and
|
|
3684
|
+
// the submit prefix route above.
|
|
3685
|
+
runRenameCommand("/rename");
|
|
3686
|
+
return;
|
|
2615
3687
|
case "/telemetry":
|
|
2616
3688
|
pushInfo(telemetrySummaryText());
|
|
2617
3689
|
return;
|
|
@@ -2642,7 +3714,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2642
3714
|
return;
|
|
2643
3715
|
}
|
|
2644
3716
|
default:
|
|
2645
|
-
|
|
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
|
+
}
|
|
2646
3728
|
}
|
|
2647
3729
|
}
|
|
2648
3730
|
// approve hook for runAgenticLoop: scoped rules first (deny refuses as a
|
|
@@ -2798,7 +3880,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2798
3880
|
h?.reject(new Error("question cancelled by user"));
|
|
2799
3881
|
}
|
|
2800
3882
|
// Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
|
|
2801
|
-
// above; each `SUBMIT STAGE n/
|
|
3883
|
+
// above; each `SUBMIT STAGE n/3` marker below names its stage plus its
|
|
2802
3884
|
// rollback-scope rule). Local "/" routing precedes the pipeline: exact
|
|
2803
3885
|
// slash commands, /allow-/deny-/rules, and skill invocations never enter
|
|
2804
3886
|
// it (no turn, no history, nothing to roll back).
|
|
@@ -2818,11 +3900,29 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2818
3900
|
await runCompactCommand(focus);
|
|
2819
3901
|
return;
|
|
2820
3902
|
}
|
|
2821
|
-
//
|
|
3903
|
+
// /rename with optional name: prefix match ("/rename" or "/rename ...").
|
|
3904
|
+
// Instant local store op — safe while busy (the turn-end persist never
|
|
3905
|
+
// carries a title, so it cannot clobber the rename).
|
|
3906
|
+
if (text === "/rename" || text.startsWith("/rename ")) {
|
|
3907
|
+
runRenameCommand(text);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
// SUBMIT STAGE 1/3 — permissions (rollback scope: pre-turn, appends
|
|
2822
3911
|
// nothing). Busy guard + API-key check: rejections return before any
|
|
2823
3912
|
// history mutation, so there is nothing to roll back.
|
|
2824
3913
|
if (!text)
|
|
2825
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
|
+
}
|
|
2826
3926
|
// Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
|
|
2827
3927
|
// the thought is never lost); /queue + /steer manage and inject. Other
|
|
2828
3928
|
// "/" input still needs idle (pickers/modals would race the turn), so it
|
|
@@ -2833,13 +3933,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2833
3933
|
runQueueCommand(text);
|
|
2834
3934
|
return;
|
|
2835
3935
|
}
|
|
2836
|
-
// /autoscroll and /
|
|
2837
|
-
// 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
|
|
2838
3938
|
// slashRunsWhileBusy).
|
|
2839
3939
|
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
2840
3940
|
runAutoScrollCommand(text);
|
|
2841
3941
|
return;
|
|
2842
3942
|
}
|
|
3943
|
+
if (text === "/goal" || text.startsWith("/goal ")) {
|
|
3944
|
+
runGoalCommand(text);
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
2843
3947
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
2844
3948
|
runThinkingCommand(text);
|
|
2845
3949
|
return;
|
|
@@ -2861,12 +3965,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2861
3965
|
return;
|
|
2862
3966
|
}
|
|
2863
3967
|
// /autoscroll takes an optional subcommand (/autoscroll on|off), like the
|
|
2864
|
-
// /queue family — SLASH_NAMES only holds the exact command. /
|
|
2865
|
-
//
|
|
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.
|
|
2866
3971
|
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
2867
3972
|
runAutoScrollCommand(text);
|
|
2868
3973
|
return;
|
|
2869
3974
|
}
|
|
3975
|
+
if (text === "/goal" || text.startsWith("/goal ")) {
|
|
3976
|
+
runGoalCommand(text);
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
2870
3979
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
2871
3980
|
runThinkingCommand(text);
|
|
2872
3981
|
return;
|
|
@@ -2887,6 +3996,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2887
3996
|
void runModelsCommand(arg);
|
|
2888
3997
|
return;
|
|
2889
3998
|
}
|
|
3999
|
+
// /session takes an optional initial filter ("/session auth" opens the
|
|
4000
|
+
// picker pre-filtered) — SLASH_NAMES only holds the exact command.
|
|
4001
|
+
if (text === "/session" || text.startsWith("/session ")) {
|
|
4002
|
+
const initial = text === "/session" ? "" : text.slice("/session".length).trim();
|
|
4003
|
+
openSessionPicker(initial);
|
|
4004
|
+
return;
|
|
4005
|
+
}
|
|
2890
4006
|
// Exact full-command + Enter runs it. A single-token "/name" not in
|
|
2891
4007
|
// SLASH_NAMES resolves through the skill registry (ticket 03, legacy
|
|
2892
4008
|
// form — the namespaced `/skill:name` below is canonical); anything
|
|
@@ -2912,6 +4028,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2912
4028
|
void invokeSkillByName(namespaced);
|
|
2913
4029
|
return;
|
|
2914
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
|
+
}
|
|
2915
4041
|
const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
2916
4042
|
if (skillName !== undefined) {
|
|
2917
4043
|
void invokeSkillByName(skillName);
|
|
@@ -2934,7 +4060,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2934
4060
|
busyRef.current = true;
|
|
2935
4061
|
setBusy(true);
|
|
2936
4062
|
setError(null);
|
|
2937
|
-
setDraft(null);
|
|
4063
|
+
streamStore.setDraft(null);
|
|
2938
4064
|
clearThinking();
|
|
2939
4065
|
// Fresh turn, fresh latch: the queue drain at the end auto-sends only
|
|
2940
4066
|
// when this turn was NOT cancelled (see the turn-end finally).
|
|
@@ -2944,13 +4070,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2944
4070
|
// Expiry happens in the turn-end finally below, plus /clear + /new.
|
|
2945
4071
|
try {
|
|
2946
4072
|
draftThrottler().reset();
|
|
4073
|
+
thinkingThrottler().reset();
|
|
2947
4074
|
}
|
|
2948
4075
|
catch {
|
|
2949
4076
|
// ignore (first token still paints; at worst one window late)
|
|
2950
4077
|
}
|
|
2951
4078
|
setToolHint(null);
|
|
2952
|
-
|
|
2953
|
-
setPhaseDetail("");
|
|
4079
|
+
setPhaseBoth("thinking", "");
|
|
2954
4080
|
// Phase 5: start the elapsed/stall timer (status-bar only, never the
|
|
2955
4081
|
// transcript). Cleared in finally below and on unmount.
|
|
2956
4082
|
startTurnTimer();
|
|
@@ -2962,25 +4088,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2962
4088
|
pendingDiffRef.current = null;
|
|
2963
4089
|
lastPartialRef.current = "";
|
|
2964
4090
|
refreshGitInfo();
|
|
2965
|
-
// SUBMIT STAGE 2/
|
|
4091
|
+
// SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
|
|
2966
4092
|
// survives failure). Refresh the pinned env block ONCE per turn (not per
|
|
2967
4093
|
// POST — the loop reuses history[0] for all its POSTs, so this is the
|
|
2968
|
-
// only git call for the turn). Before
|
|
2969
|
-
// accounts for the fresh block size; before rollbackTo so the refresh
|
|
4094
|
+
// only git call for the turn). Before rollbackTo so the refresh
|
|
2970
4095
|
// survives a failed-turn rollback (it is not part of the user turn).
|
|
4096
|
+
// History is uncapped: the full conversation rides every turn.
|
|
2971
4097
|
refreshSystemEnv();
|
|
2972
|
-
// SUBMIT STAGE 3/
|
|
2973
|
-
// survives failure). History budget at turn start, BEFORE the push +
|
|
2974
|
-
// rollbackTo capture below (so the existing splice-rollback indices stay
|
|
2975
|
-
// valid): drop oldest user-turns first, reserving room for the incoming user message
|
|
2976
|
-
// so the loop core's own budget check stays a no-op on entry - exactly
|
|
2977
|
-
// one dim notice per truncating turn. /clear drops the notice with the
|
|
2978
|
-
// transcript (usage totals still survive). Caps come from the session
|
|
2979
|
-
// ContextManager (window-derived), with the same live todo pinning.
|
|
2980
|
-
contextManager().trimForSend(historyRef.current, (msg) => {
|
|
2981
|
-
appendTurns({ role: "tool", content: `? ${msg}` });
|
|
2982
|
-
}, { messages: 1, chars: text.length }, openTodoNeedles());
|
|
2983
|
-
// SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
4098
|
+
// SUBMIT STAGE 3/3 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
2984
4099
|
// back on failure). Turn boundary: on POST failure (HTTP/network/empty/
|
|
2985
4100
|
// truncated) the whole user turn (user message plus any partial
|
|
2986
4101
|
// assistant/tool loop entries) is removed, so the next request starts
|
|
@@ -2989,15 +4104,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2989
4104
|
// catch). Cancellation (LoopCancelledError) shares the same splice
|
|
2990
4105
|
// contract.
|
|
2991
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;
|
|
2992
4113
|
// Local observability: open this turn's trace (no-op when disabled).
|
|
2993
4114
|
// Provider/model switches surface here per turn; session-level switches
|
|
2994
4115
|
// are derived from the same updates (see setSessionMeta).
|
|
2995
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;
|
|
2996
4134
|
const telemetryTurnId = telemetry.startTurn(text, {
|
|
2997
4135
|
provider: providerRef.current,
|
|
2998
4136
|
model: modelRef.current,
|
|
2999
4137
|
effort: effortRef.current,
|
|
3000
4138
|
mode: modeRef.current,
|
|
4139
|
+
...(turnGoal ? { goal: turnGoal } : {}),
|
|
3001
4140
|
});
|
|
3002
4141
|
const telemetrySink = {
|
|
3003
4142
|
onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
|
|
@@ -3037,6 +4176,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3037
4176
|
const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
|
|
3038
4177
|
approve,
|
|
3039
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
|
+
},
|
|
3040
4218
|
// Local observability sink: the loop reports completed model/tool
|
|
3041
4219
|
// calls (iterations, durations, usage) into the open turn trace.
|
|
3042
4220
|
telemetry: telemetrySink,
|
|
@@ -3056,19 +4234,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3056
4234
|
draftThrottler().push(partial);
|
|
3057
4235
|
}
|
|
3058
4236
|
catch {
|
|
3059
|
-
|
|
4237
|
+
// Never lose tokens: paint now rather than drop the partial.
|
|
4238
|
+
streamStore.setDraft(partial);
|
|
3060
4239
|
}
|
|
3061
4240
|
lastPartialRef.current = partial;
|
|
3062
4241
|
noteTurnActivity();
|
|
3063
4242
|
},
|
|
3064
4243
|
onThinking: (partial) => {
|
|
3065
4244
|
thinkingRef.current = partial;
|
|
3066
|
-
|
|
4245
|
+
try {
|
|
4246
|
+
thinkingThrottler().push(partial);
|
|
4247
|
+
}
|
|
4248
|
+
catch {
|
|
4249
|
+
// Never lose reasoning: paint now rather than drop the partial.
|
|
4250
|
+
streamStore.setThinking(partial);
|
|
4251
|
+
}
|
|
3067
4252
|
noteTurnActivity();
|
|
3068
4253
|
},
|
|
3069
4254
|
onPhase: (p, detail) => {
|
|
3070
|
-
|
|
3071
|
-
|
|
4255
|
+
// Change-guarded (zen emits "streaming" per content chunk):
|
|
4256
|
+
// steady-state tokens issue zero setStates here.
|
|
4257
|
+
setPhaseBoth(p, detail ?? "");
|
|
3072
4258
|
noteTurnActivity();
|
|
3073
4259
|
if (p === "thinking") {
|
|
3074
4260
|
// New POST: the previous round's thinking (if any) commits to
|
|
@@ -3104,29 +4290,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3104
4290
|
// POST accumulates (tool-round POSTs and successful retries each
|
|
3105
4291
|
// count once — each was billed; failed attempts report nothing, so
|
|
3106
4292
|
// nothing is deduped). usageTotals drives NK only, never P%.
|
|
3107
|
-
|
|
3108
|
-
const next = { ...prev };
|
|
3109
|
-
if (u.prompt_tokens !== undefined) {
|
|
3110
|
-
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
3111
|
-
// Load metric source: last POST's reported prompt_tokens (the
|
|
3112
|
-
// per-POST value, NOT the accumulated total).
|
|
3113
|
-
lastPromptTokensRef.current = u.prompt_tokens;
|
|
3114
|
-
}
|
|
3115
|
-
if (u.completion_tokens !== undefined) {
|
|
3116
|
-
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
3117
|
-
}
|
|
3118
|
-
if (u.total_tokens !== undefined) {
|
|
3119
|
-
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
3120
|
-
}
|
|
3121
|
-
// Prefix-cache counters accumulate like spend (real reports only;
|
|
3122
|
-
// absent fields mean "not reported", never zero).
|
|
3123
|
-
if (u.cacheReadTokens !== undefined) {
|
|
3124
|
-
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
3125
|
-
}
|
|
3126
|
-
if (u.cacheWriteTokens !== undefined) {
|
|
3127
|
-
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
3128
|
-
}
|
|
3129
|
-
setUsageBoth(next);
|
|
4293
|
+
accumulateUsage(u);
|
|
3130
4294
|
},
|
|
3131
4295
|
onReasoning: (label) => {
|
|
3132
4296
|
setReasoning(label);
|
|
@@ -3216,12 +4380,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3216
4380
|
noteTurnActivity();
|
|
3217
4381
|
},
|
|
3218
4382
|
signal: controller.signal,
|
|
3219
|
-
// Window-aware trim caps for this model's real window (the loop
|
|
3220
|
-
// falls back to legacy caps without it — see AgenticOpts.context).
|
|
3221
|
-
context: {
|
|
3222
|
-
model: modelRef.current,
|
|
3223
|
-
toolsChars: TOOLS_SCHEMA_CHARS,
|
|
3224
|
-
},
|
|
3225
4383
|
});
|
|
3226
4384
|
// Turn-end flush: any trailing throttled partial paints before the
|
|
3227
4385
|
// commit replaces the draft (byte-exact via `reply` regardless). The
|
|
@@ -3318,6 +4476,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3318
4476
|
}
|
|
3319
4477
|
}
|
|
3320
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
|
+
}
|
|
3321
4494
|
turnCancelRef.current = null;
|
|
3322
4495
|
approvalResolveRef.current = null;
|
|
3323
4496
|
setPendingApproval(null);
|
|
@@ -3340,14 +4513,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3340
4513
|
catch {
|
|
3341
4514
|
// ignore
|
|
3342
4515
|
}
|
|
3343
|
-
setDraft(null);
|
|
4516
|
+
streamStore.setDraft(null);
|
|
3344
4517
|
clearThinking();
|
|
3345
4518
|
setToolHint(null);
|
|
3346
4519
|
clearTurnTimer();
|
|
3347
|
-
|
|
4520
|
+
setStalledBoth(false);
|
|
3348
4521
|
setElapsedSecs(0);
|
|
3349
|
-
|
|
3350
|
-
setPhaseDetail("");
|
|
4522
|
+
setPhaseBoth("idle", "");
|
|
3351
4523
|
// Queue drain (Claude-Code-style): a clean turn auto-sends the next
|
|
3352
4524
|
// queued follow-up (chaining while the queue is non-empty); a cancelled
|
|
3353
4525
|
// turn keeps its queue visible but never auto-sends. A steer stranded
|
|
@@ -3476,6 +4648,53 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3476
4648
|
}
|
|
3477
4649
|
return;
|
|
3478
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
|
+
}
|
|
3479
4698
|
// 2b. Provider key prompt (masked • per char, paste works, Esc safe).
|
|
3480
4699
|
const kp = keyPromptRef.current;
|
|
3481
4700
|
if (kp && keyPrompt) {
|
|
@@ -3667,28 +4886,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3667
4886
|
// reported prompt_tokens no longer measures this context); the
|
|
3668
4887
|
// estimate applies until the new model reports.
|
|
3669
4888
|
resetContextLoadToEstimate();
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
|
|
3673
|
-
warnEffortUnsupported(picked.model);
|
|
3674
|
-
}
|
|
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).
|
|
3675
4891
|
}
|
|
3676
4892
|
else {
|
|
3677
4893
|
// Cross-provider pick: switch with the resolved key (env wins,
|
|
3678
4894
|
// else stored — remote sections only render for keyed providers;
|
|
3679
4895
|
// local sections need no key) and keep the picked model; the
|
|
3680
4896
|
// live refresh lands in the background via the standard switch
|
|
3681
|
-
// path.
|
|
3682
|
-
//
|
|
4897
|
+
// path. Effort carries over untouched — it is valid on every
|
|
4898
|
+
// provider kind.
|
|
3683
4899
|
const switchedKey = keyForProvider(picked.providerId);
|
|
3684
4900
|
if (switchedKey || !providerNeedsKey(picked.providerId)) {
|
|
3685
4901
|
const pickedProvider = picked.providerId;
|
|
3686
4902
|
const pickedModel = picked.model;
|
|
3687
4903
|
void (async () => {
|
|
3688
4904
|
await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
|
|
3689
|
-
if (effortRef.current !== "default") {
|
|
3690
|
-
warnEffortUnsupported(pickedModel);
|
|
3691
|
-
}
|
|
3692
4905
|
})();
|
|
3693
4906
|
}
|
|
3694
4907
|
}
|
|
@@ -3751,6 +4964,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3751
4964
|
}
|
|
3752
4965
|
return;
|
|
3753
4966
|
}
|
|
4967
|
+
// 3a2. Session picker (interactive switcher, same pattern as /skills:
|
|
4968
|
+
// type to filter, ↑/↓ + Enter switches, Esc cancels with the live
|
|
4969
|
+
// session completely unchanged). The list is the open-time snapshot —
|
|
4970
|
+
// filtering never touches disk. Enter on an empty filtered list only
|
|
4971
|
+
// prints a widen hint (no switch, no state change).
|
|
4972
|
+
if (selectingSession) {
|
|
4973
|
+
// Rebuilt per keypress from the same render's state the paint uses, so
|
|
4974
|
+
// highlight/filter/paint never disagree mid-tick.
|
|
4975
|
+
const entries = filterSessionEntries(sessionItems, sessionFilterRef.current);
|
|
4976
|
+
if (key.upArrow) {
|
|
4977
|
+
if (entries.length > 0) {
|
|
4978
|
+
setSessionIndexBoth((sessionIndexRef.current - 1 + entries.length) % entries.length);
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
else if (key.downArrow) {
|
|
4982
|
+
if (entries.length > 0) {
|
|
4983
|
+
setSessionIndexBoth((sessionIndexRef.current + 1) % entries.length);
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
else if (key.escape) {
|
|
4987
|
+
setSessionFilterBoth("");
|
|
4988
|
+
setSelectingSession(false);
|
|
4989
|
+
}
|
|
4990
|
+
else if (key.return) {
|
|
4991
|
+
const picked = entries[sessionIndexRef.current];
|
|
4992
|
+
setSessionFilterBoth("");
|
|
4993
|
+
setSelectingSession(false);
|
|
4994
|
+
if (picked) {
|
|
4995
|
+
exitHistoryBrowse();
|
|
4996
|
+
void switchToSession(picked.id);
|
|
4997
|
+
}
|
|
4998
|
+
else {
|
|
4999
|
+
pushInfo("(no sessions match — backspace to widen the filter.)");
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
else if (key.backspace || key.delete) {
|
|
5003
|
+
setSessionFilterBoth(sessionFilterRef.current.slice(0, -1));
|
|
5004
|
+
setSessionIndexBoth(0);
|
|
5005
|
+
}
|
|
5006
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
5007
|
+
setSessionFilterBoth(sessionFilterRef.current + ch);
|
|
5008
|
+
setSessionIndexBoth(0);
|
|
5009
|
+
}
|
|
5010
|
+
return;
|
|
5011
|
+
}
|
|
3754
5012
|
// 3b. Effort picker (/effort): same keyboard pattern as the /model
|
|
3755
5013
|
// picker (↑/↓ + Enter, Esc cancels).
|
|
3756
5014
|
if (selectingEffort) {
|
|
@@ -3766,10 +5024,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3766
5024
|
else if (key.return) {
|
|
3767
5025
|
const picked = EFFORT_OPTIONS[effortIndexRef.current];
|
|
3768
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).
|
|
3769
5029
|
setEffortBoth(picked);
|
|
3770
|
-
if (picked !== "default" && !isEffortSupported(modelRef.current)) {
|
|
3771
|
-
warnEffortUnsupported(modelRef.current);
|
|
3772
|
-
}
|
|
3773
5030
|
}
|
|
3774
5031
|
setSelectingEffort(false);
|
|
3775
5032
|
}
|
|
@@ -3830,7 +5087,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3830
5087
|
// a newline anywhere means free text (slash commands never span lines).
|
|
3831
5088
|
const cur = inputRef.current;
|
|
3832
5089
|
const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
|
|
3833
|
-
? buildSlashMenu(cur, skillMenu)
|
|
5090
|
+
? buildSlashMenu(cur, skillMenu, listExtensionCommands())
|
|
3834
5091
|
: { items: [], moreSkills: 0 };
|
|
3835
5092
|
const matches = menu.items;
|
|
3836
5093
|
if (matches.length > 0) {
|
|
@@ -3857,8 +5114,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3857
5114
|
}
|
|
3858
5115
|
else if (key.return || key.tab) {
|
|
3859
5116
|
const pick = matches[slashIndexRef.current % matches.length];
|
|
3860
|
-
// /compact, /queue, /steer, and /
|
|
3861
|
-
// 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.
|
|
3862
5119
|
if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
|
|
3863
5120
|
if (pick.skill) {
|
|
3864
5121
|
// Skill entries stage for confirm (opencode-style): Enter/Tab
|
|
@@ -3889,6 +5146,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3889
5146
|
setInputBoth("");
|
|
3890
5147
|
runAutoScrollCommand(raw);
|
|
3891
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
|
+
}
|
|
3892
5157
|
else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
|
|
3893
5158
|
inputRef.current.startsWith(pick.name)) {
|
|
3894
5159
|
// Preserve the typed rule args (e.g. "/allow bash:npm test*");
|
|
@@ -3897,6 +5162,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3897
5162
|
setInputBoth("");
|
|
3898
5163
|
runRulesCommand(raw);
|
|
3899
5164
|
}
|
|
5165
|
+
else if (pick.name === "/rename" &&
|
|
5166
|
+
(inputRef.current === "/rename" || inputRef.current.startsWith("/rename "))) {
|
|
5167
|
+
// Preserve the typed name (e.g. '/rename "Build auth"'); a bare
|
|
5168
|
+
// highlighted name falls through to usage.
|
|
5169
|
+
const raw = inputRef.current;
|
|
5170
|
+
setInputBoth("");
|
|
5171
|
+
runRenameCommand(raw);
|
|
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
|
+
}
|
|
3900
5186
|
else {
|
|
3901
5187
|
runSlashCommand(pick.name);
|
|
3902
5188
|
}
|
|
@@ -3929,7 +5215,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3929
5215
|
// input, and input keys never reach the inspector.
|
|
3930
5216
|
if (key.ctrl && (ch === "o" || ch === "O")) {
|
|
3931
5217
|
if (!busyRef.current && !turnCancelRef.current &&
|
|
3932
|
-
!pendingApproval && !pendingQuestion &&
|
|
5218
|
+
!pendingApproval && !pendingQuestion && !extDialogOpen &&
|
|
3933
5219
|
!selecting && !selectingSkills && !selectingProvider &&
|
|
3934
5220
|
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
3935
5221
|
!selectingRewind && !selectingRewindScope) {
|
|
@@ -3994,8 +5280,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3994
5280
|
// open); Enter runs through the shared busy-gate, so only
|
|
3995
5281
|
// compact/queue/steer fire while busy.
|
|
3996
5282
|
if (key.ctrl && (ch === "p" || ch === "P")) {
|
|
3997
|
-
if (!pendingApproval && !pendingQuestion &&
|
|
3998
|
-
!selecting && !selectingSkills && !selectingProvider &&
|
|
5283
|
+
if (!pendingApproval && !pendingQuestion && !extDialogOpen &&
|
|
5284
|
+
!selecting && !selectingSkills && !selectingSession && !selectingProvider &&
|
|
3999
5285
|
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
4000
5286
|
!selectingRewind && !selectingRewindScope && !inspecting) {
|
|
4001
5287
|
if (paletteOpenRef.current)
|
|
@@ -4140,6 +5426,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4140
5426
|
insertAtCursor(ch);
|
|
4141
5427
|
}
|
|
4142
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]);
|
|
4143
5458
|
// Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
|
|
4144
5459
|
// including newlines — inserts at the cursor verbatim and NEVER submits,
|
|
4145
5460
|
// so multiline pastes can't fire mid-paste. Separate channel from
|
|
@@ -4150,8 +5465,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4150
5465
|
}, {
|
|
4151
5466
|
isActive: !pendingApproval &&
|
|
4152
5467
|
!pendingQuestion &&
|
|
5468
|
+
!extDialogOpen &&
|
|
4153
5469
|
!selecting &&
|
|
4154
5470
|
!selectingSkills &&
|
|
5471
|
+
!selectingSession &&
|
|
4155
5472
|
!selectingProvider &&
|
|
4156
5473
|
!keyPrompt &&
|
|
4157
5474
|
!baseURLPrompt &&
|
|
@@ -4177,7 +5494,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4177
5494
|
: `thinking${theme.symbol.ellipsis}`;
|
|
4178
5495
|
// Slash menu derived for render (mirrors the useInput computation above):
|
|
4179
5496
|
// commands first, then matching skills as `/skill:name` entries.
|
|
4180
|
-
|
|
5497
|
+
// Memoized on every gate input: without this the fuzzy matcher rebuilds
|
|
5498
|
+
// on each 1s tick and tool append even though the menu only depends on
|
|
5499
|
+
// input + overlay state.
|
|
5500
|
+
const slashMenu = useMemo(() => !selecting &&
|
|
4181
5501
|
!selectingSkills &&
|
|
4182
5502
|
!selectingEffort &&
|
|
4183
5503
|
!selectingProvider &&
|
|
@@ -4185,13 +5505,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4185
5505
|
!baseURLPrompt &&
|
|
4186
5506
|
!pendingApproval &&
|
|
4187
5507
|
!pendingQuestion &&
|
|
5508
|
+
!extDialogOpen &&
|
|
4188
5509
|
!selectingRewind &&
|
|
4189
5510
|
!selectingRewindScope &&
|
|
4190
5511
|
!slashDismissed &&
|
|
4191
5512
|
input.startsWith("/") &&
|
|
4192
5513
|
!input.includes("\n")
|
|
4193
5514
|
? buildSlashMenu(input, skillMenu)
|
|
4194
|
-
: { items: [], moreSkills: 0 }
|
|
5515
|
+
: { items: [], moreSkills: 0 },
|
|
5516
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5517
|
+
[
|
|
5518
|
+
selecting,
|
|
5519
|
+
selectingSkills,
|
|
5520
|
+
selectingEffort,
|
|
5521
|
+
selectingProvider,
|
|
5522
|
+
keyPrompt,
|
|
5523
|
+
baseURLPrompt,
|
|
5524
|
+
pendingApproval,
|
|
5525
|
+
pendingQuestion,
|
|
5526
|
+
extDialogOpen,
|
|
5527
|
+
selectingRewind,
|
|
5528
|
+
selectingRewindScope,
|
|
5529
|
+
slashDismissed,
|
|
5530
|
+
input,
|
|
5531
|
+
skillMenu,
|
|
5532
|
+
]);
|
|
4195
5533
|
const filteredSlash = slashMenu.items;
|
|
4196
5534
|
const slashVisible = filteredSlash.length > 0;
|
|
4197
5535
|
const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
|
|
@@ -4205,9 +5543,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4205
5543
|
// Unified /model picker derived for render (mirrors the useInput
|
|
4206
5544
|
// computation above): full entries, filtered entries, clamped highlight,
|
|
4207
5545
|
// and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
|
|
4208
|
-
// rows no matter how many
|
|
4209
|
-
|
|
4210
|
-
|
|
5546
|
+
// rows no matter how many providers list. Memoized: the registry walk
|
|
5547
|
+
// (all providers + fallbacks) must not rerun on ticks/appends while open.
|
|
5548
|
+
// Deps cover every read inside buildModelEntries: selecting gate, provider
|
|
5549
|
+
// mirror, live models, auth store (key presence), and the local snapshot
|
|
5550
|
+
// (loopback baseURLs feed the cache keys); cache-ref writes always land
|
|
5551
|
+
// alongside one of these setStates, so the memo can never go stale.
|
|
5552
|
+
const { modelEntriesAll, modelEntries } = useMemo(() => {
|
|
5553
|
+
const all = selecting ? buildModelEntries() : [];
|
|
5554
|
+
return {
|
|
5555
|
+
modelEntriesAll: all,
|
|
5556
|
+
modelEntries: selecting ? filterModelEntries(all, modelFilter) : [],
|
|
5557
|
+
};
|
|
5558
|
+
},
|
|
5559
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5560
|
+
[selecting, provider, models, auth, localSnap, modelFilter]);
|
|
4211
5561
|
const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
|
|
4212
5562
|
const modelWin = pickerWindow(modelEntries.length, modelHi);
|
|
4213
5563
|
const modelTitle = `Atom — Select model (${modelEntries.length}` +
|
|
@@ -4216,13 +5566,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4216
5566
|
// /skills picker derived for render (mirrors the useInput computation
|
|
4217
5567
|
// above): names only, filtered, clamped highlight, visible window. The
|
|
4218
5568
|
// title keeps the `Skills (` prefix the registry header always had.
|
|
4219
|
-
|
|
4220
|
-
const skillEntries =
|
|
5569
|
+
// Memoized for the same tick/append reason as the model picker above.
|
|
5570
|
+
const { skillEntriesAll, skillEntries } = useMemo(() => {
|
|
5571
|
+
const all = selectingSkills ? skillPickerItems : [];
|
|
5572
|
+
return {
|
|
5573
|
+
skillEntriesAll: all,
|
|
5574
|
+
skillEntries: selectingSkills ? filterSkillPicker(all, skillFilter) : [],
|
|
5575
|
+
};
|
|
5576
|
+
}, [selectingSkills, skillPickerItems, skillFilter]);
|
|
4221
5577
|
const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
|
|
4222
5578
|
const skillWin = pickerWindow(skillEntries.length, skillHi);
|
|
4223
5579
|
const skillTitle = `Skills (${skillEntries.length}` +
|
|
4224
5580
|
(skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
|
|
4225
5581
|
`) — type to filter, up/down + Enter, Esc cancels:`;
|
|
5582
|
+
// /session picker derived for render (mirrors the useInput computation
|
|
5583
|
+
// above): open-time snapshot, filtered in memory, clamped highlight,
|
|
5584
|
+
// visible window. Memoized for the same tick/append reason as the pickers
|
|
5585
|
+
// above. nowMs via Date.now (render-time age labels, never persisted).
|
|
5586
|
+
const { sessionEntriesAll, sessionEntries } = useMemo(() => {
|
|
5587
|
+
const all = selectingSession ? sessionItems : [];
|
|
5588
|
+
return {
|
|
5589
|
+
sessionEntriesAll: all,
|
|
5590
|
+
sessionEntries: selectingSession ? filterSessionEntries(all, sessionFilter) : [],
|
|
5591
|
+
};
|
|
5592
|
+
}, [selectingSession, sessionItems, sessionFilter]);
|
|
5593
|
+
const sessionHi = sessionEntries.length === 0 ? 0 : Math.max(0, Math.min(sessionIndex, sessionEntries.length - 1));
|
|
5594
|
+
const sessionWin = pickerWindow(sessionEntries.length, sessionHi);
|
|
5595
|
+
const sessionPickerTitle = `Sessions (${sessionEntries.length}` +
|
|
5596
|
+
(sessionFilter ? ` of ${sessionEntriesAll.length}, filter: "${sessionFilter}"` : "") +
|
|
5597
|
+
`) — type to filter, up/down + Enter, Esc cancels:`;
|
|
4226
5598
|
// Memoized render derivations (flicker fix): these rebuild arrays on every
|
|
4227
5599
|
// App render (token paints, keystrokes, 1s ticks), which defeats the memo
|
|
4228
5600
|
// on the leaf panels below. Memoized, the leaves skip everything but real
|
|
@@ -4231,27 +5603,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4231
5603
|
const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
|
|
4232
5604
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4233
5605
|
[selectingRewind]);
|
|
5606
|
+
// Approval description: the tool-call one-liner for the modal. Memoized on
|
|
5607
|
+
// the pending approval itself — the 1s busy tick keeps firing while the
|
|
5608
|
+
// modal waits, and must not rebuild the string (nor re-render the modal).
|
|
5609
|
+
const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
|
|
4234
5610
|
// (The cursor clamp lives inside the memoized InputBox now, next to its
|
|
4235
5611
|
// only use — App body no longer reads cursor state for paint.)
|
|
4236
|
-
// Status-line reasoning segment wired to the effort session state:
|
|
4237
|
-
// non-
|
|
4238
|
-
//
|
|
4239
|
-
//
|
|
4240
|
-
//
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
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"
|
|
4244
5620
|
? effortSupportedNow
|
|
4245
5621
|
? effort
|
|
4246
5622
|
: `${effort} (unsupported)`
|
|
4247
|
-
: (reasoning ?? "
|
|
5623
|
+
: (reasoning ?? "auto");
|
|
4248
5624
|
// Display-only live tool elapsed: wall-clock now ≈ turn start + elapsed
|
|
4249
5625
|
// ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
|
|
4250
5626
|
// tool is running — the running line then paints with no duration.
|
|
4251
5627
|
const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
|
|
4252
5628
|
? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
|
|
4253
5629
|
: null;
|
|
4254
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(
|
|
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) => {
|
|
4255
5631
|
const i = modelWin.start + k;
|
|
4256
5632
|
const entryLocal = e.local === true;
|
|
4257
5633
|
const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
|
|
@@ -4266,7 +5642,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4266
5642
|
return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
|
|
4267
5643
|
}), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
|
|
4268
5644
|
? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
|
|
4269
|
-
: "No skills match — backspace to widen the filter." })) : null] })) :
|
|
5645
|
+
: "No skills match — backspace to widen the filter." })) : null] })) : selectingSession ? (_jsxs(PickerShell, { title: sessionPickerTitle, children: [_jsx(PickerMoreAbove, { count: sessionWin.start }), sessionEntries.slice(sessionWin.start, sessionWin.end).map((e, k) => {
|
|
5646
|
+
const i = sessionWin.start + k;
|
|
5647
|
+
const age = formatSessionAge(Date.now(), e.updatedAt);
|
|
5648
|
+
return (_jsxs(PickerRow, { highlighted: i === sessionHi, children: [e.title, e.active ? " (current)" : "", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", e.turnCount, " turn", e.turnCount === 1 ? "" : "s", " \u00B7 ", age] })] }, `${e.id}-${i}`));
|
|
5649
|
+
}), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
|
|
5650
|
+
? "No sessions yet — your current conversation is saved automatically."
|
|
5651
|
+
: "No sessions match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
|
|
4270
5652
|
const has = keyForProvider(p.id).length > 0;
|
|
4271
5653
|
const keyMark = isLocalProviderId(p.id)
|
|
4272
5654
|
? `local ${theme.symbol.descSeparator} no key needed`
|
|
@@ -4276,7 +5658,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4276
5658
|
? `${theme.symbol.descSeparator} key optional — free models need none`
|
|
4277
5659
|
: `${theme.symbol.descSeparator} no key`;
|
|
4278
5660
|
return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
|
|
4279
|
-
}) })) : 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." })] })) : (
|
|
4280
5662
|
// The input is the one boxed, prominent surface (see the memoized
|
|
4281
5663
|
// InputBox above): a quiet gray frame sets it apart from the
|
|
4282
5664
|
// transcript above and the status line below. Pickers and modals
|
|
@@ -4284,5 +5666,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4284
5666
|
// border color.
|
|
4285
5667
|
_jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
|
|
4286
5668
|
? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
|
|
4287
|
-
: `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(
|
|
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 })] }));
|
|
4288
5670
|
}
|