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/CHANGELOG.md +31 -0
- package/README.md +5 -4
- package/dist/App.js +738 -79
- package/dist/adapters.js +30 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +212 -69
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -13
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/registry.js +1 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +1 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +2 -0
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +66 -13
- package/package.json +1 -1
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
|
|
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 {
|
|
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 {
|
|
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: "
|
|
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] —
|
|
114
|
-
export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only:
|
|
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
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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:
|
|
1006
|
-
//
|
|
1007
|
-
// (
|
|
1008
|
-
//
|
|
1009
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3071
|
-
|
|
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
|
-
|
|
3890
|
+
setStalledBoth(false);
|
|
3348
3891
|
setElapsedSecs(0);
|
|
3349
|
-
|
|
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
|
-
|
|
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
|
|
4209
|
-
|
|
4210
|
-
|
|
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
|
-
|
|
4220
|
-
const skillEntries =
|
|
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(
|
|
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] })) :
|
|
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(
|
|
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
|
}
|