atom-agent 1.0.0 → 1.2.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 (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
package/dist/App.js CHANGED
@@ -1,20 +1,22 @@
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 * as fs from "node:fs";
4
5
  import * as os from "node:os";
5
- import React, { useEffect, useRef, useState } from "react";
6
- import { Box, Text, useApp, useInput, usePaste, useStdout } from "ink";
6
+ import * as path from "node:path";
7
+ import React, { useEffect, useMemo, useRef, useState } from "react";
8
+ import { Box, Text, useApp, useInput, usePaste } from "ink";
7
9
  import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, openTodoNeedles, runAgenticLoopForProvider, } from "./zen.js";
8
10
  import { createContextManager, trackHistory, } from "./context-manager.js";
9
11
  import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
10
- import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, providerSecrets } from "./tools.js";
12
+ import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
11
13
  import { classifyTurnOutcome, createTelemetryRecorder, loadTelemetrySessions, resolveTelemetryEnabled, summarizeTelemetry, telemetryDir, } from "./telemetry.js";
12
14
  import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
13
15
  import { formatRules, parseRuleInput, } from "./permissions.js";
14
16
  import { decidePolicy, skillGrantsFor } from "./policy.js";
15
17
  import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
16
18
  import { contextWindowFor } from "./context-windows.js";
17
- import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
19
+ import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFiles, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
18
20
  import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
19
21
  import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
20
22
  import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
@@ -22,20 +24,23 @@ import { validateProviderKey } from "./adapters.js";
22
24
  import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
23
25
  import { getGitInfo, withEnvBlock } from "./env-block.js";
24
26
  import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
27
+ import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
25
28
  import { loadAtomConfig } from "./config.js";
26
29
  import { cancelledTurnLine } from "./rollback.js";
27
30
  import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
28
31
  import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
29
32
  import { InputBox } from "./ui/input.js";
30
33
  import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
31
- import { LiveTail } from "./ui/live-tail.js";
34
+ import { LiveTailHost } from "./ui/live-host.js";
32
35
  import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
33
36
  import { activityText } from "./ui/activity.js";
34
37
  import { ApprovalBox, QuestionBox } from "./ui/modals.js";
35
38
  import { PalettePanel } from "./ui/palette.js";
36
39
  import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
37
40
  import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
38
- import { StatusBar, shortenCwd } from "./ui/status-bar.js";
41
+ import { shortenCwd } from "./ui/status-bar.js";
42
+ import { StatusBarHost } from "./ui/status-host.js";
43
+ import { createStreamStore } from "./ui/stream-store.js";
39
44
  import { theme } from "./ui/theme.js";
40
45
  import { TodoPanel } from "./ui/todo-panel.js";
41
46
  import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
@@ -48,7 +53,7 @@ export const SLASH_COMMANDS = [
48
53
  name: "/effort",
49
54
  description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
50
55
  },
51
- { name: "/tools", description: "List the 7 tools with one-line descriptions." },
56
+ { name: "/tools", description: "List the tools with one-line descriptions." },
52
57
  { name: "/skills", description: "List installed skills (project + global)." },
53
58
  { name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
54
59
  { name: "/mode", description: "Print the current permission mode (Tab cycles normal → yolo → plan)." },
@@ -58,11 +63,15 @@ export const SLASH_COMMANDS = [
58
63
  { name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
59
64
  { name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
60
65
  { name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
66
+ { name: "/rename", description: "Rename the current session (/rename <name>)." },
61
67
  { name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
62
68
  { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
63
69
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
64
70
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
71
+ { name: "/autoscroll", description: "Toggle following new output (off by default; bare toggles, on|off sets it; off freezes the view mid-turn)." },
72
+ { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
65
73
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
74
+ { name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
66
75
  { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
67
76
  { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
68
77
  { name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
@@ -106,8 +115,36 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
106
115
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
107
116
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
108
117
  export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
118
+ export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — off (default) freezes the view while a turn runs (a `↓ N new` indicator offers the jump back); on follows new output as it arrives. Bare /autoscroll toggles between the two.";
119
+ export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: the live block and future rounds show or hide; already-printed blocks stay as printed; the turn, history, and telemetry are untouched).";
120
+ export const RENAME_USAGE = 'usage: /rename <name> — rename the current session (e.g. /rename Build authentication; quotes optional: /rename "name with spaces"). Bare /rename prints this usage.';
121
+ // Pure arg parser for /rename (unit-tested): strips the command, trims,
122
+ // then strips one layer of matching outer quotes (single or double) so
123
+ // quoted names work even though the command line has no real parser.
124
+ // Unquoted multi-word names work as-is (everything after /rename is the
125
+ // name). Empty/whitespace-only input yields "" (caller prints usage).
126
+ export function parseRenameArg(raw) {
127
+ const text = raw.trim();
128
+ const arg = text === "/rename" ? "" : text.slice("/rename".length).trim();
129
+ if (arg.length >= 2) {
130
+ const first = arg[0];
131
+ const last = arg[arg.length - 1];
132
+ if ((first === '"' || first === "'") && last === first) {
133
+ return arg.slice(1, -1).trim();
134
+ }
135
+ }
136
+ return arg;
137
+ }
109
138
  export function filterSlashCommands(prefix) {
110
139
  const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
140
+ // Exact match wins outright: a fully-typed command collapses the menu
141
+ // to itself, so prefix-siblings (/skill vs /skills, /model vs /models)
142
+ // never read as duplicates and Enter stays deterministic. Partial
143
+ // input keeps the prefix-then-fuzzy tiers below untouched.
144
+ const full = `/${q}`;
145
+ const exact = SLASH_COMMANDS.find((c) => c.name === full);
146
+ if (exact)
147
+ return [exact];
111
148
  const pre = [];
112
149
  const fuzzy = [];
113
150
  for (const c of SLASH_COMMANDS) {
@@ -163,10 +200,18 @@ export function paletteEntries(query) {
163
200
  }));
164
201
  }
165
202
  // Busy-gate shared by the slash menu and the palette: /compact sets the
166
- // pending flag for turn-end drain; /queue + /steer manage the running turn.
203
+ // pending flag for turn-end drain; /queue + /steer manage the running turn;
204
+ // /autoscroll and /thinking only flip view flags (never touch the turn);
205
+ // /rename only renames the store record + title state (the later turn-end
206
+ // persist preserves the title, so it never races the turn).
167
207
  // Every other command waits idle.
168
208
  export function slashRunsWhileBusy(name) {
169
- return name === "/compact" || name === "/queue" || name === "/steer";
209
+ return (name === "/compact" ||
210
+ name === "/queue" ||
211
+ name === "/steer" ||
212
+ name === "/autoscroll" ||
213
+ name === "/thinking" ||
214
+ name === "/rename");
170
215
  }
171
216
  // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
172
217
  // better; null = no match). Pure — shared by the command filter, the skill
@@ -205,6 +250,15 @@ export function filterSkillPicker(entries, query) {
205
250
  return entries;
206
251
  return entries.filter((e) => e.name.toLowerCase().includes(q));
207
252
  }
253
+ export function sameSkillMenuSnapshot(a, b) {
254
+ if (a.length !== b.length)
255
+ return false;
256
+ for (let i = 0; i < a.length; i++) {
257
+ if (a[i].name !== b[i].name || a[i].description !== b[i].description)
258
+ return false;
259
+ }
260
+ return true;
261
+ }
208
262
  // Pure menu builder (unit-tested): matching commands first (prefix tier in
209
263
  // stable order, then fuzzy by score), then matching skills as `/skill:name`
210
264
  // entries (prefix tier stable, then fuzzy). Skills join only once the query
@@ -273,6 +327,8 @@ export function commandUsage(name) {
273
327
  return SKILL_USAGE;
274
328
  case "/compact":
275
329
  return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
330
+ case "/rename":
331
+ return RENAME_USAGE;
276
332
  default:
277
333
  return null;
278
334
  }
@@ -292,6 +348,41 @@ export function modelsCacheKey(providerId, baseURL) {
292
348
  }
293
349
  return providerId;
294
350
  }
351
+ export function filterSessionEntries(entries, query) {
352
+ const q = query.trim().toLowerCase();
353
+ if (!q)
354
+ return entries;
355
+ const scored = [];
356
+ entries.forEach((e, idx) => {
357
+ const titleScore = fuzzyScore(q, e.title);
358
+ const idScore = fuzzyScore(q, e.id);
359
+ let best = titleScore;
360
+ if (idScore !== null && (best === null || idScore + 5 < best)) {
361
+ best = idScore + 5;
362
+ }
363
+ if (best !== null)
364
+ scored.push({ e, score: best, idx });
365
+ });
366
+ scored.sort((a, b) => a.score - b.score || a.idx - b.idx);
367
+ return scored.map((s) => s.e);
368
+ }
369
+ // Relative age for the picker secondary line (unit-tested). Invalid dates
370
+ // say "unknown" (never throw, never invent).
371
+ export function formatSessionAge(nowMs, updatedAt) {
372
+ const t = Date.parse(updatedAt);
373
+ if (!Number.isFinite(t))
374
+ return "unknown";
375
+ const secs = Math.max(0, Math.floor((nowMs - t) / 1000));
376
+ if (secs < 60)
377
+ return "just now";
378
+ const mins = Math.floor(secs / 60);
379
+ if (mins < 60)
380
+ return `${mins}m ago`;
381
+ const hours = Math.floor(mins / 60);
382
+ if (hours < 24)
383
+ return `${hours}h ago`;
384
+ return `${Math.floor(hours / 24)}d ago`;
385
+ }
295
386
  // Pure helpers for the elapsed/stall indicator (injectable now for tests).
296
387
  export function elapsedSecsSince(startMs, nowMs) {
297
388
  return Math.max(0, Math.floor((nowMs - startMs) / 1000));
@@ -487,17 +578,13 @@ export function MissingKey() {
487
578
  // Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
488
579
  // size is a constant (avoids re-serializing ~15KB on every manager build).
489
580
  const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
581
+ // Render-count probe for propagation audits: incremented on every App body
582
+ // execution (hostile-perf suite asserts token paints and ticks stay out of
583
+ // here — only real state transitions may run the orchestrator).
584
+ export const appRenderProbe = { count: 0 };
490
585
  export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
586
+ appRenderProbe.count += 1;
491
587
  const { exit } = useApp();
492
- // Measured terminal width for the status bar's fit-or-drop branch logic.
493
- // Unknown (piped output) falls back to the bar's own default.
494
- let termColumns;
495
- try {
496
- termColumns = useStdout()?.stdout?.columns;
497
- }
498
- catch {
499
- termColumns = undefined;
500
- }
501
588
  // Saved preferences (provider/model/effort + resolved key/endpoint), loaded
502
589
  // once when restorePrefs is on (prod). Explicit props always win; without
503
590
  // prefs the CLI defaults apply. Null in tests (flag off) and on any
@@ -670,6 +757,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
670
757
  skillFilterRef.current = next;
671
758
  setSkillFilter(next);
672
759
  }
760
+ // /session picker (interactive switcher): snapshot state loaded ONCE per
761
+ // open (listSessions reads each record a single time), filtered in memory
762
+ // per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
763
+ // untouched. Same keyboard/window pattern as the /skills picker.
764
+ const [selectingSession, setSelectingSession] = useState(false);
765
+ const [sessionItems, setSessionItems] = useState([]);
766
+ const [sessionIndex, setSessionIndex] = useState(0);
767
+ const sessionIndexRef = useRef(0);
768
+ const [sessionFilter, setSessionFilter] = useState("");
769
+ const sessionFilterRef = useRef("");
770
+ function setSessionIndexBoth(next) {
771
+ sessionIndexRef.current = next;
772
+ setSessionIndex(next);
773
+ }
774
+ function setSessionFilterBoth(next) {
775
+ sessionFilterRef.current = next;
776
+ setSessionFilter(next);
777
+ }
673
778
  // Reasoning-effort picker (/effort): same pattern as the /model picker
674
779
  // (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
675
780
  // else the atom.json default, else Default.
@@ -816,6 +921,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
816
921
  scrollEndRef.current = next;
817
922
  setScrollEnd(next);
818
923
  }
924
+ // Thinking visibility (the /thinking toggle, rendering-only, default
925
+ // hidden): committed thinking turns + the live thinking block show only
926
+ // while on. Never touches the turn, history, or telemetry — purely paint.
927
+ const [showThinking, setShowThinking] = useState(false);
928
+ const showThinkingRef = useRef(false);
929
+ function setShowThinkingBoth(next) {
930
+ showThinkingRef.current = next;
931
+ setShowThinking(next);
932
+ }
933
+ // /autoscroll (session-only, default off). Off freezes the view at the
934
+ // first busy append (the `↓ N new` indicator offers the jump back); on
935
+ // follows new output as it arrives. Bare /autoscroll toggles.
936
+ const [autoScroll, setAutoScroll] = useState(false);
937
+ const autoScrollRef = useRef(false);
938
+ function setAutoScrollBoth(next) {
939
+ autoScrollRef.current = next;
940
+ setAutoScroll(next);
941
+ }
819
942
  // Skill registry (cached metadata): one instance per App, scoped to the
820
943
  // same dirs the suite injects via skillDirs. Every discovery path below
821
944
  // reads through it — refresh() revalidates by stat (mtime+size) and only
@@ -832,14 +955,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
832
955
  try {
833
956
  const found = await skillRegistry.refresh();
834
957
  const { skills } = resolveSkills(found.skills);
835
- setSkillMenu(skills
958
+ const next = skills
836
959
  .filter((s) => s.userInvocable)
837
- .map((s) => ({ name: s.name, description: s.description })));
960
+ .map((s) => ({ name: s.name, description: s.description }));
961
+ // Install only on change: the mount refresh routinely rediscovers the
962
+ // identical set mid-turn, and a fresh array identity would schedule a
963
+ // full App render for zero new information.
964
+ setSkillMenu((prev) => (sameSkillMenuSnapshot(prev, next) ? prev : next));
838
965
  }
839
966
  catch {
840
967
  // menu keeps its previous snapshot (a hiccup must never break input)
841
968
  }
842
969
  }
970
+ // Pending transcript diff (display-only): the approve-time write/edit
971
+ // preview plus full-file BEFORE/AFTER capture, held for the matching
972
+ // onToolActivity commit. Single slot is exact — the scheduler never
973
+ // parallel-batches writes (writes conflict globally; parallel members
974
+ // never prompt), and every execution commits exactly one activity entry
975
+ // in call order. Lifetime ⊆ one turn: set in approve(),
976
+ // consumed-or-cleared by the matching activity, and cleared on
977
+ // deny/cancel/turn boundaries so a stale preview can never attach to
978
+ // a later call.
979
+ // - write: BEFORE reuses the preview's pre-read; AFTER is the new
980
+ // content arg (exactly what the tool writes) — zero extra reads.
981
+ // - edit: BEFORE is a best-effort full-file read here (pre-execution);
982
+ // AFTER is read at commit time. Two reads, each once, never in render.
983
+ const pendingDiffRef = useRef(null);
984
+ // Best-effort full-file read for diff capture: null on missing dir,
985
+ // oversize, or any I/O failure. Never throws — capture degrades to the
986
+ // arg-block preview pair instead of breaking approval.
987
+ function readFileForDiff(absPath) {
988
+ try {
989
+ const st = fs.statSync(absPath);
990
+ if (!st.isFile() || st.size > APPROVAL_PREVIEW_MAX_BYTES)
991
+ return null;
992
+ return fs.readFileSync(absPath, "utf8");
993
+ }
994
+ catch {
995
+ return null;
996
+ }
997
+ }
843
998
  // Tool approval prompt (normal mode, write/edit/bash): the loop waits on
844
999
  // the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
845
1000
  // (LoopCancelledError) instead of denying one call.
@@ -930,24 +1085,91 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
930
1085
  // as a dim line while the transcript is empty; the conversation itself
931
1086
  // never auto-restores (only provider/model/effort do, via restorePrefs).
932
1087
  const [sessionHint] = useState(() => sessionExists(authHome));
1088
+ // Active session display title (rename feedback + failure messages).
1089
+ // Initialized from the store record when one already exists (sync disk
1090
+ // read, no write — creation stays in the mount effect); thereafter the
1091
+ // store is the source of truth and every sync point below refreshes it.
1092
+ // Deliberately NOT in the status bar: the sole info bar has a fixed width
1093
+ // budget and a ~30-char title wraps `mode: X` onto its own line. Session
1094
+ // identity surfaces instead in the /session picker rows, the rename
1095
+ // confirmation, and the switch/new notices.
1096
+ const [sessionTitle, setSessionTitle] = useState(() => {
1097
+ try {
1098
+ return getActiveSession(authHome)?.title ?? "";
1099
+ }
1100
+ catch {
1101
+ return "";
1102
+ }
1103
+ });
1104
+ const sessionTitleRef = useRef(sessionTitle);
933
1105
  // Reasoning label from response metadata (via onReasoning). The status
934
1106
  // line shows the session effort when non-Default (plus " (unsupported)"
935
1107
  // when the model is outside the verified-support set); when effort is
936
1108
  // Default it shows this label, falling back to `default`.
937
1109
  const [reasoning, setReasoning] = useState(null);
938
- // Live streaming state: `draft` is the growing assistant text (onToken),
939
- // `phase`/`phaseDetail` track the observe→act→inspect→adjust loop
940
- // (thinking|streaming|tool|retry|done), and `toolHint` shows a streamed
941
- // tool name before its execution line lands.
942
- const [draft, setDraft] = useState(null);
1110
+ // Live streaming state: the growing assistant text (onToken) and the
1111
+ // thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
1112
+ // useState. Token paints (up to ~15/sec) notify only the subscribed
1113
+ // LiveTailHost App's body and every other leaf skip them entirely.
1114
+ // `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
1115
+ // few times per turn (low frequency, and StatusBar legitimately needs them).
1116
+ const [streamStore] = useState(() => createStreamStore());
943
1117
  // Thinking channel (onThinking): reasoning text streamed apart from the
944
- // answer, rendered in its own dim block below. Transient like `draft`
945
- // cleared on every turn boundary below — and never committed to the
946
- // transcript or the model history.
947
- const [thinking, setThinking] = useState(null);
1118
+ // answer, rendered in its own dim block below. The live value is transient
1119
+ // like the draft — cleared on every turn boundary below — but each completed
1120
+ // round commits to the transcript via commitThinking (stays in the TUI,
1121
+ // never the model history) instead of being replaced and lost.
1122
+ const thinkingRef = useRef(null);
1123
+ // Move the accumulated round thinking into the transcript as a quiet
1124
+ // annotation turn (no-op when empty). Called when a new POST starts and at
1125
+ // turn end, so every round's reasoning stays visible; the /thinking toggle
1126
+ // only controls rendering, never this record.
1127
+ function commitThinking() {
1128
+ const text = thinkingRef.current;
1129
+ thinkingRef.current = null;
1130
+ try {
1131
+ // Drop any trailing paint: the commit carries the full text, and a
1132
+ // late flush must never resurrect stale reasoning after the clear.
1133
+ thinkingThrottleRef.current?.cancel();
1134
+ }
1135
+ catch {
1136
+ // ignore (the store clear below still wins)
1137
+ }
1138
+ streamStore.setThinking(null);
1139
+ if (typeof text === "string" && text.length > 0) {
1140
+ appendTurns({ role: "assistant", content: text, thinking: true });
1141
+ }
1142
+ }
1143
+ function clearThinking() {
1144
+ thinkingRef.current = null;
1145
+ try {
1146
+ thinkingThrottleRef.current?.cancel();
1147
+ }
1148
+ catch {
1149
+ // ignore (the store clear below still wins)
1150
+ }
1151
+ streamStore.setThinking(null);
1152
+ }
948
1153
  const [phase, setPhase] = useState("idle");
949
1154
  const [phaseDetail, setPhaseDetail] = useState("");
950
1155
  const [toolHint, setToolHint] = useState(null);
1156
+ // Synchronous mirrors for the hot loop callbacks: zen fires
1157
+ // onPhase("streaming") on EVERY content chunk, so the handler must not
1158
+ // issue same-value setStates per token (React re-invokes the component
1159
+ // before bailing out — 15 App-body executions/sec for nothing).
1160
+ // setPhaseBoth is the only writer; equal values skip setState entirely.
1161
+ const phaseRef = useRef("idle");
1162
+ const phaseDetailRef = useRef("");
1163
+ function setPhaseBoth(next, detail) {
1164
+ if (phaseRef.current !== next) {
1165
+ phaseRef.current = next;
1166
+ setPhase(next);
1167
+ }
1168
+ if (phaseDetailRef.current !== detail) {
1169
+ phaseDetailRef.current = detail;
1170
+ setPhaseDetail(detail);
1171
+ }
1172
+ }
951
1173
  // Task B smoothness (a): throttled streaming draft. onToken pushes every
952
1174
  // partial (activity/stall tracking stays per-token); paints coalesce to one
953
1175
  // per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
@@ -960,7 +1182,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
960
1182
  setTimeoutFn,
961
1183
  clearTimeoutFn,
962
1184
  onFlush: (text) => {
963
- setDraft(text);
1185
+ // Paint path only: the commit carries the byte-exact full text.
1186
+ // Writing the store notifies LiveTailHost alone — never App.
1187
+ streamStore.setDraft(text);
964
1188
  },
965
1189
  });
966
1190
  draftThrottleRef.current = th;
@@ -975,6 +1199,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
975
1199
  // ignore (draft stays as-is; the commit carries the full text)
976
1200
  }
977
1201
  }
1202
+ // Thinking paint coalescing: reasoning chunks arrive at token rate but
1203
+ // paint through the same trailing window into the store (producer/consumer
1204
+ // symmetry with the draft). thinkingRef stays synchronous per chunk so
1205
+ // commitThinking can never lose reasoning to a pending trailing paint.
1206
+ const thinkingThrottleRef = useRef(null);
1207
+ function thinkingThrottler() {
1208
+ let th = thinkingThrottleRef.current;
1209
+ if (!th) {
1210
+ th = createDraftThrottler({
1211
+ now,
1212
+ setTimeoutFn,
1213
+ clearTimeoutFn,
1214
+ onFlush: (text) => {
1215
+ streamStore.setThinking(text);
1216
+ },
1217
+ });
1218
+ thinkingThrottleRef.current = th;
1219
+ }
1220
+ return th;
1221
+ }
978
1222
  // Phase 5: models-list session cache (successful live lists only, keyed
979
1223
  // by modelsCacheKey). Failures fall back uncached, exactly as before.
980
1224
  const modelsCacheRef = useRef(new Map());
@@ -1050,6 +1294,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1050
1294
  // on the next activity.
1051
1295
  const [elapsedSecs, setElapsedSecs] = useState(0);
1052
1296
  const [stalled, setStalled] = useState(false);
1297
+ // Mirror for the per-token stall reset (same same-value-setState hazard
1298
+ // as phase above — noteTurnActivity runs on every chunk).
1299
+ const stalledRef = useRef(false);
1300
+ function setStalledBoth(next) {
1301
+ if (stalledRef.current !== next) {
1302
+ stalledRef.current = next;
1303
+ setStalled(next);
1304
+ }
1305
+ }
1053
1306
  const turnStartRef = useRef(0);
1054
1307
  const lastActivityRef = useRef(0);
1055
1308
  const turnTimerRef = useRef(null);
@@ -1175,7 +1428,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1175
1428
  catch {
1176
1429
  // ignore clock errors (stall hint just won't trigger)
1177
1430
  }
1178
- setStalled(false);
1431
+ // Guarded: runs on every token/thinking/phase event, but only a
1432
+ // true→false transition may schedule a render.
1433
+ setStalledBoth(false);
1179
1434
  }
1180
1435
  function startTurnTimer() {
1181
1436
  clearTurnTimer();
@@ -1189,7 +1444,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1189
1444
  turnStartRef.current = start;
1190
1445
  lastActivityRef.current = start;
1191
1446
  setElapsedSecs(0);
1192
- setStalled(false);
1447
+ setStalledBoth(false);
1193
1448
  try {
1194
1449
  turnTimerRef.current = (setIntervalFn ?? setInterval)(() => {
1195
1450
  let t;
@@ -1201,7 +1456,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1201
1456
  }
1202
1457
  setElapsedSecs(elapsedSecsSince(turnStartRef.current, t));
1203
1458
  if (isStalledSince(lastActivityRef.current, t)) {
1204
- setStalled(true);
1459
+ // Guarded: the stalled flag flips false→true once per silence
1460
+ // window, not on every tick within it.
1461
+ if (!stalledRef.current)
1462
+ setStalledBoth(true);
1205
1463
  }
1206
1464
  }, TURN_TICK_MS);
1207
1465
  }
@@ -1244,6 +1502,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1244
1502
  }
1245
1503
  try {
1246
1504
  draftThrottleRef.current?.cancel();
1505
+ thinkingThrottleRef.current?.cancel();
1247
1506
  }
1248
1507
  catch {
1249
1508
  // ignore
@@ -1414,6 +1673,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1414
1673
  setModelFilterBoth("");
1415
1674
  setSelectingSkills(false);
1416
1675
  setSkillFilterBoth("");
1676
+ setSelectingSession(false);
1677
+ setSessionFilterBoth("");
1417
1678
  setSelectingEffort(false);
1418
1679
  setSelectingProvider(false);
1419
1680
  setKeyPromptBoth(null);
@@ -1446,6 +1707,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1446
1707
  pushInfo("(skill discovery failed — no skills listed)");
1447
1708
  });
1448
1709
  }
1710
+ // /session picker open: snapshot the store list ONCE (most-recent-first
1711
+ // from listSessions) with the active record marked, then filter in memory
1712
+ // per keystroke (no disk reads while typing). Idle-only (callers gate on
1713
+ // busy like every picker — a mid-turn switch would race the loop's own
1714
+ // history writes). Empty store degrades to a notice (unreachable while
1715
+ // the mount bootstrap holds, but never a blank popup).
1716
+ function openSessionPicker(initialFilter) {
1717
+ setInputBoth("");
1718
+ closeAllPickers();
1719
+ let records;
1720
+ try {
1721
+ records = listSessions(authHome);
1722
+ }
1723
+ catch {
1724
+ pushInfo("(could not list sessions — store unreadable)");
1725
+ return;
1726
+ }
1727
+ if (records.length === 0) {
1728
+ pushInfo("(no sessions yet — your current conversation is saved automatically)");
1729
+ return;
1730
+ }
1731
+ let activeId = null;
1732
+ try {
1733
+ activeId = getActiveSessionId(authHome);
1734
+ }
1735
+ catch {
1736
+ activeId = null;
1737
+ }
1738
+ setSessionItems(records.map((s) => ({
1739
+ id: s.id,
1740
+ title: s.title,
1741
+ updatedAt: s.updatedAt,
1742
+ createdAt: s.createdAt,
1743
+ turnCount: s.turns.length,
1744
+ active: s.id === activeId,
1745
+ })));
1746
+ setSessionFilterBoth(initialFilter);
1747
+ setSessionIndexBoth(0);
1748
+ setSelectingSession(true);
1749
+ }
1449
1750
  // Unified /model entries for this render: active provider's current list
1450
1751
  // first, then every other keyed provider's cached-or-fallback list (pure,
1451
1752
  // local-only — see modelPickerEntries). Called from the /model open path,
@@ -1552,6 +1853,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1552
1853
  setTurns(next);
1553
1854
  }
1554
1855
  function appendTurns(...items) {
1856
+ // Autoscroll off + busy + following: freeze the view at its current end
1857
+ // BEFORE appending, so streaming output accumulates below instead of
1858
+ // yanking the viewport (smooth-scroll hold). Idle appends and already-
1859
+ // held views pass through untouched.
1860
+ if (!autoScrollRef.current && busyRef.current && scrollEndRef.current === null) {
1861
+ setScrollEndBoth(turnsRef.current.length);
1862
+ }
1555
1863
  const next = [...turnsRef.current, ...items];
1556
1864
  turnsRef.current = next;
1557
1865
  setTurns(next);
@@ -1560,6 +1868,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1560
1868
  usageRef.current = next;
1561
1869
  setUsageTotals(next);
1562
1870
  }
1871
+ function setSessionTitleBoth(next) {
1872
+ sessionTitleRef.current = next;
1873
+ setSessionTitle(next);
1874
+ }
1563
1875
  function setContextLoadBoth(next) {
1564
1876
  contextLoadRef.current = next;
1565
1877
  setContextLoad(next);
@@ -1729,7 +2041,97 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1729
2041
  // Snapshot the committed session (historyRef + turnsRef + settings refs)
1730
2042
  // to ~/.atom/session.json. Disk errors are ignored (in-memory session
1731
2043
  // still applies). Called only for committed state: completed turns and
1732
- // clean exit — never for rolled-back (failed/cancelled) turns.
2044
+ // clean exit — never for rolled-back (failed/cancelled) turns. The
2045
+ // committed diff previews (Turn.diff) are display-only and never saved:
2046
+ // they can hold whole file contents (bloat) and would render stale
2047
+ // after later edits, so /resume restores label-only turns.
2048
+ //
2049
+ // Multi-session mirror (src/sessions.ts): every conversation auto-belongs
2050
+ // to a durable session record (~/.atom/sessions/<id>.json + active
2051
+ // pointer). persistSession() also mirrors the same committed snapshot
2052
+ // into the active record via persistStoreSession() below, so completed
2053
+ // turns, clean exits, and successful compactions bump updatedAt there too.
2054
+ // Failures/cancels never reach here (same rollback rule as legacy).
2055
+ // Active id lives in a ref only — never a session list in runtime state.
2056
+ const activeSessionIdRef = useRef(null);
2057
+ function storeCwd() {
2058
+ try {
2059
+ return process.cwd();
2060
+ }
2061
+ catch {
2062
+ return "";
2063
+ }
2064
+ }
2065
+ // Ensure exactly one active persistent session exists (fresh mount creates
2066
+ // one with the current provider/model/effort/mode + cwd). Never throws,
2067
+ // never blocks render — disk errors leave the ref null and the next
2068
+ // persist retries.
2069
+ function ensureStoreSession() {
2070
+ try {
2071
+ const existing = activeSessionIdRef.current;
2072
+ // The record may vanish under a running process (external delete,
2073
+ // corrupted file): a stale cached id must re-ensure instead of
2074
+ // persisting into the void (every later turn would silently skip).
2075
+ if (existing) {
2076
+ try {
2077
+ if (getSession(existing, authHome))
2078
+ return existing;
2079
+ }
2080
+ catch {
2081
+ // fall through to re-ensure below
2082
+ }
2083
+ }
2084
+ const s = ensureActiveSession({
2085
+ cwd: storeCwd(),
2086
+ provider: providerRef.current,
2087
+ model: modelRef.current,
2088
+ effort: effortRef.current,
2089
+ mode: modeRef.current,
2090
+ }, authHome);
2091
+ activeSessionIdRef.current = s.id;
2092
+ // The store owns the title (a /rename from an earlier mount must show
2093
+ // after restart); sync the display state on every ensure.
2094
+ setSessionTitleBoth(s.title);
2095
+ return s.id;
2096
+ }
2097
+ catch {
2098
+ return null;
2099
+ }
2100
+ }
2101
+ // Mirror the committed snapshot into the active multi-session record
2102
+ // (single updateSession so updatedAt bumps on every committed turn).
2103
+ // Disk errors ignored, like the legacy save above.
2104
+ function persistStoreSession() {
2105
+ try {
2106
+ const id = ensureStoreSession();
2107
+ if (!id)
2108
+ return;
2109
+ updateSession(id, {
2110
+ history: historyRef.current,
2111
+ turns: turnsRef.current.map((t) => {
2112
+ const { diff: _dropped, ...rest } = t;
2113
+ return rest;
2114
+ }),
2115
+ usageTotals: usageRef.current,
2116
+ provider: providerRef.current,
2117
+ model: modelRef.current,
2118
+ effort: effortRef.current,
2119
+ mode: modeRef.current,
2120
+ cwd: storeCwd(),
2121
+ }, authHome);
2122
+ }
2123
+ catch {
2124
+ // ignore disk errors (in-memory session still applies)
2125
+ }
2126
+ }
2127
+ // Mount bootstrap: claim/create the active session before any turn can
2128
+ // persist, so every normal conversation belongs to a durable session.
2129
+ // Startup never auto-restores conversation state (fresh + legacy hint,
2130
+ // matching current UX) — this only ensures the record exists.
2131
+ useEffect(() => {
2132
+ ensureStoreSession();
2133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2134
+ }, []);
1733
2135
  function persistSession() {
1734
2136
  try {
1735
2137
  saveSession({
@@ -1739,12 +2141,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1739
2141
  mode: modeRef.current,
1740
2142
  usageTotals: usageRef.current,
1741
2143
  history: historyRef.current,
1742
- turns: turnsRef.current,
2144
+ turns: turnsRef.current.map((t) => {
2145
+ const { diff: _dropped, ...rest } = t;
2146
+ return rest;
2147
+ }),
1743
2148
  }, authHome);
1744
2149
  }
1745
2150
  catch {
1746
2151
  // ignore disk errors (in-memory session still applies)
1747
2152
  }
2153
+ persistStoreSession();
1748
2154
  }
1749
2155
  // Local observability persistence: flush the current telemetry session
1750
2156
  // file (atomic, best-effort). Called on turn boundaries and session events —
@@ -1876,8 +2282,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1876
2282
  telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
1877
2283
  },
1878
2284
  });
1879
- // Atomic swap: build the new history first, then replace.
1880
- const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
2285
+ // Atomic swap: build the new history first, then replace. The head's
2286
+ // touched files (collected from the committed tool_calls the loop
2287
+ // already recorded — no new tracking) ride inside the summary within
2288
+ // budget, so resumed sessions know what was touched; over-budget lists
2289
+ // shrink instead of failing compaction.
2290
+ const fitted = fitSummaryWithFiles(summary, collectTouchedFiles(split.head));
2291
+ const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
1881
2292
  // Replacement: re-wrap so the ledger restarts from the compacted array
1882
2293
  // (the old ledger is discarded with the old array).
1883
2294
  historyRef.current = trackHistory(next);
@@ -2013,6 +2424,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2013
2424
  contextManager().trimForSend(historyRef.current, (msg) => {
2014
2425
  pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2015
2426
  }, undefined, openTodoNeedles());
2427
+ // Surface the touched-file lists stored in compacted summaries, verbatim
2428
+ // in the stored format — a resumed session knows what was touched
2429
+ // without re-exploring the tree.
2430
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
2431
+ pendingNotices.push({ role: "tool", content: section });
2432
+ }
2016
2433
  // Remount the turns <Static> (same mechanism as /clear and /new): Ink's
2017
2434
  // Static only renders newly appended indices, so restoring a transcript
2018
2435
  // over a non-empty rendered buffer (e.g. the /new boundary line) would
@@ -2030,6 +2447,141 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2030
2447
  ]);
2031
2448
  telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
2032
2449
  persistTelemetry();
2450
+ // Mirror the restored legacy state into the active multi-session record
2451
+ // (create+activate when none exists). Startup itself never auto-restores
2452
+ // the store — only this explicit /resume does.
2453
+ persistStoreSession();
2454
+ }
2455
+ // /session switch: make the picked record the live conversation. Exactly
2456
+ // one history replacement (never merge, never duplicate): the target's
2457
+ // history/turns REPLACE the live arrays wholesale, following the doResume
2458
+ // precedent (endpoint recompute, env-block refresh, budget trim, lineage
2459
+ // drop, Static remount, title sync). Differences from /resume:
2460
+ // - the outgoing live state snapshots into the OLD record first, but only
2461
+ // when it holds turns (a fresh mount's system-only live state is NOT the
2462
+ // old record's content — persisting it would wipe that record);
2463
+ // - re-picking the current session is a no-op (reloading from disk would
2464
+ // drop unpersisted live turns);
2465
+ // - updatedAt is NOT bumped (switching is navigation, not a mutation —
2466
+ // listings keep true recency order);
2467
+ // - a missing/unreadable target errors WITHOUT touching the live session.
2468
+ // The legacy session.json follows the switch (same persistSession path as
2469
+ // every completed turn) so /resume stays coherent with the live view.
2470
+ function switchToSession(id) {
2471
+ let target = null;
2472
+ try {
2473
+ target = getSession(id, authHome);
2474
+ }
2475
+ catch {
2476
+ target = null;
2477
+ }
2478
+ if (!target) {
2479
+ pushInfo("(session no longer available — staying on the current session)");
2480
+ return;
2481
+ }
2482
+ const currentId = activeSessionIdRef.current;
2483
+ if (currentId !== null && currentId === target.id) {
2484
+ pushInfo(`(already on "${target.title}")`);
2485
+ return;
2486
+ }
2487
+ // Snapshot the outgoing conversation into its own record first (same
2488
+ // rule as /new's pre-reset save). Guarded: only when the live state
2489
+ // actually holds turns of the outgoing record.
2490
+ if (currentId !== null && currentId !== target.id && turnsRef.current.length > 0) {
2491
+ persistStoreSession();
2492
+ }
2493
+ try {
2494
+ setActiveSession(target.id, authHome);
2495
+ }
2496
+ catch {
2497
+ // setActiveSession never throws by contract; defensive only.
2498
+ }
2499
+ activeSessionIdRef.current = target.id;
2500
+ setProviderBoth(target.provider);
2501
+ const baseURL = chatBaseURL(target.provider);
2502
+ if (target.provider === "openai-compatible") {
2503
+ setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
2504
+ }
2505
+ else if (target.provider === "opencode-zen") {
2506
+ setActiveEndpoint(endpoint);
2507
+ }
2508
+ else {
2509
+ setActiveEndpoint(chatEndpointFor(target.provider, baseURL));
2510
+ }
2511
+ setModelBoth(target.model);
2512
+ setEffortBoth(target.effort);
2513
+ setModeBoth(target.mode);
2514
+ setUsageBoth(target.usageTotals);
2515
+ // Replacement: the target's arrays replace the live ones wholesale (a
2516
+ // fresh-created record carries empty history — fall back to a fresh
2517
+ // system line so the system-first invariant always holds).
2518
+ historyRef.current =
2519
+ target.history.length > 0
2520
+ ? trackHistory([...target.history])
2521
+ : trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]);
2522
+ if (target.history.length > 0) {
2523
+ refreshSystemEnv();
2524
+ }
2525
+ lastPromptTokensRef.current = undefined;
2526
+ if (!usageRef.current) {
2527
+ setContextLoadBoth(null);
2528
+ }
2529
+ else {
2530
+ setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
2531
+ }
2532
+ autoStreakRef.current = 0;
2533
+ setAutoDisabledBoth(false);
2534
+ pendingCompactRef.current = null;
2535
+ const pendingNotices = [];
2536
+ // New lineage (see src/rollback.ts): checkpoint marks index the old
2537
+ // history — drop them, loudly when non-empty. Disk files untouched.
2538
+ const switchDrops = clearSnapshots();
2539
+ if (switchDrops > 0) {
2540
+ pendingNotices.push({
2541
+ role: "tool",
2542
+ content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
2543
+ });
2544
+ }
2545
+ // TODO isolation (mirrors /new): the checklist is process-global memory
2546
+ // that is never persisted — carrying it across sessions would show the
2547
+ // new session the old session's plan, and the agent would act on it.
2548
+ // Reset it, loudly when non-empty.
2549
+ if (getTodos().length > 0) {
2550
+ clearTodos();
2551
+ setTodoSnap([]);
2552
+ pendingNotices.push({
2553
+ role: "tool",
2554
+ content: "(/session — checklist reset; TODOs are per-conversation and do not cross sessions)",
2555
+ });
2556
+ }
2557
+ else {
2558
+ clearTodos();
2559
+ setTodoSnap([]);
2560
+ }
2561
+ contextManager().trimForSend(historyRef.current, (msg) => {
2562
+ pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2563
+ }, undefined, openTodoNeedles());
2564
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
2565
+ pendingNotices.push({ role: "tool", content: section });
2566
+ }
2567
+ // Same Static remount as /clear, /resume, and /new: the replaced list
2568
+ // must not reuse the old buffer.
2569
+ setScrollEndBoth(null);
2570
+ setClearGen((g) => g + 1);
2571
+ setTurnsBoth([
2572
+ ...target.turns,
2573
+ {
2574
+ role: "tool",
2575
+ content: `(switched to session "${target.title}" — ${target.turns.length} turns)`,
2576
+ },
2577
+ ...pendingNotices,
2578
+ ]);
2579
+ setSessionTitleBoth(target.title);
2580
+ telemetry.recordEvent("info", `session switched to ${target.id} (${target.turns.length} turns)`);
2581
+ persistTelemetry();
2582
+ // Legacy single-file save follows the switch so /resume restores what
2583
+ // the live view shows (same path/format as every completed turn).
2584
+ persistSession();
2033
2585
  }
2034
2586
  // /rewind conversation scope (ticket 01): truncate history + transcript to
2035
2587
  // the checkpoint's turn. The cut drops the whole containing turn (submit's
@@ -2209,6 +2761,93 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2209
2761
  }
2210
2762
  pushInfo(QUEUE_USAGE);
2211
2763
  }
2764
+ // /thinking: rendering-only visibility toggle for model thinking (both
2765
+ // the committed transcript blocks and the live thinking block). Pure
2766
+ // paint — safe while busy (never touches the turn, like /autoscroll).
2767
+ // Bare toggles; anything appended prints usage (there are no arguments).
2768
+ function runThinkingCommand(raw) {
2769
+ if (raw.trim() !== "/thinking") {
2770
+ pushInfo(THINKING_USAGE);
2771
+ return;
2772
+ }
2773
+ const next = !showThinkingRef.current;
2774
+ setShowThinkingBoth(next);
2775
+ pushInfo(next
2776
+ ? "(thinking shown — model reasoning stays visible in the transcript)"
2777
+ : "(thinking hidden — reasoning still runs, it just isn't rendered)");
2778
+ }
2779
+ // /rename for the CURRENT session only (never creates one: renameSession
2780
+ // touches exactly the active record). Bare /rename prints usage — ATOM has
2781
+ // no generic text-prompt overlay (key/baseURL prompts are
2782
+ // provider-specific), so an interactive flow would be a new UI system.
2783
+ // Empty/whitespace-only names are rejected safely (previous name kept).
2784
+ // On failure the previous name is preserved (title state only changes on
2785
+ // success); history/turns are never part of the write.
2786
+ function runRenameCommand(raw) {
2787
+ const name = parseRenameArg(raw);
2788
+ if (!name) {
2789
+ pushInfo(RENAME_USAGE);
2790
+ return;
2791
+ }
2792
+ let id = null;
2793
+ try {
2794
+ id = ensureStoreSession();
2795
+ }
2796
+ catch {
2797
+ id = null;
2798
+ }
2799
+ if (!id) {
2800
+ pushInfo("(rename failed — session store unavailable; name unchanged)");
2801
+ return;
2802
+ }
2803
+ let renamed = null;
2804
+ try {
2805
+ renamed = renameSession(id, name, authHome);
2806
+ }
2807
+ catch {
2808
+ renamed = null;
2809
+ }
2810
+ if (!renamed) {
2811
+ const current = sessionTitleRef.current || "untitled";
2812
+ pushInfo(`(rename failed — still "${current}")`);
2813
+ return;
2814
+ }
2815
+ setSessionTitleBoth(renamed.title);
2816
+ pushInfo(`(renamed session to "${renamed.title}")`);
2817
+ }
2818
+ // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
2819
+ // only state — safe while busy (never touches the turn, like /queue).
2820
+ // Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
2821
+ // view at its current end (mid-turn appends then accumulate below).
2822
+ function runAutoScrollCommand(raw) {
2823
+ const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
2824
+ // Bare command toggles; explicit on|off sets directly.
2825
+ const effective = arg === "" ? (autoScrollRef.current ? "off" : "on") : arg;
2826
+ if (effective === "on") {
2827
+ if (autoScrollRef.current) {
2828
+ pushInfo("(autoscroll already on)");
2829
+ return;
2830
+ }
2831
+ setAutoScrollBoth(true);
2832
+ setScrollEndBoth(null);
2833
+ pushInfo("(autoscroll on — following the latest)");
2834
+ return;
2835
+ }
2836
+ if (effective === "off") {
2837
+ if (!autoScrollRef.current) {
2838
+ pushInfo("(autoscroll already off)");
2839
+ return;
2840
+ }
2841
+ // Confirm FIRST while still following (visible), then flip the switch:
2842
+ // flipping first would freeze this very confirm below the viewport
2843
+ // (appendTurns freezes busy appends once off). Already-held views stay
2844
+ // held; the next busy append freezes a following view via appendTurns.
2845
+ pushInfo("(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2846
+ setAutoScrollBoth(false);
2847
+ return;
2848
+ }
2849
+ pushInfo(AUTOSCROLL_USAGE);
2850
+ }
2212
2851
  // /models: local-discovery status + refresh. Bare `/models` reports the
2213
2852
  // last snapshot (kicking a first probe when discovery never ran);
2214
2853
  // `/models refresh` re-probes all three runtimes, then reports. Results
@@ -2264,6 +2903,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2264
2903
  case "/clear":
2265
2904
  // Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
2266
2905
  // fresh env block. Fresh array → fresh ledger via trackHistory.
2906
+ // Store mirror: LAZY, matching legacy — /clear wipes the live
2907
+ // transcript only and writes nothing to the store here; the cleared
2908
+ // (empty) conversation persists on the next completed turn via
2909
+ // persistSession()/persistStoreSession().
2267
2910
  historyRef.current = trackHistory([
2268
2911
  { role: "system", content: withEnvBlock(systemPrompt) },
2269
2912
  ]);
@@ -2274,11 +2917,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2274
2917
  setScrollEndBoth(null);
2275
2918
  setClearGen((g) => g + 1);
2276
2919
  setError(null);
2277
- setDraft(null);
2278
- setThinking(null);
2920
+ streamStore.setDraft(null);
2921
+ clearThinking();
2279
2922
  setToolHint(null);
2280
- setPhase("idle");
2281
- setPhaseDetail("");
2923
+ setPhaseBoth("idle", "");
2282
2924
  // /clear drops the transcript: load resets (no context), streak
2283
2925
  // resets, pending compact drains, and turn-scoped skill grants go
2284
2926
  // with it (no invisible auto-approvals survive a wiped transcript).
@@ -2309,7 +2951,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2309
2951
  // restores — same path/format as every completed turn, no new
2310
2952
  // schema). No sessions/ archive step: session.ts only has
2311
2953
  // session.json, so no archiving is invented here.
2954
+ // persistSession() also mirrors the pre-/new conversation into the
2955
+ // OLD active store record before the reset below.
2312
2956
  persistSession();
2957
+ // Fresh store record for the new conversation (settings carried over,
2958
+ // empty history/turns/usage; default title = formatSessionTitle(now)
2959
+ // via createSession). Explicit setActiveSession: createSession only
2960
+ // claims the pointer when none is set.
2961
+ try {
2962
+ const created = createSession({
2963
+ cwd: storeCwd(),
2964
+ provider: providerRef.current,
2965
+ model: modelRef.current,
2966
+ effort: effortRef.current,
2967
+ mode: modeRef.current,
2968
+ }, authHome);
2969
+ setActiveSession(created.id, authHome);
2970
+ activeSessionIdRef.current = created.id;
2971
+ setSessionTitleBoth(created.title);
2972
+ }
2973
+ catch {
2974
+ // ignore disk errors (in-memory reset below still applies)
2975
+ }
2313
2976
  // Fresh system re-read (system.ts base + current AGENTS.md overlay)
2314
2977
  // plus a fresh Task 6 env block. Fresh array → fresh ledger.
2315
2978
  historyRef.current = trackHistory([
@@ -2325,11 +2988,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2325
2988
  setScrollEndBoth(null);
2326
2989
  setClearGen((g) => g + 1);
2327
2990
  setError(null);
2328
- setDraft(null);
2329
- setThinking(null);
2991
+ streamStore.setDraft(null);
2992
+ clearThinking();
2330
2993
  setToolHint(null);
2331
- setPhase("idle");
2332
- setPhaseDetail("");
2994
+ setPhaseBoth("idle", "");
2333
2995
  // /new-vs-/clear split: /clear wipes the transcript but KEEPS usage
2334
2996
  // totals; /new resets the counters too (fresh conversation). Session
2335
2997
  // SETTINGS (effort/mode/provider/model) are kept — only the
@@ -2412,6 +3074,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2412
3074
  case "/steer":
2413
3075
  runQueueCommand("/steer");
2414
3076
  return;
3077
+ case "/thinking":
3078
+ runThinkingCommand("/thinking");
3079
+ return;
3080
+ case "/autoscroll":
3081
+ runAutoScrollCommand("/autoscroll");
3082
+ return;
2415
3083
  case "/mode":
2416
3084
  if (modeRef.current === "plan") {
2417
3085
  pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
@@ -2456,6 +3124,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2456
3124
  case "/resume":
2457
3125
  doResume();
2458
3126
  return;
3127
+ case "/session":
3128
+ openSessionPicker("");
3129
+ return;
3130
+ case "/rename":
3131
+ // Bare exact match (slash-menu Enter on the highlighted name):
3132
+ // usage — the typed-args form is preserved by the menu branch and
3133
+ // the submit prefix route above.
3134
+ runRenameCommand("/rename");
3135
+ return;
2459
3136
  case "/telemetry":
2460
3137
  pushInfo(telemetrySummaryText());
2461
3138
  return;
@@ -2503,6 +3180,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2503
3180
  async function approve(name, args) {
2504
3181
  if (turnCancelRef.current?.signal.aborted)
2505
3182
  throw new LoopCancelledError();
3183
+ // Stage the transcript-diff preview for write/edit (all outcomes):
3184
+ // the modal below reuses it, and onToolActivity consumes it when the
3185
+ // matching execution commits. Deny/cancel paths clear it (execution
3186
+ // never happens, so nothing must linger for a later call). Full-file
3187
+ // BEFORE is captured here (pre-execution); AFTER resolves at commit
3188
+ // (write content arg, or a post-execution disk read for edit).
3189
+ const stagedDiff = name === "write" || name === "edit" ? previewDiffForApproval(name, args) : null;
3190
+ if (name === "write" || name === "edit") {
3191
+ const toolPath = typeof args["path"] === "string" ? args["path"] : null;
3192
+ const beforeFull = name === "write"
3193
+ ? (stagedDiff?.oldText ?? null) // preview already pre-read it: no second read
3194
+ : toolPath !== null
3195
+ ? readFileForDiff(path.resolve(process.cwd(), toolPath))
3196
+ : null;
3197
+ const afterArg = name === "write" && typeof args["content"] === "string"
3198
+ ? args["content"]
3199
+ : null;
3200
+ pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
3201
+ }
2506
3202
  // Policy layer owns the decision order (deny → plan → allow → yolo →
2507
3203
  // trust → always → skill grants → prompt); this function owns cancel
2508
3204
  // handling and the interactive prompt plumbing around it.
@@ -2514,22 +3210,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2514
3210
  skillGrants: skillGrantsRef.current,
2515
3211
  approvalGated: needsApproval(name),
2516
3212
  });
2517
- if (outcome.kind === "deny")
3213
+ if (outcome.kind === "deny") {
3214
+ pendingDiffRef.current = null;
2518
3215
  return "no";
3216
+ }
2519
3217
  if (outcome.kind === "allow")
2520
3218
  return "once";
2521
3219
  const signal = turnCancelRef.current?.signal ?? null;
2522
- if (signal?.aborted)
3220
+ if (signal?.aborted) {
3221
+ pendingDiffRef.current = null;
2523
3222
  throw new LoopCancelledError();
3223
+ }
2524
3224
  return new Promise((resolve, reject) => {
2525
3225
  approvalResolveRef.current = { resolve, reject };
2526
3226
  setApproveIndexBoth(0);
2527
- setPendingApproval({ name, args });
3227
+ setPendingApproval({ name, args, diff: stagedDiff });
2528
3228
  if (signal) {
2529
3229
  const onAbort = () => {
2530
3230
  const h = approvalResolveRef.current;
2531
3231
  approvalResolveRef.current = null;
2532
3232
  setPendingApproval(null);
3233
+ pendingDiffRef.current = null;
2533
3234
  h?.reject(new LoopCancelledError());
2534
3235
  };
2535
3236
  if (signal.aborted)
@@ -2543,6 +3244,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2543
3244
  if (decision === "always" && pendingApproval) {
2544
3245
  alwaysAllowedRef.current.add(pendingApproval.name);
2545
3246
  }
3247
+ if (decision === "no")
3248
+ pendingDiffRef.current = null;
2546
3249
  const h = approvalResolveRef.current;
2547
3250
  approvalResolveRef.current = null;
2548
3251
  setPendingApproval(null);
@@ -2636,6 +3339,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2636
3339
  await runCompactCommand(focus);
2637
3340
  return;
2638
3341
  }
3342
+ // /rename with optional name: prefix match ("/rename" or "/rename ...").
3343
+ // Instant local store op — safe while busy (the turn-end persist never
3344
+ // carries a title, so it cannot clobber the rename).
3345
+ if (text === "/rename" || text.startsWith("/rename ")) {
3346
+ runRenameCommand(text);
3347
+ return;
3348
+ }
2639
3349
  // SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
2640
3350
  // nothing). Busy guard + API-key check: rejections return before any
2641
3351
  // history mutation, so there is nothing to roll back.
@@ -2651,6 +3361,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2651
3361
  runQueueCommand(text);
2652
3362
  return;
2653
3363
  }
3364
+ // /autoscroll and /thinking are view-only state (never touch the
3365
+ // turn), so they run while busy like /queue + /steer (see
3366
+ // slashRunsWhileBusy).
3367
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
3368
+ runAutoScrollCommand(text);
3369
+ return;
3370
+ }
3371
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
3372
+ runThinkingCommand(text);
3373
+ return;
3374
+ }
2654
3375
  if (text.startsWith("/"))
2655
3376
  return;
2656
3377
  if (queueRef.current.length >= QUEUE_CAP) {
@@ -2667,6 +3388,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2667
3388
  runQueueCommand(text);
2668
3389
  return;
2669
3390
  }
3391
+ // /autoscroll takes an optional subcommand (/autoscroll on|off), like the
3392
+ // /queue family — SLASH_NAMES only holds the exact command. /thinking
3393
+ // is bare-toggle-only; anything appended prints its usage.
3394
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
3395
+ runAutoScrollCommand(text);
3396
+ return;
3397
+ }
3398
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
3399
+ runThinkingCommand(text);
3400
+ return;
3401
+ }
2670
3402
  // Scoped rules (ticket 03): exact or free-text forms (/allow bash:x,
2671
3403
  // /rules clear) route with args intact — SLASH_NAMES only holds exact
2672
3404
  // commands, and the skill fallback below must not swallow these.
@@ -2683,6 +3415,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2683
3415
  void runModelsCommand(arg);
2684
3416
  return;
2685
3417
  }
3418
+ // /session takes an optional initial filter ("/session auth" opens the
3419
+ // picker pre-filtered) — SLASH_NAMES only holds the exact command.
3420
+ if (text === "/session" || text.startsWith("/session ")) {
3421
+ const initial = text === "/session" ? "" : text.slice("/session".length).trim();
3422
+ openSessionPicker(initial);
3423
+ return;
3424
+ }
2686
3425
  // Exact full-command + Enter runs it. A single-token "/name" not in
2687
3426
  // SLASH_NAMES resolves through the skill registry (ticket 03, legacy
2688
3427
  // form — the namespaced `/skill:name` below is canonical); anything
@@ -2730,8 +3469,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2730
3469
  busyRef.current = true;
2731
3470
  setBusy(true);
2732
3471
  setError(null);
2733
- setDraft(null);
2734
- setThinking(null);
3472
+ streamStore.setDraft(null);
3473
+ clearThinking();
2735
3474
  // Fresh turn, fresh latch: the queue drain at the end auto-sends only
2736
3475
  // when this turn was NOT cancelled (see the turn-end finally).
2737
3476
  turnCancelledRef.current = false;
@@ -2740,19 +3479,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2740
3479
  // Expiry happens in the turn-end finally below, plus /clear + /new.
2741
3480
  try {
2742
3481
  draftThrottler().reset();
3482
+ thinkingThrottler().reset();
2743
3483
  }
2744
3484
  catch {
2745
3485
  // ignore (first token still paints; at worst one window late)
2746
3486
  }
2747
3487
  setToolHint(null);
2748
- setPhase("thinking");
2749
- setPhaseDetail("");
3488
+ setPhaseBoth("thinking", "");
2750
3489
  // Phase 5: start the elapsed/stall timer (status-bar only, never the
2751
3490
  // transcript). Cleared in finally below and on unmount.
2752
3491
  startTurnTimer();
2753
3492
  // Display-only tool clock: no tool is running at turn start, so any
2754
3493
  // stale timestamp from a previous turn must not leak into this one.
2755
3494
  toolStartRef.current = null;
3495
+ // Same for the transcript-diff slot: a previous turn's unconsumed
3496
+ // preview (cancelled mid-execution) must never attach to this turn.
3497
+ pendingDiffRef.current = null;
2756
3498
  lastPartialRef.current = "";
2757
3499
  refreshGitInfo();
2758
3500
  // SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
@@ -2833,6 +3575,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2833
3575
  // Local observability sink: the loop reports completed model/tool
2834
3576
  // calls (iterations, durations, usage) into the open turn trace.
2835
3577
  telemetry: telemetrySink,
3578
+ // Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
3579
+ // bottleneck, context growth) attach to the same open turn trace.
3580
+ // Fires once per turn — including failed/cancelled turns, whose
3581
+ // endTurn below still records the outcome alongside these stats.
3582
+ onLoopStats: (s) => telemetry.recordLoopStats(telemetryTurnId, s),
2836
3583
  // Plan-mode read-only gate (ticket 04): mutations are refused here
2837
3584
  // with a replan note; every other tool delegates to executeTool.
2838
3585
  execute: guardedExecute,
@@ -2844,22 +3591,33 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2844
3591
  draftThrottler().push(partial);
2845
3592
  }
2846
3593
  catch {
2847
- setDraft(partial);
3594
+ // Never lose tokens: paint now rather than drop the partial.
3595
+ streamStore.setDraft(partial);
2848
3596
  }
2849
3597
  lastPartialRef.current = partial;
2850
3598
  noteTurnActivity();
2851
3599
  },
2852
3600
  onThinking: (partial) => {
2853
- setThinking(partial);
3601
+ thinkingRef.current = partial;
3602
+ try {
3603
+ thinkingThrottler().push(partial);
3604
+ }
3605
+ catch {
3606
+ // Never lose reasoning: paint now rather than drop the partial.
3607
+ streamStore.setThinking(partial);
3608
+ }
2854
3609
  noteTurnActivity();
2855
3610
  },
2856
3611
  onPhase: (p, detail) => {
2857
- setPhase(p);
2858
- setPhaseDetail(detail ?? "");
3612
+ // Change-guarded (zen emits "streaming" per content chunk):
3613
+ // steady-state tokens issue zero setStates here.
3614
+ setPhaseBoth(p, detail ?? "");
2859
3615
  noteTurnActivity();
2860
3616
  if (p === "thinking") {
2861
- // New POST: its thinking (if any) replaces the previous round's.
2862
- setThinking(null);
3617
+ // New POST: the previous round's thinking (if any) commits to
3618
+ // the transcript so it stays in the TUI instead of being
3619
+ // replaced and lost; the fresh round streams into the live block.
3620
+ commitThinking();
2863
3621
  }
2864
3622
  else if (p === "tool" && detail) {
2865
3623
  setToolHint(detail);
@@ -2963,6 +3721,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2963
3721
  else if (isTodo) {
2964
3722
  items.push({ role: "tool", content: result });
2965
3723
  }
3724
+ // Committed transcript diff: the approve-time capture for this
3725
+ // exact execution rides on the label turn. Consume-or-clear on
3726
+ // every matching activity (success or failure) so a stale
3727
+ // capture can never leak onto a later call; render only on
3728
+ // success with a real payload (failures keep the ↳ line only).
3729
+ // Full-file BEFORE→AFTER is preferred (aligned panes with
3730
+ // context); when either side is unavailable (unreadable file,
3731
+ // oversize), fall back to the arg-block preview pair.
3732
+ const slot = pendingDiffRef.current;
3733
+ if (slot !== null &&
3734
+ (label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `))) {
3735
+ pendingDiffRef.current = null;
3736
+ if (!isError) {
3737
+ let afterFull = null;
3738
+ if (slot.name === "write") {
3739
+ afterFull = slot.afterArg;
3740
+ }
3741
+ else if (slot.path !== null) {
3742
+ afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
3743
+ }
3744
+ const beforeFull = slot.beforeFull;
3745
+ if (beforeFull !== null && afterFull !== null) {
3746
+ items[0].diff = {
3747
+ oldText: beforeFull,
3748
+ newText: afterFull,
3749
+ lang: slot.diff?.lang ?? null,
3750
+ path: slot.path,
3751
+ };
3752
+ }
3753
+ else if (slot.diff !== null) {
3754
+ items[0].diff = slot.diff;
3755
+ }
3756
+ }
3757
+ }
2966
3758
  appendTurns(...items);
2967
3759
  noteTurnActivity();
2968
3760
  },
@@ -2975,8 +3767,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2975
3767
  },
2976
3768
  });
2977
3769
  // Turn-end flush: any trailing throttled partial paints before the
2978
- // commit replaces the draft (byte-exact via `reply` regardless).
3770
+ // commit replaces the draft (byte-exact via `reply` regardless). The
3771
+ // final round's thinking commits first (chronological: reasoning, then
3772
+ // the answer it produced).
2979
3773
  flushDraft();
3774
+ commitThinking();
2980
3775
  appendTurns({ role: "assistant", content: reply });
2981
3776
  // The turn committed to history (final text, denial-as-result, or
2982
3777
  // stop-notice) — persist the kill-safe save. Rolled-back turns (catch
@@ -3015,6 +3810,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3015
3810
  controller.signal.aborted;
3016
3811
  historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
3017
3812
  turnCancelledRef.current = cancelled;
3813
+ // The turn never happened: drop live thinking with it (a failed turn
3814
+ // commits nothing — same scope as the history rollback above).
3815
+ clearThinking();
3018
3816
  // Local observability: failed/cancelled turns still record what was
3019
3817
  // attempted (model/tool calls so far) with their outcome, then flush.
3020
3818
  // Like the save above, the telemetry file only ever gains completed
@@ -3066,6 +3864,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3066
3864
  turnCancelRef.current = null;
3067
3865
  approvalResolveRef.current = null;
3068
3866
  setPendingApproval(null);
3867
+ // Safety net: the slot is normally consumed by onToolActivity or
3868
+ // cleared on deny/cancel — never let it cross a turn boundary.
3869
+ pendingDiffRef.current = null;
3069
3870
  askResolveRef.current = null;
3070
3871
  setPendingQuestion(null);
3071
3872
  setAskCustomBoth("");
@@ -3082,14 +3883,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3082
3883
  catch {
3083
3884
  // ignore
3084
3885
  }
3085
- setDraft(null);
3086
- setThinking(null);
3886
+ streamStore.setDraft(null);
3887
+ clearThinking();
3087
3888
  setToolHint(null);
3088
3889
  clearTurnTimer();
3089
- setStalled(false);
3890
+ setStalledBoth(false);
3090
3891
  setElapsedSecs(0);
3091
- setPhase("idle");
3092
- setPhaseDetail("");
3892
+ setPhaseBoth("idle", "");
3093
3893
  // Queue drain (Claude-Code-style): a clean turn auto-sends the next
3094
3894
  // queued follow-up (chaining while the queue is non-empty); a cancelled
3095
3895
  // turn keeps its queue visible but never auto-sends. A steer stranded
@@ -3493,6 +4293,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3493
4293
  }
3494
4294
  return;
3495
4295
  }
4296
+ // 3a2. Session picker (interactive switcher, same pattern as /skills:
4297
+ // type to filter, ↑/↓ + Enter switches, Esc cancels with the live
4298
+ // session completely unchanged). The list is the open-time snapshot —
4299
+ // filtering never touches disk. Enter on an empty filtered list only
4300
+ // prints a widen hint (no switch, no state change).
4301
+ if (selectingSession) {
4302
+ // Rebuilt per keypress from the same render's state the paint uses, so
4303
+ // highlight/filter/paint never disagree mid-tick.
4304
+ const entries = filterSessionEntries(sessionItems, sessionFilterRef.current);
4305
+ if (key.upArrow) {
4306
+ if (entries.length > 0) {
4307
+ setSessionIndexBoth((sessionIndexRef.current - 1 + entries.length) % entries.length);
4308
+ }
4309
+ }
4310
+ else if (key.downArrow) {
4311
+ if (entries.length > 0) {
4312
+ setSessionIndexBoth((sessionIndexRef.current + 1) % entries.length);
4313
+ }
4314
+ }
4315
+ else if (key.escape) {
4316
+ setSessionFilterBoth("");
4317
+ setSelectingSession(false);
4318
+ }
4319
+ else if (key.return) {
4320
+ const picked = entries[sessionIndexRef.current];
4321
+ setSessionFilterBoth("");
4322
+ setSelectingSession(false);
4323
+ if (picked) {
4324
+ exitHistoryBrowse();
4325
+ switchToSession(picked.id);
4326
+ }
4327
+ else {
4328
+ pushInfo("(no sessions match — backspace to widen the filter.)");
4329
+ }
4330
+ }
4331
+ else if (key.backspace || key.delete) {
4332
+ setSessionFilterBoth(sessionFilterRef.current.slice(0, -1));
4333
+ setSessionIndexBoth(0);
4334
+ }
4335
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
4336
+ setSessionFilterBoth(sessionFilterRef.current + ch);
4337
+ setSessionIndexBoth(0);
4338
+ }
4339
+ return;
4340
+ }
3496
4341
  // 3b. Effort picker (/effort): same keyboard pattern as the /model
3497
4342
  // picker (↑/↓ + Enter, Esc cancels).
3498
4343
  if (selectingEffort) {
@@ -3599,7 +4444,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3599
4444
  }
3600
4445
  else if (key.return || key.tab) {
3601
4446
  const pick = matches[slashIndexRef.current % matches.length];
3602
- // /compact, /queue, and /steer run while busy (see
4447
+ // /compact, /queue, /steer, and /autoscroll run while busy (see
3603
4448
  // slashRunsWhileBusy); every other entry still waits idle.
3604
4449
  if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
3605
4450
  if (pick.skill) {
@@ -3623,6 +4468,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3623
4468
  setInputBoth("");
3624
4469
  void runCompactCommand(focus);
3625
4470
  }
4471
+ else if (pick.name === "/autoscroll" &&
4472
+ (inputRef.current === "/autoscroll" || inputRef.current.startsWith("/autoscroll "))) {
4473
+ // Preserve the on/off arg when the menu is open on a prefix
4474
+ // (bare highlighted name alone would drop it).
4475
+ const raw = inputRef.current;
4476
+ setInputBoth("");
4477
+ runAutoScrollCommand(raw);
4478
+ }
3626
4479
  else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
3627
4480
  inputRef.current.startsWith(pick.name)) {
3628
4481
  // Preserve the typed rule args (e.g. "/allow bash:npm test*");
@@ -3631,6 +4484,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3631
4484
  setInputBoth("");
3632
4485
  runRulesCommand(raw);
3633
4486
  }
4487
+ else if (pick.name === "/rename" &&
4488
+ (inputRef.current === "/rename" || inputRef.current.startsWith("/rename "))) {
4489
+ // Preserve the typed name (e.g. '/rename "Build auth"'); a bare
4490
+ // highlighted name falls through to usage.
4491
+ const raw = inputRef.current;
4492
+ setInputBoth("");
4493
+ runRenameCommand(raw);
4494
+ }
3634
4495
  else {
3635
4496
  runSlashCommand(pick.name);
3636
4497
  }
@@ -3729,7 +4590,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3729
4590
  // compact/queue/steer fire while busy.
3730
4591
  if (key.ctrl && (ch === "p" || ch === "P")) {
3731
4592
  if (!pendingApproval && !pendingQuestion &&
3732
- !selecting && !selectingSkills && !selectingProvider &&
4593
+ !selecting && !selectingSkills && !selectingSession && !selectingProvider &&
3733
4594
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
3734
4595
  !selectingRewind && !selectingRewindScope && !inspecting) {
3735
4596
  if (paletteOpenRef.current)
@@ -3886,6 +4747,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3886
4747
  !pendingQuestion &&
3887
4748
  !selecting &&
3888
4749
  !selectingSkills &&
4750
+ !selectingSession &&
3889
4751
  !selectingProvider &&
3890
4752
  !keyPrompt &&
3891
4753
  !baseURLPrompt &&
@@ -3911,7 +4773,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3911
4773
  : `thinking${theme.symbol.ellipsis}`;
3912
4774
  // Slash menu derived for render (mirrors the useInput computation above):
3913
4775
  // commands first, then matching skills as `/skill:name` entries.
3914
- const slashMenu = !selecting &&
4776
+ // Memoized on every gate input: without this the fuzzy matcher rebuilds
4777
+ // on each 1s tick and tool append even though the menu only depends on
4778
+ // input + overlay state.
4779
+ const slashMenu = useMemo(() => !selecting &&
3915
4780
  !selectingSkills &&
3916
4781
  !selectingEffort &&
3917
4782
  !selectingProvider &&
@@ -3925,7 +4790,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3925
4790
  input.startsWith("/") &&
3926
4791
  !input.includes("\n")
3927
4792
  ? buildSlashMenu(input, skillMenu)
3928
- : { items: [], moreSkills: 0 };
4793
+ : { items: [], moreSkills: 0 },
4794
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4795
+ [
4796
+ selecting,
4797
+ selectingSkills,
4798
+ selectingEffort,
4799
+ selectingProvider,
4800
+ keyPrompt,
4801
+ baseURLPrompt,
4802
+ pendingApproval,
4803
+ pendingQuestion,
4804
+ selectingRewind,
4805
+ selectingRewindScope,
4806
+ slashDismissed,
4807
+ input,
4808
+ skillMenu,
4809
+ ]);
3929
4810
  const filteredSlash = slashMenu.items;
3930
4811
  const slashVisible = filteredSlash.length > 0;
3931
4812
  const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
@@ -3939,9 +4820,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3939
4820
  // Unified /model picker derived for render (mirrors the useInput
3940
4821
  // computation above): full entries, filtered entries, clamped highlight,
3941
4822
  // and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
3942
- // rows no matter how many models providers list.
3943
- const modelEntriesAll = selecting ? buildModelEntries() : [];
3944
- const modelEntries = selecting ? filterModelEntries(modelEntriesAll, modelFilter) : [];
4823
+ // rows no matter how many providers list. Memoized: the registry walk
4824
+ // (all providers + fallbacks) must not rerun on ticks/appends while open.
4825
+ // Deps cover every read inside buildModelEntries: selecting gate, provider
4826
+ // mirror, live models, auth store (key presence), and the local snapshot
4827
+ // (loopback baseURLs feed the cache keys); cache-ref writes always land
4828
+ // alongside one of these setStates, so the memo can never go stale.
4829
+ const { modelEntriesAll, modelEntries } = useMemo(() => {
4830
+ const all = selecting ? buildModelEntries() : [];
4831
+ return {
4832
+ modelEntriesAll: all,
4833
+ modelEntries: selecting ? filterModelEntries(all, modelFilter) : [],
4834
+ };
4835
+ },
4836
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4837
+ [selecting, provider, models, auth, localSnap, modelFilter]);
3945
4838
  const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
3946
4839
  const modelWin = pickerWindow(modelEntries.length, modelHi);
3947
4840
  const modelTitle = `Atom — Select model (${modelEntries.length}` +
@@ -3950,13 +4843,47 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3950
4843
  // /skills picker derived for render (mirrors the useInput computation
3951
4844
  // above): names only, filtered, clamped highlight, visible window. The
3952
4845
  // title keeps the `Skills (` prefix the registry header always had.
3953
- const skillEntriesAll = selectingSkills ? skillPickerItems : [];
3954
- const skillEntries = selectingSkills ? filterSkillPicker(skillEntriesAll, skillFilter) : [];
4846
+ // Memoized for the same tick/append reason as the model picker above.
4847
+ const { skillEntriesAll, skillEntries } = useMemo(() => {
4848
+ const all = selectingSkills ? skillPickerItems : [];
4849
+ return {
4850
+ skillEntriesAll: all,
4851
+ skillEntries: selectingSkills ? filterSkillPicker(all, skillFilter) : [],
4852
+ };
4853
+ }, [selectingSkills, skillPickerItems, skillFilter]);
3955
4854
  const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
3956
4855
  const skillWin = pickerWindow(skillEntries.length, skillHi);
3957
4856
  const skillTitle = `Skills (${skillEntries.length}` +
3958
4857
  (skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
3959
4858
  `) — type to filter, up/down + Enter, Esc cancels:`;
4859
+ // /session picker derived for render (mirrors the useInput computation
4860
+ // above): open-time snapshot, filtered in memory, clamped highlight,
4861
+ // visible window. Memoized for the same tick/append reason as the pickers
4862
+ // above. nowMs via Date.now (render-time age labels, never persisted).
4863
+ const { sessionEntriesAll, sessionEntries } = useMemo(() => {
4864
+ const all = selectingSession ? sessionItems : [];
4865
+ return {
4866
+ sessionEntriesAll: all,
4867
+ sessionEntries: selectingSession ? filterSessionEntries(all, sessionFilter) : [],
4868
+ };
4869
+ }, [selectingSession, sessionItems, sessionFilter]);
4870
+ const sessionHi = sessionEntries.length === 0 ? 0 : Math.max(0, Math.min(sessionIndex, sessionEntries.length - 1));
4871
+ const sessionWin = pickerWindow(sessionEntries.length, sessionHi);
4872
+ const sessionPickerTitle = `Sessions (${sessionEntries.length}` +
4873
+ (sessionFilter ? ` of ${sessionEntriesAll.length}, filter: "${sessionFilter}"` : "") +
4874
+ `) — type to filter, up/down + Enter, Esc cancels:`;
4875
+ // Memoized render derivations (flicker fix): these rebuild arrays on every
4876
+ // App render (token paints, keystrokes, 1s ticks), which defeats the memo
4877
+ // on the leaf panels below. Memoized, the leaves skip everything but real
4878
+ // changes. Checkpoint listing reads the snapshot dir — never per frame.
4879
+ const paletteEntriesMemo = useMemo(() => (paletteOpen ? paletteEntries(paletteFilter) : []), [paletteOpen, paletteFilter]);
4880
+ const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
4881
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4882
+ [selectingRewind]);
4883
+ // Approval description: the tool-call one-liner for the modal. Memoized on
4884
+ // the pending approval itself — the 1s busy tick keeps firing while the
4885
+ // modal waits, and must not rebuild the string (nor re-render the modal).
4886
+ const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
3960
4887
  // (The cursor clamp lives inside the memoized InputBox now, next to its
3961
4888
  // only use — App body no longer reads cursor state for paint.)
3962
4889
  // Status-line reasoning segment wired to the effort session state:
@@ -3977,7 +4904,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3977
4904
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
3978
4905
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
3979
4906
  : null;
3980
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs }), 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 })) : 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: paletteEntries(paletteFilter), 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) => {
4907
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
3981
4908
  const i = modelWin.start + k;
3982
4909
  const entryLocal = e.local === true;
3983
4910
  const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
@@ -3992,7 +4919,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3992
4919
  return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
3993
4920
  }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
3994
4921
  ? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
3995
- : "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) => {
4922
+ : "No skills match — backspace to widen the filter." })) : null] })) : selectingSession ? (_jsxs(PickerShell, { title: sessionPickerTitle, children: [_jsx(PickerMoreAbove, { count: sessionWin.start }), sessionEntries.slice(sessionWin.start, sessionWin.end).map((e, k) => {
4923
+ const i = sessionWin.start + k;
4924
+ const age = formatSessionAge(Date.now(), e.updatedAt);
4925
+ return (_jsxs(PickerRow, { highlighted: i === sessionHi, children: [e.title, e.active ? " (current)" : "", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", e.turnCount, " turn", e.turnCount === 1 ? "" : "s", " \u00B7 ", age] })] }, `${e.id}-${i}`));
4926
+ }), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
4927
+ ? "No sessions yet — your current conversation is saved automatically."
4928
+ : "No sessions match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
3996
4929
  const has = keyForProvider(p.id).length > 0;
3997
4930
  const keyMark = isLocalProviderId(p.id)
3998
4931
  ? `local ${theme.symbol.descSeparator} no key needed`
@@ -4002,7 +4935,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4002
4935
  ? `${theme.symbol.descSeparator} key optional — free models need none`
4003
4936
  : `${theme.symbol.descSeparator} no key`;
4004
4937
  return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
4005
- }) })) : 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: [listCheckpoints().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." })] })) : (
4938
+ }) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "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." })] })) : (
4006
4939
  // The input is the one boxed, prominent surface (see the memoized
4007
4940
  // InputBox above): a quiet gray frame sets it apart from the
4008
4941
  // transcript above and the status line below. Pickers and modals
@@ -4010,5 +4943,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4010
4943
  // border color.
4011
4944
  _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
4012
4945
  ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
4013
- : `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 })] }));
4946
+ : `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null })] }));
4014
4947
  }