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