atom-agent 0.3.0 → 1.1.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
package/dist/App.js CHANGED
@@ -1,42 +1,74 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Ink (React) TUI for the minimal Atom chatbot.
3
3
  // Hand-rolled input + dropdowns via useInput (no extra deps).
4
- import React, { useEffect, useRef, useState } from "react";
5
- import { Box, Static, Text, useApp, useInput } from "ink";
6
- import { EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, runAgenticLoopForProvider, truncateHistory, } from "./zen.js";
7
- import { TOOL_ONE_LINERS, clearTodos, describeToolCall, executeTool, getTodos, needsApproval } from "./tools.js";
8
- import { checkRules, formatRules, parseRuleInput, } from "./permissions.js";
9
- import { discoverSkills, loadSkillBody, matchSkills, resolveSkills, skillsListText, } from "./skills.js";
10
- import { contextWindowFor, formatTokenSegment } from "./context-windows.js";
11
- import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, computeContextLoad, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, shouldAutoCompact, splitHistoryForCompaction, } from "./compact.js";
12
- import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isProviderId, maskKey, openaiCompatibleChatEndpoint, validateBaseURL, } from "./providers.js";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import React, { useEffect, useMemo, useRef, useState } from "react";
8
+ import { Box, Text, useApp, useInput, usePaste, useStdout } from "ink";
9
+ import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, openTodoNeedles, runAgenticLoopForProvider, } from "./zen.js";
10
+ import { createContextManager, trackHistory, } from "./context-manager.js";
11
+ import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
12
+ import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
13
+ import { classifyTurnOutcome, createTelemetryRecorder, loadTelemetrySessions, resolveTelemetryEnabled, summarizeTelemetry, telemetryDir, } from "./telemetry.js";
14
+ import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
15
+ import { formatRules, parseRuleInput, } from "./permissions.js";
16
+ import { decidePolicy, skillGrantsFor } from "./policy.js";
17
+ import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
18
+ import { contextWindowFor } from "./context-windows.js";
19
+ import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
20
+ import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
21
+ import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
13
22
  import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
14
23
  import { validateProviderKey } from "./adapters.js";
15
- import { withEnvBlock } from "./env-block.js";
16
- import { loadSession, saveSession, sessionExists, } from "./session.js";
17
- import { conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
24
+ import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
25
+ import { getGitInfo, withEnvBlock } from "./env-block.js";
26
+ import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
27
+ import { loadAtomConfig } from "./config.js";
28
+ import { cancelledTurnLine } from "./rollback.js";
29
+ import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
18
30
  import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
31
+ import { InputBox } from "./ui/input.js";
32
+ import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
33
+ import { LiveTail } from "./ui/live-tail.js";
34
+ import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
35
+ import { activityText } from "./ui/activity.js";
36
+ import { ApprovalBox, QuestionBox } from "./ui/modals.js";
37
+ import { PalettePanel } from "./ui/palette.js";
38
+ import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
39
+ import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
40
+ import { StatusBar, shortenCwd } from "./ui/status-bar.js";
41
+ import { theme } from "./ui/theme.js";
42
+ import { TodoPanel } from "./ui/todo-panel.js";
43
+ import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
19
44
  // Single registry for the "/" autocomplete menu and the exact-command path.
20
45
  export const SLASH_COMMANDS = [
21
46
  { name: "/model", description: "Open the model picker." },
47
+ { name: "/models", description: "Refresh local model discovery (Ollama, LM Studio, llama.cpp)." },
22
48
  { name: "/provider", description: "Pick AI provider, paste API key once, chat." },
23
49
  {
24
50
  name: "/effort",
25
51
  description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
26
52
  },
27
- { name: "/tools", description: "List the 7 tools with one-line descriptions." },
53
+ { name: "/tools", description: "List the tools with one-line descriptions." },
28
54
  { name: "/skills", description: "List installed skills (project + global)." },
29
- { name: "/mode", description: "Print the current permission mode." },
30
- { name: "/yolo", description: "Toggle yolo mode (tools run without asking). Tab toggles too." },
55
+ { name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
56
+ { name: "/mode", description: "Print the current permission mode (Tab cycles normal yolo plan)." },
31
57
  { name: "/trust", description: "Toggle session trust: auto-approve write/edit/bash without full yolo (/trust again revokes)." },
32
- { name: "/plan", description: "Enter/exit read-only plan mode (explore freely; write/edit/bash blocked; exiting approves the todo plan)." },
33
58
  { name: "/allow", description: "Pre-approve a tool pattern this session (e.g. /allow bash:npm test*)." },
34
59
  { name: "/deny", description: "Forbid a tool pattern this session — deny wins over trust/yolo (e.g. /deny bash:rm *)." },
35
60
  { name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
36
- { name: "/clear", description: "Clear the conversation history (keeps session token totals)." },
61
+ { name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
37
62
  { name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
38
63
  { name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
64
+ { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
65
+ { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
66
+ { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
67
+ { name: "/autoscroll", description: "Follow new output as it arrives (/autoscroll on|off; off freezes the view mid-turn)." },
68
+ { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
39
69
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
70
+ { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
71
+ { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
40
72
  { name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
41
73
  { name: "/help", description: "List commands with one-liners." },
42
74
  { name: "/exit", description: "Exit Atom." },
@@ -71,8 +103,198 @@ export const SUBMIT_PIPELINE_STAGES = [
71
103
  rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
72
104
  },
73
105
  ];
106
+ // Shared usage strings: the exact texts the commands print, hoisted to
107
+ // module scope so the slash-menu argument hints reuse them (no second
108
+ // implementation).
109
+ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (list with /skills, e.g. /skill:code-review)";
110
+ export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
111
+ export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
112
+ 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 prints the current state.";
114
+ export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: shows or hides the committed thinking blocks; the turn, history, and telemetry are untouched).";
74
115
  export function filterSlashCommands(prefix) {
75
- return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
116
+ const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
117
+ // Exact match wins outright: a fully-typed command collapses the menu
118
+ // to itself, so prefix-siblings (/skill vs /skills, /model vs /models)
119
+ // never read as duplicates and Enter stays deterministic. Partial
120
+ // input keeps the prefix-then-fuzzy tiers below untouched.
121
+ const full = `/${q}`;
122
+ const exact = SLASH_COMMANDS.find((c) => c.name === full);
123
+ if (exact)
124
+ return [exact];
125
+ const pre = [];
126
+ const fuzzy = [];
127
+ for (const c of SLASH_COMMANDS) {
128
+ const name = c.name.slice(1);
129
+ if (name.startsWith(q)) {
130
+ pre.push(c);
131
+ continue;
132
+ }
133
+ const s = fuzzyScore(q, name);
134
+ if (s !== null)
135
+ fuzzy.push({ c, s });
136
+ }
137
+ // Prefix tier keeps registry order (stable, muscle memory); fuzzy tier
138
+ // ranks by score, ties by name.
139
+ fuzzy.sort((a, b) => a.s - b.s || (a.c.name < b.c.name ? -1 : 1));
140
+ return [...pre, ...fuzzy.map((f) => f.c)];
141
+ }
142
+ // Command palette model (Ctrl+P): one searchable index over the SAME
143
+ // SLASH_COMMANDS registry the menu and runner use — no second command
144
+ // implementation. Filtering reuses fuzzyScore (prefix tier stable, fuzzy
145
+ // scored, description substring); display types live in ui/palette.
146
+ export function paletteEntries(query) {
147
+ const q = query.trim().toLowerCase().replace(/^\//, "");
148
+ const out = [];
149
+ SLASH_COMMANDS.forEach((c, idx) => {
150
+ const name = c.name.slice(1).toLowerCase();
151
+ if (!q) {
152
+ out.push({ c, tier: 0, score: 0, idx });
153
+ return;
154
+ }
155
+ if (name.startsWith(q)) {
156
+ out.push({ c, tier: 1, score: 0, idx });
157
+ return;
158
+ }
159
+ const s = fuzzyScore(q, name);
160
+ if (s !== null) {
161
+ out.push({ c, tier: 2, score: s, idx });
162
+ return;
163
+ }
164
+ if (c.description.toLowerCase().includes(q))
165
+ out.push({ c, tier: 3, score: 0, idx });
166
+ });
167
+ const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
168
+ out.sort((a, b) => a.tier - b.tier ||
169
+ (a.tier === 0
170
+ ? catOrder(a.c.name) - catOrder(b.c.name) || a.idx - b.idx
171
+ : a.score - b.score || a.idx - b.idx));
172
+ return out.map(({ c }) => ({
173
+ name: c.name,
174
+ description: c.description,
175
+ category: paletteCategory(c.name),
176
+ hint: PALETTE_HINTS[c.name] ?? null,
177
+ }));
178
+ }
179
+ // Busy-gate shared by the slash menu and the palette: /compact sets the
180
+ // pending flag for turn-end drain; /queue + /steer manage the running turn;
181
+ // /autoscroll and /thinking only flip view flags (never touch the turn).
182
+ // Every other command waits idle.
183
+ export function slashRunsWhileBusy(name) {
184
+ return (name === "/compact" ||
185
+ name === "/queue" ||
186
+ name === "/steer" ||
187
+ name === "/autoscroll" ||
188
+ name === "/thinking");
189
+ }
190
+ // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
191
+ // better; null = no match). Pure — shared by the command filter, the skill
192
+ // tier, and the palette, so there is exactly one matcher.
193
+ export function fuzzyScore(query, target) {
194
+ const q = query.toLowerCase();
195
+ const t = target.toLowerCase();
196
+ if (!q)
197
+ return 0;
198
+ let ti = 0;
199
+ let score = 0;
200
+ let last = -1;
201
+ for (let qi = 0; qi < q.length; qi++) {
202
+ const found = t.indexOf(q[qi], ti);
203
+ if (found === -1)
204
+ return null;
205
+ score += last === -1 ? found : found - last - 1;
206
+ if (found === 0 || /[-_/:]/.test(t[found - 1]))
207
+ score -= 2;
208
+ if (found === last + 1)
209
+ score -= 1;
210
+ last = found;
211
+ ti = found + 1;
212
+ }
213
+ return score;
214
+ }
215
+ // Max skill rows in the menu: the command list always renders whole, skills
216
+ // narrow as you type — the menu can never take over the screen.
217
+ export const SLASH_MENU_SKILL_CAP = 8;
218
+ // Pure filter for the /skills picker (unit-tested): case-insensitive
219
+ // substring over the skill name (a search popup narrows harder than the
220
+ // prefix-only slash menu). Empty query returns everything as-is.
221
+ export function filterSkillPicker(entries, query) {
222
+ const q = query.trim().toLowerCase();
223
+ if (!q)
224
+ return entries;
225
+ return entries.filter((e) => e.name.toLowerCase().includes(q));
226
+ }
227
+ // Pure menu builder (unit-tested): matching commands first (prefix tier in
228
+ // stable order, then fuzzy by score), then matching skills as `/skill:name`
229
+ // entries (prefix tier stable, then fuzzy). Skills join only once the query
230
+ // is non-trivial (input length ≥ 2 — a bare `/` lists commands only), and
231
+ // match by skill-name prefix, full `/skill:name` prefix, or fuzzy on the
232
+ // name. Skill rows carry a truncated description for discovery. Pure — the
233
+ // App feeds it the cached registry snapshot.
234
+ export const SKILL_MENU_DESC_CHARS = 60;
235
+ export function buildSlashMenu(input, skills) {
236
+ const items = filterSlashCommands(input).map((c) => ({
237
+ name: c.name,
238
+ description: c.description,
239
+ }));
240
+ if (input.length < 2)
241
+ return { items, moreSkills: 0 };
242
+ const q = input.slice(1);
243
+ const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
244
+ const pushSkill = (s, shown, more) => {
245
+ const entry = `/skill:${s.name}`;
246
+ const desc = s.description.length > SKILL_MENU_DESC_CHARS
247
+ ? `${s.description.slice(0, SKILL_MENU_DESC_CHARS)}…`
248
+ : s.description;
249
+ if (shown.n < SLASH_MENU_SKILL_CAP) {
250
+ items.push({ name: entry, description: desc, skill: s.name });
251
+ shown.n += 1;
252
+ }
253
+ else {
254
+ more.n += 1;
255
+ }
256
+ };
257
+ const shown = { n: 0 };
258
+ const more = { n: 0 };
259
+ const fuzzy = [];
260
+ for (const s of skills) {
261
+ const entry = `/skill:${s.name}`;
262
+ if (s.name.startsWith(skillQ) || entry.startsWith(input))
263
+ continue;
264
+ const score = fuzzyScore(skillQ, s.name);
265
+ if (score !== null)
266
+ fuzzy.push({ s, score });
267
+ }
268
+ for (const s of skills) {
269
+ const entry = `/skill:${s.name}`;
270
+ if (!s.name.startsWith(skillQ) && !entry.startsWith(input))
271
+ continue;
272
+ pushSkill(s, shown, more);
273
+ }
274
+ fuzzy.sort((a, b) => a.score - b.score || (a.s.name < b.s.name ? -1 : 1));
275
+ for (const f of fuzzy)
276
+ pushSkill(f.s, shown, more);
277
+ return { items, moreSkills: more.n };
278
+ }
279
+ // Argument hints for commands that take them, reusing the exact usage
280
+ // strings the commands themselves print (no second implementation).
281
+ export function commandUsage(name) {
282
+ switch (name) {
283
+ case "/allow":
284
+ case "/deny":
285
+ case "/rules":
286
+ return RULE_USAGE;
287
+ case "/queue":
288
+ return QUEUE_USAGE;
289
+ case "/steer":
290
+ return STEER_USAGE;
291
+ case "/skill":
292
+ return SKILL_USAGE;
293
+ case "/compact":
294
+ return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
295
+ default:
296
+ return null;
297
+ }
76
298
  }
77
299
  // Phase 5 observability + latency polish (surgical, three items only):
78
300
  // - TURN_TICK_MS: elapsed-time resolution while busy (1s).
@@ -81,10 +303,12 @@ export function filterSlashCommands(prefix) {
81
303
  export const TURN_TICK_MS = 1000;
82
304
  export const TURN_STALL_AFTER_MS = 3000;
83
305
  // Phase 5 models-list session cache key: provider id (+baseURL for
84
- // openai-compatible, whose list depends on the custom endpoint).
306
+ // openai-compatible, whose list depends on the custom endpoint, and for
307
+ // local runtimes, whose list depends on the loopback server probed).
85
308
  export function modelsCacheKey(providerId, baseURL) {
86
- if (providerId === "openai-compatible")
309
+ if (providerId === "openai-compatible" || isLocalProviderId(providerId)) {
87
310
  return `${providerId}|${baseURL ?? ""}`;
311
+ }
88
312
  return providerId;
89
313
  }
90
314
  // Pure helpers for the elapsed/stall indicator (injectable now for tests).
@@ -94,6 +318,56 @@ export function elapsedSecsSince(startMs, nowMs) {
94
318
  export function isStalledSince(lastActivityMs, nowMs) {
95
319
  return nowMs - lastActivityMs > TURN_STALL_AFTER_MS;
96
320
  }
321
+ export function modelPickerEntries(opts) {
322
+ const out = [];
323
+ const activeLocal = isLocalProviderId(opts.activeProvider);
324
+ for (const m of opts.activeModels) {
325
+ out.push({
326
+ providerId: opts.activeProvider,
327
+ model: m,
328
+ ...(activeLocal ? { local: true } : {}),
329
+ ...(opts.activeProvider === "kilo" && isFreeKiloModel(m) ? { free: true } : {}),
330
+ });
331
+ }
332
+ for (const p of PROVIDERS) {
333
+ if (p.id === opts.activeProvider)
334
+ continue;
335
+ if (!opts.keyFor(p.id) && providerNeedsKey(p.id))
336
+ continue;
337
+ if (p.id === "openai-compatible" && !opts.baseURLFor(p.id))
338
+ continue;
339
+ const list = opts.cached(p.id, opts.baseURLFor(p.id)) ?? p.fallbackModels;
340
+ for (const m of list) {
341
+ out.push({
342
+ providerId: p.id,
343
+ model: m,
344
+ ...(isLocalProviderId(p.id) ? { local: true } : {}),
345
+ ...(p.id === "kilo" && isFreeKiloModel(m) ? { free: true } : {}),
346
+ });
347
+ }
348
+ }
349
+ return out;
350
+ }
351
+ // Case-insensitive substring filter over the model id (the provider id is
352
+ // included so "openai" narrows to that section; "free" matches free Kilo
353
+ // models). Empty query returns the list as-is.
354
+ export function filterModelEntries(entries, query) {
355
+ const q = query.trim().toLowerCase();
356
+ if (!q)
357
+ return entries;
358
+ return entries.filter((e) => e.model.toLowerCase().includes(q) ||
359
+ e.providerId.toLowerCase().includes(q) ||
360
+ (e.free === true && "free".includes(q)));
361
+ }
362
+ // Visible window for the picker: at most MODEL_PICKER_VISIBLE rows, scrolled
363
+ // so the highlight stays visible (centered while scrolling, pinned at both
364
+ // ends). Single implementation in ui/pickers (shared by every windowed
365
+ // popup); re-exported here so existing import sites keep working.
366
+ export { MODEL_PICKER_VISIBLE, pickerWindow } from "./ui/pickers.js";
367
+ // Follow-up queue cap: Enter while busy queues instead of submitting, and
368
+ // the turn-end drain auto-sends while non-empty. Bounded so a held-down key
369
+ // can never flood the session; /queue manages, /queue clear wipes.
370
+ export const QUEUE_CAP = 10;
97
371
  // Task B smoothness (a): streaming-draft throttle. Token bursts (many
98
372
  // onToken calls per frame) would otherwise re-render the whole tree per
99
373
  // token; paints coalesce to at most one per trailing window, with
@@ -181,122 +455,112 @@ export function createDraftThrottler(opts) {
181
455
  },
182
456
  };
183
457
  }
184
- export function renderTranscriptItem(item) {
185
- if (!item.turn)
186
- return _jsx(StartupBanner, {}, item.id);
187
- const t = item.turn;
188
- const i = item.id;
189
- if (t.role === "user") {
190
- return (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["you>", " "] }), t.content] }, i));
191
- }
192
- if (t.role === "tool") {
193
- return (_jsx(Text, { color: t.error ? "red" : undefined, dimColor: !t.error, children: t.content }, i));
194
- }
195
- return (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["ATOM>", " "] }), t.content] }, i));
196
- }
197
- // Render-count probe for the timer-isolation test: incremented on every
198
- // TranscriptView render (a 1s timer tick must leave it unchanged).
199
- export const transcriptRenderProbe = { count: 0 };
200
- export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, }) {
201
- transcriptRenderProbe.count += 1;
202
- const render = renderItem ?? renderTranscriptItem;
203
- const items = clearGen === 0
204
- ? [{ id: "banner" }, ...turns.map((turn, idx) => ({ id: `turn-${idx}`, turn }))]
205
- : [...turns.map((turn, idx) => ({ id: `turn-${idx}`, turn }))];
206
- return (_jsx(Static, { items: items, children: (item) => render(item) }, `transcript-${clearGen}`));
207
- });
208
458
  function toolsListText() {
209
459
  const lines = Object.entries(TOOL_ONE_LINERS).map(([n, d]) => `${n} — ${d}`);
210
460
  return `Tools (${lines.length}):\n${lines.join("\n")}`;
211
461
  }
212
- // Display window for the live thinking block: reasoning streams can run
213
- // long, so only the frontier (tail) paints. The full text is never stored
214
- // anywhere else — thinking stays transient, like the answer draft.
215
- const THINKING_DISPLAY_CAP = 1200;
216
- // Live session checklist (Claude-Code-style TodoWrite panel). Mounted in
217
- // the live area below the transcript (NOT in <Static> scrollback) and fed
218
- // by a snapshot the loop refreshes after every todowrite/todo_update call,
219
- // so the in-progress row — shown with its activeForm when present — always
220
- // answers "what is the model doing right now". Returns null when empty.
221
- export function TodoPanel({ items }) {
222
- if (items.length === 0)
223
- return null;
224
- const done = items.filter((t) => t.status === "completed").length;
225
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Tasks ", done, "/", items.length] }), items.map((t, i) => {
226
- const mark = t.status === "completed" ? "✅" : t.status === "in_progress" ? "🔧" : "❌";
227
- const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
228
- return (_jsxs(Text, { dimColor: t.status === "completed", children: [mark, " ", label, t.priority ? ` (${t.priority})` : ""] }, `${i}-${t.content}`));
229
- })] }));
230
- }
462
+ // Live thinking block: reasoning streams in full, exactly as it arrives —
463
+ // the output stays as-is no matter how long it runs (no tail window, no
464
+ // truncation). The full text is never stored anywhere else — thinking stays
465
+ // transient like the answer draft (cleared on every turn boundary, never
466
+ // committed to the transcript or model history).
231
467
  export function helpListText() {
232
468
  const lines = SLASH_COMMANDS.map((c) => `${c.name} — ${c.description}`);
233
469
  return (`Commands:\n${lines.join("\n")}` +
234
- `\nTab toggles normal/yolo mode (in the / command menu, Tab runs the highlighted command). Tab never enters or exits plan mode (a stray keypress can't drop the safety mode — use /plan).` +
235
- `\n/plan toggles read-only plan 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 (/yolo and /trust while in plan stay read-only with a notice — exit with /plan first). Exiting is the human approval: /plan from plan mode returns to normal (never yolo) and the todowrite checklist recorded while planning carries into implementation.` +
470
+ `\nTab is the only mode switcher: normalyolo plan → normal (in the / command menu, Tab runs the highlighted command instead). Yolo runs tools without asking; plan is read-only.` +
471
+ `\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.` +
236
472
  `\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.` +
237
473
  `\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.` +
238
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 prompt_tokens, 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.` +
239
475
  `\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~8000 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).` +
240
476
  `\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.` +
241
477
  `\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.` +
242
- `\n/provider: pick opencode-zen|openai|anthropic|deepseek|mistral|google-gemini|openai-compatible, paste a key once (stored in ~/.atom/auth.json, env wins). Switching provider keeps session history text; system prompt stays.` +
478
+ `\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.` +
243
479
  `\n/effort options: Default/Low/Medium/High/Max (wire: default/low/medium/high/max; Default omits reasoning_effort).` +
244
480
  `\nNote: xhigh was requested but only Max is verified, so the top setting is Max, sent as max.` +
245
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.` +
246
- `\n/resume: restores the last saved session (turns, history, provider/model/effort/mode, usage totals). Startup never auto-restores — sending a message without /resume starts fresh, and the next completed turn overwrites the save. /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.` +
482
+ `\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.` +
247
483
  `\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)".` +
248
484
  `\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). ` +
249
485
  `Reasoning streams in its own dim block above the answer draft while busy (transient — never committed); Esc stops a running response (same rollback as Ctrl+C).` +
250
- `\n/rewind: every write/edit auto-snapshots prior bytes (silent, no prompt, no config); /rewind lists the session checkpoints and restores exact bytes (hash-verified, never a model rewrite) — files only, files + conversation, or conversation only. Shell side effects (bash) are explicitly out of scope: commands are never snapshotted and cannot be undone.`);
486
+ `\n/rewind: every write/edit auto-snapshots prior bytes (silent, no prompt, no config); /rewind lists the session checkpoints and restores exact bytes (hash-verified, never a model rewrite) — files only, files + conversation, or conversation only. Shell side effects (bash) are explicitly out of scope: commands are never snapshotted and cannot be undone.` +
487
+ `\nQueue + steer (follow-ups without losing flow): Enter while busy queues the message (visible Queued line, auto-sent when the turn ends cleanly — never after a cancel); /queue lists, /queue clear wipes (cap ${QUEUE_CAP}, in-memory only). /steer <text> injects into the RUNNING turn at the next step boundary (the current action finishes first — nothing is aborted); when idle it just sends. A steer stranded by a failed/cancelled turn rejoins the queue front instead of vanishing.` +
488
+ `\nInput: Ctrl+J inserts a newline (Enter always sends, even multiline); ↑/↓ recalls past prompts across lines (in-memory only, never saved); paste lands verbatim via bracketed paste and never submits; Ctrl+A/E line ends, Ctrl+K/U/W kills; Ctrl+P opens the searchable command palette.` +
489
+ `\nCtrl+O opens the tool-output inspector (browse past tool calls with full output, durations, and error detail: ↑/↓ selects, Enter expands/collapses, PgUp/PgDn scrolls, Esc closes). Read-only — safe in plan mode.` +
490
+ `\nObservability (local-only, on by default): every turn is traced — iterations, model calls (API-reported tokens only), tool calls (measured durations, ok/fail per tool), retries, and outcomes — into ~/.atom/telemetry/sessions/ (one small JSON file per session, 0600 POSIX, truncated + secret-scrubbed previews, never full prompts/results). /telemetry prints the summary; /dashboard writes the drill-down page (session → turn → iteration → model/tool call → result, with aggregates, charts, filters, timelines) to ~/.atom/telemetry/dashboard.html — open it in a browser, nothing is uploaded. Off via ATOM_TELEMETRY=0 or "telemetry": {"enabled": false} in atom.json. n/a means not reported (never estimated); cost is always n/a until a provider reports it; tool durations span dispatch→result (including any approval-prompt wait in normal mode).`);
251
491
  }
252
492
  // Session token totals, accumulated ONLY from usage payloads the API
253
493
  // actually reported. Null until the first usage payload arrives (rendered
254
494
  // as `token: n/a` — never 0, which would imply measurement). The segment
255
495
  // itself lives in ./context-windows.js (single source for the exact
256
- // `token: (P%) NK` format); the footer status line is its only surface.
257
- // P% tracks CURRENT context load (last prompt_tokens, else 4ch/token
258
- // estimate); NK tracks cumulative session spend.
259
- function formatTokens(usage, model, load) {
260
- return formatTokenSegment(usage, model, load);
261
- }
262
- // Startup banner: the ATOM block-letter art, rendered once at launch inside
263
- // <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
264
- // ATOM (Unicode box-drawing — needs a monospace font with box-drawing
265
- // support, which Windows Terminal / ConHost / most terminals have). The art
266
- // is the whole banner: the footer status line is the sole info bar, so no
267
- // hint lines live here.
268
- export const ATOM_ART = [
269
- " █████╗ ████████╗ ██████╗ ███╗ ███╗",
270
- "██╔══██╗╚══██╔══╝██╔═══██╗████╗ ████║",
271
- "███████║ ██║ ██║ ██║██╔████╔██║",
272
- "██╔══██║ ██║ ██║ ██║██║╚██╔╝██║",
273
- "██║ ██║ ██║ ╚██████╔╝██║ ╚═╝ ██║",
274
- "╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
275
- ];
276
- export function StartupBanner() {
277
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: ATOM_ART.map((line, i) => (_jsx(Text, { color: "cyan", bold: true, children: line }, i))) }));
496
+ // `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
497
+ // surface. P% tracks CURRENT context load (last prompt_tokens, else
498
+ // 4ch/token estimate); NK tracks cumulative session spend.
499
+ function formatKEst(chars) {
500
+ return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
278
501
  }
279
502
  // Error screen for a missing key (never print the key itself).
280
503
  export function MissingKey() {
281
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Missing OPENCODE_ZEN_API_KEY." }), _jsx(Text, { children: "Copy .env.example -> set your key from https://opencode.ai/auth" }), _jsx(Text, { children: "Or run the TUI and use /provider to paste a key (stored in ~/.atom/auth.json)." }), _jsx(Text, { dimColor: true, children: "Then run `npm start` again." })] }));
504
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: theme.color.error, bold: true, children: "Missing OPENCODE_ZEN_API_KEY." }), _jsx(Text, { children: "Copy .env.example -> set your key from https://opencode.ai/auth" }), _jsx(Text, { children: "Or run the TUI and use /provider to paste a key (stored in ~/.atom/auth.json)." }), _jsx(Text, { dimColor: true, children: "Then run `npm start` again." })] }));
282
505
  }
283
- export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, authHome, skillDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn }) {
506
+ // Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
507
+ // size is a constant (avoids re-serializing ~15KB on every manager build).
508
+ const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
509
+ export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
284
510
  const { exit } = useApp();
285
- const [model, setModel] = useState(initialModel);
286
- const modelRef = useRef(initialModel);
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
+ // Saved preferences (provider/model/effort + resolved key/endpoint), loaded
521
+ // once when restorePrefs is on (prod). Explicit props always win; without
522
+ // prefs the CLI defaults apply. Null in tests (flag off) and on any
523
+ // missing/corrupt/unusable save — startup then behaves exactly as before.
524
+ const [prefs] = useState(() => (restorePrefs ? loadPrefs(authHome, endpoint) : null));
525
+ // atom.json (project + global, per-key merge): first-run defaults sitting
526
+ // between saved prefs and compiled defaults —
527
+ // env/props > save > project > global > default.
528
+ const [atomConfigLoad] = useState(() => loadAtomConfig(configDirs?.projectDir, configDirs?.homeDir ?? authHome));
529
+ const atomConfig = atomConfigLoad.config;
530
+ const resolvedInitialProvider = initialProvider && isProviderId(initialProvider)
531
+ ? initialProvider
532
+ : (prefs?.provider ?? atomConfig.provider ?? DEFAULT_PROVIDER);
533
+ // Fresh-install bootstrap: Kilo (the default) starts on its free routing
534
+ // model until discovery + the user's pick say otherwise — no paid
535
+ // credentials required. Other providers keep the compiled default.
536
+ const resolvedInitialModel = initialModel ??
537
+ prefs?.model ??
538
+ atomConfig.model ??
539
+ (resolvedInitialProvider === "kilo"
540
+ ? (getProvider("kilo")?.defaultModel ?? DEFAULT_MODEL)
541
+ : DEFAULT_MODEL);
542
+ const [model, setModel] = useState(resolvedInitialModel);
543
+ const modelRef = useRef(resolvedInitialModel);
287
544
  const [models, setModels] = useState(initialModels ?? [...FALLBACK_MODELS]);
288
- // Active provider (default opencode-zen for backward compat).
289
- const [provider, setProvider] = useState(initialProvider && isProviderId(initialProvider) ? initialProvider : DEFAULT_PROVIDER);
290
- const providerRef = useRef(initialProvider && isProviderId(initialProvider) ? initialProvider : DEFAULT_PROVIDER);
545
+ // Active provider (explicit prop wins, then saved prefs, then zen default).
546
+ const [provider, setProvider] = useState(resolvedInitialProvider);
547
+ const providerRef = useRef(resolvedInitialProvider);
291
548
  // Auth store (env wins at resolve time; file holds pasted keys).
292
549
  const [auth, setAuth] = useState(() => loadAuth(authHome));
293
550
  const authRef = useRef(auth);
294
551
  // Resolved keys/endpoints per active provider. apiKey/endpoint props seed
295
- // the zen defaults (tests pass test-key; prod passes env-resolved values).
296
- const [activeApiKey, setActiveApiKey] = useState(apiKey);
297
- const activeApiKeyRef = useRef(apiKey);
298
- const [activeEndpoint, setActiveEndpoint] = useState(endpoint);
299
- // Permission mode (normal default, yolo toggled via /yolo). The footer
552
+ // the zen defaults (tests pass test-key; prod passes env-resolved values);
553
+ // with restorePrefs the saved key/endpoint seed a restored provider instead.
554
+ const [activeApiKey, setActiveApiKey] = useState(prefs?.apiKey ?? apiKey);
555
+ const activeApiKeyRef = useRef(prefs?.apiKey ?? apiKey);
556
+ // Owner of the seeded/active key: the apiKey/endpoint props seed the zen
557
+ // defaults (tests pass test-key; prod passes env-resolved values), while
558
+ // restorePrefs seeds the saved provider's key instead. The fallback below
559
+ // must never hand one provider's seed to another (e.g. the zen seed to an
560
+ // anonymous Kilo session) — it applies only to its owner.
561
+ const activeKeyProviderRef = useRef(prefs?.provider ?? "opencode-zen");
562
+ const [activeEndpoint, setActiveEndpoint] = useState(prefs?.endpoint ?? endpoint);
563
+ // Permission mode (normal default, Tab cycles normal → yolo → plan). The footer
300
564
  // status line always shows it (plus +trust when the session trust tier is
301
565
  // on); modeRef mirrors it for async loop callbacks.
302
566
  const [mode, setMode] = useState("normal");
@@ -344,17 +608,95 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
344
608
  // synchronous ref mirror (cursor edits must compose within one tick).
345
609
  const [cursor, setCursor] = useState(0);
346
610
  const cursorRef = useRef(0);
611
+ // Submitted-prompt history (↑/↓ recall): in-memory only, never persisted
612
+ // (prompts may carry pasted secrets). histIndex null = editing fresh;
613
+ // otherwise an index into inputHist. histStash preserves the unsent draft
614
+ // while browsing (Down past newest restores it).
615
+ const inputHistRef = useRef([]);
616
+ const histIndexRef = useRef(null);
617
+ const histStashRef = useRef("");
618
+ function pushInputHistory(text) {
619
+ inputHistRef.current = pushInputHistoryList(inputHistRef.current, text);
620
+ histIndexRef.current = null;
621
+ histStashRef.current = "";
622
+ }
623
+ function browseHistoryInput(dir) {
624
+ const hist = inputHistRef.current;
625
+ if (hist.length === 0)
626
+ return;
627
+ const cur = histIndexRef.current;
628
+ if (cur === null) {
629
+ if (dir === 1)
630
+ return; // already at the newest (fresh draft)
631
+ histStashRef.current = inputRef.current;
632
+ const idx = historyOlderIndex(hist, null) ?? hist.length - 1;
633
+ histIndexRef.current = idx;
634
+ setInputBoth(hist[idx]);
635
+ return;
636
+ }
637
+ const next = dir === -1 ? historyOlderIndex(hist, cur) : historyNewerIndex(hist, cur);
638
+ if (next === null) {
639
+ histIndexRef.current = null;
640
+ const stash = histStashRef.current;
641
+ histStashRef.current = "";
642
+ setInputBoth(stash);
643
+ return;
644
+ }
645
+ histIndexRef.current = next;
646
+ setInputBoth(hist[next]);
647
+ }
648
+ // ↑/↓ in plain input: move between lines when multiline, else browse
649
+ // submitted-prompt history (recall at the first/last line edge).
650
+ function moveOrRecall(dir) {
651
+ const t = inputRef.current;
652
+ if (t.includes("\n")) {
653
+ const r = moveVertically(t, cursorRef.current, dir);
654
+ if (!r.edge) {
655
+ setCursorBoth(r.offset);
656
+ return;
657
+ }
658
+ }
659
+ browseHistoryInput(dir);
660
+ }
347
661
  const [selecting, setSelecting] = useState(false);
348
662
  const [selIndex, setSelIndex] = useState(0);
349
663
  // Same synchronous mirror for the dropdown highlight.
350
664
  const selIndexRef = useRef(0);
665
+ // /model picker type-to-filter (unified cross-provider list): the query
666
+ // narrows entries live; highlight resets to the top on every keystroke.
667
+ // Cleared on open and on close (Esc/Enter) so every open starts unfiltered.
668
+ const [modelFilter, setModelFilter] = useState("");
669
+ const modelFilterRef = useRef("");
670
+ function setModelFilterBoth(next) {
671
+ modelFilterRef.current = next;
672
+ setModelFilter(next);
673
+ }
674
+ // /skills picker (opencode-style searchable popup): type-to-filter over the
675
+ // resolved registry, ↑/↓ + Enter to load, Esc cancels, windowed like the
676
+ // model picker so any library size stays navigable. Snapshot state (the
677
+ // registry always renders — names only, never descriptions).
678
+ const [selectingSkills, setSelectingSkills] = useState(false);
679
+ const [skillPickerItems, setSkillPickerItems] = useState([]);
680
+ const [skillIndex, setSkillIndex] = useState(0);
681
+ const skillIndexRef = useRef(0);
682
+ const [skillFilter, setSkillFilter] = useState("");
683
+ const skillFilterRef = useRef("");
684
+ function setSkillIndexBoth(next) {
685
+ skillIndexRef.current = next;
686
+ setSkillIndex(next);
687
+ }
688
+ function setSkillFilterBoth(next) {
689
+ skillFilterRef.current = next;
690
+ setSkillFilter(next);
691
+ }
351
692
  // Reasoning-effort picker (/effort): same pattern as the /model picker
352
- // (↑/↓ + Enter, Esc cancels). Session state, default Default.
693
+ // (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
694
+ // else the atom.json default, else Default.
353
695
  const [selectingEffort, setSelectingEffort] = useState(false);
354
696
  const [effortIndex, setEffortIndex] = useState(0);
355
697
  const effortIndexRef = useRef(0);
356
- const [effort, setEffort] = useState("default");
357
- const effortRef = useRef("default");
698
+ const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
699
+ const effortRef = useRef(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
358
700
  // /provider picker + key/baseURL prompts (same keyboard pattern).
359
701
  const [selectingProvider, setSelectingProvider] = useState(false);
360
702
  const [providerIndex, setProviderIndex] = useState(0);
@@ -383,11 +725,201 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
383
725
  const slashIndexRef = useRef(0);
384
726
  const [slashDismissed, setSlashDismissed] = useState(false);
385
727
  const slashDismissedRef = useRef(false);
728
+ // Follow-up queue (Claude-Code-style): Enter while busy appends instead of
729
+ // submitting; the turn-end drain auto-sends while non-empty and the turn
730
+ // wasn't cancelled. In-memory only, never persisted (like rules). Rendered
731
+ // as one dim line above the input so a queued thought is never lost.
732
+ const [queue, setQueue] = useState([]);
733
+ const queueRef = useRef([]);
734
+ function setQueueBoth(next) {
735
+ queueRef.current = next;
736
+ setQueue(next);
737
+ }
738
+ // Steer (inject into the ACTIVE turn, Claude-Code-style): /steer text while
739
+ // busy sits here until the loop's next step boundary drains it into history
740
+ // + transcript (see drainSteer below). Null when idle or nothing pending;
741
+ // rendered as one dim line while set.
742
+ const [steerPending, setSteerPending] = useState(null);
743
+ const steerRef = useRef(null);
744
+ function setSteerPendingBoth(next) {
745
+ steerRef.current = next;
746
+ setSteerPending(next);
747
+ }
748
+ // Cancellation latch for the queue drain: a cancelled turn keeps its queue
749
+ // visible but never auto-sends (the user decides what runs next). Reset at
750
+ // every submit, set in the cancel path.
751
+ const turnCancelledRef = useRef(false);
752
+ // Tool-output inspector (display-only): retained results for the browse +
753
+ // expand panel (Ctrl+O). Records are appended in onToolActivity from the
754
+ // existing payloads — execution, ordering, and turn content are untouched.
755
+ const toolLogRef = useRef([]);
756
+ const toolSeqRef = useRef(0);
757
+ const [inspecting, setInspecting] = useState(false);
758
+ const inspectingRef = useRef(false);
759
+ function setInspectingBoth(next) {
760
+ inspectingRef.current = next;
761
+ setInspecting(next);
762
+ }
763
+ const [inspectIndex, setInspectIndex] = useState(0);
764
+ const inspectIndexRef = useRef(0);
765
+ function setInspectIndexBoth(next) {
766
+ inspectIndexRef.current = next;
767
+ setInspectIndex(next);
768
+ }
769
+ const [inspectExpanded, setInspectExpanded] = useState(false);
770
+ const inspectExpandedRef = useRef(false);
771
+ function setInspectExpandedBoth(next) {
772
+ inspectExpandedRef.current = next;
773
+ setInspectExpanded(next);
774
+ }
775
+ const [inspectScroll, setInspectScroll] = useState(0);
776
+ const inspectScrollRef = useRef(0);
777
+ function setInspectScrollBoth(next) {
778
+ inspectScrollRef.current = next;
779
+ setInspectScroll(next);
780
+ }
781
+ function openInspector() {
782
+ if (toolLogRef.current.length === 0) {
783
+ pushInfo("(no tool calls yet — run something first, then Ctrl+O to inspect)");
784
+ return;
785
+ }
786
+ setInspectIndexBoth(0);
787
+ setInspectExpandedBoth(false);
788
+ setInspectScrollBoth(0);
789
+ setInspectingBoth(true);
790
+ }
791
+ function closeInspector() {
792
+ setInspectingBoth(false);
793
+ }
794
+ // Command palette (Ctrl+P): unified searchable commands. Own filter +
795
+ // highlight (the main input stays untouched behind it); Enter runs
796
+ // through runSlashCommand with the shared busy-gate.
797
+ const [paletteOpen, setPaletteOpen] = useState(false);
798
+ const paletteOpenRef = useRef(false);
799
+ function setPaletteOpenBoth(next) {
800
+ paletteOpenRef.current = next;
801
+ setPaletteOpen(next);
802
+ }
803
+ const [paletteFilter, setPaletteFilter] = useState("");
804
+ const paletteFilterRef = useRef("");
805
+ function setPaletteFilterBoth(next) {
806
+ paletteFilterRef.current = next;
807
+ setPaletteFilter(next);
808
+ }
809
+ const [paletteIndex, setPaletteIndex] = useState(0);
810
+ const paletteIndexRef = useRef(0);
811
+ function setPaletteIndexBoth(next) {
812
+ paletteIndexRef.current = next;
813
+ setPaletteIndex(next);
814
+ }
815
+ function openPalette() {
816
+ setPaletteFilterBoth("");
817
+ setPaletteIndexBoth(0);
818
+ setPaletteOpenBoth(true);
819
+ }
820
+ function closePalette() {
821
+ setPaletteOpenBoth(false);
822
+ }
823
+ // Transcript scrollback viewport (null = follow the bottom; a number =
824
+ // viewed end index = held/manual mode). New turns extend a following view
825
+ // automatically and accumulate below a held one (the `↓ N new` indicator
826
+ // offers the jump back). Held also freezes the live tail's growing
827
+ // draft/thinking blocks to one static line (see LiveTail `held`), so a
828
+ // streaming turn stops yanking the terminal while the user reads back.
829
+ // List replacement (/clear, /resume, /new) and rewind-truncate re-follow
830
+ // explicitly. The inspector never touches this (position preserved while
831
+ // inspecting).
832
+ const [scrollEnd, setScrollEnd] = useState(null);
833
+ const scrollEndRef = useRef(null);
834
+ function setScrollEndBoth(next) {
835
+ scrollEndRef.current = next;
836
+ setScrollEnd(next);
837
+ }
838
+ // Thinking visibility (the /thinking toggle, rendering-only, default
839
+ // hidden): committed thinking turns + the live thinking block show only
840
+ // while on. Never touches the turn, history, or telemetry — purely paint.
841
+ const [showThinking, setShowThinking] = useState(false);
842
+ const showThinkingRef = useRef(false);
843
+ function setShowThinkingBoth(next) {
844
+ showThinkingRef.current = next;
845
+ setShowThinking(next);
846
+ }
847
+ // /autoscroll (session-only, default on). On = today's behavior: a
848
+ // following view extends with every appended turn. Off = appends during a
849
+ // busy turn freeze a following view at its current end instead of yanking
850
+ // it (the `↓ N new` indicator offers the jump back; End resumes). Idle
851
+ // appends always follow — freezing only matters while output streams.
852
+ const [autoScroll, setAutoScroll] = useState(true);
853
+ const autoScrollRef = useRef(true);
854
+ function setAutoScrollBoth(next) {
855
+ autoScrollRef.current = next;
856
+ setAutoScroll(next);
857
+ }
858
+ // Skill registry (cached metadata): one instance per App, scoped to the
859
+ // same dirs the suite injects via skillDirs. Every discovery path below
860
+ // reads through it — refresh() revalidates by stat (mtime+size) and only
861
+ // re-reads added/modified entries, so per-message cost drops from ~1MB of
862
+ // SKILL.md reads to a directory listing plus stats. Bodies stay lazy
863
+ // (activateSkill → loadSkillBody, on demand only).
864
+ const [skillRegistry] = useState(() => createSkillRegistry({ projectDir: skillDirs?.projectDir, homeDir: skillDirs?.homeDir }));
865
+ // Skill entries for the slash menu (namespaced `/skill:name` commands):
866
+ // a snapshot of user-invocable skills (name + description), refreshed on
867
+ // mount, /skills, /clear, and /new — never per keystroke (disk I/O stays
868
+ // out of the typing path). Empty until the first refresh lands.
869
+ const [skillMenu, setSkillMenu] = useState([]);
870
+ async function refreshSkillMenu() {
871
+ try {
872
+ const found = await skillRegistry.refresh();
873
+ const { skills } = resolveSkills(found.skills);
874
+ setSkillMenu(skills
875
+ .filter((s) => s.userInvocable)
876
+ .map((s) => ({ name: s.name, description: s.description })));
877
+ }
878
+ catch {
879
+ // menu keeps its previous snapshot (a hiccup must never break input)
880
+ }
881
+ }
882
+ // Pending transcript diff (display-only): the approve-time write/edit
883
+ // preview plus full-file BEFORE/AFTER capture, held for the matching
884
+ // onToolActivity commit. Single slot is exact — the scheduler never
885
+ // parallel-batches writes (writes conflict globally; parallel members
886
+ // never prompt), and every execution commits exactly one activity entry
887
+ // in call order. Lifetime ⊆ one turn: set in approve(),
888
+ // consumed-or-cleared by the matching activity, and cleared on
889
+ // deny/cancel/turn boundaries so a stale preview can never attach to
890
+ // a later call.
891
+ // - write: BEFORE reuses the preview's pre-read; AFTER is the new
892
+ // content arg (exactly what the tool writes) — zero extra reads.
893
+ // - edit: BEFORE is a best-effort full-file read here (pre-execution);
894
+ // AFTER is read at commit time. Two reads, each once, never in render.
895
+ const pendingDiffRef = useRef(null);
896
+ // Best-effort full-file read for diff capture: null on missing dir,
897
+ // oversize, or any I/O failure. Never throws — capture degrades to the
898
+ // arg-block preview pair instead of breaking approval.
899
+ function readFileForDiff(absPath) {
900
+ try {
901
+ const st = fs.statSync(absPath);
902
+ if (!st.isFile() || st.size > APPROVAL_PREVIEW_MAX_BYTES)
903
+ return null;
904
+ return fs.readFileSync(absPath, "utf8");
905
+ }
906
+ catch {
907
+ return null;
908
+ }
909
+ }
386
910
  // Tool approval prompt (normal mode, write/edit/bash): the loop waits on
387
911
  // the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
388
912
  // (LoopCancelledError) instead of denying one call.
389
913
  const [pendingApproval, setPendingApproval] = useState(null);
390
914
  const approvalResolveRef = useRef(null);
915
+ // Approval highlight (0..3 = once/always/trust-all/deny): arrows + Enter
916
+ // select, y/a/t/n shortcut (unchanged). Display-only; reset on every open.
917
+ const [approveIndex, setApproveIndex] = useState(0);
918
+ const approveIndexRef = useRef(0);
919
+ function setApproveIndexBoth(next) {
920
+ approveIndexRef.current = next;
921
+ setApproveIndex(next);
922
+ }
391
923
  // ask_question modal: the loop waits until the user picks, types a custom
392
924
  // answer (allowCustom), cancels with Esc (question-cancel result), or
393
925
  // cancels the whole turn with Ctrl+C (LoopCancelledError).
@@ -403,7 +935,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
403
935
  // tool finishes — no new POSTs, no new executions — then the turn rolls
404
936
  // back and a dim `(cancelled)` line renders.
405
937
  const turnCancelRef = useRef(null);
938
+ // Display-only tool clock (TUI timing, never execution logic): wall-ms
939
+ // when the current tool call started, via the injectable `now` clock.
940
+ // Consumed by onToolActivity into Turn.ms and by the live running line;
941
+ // parallel batches share it (last start wins — approximate, display-only).
942
+ const toolStartRef = useRef(null);
943
+ // Latest streamed answer text (display bookkeeping only): if the turn
944
+ // FAILS after streaming (rate limits, dead network), the catch path
945
+ // commits this as a marked partial turn so the output never vanishes.
946
+ // History still rolls back (the model never sees it); the transcript
947
+ // keeps what the user already read. Cleared at every turn start.
948
+ const lastPartialRef = useRef("");
406
949
  const [error, setError] = useState(null);
950
+ // Local observability recorder (src/telemetry.ts): one telemetry session
951
+ // per App mount. Best-effort and never throwing; off via ATOM_TELEMETRY=0
952
+ // or atom.json telemetry.enabled=false. The loop reports into it through a
953
+ // per-turn sink (see submit); one small file per session lands under
954
+ // ~/.atom/telemetry/sessions/ on turn boundaries.
955
+ const [telemetry] = useState(() => createTelemetryRecorder({
956
+ home: authHome,
957
+ enabled: resolveTelemetryEnabled(process.env, atomConfig.telemetry?.enabled),
958
+ provider: resolvedInitialProvider,
959
+ model: resolvedInitialModel,
960
+ secrets: providerSecrets,
961
+ }));
407
962
  // Session token totals from real API usage payloads only (null = none
408
963
  // reported yet -> `token: n/a`). Survives /clear by design (see /help).
409
964
  const [usageTotals, setUsageTotals] = useState(null);
@@ -414,6 +969,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
414
969
  // completes. NK stays cumulative; P must NOT use the cumulative total.
415
970
  const [contextLoad, setContextLoad] = useState(null);
416
971
  const contextLoadRef = useRef(null);
972
+ // Git identity for the status bar (branch only, no status porcelain):
973
+ // refreshed at turn boundaries (a turn's bash may switch branches), read
974
+ // from render. Null outside git repos — the bar then shows cwd alone.
975
+ const [gitInfo, setGitInfo] = useState(null);
976
+ function refreshGitInfo() {
977
+ try {
978
+ setGitInfo(getGitInfo(process.cwd()));
979
+ }
980
+ catch {
981
+ setGitInfo(null);
982
+ }
983
+ }
417
984
  // Last POST's reported prompt_tokens (load metric source). Summary-request
418
985
  // usage never touches this — only main-loop POSTs do.
419
986
  const lastPromptTokensRef = useRef(undefined);
@@ -427,7 +994,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
427
994
  // never mid-turn. Null = none pending.
428
995
  const pendingCompactRef = useRef(null);
429
996
  // Startup hint: a save file exists from a previous session. Rendered once
430
- // as a dim line while the transcript is empty; startup never auto-restores.
997
+ // as a dim line while the transcript is empty; the conversation itself
998
+ // never auto-restores (only provider/model/effort do, via restorePrefs).
431
999
  const [sessionHint] = useState(() => sessionExists(authHome));
432
1000
  // Reasoning label from response metadata (via onReasoning). The status
433
1001
  // line shows the session effort when non-Default (plus " (unsupported)"
@@ -440,10 +1008,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
440
1008
  // tool name before its execution line lands.
441
1009
  const [draft, setDraft] = useState(null);
442
1010
  // Thinking channel (onThinking): reasoning text streamed apart from the
443
- // answer, rendered in its own dim block below. Transient like `draft`
444
- // cleared on every turn boundary below — and never committed to the
445
- // transcript or the model history.
1011
+ // answer, rendered in its own dim block below. The live value is transient
1012
+ // like `draft` — cleared on every turn boundary below — but each completed
1013
+ // round commits to the transcript via commitThinking (stays in the TUI,
1014
+ // never the model history) instead of being replaced and lost.
446
1015
  const [thinking, setThinking] = useState(null);
1016
+ const thinkingRef = useRef(null);
1017
+ // Move the accumulated round thinking into the transcript as a quiet
1018
+ // annotation turn (no-op when empty). Called when a new POST starts and at
1019
+ // turn end, so every round's reasoning stays visible; the /thinking toggle
1020
+ // only controls rendering, never this record.
1021
+ function commitThinking() {
1022
+ const text = thinkingRef.current;
1023
+ thinkingRef.current = null;
1024
+ setThinking(null);
1025
+ if (typeof text === "string" && text.length > 0) {
1026
+ appendTurns({ role: "assistant", content: text, thinking: true });
1027
+ }
1028
+ }
1029
+ function clearThinking() {
1030
+ thinkingRef.current = null;
1031
+ setThinking(null);
1032
+ }
447
1033
  const [phase, setPhase] = useState("idle");
448
1034
  const [phaseDetail, setPhaseDetail] = useState("");
449
1035
  const [toolHint, setToolHint] = useState(null);
@@ -477,6 +1063,72 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
477
1063
  // Phase 5: models-list session cache (successful live lists only, keyed
478
1064
  // by modelsCacheKey). Failures fall back uncached, exactly as before.
479
1065
  const modelsCacheRef = useRef(new Map());
1066
+ // Local model discovery (Ollama / LM Studio / llama.cpp): one instance
1067
+ // per App (injectable for tests), probed in the background on mount.
1068
+ // Results land in the session models cache above, so the /model picker
1069
+ // serves them through the standard entries path — no parallel registry.
1070
+ const localDiscoveryRef = useRef(localDiscovery ?? createLocalDiscovery());
1071
+ const [localSnap, setLocalSnap] = useState(() => localDiscoveryRef.current.snapshot());
1072
+ const localSnapRef = useRef(localSnap);
1073
+ function setLocalSnapBoth(next) {
1074
+ localSnapRef.current = next;
1075
+ setLocalSnap(next);
1076
+ }
1077
+ // Fold a discovery snapshot into the models cache: reachable providers
1078
+ // contribute their model ids (keyed with their loopback baseURL);
1079
+ // unreachable providers are evicted so stale entries vanish on refresh.
1080
+ function applyLocalSnapshot(snap) {
1081
+ for (const id of ["ollama", "lmstudio", "llamacpp"]) {
1082
+ const r = snap.results[id];
1083
+ const key = modelsCacheKey(id, r.baseURL);
1084
+ if (r.ok) {
1085
+ modelsCacheRef.current.set(key, r.models.map((m) => m.id));
1086
+ }
1087
+ else {
1088
+ modelsCacheRef.current.delete(key);
1089
+ }
1090
+ }
1091
+ setLocalSnapBoth(snap);
1092
+ }
1093
+ // Non-blocking refresh kick: shared in-flight promise dedupes overlapping
1094
+ // calls (mount + picker-open + /models), so servers are never probed twice.
1095
+ function kickLocalDiscovery() {
1096
+ if (initialModels)
1097
+ return;
1098
+ // Suites stay hermetic regardless of loopback servers on the dev
1099
+ // machine: under Vitest only an explicitly injected fake may probe.
1100
+ if (!localDiscovery && process.env.VITEST)
1101
+ return;
1102
+ void localDiscoveryRef.current.refresh().then((snap) => {
1103
+ applyLocalSnapshot(snap);
1104
+ }, () => {
1105
+ // Discovery never rejects (per-provider isolation), but a defensive
1106
+ // catch keeps an unexpected throw from surfacing unhandled.
1107
+ });
1108
+ }
1109
+ // Clear error when the active local server is known-unreachable: the
1110
+ // server disappeared after discovery (or was never reachable). Returns
1111
+ // the notice, or null when local routing is fine.
1112
+ function localUnreachableNotice(id) {
1113
+ if (!isLocalProviderId(id))
1114
+ return null;
1115
+ const r = localSnapRef.current.results[id];
1116
+ if (r.ok)
1117
+ return null;
1118
+ const name = getProvider(id)?.name ?? id;
1119
+ return `${name} is unreachable at ${r.baseURL} — start the server, then run /models refresh.`;
1120
+ }
1121
+ // Loopback baseURL for chat/submit paths (env override wins, else the
1122
+ // probed snapshot base, else the compiled default).
1123
+ function localBaseURL(id) {
1124
+ const snapBase = localSnapRef.current.results[id]?.baseURL;
1125
+ return localBaseURLFor(id, snapBase);
1126
+ }
1127
+ function chatBaseURL(id) {
1128
+ return isLocalProviderId(id)
1129
+ ? localBaseURL(id)
1130
+ : getStoredBaseURL(authRef.current, id);
1131
+ }
480
1132
  // Phase 5: elapsed + stall indicator (status-bar only, never transcript).
481
1133
  // `elapsedSecs` ticks at 1s resolution while busy; `stalled` turns true
482
1134
  // when no token/tool/phase activity arrives for >3s mid-turn and clears
@@ -493,9 +1145,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
493
1145
  // subset. Failed user turns are popped (rollback) — but only when the
494
1146
  // HTTP POST itself fails; tool errors are results the model sees and
495
1147
  // are never rolled back.
496
- const historyRef = useRef([
497
- { role: "system", content: withEnvBlock(systemPrompt) },
498
- ]);
1148
+ // Tracked from birth: every later push/splice/index-assign flows through
1149
+ // the ContextLedger traps, so per-step accounting stays O(1). Replacements
1150
+ // below re-wrap via trackHistory (never assign a raw array here).
1151
+ const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
499
1152
  // Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
500
1153
  // pinned to history[0] (the only slot truncateHistory never drops), NEVER
501
1154
  // to user content. Refreshed once per turn in submit() + after doResume, so
@@ -537,7 +1190,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
537
1190
  }
538
1191
  else {
539
1192
  const p = providerRef.current;
540
- const baseURL = getStoredBaseURL(authRef.current, p);
1193
+ // Local runtimes resolve loopback baseURLs here too, so the mount
1194
+ // fetch and the discovery refresh share one cache key per server.
1195
+ const baseURL = chatBaseURL(p);
541
1196
  const cacheKey = modelsCacheKey(p, baseURL);
542
1197
  const cached = modelsCacheRef.current.get(cacheKey);
543
1198
  if (cached) {
@@ -546,7 +1201,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
546
1201
  cancelled = true;
547
1202
  };
548
1203
  }
549
- const k = resolveApiKey(p, authRef.current) || activeApiKeyRef.current;
1204
+ // Owner-gated seed: an anonymous Kilo mount must not inherit the
1205
+ // zen-seeded prop key (see keyForProvider).
1206
+ const k = keyForProvider(p);
550
1207
  void fetchModelsForProviderWithStatus(p, k, baseURL, endpoint).then(({ models: list, ok }) => {
551
1208
  if (cancelled)
552
1209
  return;
@@ -559,8 +1216,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
559
1216
  cancelled = true;
560
1217
  };
561
1218
  }, [endpoint, apiKey, initialModels]);
1219
+ // Slash-menu skill snapshot once on mount (local disk reads only —
1220
+ // zero fetches; refreshed on /skills, /clear, /new below).
1221
+ useEffect(() => {
1222
+ void refreshSkillMenu();
1223
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1224
+ }, []);
1225
+ // Local model discovery once on mount (background, non-blocking: the
1226
+ // probes race with first paint and merge into the models cache when they
1227
+ // land — the /model picker then serves local entries with zero extra
1228
+ // fetches. Skipped in tests via initialModels, like the live list above).
1229
+ useEffect(() => {
1230
+ kickLocalDiscovery();
1231
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1232
+ }, []);
562
1233
  // Phase 5: turn timer helpers (injectable now/timers for tests). The
563
1234
  // interval handle is always cleared on turn end and on unmount.
1235
+ // clockNow shares the injectable `now` (display timestamps only).
1236
+ function clockNow() {
1237
+ try {
1238
+ return (now ?? Date.now)();
1239
+ }
1240
+ catch {
1241
+ return Date.now();
1242
+ }
1243
+ }
564
1244
  function clearTurnTimer() {
565
1245
  const h = turnTimerRef.current;
566
1246
  if (h !== null) {
@@ -621,6 +1301,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
621
1301
  // in that gap; runLoopWithChat checks the signal before the first POST).
622
1302
  useEffect(() => {
623
1303
  return () => {
1304
+ // Local observability: close the session trace on unmount (covers
1305
+ // every exit path — /exit, Ctrl+C idle, test teardown) and flush.
1306
+ // Best-effort, never throws; idempotent with closeTelemetry callers.
1307
+ try {
1308
+ telemetry.endSession();
1309
+ }
1310
+ catch {
1311
+ // ignore
1312
+ }
1313
+ persistTelemetry();
624
1314
  try {
625
1315
  turnCancelRef.current?.abort();
626
1316
  }
@@ -658,16 +1348,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
658
1348
  // eslint-disable-next-line react-hooks/exhaustive-deps
659
1349
  }, []);
660
1350
  function setInputAndCursor(next, cursorPos) {
1351
+ const prev = inputRef.current;
661
1352
  inputRef.current = next;
662
1353
  setInput(next);
663
1354
  const clamped = Math.max(0, Math.min(cursorPos, next.length));
664
1355
  cursorRef.current = clamped;
665
1356
  setCursor(clamped);
666
- // Any edit restarts menu filtering from the top and re-opens the menu.
667
- slashIndexRef.current = 0;
668
- setSlashIndex(0);
669
- slashDismissedRef.current = false;
670
- setSlashDismissed(false);
1357
+ // Slash-prefixed edits restart menu filtering from the top and re-open
1358
+ // the menu; plain-text edits skip the two menu setStates entirely so a
1359
+ // keystroke is always input+cursor only (one batched render).
1360
+ if (next.startsWith("/") || prev.startsWith("/")) {
1361
+ slashIndexRef.current = 0;
1362
+ setSlashIndex(0);
1363
+ slashDismissedRef.current = false;
1364
+ setSlashDismissed(false);
1365
+ }
671
1366
  }
672
1367
  function setInputBoth(next) {
673
1368
  // Append-style callers (and clear/Esc/submit reset): cursor to end.
@@ -679,10 +1374,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
679
1374
  setCursor(clamped);
680
1375
  }
681
1376
  // Cursor-aware edits (plain input + slash menu): typing inserts AT the
682
- // cursor, backspace deletes BEFORE it, Delete removes AT it.
1377
+ // cursor, backspace deletes BEFORE it, Delete removes AT it. Every edit
1378
+ // abandons history browse (typing starts a fresh draft; the stash keeps
1379
+ // the pre-browse text for Down-past-newest, which re-stashes on re-entry).
1380
+ function exitHistoryBrowse() {
1381
+ histIndexRef.current = null;
1382
+ }
683
1383
  function insertAtCursor(text) {
684
1384
  if (!text)
685
1385
  return;
1386
+ exitHistoryBrowse();
686
1387
  const cur = inputRef.current;
687
1388
  const at = Math.max(0, Math.min(cursorRef.current, cur.length));
688
1389
  setInputAndCursor(cur.slice(0, at) + text + cur.slice(at), at + text.length);
@@ -692,6 +1393,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
692
1393
  const at = Math.max(0, Math.min(cursorRef.current, cur.length));
693
1394
  if (at <= 0)
694
1395
  return;
1396
+ exitHistoryBrowse();
695
1397
  setInputAndCursor(cur.slice(0, at - 1) + cur.slice(at), at - 1);
696
1398
  }
697
1399
  function deleteAtCursor() {
@@ -699,6 +1401,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
699
1401
  const at = Math.max(0, Math.min(cursorRef.current, cur.length));
700
1402
  if (at >= cur.length)
701
1403
  return;
1404
+ exitHistoryBrowse();
702
1405
  setInputAndCursor(cur.slice(0, at) + cur.slice(at + 1), at);
703
1406
  }
704
1407
  function setSelIndexBoth(next) {
@@ -749,9 +1452,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
749
1452
  authRef.current = next;
750
1453
  setAuth(next);
751
1454
  }
752
- function setActiveKeyBoth(next) {
1455
+ function setActiveKeyBoth(next, owner) {
753
1456
  activeApiKeyRef.current = next;
754
1457
  setActiveApiKey(next);
1458
+ if (owner !== undefined)
1459
+ activeKeyProviderRef.current = owner;
755
1460
  }
756
1461
  function setProviderIndexBoth(next) {
757
1462
  providerIndexRef.current = next;
@@ -779,13 +1484,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
779
1484
  const resolved = resolveApiKey(id, authRef.current);
780
1485
  if (resolved)
781
1486
  return resolved;
782
- if (id === providerRef.current && activeApiKeyRef.current) {
1487
+ // Seeded-key fallback, owner-gated (see activeKeyProviderRef): the
1488
+ // startup seed belongs to exactly one provider and must never leak
1489
+ // into another's requests (notably anonymous Kilo sessions).
1490
+ if (id === providerRef.current &&
1491
+ id === activeKeyProviderRef.current &&
1492
+ activeApiKeyRef.current) {
783
1493
  return activeApiKeyRef.current;
784
1494
  }
785
1495
  return "";
786
1496
  }
787
1497
  function closeAllPickers() {
788
1498
  setSelecting(false);
1499
+ setModelFilterBoth("");
1500
+ setSelectingSkills(false);
1501
+ setSkillFilterBoth("");
789
1502
  setSelectingEffort(false);
790
1503
  setSelectingProvider(false);
791
1504
  setKeyPromptBoth(null);
@@ -794,6 +1507,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
794
1507
  setSelectingRewindScope(false);
795
1508
  pendingRewindRef.current = null;
796
1509
  }
1510
+ // /skills picker (opencode-style searchable popup): opens on the fresh
1511
+ // registry (names only), filters as you type, loads on Enter. Local disk
1512
+ // reads only — zero fetches. Idle-only (history injection mid-turn would
1513
+ // break the loop's assistant/tool pairing).
1514
+ function openSkillPicker() {
1515
+ setInputBoth("");
1516
+ closeAllPickers();
1517
+ setSkillFilterBoth("");
1518
+ setSkillIndexBoth(0);
1519
+ void skillRegistry.refresh().then((found) => {
1520
+ if (busyRef.current) {
1521
+ pushInfo("Skills load when idle — wait for the turn to finish.");
1522
+ return;
1523
+ }
1524
+ const { skills } = resolveSkills(found.skills);
1525
+ setSkillPickerItems(skills.map((s) => ({ name: s.name, userInvocable: s.userInvocable, source: s.source })));
1526
+ setSelectingSkills(true);
1527
+ void refreshSkillMenu();
1528
+ }, () => {
1529
+ // Discovery never throws by contract, but a rejection must never
1530
+ // become an unhandled rejection (Node kills the process) — surface it.
1531
+ pushInfo("(skill discovery failed — no skills listed)");
1532
+ });
1533
+ }
1534
+ // Unified /model entries for this render: active provider's current list
1535
+ // first, then every other keyed provider's cached-or-fallback list (pure,
1536
+ // local-only — see modelPickerEntries). Called from the /model open path,
1537
+ // the picker input branch, and the picker render: all three run on the
1538
+ // same render's state, so open/highlight/filter/paint always agree.
1539
+ function buildModelEntries() {
1540
+ return modelPickerEntries({
1541
+ activeProvider: providerRef.current,
1542
+ activeModels: models,
1543
+ cached: (id, baseURL) => modelsCacheRef.current.get(modelsCacheKey(id, baseURL)),
1544
+ keyFor: (id) => keyForProvider(id),
1545
+ // Local runtimes resolve loopback baseURLs (env/defaults), so cache
1546
+ // reads hit the same keys discovery writes.
1547
+ baseURLFor: (id) => chatBaseURL(id),
1548
+ });
1549
+ }
797
1550
  function openProviderPicker() {
798
1551
  setSelecting(false);
799
1552
  setSelectingEffort(false);
@@ -831,10 +1584,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
831
1584
  // reuse the cached live list when available (zero fetches), else fetch
832
1585
  // the live list and cache successes; failures fall back uncached.
833
1586
  // /model always reflects the switched-to provider instantly from cache
834
- // when available. Keep current model if valid else provider default.
835
- async function switchProviderWithKey(pickedId, apiKeyValue) {
1587
+ // when available. Keep current model if valid else provider default
1588
+ // unless preserveModel names an explicit pick (cross-provider /model
1589
+ // selection), which wins unconditionally so the user's pick sticks even
1590
+ // before the background live refresh lands.
1591
+ async function switchProviderWithKey(pickedId, apiKeyValue, preserveModel) {
836
1592
  const def = getProvider(pickedId);
837
- const baseURL = getStoredBaseURL(authRef.current, pickedId);
1593
+ const baseURL = chatBaseURL(pickedId);
838
1594
  const cacheKey = modelsCacheKey(pickedId, baseURL);
839
1595
  const cached = modelsCacheRef.current.get(cacheKey);
840
1596
  let list;
@@ -853,16 +1609,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
853
1609
  }
854
1610
  }
855
1611
  setModels(list);
856
- const nextModel = list.includes(modelRef.current)
1612
+ const nextModel = preserveModel ?? (list.includes(modelRef.current)
857
1613
  ? modelRef.current
858
- : def.defaultModel;
1614
+ : def.defaultModel);
859
1615
  setModelBoth(nextModel);
860
1616
  setProviderBoth(pickedId);
861
1617
  // Provider switch resets the load latch: the last reported prompt_tokens
862
1618
  // belonged to the old provider/model tokenizer, so the estimate applies
863
1619
  // until the new provider reports (usageTotals spend is untouched).
864
1620
  resetContextLoadToEstimate();
865
- setActiveKeyBoth(apiKeyValue);
1621
+ setActiveKeyBoth(apiKeyValue, pickedId);
866
1622
  if (pickedId === "openai-compatible") {
867
1623
  setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
868
1624
  }
@@ -881,6 +1637,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
881
1637
  setTurns(next);
882
1638
  }
883
1639
  function appendTurns(...items) {
1640
+ // Autoscroll off + busy + following: freeze the view at its current end
1641
+ // BEFORE appending, so streaming output accumulates below instead of
1642
+ // yanking the viewport (smooth-scroll hold). Idle appends and already-
1643
+ // held views pass through untouched.
1644
+ if (!autoScrollRef.current && busyRef.current && scrollEndRef.current === null) {
1645
+ setScrollEndBoth(turnsRef.current.length);
1646
+ }
884
1647
  const next = [...turnsRef.current, ...items];
885
1648
  turnsRef.current = next;
886
1649
  setTurns(next);
@@ -905,7 +1668,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
905
1668
  setContextLoadBoth(null);
906
1669
  return null;
907
1670
  }
908
- const load = computeContextLoad(lastPromptTokensRef.current, historyChars(historyRef.current));
1671
+ const load = contextManager().usage(historyRef.current, lastPromptTokensRef.current).loadTokens;
909
1672
  setContextLoadBoth(load);
910
1673
  return load;
911
1674
  }
@@ -925,48 +1688,128 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
925
1688
  function pushInfo(content) {
926
1689
  appendTurns({ role: "tool", content });
927
1690
  }
928
- // Load a resolved skill into the session (tickets 03/06): the body (+any
929
- // inlined references) enters model history as one marked message and the
930
- // transcript turn, and its `allowed-tools` become turn-scoped grants in
931
- // skillGrantsRef (unioned several skills may load in one turn). Never
932
- // throws: loadSkillBody degrades to empty text, surfaced plainly.
933
- async function activateSkill(info) {
934
- const loaded = await loadSkillBody(info);
1691
+ // The session's ContextManager: window-derived budgets for the active
1692
+ // model, measured tool schemas, configured ceilings. Built fresh per call
1693
+ // (pure math, no I/O beyond the resolved ceiling sources) so it always sees
1694
+ // the current model; history is measured live on every use. The schema size
1695
+ // is memoized once TOOL_DEFINITIONS never changes at runtime, so every
1696
+ // turn must not re-serialize 15KB to ask.
1697
+ function contextManager() {
1698
+ return createContextManager({
1699
+ model: modelRef.current,
1700
+ toolsChars: TOOLS_SCHEMA_CHARS,
1701
+ });
1702
+ }
1703
+ // /context (Claude-Code-style visibility): where the tokens are going, by
1704
+ // source — system prompt, tool schemas, history, skill injections — plus
1705
+ // the budget and compaction posture. All estimates use the 4ch/token
1706
+ // heuristic (honest `token: n/a` accounting is untouched); all reads are
1707
+ // local, so the command costs zero fetches.
1708
+ function buildContextText() {
1709
+ const hist = historyRef.current;
1710
+ const first = hist[0];
1711
+ const sysChars = first && typeof first.content === "string"
1712
+ ? messageChars(first)
1713
+ : 0;
1714
+ const toolsChars = TOOLS_SCHEMA_CHARS;
1715
+ const mgr = contextManager();
1716
+ const u = mgr.usage(hist, lastPromptTokensRef.current);
1717
+ const b = mgr.budget(hist);
1718
+ const skillLoads = hist.filter((m) => m?.role === "user" &&
1719
+ typeof m.content === "string" &&
1720
+ m.content.includes('[skill "')).length;
1721
+ const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
1722
+ ? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
1723
+ : `load: ${formatKEst(u.historyChars)} (no verified window — auto-compact off, use /compact manually)`;
1724
+ const cfg = atomConfigLoad;
1725
+ const cfgSources = cfg.sources.project && cfg.sources.global
1726
+ ? "project + global"
1727
+ : cfg.sources.project
1728
+ ? "project"
1729
+ : cfg.sources.global
1730
+ ? "global"
1731
+ : "none";
1732
+ const cfgKeys = Object.keys(atomConfig).length;
1733
+ const cfgLine = `config: atom.json (${cfgSources}${cfgKeys > 0 ? `, ${cfgKeys} key${cfgKeys === 1 ? "" : "s"}` : ", defaults"})` +
1734
+ (cfg.warnings.length > 0 ? `\nconfig warnings:\n${cfg.warnings.map((w) => `- ${w}`).join("\n")}` : "");
1735
+ // Prefix-cache instrumentation (estimates + reported-only hits — a
1736
+ // provider that reports nothing shows "(not reported)", never zeros).
1737
+ // stableTokens are tokens; formatKEst takes chars, hence the ×4 round-trip.
1738
+ const caps = providerCacheSupport(providerRef.current);
1739
+ const prefix = first && typeof first.content === "string"
1740
+ ? assemblePrefix({
1741
+ systemContent: first.content,
1742
+ toolsJson: JSON.stringify(TOOL_DEFINITIONS),
1743
+ })
1744
+ : null;
1745
+ const totals = usageRef.current;
1746
+ const cacheHits = totals?.cacheReadTokens !== undefined || totals?.cacheWriteTokens !== undefined
1747
+ ? `read ${formatKEst((totals?.cacheReadTokens ?? 0) * 4)} / written ${formatKEst((totals?.cacheWriteTokens ?? 0) * 4)}`
1748
+ : "not reported by provider";
1749
+ const cacheLine = prefix !== null
1750
+ ? `cache: ${formatKEst(prefix.stableTokens * 4)} stable/cacheable (fp ${prefix.fingerprint.slice(0, 12)}) + ${formatKEst(prefix.dynamicSystem !== null ? prefix.dynamicSystem.length : 0)} dynamic env · ${caps.explicitBreakpoints ? "explicit breakpoints" : caps.implicitPrefix ? "implicit prefix" : "no caching assumed"} · hits: ${cacheHits}`
1751
+ : `cache: (no system message) · hits: ${cacheHits}`;
1752
+ return (`Context (model ${modelRef.current}):\n` +
1753
+ `system: ${formatKEst(sysChars)} (base + AGENTS overlay + env block)\n` +
1754
+ `tools: ${TOOL_DEFINITIONS.length} defs, ${formatKEst(toolsChars)}\n` +
1755
+ `history: ${u.historyMessages} messages / ${u.userTurns} user turns, ${formatKEst(u.historyChars)}\n` +
1756
+ `skill injections live in history: ${skillLoads}\n` +
1757
+ `${cfgLine}\n` +
1758
+ `${cacheLine}\n` +
1759
+ `${loadLine} · budget: ${b.effectiveMaxMessages} msgs / ${(b.effectiveMaxChars / 1000).toFixed(0)}K chars`);
1760
+ }
1761
+ // Load a resolved skill into the session (tickets 03/06) with
1762
+ // progressive-disclosure tiers (Claude-Code-style):
1763
+ // - manual (explicit user invocation): full body + inlined references enter
1764
+ // model history as one marked message (the user asked for the whole skill).
1765
+ // - auto (description match): Tier 2 only — body without inlined references
1766
+ // (the model reads references/<…> via read when needed), capped at
1767
+ // AUTO_SKILL_BODY_CAP so a trigger can never flood the window.
1768
+ // Both paths print ONE transcript line (never the body — the TUI stays
1769
+ // calm no matter how large the skill is); `allowed-tools` become
1770
+ // turn-scoped grants per the skill-grant trust policy (global skills arm,
1771
+ // project skills never do — see skillGrantsFor). Never throws:
1772
+ // loadSkillBody degrades to empty text, surfaced plainly.
1773
+ async function activateSkill(info, opts) {
1774
+ const auto = opts?.auto === true;
1775
+ const loaded = await loadSkillBody(info, auto ? { inlineRefs: false } : undefined);
935
1776
  if (loaded.text.trim().length === 0) {
936
1777
  pushInfo(`Skill "${info.name}" has an empty body — nothing loaded.`);
937
1778
  return;
938
1779
  }
939
- for (const t of loaded.info.allowedTools)
1780
+ const { grants, blocked } = skillGrantsFor(info.source, loaded.info.allowedTools);
1781
+ for (const t of grants)
940
1782
  skillGrantsRef.current.add(t);
1783
+ const contextText = auto ? capSkillBodyForAuto(loaded.text, info.dir) : loaded.text;
941
1784
  historyRef.current.push({
942
1785
  role: "user",
943
- content: `[skill "${info.name}" loaded — follow these instructions]\n${loaded.text}`,
1786
+ content: `[skill "${info.name}" loaded — follow these instructions]\n${contextText}`,
944
1787
  });
945
- const grantNote = loaded.info.allowedTools.length > 0
946
- ? ` (tools pre-approved this turn: ${loaded.info.allowedTools.join(", ")})`
947
- : "";
948
- pushInfo(`Skill "${info.name}" loaded${grantNote}\n${loaded.text}`);
949
- }
950
- // Manual /skill-name invocation (ticket 03). Idle-only: injecting history
951
- // mid-turn would break the loop's assistant/tool pairing. Unknown names
952
- // get a helpful error (not a model message); model-only skills refuse
953
- // with a pointer instead of loading.
1788
+ const grantNote = grants.length > 0
1789
+ ? ` (tools pre-approved this turn: ${grants.join(", ")})`
1790
+ : blocked.length > 0
1791
+ ? ` (project skill: ${blocked.join(", ")} still needs approval)`
1792
+ : "";
1793
+ pushInfo(`${info.name} loaded${grantNote}`);
1794
+ }
1795
+ // Manual skill invocation (ticket 03; `/skill-name` legacy form and the
1796
+ // namespaced `/skill:name` form both land here with the bare name).
1797
+ // Idle-only: injecting history mid-turn would break the loop's
1798
+ // assistant/tool pairing. Unknown names get a helpful error (not a model
1799
+ // message); model-only skills refuse with a pointer instead of loading.
954
1800
  async function invokeSkillByName(name) {
955
1801
  if (busyRef.current) {
956
1802
  pushInfo("Skills load when idle — wait for the turn to finish.");
957
1803
  return;
958
1804
  }
959
- const found = await discoverSkills({
960
- projectDir: skillDirs?.projectDir,
961
- homeDir: skillDirs?.homeDir,
962
- });
1805
+ const found = await skillRegistry.refresh();
963
1806
  const { skills } = resolveSkills(found.skills);
964
1807
  const info = skills.find((s) => s.name === name);
965
1808
  if (!info) {
966
- const available = skills.filter((s) => s.userInvocable).map((s) => `/${s.name}`);
1809
+ const available = skills.filter((s) => s.userInvocable).map((s) => `/skill:${s.name}`);
967
1810
  pushInfo(available.length > 0
968
- ? `Unknown skill "/${name}". Available: ${available.join(", ")}`
969
- : `Unknown skill "/${name}" (no skills installed).`);
1811
+ ? `Unknown skill "/skill:${name}". Available: ${available.join(", ")}`
1812
+ : `Unknown skill "/skill:${name}" (no skills installed).`);
970
1813
  return;
971
1814
  }
972
1815
  if (!info.userInvocable) {
@@ -978,7 +1821,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
978
1821
  // Snapshot the committed session (historyRef + turnsRef + settings refs)
979
1822
  // to ~/.atom/session.json. Disk errors are ignored (in-memory session
980
1823
  // still applies). Called only for committed state: completed turns and
981
- // clean exit — never for rolled-back (failed/cancelled) turns.
1824
+ // clean exit — never for rolled-back (failed/cancelled) turns. The
1825
+ // committed diff previews (Turn.diff) are display-only and never saved:
1826
+ // they can hold whole file contents (bloat) and would render stale
1827
+ // after later edits, so /resume restores label-only turns.
982
1828
  function persistSession() {
983
1829
  try {
984
1830
  saveSession({
@@ -988,13 +1834,66 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
988
1834
  mode: modeRef.current,
989
1835
  usageTotals: usageRef.current,
990
1836
  history: historyRef.current,
991
- turns: turnsRef.current,
1837
+ turns: turnsRef.current.map((t) => {
1838
+ const { diff: _dropped, ...rest } = t;
1839
+ return rest;
1840
+ }),
992
1841
  }, authHome);
993
1842
  }
994
1843
  catch {
995
1844
  // ignore disk errors (in-memory session still applies)
996
1845
  }
997
1846
  }
1847
+ // Local observability persistence: flush the current telemetry session
1848
+ // file (atomic, best-effort). Called on turn boundaries and session events —
1849
+ // never in the hot path, never throwing.
1850
+ function persistTelemetry() {
1851
+ try {
1852
+ telemetry.flush();
1853
+ }
1854
+ catch {
1855
+ // ignore disk errors (telemetry never breaks the session)
1856
+ }
1857
+ }
1858
+ function closeTelemetry() {
1859
+ try {
1860
+ telemetry.endSession();
1861
+ }
1862
+ catch {
1863
+ // ignore
1864
+ }
1865
+ persistTelemetry();
1866
+ }
1867
+ // One-line /telemetry summary: current-session progress plus stored totals.
1868
+ // Token figures are API-reported only; unavailable values say n/a with the
1869
+ // reason (never zero, never estimated).
1870
+ function telemetrySummaryText() {
1871
+ try {
1872
+ if (!telemetry.isEnabled()) {
1873
+ return "(telemetry off — ATOM_TELEMETRY=0 or atom.json telemetry.enabled=false; no traces recorded)";
1874
+ }
1875
+ const snap = telemetry.getSnapshot();
1876
+ const { sessions } = loadTelemetrySessions(authHome);
1877
+ const agg = summarizeTelemetry(sessions);
1878
+ const tokens = agg.usageReported
1879
+ ? [
1880
+ agg.usage.prompt_tokens !== undefined ? `in ${agg.usage.prompt_tokens}` : null,
1881
+ agg.usage.completion_tokens !== undefined ? `out ${agg.usage.completion_tokens}` : null,
1882
+ agg.usage.total_tokens !== undefined ? `total ${agg.usage.total_tokens}` : null,
1883
+ ]
1884
+ .filter((p) => p !== null)
1885
+ .join(" · ") || "reported (empty)"
1886
+ : "n/a (no usage reported yet)";
1887
+ const rate = agg.toolSuccessRate !== null ? `${(agg.toolSuccessRate * 100).toFixed(1)}%` : "n/a (no tool calls)";
1888
+ return (`Telemetry: on · this session ${snap.sessionId} (${snap.turns.length} turn(s)) · ` +
1889
+ `store ${telemetryDir(authHome)} (${agg.sessions} session(s), ${agg.turns} turn(s), ` +
1890
+ `${agg.modelCalls} model call(s), ${agg.toolCalls} tool call(s), success ${rate}, tokens ${tokens}, ` +
1891
+ `${agg.retries} retries) · /dashboard writes the full drill-down page.`);
1892
+ }
1893
+ catch {
1894
+ return "(telemetry unavailable)";
1895
+ }
1896
+ }
998
1897
  // Compaction routine (atomic): split head/tail, summarize head with
999
1898
  // tools disabled + 4096 cap, then swap history := [system, summary, tail].
1000
1899
  // On success: boundary line, totals kept, load refreshed, save. On
@@ -1015,12 +1914,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1015
1914
  }
1016
1915
  const systemMsg = historyRef.current[0];
1017
1916
  const systemContent = typeof systemMsg.content === "string" ? systemMsg.content : systemPrompt;
1018
- const submitKey = keyForProvider(providerRef.current) || activeApiKeyRef.current;
1019
- if (!submitKey) {
1917
+ // Owner-gated (see keyForProvider): never borrow another provider's
1918
+ // seed key — anonymous Kilo sessions POST keyless.
1919
+ const submitKey = keyForProvider(providerRef.current);
1920
+ // Local runtimes need no key; a known-unreachable server fails fast
1921
+ // with a clear notice instead of a bare connection error mid-turn.
1922
+ const compactLocalNotice = localUnreachableNotice(providerRef.current);
1923
+ if (compactLocalNotice) {
1924
+ pushInfo(compactLocalNotice);
1925
+ return false;
1926
+ }
1927
+ if (!submitKey && providerNeedsKey(providerRef.current)) {
1020
1928
  pushInfo(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
1021
1929
  return false;
1022
1930
  }
1023
- const baseURL = getStoredBaseURL(authRef.current, providerRef.current);
1931
+ const baseURL = chatBaseURL(providerRef.current);
1024
1932
  try {
1025
1933
  const summary = await requestCompactSummary({
1026
1934
  provider: providerRef.current,
@@ -1045,19 +1953,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1045
1953
  if (u.total_tokens !== undefined) {
1046
1954
  next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
1047
1955
  }
1956
+ if (u.cacheReadTokens !== undefined) {
1957
+ next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
1958
+ }
1959
+ if (u.cacheWriteTokens !== undefined) {
1960
+ next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
1961
+ }
1048
1962
  // Only accumulate when the summary actually reported usage;
1049
1963
  // an empty onUsage keeps totals byte-identical.
1050
1964
  if (u.prompt_tokens !== undefined ||
1051
1965
  u.completion_tokens !== undefined ||
1052
- u.total_tokens !== undefined) {
1966
+ u.total_tokens !== undefined ||
1967
+ u.cacheReadTokens !== undefined ||
1968
+ u.cacheWriteTokens !== undefined) {
1053
1969
  setUsageBoth(next);
1054
1970
  }
1971
+ // Local observability: compaction spend is session-level (it
1972
+ // summarizes many turns and often lands after its turn ended), so
1973
+ // it is kept separate from per-turn usage. No-op when empty.
1974
+ telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
1055
1975
  },
1056
1976
  });
1057
1977
  // Atomic swap: build the new history first, then replace.
1058
1978
  const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
1059
- historyRef.current = next;
1979
+ // Replacement: re-wrap so the ledger restarts from the compacted array
1980
+ // (the old ledger is discarded with the old array).
1981
+ historyRef.current = trackHistory(next);
1060
1982
  appendTurns({ role: "tool", content: compactBoundaryLine(split.olderTurnCount) });
1983
+ // New lineage (see src/rollback.ts): the atomic swap invalidates
1984
+ // checkpoint marks — drop them, loudly when non-empty. The summary
1985
+ // keeps the story; stale marks must never truncate the new tail.
1986
+ const compactDrops = clearSnapshots();
1987
+ if (compactDrops > 0) {
1988
+ pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
1989
+ }
1061
1990
  // P% must drop immediately: the old lastPromptTokens reflects the
1062
1991
  // pre-compact context, so clear it and use the new-history estimate.
1063
1992
  lastPromptTokensRef.current = undefined;
@@ -1103,8 +2032,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1103
2032
  return;
1104
2033
  }
1105
2034
  // Unknown window → no auto trigger (never invent a window); below
1106
- // threshold → streak resets. shouldAutoCompact owns the pct math.
1107
- if (!shouldAutoCompact(load, modelRef.current)) {
2035
+ // threshold → streak resets. The manager owns the pct math, so a false
2036
+ // there means either case — re-check the window for the reset.
2037
+ if (!contextManager().needsCompaction(load)) {
1108
2038
  // Distinguish unknown-window (streak untouched — irrelevant) from
1109
2039
  // below-threshold (streak resets). shouldAutoCompact is false for
1110
2040
  // both, so re-check the window for the reset.
@@ -1133,7 +2063,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1133
2063
  }
1134
2064
  const s = result.session;
1135
2065
  setProviderBoth(s.provider);
1136
- const baseURL = getStoredBaseURL(authRef.current, s.provider);
2066
+ const baseURL = chatBaseURL(s.provider);
1137
2067
  if (s.provider === "openai-compatible") {
1138
2068
  setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
1139
2069
  }
@@ -1147,7 +2077,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1147
2077
  setEffortBoth(s.effort);
1148
2078
  setModeBoth(s.mode);
1149
2079
  setUsageBoth(s.usageTotals);
1150
- historyRef.current = [...s.history];
2080
+ // Replacement: wrap the restored array (see the init comment).
2081
+ historyRef.current = trackHistory([...s.history]);
1151
2082
  // Task 6: refresh the pinned env block on the restored system line
1152
2083
  // (strips the saved block, appends a fresh one) — keeps the restored
1153
2084
  // AGENTS overlay, never touches user content.
@@ -1165,13 +2096,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1165
2096
  setAutoDisabledBoth(false);
1166
2097
  pendingCompactRef.current = null;
1167
2098
  const pendingNotices = [];
1168
- truncateHistory(historyRef.current, (msg) => {
2099
+ // New lineage (see src/rollback.ts): the restored history replaces the
2100
+ // live array, so live checkpoint marks are stale — drop them. Disk files
2101
+ // are untouched; only undo evidence goes.
2102
+ const resumedDrops = clearSnapshots();
2103
+ if (resumedDrops > 0) {
2104
+ pendingNotices.push({
2105
+ role: "tool",
2106
+ content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
2107
+ });
2108
+ }
2109
+ // Same window-derived caps as the live path (the restored model is
2110
+ // already in modelRef above).
2111
+ contextManager().trimForSend(historyRef.current, (msg) => {
1169
2112
  pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
1170
- });
2113
+ }, undefined, openTodoNeedles());
1171
2114
  // Remount the turns <Static> (same mechanism as /clear and /new): Ink's
1172
2115
  // Static only renders newly appended indices, so restoring a transcript
1173
2116
  // over a non-empty rendered buffer (e.g. the /new boundary line) would
1174
- // misalign and hide the first restored turn(s).
2117
+ // misalign and hide the first restored turn(s). Restored list replaces
2118
+ // the live one, so a held view re-follows (see /clear).
2119
+ setScrollEndBoth(null);
1175
2120
  setClearGen((g) => g + 1);
1176
2121
  setTurnsBoth([
1177
2122
  ...s.turns,
@@ -1181,6 +2126,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1181
2126
  },
1182
2127
  ...pendingNotices,
1183
2128
  ]);
2129
+ telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
2130
+ persistTelemetry();
1184
2131
  }
1185
2132
  // /rewind conversation scope (ticket 01): truncate history + transcript to
1186
2133
  // the checkpoint's turn. The cut drops the whole containing turn (submit's
@@ -1199,6 +2146,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1199
2146
  const turnsCut = conversationCutIndex(turnsRef.current.map((t) => ({ role: t.role })), cp.turnsLength, 0);
1200
2147
  if (turnsCut < turnsRef.current.length) {
1201
2148
  setTurnsBoth(turnsRef.current.slice(0, turnsCut));
2149
+ // Truncation can strand a held end past the new bottom — re-follow.
2150
+ setScrollEndBoth(null);
1202
2151
  }
1203
2152
  if (droppedMessages <= 0) {
1204
2153
  return `(already at checkpoint #${cp.seq} — conversation untouched)`;
@@ -1267,7 +2216,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1267
2216
  // like every slash command except /compact). Rules gate write/edit/bash —
1268
2217
  // the approval tools — so a rule naming a read-only tool is accepted but
1269
2218
  // noted as inert (those calls never consult approve()).
1270
- const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
1271
2219
  function runRulesCommand(raw) {
1272
2220
  const text = raw.trim();
1273
2221
  const space = text.indexOf(" ");
@@ -1304,6 +2252,157 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1304
2252
  : ` (note: ${parsed.tool} is read-only and auto-runs — rules gate write/edit/bash)`;
1305
2253
  pushInfo(`${kind === "allow" ? "allowed" : "denied"}: ${parsed.pattern} (${rulesRef.current.length} rule(s))${gatedNote}`);
1306
2254
  }
2255
+ // Queue + steer surface (Claude-Code-style follow-ups): /queue lists,
2256
+ // /queue clear wipes, /steer <text> steers the running turn (or sends when
2257
+ // idle). Idle-only except /steer-with-text and /queue reads, which also run
2258
+ // while busy — that is their entire purpose. Callers gate on busy like
2259
+ // every slash command except /compact (submit's busy branch routes here).
2260
+ function runQueueCommand(raw) {
2261
+ const text = raw.trim();
2262
+ if (text === "/queue") {
2263
+ if (queueRef.current.length === 0 && !steerRef.current) {
2264
+ pushInfo("(queue empty — type + Enter while busy to queue a follow-up)");
2265
+ return;
2266
+ }
2267
+ const lines = queueRef.current.map((q, i) => `${i + 1}. ${q}`);
2268
+ if (steerRef.current)
2269
+ lines.unshift(`(steering now: ${steerRef.current})`);
2270
+ pushInfo(`Queued (${queueRef.current.length}):\n${lines.join("\n")}`);
2271
+ return;
2272
+ }
2273
+ if (text === "/queue clear") {
2274
+ setQueueBoth([]);
2275
+ pushInfo("(queue cleared)");
2276
+ return;
2277
+ }
2278
+ if (text === "/steer") {
2279
+ pushInfo(STEER_USAGE);
2280
+ return;
2281
+ }
2282
+ if (text.startsWith("/steer ")) {
2283
+ const msg = text.slice("/steer".length).trim();
2284
+ if (!msg) {
2285
+ pushInfo(STEER_USAGE);
2286
+ return;
2287
+ }
2288
+ if (!busyRef.current) {
2289
+ // Idle: steering is just sending (same pipeline as typed input).
2290
+ void submit(msg);
2291
+ return;
2292
+ }
2293
+ // Busy: steer the active turn — drained at the next loop step boundary
2294
+ // (see drainSteer). A second steer while one is pending queues behind
2295
+ // it instead of clobbering it.
2296
+ if (steerRef.current) {
2297
+ if (queueRef.current.length >= QUEUE_CAP) {
2298
+ pushInfo(`(queue full — ${QUEUE_CAP} pending; /queue lists, /queue clear wipes)`);
2299
+ return;
2300
+ }
2301
+ setQueueBoth([...queueRef.current, msg]);
2302
+ pushInfo(`(steer pending — queued behind it (${queueRef.current.length}))`);
2303
+ return;
2304
+ }
2305
+ setSteerPendingBoth(msg);
2306
+ return;
2307
+ }
2308
+ pushInfo(QUEUE_USAGE);
2309
+ }
2310
+ // /thinking: rendering-only visibility toggle for model thinking (both
2311
+ // the committed transcript blocks and the live thinking block). Pure
2312
+ // paint — safe while busy (never touches the turn, like /autoscroll).
2313
+ // Bare toggles; anything appended prints usage (there are no arguments).
2314
+ function runThinkingCommand(raw) {
2315
+ if (raw.trim() !== "/thinking") {
2316
+ pushInfo(THINKING_USAGE);
2317
+ return;
2318
+ }
2319
+ const next = !showThinkingRef.current;
2320
+ setShowThinkingBoth(next);
2321
+ pushInfo(next
2322
+ ? "(thinking shown — model reasoning stays visible in the transcript)"
2323
+ : "(thinking hidden — reasoning still runs, it just isn't rendered)");
2324
+ }
2325
+ // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
2326
+ // only state — safe while busy (never touches the turn, like /queue).
2327
+ // Bare prints the state; on jumps to the latest; off freezes a following
2328
+ // view at its current end (mid-turn appends then accumulate below).
2329
+ function runAutoScrollCommand(raw) {
2330
+ const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
2331
+ if (arg === "") {
2332
+ pushInfo(autoScrollRef.current
2333
+ ? "(autoscroll on — following new output as it arrives)"
2334
+ : "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2335
+ return;
2336
+ }
2337
+ if (arg === "on") {
2338
+ if (autoScrollRef.current) {
2339
+ pushInfo("(autoscroll already on)");
2340
+ return;
2341
+ }
2342
+ setAutoScrollBoth(true);
2343
+ setScrollEndBoth(null);
2344
+ pushInfo("(autoscroll on — following the latest)");
2345
+ return;
2346
+ }
2347
+ if (arg === "off") {
2348
+ if (!autoScrollRef.current) {
2349
+ pushInfo("(autoscroll already off)");
2350
+ return;
2351
+ }
2352
+ // Confirm FIRST while still following (visible), then flip the switch:
2353
+ // flipping first would freeze this very confirm below the viewport
2354
+ // (appendTurns freezes busy appends once off). Already-held views stay
2355
+ // held; the next busy append freezes a following view via appendTurns.
2356
+ pushInfo("(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2357
+ setAutoScrollBoth(false);
2358
+ return;
2359
+ }
2360
+ pushInfo(AUTOSCROLL_USAGE);
2361
+ }
2362
+ // /models: local-discovery status + refresh. Bare `/models` reports the
2363
+ // last snapshot (kicking a first probe when discovery never ran);
2364
+ // `/models refresh` re-probes all three runtimes, then reports. Results
2365
+ // merge into the models cache, so the /model picker serves them with no
2366
+ // extra fetches — one dim summary line, never transcript spam.
2367
+ async function runModelsCommand(arg) {
2368
+ const a = arg.trim().toLowerCase();
2369
+ if (a !== "" && a !== "refresh") {
2370
+ pushInfo("usage: /models [refresh] — probe local model servers (Ollama, LM Studio, llama.cpp).");
2371
+ return;
2372
+ }
2373
+ // Manual Kilo refresh: clear the gateway catalog cache and re-fetch.
2374
+ // The current model sticks when still listed; otherwise the fresh
2375
+ // catalog's preferred free model wins (never a hardcoded id).
2376
+ if (providerRef.current === "kilo" && a === "refresh") {
2377
+ pushInfo("(refreshing Kilo model catalog…)");
2378
+ clearKiloModelsCache();
2379
+ try {
2380
+ const res = await fetchModelsForProviderWithStatus("kilo", keyForProvider("kilo"), "", endpoint);
2381
+ if (res.ok)
2382
+ modelsCacheRef.current.set(modelsCacheKey("kilo"), [...res.models]);
2383
+ setModels(res.models);
2384
+ if (!res.models.includes(modelRef.current)) {
2385
+ setModelBoth(preferFreeKiloModel(res.models, modelRef.current));
2386
+ }
2387
+ pushInfo(`Kilo models refreshed: ${res.models.length} available${res.ok ? "" : " (offline list)"}.`);
2388
+ }
2389
+ catch {
2390
+ pushInfo("Kilo models refresh failed — keeping the current list.");
2391
+ }
2392
+ return;
2393
+ }
2394
+ if (localSnapRef.current.version === 0 || a === "refresh") {
2395
+ pushInfo("(probing local model servers…)");
2396
+ try {
2397
+ const snap = await localDiscoveryRef.current.refresh();
2398
+ applyLocalSnapshot(snap);
2399
+ }
2400
+ catch {
2401
+ // Discovery never rejects (per-provider isolation); defensive only.
2402
+ }
2403
+ }
2404
+ pushInfo(summarizeLocalSnapshot(localSnapRef.current));
2405
+ }
1307
2406
  function runSlashCommand(cmd) {
1308
2407
  setInputBoth("");
1309
2408
  switch (cmd) {
@@ -1314,13 +2413,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1314
2413
  return;
1315
2414
  case "/clear":
1316
2415
  // Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
1317
- // fresh env block.
1318
- historyRef.current = [{ role: "system", content: withEnvBlock(systemPrompt) }];
2416
+ // fresh env block. Fresh array → fresh ledger via trackHistory.
2417
+ historyRef.current = trackHistory([
2418
+ { role: "system", content: withEnvBlock(systemPrompt) },
2419
+ ]);
1319
2420
  setTurnsBoth([]);
2421
+ // List replacement re-follows a held view (the frozen end no longer
2422
+ // exists — render clamping would follow the window but leave a
2423
+ // stale held indicator).
2424
+ setScrollEndBoth(null);
1320
2425
  setClearGen((g) => g + 1);
1321
2426
  setError(null);
1322
2427
  setDraft(null);
1323
- setThinking(null);
2428
+ clearThinking();
1324
2429
  setToolHint(null);
1325
2430
  setPhase("idle");
1326
2431
  setPhaseDetail("");
@@ -1331,11 +2436,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1331
2436
  // kept: token totals and effort are per-session (see /help).
1332
2437
  // autoDisabled stays for the session (thrash guard is session-wide).
1333
2438
  skillGrantsRef.current = new Set();
2439
+ // Lineage rule (see src/rollback.ts): the fresh history invalidates
2440
+ // checkpoint marks, so /rewind undos never cross a cleared
2441
+ // conversation. Silent when there was nothing to drop.
2442
+ const clearedDrops = clearSnapshots();
2443
+ if (clearedDrops > 0) {
2444
+ pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
2445
+ }
1334
2446
  // autoDisabled stays for the session (thrash guard is session-wide).
1335
2447
  lastPromptTokensRef.current = undefined;
1336
2448
  setContextLoadBoth(null);
1337
2449
  autoStreakRef.current = 0;
1338
2450
  pendingCompactRef.current = null;
2451
+ telemetry.recordEvent("clear", "conversation cleared (token totals kept)");
2452
+ persistTelemetry();
2453
+ void refreshSkillMenu();
1339
2454
  return;
1340
2455
  case "/new":
1341
2456
  // Claude-Code semantics: end the current conversation and start
@@ -1346,18 +2461,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1346
2461
  // session.json, so no archiving is invented here.
1347
2462
  persistSession();
1348
2463
  // Fresh system re-read (system.ts base + current AGENTS.md overlay)
1349
- // plus a fresh Task 6 env block.
1350
- historyRef.current = [{ role: "system", content: withEnvBlock(buildSystemPrompt()) }];
2464
+ // plus a fresh Task 6 env block. Fresh array → fresh ledger.
2465
+ historyRef.current = trackHistory([
2466
+ { role: "system", content: withEnvBlock(buildSystemPrompt()) },
2467
+ ]);
1351
2468
  setTurnsBoth([
1352
2469
  {
1353
2470
  role: "tool",
1354
2471
  content: "(new session started — previous conversation kept, /resume to restore it)",
1355
2472
  },
1356
2473
  ]);
2474
+ // Fresh list: a held view has nothing to hold onto — re-follow.
2475
+ setScrollEndBoth(null);
1357
2476
  setClearGen((g) => g + 1);
1358
2477
  setError(null);
1359
2478
  setDraft(null);
1360
- setThinking(null);
2479
+ clearThinking();
1361
2480
  setToolHint(null);
1362
2481
  setPhase("idle");
1363
2482
  setPhaseDetail("");
@@ -1372,11 +2491,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1372
2491
  clearTodos();
1373
2492
  setTodoSnap([]);
1374
2493
  skillGrantsRef.current = new Set();
2494
+ // New lineage (see src/rollback.ts): yesterday's checkpoint marks
2495
+ // cannot index the fresh history — drop them, loudly when non-empty.
2496
+ const newDrops = clearSnapshots();
2497
+ if (newDrops > 0) {
2498
+ pushInfo(`(/new — discarded ${newDrops} file checkpoint(s); undos do not cross sessions)`);
2499
+ }
1375
2500
  // Compaction state restarts fresh (unlike /clear, where the thrash
1376
2501
  // guard stays disabled for the session).
1377
2502
  autoStreakRef.current = 0;
1378
2503
  setAutoDisabledBoth(false);
1379
2504
  pendingCompactRef.current = null;
2505
+ telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
2506
+ persistTelemetry();
2507
+ void refreshSkillMenu();
1380
2508
  return;
1381
2509
  case "/compact":
1382
2510
  // Bare /compact with no focus text (slash-menu path). Free-text
@@ -1384,14 +2512,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1384
2512
  // text survives; both funnel to the same busy/pending logic below.
1385
2513
  void runCompactCommand("");
1386
2514
  return;
1387
- case "/model":
1388
- setSelIndexBoth(Math.max(0, models.indexOf(model)));
2515
+ case "/model": {
2516
+ // Unified picker opens unfiltered with the highlight on the current
2517
+ // model (active provider's section first, so same-provider rises
2518
+ // stay index-stable when other keyed providers add sections below).
2519
+ // Late lifecycle: if discovery never ran (slow/no startup probe),
2520
+ // kick it now so local sections fill in behind the open picker.
2521
+ if (localSnapRef.current.version === 0)
2522
+ kickLocalDiscovery();
2523
+ setModelFilterBoth("");
2524
+ const entries = buildModelEntries();
2525
+ const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
2526
+ setSelIndexBoth(Math.max(0, at));
1389
2527
  setSelecting(true);
1390
2528
  setSelectingEffort(false);
1391
2529
  setSelectingProvider(false);
1392
2530
  setKeyPromptBoth(null);
1393
2531
  setBaseURLPromptBoth(null);
1394
2532
  return;
2533
+ }
1395
2534
  case "/provider":
1396
2535
  openProviderPicker();
1397
2536
  return;
@@ -1407,13 +2546,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1407
2546
  pushInfo(toolsListText());
1408
2547
  return;
1409
2548
  case "/skills":
1410
- // Local filesystem read: never rejects (failures become warnings),
1411
- // so no catch is needed to keep the input responsive.
1412
- void skillsListText(skillDirs?.projectDir, skillDirs?.homeDir).then((text) => pushInfo(text));
2549
+ // Searchable picker (names only, type to filter, Enter loads).
2550
+ // Local reads only zero fetches, like the model picker.
2551
+ openSkillPicker();
2552
+ return;
2553
+ case "/skill":
2554
+ pushInfo(SKILL_USAGE);
2555
+ return;
2556
+ case "/context":
2557
+ pushInfo(buildContextText());
2558
+ return;
2559
+ case "/queue":
2560
+ runQueueCommand("/queue");
2561
+ return;
2562
+ case "/steer":
2563
+ runQueueCommand("/steer");
2564
+ return;
2565
+ case "/thinking":
2566
+ runThinkingCommand("/thinking");
2567
+ return;
2568
+ case "/autoscroll":
2569
+ runAutoScrollCommand("/autoscroll");
1413
2570
  return;
1414
2571
  case "/mode":
1415
2572
  if (modeRef.current === "plan") {
1416
- pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; /plan to approve + exit)");
2573
+ pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
1417
2574
  return;
1418
2575
  }
1419
2576
  pushInfo(trustAllRef.current
@@ -1424,7 +2581,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1424
2581
  // Plan is a deliberate safety mode: trust must not punch through it.
1425
2582
  // The flag is left untouched so exiting plan restores prior behavior.
1426
2583
  if (modeRef.current === "plan") {
1427
- pushInfo("(plan mode is read-only — exit plan with /plan before /trust; trust unchanged)");
2584
+ pushInfo("(plan mode is read-only — Tab out of plan before /trust; trust unchanged)");
1428
2585
  return;
1429
2586
  }
1430
2587
  const next = !trustAllRef.current;
@@ -1434,34 +2591,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1434
2591
  : "trust: off — write/edit/bash ask again");
1435
2592
  return;
1436
2593
  }
1437
- case "/yolo": {
1438
- // Same deliberate-safety rule as /trust: yolo must not punch through
1439
- // plan mode (and Tab below never enters/exits plan either). The user
1440
- // exits explicitly with /plan.
1441
- if (modeRef.current === "plan") {
1442
- pushInfo("(plan mode is read-only — exit plan with /plan before /yolo; mode unchanged)");
1443
- return;
1444
- }
1445
- const next = modeRef.current === "normal" ? "yolo" : "normal";
1446
- setModeBoth(next);
1447
- pushInfo(`mode: ${next}`);
1448
- return;
1449
- }
2594
+ case "/yolo":
1450
2595
  case "/plan": {
1451
- if (modeRef.current === "plan") {
1452
- // Human approval: typing /plan to exit approves the recorded plan.
1453
- // Always lands in normal (never yolo) so implementation starts
1454
- // under asking permissions; the session checklist recorded while
1455
- // planning survives the switch (todowrite handoff).
1456
- setModeBoth("normal");
1457
- const planned = getTodos().length;
1458
- pushInfo(planned > 0
1459
- ? `(plan approved — ${planned} task(s) carry into implementation under normal permissions)`
1460
- : "(plan mode off — no plan recorded)");
1461
- return;
1462
- }
1463
- setModeBoth("plan");
1464
- pushInfo("plan mode: on — explore freely (read/grep/glob/web/todos/ask run free; write/edit/bash are blocked with a replan note). Record the plan with todowrite, then /plan to approve + exit into implementation.");
2596
+ // Retired: Tab is the only mode switcher (it cycles
2597
+ // normal yolo plan normal). Kept as explicit cases so typing
2598
+ // them explains instead of falling through to skill lookup.
2599
+ pushInfo("(retired Tab cycles the permission mode: normal → yolo → plan → normal)");
1465
2600
  return;
1466
2601
  }
1467
2602
  case "/help":
@@ -1477,6 +2612,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1477
2612
  case "/resume":
1478
2613
  doResume();
1479
2614
  return;
2615
+ case "/telemetry":
2616
+ pushInfo(telemetrySummaryText());
2617
+ return;
2618
+ case "/dashboard": {
2619
+ const out = writeTelemetryDashboard(authHome);
2620
+ pushInfo(out
2621
+ ? `(observability dashboard written to ${out} — open it in a browser. Local file, nothing uploaded.)`
2622
+ : "(dashboard failed to write — telemetry store unavailable)");
2623
+ return;
2624
+ }
1480
2625
  case "/rewind": {
1481
2626
  // Idle-only like every slash command except /compact (submit's busy
1482
2627
  // guard already routes here only when idle): restoring mid-turn would
@@ -1514,37 +2659,57 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1514
2659
  async function approve(name, args) {
1515
2660
  if (turnCancelRef.current?.signal.aborted)
1516
2661
  throw new LoopCancelledError();
1517
- const verdict = checkRules(rulesRef.current, name, args);
1518
- if (verdict === "deny")
2662
+ // Stage the transcript-diff preview for write/edit (all outcomes):
2663
+ // the modal below reuses it, and onToolActivity consumes it when the
2664
+ // matching execution commits. Deny/cancel paths clear it (execution
2665
+ // never happens, so nothing must linger for a later call). Full-file
2666
+ // BEFORE is captured here (pre-execution); AFTER resolves at commit
2667
+ // (write content arg, or a post-execution disk read for edit).
2668
+ const stagedDiff = name === "write" || name === "edit" ? previewDiffForApproval(name, args) : null;
2669
+ if (name === "write" || name === "edit") {
2670
+ const toolPath = typeof args["path"] === "string" ? args["path"] : null;
2671
+ const beforeFull = name === "write"
2672
+ ? (stagedDiff?.oldText ?? null) // preview already pre-read it: no second read
2673
+ : toolPath !== null
2674
+ ? readFileForDiff(path.resolve(process.cwd(), toolPath))
2675
+ : null;
2676
+ const afterArg = name === "write" && typeof args["content"] === "string"
2677
+ ? args["content"]
2678
+ : null;
2679
+ pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
2680
+ }
2681
+ // Policy layer owns the decision order (deny → plan → allow → yolo →
2682
+ // trust → always → skill grants → prompt); this function owns cancel
2683
+ // handling and the interactive prompt plumbing around it.
2684
+ const outcome = decidePolicy(name, args, {
2685
+ mode: modeRef.current,
2686
+ trustAll: trustAllRef.current,
2687
+ rules: rulesRef.current,
2688
+ alwaysAllowed: alwaysAllowedRef.current,
2689
+ skillGrants: skillGrantsRef.current,
2690
+ approvalGated: needsApproval(name),
2691
+ });
2692
+ if (outcome.kind === "deny") {
2693
+ pendingDiffRef.current = null;
1519
2694
  return "no";
1520
- // Plan mode (ticket 04): read-only. Mutations skip the prompt entirely
1521
- // and flow to guardedExecute, which refuses them pre-execution with a
1522
- // replan-friendly note. Returning "once" here only routes past the prompt
1523
- // — the gate below still blocks, so allow/yolo/trust/always/grants below
1524
- // cannot punch through.
1525
- if (modeRef.current === "plan" && needsApproval(name))
1526
- return "once";
1527
- if (verdict === "allow")
1528
- return "once";
1529
- if (modeRef.current === "yolo")
1530
- return "once";
1531
- if (trustAllRef.current)
1532
- return "once";
1533
- if (alwaysAllowedRef.current.has(name))
1534
- return "once";
1535
- if (skillGrantsRef.current.has(name))
2695
+ }
2696
+ if (outcome.kind === "allow")
1536
2697
  return "once";
1537
2698
  const signal = turnCancelRef.current?.signal ?? null;
1538
- if (signal?.aborted)
2699
+ if (signal?.aborted) {
2700
+ pendingDiffRef.current = null;
1539
2701
  throw new LoopCancelledError();
2702
+ }
1540
2703
  return new Promise((resolve, reject) => {
1541
2704
  approvalResolveRef.current = { resolve, reject };
1542
- setPendingApproval({ name, args });
2705
+ setApproveIndexBoth(0);
2706
+ setPendingApproval({ name, args, diff: stagedDiff });
1543
2707
  if (signal) {
1544
2708
  const onAbort = () => {
1545
2709
  const h = approvalResolveRef.current;
1546
2710
  approvalResolveRef.current = null;
1547
2711
  setPendingApproval(null);
2712
+ pendingDiffRef.current = null;
1548
2713
  h?.reject(new LoopCancelledError());
1549
2714
  };
1550
2715
  if (signal.aborted)
@@ -1558,6 +2723,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1558
2723
  if (decision === "always" && pendingApproval) {
1559
2724
  alwaysAllowedRef.current.add(pendingApproval.name);
1560
2725
  }
2726
+ if (decision === "no")
2727
+ pendingDiffRef.current = null;
1561
2728
  const h = approvalResolveRef.current;
1562
2729
  approvalResolveRef.current = null;
1563
2730
  setPendingApproval(null);
@@ -1584,7 +2751,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1584
2751
  function guardedExecute(name, args) {
1585
2752
  if (modeRef.current === "plan" && needsApproval(name)) {
1586
2753
  return Promise.resolve(`Error: plan mode is read-only — ${name} blocked (no writes while planning). ` +
1587
- `Explore with read/grep/glob/web tools, record the plan with todowrite, then exit plan mode (/plan) to implement.`);
2754
+ `Explore with read/grep/glob/web tools, record the plan with todowrite, then Tab out of plan mode to implement.`);
1588
2755
  }
1589
2756
  return executeTool(name, args);
1590
2757
  }
@@ -1654,8 +2821,56 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1654
2821
  // SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
1655
2822
  // nothing). Busy guard + API-key check: rejections return before any
1656
2823
  // history mutation, so there is nothing to roll back.
1657
- if (!text || busyRef.current)
2824
+ if (!text)
2825
+ return;
2826
+ // Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
2827
+ // the thought is never lost); /queue + /steer manage and inject. Other
2828
+ // "/" input still needs idle (pickers/modals would race the turn), so it
2829
+ // drops silently exactly as before.
2830
+ if (busyRef.current) {
2831
+ if (text === "/queue" || text.startsWith("/queue ") ||
2832
+ text === "/steer" || text.startsWith("/steer ")) {
2833
+ runQueueCommand(text);
2834
+ return;
2835
+ }
2836
+ // /autoscroll and /thinking are view-only state (never touch the
2837
+ // turn), so they run while busy like /queue + /steer (see
2838
+ // slashRunsWhileBusy).
2839
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2840
+ runAutoScrollCommand(text);
2841
+ return;
2842
+ }
2843
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
2844
+ runThinkingCommand(text);
2845
+ return;
2846
+ }
2847
+ if (text.startsWith("/"))
2848
+ return;
2849
+ if (queueRef.current.length >= QUEUE_CAP) {
2850
+ pushInfo(`(queue full — ${QUEUE_CAP} pending; /queue lists, /queue clear wipes)`);
2851
+ return;
2852
+ }
2853
+ setQueueBoth([...queueRef.current, text]);
2854
+ return;
2855
+ }
2856
+ // Queue + steer routing (idle): exact or free-text forms, mirroring the
2857
+ // /allow pattern above — SLASH_NAMES only holds exact commands.
2858
+ if (text === "/queue" || text.startsWith("/queue ") ||
2859
+ text === "/steer" || text.startsWith("/steer ")) {
2860
+ runQueueCommand(text);
1658
2861
  return;
2862
+ }
2863
+ // /autoscroll takes an optional subcommand (/autoscroll on|off), like the
2864
+ // /queue family — SLASH_NAMES only holds the exact command. /thinking
2865
+ // is bare-toggle-only; anything appended prints its usage.
2866
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2867
+ runAutoScrollCommand(text);
2868
+ return;
2869
+ }
2870
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
2871
+ runThinkingCommand(text);
2872
+ return;
2873
+ }
1659
2874
  // Scoped rules (ticket 03): exact or free-text forms (/allow bash:x,
1660
2875
  // /rules clear) route with args intact — SLASH_NAMES only holds exact
1661
2876
  // commands, and the skill fallback below must not swallow these.
@@ -1665,21 +2880,54 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1665
2880
  runRulesCommand(text);
1666
2881
  return;
1667
2882
  }
2883
+ // /models takes an optional subcommand (/models refresh), like the
2884
+ // /allow family — SLASH_NAMES only holds exact commands.
2885
+ if (text === "/models" || text.startsWith("/models ")) {
2886
+ const arg = text === "/models" ? "" : text.slice("/models ".length);
2887
+ void runModelsCommand(arg);
2888
+ return;
2889
+ }
1668
2890
  // Exact full-command + Enter runs it. A single-token "/name" not in
1669
- // SLASH_NAMES resolves through the skill registry (ticket 03); anything
2891
+ // SLASH_NAMES resolves through the skill registry (ticket 03, legacy
2892
+ // form — the namespaced `/skill:name` below is canonical); anything
1670
2893
  // else starting with "/" still falls through as a model message.
1671
2894
  if (SLASH_NAMES.has(text)) {
1672
2895
  runSlashCommand(text);
1673
2896
  return;
1674
2897
  }
2898
+ // Retired commands explain instead of falling through to skill lookup.
2899
+ if (text === "/yolo" || text === "/plan") {
2900
+ runSlashCommand(text);
2901
+ return;
2902
+ }
2903
+ // Namespaced skill invocation: `/skill:name` (bare `/skill` shows usage
2904
+ // via the registry path above). Resolves through the same registry as
2905
+ // the legacy `/name` form and the slash menu.
2906
+ if (text === "/skill" || text === "/skill:") {
2907
+ pushInfo(SKILL_USAGE);
2908
+ return;
2909
+ }
2910
+ const namespaced = /^\/skill:([A-Za-z0-9_-]+)$/.exec(text)?.[1];
2911
+ if (namespaced !== undefined) {
2912
+ void invokeSkillByName(namespaced);
2913
+ return;
2914
+ }
1675
2915
  const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
1676
2916
  if (skillName !== undefined) {
1677
2917
  void invokeSkillByName(skillName);
1678
2918
  return;
1679
2919
  }
1680
- // Missing key: guide to /provider instead of POSTing.
1681
- const submitKey = keyForProvider(providerRef.current) || activeApiKeyRef.current;
1682
- if (!submitKey) {
2920
+ // Missing key: guide to /provider instead of POSTing (local runtimes
2921
+ // and Kilo need no key — Kilo serves anonymous free models; a
2922
+ // known-unreachable server fails fast with a clear error instead of a
2923
+ // bare connection error mid-turn). Owner-gated (see keyForProvider).
2924
+ const submitKey = keyForProvider(providerRef.current);
2925
+ const submitLocalNotice = localUnreachableNotice(providerRef.current);
2926
+ if (submitLocalNotice) {
2927
+ setError(submitLocalNotice);
2928
+ return;
2929
+ }
2930
+ if (!submitKey && providerNeedsKey(providerRef.current)) {
1683
2931
  setError(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
1684
2932
  return;
1685
2933
  }
@@ -1687,7 +2935,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1687
2935
  setBusy(true);
1688
2936
  setError(null);
1689
2937
  setDraft(null);
1690
- setThinking(null);
2938
+ clearThinking();
2939
+ // Fresh turn, fresh latch: the queue drain at the end auto-sends only
2940
+ // when this turn was NOT cancelled (see the turn-end finally).
2941
+ turnCancelledRef.current = false;
1691
2942
  // NOTE: skill grants are NOT cleared here — a manually armed skill
1692
2943
  // (loaded while idle) must survive into the turn it was armed for.
1693
2944
  // Expiry happens in the turn-end finally below, plus /clear + /new.
@@ -1703,6 +2954,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1703
2954
  // Phase 5: start the elapsed/stall timer (status-bar only, never the
1704
2955
  // transcript). Cleared in finally below and on unmount.
1705
2956
  startTurnTimer();
2957
+ // Display-only tool clock: no tool is running at turn start, so any
2958
+ // stale timestamp from a previous turn must not leak into this one.
2959
+ toolStartRef.current = null;
2960
+ // Same for the transcript-diff slot: a previous turn's unconsumed
2961
+ // preview (cancelled mid-execution) must never attach to this turn.
2962
+ pendingDiffRef.current = null;
2963
+ lastPartialRef.current = "";
2964
+ refreshGitInfo();
1706
2965
  // SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
1707
2966
  // survives failure). Refresh the pinned env block ONCE per turn (not per
1708
2967
  // POST — the loop reuses history[0] for all its POSTs, so this is the
@@ -1714,12 +2973,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1714
2973
  // survives failure). History budget at turn start, BEFORE the push +
1715
2974
  // rollbackTo capture below (so the existing splice-rollback indices stay
1716
2975
  // valid): drop oldest user-turns first, reserving room for the incoming user message
1717
- // so the loop core's own budget check stays a no-op on entry exactly
2976
+ // so the loop core's own budget check stays a no-op on entry - exactly
1718
2977
  // one dim notice per truncating turn. /clear drops the notice with the
1719
- // transcript (usage totals still survive).
1720
- truncateHistory(historyRef.current, (msg) => {
1721
- appendTurns({ role: "tool", content: `⚠ ${msg}` });
1722
- }, { messages: 1, chars: text.length });
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());
1723
2983
  // SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
1724
2984
  // back on failure). Turn boundary: on POST failure (HTTP/network/empty/
1725
2985
  // truncated) the whole user turn (user message plus any partial
@@ -1729,22 +2989,43 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1729
2989
  // catch). Cancellation (LoopCancelledError) shares the same splice
1730
2990
  // contract.
1731
2991
  const rollbackTo = historyRef.current.length;
2992
+ // Local observability: open this turn's trace (no-op when disabled).
2993
+ // Provider/model switches surface here per turn; session-level switches
2994
+ // are derived from the same updates (see setSessionMeta).
2995
+ telemetry.setSessionMeta({ provider: providerRef.current, model: modelRef.current });
2996
+ const telemetryTurnId = telemetry.startTurn(text, {
2997
+ provider: providerRef.current,
2998
+ model: modelRef.current,
2999
+ effort: effortRef.current,
3000
+ mode: modeRef.current,
3001
+ });
3002
+ const telemetrySink = {
3003
+ onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
3004
+ onToolCall: (info) => telemetry.recordToolCall(telemetryTurnId, info),
3005
+ };
1732
3006
  const controller = new AbortController();
1733
3007
  turnCancelRef.current = controller;
1734
3008
  historyRef.current.push({ role: "user", content: text });
1735
3009
  appendTurns({ role: "user", content: text });
1736
- // Skill auto-invoke (ticket 04): deterministic description match over a
1737
- // fresh registry, inside the rollback scope so a failed turn removes
1738
- // skill context too. Slash invocations skip it (manual path owns those).
1739
- // Discovery/loading never throw; the guard only protects submit itself.
3010
+ // Skill auto-invoke (ticket 04, progressive disclosure): deterministic
3011
+ // whole-word match over a fresh registry with a high bar (3 distinct
3012
+ // word hits, at most 1 skill per turn), inside the rollback scope so a
3013
+ // failed turn removes skill context too. Slash invocations skip it
3014
+ // (manual path owns those). Auto loads Tier 2 only (body, no inlined
3015
+ // references, 12KB cap — see activateSkill), so a trigger can never flood
3016
+ // the window. Discovery/loading never throw; the guard only protects
3017
+ // submit itself.
1740
3018
  if (!text.startsWith("/")) {
1741
3019
  try {
1742
- const found = await discoverSkills({
1743
- projectDir: skillDirs?.projectDir,
1744
- homeDir: skillDirs?.homeDir,
1745
- });
1746
- for (const info of matchSkills(text, resolveSkills(found.skills).skills)) {
1747
- await activateSkill(info);
3020
+ // Registry refresh: stat-level revalidation (cheap), then the pure
3021
+ // deterministic match over metadata — no LLM, no body reads.
3022
+ const found = await skillRegistry.refresh();
3023
+ for (const info of matchSkills(text, resolveSkills(found.skills).skills, {
3024
+ max: 1,
3025
+ minHits: 3,
3026
+ wholeWords: true,
3027
+ })) {
3028
+ await activateSkill(info, { auto: true });
1748
3029
  }
1749
3030
  }
1750
3031
  catch {
@@ -1752,10 +3033,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1752
3033
  }
1753
3034
  }
1754
3035
  try {
1755
- const baseURL = getStoredBaseURL(authRef.current, providerRef.current);
3036
+ const baseURL = chatBaseURL(providerRef.current);
1756
3037
  const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
1757
3038
  approve,
1758
3039
  askUser,
3040
+ // Local observability sink: the loop reports completed model/tool
3041
+ // calls (iterations, durations, usage) into the open turn trace.
3042
+ telemetry: telemetrySink,
3043
+ // Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
3044
+ // bottleneck, context growth) attach to the same open turn trace.
3045
+ // Fires once per turn — including failed/cancelled turns, whose
3046
+ // endTurn below still records the outcome alongside these stats.
3047
+ onLoopStats: (s) => telemetry.recordLoopStats(telemetryTurnId, s),
1759
3048
  // Plan-mode read-only gate (ticket 04): mutations are refused here
1760
3049
  // with a replan note; every other tool delegates to executeTool.
1761
3050
  execute: guardedExecute,
@@ -1769,9 +3058,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1769
3058
  catch {
1770
3059
  setDraft(partial);
1771
3060
  }
3061
+ lastPartialRef.current = partial;
1772
3062
  noteTurnActivity();
1773
3063
  },
1774
3064
  onThinking: (partial) => {
3065
+ thinkingRef.current = partial;
1775
3066
  setThinking(partial);
1776
3067
  noteTurnActivity();
1777
3068
  },
@@ -1780,15 +3071,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1780
3071
  setPhaseDetail(detail ?? "");
1781
3072
  noteTurnActivity();
1782
3073
  if (p === "thinking") {
1783
- // New POST: its thinking (if any) replaces the previous round's.
1784
- setThinking(null);
3074
+ // New POST: the previous round's thinking (if any) commits to
3075
+ // the transcript so it stays in the TUI instead of being
3076
+ // replaced and lost; the fresh round streams into the live block.
3077
+ commitThinking();
1785
3078
  }
1786
3079
  else if (p === "tool" && detail) {
1787
3080
  setToolHint(detail);
3081
+ toolStartRef.current = clockNow();
1788
3082
  }
1789
3083
  else if (p === "retry") {
1790
3084
  const msg = detail ? `↻ retrying… ${detail}` : "↻ retrying…";
1791
3085
  appendTurns({ role: "tool", content: msg });
3086
+ // Local observability: transport retries attach to the model call
3087
+ // they precede (the recorder buffers them until it completes).
3088
+ telemetry.recordRetry(telemetryTurnId, detail ?? "");
1792
3089
  }
1793
3090
  else if (p === "done") {
1794
3091
  setToolHint(null);
@@ -1797,8 +3094,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1797
3094
  },
1798
3095
  onToolDelta: (name) => {
1799
3096
  setToolHint(name);
3097
+ toolStartRef.current = clockNow();
1800
3098
  },
1801
3099
  onUsage: (u) => {
3100
+ // Local observability: per-turn usage accumulates inside the
3101
+ // recorder when the loop reports the completed model call (same
3102
+ // payload) — recording it here too would count every POST twice.
1802
3103
  // Cumulative session spend from REAL reports only: every reporting
1803
3104
  // POST accumulates (tool-round POSTs and successful retries each
1804
3105
  // count once — each was billed; failed attempts report nothing, so
@@ -1817,6 +3118,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1817
3118
  if (u.total_tokens !== undefined) {
1818
3119
  next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
1819
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
+ }
1820
3129
  setUsageBoth(next);
1821
3130
  },
1822
3131
  onReasoning: (label) => {
@@ -1825,8 +3134,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1825
3134
  onWarning: (msg) => {
1826
3135
  appendTurns({ role: "tool", content: `⚠ ${msg}` });
1827
3136
  },
3137
+ // Steering seam: drains one pending /steer message into history +
3138
+ // transcript at each loop step boundary (see drainSteer in zen.ts).
3139
+ // The message joins the turn's fate — a failed turn rolls it back
3140
+ // with everything else (same splice contract as the user turn).
3141
+ drainSteer: () => {
3142
+ const s = steerRef.current;
3143
+ if (!s)
3144
+ return;
3145
+ steerRef.current = null;
3146
+ setSteerPending(null);
3147
+ historyRef.current.push({ role: "user", content: s });
3148
+ appendTurns({ role: "user", content: s });
3149
+ },
1828
3150
  onToolActivity: (label, result, isError) => {
1829
- const items = [{ role: "tool", content: label }];
3151
+ // Display-only duration: wall time since the tool started (see
3152
+ // toolStartRef). Attached as Turn.ms for the `· Ns` suffix; the
3153
+ // label text itself stays byte-identical to the loop's audit line.
3154
+ const started = toolStartRef.current;
3155
+ toolStartRef.current = null;
3156
+ const ms = started !== null ? Math.max(0, clockNow() - started) : 0;
3157
+ const items = [{ role: "tool", content: label, ms }];
3158
+ // Inspector retention (display-only): keep the full result for
3159
+ // later browsing. Capped count; stored text char-capped inside
3160
+ // the record with an explicit truncation flag.
3161
+ toolLogRef.current.push(createToolRecord(toolSeqRef.current++, label, result, isError, ms));
3162
+ if (toolLogRef.current.length > MAX_TOOL_RECORDS) {
3163
+ toolLogRef.current.splice(0, toolLogRef.current.length - MAX_TOOL_RECORDS);
3164
+ }
1830
3165
  // Todo tools are session state, not side effects: their results
1831
3166
  // are short checklists, so successful ones join the transcript
1832
3167
  // (history fidelity — what did the list look like when?) and
@@ -1843,19 +3178,66 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1843
3178
  else if (isTodo) {
1844
3179
  items.push({ role: "tool", content: result });
1845
3180
  }
3181
+ // Committed transcript diff: the approve-time capture for this
3182
+ // exact execution rides on the label turn. Consume-or-clear on
3183
+ // every matching activity (success or failure) so a stale
3184
+ // capture can never leak onto a later call; render only on
3185
+ // success with a real payload (failures keep the ↳ line only).
3186
+ // Full-file BEFORE→AFTER is preferred (aligned panes with
3187
+ // context); when either side is unavailable (unreadable file,
3188
+ // oversize), fall back to the arg-block preview pair.
3189
+ const slot = pendingDiffRef.current;
3190
+ if (slot !== null &&
3191
+ (label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `))) {
3192
+ pendingDiffRef.current = null;
3193
+ if (!isError) {
3194
+ let afterFull = null;
3195
+ if (slot.name === "write") {
3196
+ afterFull = slot.afterArg;
3197
+ }
3198
+ else if (slot.path !== null) {
3199
+ afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
3200
+ }
3201
+ const beforeFull = slot.beforeFull;
3202
+ if (beforeFull !== null && afterFull !== null) {
3203
+ items[0].diff = {
3204
+ oldText: beforeFull,
3205
+ newText: afterFull,
3206
+ lang: slot.diff?.lang ?? null,
3207
+ path: slot.path,
3208
+ };
3209
+ }
3210
+ else if (slot.diff !== null) {
3211
+ items[0].diff = slot.diff;
3212
+ }
3213
+ }
3214
+ }
1846
3215
  appendTurns(...items);
1847
3216
  noteTurnActivity();
1848
3217
  },
1849
3218
  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
+ },
1850
3225
  });
1851
3226
  // Turn-end flush: any trailing throttled partial paints before the
1852
- // commit replaces the draft (byte-exact via `reply` regardless).
3227
+ // commit replaces the draft (byte-exact via `reply` regardless). The
3228
+ // final round's thinking commits first (chronological: reasoning, then
3229
+ // the answer it produced).
1853
3230
  flushDraft();
3231
+ commitThinking();
1854
3232
  appendTurns({ role: "assistant", content: reply });
1855
3233
  // The turn committed to history (final text, denial-as-result, or
1856
3234
  // stop-notice) — persist the kill-safe save. Rolled-back turns (catch
1857
3235
  // below) never reach here, so a failure can't clobber the last good save.
1858
3236
  persistSession();
3237
+ // Local observability: close the turn trace with the loop's own outcome
3238
+ // labels (completed / blocked / unverified / budget-exceeded) and flush.
3239
+ telemetry.endTurn(telemetryTurnId, classifyTurnOutcome(reply), reply);
3240
+ persistTelemetry();
1859
3241
  // Drain boundary (still busy, never mid-turn): pending manual /compact
1860
3242
  // first (it resets the thrash counter), else auto-compact when the
1861
3243
  // load is over threshold. Compaction persists via the normal save path.
@@ -1884,12 +3266,38 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1884
3266
  (err instanceof Error && err.name === "LoopCancelledError") ||
1885
3267
  controller.signal.aborted;
1886
3268
  historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
3269
+ turnCancelledRef.current = cancelled;
3270
+ // The turn never happened: drop live thinking with it (a failed turn
3271
+ // commits nothing — same scope as the history rollback above).
3272
+ clearThinking();
3273
+ // Local observability: failed/cancelled turns still record what was
3274
+ // attempted (model/tool calls so far) with their outcome, then flush.
3275
+ // Like the save above, the telemetry file only ever gains completed
3276
+ // turn traces plus these explicit failure markers — never partial
3277
+ // transcript state.
3278
+ telemetry.endTurn(telemetryTurnId, cancelled ? "cancelled" : "failed", err instanceof Error ? err.message : String(err));
3279
+ persistTelemetry();
1887
3280
  if (cancelled) {
1888
3281
  // One dim line (tool role renders dim); not an error.
1889
3282
  // Rolled back above: no save, the last good save stays intact.
1890
- appendTurns({ role: "tool", content: "(cancelled)" });
3283
+ // The line states the rollback scope outright (see src/rollback.ts):
3284
+ // conversation only — disk and processes were NOT reverted.
3285
+ appendTurns({ role: "tool", content: cancelledTurnLine() });
1891
3286
  }
1892
3287
  else {
3288
+ // Failed (not cancelled): the streamed answer so far is committed
3289
+ // as a marked partial turn BEFORE the error. Without this, a rate
3290
+ // limit or dead network after 30s of streaming wipes everything the
3291
+ // user already read. History stays rolled back (model never sees
3292
+ // it); only the display transcript keeps the partial.
3293
+ const partial = lastPartialRef.current.trim();
3294
+ lastPartialRef.current = "";
3295
+ if (partial) {
3296
+ appendTurns({
3297
+ role: "assistant",
3298
+ content: `${partial}\n\n(request failed before completing — partial output preserved)`,
3299
+ });
3300
+ }
1893
3301
  setError(err instanceof Error ? err.message : String(err));
1894
3302
  }
1895
3303
  // Turn-end drain even after failure/rollback: pending manual still
@@ -1913,6 +3321,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1913
3321
  turnCancelRef.current = null;
1914
3322
  approvalResolveRef.current = null;
1915
3323
  setPendingApproval(null);
3324
+ // Safety net: the slot is normally consumed by onToolActivity or
3325
+ // cleared on deny/cancel — never let it cross a turn boundary.
3326
+ pendingDiffRef.current = null;
1916
3327
  askResolveRef.current = null;
1917
3328
  setPendingQuestion(null);
1918
3329
  setAskCustomBoth("");
@@ -1922,6 +3333,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1922
3333
  skillGrantsRef.current = new Set();
1923
3334
  busyRef.current = false;
1924
3335
  setBusy(false);
3336
+ refreshGitInfo();
1925
3337
  try {
1926
3338
  draftThrottleRef.current?.cancel();
1927
3339
  }
@@ -1929,13 +3341,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1929
3341
  // ignore
1930
3342
  }
1931
3343
  setDraft(null);
1932
- setThinking(null);
3344
+ clearThinking();
1933
3345
  setToolHint(null);
1934
3346
  clearTurnTimer();
1935
3347
  setStalled(false);
1936
3348
  setElapsedSecs(0);
1937
3349
  setPhase("idle");
1938
3350
  setPhaseDetail("");
3351
+ // Queue drain (Claude-Code-style): a clean turn auto-sends the next
3352
+ // queued follow-up (chaining while the queue is non-empty); a cancelled
3353
+ // turn keeps its queue visible but never auto-sends. A steer stranded
3354
+ // by a failed/cancelled turn rejoins the queue front — the thought is
3355
+ // preserved, the user decides when it runs. Runs after busy resets
3356
+ // above so the chained submit enters a clean turn.
3357
+ const stranded = steerRef.current;
3358
+ if (stranded) {
3359
+ steerRef.current = null;
3360
+ setSteerPending(null);
3361
+ setQueueBoth([stranded, ...queueRef.current]);
3362
+ }
3363
+ if (!turnCancelledRef.current && queueRef.current.length > 0) {
3364
+ const next = queueRef.current[0];
3365
+ setQueueBoth(queueRef.current.slice(1));
3366
+ void submit(next);
3367
+ }
1939
3368
  }
1940
3369
  }
1941
3370
  function cancelTurn() {
@@ -1954,6 +3383,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1954
3383
  const rawCancel = ch === "\u0003" || ch === "\u0004";
1955
3384
  const ctrlCancel = (key.ctrl && (ch === "c" || ch === "d" || ch === "C" || ch === "D")) || rawCancel;
1956
3385
  if (ctrlCancel) {
3386
+ // Inspector open (idle): Ctrl+C closes it — never exits the app from
3387
+ // inside the inspector.
3388
+ if (inspectingRef.current) {
3389
+ closeInspector();
3390
+ return;
3391
+ }
1957
3392
  // Mid-turn: cancel the whole turn (works during POST wait, tool
1958
3393
  // execution, and the approval/question modals). Idle: exit as before.
1959
3394
  if (turnCancelRef.current || busyRef.current) {
@@ -1969,11 +3404,32 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1969
3404
  exit();
1970
3405
  return;
1971
3406
  }
1972
- // 1. Tool approval prompt (normal mode): y = once, a = always this
3407
+ // 1. Tool approval prompt (normal mode): arrows + Enter select
3408
+ // (highlight starts at allow-once), y = once, a = always this
1973
3409
  // session, t = trust all write/edit/bash this session, n/Esc = deny
1974
3410
  // (denial feeds back into the loop as a result).
1975
3411
  // Ctrl+C (handled above) cancels the whole turn instead.
1976
3412
  if (pendingApproval) {
3413
+ if (key.upArrow) {
3414
+ setApproveIndexBoth((approveIndexRef.current + 3) % 4);
3415
+ return;
3416
+ }
3417
+ if (key.downArrow) {
3418
+ setApproveIndexBoth((approveIndexRef.current + 1) % 4);
3419
+ return;
3420
+ }
3421
+ if (key.return) {
3422
+ const i = approveIndexRef.current;
3423
+ if (i === 1)
3424
+ resolveApproval("always");
3425
+ else if (i === 2)
3426
+ resolveTrustAll();
3427
+ else if (i === 3)
3428
+ resolveApproval("no");
3429
+ else
3430
+ resolveApproval("once");
3431
+ return;
3432
+ }
1977
3433
  const k = (ch ?? "").toLowerCase();
1978
3434
  if (k === "y")
1979
3435
  resolveApproval("once");
@@ -2042,8 +3498,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2042
3498
  }
2043
3499
  if (key.return) {
2044
3500
  const draft = kp.draft;
2045
- if (!draft || kp.validating || busy)
3501
+ if (kp.validating || busy)
2046
3502
  return;
3503
+ // Kilo keys are optional: empty Enter continues anonymously on
3504
+ // free models (Esc also works — see above).
3505
+ if (!draft) {
3506
+ if (kp.providerId === "kilo") {
3507
+ const keep = keyForProvider(kp.providerId);
3508
+ setKeyPromptBoth(null);
3509
+ void switchProviderWithKey(kp.providerId, keep);
3510
+ return;
3511
+ }
3512
+ return;
3513
+ }
2047
3514
  const baseURL = getStoredBaseURL(authRef.current, kp.providerId);
2048
3515
  setKeyPromptBoth({ ...kp, validating: true, error: null });
2049
3516
  void validateProviderKey(kp.providerId, draft, baseURL).then((res) => {
@@ -2149,6 +3616,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2149
3616
  !getStoredBaseURL(authRef.current, picked.id)) {
2150
3617
  openBaseURLPrompt(picked.id);
2151
3618
  }
3619
+ else if (picked.id === "kilo" && !keyForProvider(picked.id)) {
3620
+ // Kilo without a key: offer the optional key prompt — anonymous
3621
+ // free models stay one (empty) Enter away inside it.
3622
+ openKeyPrompt(picked.id);
3623
+ }
3624
+ else if (!providerNeedsKey(picked.id)) {
3625
+ // Local runtime (or keyed Kilo): no key to paste — switch
3626
+ // straight in (standard switch path refreshes its model list
3627
+ // in the background).
3628
+ setSelectingProvider(false);
3629
+ void switchProviderWithKey(picked.id, keyForProvider(picked.id));
3630
+ }
2152
3631
  else {
2153
3632
  // No key -> paste prompt; key on file -> replace prompt
2154
3633
  // (masked hint; typing replaces, Esc keeps + switches).
@@ -2157,34 +3636,119 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2157
3636
  }
2158
3637
  return;
2159
3638
  }
2160
- // 3. Model picker (opening it replaces/closes the slash menu; Esc
2161
- // returns to plain input, never to the slash menu).
3639
+ // 3. Model picker (unified cross-provider list, type-to-filter, windowed;
3640
+ // opening it replaces/closes the slash menu; Esc returns to plain input,
3641
+ // never to the slash menu).
2162
3642
  if (selecting) {
3643
+ // Rebuilt per keypress from the same render's state the paint uses, so
3644
+ // highlight/filter/paint never disagree mid-tick.
3645
+ const entries = filterModelEntries(buildModelEntries(), modelFilterRef.current);
2163
3646
  if (key.upArrow) {
2164
- setSelIndexBoth((selIndexRef.current - 1 + models.length) % models.length);
3647
+ if (entries.length > 0) {
3648
+ setSelIndexBoth((selIndexRef.current - 1 + entries.length) % entries.length);
3649
+ }
2165
3650
  }
2166
3651
  else if (key.downArrow) {
2167
- setSelIndexBoth((selIndexRef.current + 1) % models.length);
3652
+ if (entries.length > 0) {
3653
+ setSelIndexBoth((selIndexRef.current + 1) % entries.length);
3654
+ }
2168
3655
  }
2169
3656
  else if (key.escape) {
3657
+ setModelFilterBoth("");
2170
3658
  setSelecting(false);
2171
3659
  }
2172
3660
  else if (key.return) {
2173
- const picked = models[selIndexRef.current];
3661
+ const picked = entries[selIndexRef.current];
3662
+ setModelFilterBoth("");
2174
3663
  if (picked) {
2175
- setModelBoth(picked);
2176
- // Model switch resets the load latch (different tokenizer: the old
2177
- // reported prompt_tokens no longer measures this context); the
2178
- // estimate applies until the new model reports.
2179
- resetContextLoadToEstimate();
2180
- // Re-gate effort on every /model switch: setting persists, but a
2181
- // non-Default effort on an unsupported model warns (kept, not sent).
2182
- if (effortRef.current !== "default" && !isEffortSupported(picked)) {
2183
- warnEffortUnsupported(picked);
3664
+ if (picked.providerId === providerRef.current) {
3665
+ setModelBoth(picked.model);
3666
+ // Model switch resets the load latch (different tokenizer: the old
3667
+ // reported prompt_tokens no longer measures this context); the
3668
+ // estimate applies until the new model reports.
3669
+ resetContextLoadToEstimate();
3670
+ // Re-gate effort on every /model switch: setting persists, but a
3671
+ // non-Default effort on an unsupported model warns (kept, not sent).
3672
+ if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
3673
+ warnEffortUnsupported(picked.model);
3674
+ }
3675
+ }
3676
+ else {
3677
+ // Cross-provider pick: switch with the resolved key (env wins,
3678
+ // else stored — remote sections only render for keyed providers;
3679
+ // local sections need no key) and keep the picked model; the
3680
+ // live refresh lands in the background via the standard switch
3681
+ // path. reasoning_effort is zen-only, so a non-Default effort
3682
+ // warns (kept, not sent).
3683
+ const switchedKey = keyForProvider(picked.providerId);
3684
+ if (switchedKey || !providerNeedsKey(picked.providerId)) {
3685
+ const pickedProvider = picked.providerId;
3686
+ const pickedModel = picked.model;
3687
+ void (async () => {
3688
+ await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
3689
+ if (effortRef.current !== "default") {
3690
+ warnEffortUnsupported(pickedModel);
3691
+ }
3692
+ })();
3693
+ }
2184
3694
  }
2185
3695
  }
2186
3696
  setSelecting(false);
2187
3697
  }
3698
+ else if (key.backspace || key.delete) {
3699
+ setModelFilterBoth(modelFilterRef.current.slice(0, -1));
3700
+ setSelIndexBoth(0);
3701
+ }
3702
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
3703
+ setModelFilterBoth(modelFilterRef.current + ch);
3704
+ setSelIndexBoth(0);
3705
+ }
3706
+ return;
3707
+ }
3708
+ // 3a. Skills picker (searchable popup, same pattern as /model, but
3709
+ // selection STAGES for confirm: type to filter, ↑/↓ + Enter/Tab puts
3710
+ // `/skill:name` into the input (nothing is sent), Esc cancels.
3711
+ // Backspace edits the filter.
3712
+ if (selectingSkills) {
3713
+ // Rebuilt per keypress from the same render's state the paint uses, so
3714
+ // highlight/filter/paint never disagree mid-tick.
3715
+ const entries = filterSkillPicker(skillPickerItems, skillFilterRef.current);
3716
+ if (key.upArrow) {
3717
+ if (entries.length > 0) {
3718
+ setSkillIndexBoth((skillIndexRef.current - 1 + entries.length) % entries.length);
3719
+ }
3720
+ }
3721
+ else if (key.downArrow) {
3722
+ if (entries.length > 0) {
3723
+ setSkillIndexBoth((skillIndexRef.current + 1) % entries.length);
3724
+ }
3725
+ }
3726
+ else if (key.escape) {
3727
+ setSkillFilterBoth("");
3728
+ setSelectingSkills(false);
3729
+ }
3730
+ else if (key.return || key.tab) {
3731
+ const picked = entries[skillIndexRef.current];
3732
+ const name = picked?.name;
3733
+ setSkillFilterBoth("");
3734
+ setSelectingSkills(false);
3735
+ // Stage for confirm, never auto-send: the exact command lands in the
3736
+ // input and a second Enter runs it through the normal submit path
3737
+ // (exact `/skill:name` executes). Model-only entries refuse there,
3738
+ // same as a typed /skill:name.
3739
+ if (name) {
3740
+ exitHistoryBrowse();
3741
+ setInputBoth(`/skill:${name}`);
3742
+ }
3743
+ }
3744
+ else if (key.backspace || key.delete) {
3745
+ setSkillFilterBoth(skillFilterRef.current.slice(0, -1));
3746
+ setSkillIndexBoth(0);
3747
+ }
3748
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
3749
+ setSkillFilterBoth(skillFilterRef.current + ch);
3750
+ setSkillIndexBoth(0);
3751
+ }
2188
3752
  return;
2189
3753
  }
2190
3754
  // 3b. Effort picker (/effort): same keyboard pattern as the /model
@@ -2260,12 +3824,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2260
3824
  }
2261
3825
  return;
2262
3826
  }
2263
- // 4. "/" slash menu (filter-as-you-type): ↑/↓ + Enter/Tab runs the
2264
- // highlighted command, Esc dismisses back to plain input.
3827
+ // 4. "/" slash menu (filter-as-you-type): commands first, then matching
3828
+ // skills as namespaced `/skill:name` entries. ↑/↓ + Enter/Tab runs the
3829
+ // highlighted entry, Esc dismisses back to plain input. Single-line only:
3830
+ // a newline anywhere means free text (slash commands never span lines).
2265
3831
  const cur = inputRef.current;
2266
- const matches = !slashDismissedRef.current && cur.startsWith("/")
2267
- ? filterSlashCommands(cur)
2268
- : [];
3832
+ const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
3833
+ ? buildSlashMenu(cur, skillMenu)
3834
+ : { items: [], moreSkills: 0 };
3835
+ const matches = menu.items;
2269
3836
  if (matches.length > 0) {
2270
3837
  if (key.leftArrow) {
2271
3838
  setCursorBoth(cursorRef.current - 1);
@@ -2290,15 +3857,38 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2290
3857
  }
2291
3858
  else if (key.return || key.tab) {
2292
3859
  const pick = matches[slashIndexRef.current % matches.length];
2293
- // /compact is allowed while busy (sets the pending flag for turn-end
2294
- // drain); every other command still waits for idle.
2295
- if (pick && (pick.name === "/compact" || !busyRef.current)) {
2296
- if (pick.name === "/compact" && inputRef.current.startsWith("/compact ")) {
3860
+ // /compact, /queue, /steer, and /autoscroll run while busy (see
3861
+ // slashRunsWhileBusy); every other entry still waits idle.
3862
+ if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
3863
+ if (pick.skill) {
3864
+ // Skill entries stage for confirm (opencode-style): Enter/Tab
3865
+ // completes `/skill:name` into the input — nothing is sent.
3866
+ // A second Enter on the exact text runs it; a fully typed
3867
+ // `/skill:name` or legacy `/name` runs immediately (unambiguous).
3868
+ const staged = `/skill:${pick.skill}`;
3869
+ const typed = inputRef.current.trim();
3870
+ if (typed === staged || typed === `/${pick.skill}`) {
3871
+ setInputBoth("");
3872
+ void invokeSkillByName(pick.skill);
3873
+ }
3874
+ else {
3875
+ setInputBoth(staged);
3876
+ }
3877
+ }
3878
+ else if (pick.name === "/compact" && inputRef.current.startsWith("/compact ")) {
2297
3879
  // Preserve free-text focus when the menu is open on a prefix.
2298
3880
  const focus = inputRef.current.slice("/compact".length).trim();
2299
3881
  setInputBoth("");
2300
3882
  void runCompactCommand(focus);
2301
3883
  }
3884
+ else if (pick.name === "/autoscroll" &&
3885
+ (inputRef.current === "/autoscroll" || inputRef.current.startsWith("/autoscroll "))) {
3886
+ // Preserve the on/off arg when the menu is open on a prefix
3887
+ // (bare highlighted name alone would drop it).
3888
+ const raw = inputRef.current;
3889
+ setInputBoth("");
3890
+ runAutoScrollCommand(raw);
3891
+ }
2302
3892
  else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
2303
3893
  inputRef.current.startsWith(pick.name)) {
2304
3894
  // Preserve the typed rule args (e.g. "/allow bash:npm test*");
@@ -2333,32 +3923,208 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2333
3923
  cancelTurn();
2334
3924
  return;
2335
3925
  }
2336
- // 5. Plain input. Tab toggles normal<->yolo here; when the "/" slash
2337
- // menu is open (section 4 above) Tab instead runs the highlighted
2338
- // command and never reaches this branch.
3926
+ // 4c. Tool-output inspector (read-only: safe in any mode incl. plan).
3927
+ // Ctrl+O toggles when fully idle (no turn, modal, or picker open);
3928
+ // arrows/Enter/Esc drive it while open. Inspector keys never reach the
3929
+ // input, and input keys never reach the inspector.
3930
+ if (key.ctrl && (ch === "o" || ch === "O")) {
3931
+ if (!busyRef.current && !turnCancelRef.current &&
3932
+ !pendingApproval && !pendingQuestion &&
3933
+ !selecting && !selectingSkills && !selectingProvider &&
3934
+ !keyPrompt && !baseURLPrompt && !selectingEffort &&
3935
+ !selectingRewind && !selectingRewindScope) {
3936
+ if (inspectingRef.current)
3937
+ closeInspector();
3938
+ else
3939
+ openInspector();
3940
+ }
3941
+ return;
3942
+ }
3943
+ if (inspectingRef.current) {
3944
+ const last = Math.max(0, toolLogRef.current.length - 1);
3945
+ if (key.escape) {
3946
+ if (inspectExpandedRef.current) {
3947
+ setInspectExpandedBoth(false);
3948
+ setInspectScrollBoth(0);
3949
+ }
3950
+ else {
3951
+ closeInspector();
3952
+ }
3953
+ }
3954
+ else if (key.return) {
3955
+ setInspectExpandedBoth(!inspectExpandedRef.current);
3956
+ setInspectScrollBoth(0);
3957
+ }
3958
+ else if (key.upArrow) {
3959
+ if (inspectExpandedRef.current) {
3960
+ setInspectScrollBoth(Math.max(0, inspectScrollRef.current - 1));
3961
+ }
3962
+ else if (inspectIndexRef.current > 0) {
3963
+ setInspectIndexBoth(inspectIndexRef.current - 1);
3964
+ }
3965
+ }
3966
+ else if (key.downArrow) {
3967
+ if (inspectExpandedRef.current) {
3968
+ setInspectScrollBoth(inspectScrollRef.current + 1);
3969
+ }
3970
+ else if (inspectIndexRef.current < last) {
3971
+ setInspectIndexBoth(inspectIndexRef.current + 1);
3972
+ }
3973
+ }
3974
+ else if (key.pageUp) {
3975
+ if (inspectExpandedRef.current) {
3976
+ setInspectScrollBoth(Math.max(0, inspectScrollRef.current - VIEWPORT_LINES));
3977
+ }
3978
+ else {
3979
+ setInspectIndexBoth(Math.max(0, inspectIndexRef.current - 5));
3980
+ }
3981
+ }
3982
+ else if (key.pageDown) {
3983
+ if (inspectExpandedRef.current) {
3984
+ setInspectScrollBoth(inspectScrollRef.current + VIEWPORT_LINES);
3985
+ }
3986
+ else {
3987
+ setInspectIndexBoth(Math.min(last, inspectIndexRef.current + 5));
3988
+ }
3989
+ }
3990
+ return;
3991
+ }
3992
+ // 4d. Command palette (Ctrl+P toggles; Ctrl+K stays kill-to-end).
3993
+ // Opens over idle or busy turns alike (no modal/picker/inspector may be
3994
+ // open); Enter runs through the shared busy-gate, so only
3995
+ // compact/queue/steer fire while busy.
3996
+ if (key.ctrl && (ch === "p" || ch === "P")) {
3997
+ if (!pendingApproval && !pendingQuestion &&
3998
+ !selecting && !selectingSkills && !selectingProvider &&
3999
+ !keyPrompt && !baseURLPrompt && !selectingEffort &&
4000
+ !selectingRewind && !selectingRewindScope && !inspecting) {
4001
+ if (paletteOpenRef.current)
4002
+ closePalette();
4003
+ else
4004
+ openPalette();
4005
+ }
4006
+ return;
4007
+ }
4008
+ if (paletteOpenRef.current) {
4009
+ const entries = paletteEntries(paletteFilterRef.current);
4010
+ if (key.escape) {
4011
+ closePalette();
4012
+ }
4013
+ else if (key.return) {
4014
+ const pick = entries[paletteIndexRef.current];
4015
+ if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
4016
+ const name = pick.name;
4017
+ closePalette();
4018
+ runSlashCommand(name);
4019
+ }
4020
+ }
4021
+ else if (key.upArrow) {
4022
+ if (entries.length > 0) {
4023
+ setPaletteIndexBoth((paletteIndexRef.current - 1 + entries.length) % entries.length);
4024
+ }
4025
+ }
4026
+ else if (key.downArrow) {
4027
+ if (entries.length > 0) {
4028
+ setPaletteIndexBoth((paletteIndexRef.current + 1) % entries.length);
4029
+ }
4030
+ }
4031
+ else if (key.backspace) {
4032
+ setPaletteFilterBoth(paletteFilterRef.current.slice(0, -1));
4033
+ setPaletteIndexBoth(0);
4034
+ }
4035
+ else if (ch && !key.ctrl && !key.meta && !key.tab && ch !== "\n") {
4036
+ setPaletteFilterBoth(paletteFilterRef.current + ch);
4037
+ setPaletteIndexBoth(0);
4038
+ }
4039
+ return;
4040
+ }
4041
+ // 5. Plain input (multiline-aware). Tab toggles normal<->yolo here;
4042
+ // when the "/" slash menu is open (section 4 above) Tab instead runs the
4043
+ // highlighted command and never reaches this branch. Enter ALWAYS sends
4044
+ // (even multiline); Ctrl+J (ch "\n") inserts a newline. ↑/↓ move between
4045
+ // lines, falling through to history recall at the first/last line.
2339
4046
  if (key.leftArrow) {
2340
4047
  setCursorBoth(cursorRef.current - 1);
2341
4048
  }
2342
4049
  else if (key.rightArrow) {
2343
4050
  setCursorBoth(cursorRef.current + 1);
2344
4051
  }
2345
- else if (key.home) {
2346
- setCursorBoth(0);
4052
+ else if (key.pageUp) {
4053
+ // Transcript scrollback (idle and busy alike): PgUp holds the view —
4054
+ // the window freezes and the growing draft/thinking blocks collapse
4055
+ // to one static line, so the terminal stops yanking mid-turn and
4056
+ // scrollback stays readable. End (or PgDn at the bottom) follows
4057
+ // again; /clear, /resume, /new, and rewind-truncate re-follow too.
4058
+ setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "pageUp" }));
4059
+ }
4060
+ else if (key.pageDown) {
4061
+ setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "pageDown" }));
4062
+ }
4063
+ else if (key.home && inputRef.current.length === 0) {
4064
+ setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "home" }));
4065
+ }
4066
+ else if (key.end && inputRef.current.length === 0) {
4067
+ setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "end" }));
2347
4068
  }
2348
- else if (key.end) {
2349
- setCursorBoth(inputRef.current.length);
4069
+ else if (key.home || (key.ctrl && (ch === "a" || ch === "A"))) {
4070
+ const t = inputRef.current;
4071
+ const { line } = lineColOf(t, cursorRef.current);
4072
+ setCursorBoth(offsetOfLines(splitInputLines(t), line, 0));
4073
+ }
4074
+ else if (key.end || (key.ctrl && (ch === "e" || ch === "E"))) {
4075
+ const t = inputRef.current;
4076
+ const lines = splitInputLines(t);
4077
+ const { line } = lineColOf(t, cursorRef.current);
4078
+ setCursorBoth(offsetOfLines(lines, line, lines[line].length));
4079
+ }
4080
+ else if (key.upArrow) {
4081
+ moveOrRecall(-1);
4082
+ }
4083
+ else if (key.downArrow) {
4084
+ moveOrRecall(1);
4085
+ }
4086
+ else if (ch === "\n" || (key.ctrl && (ch === "j" || ch === "J"))) {
4087
+ insertAtCursor("\n");
4088
+ }
4089
+ else if (key.ctrl && (ch === "k" || ch === "K")) {
4090
+ const r = killToLineEnd(inputRef.current, cursorRef.current);
4091
+ exitHistoryBrowse();
4092
+ setInputAndCursor(r.text, r.offset);
4093
+ }
4094
+ else if (key.ctrl && (ch === "u" || ch === "U")) {
4095
+ const r = killToLineStart(inputRef.current, cursorRef.current);
4096
+ exitHistoryBrowse();
4097
+ setInputAndCursor(r.text, r.offset);
4098
+ }
4099
+ else if (key.ctrl && (ch === "w" || ch === "W")) {
4100
+ const r = killWordBefore(inputRef.current, cursorRef.current);
4101
+ exitHistoryBrowse();
4102
+ setInputAndCursor(r.text, r.offset);
2350
4103
  }
2351
4104
  else if (key.return) {
4105
+ pushInputHistory(inputRef.current);
2352
4106
  void submit(inputRef.current);
2353
4107
  }
2354
4108
  else if (key.tab) {
2355
- // Tab toggles normal<->yolo only (pinned by tests/status.test.tsx): it
2356
- // never enters or exits plan mode, so a stray keypress can't drop the
2357
- // deliberate safety mode use /plan. Silent no-op in plan (the status
2358
- // line already shows mode: plan).
2359
- if (modeRef.current !== "plan") {
2360
- const next = modeRef.current === "normal" ? "yolo" : "normal";
2361
- setModeBoth(next);
4109
+ // Tab is the only mode switcher: normalyolo plan normal.
4110
+ // normal→yolo stays silent (the status line shows it); plan
4111
+ // transitions announce, since entering arms read-only mode and
4112
+ // exiting approves the recorded todowrite plan into implementation
4113
+ // (always landing in normal, never yolo — the checklist survives).
4114
+ const cur = modeRef.current;
4115
+ if (cur === "normal") {
4116
+ setModeBoth("yolo");
4117
+ }
4118
+ else if (cur === "yolo") {
4119
+ setModeBoth("plan");
4120
+ pushInfo("plan mode: on — explore freely (read/grep/glob/web/todos/ask run free; write/edit/bash are blocked with a replan note). Record the plan with todowrite, then Tab to approve + exit into implementation.");
4121
+ }
4122
+ else {
4123
+ setModeBoth("normal");
4124
+ const planned = getTodos().length;
4125
+ pushInfo(planned > 0
4126
+ ? `(plan approved — ${planned} task(s) carry into implementation under normal permissions)`
4127
+ : "(plan mode off — no plan recorded)");
2362
4128
  }
2363
4129
  }
2364
4130
  else if (key.delete && !key.backspace) {
@@ -2374,23 +4140,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2374
4140
  insertAtCursor(ch);
2375
4141
  }
2376
4142
  });
4143
+ // Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
4144
+ // including newlines — inserts at the cursor verbatim and NEVER submits,
4145
+ // so multiline pastes can't fire mid-paste. Separate channel from
4146
+ // useInput above. Active exactly when plain input is focused (idle or
4147
+ // busy queue-draft; never inside a modal, picker, or the inspector).
4148
+ usePaste((text) => {
4149
+ insertAtCursor(normalizePaste(text));
4150
+ }, {
4151
+ isActive: !pendingApproval &&
4152
+ !pendingQuestion &&
4153
+ !selecting &&
4154
+ !selectingSkills &&
4155
+ !selectingProvider &&
4156
+ !keyPrompt &&
4157
+ !baseURLPrompt &&
4158
+ !selectingEffort &&
4159
+ !selectingRewind &&
4160
+ !selectingRewindScope &&
4161
+ !inspecting,
4162
+ });
2377
4163
  const phaseLabel = phase === "thinking" || phase === "idle"
2378
- ? "thinking…"
4164
+ ? `thinking${theme.symbol.ellipsis}`
2379
4165
  : phase === "streaming"
2380
- ? "streaming…"
4166
+ ? `streaming${theme.symbol.ellipsis}`
2381
4167
  : phase === "tool"
2382
4168
  ? phaseDetail
2383
- ? `calling ${phaseDetail}…`
2384
- : "tool…"
4169
+ ? `calling ${phaseDetail}${theme.symbol.ellipsis}`
4170
+ : `tool${theme.symbol.ellipsis}`
2385
4171
  : phase === "retry"
2386
4172
  ? phaseDetail
2387
- ? `retrying ${phaseDetail}`
2388
- : "retrying…"
4173
+ ? `retrying${theme.symbol.ellipsis} ${phaseDetail}`
4174
+ : `retrying${theme.symbol.ellipsis}`
2389
4175
  : phase === "done"
2390
4176
  ? "done"
2391
- : "thinking…";
2392
- // Slash menu derived for render (mirrors the useInput computation above).
2393
- const filteredSlash = !selecting &&
4177
+ : `thinking${theme.symbol.ellipsis}`;
4178
+ // Slash menu derived for render (mirrors the useInput computation above):
4179
+ // commands first, then matching skills as `/skill:name` entries.
4180
+ const slashMenu = !selecting &&
4181
+ !selectingSkills &&
2394
4182
  !selectingEffort &&
2395
4183
  !selectingProvider &&
2396
4184
  !keyPrompt &&
@@ -2400,15 +4188,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2400
4188
  !selectingRewind &&
2401
4189
  !selectingRewindScope &&
2402
4190
  !slashDismissed &&
2403
- input.startsWith("/")
2404
- ? filterSlashCommands(input)
2405
- : [];
4191
+ input.startsWith("/") &&
4192
+ !input.includes("\n")
4193
+ ? buildSlashMenu(input, skillMenu)
4194
+ : { items: [], moreSkills: 0 };
4195
+ const filteredSlash = slashMenu.items;
2406
4196
  const slashVisible = filteredSlash.length > 0;
2407
- const slashHighlight = filteredSlash.length > 0 ? filteredSlash[slashIndex % filteredSlash.length]?.name : undefined;
2408
- // Cursor block renders AT the cursor offset (defensive clamp: the ref is
2409
- // the source of truth mid-tick and always stays in range via setCursorBoth,
2410
- // but state may lag it by one render).
2411
- const safeCursor = Math.max(0, Math.min(cursor, input.length));
4197
+ const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
4198
+ const slashHighlight = filteredSlash.length > 0 ? filteredSlash[slashHi]?.name : undefined;
4199
+ const slashWin = pickerWindow(filteredSlash.length, slashHi);
4200
+ const slashHasSkills = filteredSlash.some((c) => c.skill !== undefined);
4201
+ // Argument hint for the highlighted command (reused usage strings only).
4202
+ const slashUsage = filteredSlash.length > 0 && filteredSlash[slashHi]?.skill === undefined
4203
+ ? commandUsage(filteredSlash[slashHi].name)
4204
+ : null;
4205
+ // Unified /model picker derived for render (mirrors the useInput
4206
+ // computation above): full entries, filtered entries, clamped highlight,
4207
+ // and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
4208
+ // rows no matter how many models providers list.
4209
+ const modelEntriesAll = selecting ? buildModelEntries() : [];
4210
+ const modelEntries = selecting ? filterModelEntries(modelEntriesAll, modelFilter) : [];
4211
+ const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
4212
+ const modelWin = pickerWindow(modelEntries.length, modelHi);
4213
+ const modelTitle = `Atom — Select model (${modelEntries.length}` +
4214
+ (modelFilter ? ` of ${modelEntriesAll.length}, filter: "${modelFilter}"` : "") +
4215
+ `) — type to filter, up/down + Enter, Esc cancels:`;
4216
+ // /skills picker derived for render (mirrors the useInput computation
4217
+ // above): names only, filtered, clamped highlight, visible window. The
4218
+ // title keeps the `Skills (` prefix the registry header always had.
4219
+ const skillEntriesAll = selectingSkills ? skillPickerItems : [];
4220
+ const skillEntries = selectingSkills ? filterSkillPicker(skillEntriesAll, skillFilter) : [];
4221
+ const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
4222
+ const skillWin = pickerWindow(skillEntries.length, skillHi);
4223
+ const skillTitle = `Skills (${skillEntries.length}` +
4224
+ (skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
4225
+ `) — type to filter, up/down + Enter, Esc cancels:`;
4226
+ // Memoized render derivations (flicker fix): these rebuild arrays on every
4227
+ // App render (token paints, keystrokes, 1s ticks), which defeats the memo
4228
+ // on the leaf panels below. Memoized, the leaves skip everything but real
4229
+ // changes. Checkpoint listing reads the snapshot dir — never per frame.
4230
+ const paletteEntriesMemo = useMemo(() => (paletteOpen ? paletteEntries(paletteFilter) : []), [paletteOpen, paletteFilter]);
4231
+ const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
4232
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4233
+ [selectingRewind]);
4234
+ // (The cursor clamp lives inside the memoized InputBox now, next to its
4235
+ // only use — App body no longer reads cursor state for paint.)
2412
4236
  // Status-line reasoning segment wired to the effort session state:
2413
4237
  // non-Default shows the effort (plus " (unsupported)" when the model is
2414
4238
  // outside the verified-support set OR the provider is not opencode-zen);
@@ -2421,8 +4245,44 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2421
4245
  ? effort
2422
4246
  : `${effort} (unsupported)`
2423
4247
  : (reasoning ?? "default");
2424
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [turns.length === 0 ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && turns.length === 0 ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, draft ? (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["ATOM>", " "] }), draft, _jsx(Text, { color: "gray", children: "\u258D" })] })) : null, thinking ? (_jsxs(Text, { dimColor: true, children: ["\uD83D\uDCAD ", thinking.length > THINKING_DISPLAY_CAP ? "…" + thinking.slice(-THINKING_DISPLAY_CAP) : thinking, _jsx(Text, { color: "gray", children: "\u258D" })] })) : null, busy && toolHint ? _jsxs(Text, { dimColor: true, children: ["\u25CC calling ", toolHint, "\u2026"] }) : null] }), error ? _jsxs(Text, { color: "red", children: ["error> ", error] }) : null, pendingApproval ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom permission \u2014 allow this tool?" }), _jsx(Text, { children: describeToolCall(pendingApproval.name, pendingApproval.args) }), _jsxs(Text, { children: ["[y]es once \u00B7 [a]lways allow ", pendingApproval.name, " this session \u00B7 [t]rust all write/edit/bash this session \u00B7 [n]o (Esc = no)"] })] })) : null, pendingQuestion ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Atom question \u2014 ", pendingQuestion.question] }), pendingQuestion.options.map((o, i) => (_jsxs(Text, { color: i === askSelIndex ? "magenta" : undefined, children: [i === askSelIndex ? "❯ " : " ", o] }, `${o}-${i}`))), pendingQuestion.allowCustom ? (_jsxs(Text, { dimColor: true, children: ["Type a custom answer + Enter to send it", askCustom ? `: ${askCustom}` : "", " \u00B7 \u2191/\u2193 + Enter picks \u00B7 Esc cancels"] })) : (_jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter to pick \u00B7 Esc cancels" }))] })) : null, _jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", marginTop: 1 }), _jsx(TodoPanel, { items: todoSnap }), selecting ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select model (up/down + Enter, Esc cancels):" }), models.map((m, i) => (_jsxs(Text, { color: i === selIndex ? "green" : undefined, children: [i === selIndex ? "❯ " : " ", m, m === model ? " (current)" : ""] }, `${m}-${i}`)))] })) : selectingProvider ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):" }), PROVIDERS.map((p, i) => {
2425
- const has = keyForProvider(p.id).length > 0;
2426
- return (_jsxs(Text, { color: i === providerIndex ? "green" : undefined, children: [i === providerIndex ? "❯ " : " ", p.name, " (", p.id, ") ", has ? "✓ key" : "— no key", p.id === provider ? " (current)" : ""] }, p.id));
2427
- })] })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, 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" })), _jsxs(Text, { children: ["key: ", "•".repeat(keyPrompt.draft.length), _jsx(Text, { color: "gray", children: "\u2588" })] }), keyPrompt.validating ? _jsx(Text, { dimColor: true, children: "validating\u2026" }) : null, keyPrompt.error ? _jsx(Text, { color: "red", children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, 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: "gray", children: "\u2588" })] }), baseURLPrompt.error ? _jsx(Text, { color: "red", children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):" }), EFFORT_OPTIONS.map((o, i) => (_jsxs(Text, { color: i === effortIndex ? "green" : undefined, children: [i === effortIndex ? "❯ " : " ", o === "default" ? "Default" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Top is Max (sent as max); xhigh is not a verified value." })] })) : selectingRewind ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):" }), listCheckpoints().map((c, i) => (_jsxs(Text, { color: i === rewindIndex ? "green" : undefined, children: [i === rewindIndex ? "❯ " : " ", "#", c.seq, " \u00B7 ", c.label, " \u00B7 ", 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(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):" }), REWIND_SCOPES.map((s, i) => (_jsxs(Text, { color: i === rewindScopeIndex ? "green" : undefined, children: [i === rewindScopeIndex ? "❯ " : " ", s] }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u203A", " "] }), _jsxs(Text, { children: [input.slice(0, safeCursor), _jsx(Text, { color: "gray", children: "\u2588" }), input.slice(safeCursor)] })] })), slashVisible ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom commands (\u2191/\u2193 + Enter/Tab to run, Esc dismisses):" }), filteredSlash.map((c) => (_jsxs(Text, { color: c.name === slashHighlight ? "cyan" : undefined, children: [c.name === slashHighlight ? "❯ " : " ", c.name, " \u2014 ", c.description] }, c.name)))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["provider: ", provider, " \u00B7 model: ", model, " \u00B7 ", formatTokens(usageTotals, model, contextLoad), " \u00B7 reasoning:", " ", reasoningDisplay, " \u00B7 mode: ", mode, trustAll && mode !== "plan" ? "+trust" : null, busy ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", phaseLabel, " ", elapsedSecs, "s \u00B7 esc stops"] }) : null, busy && stalled ? " · waiting…" : null] }) })] }));
4248
+ // Display-only live tool elapsed: wall-clock now turn start + elapsed
4249
+ // ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
4250
+ // tool is running the running line then paints with no duration.
4251
+ const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
4252
+ ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
4253
+ : null;
4254
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, 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: describeToolCall(pendingApproval.name, pendingApproval.args), selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
4255
+ const i = modelWin.start + k;
4256
+ const entryLocal = e.local === true;
4257
+ const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
4258
+ const showGroup = i === 0 || prevLocal !== entryLocal;
4259
+ const showHeader = showGroup || modelEntries[i - 1]?.providerId !== e.providerId;
4260
+ const def = getProvider(e.providerId);
4261
+ return (_jsxs(React.Fragment, { children: [showGroup ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", entryLocal ? "Local" : "Remote"] })) : null, showHeader ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", def?.name ?? e.providerId, entryLocal && isLocalProviderId(e.providerId)
4262
+ ? ` ${theme.symbol.separator} ${localBaseURLFor(e.providerId)}`
4263
+ : null, e.providerId === provider ? " (current)" : ""] })) : null, _jsxs(PickerRow, { highlighted: i === modelHi, children: [e.model, e.free === true ? _jsx(Text, { dimColor: true, children: " (free)" }) : null, e.providerId === provider && e.model === model ? " (current)" : ""] })] }, `${e.providerId}-${e.model}-${i}`));
4264
+ }), _jsx(PickerMoreBelow, { count: modelEntries.length - modelWin.end }), modelEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models match \u2014 backspace to widen the filter." })) : null] })) : selectingSkills ? (_jsxs(PickerShell, { title: skillTitle, children: [_jsx(PickerMoreAbove, { count: skillWin.start }), skillEntries.slice(skillWin.start, skillWin.end).map((e, k) => {
4265
+ const i = skillWin.start + k;
4266
+ return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
4267
+ }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
4268
+ ? "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] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
4270
+ const has = keyForProvider(p.id).length > 0;
4271
+ const keyMark = isLocalProviderId(p.id)
4272
+ ? `local ${theme.symbol.descSeparator} no key needed`
4273
+ : has
4274
+ ? `${theme.symbol.keyPresent} key`
4275
+ : p.id === "kilo"
4276
+ ? `${theme.symbol.descSeparator} key optional — free models need none`
4277
+ : `${theme.symbol.descSeparator} no key`;
4278
+ 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 === "default" ? "Default" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Top is Max (sent as max); xhigh is not a verified value." })] })) : 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
+ // The input is the one boxed, prominent surface (see the memoized
4281
+ // InputBox above): a quiet gray frame sets it apart from the
4282
+ // transcript above and the status line below. Pickers and modals
4283
+ // replace it (never stack with it), each carrying their own semantic
4284
+ // border color.
4285
+ _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
4286
+ ? `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(StatusBar, { 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, columns: termColumns })] }));
2428
4288
  }