atom-agent 1.4.0 → 1.5.1

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 (68) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +221 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +502 -21
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +250 -434
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/providers.js +11 -3
  21. package/dist/scheduler.js +38 -9
  22. package/dist/session-revert.js +125 -0
  23. package/dist/sessions.js +101 -0
  24. package/dist/snapshots.js +69 -0
  25. package/dist/system.js +2 -89
  26. package/dist/telemetry.js +79 -5
  27. package/dist/todos.js +241 -0
  28. package/dist/tools/filesystem.js +102 -22
  29. package/dist/tools/registry.js +184 -45
  30. package/dist/tools/ripgrep.js +7 -6
  31. package/dist/tools/search.js +172 -17
  32. package/dist/tools/shared.js +6 -0
  33. package/dist/tools.js +7 -39
  34. package/dist/ui/diff-panel.js +1 -1
  35. package/dist/ui/diff-view.js +13 -5
  36. package/dist/ui/diff.js +67 -0
  37. package/dist/ui/errors.js +20 -6
  38. package/dist/ui/input.js +24 -20
  39. package/dist/ui/live-tail.js +36 -1
  40. package/dist/ui/markdown.js +9 -4
  41. package/dist/ui/modals.js +7 -5
  42. package/dist/ui/paint-scheduler.js +120 -0
  43. package/dist/ui/palette.js +4 -2
  44. package/dist/ui/pickers.js +4 -1
  45. package/dist/ui/side-by-side.js +81 -22
  46. package/dist/ui/status-bar.js +63 -8
  47. package/dist/ui/stream-store.js +7 -0
  48. package/dist/ui/theme.js +23 -1
  49. package/dist/ui/todo-panel.js +5 -2
  50. package/dist/ui/tool-inspector.js +33 -4
  51. package/dist/ui/transcript.js +8 -5
  52. package/dist/web/events.js +93 -0
  53. package/dist/web/runtime.js +790 -0
  54. package/dist/web/server.js +570 -0
  55. package/dist/web/ui/app.js +1925 -0
  56. package/dist/web/ui/index.html +135 -0
  57. package/dist/web/ui/styles.css +515 -0
  58. package/dist/zen.js +532 -34
  59. package/documentation/cli.md +5 -5
  60. package/documentation/configuration.md +11 -6
  61. package/documentation/development.md +4 -3
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/package.json +3 -2
package/dist/App.js CHANGED
@@ -9,16 +9,20 @@ import { Box, Text, useApp, useInput, usePaste } from "ink";
9
9
  import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, normalizeEffort, runAgenticLoopForProvider, } from "./zen.js";
10
10
  import { createContextManager, trackHistory, } from "./context-manager.js";
11
11
  import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
12
- import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
12
+ import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets, todowriteTool } from "./tools.js";
13
13
  import { classifyTurnOutcome, createTelemetryRecorder, loadTelemetrySessions, resolveTelemetryEnabled, summarizeTelemetry, telemetryDir, } from "./telemetry.js";
14
14
  import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
15
15
  import { formatRules, parseRuleInput, } from "./permissions.js";
16
- import { decidePolicy, skillGrantsFor } from "./policy.js";
16
+ import { decideApproval, skillGrantsFor } from "./policy.js";
17
17
  import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
18
18
  import { contextWindowFor } from "./context-windows.js";
19
19
  import { emptyGoalStats, formatGoalForCompact, goalClearNotice, goalFollowUp, goalPauseNotice, goalResumeNotice, goalSetNotice, goalStatusText, goalTokensForUsage, parseGoalCommand, restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
20
20
  import { requestGoalVerdict } from "./agent/goal-evaluator.js";
21
+ import { readSessionTodos, withSessionTodos, } from "./todos.js";
22
+ import { collectTurnFileDiffs, emptyFileDiffs, FILE_DIFFS_METADATA_KEY, mergeFileDiffs, readFileDiffs, serializeFileDiffs, } from "./file-diffs.js";
23
+ import { revertSessionToCheckpoint, } from "./session-revert.js";
21
24
  import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFilesAndGoal, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
25
+ import { shouldAutoCompactReal } from "./overflow.js";
22
26
  import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
23
27
  import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
24
28
  import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
@@ -26,7 +30,7 @@ import { validateProviderKey } from "./adapters.js";
26
30
  import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
27
31
  import { getGitInfo, withEnvBlock } from "./env-block.js";
28
32
  import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
29
- import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
33
+ import { createSession, ensureActiveSession, forkSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
30
34
  import { discoverExtensionEntries, loadExtensions, resolveExtensionName, } from "./extensions.js";
31
35
  import { formatExtensionStatusText } from "./extension-ui.js";
32
36
  import { getExtensionCommand, listExtensionCommands, parseExtensionCommandInput, runExtensionCommand, } from "./extension-commands.js";
@@ -47,21 +51,20 @@ import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow
47
51
  import { shortenCwd } from "./ui/status-bar.js";
48
52
  import { StatusBarHost } from "./ui/status-host.js";
49
53
  import { createStreamStore } from "./ui/stream-store.js";
54
+ import { createPaintScheduler } from "./ui/paint-scheduler.js";
50
55
  import { theme } from "./ui/theme.js";
51
56
  import { TodoPanel } from "./ui/todo-panel.js";
52
57
  import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
53
58
  // Single registry for the "/" autocomplete menu and the exact-command path.
54
59
  export const SLASH_COMMANDS = [
55
- { name: "/model", description: "Open the model picker." },
56
- { name: "/models", description: "Refresh local model discovery (Ollama, LM Studio, llama.cpp)." },
60
+ { name: "/model", description: "Open the model picker (/model <text> filters, /model refresh re-probes servers)." },
57
61
  { name: "/provider", description: "Pick AI provider, paste API key once, chat." },
58
62
  {
59
63
  name: "/effort",
60
64
  description: "Open the reasoning-effort picker (Auto/Low/Medium/High/Max; Auto lets the model decide).",
61
65
  },
62
66
  { name: "/tools", description: "List the tools with one-line descriptions." },
63
- { name: "/skills", description: "List installed skills (project + global)." },
64
- { name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
67
+ { name: "/skill", description: "List skills in a picker, or invoke (/skill:name, /skill <name>)." },
65
68
  { name: "/mode", description: "Print the current permission mode (Tab cycles normal → yolo → plan)." },
66
69
  { name: "/trust", description: "Toggle session trust: auto-approve write/edit/bash without full yolo (/trust again revokes)." },
67
70
  { name: "/allow", description: "Pre-approve a tool pattern this session (e.g. /allow bash:npm test*)." },
@@ -75,10 +78,12 @@ export const SLASH_COMMANDS = [
75
78
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
76
79
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
77
80
  { name: "/autoscroll", description: "Toggle following new output (on by default; bare toggles, on|off sets it; off freezes the view mid-turn)." },
78
- { name: "/goal", description: "Set, show, pause, resume, or clear the session goal (/goal <objective>; bare shows it; /goal pause|resume; /goal clear ends it)." },
81
+ { name: "/goal", description: "Set (and start working, like a normal message), show, pause, resume, or clear the session goal (/goal <objective>; bare shows it; /goal pause|resume; /goal clear ends it)." },
79
82
  { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
80
83
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
81
84
  { name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
85
+ { name: "/fork", description: "Fork this session into a new one and switch to it (/fork [n] drops the last n messages first)." },
86
+ { name: "/revert", description: "Undo to a checkpoint — restores conversation + files (/revert [n] goes n checkpoints back)." },
82
87
  { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
83
88
  { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
84
89
  { name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
@@ -114,14 +119,17 @@ export const SUBMIT_PIPELINE_STAGES = [
114
119
  // Shared usage strings: the exact texts the commands print, hoisted to
115
120
  // module scope so the slash-menu argument hints reuse them (no second
116
121
  // implementation).
117
- export const SKILL_USAGE = "usage: /skill:<name> invoke a skill directly (list with /skills, e.g. /skill:code-review)";
122
+ export const SKILL_USAGE = "usage: /skill (list) · /skill:name or /skill <name> (invoke, e.g. /skill:code-review)";
123
+ export const MODEL_USAGE = "usage: /model [filter text] — open the picker; /model refresh re-probes local servers (Ollama, LM Studio, llama.cpp)";
118
124
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
119
125
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
120
126
  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";
121
127
  export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll toggles between the two.";
122
128
  export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: the live block and future rounds show or hide; already-printed blocks stay as printed; the turn, history, and telemetry are untouched).";
123
129
  export const RENAME_USAGE = 'usage: /rename <name> — rename the current session (e.g. /rename Build authentication; quotes optional: /rename "name with spaces"). Bare /rename prints this usage.';
124
- export const GOAL_USAGE = "usage: /goal <objective> (set; replacing resets counters) · /goal (show with cumulative stats) · /goal pause · /goal resume (re-arms; idle starts a turn, busy resumes at turn end) · /goal clear (ends it)";
130
+ export const FORK_USAGE = "usage: /fork [n] fork this session into a new one and switch to it (optional n drops the last n messages first, snapped to a turn boundary). Bare /fork clones the full conversation.";
131
+ export const REVERT_USAGE = "usage: /revert [n] — undo to a checkpoint (n checkpoints back, default 0 = latest). Restores conversation + files; other sessions and forks untouched. Bare /revert undoes the last bad turn.";
132
+ export const GOAL_USAGE = "usage: /goal <objective> (set and start working, just like a normal message; replacing resets counters; mid-turn set replaces quietly) · /goal (show with cumulative stats) · /goal pause · /goal resume (re-arms; idle starts a turn, busy resumes at turn end) · /goal clear (ends it)";
125
133
  // Pure arg parser for /rename (unit-tested): strips the command, trims,
126
134
  // then strips one layer of matching outer quotes (single or double) so
127
135
  // quoted names work even though the command line has no real parser.
@@ -142,8 +150,8 @@ export function parseRenameArg(raw) {
142
150
  export function filterSlashCommands(prefix) {
143
151
  const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
144
152
  // Exact match wins outright: a fully-typed command collapses the menu
145
- // to itself, so prefix-siblings (/skill vs /skills, /model vs /models)
146
- // never read as duplicates and Enter stays deterministic. Partial
153
+ // to itself, so prefix-siblings (/mode vs /model, /skill vs /skill:name
154
+ // rows) never read as duplicates and Enter stays deterministic. Partial
147
155
  // input keeps the prefix-then-fuzzy tiers below untouched.
148
156
  const full = `/${q}`;
149
157
  const exact = SLASH_COMMANDS.find((c) => c.name === full);
@@ -273,7 +281,7 @@ export function fuzzyScore(query, target) {
273
281
  // Max skill rows in the menu: the command list always renders whole, skills
274
282
  // narrow as you type — the menu can never take over the screen.
275
283
  export const SLASH_MENU_SKILL_CAP = 8;
276
- // Pure filter for the /skills picker (unit-tested): case-insensitive
284
+ // Pure filter for the /skill picker (unit-tested): case-insensitive
277
285
  // substring over the skill name (a search popup narrows harder than the
278
286
  // prefix-only slash menu). Empty query returns everything as-is.
279
287
  export function filterSkillPicker(entries, query) {
@@ -380,10 +388,16 @@ export function commandUsage(name) {
380
388
  return STEER_USAGE;
381
389
  case "/skill":
382
390
  return SKILL_USAGE;
391
+ case "/model":
392
+ return MODEL_USAGE;
383
393
  case "/compact":
384
394
  return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
385
395
  case "/rename":
386
396
  return RENAME_USAGE;
397
+ case "/fork":
398
+ return FORK_USAGE;
399
+ case "/revert":
400
+ return REVERT_USAGE;
387
401
  case "/goal":
388
402
  return GOAL_USAGE;
389
403
  default:
@@ -796,7 +810,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
796
810
  modelFilterRef.current = next;
797
811
  setModelFilter(next);
798
812
  }
799
- // /skills picker (opencode-style searchable popup): type-to-filter over the
813
+ // /skill picker (opencode-style searchable popup): type-to-filter over the
800
814
  // resolved registry, ↑/↓ + Enter to load, Esc cancels, windowed like the
801
815
  // model picker so any library size stays navigable. Snapshot state (the
802
816
  // registry always renders — names only, never descriptions).
@@ -817,7 +831,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
817
831
  // /session picker (interactive switcher): snapshot state loaded ONCE per
818
832
  // open (listSessions reads each record a single time), filtered in memory
819
833
  // per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
820
- // untouched. Same keyboard/window pattern as the /skills picker.
834
+ // untouched. Same keyboard/window pattern as the /skill picker.
821
835
  const [selectingSession, setSelectingSession] = useState(false);
822
836
  const [sessionItems, setSessionItems] = useState([]);
823
837
  const [sessionIndex, setSessionIndex] = useState(0);
@@ -980,10 +994,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
980
994
  setScrollEnd(next);
981
995
  }
982
996
  // Thinking visibility (the /thinking toggle, rendering-only, default
983
- // hidden): committed thinking turns + the live thinking block show only
984
- // while on. Never touches the turn, history, or telemetry — purely paint.
985
- const [showThinking, setShowThinking] = useState(false);
986
- const showThinkingRef = useRef(false);
997
+ // shown): committed thinking turns + the live thinking block show while
998
+ // on. Never touches the turn, history, or telemetry — purely paint.
999
+ const [showThinking, setShowThinking] = useState(true);
1000
+ const showThinkingRef = useRef(true);
987
1001
  function setShowThinkingBoth(next) {
988
1002
  showThinkingRef.current = next;
989
1003
  setShowThinking(next);
@@ -1019,6 +1033,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1019
1033
  // accounting never breaks the turn
1020
1034
  }
1021
1035
  }
1036
+ // Goal-resume staged while busy (ticket 07): `/goal resume` mid-turn
1037
+ // re-arms the flag for turn-end pickup (see runGoalCommand) — the running
1038
+ // loop usually consumes the re-arm live at its next continuation check,
1039
+ // but a resume that raced the loop's final check (or a failed turn, which
1040
+ // never continues) leaves the goal active with no continuation. The
1041
+ // turn-boundary drain consumes this flag exactly once (see
1042
+ // drainTurnBoundary stage 5). Never set when idle (idle resume submits
1043
+ // directly); cleared on every turn end even when it kicks nothing.
1044
+ const goalResumePendingRef = useRef(false);
1022
1045
  // Usage accumulator (session totals + goal slice, real reports only): the
1023
1046
  // turn's onUsage below and the goal-judge runner share it so judge spend
1024
1047
  // bills exactly like model spend. Every reporting POST accumulates
@@ -1038,6 +1061,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1038
1061
  // the per-POST value, NOT the accumulated total.
1039
1062
  if (updateLoad)
1040
1063
  lastPromptTokensRef.current = u.prompt_tokens;
1064
+ // A real main-loop report just arrived: the load is exact again, so
1065
+ // the estimate latch clears (reset paths set it; summary/judge POSTs
1066
+ // never touch it — same rule as the latch above).
1067
+ if (updateLoad)
1068
+ setLoadEstimatedBoth(false);
1069
+ // Overflow-trigger source: the full last report (total, else parts).
1070
+ // Stashed only for main-loop POSTs — summary/judge spend must not
1071
+ // move the trigger (same rule as the load latch above).
1072
+ if (updateLoad)
1073
+ lastUsageRef.current = u;
1041
1074
  }
1042
1075
  if (u.completion_tokens !== undefined) {
1043
1076
  next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
@@ -1088,7 +1121,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1088
1121
  const [skillRegistry] = useState(() => createSkillRegistry({ projectDir: skillDirs?.projectDir, homeDir: skillDirs?.homeDir }));
1089
1122
  // Skill entries for the slash menu (namespaced `/skill:name` commands):
1090
1123
  // a snapshot of user-invocable skills (name + description), refreshed on
1091
- // mount, /skills, /clear, and /new — never per keystroke (disk I/O stays
1124
+ // mount, /skill, /clear, and /new — never per keystroke (disk I/O stays
1092
1125
  // out of the typing path). Empty until the first refresh lands.
1093
1126
  const [skillMenu, setSkillMenu] = useState([]);
1094
1127
  async function refreshSkillMenu() {
@@ -1135,6 +1168,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1135
1168
  return null;
1136
1169
  }
1137
1170
  }
1171
+ // Approval provenance slot (display-only, ticket 04): the verdict's via
1172
+ // token for the in-flight approval-gated call, held for the matching
1173
+ // onToolActivity commit. Same single-slot discipline as pendingDiffRef —
1174
+ // the scheduler never parallel-batches conflicting writes, and every
1175
+ // execution commits exactly one activity entry in call order. Lifetime ⊆
1176
+ // one turn: set in approve(), consumed-or-cleared by the matching
1177
+ // activity (same name-match predicate as the diff slot), and cleared on
1178
+ // deny/cancel/turn boundaries so a stale token can never attribute to a
1179
+ // later call. Read-only tools never consult approval, so they never set
1180
+ // this (their audit lines stay exactly as before).
1181
+ const pendingViaRef = useRef(null);
1138
1182
  // Tool approval prompt (normal mode, write/edit/bash): the loop waits on
1139
1183
  // the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
1140
1184
  // (LoopCancelledError) instead of denying one call.
@@ -1176,12 +1220,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1176
1220
  // Consumed by onToolActivity into Turn.ms and by the live running line;
1177
1221
  // parallel batches share it (last start wins — approximate, display-only).
1178
1222
  const toolStartRef = useRef(null);
1223
+ // Structured tool identity (ticket 02 sink consumer): FIFO of started
1224
+ // tools in commit order ({toolCallId, name, startedAt}). The loop's
1225
+ // onToolStarted fires before each commit and onToolFinished right after
1226
+ // the matching onToolActivity, so the queue head at activity time IS the
1227
+ // committing call's stable identity — no label parsing. Lifetime ⊆ one
1228
+ // turn like toolStartRef/pendingDiffRef: cleared at turn start and in the
1229
+ // turn-end finally so a cancelled/vetoed start can never leak sideways.
1230
+ const toolIdentityQueueRef = useRef([]);
1179
1231
  // Latest streamed answer text (display bookkeeping only): if the turn
1180
1232
  // FAILS after streaming (rate limits, dead network), the catch path
1181
1233
  // commits this as a marked partial turn so the output never vanishes.
1182
1234
  // History still rolls back (the model never sees it); the transcript
1183
1235
  // keeps what the user already read. Cleared at every turn start.
1184
1236
  const lastPartialRef = useRef("");
1237
+ // Streamed answer text already placed in the transcript this turn.
1238
+ // Multi-POST turns stream inter-tool chatter the loop keeps only in
1239
+ // history — without this the commit (final `reply` only) drops what the
1240
+ // user already read, and an empty final reply commits a blank turn that
1241
+ // reads as vanished output. Drained at tool commits and turn end; reset
1242
+ // at every turn start alongside lastPartialRef.
1243
+ const committedStreamRef = useRef("");
1185
1244
  const [error, setError] = useState(null);
1186
1245
  // Local observability recorder (src/telemetry.ts): one telemetry session
1187
1246
  // per App mount. Best-effort and never throwing; off via ATOM_TELEMETRY=0
@@ -1205,6 +1264,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1205
1264
  // completes. NK stays cumulative; P must NOT use the cumulative total.
1206
1265
  const [contextLoad, setContextLoad] = useState(null);
1207
1266
  const contextLoadRef = useRef(null);
1267
+ // Estimate latch (ticket 07 closes the ticket-06 follow-up): true when the
1268
+ // load behind P% is the chars/token heuristic rather than provider-reported
1269
+ // input tokens (post-compaction / /clear / resume / switch resets, or a
1270
+ // provider that never reports prompt_tokens). False once a main-loop POST
1271
+ // reports prompt_tokens; null when there is no load to qualify. Passed to
1272
+ // the status bar's `loadEstimated` prop — the bar's own heuristic covers
1273
+ // only the never-reported case, this latch covers the reset paths.
1274
+ const [loadEstimated, setLoadEstimated] = useState(null);
1275
+ const loadEstimatedRef = useRef(null);
1208
1276
  // Git identity for the status bar (branch only, no status porcelain):
1209
1277
  // refreshed at turn boundaries (a turn's bash may switch branches), read
1210
1278
  // from render. Null outside git repos — the bar then shows cwd alone.
@@ -1221,10 +1289,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1221
1289
  // parse time to include exclusive cache counters) — the load metric source.
1222
1290
  // Summary-request usage never touches this — only main-loop POSTs do.
1223
1291
  const lastPromptTokensRef = useRef(undefined);
1292
+ // Last main-loop POST's full reported usage (the real-total source for the
1293
+ // overflow trigger below). Summary/judge POSTs never touch this — only
1294
+ // main-loop POSTs do (same rule as lastPromptTokensRef). Reset everywhere
1295
+ // the load latch resets: the old report no longer measures this context.
1296
+ const lastUsageRef = useRef(undefined);
1224
1297
  // Thrash guard: consecutive auto-compactions without the load dropping
1225
1298
  // below threshold. At 3, auto disables for the session (manual still
1226
1299
  // works and resets the counter on success).
1227
1300
  const autoStreakRef = useRef(0);
1301
+ // Per-turn file-diff watermark (ticket 06): index into historyRef.current
1302
+ // up to which committed tool_calls have been collected into the session's
1303
+ // metadata.filediffs record. Replacements (compact/switch/resume/new/
1304
+ // clear) swap the array, so the read site guards a stale watermark into
1305
+ // a rescan — merge dedupes, so records are never lost, only re-scanned.
1306
+ const fileDiffsWatermarkRef = useRef(0);
1228
1307
  const [autoDisabled, setAutoDisabled] = useState(false);
1229
1308
  const autoDisabledRef = useRef(false);
1230
1309
  // /compact typed while busy: focus text ("" = no focus) runs at turn end,
@@ -1262,6 +1341,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1262
1341
  // `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
1263
1342
  // few times per turn (low frequency, and StatusBar legitimately needs them).
1264
1343
  const [streamStore] = useState(() => createStreamStore());
1344
+ // Centralized streaming paint scheduler (render-stability): ONE trailing
1345
+ // timer for the answer draft + thinking lanes. onToken/onThinking push
1346
+ // every partial (activity/stall tracking stays per-token); paints coalesce
1347
+ // to one per DRAFT_THROTTLE_MS window, delivered in a single store update
1348
+ // so both lanes land in the same React render. Flushed on done/turn-end
1349
+ // and on tool transitions, errors, and cancellation (no stale trailing
1350
+ // paint may outlive the state it depicts).
1351
+ const paintSchedulerRef = useRef(null);
1352
+ function paintScheduler() {
1353
+ let ps = paintSchedulerRef.current;
1354
+ if (!ps) {
1355
+ ps = createPaintScheduler({
1356
+ intervalMs: DRAFT_THROTTLE_MS,
1357
+ now,
1358
+ setTimeoutFn,
1359
+ clearTimeoutFn,
1360
+ onFlush: (lanes) => {
1361
+ // Paint path only: the commit carries the byte-exact full text.
1362
+ // One store update notifies LiveTailHost alone — never App.
1363
+ streamStore.set(lanes);
1364
+ },
1365
+ });
1366
+ paintSchedulerRef.current = ps;
1367
+ }
1368
+ return ps;
1369
+ }
1370
+ function flushDraft() {
1371
+ try {
1372
+ paintScheduler().flush();
1373
+ }
1374
+ catch {
1375
+ // ignore (draft stays as-is; the commit carries the full text)
1376
+ }
1377
+ }
1265
1378
  // Thinking channel (onThinking): reasoning text streamed apart from the
1266
1379
  // answer, rendered in its own dim block below. The live value is transient
1267
1380
  // like the draft — cleared on every turn boundary below — but each completed
@@ -1278,7 +1391,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1278
1391
  try {
1279
1392
  // Drop any trailing paint: the commit carries the full text, and a
1280
1393
  // late flush must never resurrect stale reasoning after the clear.
1281
- thinkingThrottleRef.current?.cancel();
1394
+ paintScheduler().cancel("thinking");
1282
1395
  }
1283
1396
  catch {
1284
1397
  // ignore (the store clear below still wins)
@@ -1291,13 +1404,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1291
1404
  function clearThinking() {
1292
1405
  thinkingRef.current = null;
1293
1406
  try {
1294
- thinkingThrottleRef.current?.cancel();
1407
+ paintScheduler().cancel("thinking");
1295
1408
  }
1296
1409
  catch {
1297
1410
  // ignore (the store clear below still wins)
1298
1411
  }
1299
1412
  streamStore.setThinking(null);
1300
1413
  }
1414
+ // Take streamed answer text not yet in the transcript (null when none or
1415
+ // already committed). Marks the take so later drains never duplicate it.
1416
+ function takeUncommittedStream() {
1417
+ const text = lastPartialRef.current;
1418
+ if (typeof text !== "string" || text.trim().length === 0)
1419
+ return null;
1420
+ if (text === committedStreamRef.current)
1421
+ return null;
1422
+ committedStreamRef.current = text;
1423
+ return { role: "assistant", content: text };
1424
+ }
1301
1425
  const [phase, setPhase] = useState("idle");
1302
1426
  const [phaseDetail, setPhaseDetail] = useState("");
1303
1427
  const [toolHint, setToolHint] = useState(null);
@@ -1318,55 +1442,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1318
1442
  setPhaseDetail(detail);
1319
1443
  }
1320
1444
  }
1321
- // Task B smoothness (a): throttled streaming draft. onToken pushes every
1322
- // partial (activity/stall tracking stays per-token); paints coalesce to one
1323
- // per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
1324
- const draftThrottleRef = useRef(null);
1325
- function draftThrottler() {
1326
- let th = draftThrottleRef.current;
1327
- if (!th) {
1328
- th = createDraftThrottler({
1329
- now,
1330
- setTimeoutFn,
1331
- clearTimeoutFn,
1332
- onFlush: (text) => {
1333
- // Paint path only: the commit carries the byte-exact full text.
1334
- // Writing the store notifies LiveTailHost alone — never App.
1335
- streamStore.setDraft(text);
1336
- },
1337
- });
1338
- draftThrottleRef.current = th;
1339
- }
1340
- return th;
1341
- }
1342
- function flushDraft() {
1343
- try {
1344
- draftThrottler().flush();
1345
- }
1346
- catch {
1347
- // ignore (draft stays as-is; the commit carries the full text)
1348
- }
1349
- }
1350
- // Thinking paint coalescing: reasoning chunks arrive at token rate but
1351
- // paint through the same trailing window into the store (producer/consumer
1352
- // symmetry with the draft). thinkingRef stays synchronous per chunk so
1353
- // commitThinking can never lose reasoning to a pending trailing paint.
1354
- const thinkingThrottleRef = useRef(null);
1355
- function thinkingThrottler() {
1356
- let th = thinkingThrottleRef.current;
1357
- if (!th) {
1358
- th = createDraftThrottler({
1359
- now,
1360
- setTimeoutFn,
1361
- clearTimeoutFn,
1362
- onFlush: (text) => {
1363
- streamStore.setThinking(text);
1364
- },
1365
- });
1366
- thinkingThrottleRef.current = th;
1367
- }
1368
- return th;
1369
- }
1445
+ // Production paint path uses paintScheduler() above (single trailing
1446
+ // timer for both lanes). createDraftThrottler further below is retained
1447
+ // for its unit tests and as the documented single-lane primitive.
1370
1448
  // Phase 5: models-list session cache (successful live lists only, keyed
1371
1449
  // by modelsCacheKey). Failures fall back uncached, exactly as before.
1372
1450
  const modelsCacheRef = useRef(new Map());
@@ -1398,7 +1476,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1398
1476
  setLocalSnapBoth(snap);
1399
1477
  }
1400
1478
  // Non-blocking refresh kick: shared in-flight promise dedupes overlapping
1401
- // calls (mount + picker-open + /models), so servers are never probed twice.
1479
+ // calls (mount + picker-open + /model refresh), so servers are never probed twice.
1402
1480
  function kickLocalDiscovery() {
1403
1481
  if (initialModels)
1404
1482
  return;
@@ -1423,7 +1501,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1423
1501
  if (r.ok)
1424
1502
  return null;
1425
1503
  const name = getProvider(id)?.name ?? id;
1426
- return `${name} is unreachable at ${r.baseURL} — start the server, then run /models refresh.`;
1504
+ return `${name} is unreachable at ${r.baseURL} — start the server, then run /model refresh.`;
1427
1505
  }
1428
1506
  // Loopback baseURL for chat/submit paths (env override wins, else the
1429
1507
  // probed snapshot base, else the compiled default).
@@ -1533,7 +1611,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1533
1611
  };
1534
1612
  }, [endpoint, apiKey, initialModels]);
1535
1613
  // Slash-menu skill snapshot once on mount (local disk reads only —
1536
- // zero fetches; refreshed on /skills, /clear, /new below).
1614
+ // zero fetches; refreshed on /skill, /clear, /new below).
1537
1615
  useEffect(() => {
1538
1616
  void refreshSkillMenu();
1539
1617
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1669,8 +1747,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1669
1747
  turnTimerRef.current = null;
1670
1748
  }
1671
1749
  try {
1672
- draftThrottleRef.current?.cancel();
1673
- thinkingThrottleRef.current?.cancel();
1750
+ paintSchedulerRef.current?.cancel();
1674
1751
  }
1675
1752
  catch {
1676
1753
  // ignore
@@ -1863,7 +1940,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1863
1940
  setSelectingRewindScope(false);
1864
1941
  pendingRewindRef.current = null;
1865
1942
  }
1866
- // /skills picker (opencode-style searchable popup): opens on the fresh
1943
+ // /skill picker (opencode-style searchable popup): opens on the fresh
1867
1944
  // registry (names only), filters as you type, loads on Enter. Local disk
1868
1945
  // reads only — zero fetches. Idle-only (history injection mid-turn would
1869
1946
  // break the loop's assistant/tool pairing).
@@ -2056,6 +2133,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2056
2133
  contextLoadRef.current = next;
2057
2134
  setContextLoad(next);
2058
2135
  }
2136
+ function setLoadEstimatedBoth(next) {
2137
+ loadEstimatedRef.current = next;
2138
+ setLoadEstimated(next);
2139
+ }
2059
2140
  function setAutoDisabledBoth(next) {
2060
2141
  autoDisabledRef.current = next;
2061
2142
  setAutoDisabled(next);
@@ -2078,6 +2159,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2078
2159
  // the chars/4 estimate applies until the next report arrives.
2079
2160
  function resetContextLoadToEstimate() {
2080
2161
  lastPromptTokensRef.current = undefined;
2162
+ lastUsageRef.current = undefined;
2163
+ // The estimate applies until the next report arrives: the latch marks
2164
+ // P% estimated so the bar reads `(~P%)`, never an exact fact.
2165
+ setLoadEstimatedBoth(true);
2081
2166
  if (!usageRef.current) {
2082
2167
  setContextLoadBoth(null);
2083
2168
  }
@@ -2363,10 +2448,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2363
2448
  const id = ensureStoreSession();
2364
2449
  if (!id)
2365
2450
  return;
2451
+ // The live checklist rides every store persist (same call as every
2452
+ // completed turn — no new save cadence). Other metadata keys pass
2453
+ // through untouched; only metadata.todos is set (never filediffs).
2454
+ let diskMetadata;
2455
+ try {
2456
+ diskMetadata = getSession(id, authHome)?.metadata;
2457
+ }
2458
+ catch {
2459
+ diskMetadata = undefined;
2460
+ }
2461
+ // Per-turn file diffs (ticket 06): collect the tool_calls committed
2462
+ // since the last persist and merge them into the session-scoped
2463
+ // metadata.filediffs record. This site runs on completed turns only
2464
+ // (failed/cancelled turns roll back and never persist), so failed
2465
+ // work is never recorded. Delta-only scan; a stale watermark after
2466
+ // an array replacement degrades to a rescan, and merge dedupes.
2467
+ const liveHistory = historyRef.current;
2468
+ const diffsStart = fileDiffsWatermarkRef.current <= liveHistory.length
2469
+ ? fileDiffsWatermarkRef.current
2470
+ : 0;
2471
+ const diskRecord = typeof diskMetadata === "object" && diskMetadata !== null && !Array.isArray(diskMetadata)
2472
+ ? diskMetadata
2473
+ : undefined;
2474
+ const mergedDiffs = mergeFileDiffs(readFileDiffs(diskRecord?.[FILE_DIFFS_METADATA_KEY]), collectTurnFileDiffs(liveHistory.slice(diffsStart)));
2475
+ fileDiffsWatermarkRef.current = liveHistory.length;
2366
2476
  updateSession(id, {
2367
2477
  history: historyRef.current,
2368
2478
  turns: turnsRef.current.map((t) => {
2369
- const { diff: _dropped, ...rest } = t;
2479
+ const { diff: _dropped, approvalVia: _viaDropped, ...rest } = t;
2370
2480
  return rest;
2371
2481
  }),
2372
2482
  usageTotals: usageRef.current,
@@ -2375,6 +2485,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2375
2485
  // snapshots this session's goal into its own record first, so a
2376
2486
  // switch back restores it and sessions never leak goals.
2377
2487
  goal: serializeGoalForPersist(goalRef.current),
2488
+ metadata: {
2489
+ ...withSessionTodos(diskMetadata, getTodos()),
2490
+ [FILE_DIFFS_METADATA_KEY]: serializeFileDiffs(mergedDiffs),
2491
+ },
2378
2492
  provider: providerRef.current,
2379
2493
  model: modelRef.current,
2380
2494
  effort: effortRef.current,
@@ -2525,7 +2639,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2525
2639
  goal: goalRef.current,
2526
2640
  history: historyRef.current,
2527
2641
  turns: turnsRef.current.map((t) => {
2528
- const { diff: _dropped, ...rest } = t;
2642
+ const { diff: _dropped, approvalVia: _viaDropped, ...rest } = t;
2529
2643
  return rest;
2530
2644
  }),
2531
2645
  }, authHome);
@@ -2611,7 +2725,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2611
2725
  pushInfo("(nothing to compact)");
2612
2726
  return false;
2613
2727
  }
2614
- const split = splitHistoryForCompaction(historyRef.current);
2728
+ const split = splitHistoryForCompaction(historyRef.current, undefined, modelRef.current);
2615
2729
  if (split.olderTurnCount <= 0 || split.head.length === 0) {
2616
2730
  if (!isAuto)
2617
2731
  pushInfo("(nothing to compact)");
@@ -2736,7 +2850,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2736
2850
  // the same goal without re-exploring; over-budget lists shrink instead
2737
2851
  // of failing compaction (model text + goal block are never cut).
2738
2852
  const goalBlock = formatGoalForCompact(goalRef.current, getTodos().map((t) => ({ content: t.content, status: t.status })));
2739
- const fitted = fitSummaryWithFilesAndGoal(summary, collectTouchedFiles(split.head), goalBlock);
2853
+ // Ticket 06: the session-scoped accumulated record (files from earlier
2854
+ // turns and prior compactions) merges with this head's touches, so
2855
+ // Relevant Files names exactly what the session touched — not just
2856
+ // the head being summarized now. Disk is the source of truth; a
2857
+ // failed read falls back to the head alone (compaction never fails
2858
+ // for a files feed).
2859
+ let recordedDiffs = emptyFileDiffs();
2860
+ try {
2861
+ const compactId = ensureStoreSession();
2862
+ if (compactId) {
2863
+ recordedDiffs = readFileDiffs(getSession(compactId, authHome)?.metadata?.[FILE_DIFFS_METADATA_KEY]);
2864
+ }
2865
+ }
2866
+ catch {
2867
+ // ignore — head touches alone still summarize
2868
+ }
2869
+ const fitted = fitSummaryWithFilesAndGoal(summary, mergeFileDiffs(recordedDiffs, collectTouchedFiles(split.head)), goalBlock);
2740
2870
  const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
2741
2871
  // Replacement: re-wrap so the ledger restarts from the compacted array
2742
2872
  // (the old ledger is discarded with the old array).
@@ -2750,14 +2880,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2750
2880
  pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
2751
2881
  }
2752
2882
  // P% must drop immediately: the old lastPromptTokens reflects the
2753
- // pre-compact context (and its cache counters), so clear it and use
2754
- // the new-history estimate.
2755
- lastPromptTokensRef.current = undefined;
2756
- const newLoad = estimateTokensForChars(historyChars(historyRef.current));
2757
- setContextLoadBoth(newLoad);
2883
+ // pre-compact context (and its cache counters), so the estimate latch
2884
+ // resets onto the new history (marks P% `(~P%)` until next report).
2885
+ resetContextLoadToEstimate();
2758
2886
  if (isAuto) {
2759
2887
  const pct = compactPct();
2760
2888
  const window = contextWindowFor(modelRef.current);
2889
+ const newLoad = contextLoadRef.current ?? 0;
2761
2890
  if (window !== undefined && newLoad / window < pct) {
2762
2891
  autoStreakRef.current = 0;
2763
2892
  }
@@ -2778,22 +2907,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2778
2907
  return false;
2779
2908
  }
2780
2909
  }
2781
- // After a completed main turn: refresh load, reset the streak when below
2782
- // threshold, else auto-compact (known-window models only, unless
2783
- // thrash-disabled). Called while still busy, before the next turn.
2910
+ // After a completed main turn: refresh load, reset the streak when the
2911
+ // real-usage overflow trigger is quiet, else auto-compact (known-window
2912
+ // models with a reported real total at/above the usable limit only,
2913
+ // unless thrash-disabled). Called while still busy, before the next turn.
2784
2914
  async function maybeAutoCompact() {
2785
2915
  const load = refreshContextLoad();
2786
2916
  if (load === null) {
2787
2917
  autoStreakRef.current = 0;
2788
2918
  return;
2789
2919
  }
2790
- // Unknown window → no auto trigger (never invent a window); below
2791
- // threshold streak resets. The manager owns the pct math, so a false
2792
- // there means either case re-check the window for the reset.
2793
- if (!contextManager().needsCompaction(load)) {
2920
+ // Unknown window or no real usage reported → no auto trigger (never
2921
+ // invent a window, never estimate); below the usable limit streak
2922
+ // resets. shouldAutoCompactReal is false for all three, so re-check
2923
+ // the window for the reset.
2924
+ if (!shouldAutoCompactReal(modelRef.current, lastUsageRef.current)) {
2794
2925
  // Distinguish unknown-window (streak untouched — irrelevant) from
2795
- // below-threshold (streak resets). shouldAutoCompact is false for
2796
- // both, so re-check the window for the reset.
2926
+ // below-limit (streak resets).
2797
2927
  if (contextWindowFor(modelRef.current) !== undefined) {
2798
2928
  autoStreakRef.current = 0;
2799
2929
  }
@@ -2846,13 +2976,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2846
2976
  refreshSystemEnv();
2847
2977
  // Restored load is the estimate (no prompt_tokens survived the save);
2848
2978
  // thrash state restarts fresh on resume.
2849
- lastPromptTokensRef.current = undefined;
2850
- if (!usageRef.current) {
2851
- setContextLoadBoth(null);
2852
- }
2853
- else {
2854
- setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
2855
- }
2979
+ resetContextLoadToEstimate();
2856
2980
  autoStreakRef.current = 0;
2857
2981
  setAutoDisabledBoth(false);
2858
2982
  pendingCompactRef.current = null;
@@ -2995,13 +3119,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2995
3119
  if (target.history.length > 0) {
2996
3120
  refreshSystemEnv();
2997
3121
  }
2998
- lastPromptTokensRef.current = undefined;
2999
- if (!usageRef.current) {
3000
- setContextLoadBoth(null);
3001
- }
3002
- else {
3003
- setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
3004
- }
3122
+ // Switched sessions measure a different context: the estimate applies
3123
+ // until the next report (same latch as resume above).
3124
+ resetContextLoadToEstimate();
3005
3125
  autoStreakRef.current = 0;
3006
3126
  setAutoDisabledBoth(false);
3007
3127
  pendingCompactRef.current = null;
@@ -3015,22 +3135,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3015
3135
  content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
3016
3136
  });
3017
3137
  }
3018
- // TODO isolation (mirrors /new): the checklist is process-global memory
3019
- // that is never persisted carrying it across sessions would show the
3020
- // new session the old session's plan, and the agent would act on it.
3021
- // Reset it, loudly when non-empty.
3022
- if (getTodos().length > 0) {
3023
- clearTodos();
3024
- setTodoSnap([]);
3025
- pendingNotices.push({
3026
- role: "tool",
3027
- content: "(/session checklist reset; TODOs are per-conversation and do not cross sessions)",
3028
- });
3029
- }
3030
- else {
3031
- clearTodos();
3032
- setTodoSnap([]);
3033
- }
3138
+ // Todo restore (mirrors the goal restore above): the target's checklist
3139
+ // replaces the live one wholesale (never merged) one session's plan
3140
+ // can never leak into another. The outgoing list was snapshotted into
3141
+ // its own record by persistStoreSession above, so switching back
3142
+ // restores it. Absent/corrupt data lands on an empty list. Restored
3143
+ // through todowriteTool so the live invariants still hold; the record
3144
+ // always replays cleanly because it was valid when saved.
3145
+ clearTodos();
3146
+ const restoredTodos = readSessionTodos(target.metadata);
3147
+ if (restoredTodos.length > 0) {
3148
+ await todowriteTool({ todos: restoredTodos });
3149
+ }
3150
+ setTodoSnap(getTodos());
3034
3151
  // The target's history/turns REPLACE the live arrays wholesale — used
3035
3152
  // whole, never trimmed.
3036
3153
  for (const section of collectStoredTouchedFiles(historyRef.current)) {
@@ -3288,6 +3405,132 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3288
3405
  setSessionTitleBoth(renamed.title);
3289
3406
  pushInfo(`(renamed session to "${renamed.title}")`);
3290
3407
  }
3408
+ // /fork [n]: clone the active session into a brand-new session and switch
3409
+ // to it ("try another approach from here"). Bare forks at the tip; /fork
3410
+ // <n> keeps all but the last n messages (forkSession snaps the cut to a
3411
+ // turn boundary, so assistant/tool pairing never splits). Idle-only: the
3412
+ // wholesale live-array swap would race a running turn.
3413
+ async function runForkCommand(raw) {
3414
+ const arg = raw.trim() === "/fork" ? "" : raw.trim().slice("/fork".length).trim();
3415
+ let drop = 0;
3416
+ if (arg !== "") {
3417
+ if (!/^\d+$/.test(arg)) {
3418
+ pushInfo(FORK_USAGE);
3419
+ return;
3420
+ }
3421
+ drop = Number(arg);
3422
+ }
3423
+ let id = null;
3424
+ try {
3425
+ id = ensureStoreSession();
3426
+ }
3427
+ catch {
3428
+ id = null;
3429
+ }
3430
+ if (!id) {
3431
+ pushInfo("(fork failed — session store unavailable; staying put)");
3432
+ return;
3433
+ }
3434
+ // Snapshot live state first so unpersisted turns ride into the fork;
3435
+ // forkSession only reads the disk record.
3436
+ persistStoreSession();
3437
+ const keep = drop === 0 ? undefined : Math.max(0, historyRef.current.length - drop);
3438
+ let forked = null;
3439
+ try {
3440
+ forked = forkSession(id, keep, authHome);
3441
+ }
3442
+ catch {
3443
+ forked = null;
3444
+ }
3445
+ if (!forked) {
3446
+ pushInfo("(fork failed — source session unreadable; staying put)");
3447
+ return;
3448
+ }
3449
+ await switchToSession(forked.id);
3450
+ pushInfo(`(forked into "${forked.title}")`);
3451
+ }
3452
+ // /revert [n]: undo to a checkpoint via revertSessionToCheckpoint —
3453
+ // restores the session's conversation AND files, then swaps the live
3454
+ // arrays wholesale (switch precedent). Bare reverts to the latest
3455
+ // checkpoint; /revert <n> goes n checkpoints back. Idle-only: the swap
3456
+ // would race a running turn. Checkpoints are live-lineage (in-memory,
3457
+ // dropped by compact/switch like /rewind's) — nothing older is offered.
3458
+ async function runRevertCommand(raw) {
3459
+ const arg = raw.trim() === "/revert" ? "" : raw.trim().slice("/revert".length).trim();
3460
+ let back = 0;
3461
+ if (arg !== "") {
3462
+ if (!/^\d+$/.test(arg)) {
3463
+ pushInfo(REVERT_USAGE);
3464
+ return;
3465
+ }
3466
+ back = Number(arg);
3467
+ }
3468
+ const cps = listCheckpoints();
3469
+ if (cps.length === 0) {
3470
+ pushInfo("(no snapshots recorded — every write/edit auto-snapshots; nothing to revert)");
3471
+ return;
3472
+ }
3473
+ if (back >= cps.length) {
3474
+ pushInfo(`(only ${cps.length} checkpoint(s) — nothing was changed)`);
3475
+ return;
3476
+ }
3477
+ const cp = cps[cps.length - 1 - back];
3478
+ let id = null;
3479
+ try {
3480
+ id = ensureStoreSession();
3481
+ }
3482
+ catch {
3483
+ id = null;
3484
+ }
3485
+ if (!id) {
3486
+ pushInfo("(revert failed — session store unavailable; nothing was changed)");
3487
+ return;
3488
+ }
3489
+ // Snapshot live state first: the revert reads the disk record, so
3490
+ // unpersisted turns must land there before the cut.
3491
+ persistStoreSession();
3492
+ let result;
3493
+ try {
3494
+ result = await revertSessionToCheckpoint(id, cp.id, authHome);
3495
+ }
3496
+ catch {
3497
+ result = { ok: false, error: "revert failed unexpectedly — session left exactly as it was" };
3498
+ }
3499
+ if (!result.ok) {
3500
+ pushInfo(`(${result.error})`);
3501
+ return;
3502
+ }
3503
+ // Wholesale live swap: the persisted record is truth (goal/todos live
3504
+ // on unchanged — the revert only rewrote history+turns).
3505
+ historyRef.current = trackHistory(result.session.history.length > 0
3506
+ ? [...result.session.history]
3507
+ : [{ role: "system", content: withEnvBlock(systemPrompt) }]);
3508
+ setTurnsBoth(result.session.turns);
3509
+ // Restored bytes invalidate stale-read fingerprints (runRewind
3510
+ // precedent): forget them so later edits re-capture instead of
3511
+ // false-refusing.
3512
+ for (const f of cp.files) {
3513
+ try {
3514
+ forgetReadFingerprint(f.abs);
3515
+ }
3516
+ catch {
3517
+ // ignore — fingerprint refresh never breaks a revert
3518
+ }
3519
+ }
3520
+ // Same remount + load refresh as /clear and /resume: the cut tail
3521
+ // leaves the test frame, and the old load no longer measures this
3522
+ // context.
3523
+ setScrollEndBoth(null);
3524
+ setClearGen((g) => g + 1);
3525
+ refreshContextLoad();
3526
+ lastPromptTokensRef.current = undefined;
3527
+ lastUsageRef.current = undefined;
3528
+ // The cut tail leaves the test frame: the refreshed load above was built
3529
+ // on the pre-cut report, so the estimate latch marks it until next turn.
3530
+ setLoadEstimatedBoth(true);
3531
+ pushInfo(result.message);
3532
+ persistSession();
3533
+ }
3291
3534
  // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
3292
3535
  // only state — safe while busy (never touches the turn, like /queue).
3293
3536
  // Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
@@ -3365,6 +3608,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3365
3608
  setGoalBoth({ ...g, active: true });
3366
3609
  persistSession();
3367
3610
  if (busyRef.current) {
3611
+ // Re-arm for turn-end pickup: the running loop reads the live flag
3612
+ // (usually consuming this immediately), and the turn-boundary drain
3613
+ // kicks one continuation turn when the ended turn left it stranded
3614
+ // (see drainTurnBoundary stage 5). Staged only on a real re-arm —
3615
+ // absent/already-active goals return above with no flag.
3616
+ goalResumePendingRef.current = true;
3368
3617
  pushInfo("(goal resumes when the current turn ends — no new turn started while busy)");
3369
3618
  return;
3370
3619
  }
@@ -3374,16 +3623,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3374
3623
  pushInfo(goalSetNotice(cmd.objective, goalRef.current));
3375
3624
  setGoalBoth({ objective: cmd.objective, active: true, stats: emptyGoalStats() });
3376
3625
  persistSession();
3626
+ if (busyRef.current) {
3627
+ // Mid-turn set replaces the live goal quietly (pre-existing
3628
+ // behavior): the running loop reads the live goal at its
3629
+ // continuation checks. No turn is ever injected while busy.
3630
+ pushInfo("(goal set — the running turn picks it up; nothing new started while busy)");
3631
+ return;
3632
+ }
3633
+ // A goal is just like a normal message: setting it starts a turn with
3634
+ // the objective as the user message, so the live-goal loop engages
3635
+ // (resume already kicks this way; set was the quiet outlier).
3636
+ void submit(cmd.objective);
3637
+ }
3638
+ // runModelsCommand (the `/model refresh` backend): reports the last
3639
+ // snapshot (kicking a first probe when discovery never ran) on a bare
3640
+ // call; `refresh` re-probes all three runtimes first. Results merge into
3641
+ // the models cache, so the /model picker serves them with no extra
3642
+ // fetches — one dim summary line, never transcript spam.
3643
+ // Unified /model picker open: unfiltered with the highlight on the
3644
+ // current model, or pre-filtered when the command carried text
3645
+ // (/model <text>). Active provider's section first, so same-provider
3646
+ // rises stay index-stable when other keyed providers add sections below.
3647
+ // Late lifecycle: if discovery never ran (slow/no startup probe),
3648
+ // kick it now so local sections fill in behind the open picker.
3649
+ function openModelPicker(initialFilter) {
3650
+ if (localSnapRef.current.version === 0)
3651
+ kickLocalDiscovery();
3652
+ setModelFilterBoth(initialFilter);
3653
+ const entries = buildModelEntries();
3654
+ const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
3655
+ setSelIndexBoth(Math.max(0, at));
3656
+ setSelecting(true);
3657
+ setSelectingEffort(false);
3658
+ setSelectingProvider(false);
3659
+ setKeyPromptBoth(null);
3660
+ setBaseURLPromptBoth(null);
3377
3661
  }
3378
- // /models: local-discovery status + refresh. Bare `/models` reports the
3379
- // last snapshot (kicking a first probe when discovery never ran);
3380
- // `/models refresh` re-probes all three runtimes, then reports. Results
3381
- // merge into the models cache, so the /model picker serves them with no
3382
- // extra fetches — one dim summary line, never transcript spam.
3383
3662
  async function runModelsCommand(arg) {
3384
3663
  const a = arg.trim().toLowerCase();
3385
3664
  if (a !== "" && a !== "refresh") {
3386
- pushInfo("usage: /models [refresh] — probe local model servers (Ollama, LM Studio, llama.cpp).");
3665
+ pushInfo("usage: /model [filter|refresh] — pick a model, or probe local model servers (Ollama, LM Studio, llama.cpp).");
3387
3666
  return;
3388
3667
  }
3389
3668
  // Manual Kilo refresh: clear the gateway catalog cache and re-fetch.
@@ -3445,6 +3724,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3445
3724
  setClearGen((g) => g + 1);
3446
3725
  setError(null);
3447
3726
  streamStore.setDraft(null);
3727
+ lastPartialRef.current = "";
3728
+ committedStreamRef.current = "";
3448
3729
  clearThinking();
3449
3730
  setToolHint(null);
3450
3731
  setPhaseBoth("idle", "");
@@ -3473,7 +3754,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3473
3754
  }
3474
3755
  // autoDisabled stays for the session (thrash guard is session-wide).
3475
3756
  lastPromptTokensRef.current = undefined;
3757
+ lastUsageRef.current = undefined;
3476
3758
  setContextLoadBoth(null);
3759
+ // No load left to qualify: the latch clears with it.
3760
+ setLoadEstimatedBoth(null);
3477
3761
  autoStreakRef.current = 0;
3478
3762
  pendingCompactRef.current = null;
3479
3763
  telemetry.recordEvent("clear", "conversation cleared (token totals kept)");
@@ -3536,6 +3820,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3536
3820
  setClearGen((g) => g + 1);
3537
3821
  setError(null);
3538
3822
  streamStore.setDraft(null);
3823
+ lastPartialRef.current = "";
3824
+ committedStreamRef.current = "";
3539
3825
  clearThinking();
3540
3826
  setToolHint(null);
3541
3827
  setPhaseBoth("idle", "");
@@ -3545,7 +3831,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3545
3831
  // conversation + counters reset.
3546
3832
  setUsageBoth(null);
3547
3833
  lastPromptTokensRef.current = undefined;
3834
+ lastUsageRef.current = undefined;
3548
3835
  setContextLoadBoth(null);
3836
+ // Fresh conversation with no usage and no load: nothing to qualify.
3837
+ setLoadEstimatedBoth(null);
3549
3838
  // Fresh conversation: the session checklist restarts too.
3550
3839
  clearTodos();
3551
3840
  setTodoSnap([]);
@@ -3573,22 +3862,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3573
3862
  void runCompactCommand("");
3574
3863
  return;
3575
3864
  case "/model": {
3576
- // Unified picker opens unfiltered with the highlight on the current
3577
- // model (active provider's section first, so same-provider rises
3578
- // stay index-stable when other keyed providers add sections below).
3579
- // Late lifecycle: if discovery never ran (slow/no startup probe),
3580
- // kick it now so local sections fill in behind the open picker.
3581
- if (localSnapRef.current.version === 0)
3582
- kickLocalDiscovery();
3583
- setModelFilterBoth("");
3584
- const entries = buildModelEntries();
3585
- const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
3586
- setSelIndexBoth(Math.max(0, at));
3587
- setSelecting(true);
3588
- setSelectingEffort(false);
3589
- setSelectingProvider(false);
3590
- setKeyPromptBoth(null);
3591
- setBaseURLPromptBoth(null);
3865
+ openModelPicker("");
3592
3866
  return;
3593
3867
  }
3594
3868
  case "/provider":
@@ -3605,13 +3879,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3605
3879
  case "/tools":
3606
3880
  pushInfo(toolsListText());
3607
3881
  return;
3608
- case "/skills":
3609
- // Searchable picker (names only, type to filter, Enter loads).
3610
- // Local reads only — zero fetches, like the model picker.
3611
- openSkillPicker();
3612
- return;
3613
3882
  case "/skill":
3614
- pushInfo(SKILL_USAGE);
3883
+ // Unified skill command: bare opens the picker (what /skills did).
3884
+ openSkillPicker();
3615
3885
  return;
3616
3886
  case "/context":
3617
3887
  pushInfo(buildContextText());
@@ -3678,6 +3948,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3678
3948
  case "/session":
3679
3949
  openSessionPicker("");
3680
3950
  return;
3951
+ case "/fork":
3952
+ // Bare exact match (slash-menu Enter on the highlighted name):
3953
+ // full-conversation fork — the typed-args form is preserved by the
3954
+ // menu branch and the submit prefix route below.
3955
+ void runForkCommand("/fork");
3956
+ return;
3957
+ case "/revert":
3958
+ // Bare exact match: revert to the latest checkpoint — the typed
3959
+ // form is preserved by the menu branch and the submit prefix
3960
+ // route below.
3961
+ void runRevertCommand("/revert");
3962
+ return;
3681
3963
  case "/rename":
3682
3964
  // Bare exact match (slash-menu Enter on the highlighted name):
3683
3965
  // usage — the typed-args form is preserved by the menu branch and
@@ -3727,14 +4009,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3727
4009
  }
3728
4010
  }
3729
4011
  }
3730
- // approve hook for runAgenticLoop: scoped rules first (deny refuses as a
4012
+ // approve hook for runAgenticLoop: one verdict per call (deny refuses as a
3731
4013
  // standard "no" — pre-execution, model-visible denial result, audit line
3732
4014
  // via the untouched onToolActivity path — and wins over everything below,
3733
- // including plan mode); plan mode second (mutations flow to the execute
3734
- // gate, which refuses with a replan note — never a prompt here, so
3735
- // allow/yolo/trust/always/skill grants cannot punch through); then allow,
3736
- // yolo, session trust (/trust or [t]), and always-allowed tools run without
3737
- // prompting; otherwise an Ink y/a/t/n prompt resolves the promise.
4015
+ // including plan mode); plan mode never prompts — its mutations flow to
4016
+ // the execute gate, which refuses with a replan note (allow/yolo/trust/
4017
+ // always/skill grants cannot punch through); then allow, yolo, session
4018
+ // trust (/trust or [t]), and always-allowed tools run without prompting;
4019
+ // otherwise an Ink y/a/t/n prompt resolves the promise.
4020
+ // The verdict (decision + provenance + preview) is computed ONCE here via
4021
+ // decideApproval: the modal renders verdict.preview and the stashed
4022
+ // description (never recomputing either — no filesystem reads in render),
4023
+ // and execution consumes the recorded via without re-deciding. The only
4024
+ // other mode consultation is the execute gate below (guardedExecute),
4025
+ // which enforces plan-mode at execution time — it consults no rules and
4026
+ // prompts nothing, so approval still decides exactly once.
3738
4027
  // The promise also rejects with LoopCancelledError when the turn is
3739
4028
  // cancelled (Ctrl+C aborts the controller), so a cancel unblocks the loop
3740
4029
  // as a whole-turn cancel — never as a one-call denial.
@@ -3760,38 +4049,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3760
4049
  : null;
3761
4050
  pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
3762
4051
  }
3763
- // Policy layer owns the decision order (deny → plan → allow → yolo →
3764
- // trust → always → skill grants → prompt); this function owns cancel
3765
- // handling and the interactive prompt plumbing around it.
3766
- const outcome = decidePolicy(name, args, {
4052
+ // One verdict for this call (deny → plan → allow → yolo → trust →
4053
+ // always → skill grants → prompt, owned by the policy layer). The
4054
+ // modal description is computed here too (same single pass — the label
4055
+ // may resolve symlinks, so render must not recompute it).
4056
+ const verdict = decideApproval(name, args, {
3767
4057
  mode: modeRef.current,
3768
4058
  trustAll: trustAllRef.current,
3769
4059
  rules: rulesRef.current,
3770
4060
  alwaysAllowed: alwaysAllowedRef.current,
3771
4061
  skillGrants: skillGrantsRef.current,
3772
4062
  approvalGated: needsApproval(name),
3773
- });
3774
- if (outcome.kind === "deny") {
4063
+ }, stagedDiff);
4064
+ // Provenance for the transcript: approval-gated calls record their via
4065
+ // for the matching activity commit (deny clears immediately below —
4066
+ // its ↳ line already names the denial — and prompt-denials clear in
4067
+ // resolveApproval, so only executed calls ever render it).
4068
+ if (needsApproval(name))
4069
+ pendingViaRef.current = { name, via: verdict.via };
4070
+ if (verdict.decision === "deny") {
3775
4071
  pendingDiffRef.current = null;
4072
+ pendingViaRef.current = null;
3776
4073
  return "no";
3777
4074
  }
3778
- if (outcome.kind === "allow")
4075
+ if (verdict.decision === "allow")
3779
4076
  return "once";
4077
+ const description = describeToolCall(name, args);
3780
4078
  const signal = turnCancelRef.current?.signal ?? null;
3781
4079
  if (signal?.aborted) {
3782
4080
  pendingDiffRef.current = null;
4081
+ pendingViaRef.current = null;
3783
4082
  throw new LoopCancelledError();
3784
4083
  }
3785
4084
  return new Promise((resolve, reject) => {
3786
4085
  approvalResolveRef.current = { resolve, reject };
3787
4086
  setApproveIndexBoth(0);
3788
- setPendingApproval({ name, args, diff: stagedDiff });
4087
+ setPendingApproval({ name, args, diff: verdict.preview, description });
3789
4088
  if (signal) {
3790
4089
  const onAbort = () => {
3791
4090
  const h = approvalResolveRef.current;
3792
4091
  approvalResolveRef.current = null;
3793
4092
  setPendingApproval(null);
3794
4093
  pendingDiffRef.current = null;
4094
+ pendingViaRef.current = null;
3795
4095
  h?.reject(new LoopCancelledError());
3796
4096
  };
3797
4097
  if (signal.aborted)
@@ -3805,8 +4105,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3805
4105
  if (decision === "always" && pendingApproval) {
3806
4106
  alwaysAllowedRef.current.add(pendingApproval.name);
3807
4107
  }
3808
- if (decision === "no")
4108
+ if (decision === "no") {
3809
4109
  pendingDiffRef.current = null;
4110
+ pendingViaRef.current = null;
4111
+ }
3810
4112
  const h = approvalResolveRef.current;
3811
4113
  approvalResolveRef.current = null;
3812
4114
  setPendingApproval(null);
@@ -3879,6 +4181,175 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3879
4181
  setAskCustomBoth("");
3880
4182
  h?.reject(new Error("question cancelled by user"));
3881
4183
  }
4184
+ // Turn-boundary drain (ticket 07): the ONE explicitly ordered routine for
4185
+ // everything pending at a turn boundary. Called once per turn from
4186
+ // submit()'s turn-end finally (the single turn boundary — the compact
4187
+ // drains are extracted from submit's success path (try) and failure path
4188
+ // (catch) here); submit-time handlers only STAGE pending state (glue that
4189
+ // stays at those call sites, documented where it stages: /compact-while-
4190
+ // busy sets pendingCompactRef, busy follow-ups enqueue, busy /steer sets
4191
+ // steerRef, busy /goal resume re-arms plus stages goalResumePendingRef).
4192
+ //
4193
+ // Effective order (verified against the pre-ticket code + tests — every
4194
+ // stage keeps today's semantics; do not reorder):
4195
+ // 1. compact — manual /compact first (pending flag; a /compact arriving
4196
+ // mid-compaction re-arms the flag → nested second drain on clean
4197
+ // turns), else auto-compact on clean turns only. Failed/cancelled
4198
+ // turns drain a pending manual only (single, guarded); auto never
4199
+ // fires there (load is meaningless for a rolled-back turn — just
4200
+ // refresh it). Runs while still busy, so a /compact now re-arms the
4201
+ // pending flag instead of running a concurrent compaction.
4202
+ // 2. turn teardown (pinned here, not a drain stage: the goal work-time
4203
+ // accrual must follow compaction — both old paths ran compact-then-
4204
+ // accrue — and the busy reset plus modal/slot cleanup must precede
4205
+ // any chained submit so the next turn starts clean).
4206
+ // 3. steer — a steer stranded by a failed/cancelled turn rejoins the
4207
+ // queue FRONT (all outcomes; never dropped, never run inline).
4208
+ // 4. queue — the next queued follow-up auto-sends on non-cancelled turns
4209
+ // only (cancelled turns keep the queue visible but never auto-send;
4210
+ // failed turns auto-send like clean ones — the gate is the
4211
+ // cancellation latch, same `!turnCancelledRef` as before). The
4212
+ // chained submit re-enters submit() → a clean turn → this drain
4213
+ // again at its end.
4214
+ // 5. goal-resume — a staged busy-resume whose goal is still active with
4215
+ // no turn just chained starts exactly one continuation turn (covers
4216
+ // failed turns, which never continue inside the loop, and resumes
4217
+ // that raced the loop's final continuation check). A chained queue
4218
+ // turn consumes the stage instead (its loop reads the live flag — no
4219
+ // second turn starts); cancelled turns never kick (the loop paused
4220
+ // the goal, and the queue stays put for the user).
4221
+ // Re-entrancy: chained submits re-enter this routine per turn; every flag
4222
+ // is consumed (nulled) before the await that acts on it, so a nested
4223
+ // drain can never double-run a stage.
4224
+ async function drainTurnBoundary(outcome, goalWorkStartMs, goalWorkObjective) {
4225
+ // STAGE 1 — compact (verbatim from the old try/catch drains).
4226
+ if (outcome === "clean") {
4227
+ // Drain boundary (still busy, never mid-turn): pending manual /compact
4228
+ // first (it resets the thrash counter), else auto-compact when the
4229
+ // load is over threshold. Compaction persists via the normal save path.
4230
+ if (pendingCompactRef.current !== null) {
4231
+ const focus = pendingCompactRef.current;
4232
+ pendingCompactRef.current = null;
4233
+ await doCompact(focus, false);
4234
+ // A /compact that arrived during the compaction above drains now.
4235
+ if (pendingCompactRef.current !== null) {
4236
+ const focus2 = pendingCompactRef.current;
4237
+ pendingCompactRef.current = null;
4238
+ await doCompact(focus2, false);
4239
+ }
4240
+ }
4241
+ else {
4242
+ await maybeAutoCompact();
4243
+ if (pendingCompactRef.current !== null) {
4244
+ const focus = pendingCompactRef.current;
4245
+ pendingCompactRef.current = null;
4246
+ await doCompact(focus, false);
4247
+ }
4248
+ }
4249
+ }
4250
+ else {
4251
+ // Turn-end drain even after failure/rollback: pending manual still
4252
+ // runs (it applies to the surviving history); auto never fires here
4253
+ // (load is meaningless for a rolled-back turn — just refresh it).
4254
+ if (pendingCompactRef.current !== null) {
4255
+ const focus = pendingCompactRef.current;
4256
+ pendingCompactRef.current = null;
4257
+ try {
4258
+ await doCompact(focus, false);
4259
+ }
4260
+ catch {
4261
+ // doCompact never throws (it reports inline), but stay safe.
4262
+ }
4263
+ }
4264
+ else {
4265
+ refreshContextLoad();
4266
+ }
4267
+ }
4268
+ // STAGE 2 — turn teardown (verbatim from the old finally, position
4269
+ // pinned: accrual after compaction, busy reset before any chained turn).
4270
+ // Goal work time (ticket 02): this submit's wall clock accrues once,
4271
+ // for every outcome (success, failure, and cancel all did work), when
4272
+ // the same goal is still live. A mid-turn replacement keeps its own
4273
+ // stats — we accrue only while the objective still matches.
4274
+ try {
4275
+ if (goalWorkStartMs !== null &&
4276
+ goalRef.current !== null &&
4277
+ goalRef.current.objective === goalWorkObjective) {
4278
+ const workedMs = Math.max(0, Date.now() - goalWorkStartMs);
4279
+ patchGoalStats((s) => ({ ...s, workMs: s.workMs + workedMs }));
4280
+ }
4281
+ }
4282
+ catch {
4283
+ // accounting never breaks turn teardown
4284
+ }
4285
+ turnCancelRef.current = null;
4286
+ approvalResolveRef.current = null;
4287
+ setPendingApproval(null);
4288
+ // Safety net: the slot is normally consumed by onToolActivity or
4289
+ // cleared on deny/cancel — never let it cross a turn boundary.
4290
+ // Same for the structured-identity queue (cancelled/vetoed starts)
4291
+ // and the provenance slot (same lifetime as the diff slot).
4292
+ pendingDiffRef.current = null;
4293
+ pendingViaRef.current = null;
4294
+ toolIdentityQueueRef.current = [];
4295
+ askResolveRef.current = null;
4296
+ setPendingQuestion(null);
4297
+ setAskCustomBoth("");
4298
+ // Turn-scoped skill grants expire here: armed-while-idle and auto
4299
+ // skills cover exactly the turn that just ended (success, failure,
4300
+ // or cancel) — the next user message starts clean (ticket 06).
4301
+ skillGrantsRef.current = new Set();
4302
+ busyRef.current = false;
4303
+ setBusy(false);
4304
+ refreshGitInfo();
4305
+ try {
4306
+ paintSchedulerRef.current?.cancel();
4307
+ }
4308
+ catch {
4309
+ // ignore
4310
+ }
4311
+ streamStore.setDraft(null);
4312
+ clearThinking();
4313
+ setToolHint(null);
4314
+ clearTurnTimer();
4315
+ setStalledBoth(false);
4316
+ setElapsedSecs(0);
4317
+ setPhaseBoth("idle", "");
4318
+ // STAGE 3 — steer (verbatim from the old finally): a steer stranded
4319
+ // by a failed/cancelled turn rejoins the queue front — the thought is
4320
+ // preserved, the user decides when it runs.
4321
+ const stranded = steerRef.current;
4322
+ if (stranded) {
4323
+ steerRef.current = null;
4324
+ setSteerPending(null);
4325
+ setQueueBoth([stranded, ...queueRef.current]);
4326
+ }
4327
+ // STAGE 4 — queue (verbatim from the old finally): a clean turn
4328
+ // auto-sends the next queued follow-up (chaining while the queue is
4329
+ // non-empty); a cancelled turn keeps its queue visible but never
4330
+ // auto-sends. Runs after the busy reset above so the chained submit
4331
+ // enters a clean turn.
4332
+ if (!turnCancelledRef.current && queueRef.current.length > 0) {
4333
+ const next = queueRef.current[0];
4334
+ setQueueBoth(queueRef.current.slice(1));
4335
+ void submit(next);
4336
+ }
4337
+ // STAGE 5 — goal-resume: consume the staged busy-resume (always —
4338
+ // even when it kicks nothing, so the flag never leaks across turns),
4339
+ // then kick exactly one continuation turn when the ended turn left the
4340
+ // goal active without chaining (failed turns and raced resumes — the
4341
+ // running loop consumes live re-arms itself, and a chained queue turn
4342
+ // above already carries the live goal). Cancelled turns never kick.
4343
+ const resumeStaged = goalResumePendingRef.current;
4344
+ goalResumePendingRef.current = false;
4345
+ if (resumeStaged &&
4346
+ outcome !== "cancelled" &&
4347
+ !busyRef.current &&
4348
+ goalRef.current !== null &&
4349
+ goalRef.current.active === true) {
4350
+ void submit(goalFollowUp(goalRef.current.objective));
4351
+ }
4352
+ }
3882
4353
  // Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
3883
4354
  // above; each `SUBMIT STAGE n/3` marker below names its stage plus its
3884
4355
  // rollback-scope rule). Local "/" routing precedes the pipeline: exact
@@ -3989,11 +4460,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3989
4460
  runRulesCommand(text);
3990
4461
  return;
3991
4462
  }
3992
- // /models takes an optional subcommand (/models refresh), like the
3993
- // /allow family SLASH_NAMES only holds exact commands.
4463
+ // /model takes an optional filter or subcommand: bare opens the picker,
4464
+ // `/model refresh` re-probes local servers (and the Kilo catalog when
4465
+ // Kilo is active), `/model <text>` opens the picker pre-filtered.
4466
+ // SLASH_NAMES only holds the exact command.
4467
+ if (text === "/model" || text.startsWith("/model ")) {
4468
+ const arg = text === "/model" ? "" : text.slice("/model ".length).trim();
4469
+ if (arg.toLowerCase() === "refresh") {
4470
+ void runModelsCommand("refresh");
4471
+ return;
4472
+ }
4473
+ openModelPicker(arg);
4474
+ return;
4475
+ }
4476
+ // Retired: /models merged into /model (see above). Explicit branch so
4477
+ // the input explains instead of hitting skill lookup — the name stays
4478
+ // reserved against extension shadowing.
3994
4479
  if (text === "/models" || text.startsWith("/models ")) {
3995
- const arg = text === "/models" ? "" : text.slice("/models ".length);
3996
- void runModelsCommand(arg);
4480
+ pushInfo("(merged use /model to pick, /model refresh to re-probe local servers)");
3997
4481
  return;
3998
4482
  }
3999
4483
  // /session takes an optional initial filter ("/session auth" opens the
@@ -4003,6 +4487,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4003
4487
  openSessionPicker(initial);
4004
4488
  return;
4005
4489
  }
4490
+ // /fork takes an optional drop count ("/fork 5" drops the last 5
4491
+ // messages first) — SLASH_NAMES only holds the exact command. Idle-only
4492
+ // like /session: the live-array swap would race a running turn, and the
4493
+ // busy guard above already drops other "/" input while busy.
4494
+ if (text === "/fork" || text.startsWith("/fork ")) {
4495
+ await runForkCommand(text);
4496
+ return;
4497
+ }
4498
+ // /revert takes an optional checkpoint index ("/revert 1" goes one
4499
+ // checkpoint back) — SLASH_NAMES only holds the exact command.
4500
+ // Idle-only like /fork: the live-array swap would race a running turn.
4501
+ if (text === "/revert" || text.startsWith("/revert ")) {
4502
+ await runRevertCommand(text);
4503
+ return;
4504
+ }
4006
4505
  // Exact full-command + Enter runs it. A single-token "/name" not in
4007
4506
  // SLASH_NAMES resolves through the skill registry (ticket 03, legacy
4008
4507
  // form — the namespaced `/skill:name` below is canonical); anything
@@ -4016,13 +4515,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4016
4515
  runSlashCommand(text);
4017
4516
  return;
4018
4517
  }
4019
- // Namespaced skill invocation: `/skill:name` (bare `/skill` shows usage
4020
- // via the registry path above). Resolves through the same registry as
4021
- // the legacy `/name` form and the slash menu.
4518
+ // Namespaced skill invocation: `/skill:name` (bare `/skill` opens the
4519
+ // picker via the registry path above; `/skill <name>` below invokes).
4520
+ // Resolves through the same registry as the legacy `/name` form and
4521
+ // the slash menu.
4022
4522
  if (text === "/skill" || text === "/skill:") {
4023
4523
  pushInfo(SKILL_USAGE);
4024
4524
  return;
4025
4525
  }
4526
+ // Space form for the unified command: `/skill deploy` invokes exactly
4527
+ // like `/skill:deploy`.
4528
+ const spacedSkill = /^\/skill\s+([A-Za-z0-9_-]+)\s*$/.exec(text)?.[1];
4529
+ if (spacedSkill !== undefined) {
4530
+ void invokeSkillByName(spacedSkill);
4531
+ return;
4532
+ }
4533
+ // Retired: /skills merged into /skill (bare opens the picker).
4534
+ // Explicit branch so the input explains instead of hitting skill
4535
+ // lookup — the name stays reserved against extension shadowing.
4536
+ if (text === "/skills" || text.startsWith("/skills ")) {
4537
+ pushInfo("(merged — /skill lists and picks, /skill:name invokes)");
4538
+ return;
4539
+ }
4026
4540
  const namespaced = /^\/skill:([A-Za-z0-9_-]+)$/.exec(text)?.[1];
4027
4541
  if (namespaced !== undefined) {
4028
4542
  void invokeSkillByName(namespaced);
@@ -4069,8 +4583,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4069
4583
  // (loaded while idle) must survive into the turn it was armed for.
4070
4584
  // Expiry happens in the turn-end finally below, plus /clear + /new.
4071
4585
  try {
4072
- draftThrottler().reset();
4073
- thinkingThrottler().reset();
4586
+ paintScheduler().reset();
4074
4587
  }
4075
4588
  catch {
4076
4589
  // ignore (first token still paints; at worst one window late)
@@ -4082,11 +4595,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4082
4595
  startTurnTimer();
4083
4596
  // Display-only tool clock: no tool is running at turn start, so any
4084
4597
  // stale timestamp from a previous turn must not leak into this one.
4598
+ // Same for the structured-identity queue (a cancelled turn's unconsumed
4599
+ // starts must never attribute to this turn).
4085
4600
  toolStartRef.current = null;
4601
+ toolIdentityQueueRef.current = [];
4086
4602
  // Same for the transcript-diff slot: a previous turn's unconsumed
4087
4603
  // preview (cancelled mid-execution) must never attach to this turn.
4604
+ // Same for the provenance slot (same lifetime, same reason).
4088
4605
  pendingDiffRef.current = null;
4606
+ pendingViaRef.current = null;
4089
4607
  lastPartialRef.current = "";
4608
+ committedStreamRef.current = "";
4090
4609
  refreshGitInfo();
4091
4610
  // SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
4092
4611
  // survives failure). Refresh the pinned env block ONCE per turn (not per
@@ -4104,6 +4623,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4104
4623
  // catch). Cancellation (LoopCancelledError) shares the same splice
4105
4624
  // contract.
4106
4625
  const rollbackTo = historyRef.current.length;
4626
+ // Turn-boundary outcome glue (ticket 07): the try end marks clean, the
4627
+ // catch marks failed/cancelled — the turn-end finally passes it to the
4628
+ // single drain routine (see drainTurnBoundary). Dead default is the most
4629
+ // conservative ("cancelled" never auto-sends); every path below
4630
+ // overwrites it before the finally reads it.
4631
+ let turnOutcome = "cancelled";
4107
4632
  // Goal work time (ticket 02): wall clock for this submit accrues to the
4108
4633
  // live goal in the turn finally (all outcomes — success, failure, and
4109
4634
  // cancel all did work). Pinned to the starting objective so a mid-turn
@@ -4218,6 +4743,37 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4218
4743
  // Local observability sink: the loop reports completed model/tool
4219
4744
  // calls (iterations, durations, usage) into the open turn trace.
4220
4745
  telemetry: telemetrySink,
4746
+ // Structured tool identity (ticket 02 sink consumer): every tool
4747
+ // start/finish arrives here with its stable toolCallId + name, in
4748
+ // commit order. onToolActivity below consumes the queue head (the
4749
+ // start fired before the commit); onToolFinished reconciles by id
4750
+ // for paths whose activity never fired. Guarded: observer errors
4751
+ // degrade to the label-matching fallback, never break the turn.
4752
+ turnEvents: {
4753
+ onToolStarted: (info) => {
4754
+ try {
4755
+ toolIdentityQueueRef.current.push({
4756
+ toolCallId: info.toolCallId,
4757
+ name: info.name,
4758
+ startedAt: clockNow(),
4759
+ });
4760
+ }
4761
+ catch {
4762
+ // ignore (that call falls back to the phase-timing channel)
4763
+ }
4764
+ },
4765
+ onToolFinished: (info) => {
4766
+ try {
4767
+ const q = toolIdentityQueueRef.current;
4768
+ const at = q.findIndex((e) => e.toolCallId === info.toolCallId);
4769
+ if (at >= 0)
4770
+ q.splice(at, 1);
4771
+ }
4772
+ catch {
4773
+ // ignore (queue hygiene only; the activity already consumed)
4774
+ }
4775
+ },
4776
+ },
4221
4777
  // Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
4222
4778
  // bottleneck, context growth) attach to the same open turn trace.
4223
4779
  // Fires once per turn — including failed/cancelled turns, whose
@@ -4231,7 +4787,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4231
4787
  endpointOverride: activeEndpoint,
4232
4788
  onToken: (partial) => {
4233
4789
  try {
4234
- draftThrottler().push(partial);
4790
+ paintScheduler().push("draft", partial);
4235
4791
  }
4236
4792
  catch {
4237
4793
  // Never lose tokens: paint now rather than drop the partial.
@@ -4243,7 +4799,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4243
4799
  onThinking: (partial) => {
4244
4800
  thinkingRef.current = partial;
4245
4801
  try {
4246
- thinkingThrottler().push(partial);
4802
+ paintScheduler().push("thinking", partial);
4247
4803
  }
4248
4804
  catch {
4249
4805
  // Never lose reasoning: paint now rather than drop the partial.
@@ -4265,8 +4821,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4265
4821
  else if (p === "tool" && detail) {
4266
4822
  setToolHint(detail);
4267
4823
  toolStartRef.current = clockNow();
4824
+ // Paint any coalesced stream text NOW so the tool transition
4825
+ // never shows a stale draft for up to a window behind.
4826
+ flushDraft();
4268
4827
  }
4269
4828
  else if (p === "retry") {
4829
+ // Same ordering as tool start: pending paint lands before the
4830
+ // retry line commits, so the transcript never reorders.
4831
+ flushDraft();
4270
4832
  const msg = detail ? `↻ retrying… ${detail}` : "↻ retrying…";
4271
4833
  appendTurns({ role: "tool", content: msg });
4272
4834
  // Local observability: transport retries attach to the model call
@@ -4281,6 +4843,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4281
4843
  onToolDelta: (name) => {
4282
4844
  setToolHint(name);
4283
4845
  toolStartRef.current = clockNow();
4846
+ // Tool calls can start mid-stream: paint the pending draft now so
4847
+ // the running line and the latest text arrive in the same frame.
4848
+ flushDraft();
4284
4849
  },
4285
4850
  onUsage: (u) => {
4286
4851
  // Local observability: per-turn usage accumulates inside the
@@ -4312,13 +4877,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4312
4877
  appendTurns({ role: "user", content: s });
4313
4878
  },
4314
4879
  onToolActivity: (label, result, isError) => {
4315
- // Display-only duration: wall time since the tool started (see
4316
- // toolStartRef). Attached as Turn.ms for the Ns` suffix; the
4317
- // label text itself stays byte-identical to the loop's audit line.
4318
- const started = toolStartRef.current;
4880
+ // Structured identity first (ticket 02 sink): the queue head is
4881
+ // this commit's stable identity (toolCallId + name, commit order)
4882
+ // never parsed out of the label. Null head = identity-less call
4883
+ // (old paths, tests driving the callback directly) → the legacy
4884
+ // label-matching fallback for that call only, so no line is ever
4885
+ // dropped. The label text itself stays byte-identical either way.
4886
+ const identity = toolIdentityQueueRef.current.length > 0
4887
+ ? toolIdentityQueueRef.current.shift()
4888
+ : null;
4889
+ // Display-only duration: wall time since the tool started. On the
4890
+ // sink path the start comes from the structured identity (never
4891
+ // the phase-timing side channel); the fallback keeps toolStartRef.
4892
+ // Attached as Turn.ms for the `· Ns` suffix.
4893
+ const phaseStarted = toolStartRef.current;
4319
4894
  toolStartRef.current = null;
4320
- const ms = started !== null ? Math.max(0, clockNow() - started) : 0;
4321
- const items = [{ role: "tool", content: label, ms }];
4895
+ const ms = identity !== null
4896
+ ? Math.max(0, clockNow() - identity.startedAt)
4897
+ : phaseStarted !== null
4898
+ ? Math.max(0, clockNow() - phaseStarted)
4899
+ : 0;
4900
+ const items = [];
4901
+ // Inter-tool chatter streamed before this result would otherwise
4902
+ // vanish (the turn commit carries the final reply only). Pin it
4903
+ // above the tool line in commit order.
4904
+ const pendingStream = takeUncommittedStream();
4905
+ if (pendingStream !== null)
4906
+ items.push(pendingStream);
4907
+ items.push({ role: "tool", content: label, ms });
4322
4908
  // Inspector retention (display-only): keep the full result for
4323
4909
  // later browsing. Capped count; stored text char-capped inside
4324
4910
  // the record with an explicit truncation flag.
@@ -4330,12 +4916,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4330
4916
  // are short checklists, so successful ones join the transcript
4331
4917
  // (history fidelity — what did the list look like when?) and
4332
4918
  // refresh the live <TodoPanel> snapshot below the transcript.
4333
- const isTodo = label === "⚙ todo_get" ||
4334
- label.startsWith("⚙ todowrite ") ||
4335
- label.startsWith("⚙ todo_update ");
4919
+ // The membership test reads the structured tool name on the sink
4920
+ // path; the label-prefix match survives only for identity-less
4921
+ // fallback calls.
4922
+ const isTodo = identity !== null
4923
+ ? identity.name === "todo_get" ||
4924
+ identity.name === "todowrite" ||
4925
+ identity.name === "todo_update"
4926
+ : label === "⚙ todo_get" ||
4927
+ label.startsWith("⚙ todowrite ") ||
4928
+ label.startsWith("⚙ todo_update ");
4336
4929
  if (isTodo)
4337
4930
  setTodoSnap(getTodos());
4338
4931
  if (isError) {
4932
+ // Errors commit immediately: paint any coalesced stream text
4933
+ // first so the failure line never overtakes the text it follows.
4934
+ flushDraft();
4339
4935
  const firstLine = result.split("\n", 1)[0] ?? result;
4340
4936
  items.push({ role: "tool", content: ` ↳ ${firstLine}`, error: true });
4341
4937
  }
@@ -4347,12 +4943,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4347
4943
  // every matching activity (success or failure) so a stale
4348
4944
  // capture can never leak onto a later call; render only on
4349
4945
  // success with a real payload (failures keep the ↳ line only).
4946
+ // The match reads the structured tool name on the sink path
4947
+ // (never parsed out of the label); the label-prefix match
4948
+ // survives only for identity-less fallback calls.
4350
4949
  // Full-file BEFORE→AFTER is preferred (aligned panes with
4351
4950
  // context); when either side is unavailable (unreadable file,
4352
4951
  // oversize), fall back to the arg-block preview pair.
4353
4952
  const slot = pendingDiffRef.current;
4354
- if (slot !== null &&
4355
- (label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `))) {
4953
+ const slotMatch = slot !== null &&
4954
+ (identity !== null
4955
+ ? identity.name === slot.name
4956
+ : label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `));
4957
+ // Approval provenance rides the same pairing: the verdict's via
4958
+ // token recorded in approve() attributes to this exact execution.
4959
+ // Consume-or-clear on match (same predicate as the diff slot), so
4960
+ // a stale token can never leak onto a later call; denied calls
4961
+ // cleared their slot in approve()/resolveApproval and render no
4962
+ // suffix. Attached to the label turn for success and error alike
4963
+ // (a plan-passthrough refusal names its provenance too).
4964
+ const viaSlot = pendingViaRef.current;
4965
+ const viaMatch = viaSlot !== null &&
4966
+ (identity !== null
4967
+ ? identity.name === viaSlot.name
4968
+ : label === `⚙ ${viaSlot.name}` || label.startsWith(`⚙ ${viaSlot.name} `));
4969
+ if (viaMatch) {
4970
+ pendingViaRef.current = null;
4971
+ items[0].approvalVia = viaSlot.via;
4972
+ }
4973
+ if (slotMatch) {
4356
4974
  pendingDiffRef.current = null;
4357
4975
  if (!isError) {
4358
4976
  let afterFull = null;
@@ -4363,7 +4981,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4363
4981
  afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
4364
4982
  }
4365
4983
  const beforeFull = slot.beforeFull;
4366
- if (beforeFull !== null && afterFull !== null) {
4984
+ // Retention bound (OOM defense): full file texts stay on the
4985
+ // committed turn for the whole session. Past 1MB a side the
4986
+ // diff engine would only render its "file over 1MB — diff
4987
+ // skipped" notice anyway, so attach nothing and keep the
4988
+ // label-only turn instead of retaining megabytes to paint one
4989
+ // line. The modal preview (transient) is unaffected.
4990
+ const oversize = (beforeFull !== null && beforeFull.length > APPROVAL_PREVIEW_MAX_BYTES) ||
4991
+ (afterFull !== null && afterFull.length > APPROVAL_PREVIEW_MAX_BYTES) ||
4992
+ (slot.diff !== null &&
4993
+ ((slot.diff.oldText !== null && slot.diff.oldText.length > APPROVAL_PREVIEW_MAX_BYTES) ||
4994
+ slot.diff.newText.length > APPROVAL_PREVIEW_MAX_BYTES));
4995
+ if (!oversize && beforeFull !== null && afterFull !== null) {
4367
4996
  items[0].diff = {
4368
4997
  oldText: beforeFull,
4369
4998
  newText: afterFull,
@@ -4371,7 +5000,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4371
5000
  path: slot.path,
4372
5001
  };
4373
5002
  }
4374
- else if (slot.diff !== null) {
5003
+ else if (!oversize && slot.diff !== null) {
4375
5004
  items[0].diff = slot.diff;
4376
5005
  }
4377
5006
  }
@@ -4387,7 +5016,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4387
5016
  // the answer it produced).
4388
5017
  flushDraft();
4389
5018
  commitThinking();
4390
- appendTurns({ role: "assistant", content: reply });
5019
+ // The loop returns the FINAL post's text only: inter-tool chatter was
5020
+ // pinned above at each tool commit, so commit just the remainder — an
5021
+ // empty final reply falls back to uncommitted stream text, and a turn
5022
+ // with nothing streamed commits nothing (never a blank vanishing turn).
5023
+ if (reply.trim().length > 0) {
5024
+ if (reply !== committedStreamRef.current) {
5025
+ committedStreamRef.current = reply;
5026
+ appendTurns({ role: "assistant", content: reply });
5027
+ }
5028
+ }
5029
+ else {
5030
+ const pendingReply = takeUncommittedStream();
5031
+ if (pendingReply !== null)
5032
+ appendTurns(pendingReply);
5033
+ }
4391
5034
  // The turn committed to history (final text, denial-as-result, or
4392
5035
  // stop-notice) — persist the kill-safe save. Rolled-back turns (catch
4393
5036
  // below) never reach here, so a failure can't clobber the last good save.
@@ -4396,28 +5039,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4396
5039
  // labels (completed / blocked / unverified / budget-exceeded) and flush.
4397
5040
  telemetry.endTurn(telemetryTurnId, classifyTurnOutcome(reply), reply);
4398
5041
  persistTelemetry();
4399
- // Drain boundary (still busy, never mid-turn): pending manual /compact
4400
- // first (it resets the thrash counter), else auto-compact when the
4401
- // load is over threshold. Compaction persists via the normal save path.
4402
- if (pendingCompactRef.current !== null) {
4403
- const focus = pendingCompactRef.current;
4404
- pendingCompactRef.current = null;
4405
- await doCompact(focus, false);
4406
- // A /compact that arrived during the compaction above drains now.
4407
- if (pendingCompactRef.current !== null) {
4408
- const focus2 = pendingCompactRef.current;
4409
- pendingCompactRef.current = null;
4410
- await doCompact(focus2, false);
4411
- }
4412
- }
4413
- else {
4414
- await maybeAutoCompact();
4415
- if (pendingCompactRef.current !== null) {
4416
- const focus = pendingCompactRef.current;
4417
- pendingCompactRef.current = null;
4418
- await doCompact(focus, false);
4419
- }
4420
- }
5042
+ // Success path reached turn end the compact drain plus everything
5043
+ // after it runs in the single turn-boundary drain (see the finally).
5044
+ turnOutcome = "clean";
4421
5045
  }
4422
5046
  catch (err) {
4423
5047
  const cancelled = err instanceof LoopCancelledError ||
@@ -4425,6 +5049,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4425
5049
  controller.signal.aborted;
4426
5050
  historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
4427
5051
  turnCancelledRef.current = cancelled;
5052
+ // The failure path reached turn end — compact drain plus the rest runs
5053
+ // in the single turn-boundary drain (see the finally).
5054
+ turnOutcome = cancelled ? "cancelled" : "failed";
4428
5055
  // The turn never happened: drop live thinking with it (a failed turn
4429
5056
  // commits nothing — same scope as the history rollback above).
4430
5057
  clearThinking();
@@ -4448,95 +5075,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4448
5075
  // limit or dead network after 30s of streaming wipes everything the
4449
5076
  // user already read. History stays rolled back (model never sees
4450
5077
  // it); only the display transcript keeps the partial.
4451
- const partial = lastPartialRef.current.trim();
5078
+ const pendingPartial = takeUncommittedStream();
4452
5079
  lastPartialRef.current = "";
4453
- if (partial) {
5080
+ if (pendingPartial !== null) {
4454
5081
  appendTurns({
4455
5082
  role: "assistant",
4456
- content: `${partial}\n\n(request failed before completing — partial output preserved)`,
5083
+ content: `${pendingPartial.content}\n\n(request failed before completing — partial output preserved)`,
4457
5084
  });
4458
5085
  }
4459
5086
  setError(err instanceof Error ? err.message : String(err));
4460
5087
  }
4461
- // Turn-end drain even after failure/rollback: pending manual still
4462
- // runs (it applies to the surviving history); auto never fires here
4463
- // (load is meaningless for a rolled-back turn — just refresh it).
4464
- if (pendingCompactRef.current !== null) {
4465
- const focus = pendingCompactRef.current;
4466
- pendingCompactRef.current = null;
4467
- try {
4468
- await doCompact(focus, false);
4469
- }
4470
- catch {
4471
- // doCompact never throws (it reports inline), but stay safe.
4472
- }
4473
- }
4474
- else {
4475
- refreshContextLoad();
4476
- }
5088
+ // (Compact drain for this path lives in the single turn-boundary
5089
+ // drain below no inline drain logic remains here.)
4477
5090
  }
4478
5091
  finally {
4479
- // Goal work time (ticket 02): this submit's wall clock accrues once,
4480
- // for every outcome (success, failure, and cancel all did work), when
4481
- // the same goal is still live. A mid-turn replacement keeps its own
4482
- // stats we accrue only while the objective still matches.
4483
- try {
4484
- if (goalWorkStartMs !== null &&
4485
- goalRef.current !== null &&
4486
- goalRef.current.objective === goalWorkObjective) {
4487
- const workedMs = Math.max(0, Date.now() - goalWorkStartMs);
4488
- patchGoalStats((s) => ({ ...s, workMs: s.workMs + workedMs }));
4489
- }
4490
- }
4491
- catch {
4492
- // accounting never breaks turn teardown
4493
- }
4494
- turnCancelRef.current = null;
4495
- approvalResolveRef.current = null;
4496
- setPendingApproval(null);
4497
- // Safety net: the slot is normally consumed by onToolActivity or
4498
- // cleared on deny/cancel — never let it cross a turn boundary.
4499
- pendingDiffRef.current = null;
4500
- askResolveRef.current = null;
4501
- setPendingQuestion(null);
4502
- setAskCustomBoth("");
4503
- // Turn-scoped skill grants expire here: armed-while-idle and auto
4504
- // skills cover exactly the turn that just ended (success, failure,
4505
- // or cancel) — the next user message starts clean (ticket 06).
4506
- skillGrantsRef.current = new Set();
4507
- busyRef.current = false;
4508
- setBusy(false);
4509
- refreshGitInfo();
4510
- try {
4511
- draftThrottleRef.current?.cancel();
4512
- }
4513
- catch {
4514
- // ignore
4515
- }
4516
- streamStore.setDraft(null);
4517
- clearThinking();
4518
- setToolHint(null);
4519
- clearTurnTimer();
4520
- setStalledBoth(false);
4521
- setElapsedSecs(0);
4522
- setPhaseBoth("idle", "");
4523
- // Queue drain (Claude-Code-style): a clean turn auto-sends the next
4524
- // queued follow-up (chaining while the queue is non-empty); a cancelled
4525
- // turn keeps its queue visible but never auto-sends. A steer stranded
4526
- // by a failed/cancelled turn rejoins the queue front — the thought is
4527
- // preserved, the user decides when it runs. Runs after busy resets
4528
- // above so the chained submit enters a clean turn.
4529
- const stranded = steerRef.current;
4530
- if (stranded) {
4531
- steerRef.current = null;
4532
- setSteerPending(null);
4533
- setQueueBoth([stranded, ...queueRef.current]);
4534
- }
4535
- if (!turnCancelledRef.current && queueRef.current.length > 0) {
4536
- const next = queueRef.current[0];
4537
- setQueueBoth(queueRef.current.slice(1));
4538
- void submit(next);
4539
- }
5092
+ // The single turn-boundary drain (ticket 07): compact steer
5093
+ // queue goal-resume, in that order (see drainTurnBoundary). The
5094
+ // outcome glue above selects the clean vs failed/cancelled compact
5095
+ // variant; everything else (teardown position, steer-to-front,
5096
+ // cancel-keeps-queue) is owned by the routine.
5097
+ await drainTurnBoundary(turnOutcome, goalWorkStartMs, goalWorkObjective);
4540
5098
  }
4541
5099
  }
4542
5100
  function cancelTurn() {
@@ -4964,7 +5522,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4964
5522
  }
4965
5523
  return;
4966
5524
  }
4967
- // 3a2. Session picker (interactive switcher, same pattern as /skills:
5525
+ // 3a2. Session picker (interactive switcher, same pattern as /skill:
4968
5526
  // type to filter, ↑/↓ + Enter switches, Esc cancels with the live
4969
5527
  // session completely unchanged). The list is the open-time snapshot —
4970
5528
  // filtering never touches disk. Enter on an empty filtered list only
@@ -5170,6 +5728,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
5170
5728
  setInputBoth("");
5171
5729
  runRenameCommand(raw);
5172
5730
  }
5731
+ else if (pick.name === "/fork" &&
5732
+ (inputRef.current === "/fork" || inputRef.current.startsWith("/fork "))) {
5733
+ // Preserve the typed drop count (e.g. "/fork 5"); a bare
5734
+ // highlighted name forks at the tip.
5735
+ const raw = inputRef.current;
5736
+ setInputBoth("");
5737
+ void runForkCommand(raw);
5738
+ }
5739
+ else if (pick.name === "/revert" &&
5740
+ (inputRef.current === "/revert" || inputRef.current.startsWith("/revert "))) {
5741
+ // Preserve the typed checkpoint index (e.g. "/revert 1"); a
5742
+ // bare highlighted name reverts to the latest checkpoint.
5743
+ const raw = inputRef.current;
5744
+ setInputBoth("");
5745
+ void runRevertCommand(raw);
5746
+ }
5173
5747
  else if (!pick.skill &&
5174
5748
  getExtensionCommand(pick.name.slice(1)) &&
5175
5749
  (inputRef.current === pick.name || inputRef.current.startsWith(`${pick.name} `))) {
@@ -5563,7 +6137,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
5563
6137
  const modelTitle = `Atom — Select model (${modelEntries.length}` +
5564
6138
  (modelFilter ? ` of ${modelEntriesAll.length}, filter: "${modelFilter}"` : "") +
5565
6139
  `) — type to filter, up/down + Enter, Esc cancels:`;
5566
- // /skills picker derived for render (mirrors the useInput computation
6140
+ // /skill picker derived for render (mirrors the useInput computation
5567
6141
  // above): names only, filtered, clamped highlight, visible window. The
5568
6142
  // title keeps the `Skills (` prefix the registry header always had.
5569
6143
  // Memoized for the same tick/append reason as the model picker above.
@@ -5603,10 +6177,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
5603
6177
  const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
5604
6178
  // eslint-disable-next-line react-hooks/exhaustive-deps
5605
6179
  [selectingRewind]);
5606
- // Approval description: the tool-call one-liner for the modal. Memoized on
5607
- // the pending approval itself — the 1s busy tick keeps firing while the
5608
- // modal waits, and must not rebuild the string (nor re-render the modal).
5609
- const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
6180
+ // Approval description: the tool-call one-liner for the modal, stashed on
6181
+ // the pending approval by approve() itself — render never recomputes it
6182
+ // (describeToolCall may resolve symlinks, so rebuilding it here would put
6183
+ // filesystem reads back into the 1s busy-tick render path).
6184
+ const approvalDescription = pendingApproval?.description ?? "";
5610
6185
  // (The cursor clamp lives inside the memoized InputBox now, next to its
5611
6186
  // only use — App body no longer reads cursor state for paint.)
5612
6187
  // Status-line reasoning segment wired to the effort session state: a
@@ -5627,44 +6202,50 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
5627
6202
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
5628
6203
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
5629
6204
  : null;
5630
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, extPendingDialog && !pendingApproval && !pendingQuestion ? (_jsx(QuestionBox, { question: `[${extPendingDialog.owner}] ${extPendingDialog.question}`, options: extPendingDialog.options, allowCustom: extPendingDialog.allowCustom, askCustom: extDlgCustom, askSelIndex: extDlgSel }, `ext-dialog-${extPendingDialog.id}`)) : null, _jsx(TodoPanel, { items: todoSnap }), extWidgets.map((w) => (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["[", w.owner, "] ", w.title] }), _jsx(Text, { children: w.text })] }, `${w.owner}-${w.id}`))), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
5631
- const i = modelWin.start + k;
5632
- const entryLocal = e.local === true;
5633
- const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
5634
- const showGroup = i === 0 || prevLocal !== entryLocal;
5635
- const showHeader = showGroup || modelEntries[i - 1]?.providerId !== e.providerId;
5636
- const def = getProvider(e.providerId);
5637
- return (_jsxs(React.Fragment, { children: [showGroup ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", entryLocal ? "Local" : "Remote"] })) : null, showHeader ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", def?.name ?? e.providerId, entryLocal && isLocalProviderId(e.providerId)
5638
- ? ` ${theme.symbol.separator} ${localBaseURLFor(e.providerId)}`
5639
- : null, e.providerId === provider ? " (current)" : ""] })) : null, _jsxs(PickerRow, { highlighted: i === modelHi, children: [e.model, e.free === true ? _jsx(Text, { dimColor: true, children: " (free)" }) : null, e.providerId === provider && e.model === model ? " (current)" : ""] })] }, `${e.providerId}-${e.model}-${i}`));
5640
- }), _jsx(PickerMoreBelow, { count: modelEntries.length - modelWin.end }), modelEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models match \u2014 backspace to widen the filter." })) : null] })) : selectingSkills ? (_jsxs(PickerShell, { title: skillTitle, children: [_jsx(PickerMoreAbove, { count: skillWin.start }), skillEntries.slice(skillWin.start, skillWin.end).map((e, k) => {
5641
- const i = skillWin.start + k;
5642
- return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
5643
- }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
5644
- ? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
5645
- : "No skills match backspace to widen the filter." })) : null] })) : selectingSession ? (_jsxs(PickerShell, { title: sessionPickerTitle, children: [_jsx(PickerMoreAbove, { count: sessionWin.start }), sessionEntries.slice(sessionWin.start, sessionWin.end).map((e, k) => {
5646
- const i = sessionWin.start + k;
5647
- const age = formatSessionAge(Date.now(), e.updatedAt);
5648
- return (_jsxs(PickerRow, { highlighted: i === sessionHi, children: [e.title, e.active ? " (current)" : "", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", e.turnCount, " turn", e.turnCount === 1 ? "" : "s", " \u00B7 ", age] })] }, `${e.id}-${i}`));
5649
- }), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
5650
- ? "No sessions yetyour current conversation is saved automatically."
5651
- : "No sessions match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
5652
- const has = keyForProvider(p.id).length > 0;
5653
- const keyMark = isLocalProviderId(p.id)
5654
- ? `local ${theme.symbol.descSeparator} no key needed`
5655
- : has
5656
- ? `${theme.symbol.keyPresent} key`
5657
- : p.id === "kilo"
5658
- ? `${theme.symbol.descSeparator} key optional — free models need none`
5659
- : `${theme.symbol.descSeparator} no key`;
5660
- return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
5661
- }) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "auto" ? "Auto" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Auto lets the model decide; Low\u2192Max raise reasoning depth on every model." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
5662
- // The input is the one boxed, prominent surface (see the memoized
5663
- // InputBox above): a quiet gray frame sets it apart from the
5664
- // transcript above and the status line below. Pickers and modals
5665
- // replace it (never stack with it), each carrying their own semantic
5666
- // border color.
5667
- _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
5668
- ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
5669
- : `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, extensionStatus: extStatusText, goal: goal ? { objective: goal.objective, active: goal.active === true } : null })] }));
6205
+ // Memoized goal slice for the status bar (render-stability): the inline
6206
+ // literal used to defeat StatusBarHost's memo on every App render
6207
+ // (keystrokes, 1s ticks) whenever a goal was active — a new object
6208
+ // identity per render meant the whole status subtree reconciled for
6209
+ // nothing. Identity now tracks the goal, not the render.
6210
+ const goalStatus = useMemo(() => (goal ? { objective: goal.objective, active: goal.active === true } : null), [goal]);
6211
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, extPendingDialog && !pendingApproval && !pendingQuestion ? (_jsx(QuestionBox, { question: `[${extPendingDialog.owner}] ${extPendingDialog.question}`, options: extPendingDialog.options, allowCustom: extPendingDialog.allowCustom, askCustom: extDlgCustom, askSelIndex: extDlgSel }, `ext-dialog-${extPendingDialog.id}`)) : null, _jsx(TodoPanel, { items: todoSnap }), extWidgets.map((w) => (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["[", w.owner, "] ", w.title] }), _jsx(Text, { children: w.text })] }, `${w.owner}-${w.id}`))), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [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) => {
6212
+ const i = modelWin.start + k;
6213
+ const entryLocal = e.local === true;
6214
+ const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
6215
+ const showGroup = i === 0 || prevLocal !== entryLocal;
6216
+ const showHeader = showGroup || modelEntries[i - 1]?.providerId !== e.providerId;
6217
+ const def = getProvider(e.providerId);
6218
+ return (_jsxs(React.Fragment, { children: [showGroup ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", entryLocal ? "Local" : "Remote"] })) : null, showHeader ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", def?.name ?? e.providerId, entryLocal && isLocalProviderId(e.providerId)
6219
+ ? ` ${theme.symbol.separator} ${localBaseURLFor(e.providerId)}`
6220
+ : null, e.providerId === provider ? " (current)" : ""] })) : null, _jsxs(PickerRow, { highlighted: i === modelHi, children: [e.model, e.free === true ? _jsx(Text, { dimColor: true, children: " (free)" }) : null, e.providerId === provider && e.model === model ? " (current)" : ""] })] }, `${e.providerId}-${e.model}-${i}`));
6221
+ }), _jsx(PickerMoreBelow, { count: modelEntries.length - modelWin.end }), modelEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models match \u2014 backspace to widen the filter." })) : null] })) : selectingSkills ? (_jsxs(PickerShell, { title: skillTitle, children: [_jsx(PickerMoreAbove, { count: skillWin.start }), skillEntries.slice(skillWin.start, skillWin.end).map((e, k) => {
6222
+ const i = skillWin.start + k;
6223
+ return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
6224
+ }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
6225
+ ? "No skills installedadd SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
6226
+ : "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) => {
6227
+ const i = sessionWin.start + k;
6228
+ const age = formatSessionAge(Date.now(), e.updatedAt);
6229
+ 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}`));
6230
+ }), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
6231
+ ? "No sessions yet — your current conversation is saved automatically."
6232
+ : "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) => {
6233
+ const has = keyForProvider(p.id).length > 0;
6234
+ const keyMark = isLocalProviderId(p.id)
6235
+ ? `local ${theme.symbol.descSeparator} no key needed`
6236
+ : has
6237
+ ? `${theme.symbol.keyPresent} key`
6238
+ : p.id === "kilo"
6239
+ ? `${theme.symbol.descSeparator} key optional free models need none`
6240
+ : `${theme.symbol.descSeparator} no key`;
6241
+ return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
6242
+ }) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "auto" ? "Auto" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Auto lets the model decide; Low\u2192Max raise reasoning depth on every model." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
6243
+ // The input is the one boxed, prominent surface (see the memoized
6244
+ // InputBox above): a quiet gray frame sets it apart from the
6245
+ // transcript above and the status line below. Pickers and modals
6246
+ // replace it (never stack with it), each carrying their own semantic
6247
+ // border color.
6248
+ _jsx(InputBox, { input: input, cursor: cursor, busy: busy })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
6249
+ ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
6250
+ : `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, loadEstimated: loadEstimated, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, extensionStatus: extStatusText, goal: goalStatus })] })] }));
5670
6251
  }