atom-agent 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import React, { useEffect, useMemo, useRef, useState } from "react";
8
- import { Box, Text, useApp, useInput, usePaste, useStdout } from "ink";
8
+ import { Box, Text, useApp, useInput, usePaste } from "ink";
9
9
  import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, openTodoNeedles, runAgenticLoopForProvider, } from "./zen.js";
10
10
  import { createContextManager, trackHistory, } from "./context-manager.js";
11
11
  import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
@@ -16,7 +16,7 @@ import { formatRules, parseRuleInput, } from "./permissions.js";
16
16
  import { decidePolicy, skillGrantsFor } from "./policy.js";
17
17
  import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
18
18
  import { contextWindowFor } from "./context-windows.js";
19
- import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
19
+ import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFiles, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
20
20
  import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
21
21
  import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
22
22
  import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
@@ -24,20 +24,23 @@ import { validateProviderKey } from "./adapters.js";
24
24
  import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
25
25
  import { getGitInfo, withEnvBlock } from "./env-block.js";
26
26
  import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
27
+ import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
27
28
  import { loadAtomConfig } from "./config.js";
28
29
  import { cancelledTurnLine } from "./rollback.js";
29
30
  import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
30
31
  import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
31
32
  import { InputBox } from "./ui/input.js";
32
33
  import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
33
- import { LiveTail } from "./ui/live-tail.js";
34
+ import { LiveTailHost } from "./ui/live-host.js";
34
35
  import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
35
36
  import { activityText } from "./ui/activity.js";
36
37
  import { ApprovalBox, QuestionBox } from "./ui/modals.js";
37
38
  import { PalettePanel } from "./ui/palette.js";
38
39
  import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
39
40
  import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
40
- import { StatusBar, shortenCwd } from "./ui/status-bar.js";
41
+ import { shortenCwd } from "./ui/status-bar.js";
42
+ import { StatusBarHost } from "./ui/status-host.js";
43
+ import { createStreamStore } from "./ui/stream-store.js";
41
44
  import { theme } from "./ui/theme.js";
42
45
  import { TodoPanel } from "./ui/todo-panel.js";
43
46
  import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
@@ -60,13 +63,15 @@ export const SLASH_COMMANDS = [
60
63
  { name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
61
64
  { name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
62
65
  { name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
66
+ { name: "/rename", description: "Rename the current session (/rename <name>)." },
63
67
  { name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
64
68
  { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
65
69
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
66
70
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
67
- { name: "/autoscroll", description: "Follow new output as it arrives (/autoscroll on|off; off freezes the view mid-turn)." },
71
+ { name: "/autoscroll", description: "Toggle following new output (off by default; bare toggles, on|off sets it; off freezes the view mid-turn)." },
68
72
  { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
69
73
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
74
+ { name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
70
75
  { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
71
76
  { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
72
77
  { name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
@@ -110,8 +115,26 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
110
115
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
111
116
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
112
117
  export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
113
- export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll prints the current state.";
114
- export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: shows or hides the committed thinking blocks; the turn, history, and telemetry are untouched).";
118
+ export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — off (default) freezes the view while a turn runs (a `↓ N new` indicator offers the jump back); on follows new output as it arrives. Bare /autoscroll toggles between the two.";
119
+ export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: the live block and future rounds show or hide; already-printed blocks stay as printed; the turn, history, and telemetry are untouched).";
120
+ export const RENAME_USAGE = 'usage: /rename <name> — rename the current session (e.g. /rename Build authentication; quotes optional: /rename "name with spaces"). Bare /rename prints this usage.';
121
+ // Pure arg parser for /rename (unit-tested): strips the command, trims,
122
+ // then strips one layer of matching outer quotes (single or double) so
123
+ // quoted names work even though the command line has no real parser.
124
+ // Unquoted multi-word names work as-is (everything after /rename is the
125
+ // name). Empty/whitespace-only input yields "" (caller prints usage).
126
+ export function parseRenameArg(raw) {
127
+ const text = raw.trim();
128
+ const arg = text === "/rename" ? "" : text.slice("/rename".length).trim();
129
+ if (arg.length >= 2) {
130
+ const first = arg[0];
131
+ const last = arg[arg.length - 1];
132
+ if ((first === '"' || first === "'") && last === first) {
133
+ return arg.slice(1, -1).trim();
134
+ }
135
+ }
136
+ return arg;
137
+ }
115
138
  export function filterSlashCommands(prefix) {
116
139
  const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
117
140
  // Exact match wins outright: a fully-typed command collapses the menu
@@ -178,14 +201,17 @@ export function paletteEntries(query) {
178
201
  }
179
202
  // Busy-gate shared by the slash menu and the palette: /compact sets the
180
203
  // pending flag for turn-end drain; /queue + /steer manage the running turn;
181
- // /autoscroll and /thinking only flip view flags (never touch the turn).
204
+ // /autoscroll and /thinking only flip view flags (never touch the turn);
205
+ // /rename only renames the store record + title state (the later turn-end
206
+ // persist preserves the title, so it never races the turn).
182
207
  // Every other command waits idle.
183
208
  export function slashRunsWhileBusy(name) {
184
209
  return (name === "/compact" ||
185
210
  name === "/queue" ||
186
211
  name === "/steer" ||
187
212
  name === "/autoscroll" ||
188
- name === "/thinking");
213
+ name === "/thinking" ||
214
+ name === "/rename");
189
215
  }
190
216
  // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
191
217
  // better; null = no match). Pure — shared by the command filter, the skill
@@ -224,6 +250,15 @@ export function filterSkillPicker(entries, query) {
224
250
  return entries;
225
251
  return entries.filter((e) => e.name.toLowerCase().includes(q));
226
252
  }
253
+ export function sameSkillMenuSnapshot(a, b) {
254
+ if (a.length !== b.length)
255
+ return false;
256
+ for (let i = 0; i < a.length; i++) {
257
+ if (a[i].name !== b[i].name || a[i].description !== b[i].description)
258
+ return false;
259
+ }
260
+ return true;
261
+ }
227
262
  // Pure menu builder (unit-tested): matching commands first (prefix tier in
228
263
  // stable order, then fuzzy by score), then matching skills as `/skill:name`
229
264
  // entries (prefix tier stable, then fuzzy). Skills join only once the query
@@ -292,6 +327,8 @@ export function commandUsage(name) {
292
327
  return SKILL_USAGE;
293
328
  case "/compact":
294
329
  return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
330
+ case "/rename":
331
+ return RENAME_USAGE;
295
332
  default:
296
333
  return null;
297
334
  }
@@ -311,6 +348,41 @@ export function modelsCacheKey(providerId, baseURL) {
311
348
  }
312
349
  return providerId;
313
350
  }
351
+ export function filterSessionEntries(entries, query) {
352
+ const q = query.trim().toLowerCase();
353
+ if (!q)
354
+ return entries;
355
+ const scored = [];
356
+ entries.forEach((e, idx) => {
357
+ const titleScore = fuzzyScore(q, e.title);
358
+ const idScore = fuzzyScore(q, e.id);
359
+ let best = titleScore;
360
+ if (idScore !== null && (best === null || idScore + 5 < best)) {
361
+ best = idScore + 5;
362
+ }
363
+ if (best !== null)
364
+ scored.push({ e, score: best, idx });
365
+ });
366
+ scored.sort((a, b) => a.score - b.score || a.idx - b.idx);
367
+ return scored.map((s) => s.e);
368
+ }
369
+ // Relative age for the picker secondary line (unit-tested). Invalid dates
370
+ // say "unknown" (never throw, never invent).
371
+ export function formatSessionAge(nowMs, updatedAt) {
372
+ const t = Date.parse(updatedAt);
373
+ if (!Number.isFinite(t))
374
+ return "unknown";
375
+ const secs = Math.max(0, Math.floor((nowMs - t) / 1000));
376
+ if (secs < 60)
377
+ return "just now";
378
+ const mins = Math.floor(secs / 60);
379
+ if (mins < 60)
380
+ return `${mins}m ago`;
381
+ const hours = Math.floor(mins / 60);
382
+ if (hours < 24)
383
+ return `${hours}h ago`;
384
+ return `${Math.floor(hours / 24)}d ago`;
385
+ }
314
386
  // Pure helpers for the elapsed/stall indicator (injectable now for tests).
315
387
  export function elapsedSecsSince(startMs, nowMs) {
316
388
  return Math.max(0, Math.floor((nowMs - startMs) / 1000));
@@ -506,17 +578,13 @@ export function MissingKey() {
506
578
  // Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
507
579
  // size is a constant (avoids re-serializing ~15KB on every manager build).
508
580
  const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
581
+ // Render-count probe for propagation audits: incremented on every App body
582
+ // execution (hostile-perf suite asserts token paints and ticks stay out of
583
+ // here — only real state transitions may run the orchestrator).
584
+ export const appRenderProbe = { count: 0 };
509
585
  export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
586
+ appRenderProbe.count += 1;
510
587
  const { exit } = useApp();
511
- // Measured terminal width for the status bar's fit-or-drop branch logic.
512
- // Unknown (piped output) falls back to the bar's own default.
513
- let termColumns;
514
- try {
515
- termColumns = useStdout()?.stdout?.columns;
516
- }
517
- catch {
518
- termColumns = undefined;
519
- }
520
588
  // Saved preferences (provider/model/effort + resolved key/endpoint), loaded
521
589
  // once when restorePrefs is on (prod). Explicit props always win; without
522
590
  // prefs the CLI defaults apply. Null in tests (flag off) and on any
@@ -689,6 +757,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
689
757
  skillFilterRef.current = next;
690
758
  setSkillFilter(next);
691
759
  }
760
+ // /session picker (interactive switcher): snapshot state loaded ONCE per
761
+ // open (listSessions reads each record a single time), filtered in memory
762
+ // per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
763
+ // untouched. Same keyboard/window pattern as the /skills picker.
764
+ const [selectingSession, setSelectingSession] = useState(false);
765
+ const [sessionItems, setSessionItems] = useState([]);
766
+ const [sessionIndex, setSessionIndex] = useState(0);
767
+ const sessionIndexRef = useRef(0);
768
+ const [sessionFilter, setSessionFilter] = useState("");
769
+ const sessionFilterRef = useRef("");
770
+ function setSessionIndexBoth(next) {
771
+ sessionIndexRef.current = next;
772
+ setSessionIndex(next);
773
+ }
774
+ function setSessionFilterBoth(next) {
775
+ sessionFilterRef.current = next;
776
+ setSessionFilter(next);
777
+ }
692
778
  // Reasoning-effort picker (/effort): same pattern as the /model picker
693
779
  // (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
694
780
  // else the atom.json default, else Default.
@@ -844,13 +930,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
844
930
  showThinkingRef.current = next;
845
931
  setShowThinking(next);
846
932
  }
847
- // /autoscroll (session-only, default on). On = today's behavior: a
848
- // following view extends with every appended turn. Off = appends during a
849
- // busy turn freeze a following view at its current end instead of yanking
850
- // it (the `↓ N new` indicator offers the jump back; End resumes). Idle
851
- // appends always follow — freezing only matters while output streams.
852
- const [autoScroll, setAutoScroll] = useState(true);
853
- const autoScrollRef = useRef(true);
933
+ // /autoscroll (session-only, default off). Off freezes the view at the
934
+ // first busy append (the `↓ N new` indicator offers the jump back); on
935
+ // follows new output as it arrives. Bare /autoscroll toggles.
936
+ const [autoScroll, setAutoScroll] = useState(false);
937
+ const autoScrollRef = useRef(false);
854
938
  function setAutoScrollBoth(next) {
855
939
  autoScrollRef.current = next;
856
940
  setAutoScroll(next);
@@ -871,9 +955,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
871
955
  try {
872
956
  const found = await skillRegistry.refresh();
873
957
  const { skills } = resolveSkills(found.skills);
874
- setSkillMenu(skills
958
+ const next = skills
875
959
  .filter((s) => s.userInvocable)
876
- .map((s) => ({ name: s.name, description: s.description })));
960
+ .map((s) => ({ name: s.name, description: s.description }));
961
+ // Install only on change: the mount refresh routinely rediscovers the
962
+ // identical set mid-turn, and a fresh array identity would schedule a
963
+ // full App render for zero new information.
964
+ setSkillMenu((prev) => (sameSkillMenuSnapshot(prev, next) ? prev : next));
877
965
  }
878
966
  catch {
879
967
  // menu keeps its previous snapshot (a hiccup must never break input)
@@ -997,22 +1085,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
997
1085
  // as a dim line while the transcript is empty; the conversation itself
998
1086
  // never auto-restores (only provider/model/effort do, via restorePrefs).
999
1087
  const [sessionHint] = useState(() => sessionExists(authHome));
1088
+ // Active session display title (rename feedback + failure messages).
1089
+ // Initialized from the store record when one already exists (sync disk
1090
+ // read, no write — creation stays in the mount effect); thereafter the
1091
+ // store is the source of truth and every sync point below refreshes it.
1092
+ // Deliberately NOT in the status bar: the sole info bar has a fixed width
1093
+ // budget and a ~30-char title wraps `mode: X` onto its own line. Session
1094
+ // identity surfaces instead in the /session picker rows, the rename
1095
+ // confirmation, and the switch/new notices.
1096
+ const [sessionTitle, setSessionTitle] = useState(() => {
1097
+ try {
1098
+ return getActiveSession(authHome)?.title ?? "";
1099
+ }
1100
+ catch {
1101
+ return "";
1102
+ }
1103
+ });
1104
+ const sessionTitleRef = useRef(sessionTitle);
1000
1105
  // Reasoning label from response metadata (via onReasoning). The status
1001
1106
  // line shows the session effort when non-Default (plus " (unsupported)"
1002
1107
  // when the model is outside the verified-support set); when effort is
1003
1108
  // Default it shows this label, falling back to `default`.
1004
1109
  const [reasoning, setReasoning] = useState(null);
1005
- // Live streaming state: `draft` is the growing assistant text (onToken),
1006
- // `phase`/`phaseDetail` track the observe→act→inspect→adjust loop
1007
- // (thinking|streaming|tool|retry|done), and `toolHint` shows a streamed
1008
- // tool name before its execution line lands.
1009
- const [draft, setDraft] = useState(null);
1110
+ // Live streaming state: the growing assistant text (onToken) and the
1111
+ // thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
1112
+ // useState. Token paints (up to ~15/sec) notify only the subscribed
1113
+ // LiveTailHost App's body and every other leaf skip them entirely.
1114
+ // `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
1115
+ // few times per turn (low frequency, and StatusBar legitimately needs them).
1116
+ const [streamStore] = useState(() => createStreamStore());
1010
1117
  // Thinking channel (onThinking): reasoning text streamed apart from the
1011
1118
  // answer, rendered in its own dim block below. The live value is transient
1012
- // like `draft` — cleared on every turn boundary below — but each completed
1119
+ // like the draft — cleared on every turn boundary below — but each completed
1013
1120
  // round commits to the transcript via commitThinking (stays in the TUI,
1014
1121
  // never the model history) instead of being replaced and lost.
1015
- const [thinking, setThinking] = useState(null);
1016
1122
  const thinkingRef = useRef(null);
1017
1123
  // Move the accumulated round thinking into the transcript as a quiet
1018
1124
  // annotation turn (no-op when empty). Called when a new POST starts and at
@@ -1021,18 +1127,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1021
1127
  function commitThinking() {
1022
1128
  const text = thinkingRef.current;
1023
1129
  thinkingRef.current = null;
1024
- setThinking(null);
1130
+ try {
1131
+ // Drop any trailing paint: the commit carries the full text, and a
1132
+ // late flush must never resurrect stale reasoning after the clear.
1133
+ thinkingThrottleRef.current?.cancel();
1134
+ }
1135
+ catch {
1136
+ // ignore (the store clear below still wins)
1137
+ }
1138
+ streamStore.setThinking(null);
1025
1139
  if (typeof text === "string" && text.length > 0) {
1026
1140
  appendTurns({ role: "assistant", content: text, thinking: true });
1027
1141
  }
1028
1142
  }
1029
1143
  function clearThinking() {
1030
1144
  thinkingRef.current = null;
1031
- setThinking(null);
1145
+ try {
1146
+ thinkingThrottleRef.current?.cancel();
1147
+ }
1148
+ catch {
1149
+ // ignore (the store clear below still wins)
1150
+ }
1151
+ streamStore.setThinking(null);
1032
1152
  }
1033
1153
  const [phase, setPhase] = useState("idle");
1034
1154
  const [phaseDetail, setPhaseDetail] = useState("");
1035
1155
  const [toolHint, setToolHint] = useState(null);
1156
+ // Synchronous mirrors for the hot loop callbacks: zen fires
1157
+ // onPhase("streaming") on EVERY content chunk, so the handler must not
1158
+ // issue same-value setStates per token (React re-invokes the component
1159
+ // before bailing out — 15 App-body executions/sec for nothing).
1160
+ // setPhaseBoth is the only writer; equal values skip setState entirely.
1161
+ const phaseRef = useRef("idle");
1162
+ const phaseDetailRef = useRef("");
1163
+ function setPhaseBoth(next, detail) {
1164
+ if (phaseRef.current !== next) {
1165
+ phaseRef.current = next;
1166
+ setPhase(next);
1167
+ }
1168
+ if (phaseDetailRef.current !== detail) {
1169
+ phaseDetailRef.current = detail;
1170
+ setPhaseDetail(detail);
1171
+ }
1172
+ }
1036
1173
  // Task B smoothness (a): throttled streaming draft. onToken pushes every
1037
1174
  // partial (activity/stall tracking stays per-token); paints coalesce to one
1038
1175
  // per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
@@ -1045,7 +1182,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1045
1182
  setTimeoutFn,
1046
1183
  clearTimeoutFn,
1047
1184
  onFlush: (text) => {
1048
- setDraft(text);
1185
+ // Paint path only: the commit carries the byte-exact full text.
1186
+ // Writing the store notifies LiveTailHost alone — never App.
1187
+ streamStore.setDraft(text);
1049
1188
  },
1050
1189
  });
1051
1190
  draftThrottleRef.current = th;
@@ -1060,6 +1199,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1060
1199
  // ignore (draft stays as-is; the commit carries the full text)
1061
1200
  }
1062
1201
  }
1202
+ // Thinking paint coalescing: reasoning chunks arrive at token rate but
1203
+ // paint through the same trailing window into the store (producer/consumer
1204
+ // symmetry with the draft). thinkingRef stays synchronous per chunk so
1205
+ // commitThinking can never lose reasoning to a pending trailing paint.
1206
+ const thinkingThrottleRef = useRef(null);
1207
+ function thinkingThrottler() {
1208
+ let th = thinkingThrottleRef.current;
1209
+ if (!th) {
1210
+ th = createDraftThrottler({
1211
+ now,
1212
+ setTimeoutFn,
1213
+ clearTimeoutFn,
1214
+ onFlush: (text) => {
1215
+ streamStore.setThinking(text);
1216
+ },
1217
+ });
1218
+ thinkingThrottleRef.current = th;
1219
+ }
1220
+ return th;
1221
+ }
1063
1222
  // Phase 5: models-list session cache (successful live lists only, keyed
1064
1223
  // by modelsCacheKey). Failures fall back uncached, exactly as before.
1065
1224
  const modelsCacheRef = useRef(new Map());
@@ -1135,6 +1294,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1135
1294
  // on the next activity.
1136
1295
  const [elapsedSecs, setElapsedSecs] = useState(0);
1137
1296
  const [stalled, setStalled] = useState(false);
1297
+ // Mirror for the per-token stall reset (same same-value-setState hazard
1298
+ // as phase above — noteTurnActivity runs on every chunk).
1299
+ const stalledRef = useRef(false);
1300
+ function setStalledBoth(next) {
1301
+ if (stalledRef.current !== next) {
1302
+ stalledRef.current = next;
1303
+ setStalled(next);
1304
+ }
1305
+ }
1138
1306
  const turnStartRef = useRef(0);
1139
1307
  const lastActivityRef = useRef(0);
1140
1308
  const turnTimerRef = useRef(null);
@@ -1260,7 +1428,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1260
1428
  catch {
1261
1429
  // ignore clock errors (stall hint just won't trigger)
1262
1430
  }
1263
- setStalled(false);
1431
+ // Guarded: runs on every token/thinking/phase event, but only a
1432
+ // true→false transition may schedule a render.
1433
+ setStalledBoth(false);
1264
1434
  }
1265
1435
  function startTurnTimer() {
1266
1436
  clearTurnTimer();
@@ -1274,7 +1444,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1274
1444
  turnStartRef.current = start;
1275
1445
  lastActivityRef.current = start;
1276
1446
  setElapsedSecs(0);
1277
- setStalled(false);
1447
+ setStalledBoth(false);
1278
1448
  try {
1279
1449
  turnTimerRef.current = (setIntervalFn ?? setInterval)(() => {
1280
1450
  let t;
@@ -1286,7 +1456,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1286
1456
  }
1287
1457
  setElapsedSecs(elapsedSecsSince(turnStartRef.current, t));
1288
1458
  if (isStalledSince(lastActivityRef.current, t)) {
1289
- setStalled(true);
1459
+ // Guarded: the stalled flag flips false→true once per silence
1460
+ // window, not on every tick within it.
1461
+ if (!stalledRef.current)
1462
+ setStalledBoth(true);
1290
1463
  }
1291
1464
  }, TURN_TICK_MS);
1292
1465
  }
@@ -1329,6 +1502,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1329
1502
  }
1330
1503
  try {
1331
1504
  draftThrottleRef.current?.cancel();
1505
+ thinkingThrottleRef.current?.cancel();
1332
1506
  }
1333
1507
  catch {
1334
1508
  // ignore
@@ -1499,6 +1673,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1499
1673
  setModelFilterBoth("");
1500
1674
  setSelectingSkills(false);
1501
1675
  setSkillFilterBoth("");
1676
+ setSelectingSession(false);
1677
+ setSessionFilterBoth("");
1502
1678
  setSelectingEffort(false);
1503
1679
  setSelectingProvider(false);
1504
1680
  setKeyPromptBoth(null);
@@ -1531,6 +1707,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1531
1707
  pushInfo("(skill discovery failed — no skills listed)");
1532
1708
  });
1533
1709
  }
1710
+ // /session picker open: snapshot the store list ONCE (most-recent-first
1711
+ // from listSessions) with the active record marked, then filter in memory
1712
+ // per keystroke (no disk reads while typing). Idle-only (callers gate on
1713
+ // busy like every picker — a mid-turn switch would race the loop's own
1714
+ // history writes). Empty store degrades to a notice (unreachable while
1715
+ // the mount bootstrap holds, but never a blank popup).
1716
+ function openSessionPicker(initialFilter) {
1717
+ setInputBoth("");
1718
+ closeAllPickers();
1719
+ let records;
1720
+ try {
1721
+ records = listSessions(authHome);
1722
+ }
1723
+ catch {
1724
+ pushInfo("(could not list sessions — store unreadable)");
1725
+ return;
1726
+ }
1727
+ if (records.length === 0) {
1728
+ pushInfo("(no sessions yet — your current conversation is saved automatically)");
1729
+ return;
1730
+ }
1731
+ let activeId = null;
1732
+ try {
1733
+ activeId = getActiveSessionId(authHome);
1734
+ }
1735
+ catch {
1736
+ activeId = null;
1737
+ }
1738
+ setSessionItems(records.map((s) => ({
1739
+ id: s.id,
1740
+ title: s.title,
1741
+ updatedAt: s.updatedAt,
1742
+ createdAt: s.createdAt,
1743
+ turnCount: s.turns.length,
1744
+ active: s.id === activeId,
1745
+ })));
1746
+ setSessionFilterBoth(initialFilter);
1747
+ setSessionIndexBoth(0);
1748
+ setSelectingSession(true);
1749
+ }
1534
1750
  // Unified /model entries for this render: active provider's current list
1535
1751
  // first, then every other keyed provider's cached-or-fallback list (pure,
1536
1752
  // local-only — see modelPickerEntries). Called from the /model open path,
@@ -1652,6 +1868,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1652
1868
  usageRef.current = next;
1653
1869
  setUsageTotals(next);
1654
1870
  }
1871
+ function setSessionTitleBoth(next) {
1872
+ sessionTitleRef.current = next;
1873
+ setSessionTitle(next);
1874
+ }
1655
1875
  function setContextLoadBoth(next) {
1656
1876
  contextLoadRef.current = next;
1657
1877
  setContextLoad(next);
@@ -1825,6 +2045,93 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1825
2045
  // committed diff previews (Turn.diff) are display-only and never saved:
1826
2046
  // they can hold whole file contents (bloat) and would render stale
1827
2047
  // after later edits, so /resume restores label-only turns.
2048
+ //
2049
+ // Multi-session mirror (src/sessions.ts): every conversation auto-belongs
2050
+ // to a durable session record (~/.atom/sessions/<id>.json + active
2051
+ // pointer). persistSession() also mirrors the same committed snapshot
2052
+ // into the active record via persistStoreSession() below, so completed
2053
+ // turns, clean exits, and successful compactions bump updatedAt there too.
2054
+ // Failures/cancels never reach here (same rollback rule as legacy).
2055
+ // Active id lives in a ref only — never a session list in runtime state.
2056
+ const activeSessionIdRef = useRef(null);
2057
+ function storeCwd() {
2058
+ try {
2059
+ return process.cwd();
2060
+ }
2061
+ catch {
2062
+ return "";
2063
+ }
2064
+ }
2065
+ // Ensure exactly one active persistent session exists (fresh mount creates
2066
+ // one with the current provider/model/effort/mode + cwd). Never throws,
2067
+ // never blocks render — disk errors leave the ref null and the next
2068
+ // persist retries.
2069
+ function ensureStoreSession() {
2070
+ try {
2071
+ const existing = activeSessionIdRef.current;
2072
+ // The record may vanish under a running process (external delete,
2073
+ // corrupted file): a stale cached id must re-ensure instead of
2074
+ // persisting into the void (every later turn would silently skip).
2075
+ if (existing) {
2076
+ try {
2077
+ if (getSession(existing, authHome))
2078
+ return existing;
2079
+ }
2080
+ catch {
2081
+ // fall through to re-ensure below
2082
+ }
2083
+ }
2084
+ const s = ensureActiveSession({
2085
+ cwd: storeCwd(),
2086
+ provider: providerRef.current,
2087
+ model: modelRef.current,
2088
+ effort: effortRef.current,
2089
+ mode: modeRef.current,
2090
+ }, authHome);
2091
+ activeSessionIdRef.current = s.id;
2092
+ // The store owns the title (a /rename from an earlier mount must show
2093
+ // after restart); sync the display state on every ensure.
2094
+ setSessionTitleBoth(s.title);
2095
+ return s.id;
2096
+ }
2097
+ catch {
2098
+ return null;
2099
+ }
2100
+ }
2101
+ // Mirror the committed snapshot into the active multi-session record
2102
+ // (single updateSession so updatedAt bumps on every committed turn).
2103
+ // Disk errors ignored, like the legacy save above.
2104
+ function persistStoreSession() {
2105
+ try {
2106
+ const id = ensureStoreSession();
2107
+ if (!id)
2108
+ return;
2109
+ updateSession(id, {
2110
+ history: historyRef.current,
2111
+ turns: turnsRef.current.map((t) => {
2112
+ const { diff: _dropped, ...rest } = t;
2113
+ return rest;
2114
+ }),
2115
+ usageTotals: usageRef.current,
2116
+ provider: providerRef.current,
2117
+ model: modelRef.current,
2118
+ effort: effortRef.current,
2119
+ mode: modeRef.current,
2120
+ cwd: storeCwd(),
2121
+ }, authHome);
2122
+ }
2123
+ catch {
2124
+ // ignore disk errors (in-memory session still applies)
2125
+ }
2126
+ }
2127
+ // Mount bootstrap: claim/create the active session before any turn can
2128
+ // persist, so every normal conversation belongs to a durable session.
2129
+ // Startup never auto-restores conversation state (fresh + legacy hint,
2130
+ // matching current UX) — this only ensures the record exists.
2131
+ useEffect(() => {
2132
+ ensureStoreSession();
2133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2134
+ }, []);
1828
2135
  function persistSession() {
1829
2136
  try {
1830
2137
  saveSession({
@@ -1843,6 +2150,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1843
2150
  catch {
1844
2151
  // ignore disk errors (in-memory session still applies)
1845
2152
  }
2153
+ persistStoreSession();
1846
2154
  }
1847
2155
  // Local observability persistence: flush the current telemetry session
1848
2156
  // file (atomic, best-effort). Called on turn boundaries and session events —
@@ -1974,8 +2282,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1974
2282
  telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
1975
2283
  },
1976
2284
  });
1977
- // Atomic swap: build the new history first, then replace.
1978
- const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
2285
+ // Atomic swap: build the new history first, then replace. The head's
2286
+ // touched files (collected from the committed tool_calls the loop
2287
+ // already recorded — no new tracking) ride inside the summary within
2288
+ // budget, so resumed sessions know what was touched; over-budget lists
2289
+ // shrink instead of failing compaction.
2290
+ const fitted = fitSummaryWithFiles(summary, collectTouchedFiles(split.head));
2291
+ const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
1979
2292
  // Replacement: re-wrap so the ledger restarts from the compacted array
1980
2293
  // (the old ledger is discarded with the old array).
1981
2294
  historyRef.current = trackHistory(next);
@@ -2111,6 +2424,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2111
2424
  contextManager().trimForSend(historyRef.current, (msg) => {
2112
2425
  pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2113
2426
  }, undefined, openTodoNeedles());
2427
+ // Surface the touched-file lists stored in compacted summaries, verbatim
2428
+ // in the stored format — a resumed session knows what was touched
2429
+ // without re-exploring the tree.
2430
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
2431
+ pendingNotices.push({ role: "tool", content: section });
2432
+ }
2114
2433
  // Remount the turns <Static> (same mechanism as /clear and /new): Ink's
2115
2434
  // Static only renders newly appended indices, so restoring a transcript
2116
2435
  // over a non-empty rendered buffer (e.g. the /new boundary line) would
@@ -2128,6 +2447,141 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2128
2447
  ]);
2129
2448
  telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
2130
2449
  persistTelemetry();
2450
+ // Mirror the restored legacy state into the active multi-session record
2451
+ // (create+activate when none exists). Startup itself never auto-restores
2452
+ // the store — only this explicit /resume does.
2453
+ persistStoreSession();
2454
+ }
2455
+ // /session switch: make the picked record the live conversation. Exactly
2456
+ // one history replacement (never merge, never duplicate): the target's
2457
+ // history/turns REPLACE the live arrays wholesale, following the doResume
2458
+ // precedent (endpoint recompute, env-block refresh, budget trim, lineage
2459
+ // drop, Static remount, title sync). Differences from /resume:
2460
+ // - the outgoing live state snapshots into the OLD record first, but only
2461
+ // when it holds turns (a fresh mount's system-only live state is NOT the
2462
+ // old record's content — persisting it would wipe that record);
2463
+ // - re-picking the current session is a no-op (reloading from disk would
2464
+ // drop unpersisted live turns);
2465
+ // - updatedAt is NOT bumped (switching is navigation, not a mutation —
2466
+ // listings keep true recency order);
2467
+ // - a missing/unreadable target errors WITHOUT touching the live session.
2468
+ // The legacy session.json follows the switch (same persistSession path as
2469
+ // every completed turn) so /resume stays coherent with the live view.
2470
+ function switchToSession(id) {
2471
+ let target = null;
2472
+ try {
2473
+ target = getSession(id, authHome);
2474
+ }
2475
+ catch {
2476
+ target = null;
2477
+ }
2478
+ if (!target) {
2479
+ pushInfo("(session no longer available — staying on the current session)");
2480
+ return;
2481
+ }
2482
+ const currentId = activeSessionIdRef.current;
2483
+ if (currentId !== null && currentId === target.id) {
2484
+ pushInfo(`(already on "${target.title}")`);
2485
+ return;
2486
+ }
2487
+ // Snapshot the outgoing conversation into its own record first (same
2488
+ // rule as /new's pre-reset save). Guarded: only when the live state
2489
+ // actually holds turns of the outgoing record.
2490
+ if (currentId !== null && currentId !== target.id && turnsRef.current.length > 0) {
2491
+ persistStoreSession();
2492
+ }
2493
+ try {
2494
+ setActiveSession(target.id, authHome);
2495
+ }
2496
+ catch {
2497
+ // setActiveSession never throws by contract; defensive only.
2498
+ }
2499
+ activeSessionIdRef.current = target.id;
2500
+ setProviderBoth(target.provider);
2501
+ const baseURL = chatBaseURL(target.provider);
2502
+ if (target.provider === "openai-compatible") {
2503
+ setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
2504
+ }
2505
+ else if (target.provider === "opencode-zen") {
2506
+ setActiveEndpoint(endpoint);
2507
+ }
2508
+ else {
2509
+ setActiveEndpoint(chatEndpointFor(target.provider, baseURL));
2510
+ }
2511
+ setModelBoth(target.model);
2512
+ setEffortBoth(target.effort);
2513
+ setModeBoth(target.mode);
2514
+ setUsageBoth(target.usageTotals);
2515
+ // Replacement: the target's arrays replace the live ones wholesale (a
2516
+ // fresh-created record carries empty history — fall back to a fresh
2517
+ // system line so the system-first invariant always holds).
2518
+ historyRef.current =
2519
+ target.history.length > 0
2520
+ ? trackHistory([...target.history])
2521
+ : trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]);
2522
+ if (target.history.length > 0) {
2523
+ refreshSystemEnv();
2524
+ }
2525
+ lastPromptTokensRef.current = undefined;
2526
+ if (!usageRef.current) {
2527
+ setContextLoadBoth(null);
2528
+ }
2529
+ else {
2530
+ setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
2531
+ }
2532
+ autoStreakRef.current = 0;
2533
+ setAutoDisabledBoth(false);
2534
+ pendingCompactRef.current = null;
2535
+ const pendingNotices = [];
2536
+ // New lineage (see src/rollback.ts): checkpoint marks index the old
2537
+ // history — drop them, loudly when non-empty. Disk files untouched.
2538
+ const switchDrops = clearSnapshots();
2539
+ if (switchDrops > 0) {
2540
+ pendingNotices.push({
2541
+ role: "tool",
2542
+ content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
2543
+ });
2544
+ }
2545
+ // TODO isolation (mirrors /new): the checklist is process-global memory
2546
+ // that is never persisted — carrying it across sessions would show the
2547
+ // new session the old session's plan, and the agent would act on it.
2548
+ // Reset it, loudly when non-empty.
2549
+ if (getTodos().length > 0) {
2550
+ clearTodos();
2551
+ setTodoSnap([]);
2552
+ pendingNotices.push({
2553
+ role: "tool",
2554
+ content: "(/session — checklist reset; TODOs are per-conversation and do not cross sessions)",
2555
+ });
2556
+ }
2557
+ else {
2558
+ clearTodos();
2559
+ setTodoSnap([]);
2560
+ }
2561
+ contextManager().trimForSend(historyRef.current, (msg) => {
2562
+ pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2563
+ }, undefined, openTodoNeedles());
2564
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
2565
+ pendingNotices.push({ role: "tool", content: section });
2566
+ }
2567
+ // Same Static remount as /clear, /resume, and /new: the replaced list
2568
+ // must not reuse the old buffer.
2569
+ setScrollEndBoth(null);
2570
+ setClearGen((g) => g + 1);
2571
+ setTurnsBoth([
2572
+ ...target.turns,
2573
+ {
2574
+ role: "tool",
2575
+ content: `(switched to session "${target.title}" — ${target.turns.length} turns)`,
2576
+ },
2577
+ ...pendingNotices,
2578
+ ]);
2579
+ setSessionTitleBoth(target.title);
2580
+ telemetry.recordEvent("info", `session switched to ${target.id} (${target.turns.length} turns)`);
2581
+ persistTelemetry();
2582
+ // Legacy single-file save follows the switch so /resume restores what
2583
+ // the live view shows (same path/format as every completed turn).
2584
+ persistSession();
2131
2585
  }
2132
2586
  // /rewind conversation scope (ticket 01): truncate history + transcript to
2133
2587
  // the checkpoint's turn. The cut drops the whole containing turn (submit's
@@ -2322,19 +2776,54 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2322
2776
  ? "(thinking shown — model reasoning stays visible in the transcript)"
2323
2777
  : "(thinking hidden — reasoning still runs, it just isn't rendered)");
2324
2778
  }
2779
+ // /rename for the CURRENT session only (never creates one: renameSession
2780
+ // touches exactly the active record). Bare /rename prints usage — ATOM has
2781
+ // no generic text-prompt overlay (key/baseURL prompts are
2782
+ // provider-specific), so an interactive flow would be a new UI system.
2783
+ // Empty/whitespace-only names are rejected safely (previous name kept).
2784
+ // On failure the previous name is preserved (title state only changes on
2785
+ // success); history/turns are never part of the write.
2786
+ function runRenameCommand(raw) {
2787
+ const name = parseRenameArg(raw);
2788
+ if (!name) {
2789
+ pushInfo(RENAME_USAGE);
2790
+ return;
2791
+ }
2792
+ let id = null;
2793
+ try {
2794
+ id = ensureStoreSession();
2795
+ }
2796
+ catch {
2797
+ id = null;
2798
+ }
2799
+ if (!id) {
2800
+ pushInfo("(rename failed — session store unavailable; name unchanged)");
2801
+ return;
2802
+ }
2803
+ let renamed = null;
2804
+ try {
2805
+ renamed = renameSession(id, name, authHome);
2806
+ }
2807
+ catch {
2808
+ renamed = null;
2809
+ }
2810
+ if (!renamed) {
2811
+ const current = sessionTitleRef.current || "untitled";
2812
+ pushInfo(`(rename failed — still "${current}")`);
2813
+ return;
2814
+ }
2815
+ setSessionTitleBoth(renamed.title);
2816
+ pushInfo(`(renamed session to "${renamed.title}")`);
2817
+ }
2325
2818
  // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
2326
2819
  // only state — safe while busy (never touches the turn, like /queue).
2327
- // Bare prints the state; on jumps to the latest; off freezes a following
2820
+ // Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
2328
2821
  // view at its current end (mid-turn appends then accumulate below).
2329
2822
  function runAutoScrollCommand(raw) {
2330
2823
  const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
2331
- if (arg === "") {
2332
- pushInfo(autoScrollRef.current
2333
- ? "(autoscroll on — following new output as it arrives)"
2334
- : "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2335
- return;
2336
- }
2337
- if (arg === "on") {
2824
+ // Bare command toggles; explicit on|off sets directly.
2825
+ const effective = arg === "" ? (autoScrollRef.current ? "off" : "on") : arg;
2826
+ if (effective === "on") {
2338
2827
  if (autoScrollRef.current) {
2339
2828
  pushInfo("(autoscroll already on)");
2340
2829
  return;
@@ -2344,7 +2833,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2344
2833
  pushInfo("(autoscroll on — following the latest)");
2345
2834
  return;
2346
2835
  }
2347
- if (arg === "off") {
2836
+ if (effective === "off") {
2348
2837
  if (!autoScrollRef.current) {
2349
2838
  pushInfo("(autoscroll already off)");
2350
2839
  return;
@@ -2414,6 +2903,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2414
2903
  case "/clear":
2415
2904
  // Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
2416
2905
  // fresh env block. Fresh array → fresh ledger via trackHistory.
2906
+ // Store mirror: LAZY, matching legacy — /clear wipes the live
2907
+ // transcript only and writes nothing to the store here; the cleared
2908
+ // (empty) conversation persists on the next completed turn via
2909
+ // persistSession()/persistStoreSession().
2417
2910
  historyRef.current = trackHistory([
2418
2911
  { role: "system", content: withEnvBlock(systemPrompt) },
2419
2912
  ]);
@@ -2424,11 +2917,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2424
2917
  setScrollEndBoth(null);
2425
2918
  setClearGen((g) => g + 1);
2426
2919
  setError(null);
2427
- setDraft(null);
2920
+ streamStore.setDraft(null);
2428
2921
  clearThinking();
2429
2922
  setToolHint(null);
2430
- setPhase("idle");
2431
- setPhaseDetail("");
2923
+ setPhaseBoth("idle", "");
2432
2924
  // /clear drops the transcript: load resets (no context), streak
2433
2925
  // resets, pending compact drains, and turn-scoped skill grants go
2434
2926
  // with it (no invisible auto-approvals survive a wiped transcript).
@@ -2459,7 +2951,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2459
2951
  // restores — same path/format as every completed turn, no new
2460
2952
  // schema). No sessions/ archive step: session.ts only has
2461
2953
  // session.json, so no archiving is invented here.
2954
+ // persistSession() also mirrors the pre-/new conversation into the
2955
+ // OLD active store record before the reset below.
2462
2956
  persistSession();
2957
+ // Fresh store record for the new conversation (settings carried over,
2958
+ // empty history/turns/usage; default title = formatSessionTitle(now)
2959
+ // via createSession). Explicit setActiveSession: createSession only
2960
+ // claims the pointer when none is set.
2961
+ try {
2962
+ const created = createSession({
2963
+ cwd: storeCwd(),
2964
+ provider: providerRef.current,
2965
+ model: modelRef.current,
2966
+ effort: effortRef.current,
2967
+ mode: modeRef.current,
2968
+ }, authHome);
2969
+ setActiveSession(created.id, authHome);
2970
+ activeSessionIdRef.current = created.id;
2971
+ setSessionTitleBoth(created.title);
2972
+ }
2973
+ catch {
2974
+ // ignore disk errors (in-memory reset below still applies)
2975
+ }
2463
2976
  // Fresh system re-read (system.ts base + current AGENTS.md overlay)
2464
2977
  // plus a fresh Task 6 env block. Fresh array → fresh ledger.
2465
2978
  historyRef.current = trackHistory([
@@ -2475,11 +2988,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2475
2988
  setScrollEndBoth(null);
2476
2989
  setClearGen((g) => g + 1);
2477
2990
  setError(null);
2478
- setDraft(null);
2991
+ streamStore.setDraft(null);
2479
2992
  clearThinking();
2480
2993
  setToolHint(null);
2481
- setPhase("idle");
2482
- setPhaseDetail("");
2994
+ setPhaseBoth("idle", "");
2483
2995
  // /new-vs-/clear split: /clear wipes the transcript but KEEPS usage
2484
2996
  // totals; /new resets the counters too (fresh conversation). Session
2485
2997
  // SETTINGS (effort/mode/provider/model) are kept — only the
@@ -2612,6 +3124,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2612
3124
  case "/resume":
2613
3125
  doResume();
2614
3126
  return;
3127
+ case "/session":
3128
+ openSessionPicker("");
3129
+ return;
3130
+ case "/rename":
3131
+ // Bare exact match (slash-menu Enter on the highlighted name):
3132
+ // usage — the typed-args form is preserved by the menu branch and
3133
+ // the submit prefix route above.
3134
+ runRenameCommand("/rename");
3135
+ return;
2615
3136
  case "/telemetry":
2616
3137
  pushInfo(telemetrySummaryText());
2617
3138
  return;
@@ -2818,6 +3339,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2818
3339
  await runCompactCommand(focus);
2819
3340
  return;
2820
3341
  }
3342
+ // /rename with optional name: prefix match ("/rename" or "/rename ...").
3343
+ // Instant local store op — safe while busy (the turn-end persist never
3344
+ // carries a title, so it cannot clobber the rename).
3345
+ if (text === "/rename" || text.startsWith("/rename ")) {
3346
+ runRenameCommand(text);
3347
+ return;
3348
+ }
2821
3349
  // SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
2822
3350
  // nothing). Busy guard + API-key check: rejections return before any
2823
3351
  // history mutation, so there is nothing to roll back.
@@ -2887,6 +3415,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2887
3415
  void runModelsCommand(arg);
2888
3416
  return;
2889
3417
  }
3418
+ // /session takes an optional initial filter ("/session auth" opens the
3419
+ // picker pre-filtered) — SLASH_NAMES only holds the exact command.
3420
+ if (text === "/session" || text.startsWith("/session ")) {
3421
+ const initial = text === "/session" ? "" : text.slice("/session".length).trim();
3422
+ openSessionPicker(initial);
3423
+ return;
3424
+ }
2890
3425
  // Exact full-command + Enter runs it. A single-token "/name" not in
2891
3426
  // SLASH_NAMES resolves through the skill registry (ticket 03, legacy
2892
3427
  // form — the namespaced `/skill:name` below is canonical); anything
@@ -2934,7 +3469,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2934
3469
  busyRef.current = true;
2935
3470
  setBusy(true);
2936
3471
  setError(null);
2937
- setDraft(null);
3472
+ streamStore.setDraft(null);
2938
3473
  clearThinking();
2939
3474
  // Fresh turn, fresh latch: the queue drain at the end auto-sends only
2940
3475
  // when this turn was NOT cancelled (see the turn-end finally).
@@ -2944,13 +3479,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2944
3479
  // Expiry happens in the turn-end finally below, plus /clear + /new.
2945
3480
  try {
2946
3481
  draftThrottler().reset();
3482
+ thinkingThrottler().reset();
2947
3483
  }
2948
3484
  catch {
2949
3485
  // ignore (first token still paints; at worst one window late)
2950
3486
  }
2951
3487
  setToolHint(null);
2952
- setPhase("thinking");
2953
- setPhaseDetail("");
3488
+ setPhaseBoth("thinking", "");
2954
3489
  // Phase 5: start the elapsed/stall timer (status-bar only, never the
2955
3490
  // transcript). Cleared in finally below and on unmount.
2956
3491
  startTurnTimer();
@@ -3056,19 +3591,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3056
3591
  draftThrottler().push(partial);
3057
3592
  }
3058
3593
  catch {
3059
- setDraft(partial);
3594
+ // Never lose tokens: paint now rather than drop the partial.
3595
+ streamStore.setDraft(partial);
3060
3596
  }
3061
3597
  lastPartialRef.current = partial;
3062
3598
  noteTurnActivity();
3063
3599
  },
3064
3600
  onThinking: (partial) => {
3065
3601
  thinkingRef.current = partial;
3066
- setThinking(partial);
3602
+ try {
3603
+ thinkingThrottler().push(partial);
3604
+ }
3605
+ catch {
3606
+ // Never lose reasoning: paint now rather than drop the partial.
3607
+ streamStore.setThinking(partial);
3608
+ }
3067
3609
  noteTurnActivity();
3068
3610
  },
3069
3611
  onPhase: (p, detail) => {
3070
- setPhase(p);
3071
- setPhaseDetail(detail ?? "");
3612
+ // Change-guarded (zen emits "streaming" per content chunk):
3613
+ // steady-state tokens issue zero setStates here.
3614
+ setPhaseBoth(p, detail ?? "");
3072
3615
  noteTurnActivity();
3073
3616
  if (p === "thinking") {
3074
3617
  // New POST: the previous round's thinking (if any) commits to
@@ -3340,14 +3883,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3340
3883
  catch {
3341
3884
  // ignore
3342
3885
  }
3343
- setDraft(null);
3886
+ streamStore.setDraft(null);
3344
3887
  clearThinking();
3345
3888
  setToolHint(null);
3346
3889
  clearTurnTimer();
3347
- setStalled(false);
3890
+ setStalledBoth(false);
3348
3891
  setElapsedSecs(0);
3349
- setPhase("idle");
3350
- setPhaseDetail("");
3892
+ setPhaseBoth("idle", "");
3351
3893
  // Queue drain (Claude-Code-style): a clean turn auto-sends the next
3352
3894
  // queued follow-up (chaining while the queue is non-empty); a cancelled
3353
3895
  // turn keeps its queue visible but never auto-sends. A steer stranded
@@ -3751,6 +4293,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3751
4293
  }
3752
4294
  return;
3753
4295
  }
4296
+ // 3a2. Session picker (interactive switcher, same pattern as /skills:
4297
+ // type to filter, ↑/↓ + Enter switches, Esc cancels with the live
4298
+ // session completely unchanged). The list is the open-time snapshot —
4299
+ // filtering never touches disk. Enter on an empty filtered list only
4300
+ // prints a widen hint (no switch, no state change).
4301
+ if (selectingSession) {
4302
+ // Rebuilt per keypress from the same render's state the paint uses, so
4303
+ // highlight/filter/paint never disagree mid-tick.
4304
+ const entries = filterSessionEntries(sessionItems, sessionFilterRef.current);
4305
+ if (key.upArrow) {
4306
+ if (entries.length > 0) {
4307
+ setSessionIndexBoth((sessionIndexRef.current - 1 + entries.length) % entries.length);
4308
+ }
4309
+ }
4310
+ else if (key.downArrow) {
4311
+ if (entries.length > 0) {
4312
+ setSessionIndexBoth((sessionIndexRef.current + 1) % entries.length);
4313
+ }
4314
+ }
4315
+ else if (key.escape) {
4316
+ setSessionFilterBoth("");
4317
+ setSelectingSession(false);
4318
+ }
4319
+ else if (key.return) {
4320
+ const picked = entries[sessionIndexRef.current];
4321
+ setSessionFilterBoth("");
4322
+ setSelectingSession(false);
4323
+ if (picked) {
4324
+ exitHistoryBrowse();
4325
+ switchToSession(picked.id);
4326
+ }
4327
+ else {
4328
+ pushInfo("(no sessions match — backspace to widen the filter.)");
4329
+ }
4330
+ }
4331
+ else if (key.backspace || key.delete) {
4332
+ setSessionFilterBoth(sessionFilterRef.current.slice(0, -1));
4333
+ setSessionIndexBoth(0);
4334
+ }
4335
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
4336
+ setSessionFilterBoth(sessionFilterRef.current + ch);
4337
+ setSessionIndexBoth(0);
4338
+ }
4339
+ return;
4340
+ }
3754
4341
  // 3b. Effort picker (/effort): same keyboard pattern as the /model
3755
4342
  // picker (↑/↓ + Enter, Esc cancels).
3756
4343
  if (selectingEffort) {
@@ -3897,6 +4484,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3897
4484
  setInputBoth("");
3898
4485
  runRulesCommand(raw);
3899
4486
  }
4487
+ else if (pick.name === "/rename" &&
4488
+ (inputRef.current === "/rename" || inputRef.current.startsWith("/rename "))) {
4489
+ // Preserve the typed name (e.g. '/rename "Build auth"'); a bare
4490
+ // highlighted name falls through to usage.
4491
+ const raw = inputRef.current;
4492
+ setInputBoth("");
4493
+ runRenameCommand(raw);
4494
+ }
3900
4495
  else {
3901
4496
  runSlashCommand(pick.name);
3902
4497
  }
@@ -3995,7 +4590,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3995
4590
  // compact/queue/steer fire while busy.
3996
4591
  if (key.ctrl && (ch === "p" || ch === "P")) {
3997
4592
  if (!pendingApproval && !pendingQuestion &&
3998
- !selecting && !selectingSkills && !selectingProvider &&
4593
+ !selecting && !selectingSkills && !selectingSession && !selectingProvider &&
3999
4594
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
4000
4595
  !selectingRewind && !selectingRewindScope && !inspecting) {
4001
4596
  if (paletteOpenRef.current)
@@ -4152,6 +4747,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4152
4747
  !pendingQuestion &&
4153
4748
  !selecting &&
4154
4749
  !selectingSkills &&
4750
+ !selectingSession &&
4155
4751
  !selectingProvider &&
4156
4752
  !keyPrompt &&
4157
4753
  !baseURLPrompt &&
@@ -4177,7 +4773,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4177
4773
  : `thinking${theme.symbol.ellipsis}`;
4178
4774
  // Slash menu derived for render (mirrors the useInput computation above):
4179
4775
  // commands first, then matching skills as `/skill:name` entries.
4180
- const slashMenu = !selecting &&
4776
+ // Memoized on every gate input: without this the fuzzy matcher rebuilds
4777
+ // on each 1s tick and tool append even though the menu only depends on
4778
+ // input + overlay state.
4779
+ const slashMenu = useMemo(() => !selecting &&
4181
4780
  !selectingSkills &&
4182
4781
  !selectingEffort &&
4183
4782
  !selectingProvider &&
@@ -4191,7 +4790,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4191
4790
  input.startsWith("/") &&
4192
4791
  !input.includes("\n")
4193
4792
  ? buildSlashMenu(input, skillMenu)
4194
- : { items: [], moreSkills: 0 };
4793
+ : { items: [], moreSkills: 0 },
4794
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4795
+ [
4796
+ selecting,
4797
+ selectingSkills,
4798
+ selectingEffort,
4799
+ selectingProvider,
4800
+ keyPrompt,
4801
+ baseURLPrompt,
4802
+ pendingApproval,
4803
+ pendingQuestion,
4804
+ selectingRewind,
4805
+ selectingRewindScope,
4806
+ slashDismissed,
4807
+ input,
4808
+ skillMenu,
4809
+ ]);
4195
4810
  const filteredSlash = slashMenu.items;
4196
4811
  const slashVisible = filteredSlash.length > 0;
4197
4812
  const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
@@ -4205,9 +4820,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4205
4820
  // Unified /model picker derived for render (mirrors the useInput
4206
4821
  // computation above): full entries, filtered entries, clamped highlight,
4207
4822
  // and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
4208
- // rows no matter how many models providers list.
4209
- const modelEntriesAll = selecting ? buildModelEntries() : [];
4210
- const modelEntries = selecting ? filterModelEntries(modelEntriesAll, modelFilter) : [];
4823
+ // rows no matter how many providers list. Memoized: the registry walk
4824
+ // (all providers + fallbacks) must not rerun on ticks/appends while open.
4825
+ // Deps cover every read inside buildModelEntries: selecting gate, provider
4826
+ // mirror, live models, auth store (key presence), and the local snapshot
4827
+ // (loopback baseURLs feed the cache keys); cache-ref writes always land
4828
+ // alongside one of these setStates, so the memo can never go stale.
4829
+ const { modelEntriesAll, modelEntries } = useMemo(() => {
4830
+ const all = selecting ? buildModelEntries() : [];
4831
+ return {
4832
+ modelEntriesAll: all,
4833
+ modelEntries: selecting ? filterModelEntries(all, modelFilter) : [],
4834
+ };
4835
+ },
4836
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4837
+ [selecting, provider, models, auth, localSnap, modelFilter]);
4211
4838
  const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
4212
4839
  const modelWin = pickerWindow(modelEntries.length, modelHi);
4213
4840
  const modelTitle = `Atom — Select model (${modelEntries.length}` +
@@ -4216,13 +4843,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4216
4843
  // /skills picker derived for render (mirrors the useInput computation
4217
4844
  // above): names only, filtered, clamped highlight, visible window. The
4218
4845
  // title keeps the `Skills (` prefix the registry header always had.
4219
- const skillEntriesAll = selectingSkills ? skillPickerItems : [];
4220
- const skillEntries = selectingSkills ? filterSkillPicker(skillEntriesAll, skillFilter) : [];
4846
+ // Memoized for the same tick/append reason as the model picker above.
4847
+ const { skillEntriesAll, skillEntries } = useMemo(() => {
4848
+ const all = selectingSkills ? skillPickerItems : [];
4849
+ return {
4850
+ skillEntriesAll: all,
4851
+ skillEntries: selectingSkills ? filterSkillPicker(all, skillFilter) : [],
4852
+ };
4853
+ }, [selectingSkills, skillPickerItems, skillFilter]);
4221
4854
  const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
4222
4855
  const skillWin = pickerWindow(skillEntries.length, skillHi);
4223
4856
  const skillTitle = `Skills (${skillEntries.length}` +
4224
4857
  (skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
4225
4858
  `) — type to filter, up/down + Enter, Esc cancels:`;
4859
+ // /session picker derived for render (mirrors the useInput computation
4860
+ // above): open-time snapshot, filtered in memory, clamped highlight,
4861
+ // visible window. Memoized for the same tick/append reason as the pickers
4862
+ // above. nowMs via Date.now (render-time age labels, never persisted).
4863
+ const { sessionEntriesAll, sessionEntries } = useMemo(() => {
4864
+ const all = selectingSession ? sessionItems : [];
4865
+ return {
4866
+ sessionEntriesAll: all,
4867
+ sessionEntries: selectingSession ? filterSessionEntries(all, sessionFilter) : [],
4868
+ };
4869
+ }, [selectingSession, sessionItems, sessionFilter]);
4870
+ const sessionHi = sessionEntries.length === 0 ? 0 : Math.max(0, Math.min(sessionIndex, sessionEntries.length - 1));
4871
+ const sessionWin = pickerWindow(sessionEntries.length, sessionHi);
4872
+ const sessionPickerTitle = `Sessions (${sessionEntries.length}` +
4873
+ (sessionFilter ? ` of ${sessionEntriesAll.length}, filter: "${sessionFilter}"` : "") +
4874
+ `) — type to filter, up/down + Enter, Esc cancels:`;
4226
4875
  // Memoized render derivations (flicker fix): these rebuild arrays on every
4227
4876
  // App render (token paints, keystrokes, 1s ticks), which defeats the memo
4228
4877
  // on the leaf panels below. Memoized, the leaves skip everything but real
@@ -4231,6 +4880,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4231
4880
  const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
4232
4881
  // eslint-disable-next-line react-hooks/exhaustive-deps
4233
4882
  [selectingRewind]);
4883
+ // Approval description: the tool-call one-liner for the modal. Memoized on
4884
+ // the pending approval itself — the 1s busy tick keeps firing while the
4885
+ // modal waits, and must not rebuild the string (nor re-render the modal).
4886
+ const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
4234
4887
  // (The cursor clamp lives inside the memoized InputBox now, next to its
4235
4888
  // only use — App body no longer reads cursor state for paint.)
4236
4889
  // Status-line reasoning segment wired to the effort session state:
@@ -4251,7 +4904,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4251
4904
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
4252
4905
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
4253
4906
  : null;
4254
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: describeToolCall(pendingApproval.name, pendingApproval.args), selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
4907
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
4255
4908
  const i = modelWin.start + k;
4256
4909
  const entryLocal = e.local === true;
4257
4910
  const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
@@ -4266,7 +4919,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4266
4919
  return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
4267
4920
  }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
4268
4921
  ? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
4269
- : "No skills match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
4922
+ : "No skills match — backspace to widen the filter." })) : null] })) : selectingSession ? (_jsxs(PickerShell, { title: sessionPickerTitle, children: [_jsx(PickerMoreAbove, { count: sessionWin.start }), sessionEntries.slice(sessionWin.start, sessionWin.end).map((e, k) => {
4923
+ const i = sessionWin.start + k;
4924
+ const age = formatSessionAge(Date.now(), e.updatedAt);
4925
+ return (_jsxs(PickerRow, { highlighted: i === sessionHi, children: [e.title, e.active ? " (current)" : "", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", e.turnCount, " turn", e.turnCount === 1 ? "" : "s", " \u00B7 ", age] })] }, `${e.id}-${i}`));
4926
+ }), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
4927
+ ? "No sessions yet — your current conversation is saved automatically."
4928
+ : "No sessions match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
4270
4929
  const has = keyForProvider(p.id).length > 0;
4271
4930
  const keyMark = isLocalProviderId(p.id)
4272
4931
  ? `local ${theme.symbol.descSeparator} no key needed`
@@ -4284,5 +4943,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4284
4943
  // border color.
4285
4944
  _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
4286
4945
  ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
4287
- : `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBar, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, columns: termColumns })] }));
4946
+ : `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null })] }));
4288
4947
  }