atom-agent 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -1,13 +1,15 @@
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 * as path from "node:path";
7
+ import React, { useEffect, useMemo, useRef, useState } from "react";
6
8
  import { Box, Text, useApp, useInput, usePaste, useStdout } 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";
@@ -48,7 +50,7 @@ export const SLASH_COMMANDS = [
48
50
  name: "/effort",
49
51
  description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
50
52
  },
51
- { name: "/tools", description: "List the 7 tools with one-line descriptions." },
53
+ { name: "/tools", description: "List the tools with one-line descriptions." },
52
54
  { name: "/skills", description: "List installed skills (project + global)." },
53
55
  { name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
54
56
  { name: "/mode", description: "Print the current permission mode (Tab cycles normal → yolo → plan)." },
@@ -62,6 +64,8 @@ export const SLASH_COMMANDS = [
62
64
  { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
63
65
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
64
66
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
67
+ { name: "/autoscroll", description: "Follow new output as it arrives (/autoscroll on|off; off freezes the view mid-turn)." },
68
+ { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
65
69
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
66
70
  { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
67
71
  { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
@@ -106,8 +110,18 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
106
110
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
107
111
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
108
112
  export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
113
+ export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll prints the current state.";
114
+ export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: shows or hides the committed thinking blocks; the turn, history, and telemetry are untouched).";
109
115
  export function filterSlashCommands(prefix) {
110
116
  const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
117
+ // Exact match wins outright: a fully-typed command collapses the menu
118
+ // to itself, so prefix-siblings (/skill vs /skills, /model vs /models)
119
+ // never read as duplicates and Enter stays deterministic. Partial
120
+ // input keeps the prefix-then-fuzzy tiers below untouched.
121
+ const full = `/${q}`;
122
+ const exact = SLASH_COMMANDS.find((c) => c.name === full);
123
+ if (exact)
124
+ return [exact];
111
125
  const pre = [];
112
126
  const fuzzy = [];
113
127
  for (const c of SLASH_COMMANDS) {
@@ -163,10 +177,15 @@ export function paletteEntries(query) {
163
177
  }));
164
178
  }
165
179
  // 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.
180
+ // pending flag for turn-end drain; /queue + /steer manage the running turn;
181
+ // /autoscroll and /thinking only flip view flags (never touch the turn).
167
182
  // Every other command waits idle.
168
183
  export function slashRunsWhileBusy(name) {
169
- return name === "/compact" || name === "/queue" || name === "/steer";
184
+ return (name === "/compact" ||
185
+ name === "/queue" ||
186
+ name === "/steer" ||
187
+ name === "/autoscroll" ||
188
+ name === "/thinking");
170
189
  }
171
190
  // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
172
191
  // better; null = no match). Pure — shared by the command filter, the skill
@@ -816,6 +835,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
816
835
  scrollEndRef.current = next;
817
836
  setScrollEnd(next);
818
837
  }
838
+ // Thinking visibility (the /thinking toggle, rendering-only, default
839
+ // hidden): committed thinking turns + the live thinking block show only
840
+ // while on. Never touches the turn, history, or telemetry — purely paint.
841
+ const [showThinking, setShowThinking] = useState(false);
842
+ const showThinkingRef = useRef(false);
843
+ function setShowThinkingBoth(next) {
844
+ showThinkingRef.current = next;
845
+ setShowThinking(next);
846
+ }
847
+ // /autoscroll (session-only, default on). On = today's behavior: a
848
+ // following view extends with every appended turn. Off = appends during a
849
+ // busy turn freeze a following view at its current end instead of yanking
850
+ // it (the `↓ N new` indicator offers the jump back; End resumes). Idle
851
+ // appends always follow — freezing only matters while output streams.
852
+ const [autoScroll, setAutoScroll] = useState(true);
853
+ const autoScrollRef = useRef(true);
854
+ function setAutoScrollBoth(next) {
855
+ autoScrollRef.current = next;
856
+ setAutoScroll(next);
857
+ }
819
858
  // Skill registry (cached metadata): one instance per App, scoped to the
820
859
  // same dirs the suite injects via skillDirs. Every discovery path below
821
860
  // reads through it — refresh() revalidates by stat (mtime+size) and only
@@ -840,6 +879,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
840
879
  // menu keeps its previous snapshot (a hiccup must never break input)
841
880
  }
842
881
  }
882
+ // Pending transcript diff (display-only): the approve-time write/edit
883
+ // preview plus full-file BEFORE/AFTER capture, held for the matching
884
+ // onToolActivity commit. Single slot is exact — the scheduler never
885
+ // parallel-batches writes (writes conflict globally; parallel members
886
+ // never prompt), and every execution commits exactly one activity entry
887
+ // in call order. Lifetime ⊆ one turn: set in approve(),
888
+ // consumed-or-cleared by the matching activity, and cleared on
889
+ // deny/cancel/turn boundaries so a stale preview can never attach to
890
+ // a later call.
891
+ // - write: BEFORE reuses the preview's pre-read; AFTER is the new
892
+ // content arg (exactly what the tool writes) — zero extra reads.
893
+ // - edit: BEFORE is a best-effort full-file read here (pre-execution);
894
+ // AFTER is read at commit time. Two reads, each once, never in render.
895
+ const pendingDiffRef = useRef(null);
896
+ // Best-effort full-file read for diff capture: null on missing dir,
897
+ // oversize, or any I/O failure. Never throws — capture degrades to the
898
+ // arg-block preview pair instead of breaking approval.
899
+ function readFileForDiff(absPath) {
900
+ try {
901
+ const st = fs.statSync(absPath);
902
+ if (!st.isFile() || st.size > APPROVAL_PREVIEW_MAX_BYTES)
903
+ return null;
904
+ return fs.readFileSync(absPath, "utf8");
905
+ }
906
+ catch {
907
+ return null;
908
+ }
909
+ }
843
910
  // Tool approval prompt (normal mode, write/edit/bash): the loop waits on
844
911
  // the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
845
912
  // (LoopCancelledError) instead of denying one call.
@@ -941,10 +1008,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
941
1008
  // tool name before its execution line lands.
942
1009
  const [draft, setDraft] = useState(null);
943
1010
  // 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.
1011
+ // answer, rendered in its own dim block below. The live value is transient
1012
+ // like `draft` — cleared on every turn boundary below — but each completed
1013
+ // round commits to the transcript via commitThinking (stays in the TUI,
1014
+ // never the model history) instead of being replaced and lost.
947
1015
  const [thinking, setThinking] = useState(null);
1016
+ const thinkingRef = useRef(null);
1017
+ // Move the accumulated round thinking into the transcript as a quiet
1018
+ // annotation turn (no-op when empty). Called when a new POST starts and at
1019
+ // turn end, so every round's reasoning stays visible; the /thinking toggle
1020
+ // only controls rendering, never this record.
1021
+ function commitThinking() {
1022
+ const text = thinkingRef.current;
1023
+ thinkingRef.current = null;
1024
+ setThinking(null);
1025
+ if (typeof text === "string" && text.length > 0) {
1026
+ appendTurns({ role: "assistant", content: text, thinking: true });
1027
+ }
1028
+ }
1029
+ function clearThinking() {
1030
+ thinkingRef.current = null;
1031
+ setThinking(null);
1032
+ }
948
1033
  const [phase, setPhase] = useState("idle");
949
1034
  const [phaseDetail, setPhaseDetail] = useState("");
950
1035
  const [toolHint, setToolHint] = useState(null);
@@ -1552,6 +1637,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1552
1637
  setTurns(next);
1553
1638
  }
1554
1639
  function appendTurns(...items) {
1640
+ // Autoscroll off + busy + following: freeze the view at its current end
1641
+ // BEFORE appending, so streaming output accumulates below instead of
1642
+ // yanking the viewport (smooth-scroll hold). Idle appends and already-
1643
+ // held views pass through untouched.
1644
+ if (!autoScrollRef.current && busyRef.current && scrollEndRef.current === null) {
1645
+ setScrollEndBoth(turnsRef.current.length);
1646
+ }
1555
1647
  const next = [...turnsRef.current, ...items];
1556
1648
  turnsRef.current = next;
1557
1649
  setTurns(next);
@@ -1729,7 +1821,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1729
1821
  // Snapshot the committed session (historyRef + turnsRef + settings refs)
1730
1822
  // to ~/.atom/session.json. Disk errors are ignored (in-memory session
1731
1823
  // still applies). Called only for committed state: completed turns and
1732
- // clean exit — never for rolled-back (failed/cancelled) turns.
1824
+ // clean exit — never for rolled-back (failed/cancelled) turns. The
1825
+ // committed diff previews (Turn.diff) are display-only and never saved:
1826
+ // they can hold whole file contents (bloat) and would render stale
1827
+ // after later edits, so /resume restores label-only turns.
1733
1828
  function persistSession() {
1734
1829
  try {
1735
1830
  saveSession({
@@ -1739,7 +1834,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1739
1834
  mode: modeRef.current,
1740
1835
  usageTotals: usageRef.current,
1741
1836
  history: historyRef.current,
1742
- turns: turnsRef.current,
1837
+ turns: turnsRef.current.map((t) => {
1838
+ const { diff: _dropped, ...rest } = t;
1839
+ return rest;
1840
+ }),
1743
1841
  }, authHome);
1744
1842
  }
1745
1843
  catch {
@@ -2209,6 +2307,58 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2209
2307
  }
2210
2308
  pushInfo(QUEUE_USAGE);
2211
2309
  }
2310
+ // /thinking: rendering-only visibility toggle for model thinking (both
2311
+ // the committed transcript blocks and the live thinking block). Pure
2312
+ // paint — safe while busy (never touches the turn, like /autoscroll).
2313
+ // Bare toggles; anything appended prints usage (there are no arguments).
2314
+ function runThinkingCommand(raw) {
2315
+ if (raw.trim() !== "/thinking") {
2316
+ pushInfo(THINKING_USAGE);
2317
+ return;
2318
+ }
2319
+ const next = !showThinkingRef.current;
2320
+ setShowThinkingBoth(next);
2321
+ pushInfo(next
2322
+ ? "(thinking shown — model reasoning stays visible in the transcript)"
2323
+ : "(thinking hidden — reasoning still runs, it just isn't rendered)");
2324
+ }
2325
+ // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
2326
+ // only state — safe while busy (never touches the turn, like /queue).
2327
+ // Bare prints the state; on jumps to the latest; off freezes a following
2328
+ // view at its current end (mid-turn appends then accumulate below).
2329
+ function runAutoScrollCommand(raw) {
2330
+ const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
2331
+ if (arg === "") {
2332
+ pushInfo(autoScrollRef.current
2333
+ ? "(autoscroll on — following new output as it arrives)"
2334
+ : "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2335
+ return;
2336
+ }
2337
+ if (arg === "on") {
2338
+ if (autoScrollRef.current) {
2339
+ pushInfo("(autoscroll already on)");
2340
+ return;
2341
+ }
2342
+ setAutoScrollBoth(true);
2343
+ setScrollEndBoth(null);
2344
+ pushInfo("(autoscroll on — following the latest)");
2345
+ return;
2346
+ }
2347
+ if (arg === "off") {
2348
+ if (!autoScrollRef.current) {
2349
+ pushInfo("(autoscroll already off)");
2350
+ return;
2351
+ }
2352
+ // Confirm FIRST while still following (visible), then flip the switch:
2353
+ // flipping first would freeze this very confirm below the viewport
2354
+ // (appendTurns freezes busy appends once off). Already-held views stay
2355
+ // held; the next busy append freezes a following view via appendTurns.
2356
+ pushInfo("(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2357
+ setAutoScrollBoth(false);
2358
+ return;
2359
+ }
2360
+ pushInfo(AUTOSCROLL_USAGE);
2361
+ }
2212
2362
  // /models: local-discovery status + refresh. Bare `/models` reports the
2213
2363
  // last snapshot (kicking a first probe when discovery never ran);
2214
2364
  // `/models refresh` re-probes all three runtimes, then reports. Results
@@ -2275,7 +2425,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2275
2425
  setClearGen((g) => g + 1);
2276
2426
  setError(null);
2277
2427
  setDraft(null);
2278
- setThinking(null);
2428
+ clearThinking();
2279
2429
  setToolHint(null);
2280
2430
  setPhase("idle");
2281
2431
  setPhaseDetail("");
@@ -2326,7 +2476,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2326
2476
  setClearGen((g) => g + 1);
2327
2477
  setError(null);
2328
2478
  setDraft(null);
2329
- setThinking(null);
2479
+ clearThinking();
2330
2480
  setToolHint(null);
2331
2481
  setPhase("idle");
2332
2482
  setPhaseDetail("");
@@ -2412,6 +2562,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2412
2562
  case "/steer":
2413
2563
  runQueueCommand("/steer");
2414
2564
  return;
2565
+ case "/thinking":
2566
+ runThinkingCommand("/thinking");
2567
+ return;
2568
+ case "/autoscroll":
2569
+ runAutoScrollCommand("/autoscroll");
2570
+ return;
2415
2571
  case "/mode":
2416
2572
  if (modeRef.current === "plan") {
2417
2573
  pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
@@ -2503,6 +2659,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2503
2659
  async function approve(name, args) {
2504
2660
  if (turnCancelRef.current?.signal.aborted)
2505
2661
  throw new LoopCancelledError();
2662
+ // Stage the transcript-diff preview for write/edit (all outcomes):
2663
+ // the modal below reuses it, and onToolActivity consumes it when the
2664
+ // matching execution commits. Deny/cancel paths clear it (execution
2665
+ // never happens, so nothing must linger for a later call). Full-file
2666
+ // BEFORE is captured here (pre-execution); AFTER resolves at commit
2667
+ // (write content arg, or a post-execution disk read for edit).
2668
+ const stagedDiff = name === "write" || name === "edit" ? previewDiffForApproval(name, args) : null;
2669
+ if (name === "write" || name === "edit") {
2670
+ const toolPath = typeof args["path"] === "string" ? args["path"] : null;
2671
+ const beforeFull = name === "write"
2672
+ ? (stagedDiff?.oldText ?? null) // preview already pre-read it: no second read
2673
+ : toolPath !== null
2674
+ ? readFileForDiff(path.resolve(process.cwd(), toolPath))
2675
+ : null;
2676
+ const afterArg = name === "write" && typeof args["content"] === "string"
2677
+ ? args["content"]
2678
+ : null;
2679
+ pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
2680
+ }
2506
2681
  // Policy layer owns the decision order (deny → plan → allow → yolo →
2507
2682
  // trust → always → skill grants → prompt); this function owns cancel
2508
2683
  // handling and the interactive prompt plumbing around it.
@@ -2514,22 +2689,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2514
2689
  skillGrants: skillGrantsRef.current,
2515
2690
  approvalGated: needsApproval(name),
2516
2691
  });
2517
- if (outcome.kind === "deny")
2692
+ if (outcome.kind === "deny") {
2693
+ pendingDiffRef.current = null;
2518
2694
  return "no";
2695
+ }
2519
2696
  if (outcome.kind === "allow")
2520
2697
  return "once";
2521
2698
  const signal = turnCancelRef.current?.signal ?? null;
2522
- if (signal?.aborted)
2699
+ if (signal?.aborted) {
2700
+ pendingDiffRef.current = null;
2523
2701
  throw new LoopCancelledError();
2702
+ }
2524
2703
  return new Promise((resolve, reject) => {
2525
2704
  approvalResolveRef.current = { resolve, reject };
2526
2705
  setApproveIndexBoth(0);
2527
- setPendingApproval({ name, args });
2706
+ setPendingApproval({ name, args, diff: stagedDiff });
2528
2707
  if (signal) {
2529
2708
  const onAbort = () => {
2530
2709
  const h = approvalResolveRef.current;
2531
2710
  approvalResolveRef.current = null;
2532
2711
  setPendingApproval(null);
2712
+ pendingDiffRef.current = null;
2533
2713
  h?.reject(new LoopCancelledError());
2534
2714
  };
2535
2715
  if (signal.aborted)
@@ -2543,6 +2723,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2543
2723
  if (decision === "always" && pendingApproval) {
2544
2724
  alwaysAllowedRef.current.add(pendingApproval.name);
2545
2725
  }
2726
+ if (decision === "no")
2727
+ pendingDiffRef.current = null;
2546
2728
  const h = approvalResolveRef.current;
2547
2729
  approvalResolveRef.current = null;
2548
2730
  setPendingApproval(null);
@@ -2651,6 +2833,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2651
2833
  runQueueCommand(text);
2652
2834
  return;
2653
2835
  }
2836
+ // /autoscroll and /thinking are view-only state (never touch the
2837
+ // turn), so they run while busy like /queue + /steer (see
2838
+ // slashRunsWhileBusy).
2839
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2840
+ runAutoScrollCommand(text);
2841
+ return;
2842
+ }
2843
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
2844
+ runThinkingCommand(text);
2845
+ return;
2846
+ }
2654
2847
  if (text.startsWith("/"))
2655
2848
  return;
2656
2849
  if (queueRef.current.length >= QUEUE_CAP) {
@@ -2667,6 +2860,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2667
2860
  runQueueCommand(text);
2668
2861
  return;
2669
2862
  }
2863
+ // /autoscroll takes an optional subcommand (/autoscroll on|off), like the
2864
+ // /queue family — SLASH_NAMES only holds the exact command. /thinking
2865
+ // is bare-toggle-only; anything appended prints its usage.
2866
+ if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2867
+ runAutoScrollCommand(text);
2868
+ return;
2869
+ }
2870
+ if (text === "/thinking" || text.startsWith("/thinking ")) {
2871
+ runThinkingCommand(text);
2872
+ return;
2873
+ }
2670
2874
  // Scoped rules (ticket 03): exact or free-text forms (/allow bash:x,
2671
2875
  // /rules clear) route with args intact — SLASH_NAMES only holds exact
2672
2876
  // commands, and the skill fallback below must not swallow these.
@@ -2731,7 +2935,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2731
2935
  setBusy(true);
2732
2936
  setError(null);
2733
2937
  setDraft(null);
2734
- setThinking(null);
2938
+ clearThinking();
2735
2939
  // Fresh turn, fresh latch: the queue drain at the end auto-sends only
2736
2940
  // when this turn was NOT cancelled (see the turn-end finally).
2737
2941
  turnCancelledRef.current = false;
@@ -2753,6 +2957,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2753
2957
  // Display-only tool clock: no tool is running at turn start, so any
2754
2958
  // stale timestamp from a previous turn must not leak into this one.
2755
2959
  toolStartRef.current = null;
2960
+ // Same for the transcript-diff slot: a previous turn's unconsumed
2961
+ // preview (cancelled mid-execution) must never attach to this turn.
2962
+ pendingDiffRef.current = null;
2756
2963
  lastPartialRef.current = "";
2757
2964
  refreshGitInfo();
2758
2965
  // SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
@@ -2833,6 +3040,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2833
3040
  // Local observability sink: the loop reports completed model/tool
2834
3041
  // calls (iterations, durations, usage) into the open turn trace.
2835
3042
  telemetry: telemetrySink,
3043
+ // Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
3044
+ // bottleneck, context growth) attach to the same open turn trace.
3045
+ // Fires once per turn — including failed/cancelled turns, whose
3046
+ // endTurn below still records the outcome alongside these stats.
3047
+ onLoopStats: (s) => telemetry.recordLoopStats(telemetryTurnId, s),
2836
3048
  // Plan-mode read-only gate (ticket 04): mutations are refused here
2837
3049
  // with a replan note; every other tool delegates to executeTool.
2838
3050
  execute: guardedExecute,
@@ -2850,6 +3062,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2850
3062
  noteTurnActivity();
2851
3063
  },
2852
3064
  onThinking: (partial) => {
3065
+ thinkingRef.current = partial;
2853
3066
  setThinking(partial);
2854
3067
  noteTurnActivity();
2855
3068
  },
@@ -2858,8 +3071,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2858
3071
  setPhaseDetail(detail ?? "");
2859
3072
  noteTurnActivity();
2860
3073
  if (p === "thinking") {
2861
- // New POST: its thinking (if any) replaces the previous round's.
2862
- setThinking(null);
3074
+ // New POST: the previous round's thinking (if any) commits to
3075
+ // the transcript so it stays in the TUI instead of being
3076
+ // replaced and lost; the fresh round streams into the live block.
3077
+ commitThinking();
2863
3078
  }
2864
3079
  else if (p === "tool" && detail) {
2865
3080
  setToolHint(detail);
@@ -2963,6 +3178,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2963
3178
  else if (isTodo) {
2964
3179
  items.push({ role: "tool", content: result });
2965
3180
  }
3181
+ // Committed transcript diff: the approve-time capture for this
3182
+ // exact execution rides on the label turn. Consume-or-clear on
3183
+ // every matching activity (success or failure) so a stale
3184
+ // capture can never leak onto a later call; render only on
3185
+ // success with a real payload (failures keep the ↳ line only).
3186
+ // Full-file BEFORE→AFTER is preferred (aligned panes with
3187
+ // context); when either side is unavailable (unreadable file,
3188
+ // oversize), fall back to the arg-block preview pair.
3189
+ const slot = pendingDiffRef.current;
3190
+ if (slot !== null &&
3191
+ (label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `))) {
3192
+ pendingDiffRef.current = null;
3193
+ if (!isError) {
3194
+ let afterFull = null;
3195
+ if (slot.name === "write") {
3196
+ afterFull = slot.afterArg;
3197
+ }
3198
+ else if (slot.path !== null) {
3199
+ afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
3200
+ }
3201
+ const beforeFull = slot.beforeFull;
3202
+ if (beforeFull !== null && afterFull !== null) {
3203
+ items[0].diff = {
3204
+ oldText: beforeFull,
3205
+ newText: afterFull,
3206
+ lang: slot.diff?.lang ?? null,
3207
+ path: slot.path,
3208
+ };
3209
+ }
3210
+ else if (slot.diff !== null) {
3211
+ items[0].diff = slot.diff;
3212
+ }
3213
+ }
3214
+ }
2966
3215
  appendTurns(...items);
2967
3216
  noteTurnActivity();
2968
3217
  },
@@ -2975,8 +3224,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2975
3224
  },
2976
3225
  });
2977
3226
  // Turn-end flush: any trailing throttled partial paints before the
2978
- // commit replaces the draft (byte-exact via `reply` regardless).
3227
+ // commit replaces the draft (byte-exact via `reply` regardless). The
3228
+ // final round's thinking commits first (chronological: reasoning, then
3229
+ // the answer it produced).
2979
3230
  flushDraft();
3231
+ commitThinking();
2980
3232
  appendTurns({ role: "assistant", content: reply });
2981
3233
  // The turn committed to history (final text, denial-as-result, or
2982
3234
  // stop-notice) — persist the kill-safe save. Rolled-back turns (catch
@@ -3015,6 +3267,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3015
3267
  controller.signal.aborted;
3016
3268
  historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
3017
3269
  turnCancelledRef.current = cancelled;
3270
+ // The turn never happened: drop live thinking with it (a failed turn
3271
+ // commits nothing — same scope as the history rollback above).
3272
+ clearThinking();
3018
3273
  // Local observability: failed/cancelled turns still record what was
3019
3274
  // attempted (model/tool calls so far) with their outcome, then flush.
3020
3275
  // Like the save above, the telemetry file only ever gains completed
@@ -3066,6 +3321,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3066
3321
  turnCancelRef.current = null;
3067
3322
  approvalResolveRef.current = null;
3068
3323
  setPendingApproval(null);
3324
+ // Safety net: the slot is normally consumed by onToolActivity or
3325
+ // cleared on deny/cancel — never let it cross a turn boundary.
3326
+ pendingDiffRef.current = null;
3069
3327
  askResolveRef.current = null;
3070
3328
  setPendingQuestion(null);
3071
3329
  setAskCustomBoth("");
@@ -3083,7 +3341,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3083
3341
  // ignore
3084
3342
  }
3085
3343
  setDraft(null);
3086
- setThinking(null);
3344
+ clearThinking();
3087
3345
  setToolHint(null);
3088
3346
  clearTurnTimer();
3089
3347
  setStalled(false);
@@ -3599,7 +3857,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3599
3857
  }
3600
3858
  else if (key.return || key.tab) {
3601
3859
  const pick = matches[slashIndexRef.current % matches.length];
3602
- // /compact, /queue, and /steer run while busy (see
3860
+ // /compact, /queue, /steer, and /autoscroll run while busy (see
3603
3861
  // slashRunsWhileBusy); every other entry still waits idle.
3604
3862
  if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
3605
3863
  if (pick.skill) {
@@ -3623,6 +3881,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3623
3881
  setInputBoth("");
3624
3882
  void runCompactCommand(focus);
3625
3883
  }
3884
+ else if (pick.name === "/autoscroll" &&
3885
+ (inputRef.current === "/autoscroll" || inputRef.current.startsWith("/autoscroll "))) {
3886
+ // Preserve the on/off arg when the menu is open on a prefix
3887
+ // (bare highlighted name alone would drop it).
3888
+ const raw = inputRef.current;
3889
+ setInputBoth("");
3890
+ runAutoScrollCommand(raw);
3891
+ }
3626
3892
  else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
3627
3893
  inputRef.current.startsWith(pick.name)) {
3628
3894
  // Preserve the typed rule args (e.g. "/allow bash:npm test*");
@@ -3957,6 +4223,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3957
4223
  const skillTitle = `Skills (${skillEntries.length}` +
3958
4224
  (skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
3959
4225
  `) — type to filter, up/down + Enter, Esc cancels:`;
4226
+ // Memoized render derivations (flicker fix): these rebuild arrays on every
4227
+ // App render (token paints, keystrokes, 1s ticks), which defeats the memo
4228
+ // on the leaf panels below. Memoized, the leaves skip everything but real
4229
+ // changes. Checkpoint listing reads the snapshot dir — never per frame.
4230
+ const paletteEntriesMemo = useMemo(() => (paletteOpen ? paletteEntries(paletteFilter) : []), [paletteOpen, paletteFilter]);
4231
+ const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
4232
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4233
+ [selectingRewind]);
3960
4234
  // (The cursor clamp lives inside the memoized InputBox now, next to its
3961
4235
  // only use — App body no longer reads cursor state for paint.)
3962
4236
  // Status-line reasoning segment wired to the effort session state:
@@ -3977,7 +4251,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3977
4251
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
3978
4252
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
3979
4253
  : 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) => {
4254
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: describeToolCall(pendingApproval.name, pendingApproval.args), selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
3981
4255
  const i = modelWin.start + k;
3982
4256
  const entryLocal = e.local === true;
3983
4257
  const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
@@ -4002,7 +4276,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4002
4276
  ? `${theme.symbol.descSeparator} key optional — free models need none`
4003
4277
  : `${theme.symbol.descSeparator} no key`;
4004
4278
  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." })] })) : (
4279
+ }) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "default" ? "Default" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Top is Max (sent as max); xhigh is not a verified value." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
4006
4280
  // The input is the one boxed, prominent surface (see the memoized
4007
4281
  // InputBox above): a quiet gray frame sets it apart from the
4008
4282
  // transcript above and the status line below. Pickers and modals