atom-agent 0.3.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 +27 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/App.js +2428 -0
- package/dist/adapters.js +926 -0
- package/dist/auth.js +122 -0
- package/dist/cli.js +28 -0
- package/dist/compact.js +277 -0
- package/dist/context-windows.js +112 -0
- package/dist/env-block.js +166 -0
- package/dist/permissions.js +129 -0
- package/dist/providers.js +224 -0
- package/dist/session.js +218 -0
- package/dist/skills.js +283 -0
- package/dist/snapshots.js +243 -0
- package/dist/system.js +22 -0
- package/dist/tools.js +1867 -0
- package/dist/zen.js +1862 -0
- package/package.json +54 -0
package/dist/App.js
ADDED
|
@@ -0,0 +1,2428 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Ink (React) TUI for the minimal Atom chatbot.
|
|
3
|
+
// Hand-rolled input + dropdowns via useInput (no extra deps).
|
|
4
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
6
|
+
import { EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, runAgenticLoopForProvider, truncateHistory, } from "./zen.js";
|
|
7
|
+
import { TOOL_ONE_LINERS, clearTodos, describeToolCall, executeTool, getTodos, needsApproval } from "./tools.js";
|
|
8
|
+
import { checkRules, formatRules, parseRuleInput, } from "./permissions.js";
|
|
9
|
+
import { discoverSkills, loadSkillBody, matchSkills, resolveSkills, skillsListText, } from "./skills.js";
|
|
10
|
+
import { contextWindowFor, formatTokenSegment } from "./context-windows.js";
|
|
11
|
+
import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, computeContextLoad, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, shouldAutoCompact, splitHistoryForCompaction, } from "./compact.js";
|
|
12
|
+
import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isProviderId, maskKey, openaiCompatibleChatEndpoint, validateBaseURL, } from "./providers.js";
|
|
13
|
+
import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
|
|
14
|
+
import { validateProviderKey } from "./adapters.js";
|
|
15
|
+
import { withEnvBlock } from "./env-block.js";
|
|
16
|
+
import { loadSession, saveSession, sessionExists, } from "./session.js";
|
|
17
|
+
import { conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
|
|
18
|
+
import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
19
|
+
// Single registry for the "/" autocomplete menu and the exact-command path.
|
|
20
|
+
export const SLASH_COMMANDS = [
|
|
21
|
+
{ name: "/model", description: "Open the model picker." },
|
|
22
|
+
{ name: "/provider", description: "Pick AI provider, paste API key once, chat." },
|
|
23
|
+
{
|
|
24
|
+
name: "/effort",
|
|
25
|
+
description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
|
|
26
|
+
},
|
|
27
|
+
{ name: "/tools", description: "List the 7 tools with one-line descriptions." },
|
|
28
|
+
{ name: "/skills", description: "List installed skills (project + global)." },
|
|
29
|
+
{ name: "/mode", description: "Print the current permission mode." },
|
|
30
|
+
{ name: "/yolo", description: "Toggle yolo mode (tools run without asking). Tab toggles too." },
|
|
31
|
+
{ name: "/trust", description: "Toggle session trust: auto-approve write/edit/bash without full yolo (/trust again revokes)." },
|
|
32
|
+
{ name: "/plan", description: "Enter/exit read-only plan mode (explore freely; write/edit/bash blocked; exiting approves the todo plan)." },
|
|
33
|
+
{ name: "/allow", description: "Pre-approve a tool pattern this session (e.g. /allow bash:npm test*)." },
|
|
34
|
+
{ name: "/deny", description: "Forbid a tool pattern this session — deny wins over trust/yolo (e.g. /deny bash:rm *)." },
|
|
35
|
+
{ name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
|
|
36
|
+
{ name: "/clear", description: "Clear the conversation history (keeps session token totals)." },
|
|
37
|
+
{ name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
|
|
38
|
+
{ name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
|
|
39
|
+
{ name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
|
|
40
|
+
{ name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
|
|
41
|
+
{ name: "/help", description: "List commands with one-liners." },
|
|
42
|
+
{ name: "/exit", description: "Exit Atom." },
|
|
43
|
+
{ name: "/quit", description: "Exit Atom." },
|
|
44
|
+
];
|
|
45
|
+
const SLASH_NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
|
|
46
|
+
// /rewind restore scope (ticket 01): files only, files + conversation, or
|
|
47
|
+
// conversation only. Files-only is the default highlight (safest).
|
|
48
|
+
export const REWIND_SCOPES = ["files only", "files + conversation", "conversation only"];
|
|
49
|
+
// Submit-time pipeline order (ticket 02): submit() below reads as one
|
|
50
|
+
// ordered sequence — permissions → context assembly → budget check → loop
|
|
51
|
+
// entry — so future submit-time work has exactly one home stage. The
|
|
52
|
+
// rollback-scope rule per stage states what a failed turn keeps vs drops.
|
|
53
|
+
// This descriptor is the order test's source of truth:
|
|
54
|
+
// tests/submit-order.test.ts pins both this order and the matching
|
|
55
|
+
// `SUBMIT STAGE n/4` markers inside submit().
|
|
56
|
+
export const SUBMIT_PIPELINE_STAGES = [
|
|
57
|
+
{
|
|
58
|
+
name: "permissions",
|
|
59
|
+
rollbackScope: "pre-turn: rejections append nothing, so history is untouched",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "context-assembly",
|
|
63
|
+
rollbackScope: "pre-rollbackTo: the env-block refresh survives a failed turn (it is not part of the user turn)",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "budget-check",
|
|
67
|
+
rollbackScope: "pre-rollbackTo: the budget trim survives a failed turn (rollback indices are captured after it)",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "loop-entry",
|
|
71
|
+
rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
export function filterSlashCommands(prefix) {
|
|
75
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
|
|
76
|
+
}
|
|
77
|
+
// Phase 5 observability + latency polish (surgical, three items only):
|
|
78
|
+
// - TURN_TICK_MS: elapsed-time resolution while busy (1s).
|
|
79
|
+
// - TURN_STALL_AFTER_MS: silence threshold for the dim `waiting…` hint (>3s
|
|
80
|
+
// with no token/tool/phase activity, status-bar only, never transcript).
|
|
81
|
+
export const TURN_TICK_MS = 1000;
|
|
82
|
+
export const TURN_STALL_AFTER_MS = 3000;
|
|
83
|
+
// Phase 5 models-list session cache key: provider id (+baseURL for
|
|
84
|
+
// openai-compatible, whose list depends on the custom endpoint).
|
|
85
|
+
export function modelsCacheKey(providerId, baseURL) {
|
|
86
|
+
if (providerId === "openai-compatible")
|
|
87
|
+
return `${providerId}|${baseURL ?? ""}`;
|
|
88
|
+
return providerId;
|
|
89
|
+
}
|
|
90
|
+
// Pure helpers for the elapsed/stall indicator (injectable now for tests).
|
|
91
|
+
export function elapsedSecsSince(startMs, nowMs) {
|
|
92
|
+
return Math.max(0, Math.floor((nowMs - startMs) / 1000));
|
|
93
|
+
}
|
|
94
|
+
export function isStalledSince(lastActivityMs, nowMs) {
|
|
95
|
+
return nowMs - lastActivityMs > TURN_STALL_AFTER_MS;
|
|
96
|
+
}
|
|
97
|
+
// Task B smoothness (a): streaming-draft throttle. Token bursts (many
|
|
98
|
+
// onToken calls per frame) would otherwise re-render the whole tree per
|
|
99
|
+
// token; paints coalesce to at most one per trailing window, with
|
|
100
|
+
// flush-on-done so the exact full text always lands. Injectable now/clock
|
|
101
|
+
// for tests.
|
|
102
|
+
export const DRAFT_THROTTLE_MS = 64;
|
|
103
|
+
export function createDraftThrottler(opts) {
|
|
104
|
+
const intervalMs = opts.intervalMs ?? DRAFT_THROTTLE_MS;
|
|
105
|
+
const nowFn = opts.now ?? Date.now;
|
|
106
|
+
const setT = opts.setTimeoutFn ?? setTimeout;
|
|
107
|
+
const clearT = opts.clearTimeoutFn ?? clearTimeout;
|
|
108
|
+
const onFlush = opts.onFlush;
|
|
109
|
+
let pending = null;
|
|
110
|
+
let lastFlush = Number.NEGATIVE_INFINITY;
|
|
111
|
+
let timer = null;
|
|
112
|
+
function safeNow() {
|
|
113
|
+
try {
|
|
114
|
+
return nowFn();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return Date.now();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function clearTimer() {
|
|
121
|
+
if (timer !== null) {
|
|
122
|
+
try {
|
|
123
|
+
clearT(timer);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore clock errors (a stray trailing paint is harmless)
|
|
127
|
+
}
|
|
128
|
+
timer = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Immediate paint path (also the never-lose-tokens fallback).
|
|
132
|
+
function emit(text, at) {
|
|
133
|
+
clearTimer();
|
|
134
|
+
pending = null;
|
|
135
|
+
lastFlush = at;
|
|
136
|
+
onFlush(text);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
push(text) {
|
|
140
|
+
pending = text;
|
|
141
|
+
const t = safeNow();
|
|
142
|
+
if (t - lastFlush >= intervalMs) {
|
|
143
|
+
emit(text, t);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (timer !== null)
|
|
147
|
+
return; // trailing paint already scheduled
|
|
148
|
+
const wait = intervalMs - (t - lastFlush);
|
|
149
|
+
try {
|
|
150
|
+
timer = setT(() => {
|
|
151
|
+
timer = null;
|
|
152
|
+
const latest = pending;
|
|
153
|
+
if (latest === null)
|
|
154
|
+
return;
|
|
155
|
+
emit(latest, safeNow());
|
|
156
|
+
}, Math.max(0, wait));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// No timer available: paint now rather than lose the token.
|
|
160
|
+
emit(text, safeNow());
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
flush() {
|
|
164
|
+
if (pending === null) {
|
|
165
|
+
clearTimer();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
emit(pending, safeNow());
|
|
169
|
+
},
|
|
170
|
+
cancel() {
|
|
171
|
+
clearTimer();
|
|
172
|
+
pending = null;
|
|
173
|
+
},
|
|
174
|
+
reset() {
|
|
175
|
+
clearTimer();
|
|
176
|
+
pending = null;
|
|
177
|
+
lastFlush = Number.NEGATIVE_INFINITY;
|
|
178
|
+
},
|
|
179
|
+
getPending() {
|
|
180
|
+
return pending;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function renderTranscriptItem(item) {
|
|
185
|
+
if (!item.turn)
|
|
186
|
+
return _jsx(StartupBanner, {}, item.id);
|
|
187
|
+
const t = item.turn;
|
|
188
|
+
const i = item.id;
|
|
189
|
+
if (t.role === "user") {
|
|
190
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["you>", " "] }), t.content] }, i));
|
|
191
|
+
}
|
|
192
|
+
if (t.role === "tool") {
|
|
193
|
+
return (_jsx(Text, { color: t.error ? "red" : undefined, dimColor: !t.error, children: t.content }, i));
|
|
194
|
+
}
|
|
195
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["ATOM>", " "] }), t.content] }, i));
|
|
196
|
+
}
|
|
197
|
+
// Render-count probe for the timer-isolation test: incremented on every
|
|
198
|
+
// TranscriptView render (a 1s timer tick must leave it unchanged).
|
|
199
|
+
export const transcriptRenderProbe = { count: 0 };
|
|
200
|
+
export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, }) {
|
|
201
|
+
transcriptRenderProbe.count += 1;
|
|
202
|
+
const render = renderItem ?? renderTranscriptItem;
|
|
203
|
+
const items = clearGen === 0
|
|
204
|
+
? [{ id: "banner" }, ...turns.map((turn, idx) => ({ id: `turn-${idx}`, turn }))]
|
|
205
|
+
: [...turns.map((turn, idx) => ({ id: `turn-${idx}`, turn }))];
|
|
206
|
+
return (_jsx(Static, { items: items, children: (item) => render(item) }, `transcript-${clearGen}`));
|
|
207
|
+
});
|
|
208
|
+
function toolsListText() {
|
|
209
|
+
const lines = Object.entries(TOOL_ONE_LINERS).map(([n, d]) => `${n} — ${d}`);
|
|
210
|
+
return `Tools (${lines.length}):\n${lines.join("\n")}`;
|
|
211
|
+
}
|
|
212
|
+
// Display window for the live thinking block: reasoning streams can run
|
|
213
|
+
// long, so only the frontier (tail) paints. The full text is never stored
|
|
214
|
+
// anywhere else — thinking stays transient, like the answer draft.
|
|
215
|
+
const THINKING_DISPLAY_CAP = 1200;
|
|
216
|
+
// Live session checklist (Claude-Code-style TodoWrite panel). Mounted in
|
|
217
|
+
// the live area below the transcript (NOT in <Static> scrollback) and fed
|
|
218
|
+
// by a snapshot the loop refreshes after every todowrite/todo_update call,
|
|
219
|
+
// so the in-progress row — shown with its activeForm when present — always
|
|
220
|
+
// answers "what is the model doing right now". Returns null when empty.
|
|
221
|
+
export function TodoPanel({ items }) {
|
|
222
|
+
if (items.length === 0)
|
|
223
|
+
return null;
|
|
224
|
+
const done = items.filter((t) => t.status === "completed").length;
|
|
225
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Tasks ", done, "/", items.length] }), items.map((t, i) => {
|
|
226
|
+
const mark = t.status === "completed" ? "✅" : t.status === "in_progress" ? "🔧" : "❌";
|
|
227
|
+
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
|
228
|
+
return (_jsxs(Text, { dimColor: t.status === "completed", children: [mark, " ", label, t.priority ? ` (${t.priority})` : ""] }, `${i}-${t.content}`));
|
|
229
|
+
})] }));
|
|
230
|
+
}
|
|
231
|
+
export function helpListText() {
|
|
232
|
+
const lines = SLASH_COMMANDS.map((c) => `${c.name} — ${c.description}`);
|
|
233
|
+
return (`Commands:\n${lines.join("\n")}` +
|
|
234
|
+
`\nTab toggles normal/yolo mode (in the / command menu, Tab runs the highlighted command). Tab never enters or exits plan mode (a stray keypress can't drop the safety mode — use /plan).` +
|
|
235
|
+
`\n/plan toggles read-only plan mode for risky work: explore with read/grep/glob/webfetch/websearch/todos/ask_question (all run free) while write/edit/bash are blocked pre-execution with a replan note (never a prompt, never silent — the ⚙ audit line still renders). Scoped /deny rules still win in plan mode; /allow, /trust, yolo, [a]lways, and skill grants cannot punch through it (/yolo and /trust while in plan stay read-only with a notice — exit with /plan first). Exiting is the human approval: /plan from plan mode returns to normal (never yolo) and the todowrite checklist recorded while planning carries into implementation.` +
|
|
236
|
+
`\n/trust toggles the session trust tier: with trust on, write/edit/bash auto-approve (one approval covers the whole task) without global yolo. Default off, normal mode stays the default; in-memory only, never saved. Every auto-approved call still renders its ⚙ line. The approval prompt also offers [t]rust-all mid-run; [n]/Esc still denies one call, Ctrl+C (or Esc while busy) still cancels the whole turn.` +
|
|
237
|
+
`\n/allow <tool[:glob]> pre-approves matching write/edit/bash calls this session (no prompt; e.g. /allow bash:npm test*, /allow write:src/**; bare /allow bash matches any args). /deny <tool[:glob]> refuses matching calls before execution — the model sees the standard denial result and replans. Deny wins over /trust, yolo, [a]lways, and skill grants. Every auto-approved call still renders its ⚙ line. Rules are in-memory only (like /trust, never saved); /rules lists them, /rules clear wipes them.` +
|
|
238
|
+
`\nToken totals accumulate per session from API-reported usage only: the status line shows \`token: n/a\` until the API reports usage (never estimated, never 0-by-default); with usage it shows \`token: (P%) NK\` — NK is the cumulative session spend in K, P% is the CURRENT context load over the model's verified window (last POST prompt_tokens, else the 4ch/token estimate; models with no verified window show a bare \`token: NK\`, never an invented percent). /clear keeps the totals; /new resets them.` +
|
|
239
|
+
`\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~8000 estimated tokens, tool outputs capped at 2000 chars). Tiny history (≤1 user turn) reports \`(nothing to compact)\`. Works for unknown-window models (estimate only for the tail split).` +
|
|
240
|
+
`\nAuto-compact: after every completed turn the load is checked; on known-window models with load/window ≥ ${Math.round(COMPACT_PCT_DEFAULT * 100)}% (env ATOM_COMPACT_PCT percent, clamped 50–95, invalid→default) history auto-compacts before the next turn. Unknown-window models never auto-compact — use /compact manually.` +
|
|
241
|
+
`\nThrash guard: 3 auto-compactions without the load dropping below threshold disables auto for the session with \`(auto-compact thrashing — disabled, use /compact or /clear)\`; manual /compact still works and resets the counter on success.` +
|
|
242
|
+
`\n/provider: pick opencode-zen|openai|anthropic|deepseek|mistral|google-gemini|openai-compatible, paste a key once (stored in ~/.atom/auth.json, env wins). Switching provider keeps session history text; system prompt stays.` +
|
|
243
|
+
`\n/effort options: Default/Low/Medium/High/Max (wire: default/low/medium/high/max; Default omits reasoning_effort).` +
|
|
244
|
+
`\nNote: xhigh was requested but only Max is verified, so the top setting is Max, sent as max.` +
|
|
245
|
+
`\nGating: reasoning_effort is sent ONLY when effort != Default AND the model is one of ${[...REASONING_EFFORT_SUPPORTED_MODELS].join(", ")} AND the provider is opencode-zen; otherwise omitted (setting kept, warning shown, status shows (unsupported)). Effort persists across /model switches.` +
|
|
246
|
+
`\n/resume: restores the last saved session (turns, history, provider/model/effort/mode, usage totals). Startup never auto-restores — sending a message without /resume starts fresh, and the next completed turn overwrites the save. /clear clears the live session only (the save keeps the pre-clear state until the next completed turn overwrites it). /new saves first, then starts a brand-new session (conversation + counters reset, settings kept) — so /resume right after /new restores the pre-/new conversation. Split: /clear = wipe transcript, keep counters; /new = full fresh conversation + counters reset, previous kept for /resume.` +
|
|
247
|
+
`\nSession autosave: every completed turn (and clean exit, plus after each successful compaction) writes ~/.atom/session.json (0600 POSIX, may contain pasted secrets — never commit it); failed/cancelled turns never touch it; a corrupt save loads as "(saved session unreadable — starting fresh)".` +
|
|
248
|
+
`\nBusy status shows the live phase plus elapsed seconds in the status line (· thinking… 4s); >3s without token/tool/phase activity adds a dim waiting… hint (status-bar only, never saved). ` +
|
|
249
|
+
`Reasoning streams in its own dim block above the answer draft while busy (transient — never committed); Esc stops a running response (same rollback as Ctrl+C).` +
|
|
250
|
+
`\n/rewind: every write/edit auto-snapshots prior bytes (silent, no prompt, no config); /rewind lists the session checkpoints and restores exact bytes (hash-verified, never a model rewrite) — files only, files + conversation, or conversation only. Shell side effects (bash) are explicitly out of scope: commands are never snapshotted and cannot be undone.`);
|
|
251
|
+
}
|
|
252
|
+
// Session token totals, accumulated ONLY from usage payloads the API
|
|
253
|
+
// actually reported. Null until the first usage payload arrives (rendered
|
|
254
|
+
// as `token: n/a` — never 0, which would imply measurement). The segment
|
|
255
|
+
// itself lives in ./context-windows.js (single source for the exact
|
|
256
|
+
// `token: (P%) NK` format); the footer status line is its only surface.
|
|
257
|
+
// P% tracks CURRENT context load (last prompt_tokens, else 4ch/token
|
|
258
|
+
// estimate); NK tracks cumulative session spend.
|
|
259
|
+
function formatTokens(usage, model, load) {
|
|
260
|
+
return formatTokenSegment(usage, model, load);
|
|
261
|
+
}
|
|
262
|
+
// Startup banner: the ATOM block-letter art, rendered once at launch inside
|
|
263
|
+
// <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
|
|
264
|
+
// ATOM (Unicode box-drawing — needs a monospace font with box-drawing
|
|
265
|
+
// support, which Windows Terminal / ConHost / most terminals have). The art
|
|
266
|
+
// is the whole banner: the footer status line is the sole info bar, so no
|
|
267
|
+
// hint lines live here.
|
|
268
|
+
export const ATOM_ART = [
|
|
269
|
+
" █████╗ ████████╗ ██████╗ ███╗ ███╗",
|
|
270
|
+
"██╔══██╗╚══██╔══╝██╔═══██╗████╗ ████║",
|
|
271
|
+
"███████║ ██║ ██║ ██║██╔████╔██║",
|
|
272
|
+
"██╔══██║ ██║ ██║ ██║██║╚██╔╝██║",
|
|
273
|
+
"██║ ██║ ██║ ╚██████╔╝██║ ╚═╝ ██║",
|
|
274
|
+
"╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
|
|
275
|
+
];
|
|
276
|
+
export function StartupBanner() {
|
|
277
|
+
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: ATOM_ART.map((line, i) => (_jsx(Text, { color: "cyan", bold: true, children: line }, i))) }));
|
|
278
|
+
}
|
|
279
|
+
// Error screen for a missing key (never print the key itself).
|
|
280
|
+
export function MissingKey() {
|
|
281
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Missing OPENCODE_ZEN_API_KEY." }), _jsx(Text, { children: "Copy .env.example -> set your key from https://opencode.ai/auth" }), _jsx(Text, { children: "Or run the TUI and use /provider to paste a key (stored in ~/.atom/auth.json)." }), _jsx(Text, { dimColor: true, children: "Then run `npm start` again." })] }));
|
|
282
|
+
}
|
|
283
|
+
export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, authHome, skillDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn }) {
|
|
284
|
+
const { exit } = useApp();
|
|
285
|
+
const [model, setModel] = useState(initialModel);
|
|
286
|
+
const modelRef = useRef(initialModel);
|
|
287
|
+
const [models, setModels] = useState(initialModels ?? [...FALLBACK_MODELS]);
|
|
288
|
+
// Active provider (default opencode-zen for backward compat).
|
|
289
|
+
const [provider, setProvider] = useState(initialProvider && isProviderId(initialProvider) ? initialProvider : DEFAULT_PROVIDER);
|
|
290
|
+
const providerRef = useRef(initialProvider && isProviderId(initialProvider) ? initialProvider : DEFAULT_PROVIDER);
|
|
291
|
+
// Auth store (env wins at resolve time; file holds pasted keys).
|
|
292
|
+
const [auth, setAuth] = useState(() => loadAuth(authHome));
|
|
293
|
+
const authRef = useRef(auth);
|
|
294
|
+
// Resolved keys/endpoints per active provider. apiKey/endpoint props seed
|
|
295
|
+
// the zen defaults (tests pass test-key; prod passes env-resolved values).
|
|
296
|
+
const [activeApiKey, setActiveApiKey] = useState(apiKey);
|
|
297
|
+
const activeApiKeyRef = useRef(apiKey);
|
|
298
|
+
const [activeEndpoint, setActiveEndpoint] = useState(endpoint);
|
|
299
|
+
// Permission mode (normal default, yolo toggled via /yolo). The footer
|
|
300
|
+
// status line always shows it (plus +trust when the session trust tier is
|
|
301
|
+
// on); modeRef mirrors it for async loop callbacks.
|
|
302
|
+
const [mode, setMode] = useState("normal");
|
|
303
|
+
const modeRef = useRef("normal");
|
|
304
|
+
// Tools the user approved with "always" this session (never re-prompt).
|
|
305
|
+
const alwaysAllowedRef = useRef(new Set());
|
|
306
|
+
// Turn-scoped skill grants (ticket 06): tools pre-approved by an invoked
|
|
307
|
+
// skill's `allowed-tools` for exactly one turn — the turn the skill was
|
|
308
|
+
// armed for (manual arming happens while idle, auto arming at submit).
|
|
309
|
+
// Cleared in the turn-end finally (any outcome) and on /clear + /new,
|
|
310
|
+
// mirroring Claude's grant-clears-on-next-message rule. In-memory only,
|
|
311
|
+
// never persisted.
|
|
312
|
+
const skillGrantsRef = useRef(new Set());
|
|
313
|
+
// Session trust tier (Task 4): per-session opt-in that auto-approves every
|
|
314
|
+
// approval tool (write/edit/bash) at once, without global yolo. Set via
|
|
315
|
+
// /trust or the [t] key in the approval prompt; revoked via /trust again.
|
|
316
|
+
// A separate flag (not folded into alwaysAllowedRef) so revoking restores
|
|
317
|
+
// per-tool prompting without disturbing individual [a] grants. In-memory
|
|
318
|
+
// only, like alwaysAllowedRef — never persisted, default off.
|
|
319
|
+
const [trustAll, setTrustAll] = useState(false);
|
|
320
|
+
const trustAllRef = useRef(false);
|
|
321
|
+
// Scoped allow/deny rules (ticket 03): user-added `tool[:glob]` patterns
|
|
322
|
+
// consulted in approve() before prompting — allow runs without asking, deny
|
|
323
|
+
// refuses (deny wins over yolo/trust/always/skill grants). Session-scoped,
|
|
324
|
+
// in-memory only like trustAllRef — never persisted, default empty (no
|
|
325
|
+
// rules → today's prompt flow byte-identical). Survives /clear + /new like
|
|
326
|
+
// other session settings; turn-scoped skill grants stay separate.
|
|
327
|
+
const rulesRef = useRef([]);
|
|
328
|
+
const [turns, setTurns] = useState([]);
|
|
329
|
+
// Live snapshot of the session checklist for <TodoPanel>: refreshed from
|
|
330
|
+
// getTodos() after every todowrite/todo_update call (see onToolActivity).
|
|
331
|
+
// /new resets it alongside the transcript (fresh conversation); /clear
|
|
332
|
+
// keeps it (same session continues).
|
|
333
|
+
const [todoSnap, setTodoSnap] = useState([]);
|
|
334
|
+
// Synchronous mirror of `turns`: submit callbacks queue many functional
|
|
335
|
+
// updates, but the session save needs the committed value synchronously,
|
|
336
|
+
// so every append/replace goes through appendTurns/setTurnsBoth below.
|
|
337
|
+
const turnsRef = useRef([]);
|
|
338
|
+
const [input, setInput] = useState("");
|
|
339
|
+
// Mirror of `input` updated synchronously: keypresses arriving in the same
|
|
340
|
+
// tick share one render closure, so the ref (not state) is the source of
|
|
341
|
+
// truth when Enter is handled.
|
|
342
|
+
const inputRef = useRef("");
|
|
343
|
+
// Task B (1): cursor offset (chars from start, 0..length) with the same
|
|
344
|
+
// synchronous ref mirror (cursor edits must compose within one tick).
|
|
345
|
+
const [cursor, setCursor] = useState(0);
|
|
346
|
+
const cursorRef = useRef(0);
|
|
347
|
+
const [selecting, setSelecting] = useState(false);
|
|
348
|
+
const [selIndex, setSelIndex] = useState(0);
|
|
349
|
+
// Same synchronous mirror for the dropdown highlight.
|
|
350
|
+
const selIndexRef = useRef(0);
|
|
351
|
+
// Reasoning-effort picker (/effort): same pattern as the /model picker
|
|
352
|
+
// (↑/↓ + Enter, Esc cancels). Session state, default Default.
|
|
353
|
+
const [selectingEffort, setSelectingEffort] = useState(false);
|
|
354
|
+
const [effortIndex, setEffortIndex] = useState(0);
|
|
355
|
+
const effortIndexRef = useRef(0);
|
|
356
|
+
const [effort, setEffort] = useState("default");
|
|
357
|
+
const effortRef = useRef("default");
|
|
358
|
+
// /provider picker + key/baseURL prompts (same keyboard pattern).
|
|
359
|
+
const [selectingProvider, setSelectingProvider] = useState(false);
|
|
360
|
+
const [providerIndex, setProviderIndex] = useState(0);
|
|
361
|
+
const providerIndexRef = useRef(0);
|
|
362
|
+
const [keyPrompt, setKeyPrompt] = useState(null);
|
|
363
|
+
const keyPromptRef = useRef(null);
|
|
364
|
+
const [baseURLPrompt, setBaseURLPrompt] = useState(null);
|
|
365
|
+
const baseURLPromptRef = useRef(null);
|
|
366
|
+
// /rewind pickers (ticket 01): checkpoint list, then restore scope. Same
|
|
367
|
+
// keyboard pattern as the /model picker (↑/↓ + Enter, Esc cancels).
|
|
368
|
+
// pendingRewindRef holds the picked checkpoint id between the two steps.
|
|
369
|
+
const [selectingRewind, setSelectingRewind] = useState(false);
|
|
370
|
+
const [rewindIndex, setRewindIndex] = useState(0);
|
|
371
|
+
const rewindIndexRef = useRef(0);
|
|
372
|
+
const [selectingRewindScope, setSelectingRewindScope] = useState(false);
|
|
373
|
+
const [rewindScopeIndex, setRewindScopeIndex] = useState(0);
|
|
374
|
+
const rewindScopeIndexRef = useRef(0);
|
|
375
|
+
const pendingRewindRef = useRef(null);
|
|
376
|
+
// Generation bumped on /clear to remount the turns <Static> (Ink resets
|
|
377
|
+
// its static buffer when the Static identity changes, so old turns leave
|
|
378
|
+
// the test frame while staying in real-terminal scrollback).
|
|
379
|
+
const [clearGen, setClearGen] = useState(0);
|
|
380
|
+
// "/" slash menu: highlight mirror + dismissed flag (Esc hides the menu
|
|
381
|
+
// back to plain input until the next keystroke).
|
|
382
|
+
const [slashIndex, setSlashIndex] = useState(0);
|
|
383
|
+
const slashIndexRef = useRef(0);
|
|
384
|
+
const [slashDismissed, setSlashDismissed] = useState(false);
|
|
385
|
+
const slashDismissedRef = useRef(false);
|
|
386
|
+
// Tool approval prompt (normal mode, write/edit/bash): the loop waits on
|
|
387
|
+
// the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
|
|
388
|
+
// (LoopCancelledError) instead of denying one call.
|
|
389
|
+
const [pendingApproval, setPendingApproval] = useState(null);
|
|
390
|
+
const approvalResolveRef = useRef(null);
|
|
391
|
+
// ask_question modal: the loop waits until the user picks, types a custom
|
|
392
|
+
// answer (allowCustom), cancels with Esc (question-cancel result), or
|
|
393
|
+
// cancels the whole turn with Ctrl+C (LoopCancelledError).
|
|
394
|
+
const [pendingQuestion, setPendingQuestion] = useState(null);
|
|
395
|
+
const askResolveRef = useRef(null);
|
|
396
|
+
const [askSelIndex, setAskSelIndex] = useState(0);
|
|
397
|
+
const askSelIndexRef = useRef(0);
|
|
398
|
+
const [askCustom, setAskCustom] = useState("");
|
|
399
|
+
const askCustomRef = useRef("");
|
|
400
|
+
const [busy, setBusy] = useState(false);
|
|
401
|
+
const busyRef = useRef(false);
|
|
402
|
+
// Per-turn cancellation (Ctrl+C mid-loop): abort stops after the current
|
|
403
|
+
// tool finishes — no new POSTs, no new executions — then the turn rolls
|
|
404
|
+
// back and a dim `(cancelled)` line renders.
|
|
405
|
+
const turnCancelRef = useRef(null);
|
|
406
|
+
const [error, setError] = useState(null);
|
|
407
|
+
// Session token totals from real API usage payloads only (null = none
|
|
408
|
+
// reported yet -> `token: n/a`). Survives /clear by design (see /help).
|
|
409
|
+
const [usageTotals, setUsageTotals] = useState(null);
|
|
410
|
+
// Synchronous mirror of `usageTotals` (same save-time reason as turnsRef).
|
|
411
|
+
const usageRef = useRef(null);
|
|
412
|
+
// Current context load driving status P% (last POST prompt_tokens when
|
|
413
|
+
// available, else the 4ch/token estimate). Null until the first turn
|
|
414
|
+
// completes. NK stays cumulative; P must NOT use the cumulative total.
|
|
415
|
+
const [contextLoad, setContextLoad] = useState(null);
|
|
416
|
+
const contextLoadRef = useRef(null);
|
|
417
|
+
// Last POST's reported prompt_tokens (load metric source). Summary-request
|
|
418
|
+
// usage never touches this — only main-loop POSTs do.
|
|
419
|
+
const lastPromptTokensRef = useRef(undefined);
|
|
420
|
+
// Thrash guard: consecutive auto-compactions without the load dropping
|
|
421
|
+
// below threshold. At 3, auto disables for the session (manual still
|
|
422
|
+
// works and resets the counter on success).
|
|
423
|
+
const autoStreakRef = useRef(0);
|
|
424
|
+
const [autoDisabled, setAutoDisabled] = useState(false);
|
|
425
|
+
const autoDisabledRef = useRef(false);
|
|
426
|
+
// /compact typed while busy: focus text ("" = no focus) runs at turn end,
|
|
427
|
+
// never mid-turn. Null = none pending.
|
|
428
|
+
const pendingCompactRef = useRef(null);
|
|
429
|
+
// Startup hint: a save file exists from a previous session. Rendered once
|
|
430
|
+
// as a dim line while the transcript is empty; startup never auto-restores.
|
|
431
|
+
const [sessionHint] = useState(() => sessionExists(authHome));
|
|
432
|
+
// Reasoning label from response metadata (via onReasoning). The status
|
|
433
|
+
// line shows the session effort when non-Default (plus " (unsupported)"
|
|
434
|
+
// when the model is outside the verified-support set); when effort is
|
|
435
|
+
// Default it shows this label, falling back to `default`.
|
|
436
|
+
const [reasoning, setReasoning] = useState(null);
|
|
437
|
+
// Live streaming state: `draft` is the growing assistant text (onToken),
|
|
438
|
+
// `phase`/`phaseDetail` track the observe→act→inspect→adjust loop
|
|
439
|
+
// (thinking|streaming|tool|retry|done), and `toolHint` shows a streamed
|
|
440
|
+
// tool name before its execution line lands.
|
|
441
|
+
const [draft, setDraft] = useState(null);
|
|
442
|
+
// Thinking channel (onThinking): reasoning text streamed apart from the
|
|
443
|
+
// answer, rendered in its own dim block below. Transient like `draft` —
|
|
444
|
+
// cleared on every turn boundary below — and never committed to the
|
|
445
|
+
// transcript or the model history.
|
|
446
|
+
const [thinking, setThinking] = useState(null);
|
|
447
|
+
const [phase, setPhase] = useState("idle");
|
|
448
|
+
const [phaseDetail, setPhaseDetail] = useState("");
|
|
449
|
+
const [toolHint, setToolHint] = useState(null);
|
|
450
|
+
// Task B smoothness (a): throttled streaming draft. onToken pushes every
|
|
451
|
+
// partial (activity/stall tracking stays per-token); paints coalesce to one
|
|
452
|
+
// per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
|
|
453
|
+
const draftThrottleRef = useRef(null);
|
|
454
|
+
function draftThrottler() {
|
|
455
|
+
let th = draftThrottleRef.current;
|
|
456
|
+
if (!th) {
|
|
457
|
+
th = createDraftThrottler({
|
|
458
|
+
now,
|
|
459
|
+
setTimeoutFn,
|
|
460
|
+
clearTimeoutFn,
|
|
461
|
+
onFlush: (text) => {
|
|
462
|
+
setDraft(text);
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
draftThrottleRef.current = th;
|
|
466
|
+
}
|
|
467
|
+
return th;
|
|
468
|
+
}
|
|
469
|
+
function flushDraft() {
|
|
470
|
+
try {
|
|
471
|
+
draftThrottler().flush();
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// ignore (draft stays as-is; the commit carries the full text)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Phase 5: models-list session cache (successful live lists only, keyed
|
|
478
|
+
// by modelsCacheKey). Failures fall back uncached, exactly as before.
|
|
479
|
+
const modelsCacheRef = useRef(new Map());
|
|
480
|
+
// Phase 5: elapsed + stall indicator (status-bar only, never transcript).
|
|
481
|
+
// `elapsedSecs` ticks at 1s resolution while busy; `stalled` turns true
|
|
482
|
+
// when no token/tool/phase activity arrives for >3s mid-turn and clears
|
|
483
|
+
// on the next activity.
|
|
484
|
+
const [elapsedSecs, setElapsedSecs] = useState(0);
|
|
485
|
+
const [stalled, setStalled] = useState(false);
|
|
486
|
+
const turnStartRef = useRef(0);
|
|
487
|
+
const lastActivityRef = useRef(0);
|
|
488
|
+
const turnTimerRef = useRef(null);
|
|
489
|
+
// Startup snapshot of the system prompt (default + repo AGENTS.md),
|
|
490
|
+
// computed once — never re-read on re-render.
|
|
491
|
+
const [systemPrompt] = useState(buildSystemPrompt);
|
|
492
|
+
// Full API history (includes the system prompt); `turns` is the display
|
|
493
|
+
// subset. Failed user turns are popped (rollback) — but only when the
|
|
494
|
+
// HTTP POST itself fails; tool errors are results the model sees and
|
|
495
|
+
// are never rolled back.
|
|
496
|
+
const historyRef = useRef([
|
|
497
|
+
{ role: "system", content: withEnvBlock(systemPrompt) },
|
|
498
|
+
]);
|
|
499
|
+
// Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
|
|
500
|
+
// pinned to history[0] (the only slot truncateHistory never drops), NEVER
|
|
501
|
+
// to user content. Refreshed once per turn in submit() + after doResume, so
|
|
502
|
+
// the loop's many POSTs reuse one block (no per-POST shell-outs).
|
|
503
|
+
// Failure-silent via withEnvBlock (missing git → block shrinks).
|
|
504
|
+
function refreshSystemEnv() {
|
|
505
|
+
const first = historyRef.current[0];
|
|
506
|
+
if (first?.role !== "system")
|
|
507
|
+
return;
|
|
508
|
+
const content = first.content;
|
|
509
|
+
if (typeof content !== "string")
|
|
510
|
+
return;
|
|
511
|
+
historyRef.current[0] = { role: "system", content: withEnvBlock(content) };
|
|
512
|
+
}
|
|
513
|
+
// Live model list once on mount (skipped in tests via initialModels).
|
|
514
|
+
// Per-provider: live list per kind with curated fallback on ANY failure.
|
|
515
|
+
// Phase 5: successful lists are cached per provider (+baseURL for
|
|
516
|
+
// openai-compatible); failures fall back uncached, exactly as before.
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
if (initialModels)
|
|
519
|
+
return;
|
|
520
|
+
let cancelled = false;
|
|
521
|
+
if (providerRef.current === "opencode-zen" && providerRef.current === DEFAULT_PROVIDER) {
|
|
522
|
+
const cacheKey = modelsCacheKey(providerRef.current);
|
|
523
|
+
const cached = modelsCacheRef.current.get(cacheKey);
|
|
524
|
+
if (cached) {
|
|
525
|
+
setModels([...cached]);
|
|
526
|
+
return () => {
|
|
527
|
+
cancelled = true;
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
void fetchModelsWithStatus(endpoint, apiKey).then(({ models: list, ok }) => {
|
|
531
|
+
if (cancelled)
|
|
532
|
+
return;
|
|
533
|
+
if (ok)
|
|
534
|
+
modelsCacheRef.current.set(cacheKey, [...list]);
|
|
535
|
+
setModels(list);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
const p = providerRef.current;
|
|
540
|
+
const baseURL = getStoredBaseURL(authRef.current, p);
|
|
541
|
+
const cacheKey = modelsCacheKey(p, baseURL);
|
|
542
|
+
const cached = modelsCacheRef.current.get(cacheKey);
|
|
543
|
+
if (cached) {
|
|
544
|
+
setModels([...cached]);
|
|
545
|
+
return () => {
|
|
546
|
+
cancelled = true;
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const k = resolveApiKey(p, authRef.current) || activeApiKeyRef.current;
|
|
550
|
+
void fetchModelsForProviderWithStatus(p, k, baseURL, endpoint).then(({ models: list, ok }) => {
|
|
551
|
+
if (cancelled)
|
|
552
|
+
return;
|
|
553
|
+
if (ok)
|
|
554
|
+
modelsCacheRef.current.set(cacheKey, [...list]);
|
|
555
|
+
setModels(list);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return () => {
|
|
559
|
+
cancelled = true;
|
|
560
|
+
};
|
|
561
|
+
}, [endpoint, apiKey, initialModels]);
|
|
562
|
+
// Phase 5: turn timer helpers (injectable now/timers for tests). The
|
|
563
|
+
// interval handle is always cleared on turn end and on unmount.
|
|
564
|
+
function clearTurnTimer() {
|
|
565
|
+
const h = turnTimerRef.current;
|
|
566
|
+
if (h !== null) {
|
|
567
|
+
try {
|
|
568
|
+
(clearIntervalFn ?? clearInterval)(h);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// ignore
|
|
572
|
+
}
|
|
573
|
+
turnTimerRef.current = null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function noteTurnActivity() {
|
|
577
|
+
try {
|
|
578
|
+
lastActivityRef.current = (now ?? Date.now)();
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// ignore clock errors (stall hint just won't trigger)
|
|
582
|
+
}
|
|
583
|
+
setStalled(false);
|
|
584
|
+
}
|
|
585
|
+
function startTurnTimer() {
|
|
586
|
+
clearTurnTimer();
|
|
587
|
+
let start;
|
|
588
|
+
try {
|
|
589
|
+
start = (now ?? Date.now)();
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
start = Date.now();
|
|
593
|
+
}
|
|
594
|
+
turnStartRef.current = start;
|
|
595
|
+
lastActivityRef.current = start;
|
|
596
|
+
setElapsedSecs(0);
|
|
597
|
+
setStalled(false);
|
|
598
|
+
try {
|
|
599
|
+
turnTimerRef.current = (setIntervalFn ?? setInterval)(() => {
|
|
600
|
+
let t;
|
|
601
|
+
try {
|
|
602
|
+
t = (now ?? Date.now)();
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
setElapsedSecs(elapsedSecsSince(turnStartRef.current, t));
|
|
608
|
+
if (isStalledSince(lastActivityRef.current, t)) {
|
|
609
|
+
setStalled(true);
|
|
610
|
+
}
|
|
611
|
+
}, TURN_TICK_MS);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
turnTimerRef.current = null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// No leaked handles: clear the turn timer on unmount + abort a pending
|
|
618
|
+
// turn so its first POST can never leak into the next mount's fetch
|
|
619
|
+
// (submit's context-assembly/loop-entry stages run async skill discovery
|
|
620
|
+
// before the first POST — see SUBMIT_PIPELINE_STAGES — so unmount can land
|
|
621
|
+
// in that gap; runLoopWithChat checks the signal before the first POST).
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
return () => {
|
|
624
|
+
try {
|
|
625
|
+
turnCancelRef.current?.abort();
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// ignore
|
|
629
|
+
}
|
|
630
|
+
const h = turnTimerRef.current;
|
|
631
|
+
if (h !== null) {
|
|
632
|
+
try {
|
|
633
|
+
(clearIntervalFn ?? clearInterval)(h);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// ignore
|
|
637
|
+
}
|
|
638
|
+
turnTimerRef.current = null;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
draftThrottleRef.current?.cancel();
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
// ignore
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
648
|
+
}, []);
|
|
649
|
+
// Ticket 01 (/rewind): feed the live conversation lengths to the snapshot
|
|
650
|
+
// capture hook, so each checkpoint knows which turn it belongs to. The
|
|
651
|
+
// refs (not state) are the source of truth mid-turn.
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
registerHistoryProbe(() => ({
|
|
654
|
+
history: historyRef.current.length,
|
|
655
|
+
turns: turnsRef.current.length,
|
|
656
|
+
}));
|
|
657
|
+
return () => registerHistoryProbe(null);
|
|
658
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
659
|
+
}, []);
|
|
660
|
+
function setInputAndCursor(next, cursorPos) {
|
|
661
|
+
inputRef.current = next;
|
|
662
|
+
setInput(next);
|
|
663
|
+
const clamped = Math.max(0, Math.min(cursorPos, next.length));
|
|
664
|
+
cursorRef.current = clamped;
|
|
665
|
+
setCursor(clamped);
|
|
666
|
+
// Any edit restarts menu filtering from the top and re-opens the menu.
|
|
667
|
+
slashIndexRef.current = 0;
|
|
668
|
+
setSlashIndex(0);
|
|
669
|
+
slashDismissedRef.current = false;
|
|
670
|
+
setSlashDismissed(false);
|
|
671
|
+
}
|
|
672
|
+
function setInputBoth(next) {
|
|
673
|
+
// Append-style callers (and clear/Esc/submit reset): cursor to end.
|
|
674
|
+
setInputAndCursor(next, next.length);
|
|
675
|
+
}
|
|
676
|
+
function setCursorBoth(next) {
|
|
677
|
+
const clamped = Math.max(0, Math.min(next, inputRef.current.length));
|
|
678
|
+
cursorRef.current = clamped;
|
|
679
|
+
setCursor(clamped);
|
|
680
|
+
}
|
|
681
|
+
// Cursor-aware edits (plain input + slash menu): typing inserts AT the
|
|
682
|
+
// cursor, backspace deletes BEFORE it, Delete removes AT it.
|
|
683
|
+
function insertAtCursor(text) {
|
|
684
|
+
if (!text)
|
|
685
|
+
return;
|
|
686
|
+
const cur = inputRef.current;
|
|
687
|
+
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
688
|
+
setInputAndCursor(cur.slice(0, at) + text + cur.slice(at), at + text.length);
|
|
689
|
+
}
|
|
690
|
+
function backspaceAtCursor() {
|
|
691
|
+
const cur = inputRef.current;
|
|
692
|
+
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
693
|
+
if (at <= 0)
|
|
694
|
+
return;
|
|
695
|
+
setInputAndCursor(cur.slice(0, at - 1) + cur.slice(at), at - 1);
|
|
696
|
+
}
|
|
697
|
+
function deleteAtCursor() {
|
|
698
|
+
const cur = inputRef.current;
|
|
699
|
+
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
700
|
+
if (at >= cur.length)
|
|
701
|
+
return;
|
|
702
|
+
setInputAndCursor(cur.slice(0, at) + cur.slice(at + 1), at);
|
|
703
|
+
}
|
|
704
|
+
function setSelIndexBoth(next) {
|
|
705
|
+
selIndexRef.current = next;
|
|
706
|
+
setSelIndex(next);
|
|
707
|
+
}
|
|
708
|
+
function setSlashIndexBoth(next) {
|
|
709
|
+
slashIndexRef.current = next;
|
|
710
|
+
setSlashIndex(next);
|
|
711
|
+
}
|
|
712
|
+
function setSlashDismissedBoth(next) {
|
|
713
|
+
slashDismissedRef.current = next;
|
|
714
|
+
setSlashDismissed(next);
|
|
715
|
+
}
|
|
716
|
+
function setModeBoth(next) {
|
|
717
|
+
modeRef.current = next;
|
|
718
|
+
setMode(next);
|
|
719
|
+
}
|
|
720
|
+
function setTrustAllBoth(next) {
|
|
721
|
+
trustAllRef.current = next;
|
|
722
|
+
setTrustAll(next);
|
|
723
|
+
}
|
|
724
|
+
function setAskSelIndexBoth(next) {
|
|
725
|
+
askSelIndexRef.current = next;
|
|
726
|
+
setAskSelIndex(next);
|
|
727
|
+
}
|
|
728
|
+
function setAskCustomBoth(next) {
|
|
729
|
+
askCustomRef.current = next;
|
|
730
|
+
setAskCustom(next);
|
|
731
|
+
}
|
|
732
|
+
function setEffortBoth(next) {
|
|
733
|
+
effortRef.current = next;
|
|
734
|
+
setEffort(next);
|
|
735
|
+
}
|
|
736
|
+
function setEffortIndexBoth(next) {
|
|
737
|
+
effortIndexRef.current = next;
|
|
738
|
+
setEffortIndex(next);
|
|
739
|
+
}
|
|
740
|
+
function setProviderBoth(next) {
|
|
741
|
+
providerRef.current = next;
|
|
742
|
+
setProvider(next);
|
|
743
|
+
}
|
|
744
|
+
function setModelBoth(next) {
|
|
745
|
+
modelRef.current = next;
|
|
746
|
+
setModel(next);
|
|
747
|
+
}
|
|
748
|
+
function setAuthBoth(next) {
|
|
749
|
+
authRef.current = next;
|
|
750
|
+
setAuth(next);
|
|
751
|
+
}
|
|
752
|
+
function setActiveKeyBoth(next) {
|
|
753
|
+
activeApiKeyRef.current = next;
|
|
754
|
+
setActiveApiKey(next);
|
|
755
|
+
}
|
|
756
|
+
function setProviderIndexBoth(next) {
|
|
757
|
+
providerIndexRef.current = next;
|
|
758
|
+
setProviderIndex(next);
|
|
759
|
+
}
|
|
760
|
+
function setKeyPromptBoth(next) {
|
|
761
|
+
keyPromptRef.current = next;
|
|
762
|
+
setKeyPrompt(next);
|
|
763
|
+
}
|
|
764
|
+
function setBaseURLPromptBoth(next) {
|
|
765
|
+
baseURLPromptRef.current = next;
|
|
766
|
+
setBaseURLPrompt(next);
|
|
767
|
+
}
|
|
768
|
+
function setRewindIndexBoth(next) {
|
|
769
|
+
rewindIndexRef.current = next;
|
|
770
|
+
setRewindIndex(next);
|
|
771
|
+
}
|
|
772
|
+
function setRewindScopeIndexBoth(next) {
|
|
773
|
+
rewindScopeIndexRef.current = next;
|
|
774
|
+
setRewindScopeIndex(next);
|
|
775
|
+
}
|
|
776
|
+
// Resolved key for picker markers: env wins, else stored; the active
|
|
777
|
+
// provider falls back to the seeded prop key (tests/prod initial).
|
|
778
|
+
function keyForProvider(id) {
|
|
779
|
+
const resolved = resolveApiKey(id, authRef.current);
|
|
780
|
+
if (resolved)
|
|
781
|
+
return resolved;
|
|
782
|
+
if (id === providerRef.current && activeApiKeyRef.current) {
|
|
783
|
+
return activeApiKeyRef.current;
|
|
784
|
+
}
|
|
785
|
+
return "";
|
|
786
|
+
}
|
|
787
|
+
function closeAllPickers() {
|
|
788
|
+
setSelecting(false);
|
|
789
|
+
setSelectingEffort(false);
|
|
790
|
+
setSelectingProvider(false);
|
|
791
|
+
setKeyPromptBoth(null);
|
|
792
|
+
setBaseURLPromptBoth(null);
|
|
793
|
+
setSelectingRewind(false);
|
|
794
|
+
setSelectingRewindScope(false);
|
|
795
|
+
pendingRewindRef.current = null;
|
|
796
|
+
}
|
|
797
|
+
function openProviderPicker() {
|
|
798
|
+
setSelecting(false);
|
|
799
|
+
setSelectingEffort(false);
|
|
800
|
+
setKeyPromptBoth(null);
|
|
801
|
+
setBaseURLPromptBoth(null);
|
|
802
|
+
const idx = Math.max(0, PROVIDERS.findIndex((p) => p.id === providerRef.current));
|
|
803
|
+
setProviderIndexBoth(idx);
|
|
804
|
+
setSelectingProvider(true);
|
|
805
|
+
}
|
|
806
|
+
function openKeyPrompt(providerId) {
|
|
807
|
+
const def = getProvider(providerId);
|
|
808
|
+
const existing = keyForProvider(providerId);
|
|
809
|
+
setSelectingProvider(false);
|
|
810
|
+
setSelecting(false);
|
|
811
|
+
setSelectingEffort(false);
|
|
812
|
+
setBaseURLPromptBoth(null);
|
|
813
|
+
setKeyPromptBoth({
|
|
814
|
+
providerId,
|
|
815
|
+
draft: "",
|
|
816
|
+
error: null,
|
|
817
|
+
existingMasked: existing ? maskKey(existing) : null,
|
|
818
|
+
consoleURL: def.consoleURL,
|
|
819
|
+
validating: false,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
function openBaseURLPrompt(providerId) {
|
|
823
|
+
setSelectingProvider(false);
|
|
824
|
+
setSelecting(false);
|
|
825
|
+
setSelectingEffort(false);
|
|
826
|
+
setKeyPromptBoth(null);
|
|
827
|
+
const prev = getStoredBaseURL(authRef.current, providerId);
|
|
828
|
+
setBaseURLPromptBoth({ providerId, draft: prev, error: null });
|
|
829
|
+
}
|
|
830
|
+
// Switch provider after a validated key (or Esc-keeps existing):
|
|
831
|
+
// reuse the cached live list when available (zero fetches), else fetch
|
|
832
|
+
// the live list and cache successes; failures fall back uncached.
|
|
833
|
+
// /model always reflects the switched-to provider instantly from cache
|
|
834
|
+
// when available. Keep current model if valid else provider default.
|
|
835
|
+
async function switchProviderWithKey(pickedId, apiKeyValue) {
|
|
836
|
+
const def = getProvider(pickedId);
|
|
837
|
+
const baseURL = getStoredBaseURL(authRef.current, pickedId);
|
|
838
|
+
const cacheKey = modelsCacheKey(pickedId, baseURL);
|
|
839
|
+
const cached = modelsCacheRef.current.get(cacheKey);
|
|
840
|
+
let list;
|
|
841
|
+
if (cached) {
|
|
842
|
+
list = [...cached];
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
try {
|
|
846
|
+
const res = await fetchModelsForProviderWithStatus(pickedId, apiKeyValue, baseURL, endpoint);
|
|
847
|
+
list = res.models;
|
|
848
|
+
if (res.ok)
|
|
849
|
+
modelsCacheRef.current.set(cacheKey, [...list]);
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
list = [...def.fallbackModels];
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
setModels(list);
|
|
856
|
+
const nextModel = list.includes(modelRef.current)
|
|
857
|
+
? modelRef.current
|
|
858
|
+
: def.defaultModel;
|
|
859
|
+
setModelBoth(nextModel);
|
|
860
|
+
setProviderBoth(pickedId);
|
|
861
|
+
// Provider switch resets the load latch: the last reported prompt_tokens
|
|
862
|
+
// belonged to the old provider/model tokenizer, so the estimate applies
|
|
863
|
+
// until the new provider reports (usageTotals spend is untouched).
|
|
864
|
+
resetContextLoadToEstimate();
|
|
865
|
+
setActiveKeyBoth(apiKeyValue);
|
|
866
|
+
if (pickedId === "openai-compatible") {
|
|
867
|
+
setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
|
|
868
|
+
}
|
|
869
|
+
else if (pickedId === "opencode-zen") {
|
|
870
|
+
setActiveEndpoint(endpoint);
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
setActiveEndpoint(chatEndpointFor(pickedId, baseURL));
|
|
874
|
+
}
|
|
875
|
+
pushInfo(`provider: ${pickedId} · model: ${nextModel}`);
|
|
876
|
+
}
|
|
877
|
+
// Ref-synced turn writers: keep turnsRef current so persistSession can
|
|
878
|
+
// snapshot synchronously right after a commit.
|
|
879
|
+
function setTurnsBoth(next) {
|
|
880
|
+
turnsRef.current = next;
|
|
881
|
+
setTurns(next);
|
|
882
|
+
}
|
|
883
|
+
function appendTurns(...items) {
|
|
884
|
+
const next = [...turnsRef.current, ...items];
|
|
885
|
+
turnsRef.current = next;
|
|
886
|
+
setTurns(next);
|
|
887
|
+
}
|
|
888
|
+
function setUsageBoth(next) {
|
|
889
|
+
usageRef.current = next;
|
|
890
|
+
setUsageTotals(next);
|
|
891
|
+
}
|
|
892
|
+
function setContextLoadBoth(next) {
|
|
893
|
+
contextLoadRef.current = next;
|
|
894
|
+
setContextLoad(next);
|
|
895
|
+
}
|
|
896
|
+
function setAutoDisabledBoth(next) {
|
|
897
|
+
autoDisabledRef.current = next;
|
|
898
|
+
setAutoDisabled(next);
|
|
899
|
+
}
|
|
900
|
+
// Recompute contextLoad after a committed turn (or compaction): last
|
|
901
|
+
// POST prompt_tokens when available, else the 4ch/token estimate.
|
|
902
|
+
function refreshContextLoad() {
|
|
903
|
+
// No usage yet → no load (status keeps `token: n/a`).
|
|
904
|
+
if (!usageRef.current) {
|
|
905
|
+
setContextLoadBoth(null);
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
const load = computeContextLoad(lastPromptTokensRef.current, historyChars(historyRef.current));
|
|
909
|
+
setContextLoadBoth(load);
|
|
910
|
+
return load;
|
|
911
|
+
}
|
|
912
|
+
// Load-reset contract (hold-last-known): the reported prompt_tokens survive
|
|
913
|
+
// silent POSTs — estimates never override a fresher report — and reset ONLY
|
|
914
|
+
// here: compaction, /clear, resume, and model/provider switch. After a reset
|
|
915
|
+
// the chars/4 estimate applies until the next report arrives.
|
|
916
|
+
function resetContextLoadToEstimate() {
|
|
917
|
+
lastPromptTokensRef.current = undefined;
|
|
918
|
+
if (!usageRef.current) {
|
|
919
|
+
setContextLoadBoth(null);
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function pushInfo(content) {
|
|
926
|
+
appendTurns({ role: "tool", content });
|
|
927
|
+
}
|
|
928
|
+
// Load a resolved skill into the session (tickets 03/06): the body (+any
|
|
929
|
+
// inlined references) enters model history as one marked message and the
|
|
930
|
+
// transcript turn, and its `allowed-tools` become turn-scoped grants in
|
|
931
|
+
// skillGrantsRef (unioned — several skills may load in one turn). Never
|
|
932
|
+
// throws: loadSkillBody degrades to empty text, surfaced plainly.
|
|
933
|
+
async function activateSkill(info) {
|
|
934
|
+
const loaded = await loadSkillBody(info);
|
|
935
|
+
if (loaded.text.trim().length === 0) {
|
|
936
|
+
pushInfo(`Skill "${info.name}" has an empty body — nothing loaded.`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
for (const t of loaded.info.allowedTools)
|
|
940
|
+
skillGrantsRef.current.add(t);
|
|
941
|
+
historyRef.current.push({
|
|
942
|
+
role: "user",
|
|
943
|
+
content: `[skill "${info.name}" loaded — follow these instructions]\n${loaded.text}`,
|
|
944
|
+
});
|
|
945
|
+
const grantNote = loaded.info.allowedTools.length > 0
|
|
946
|
+
? ` (tools pre-approved this turn: ${loaded.info.allowedTools.join(", ")})`
|
|
947
|
+
: "";
|
|
948
|
+
pushInfo(`Skill "${info.name}" loaded${grantNote}\n${loaded.text}`);
|
|
949
|
+
}
|
|
950
|
+
// Manual /skill-name invocation (ticket 03). Idle-only: injecting history
|
|
951
|
+
// mid-turn would break the loop's assistant/tool pairing. Unknown names
|
|
952
|
+
// get a helpful error (not a model message); model-only skills refuse
|
|
953
|
+
// with a pointer instead of loading.
|
|
954
|
+
async function invokeSkillByName(name) {
|
|
955
|
+
if (busyRef.current) {
|
|
956
|
+
pushInfo("Skills load when idle — wait for the turn to finish.");
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const found = await discoverSkills({
|
|
960
|
+
projectDir: skillDirs?.projectDir,
|
|
961
|
+
homeDir: skillDirs?.homeDir,
|
|
962
|
+
});
|
|
963
|
+
const { skills } = resolveSkills(found.skills);
|
|
964
|
+
const info = skills.find((s) => s.name === name);
|
|
965
|
+
if (!info) {
|
|
966
|
+
const available = skills.filter((s) => s.userInvocable).map((s) => `/${s.name}`);
|
|
967
|
+
pushInfo(available.length > 0
|
|
968
|
+
? `Unknown skill "/${name}". Available: ${available.join(", ")}`
|
|
969
|
+
: `Unknown skill "/${name}" (no skills installed).`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!info.userInvocable) {
|
|
973
|
+
pushInfo(`Skill "${name}" is model-invoked only (user-invocable: false).`);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
await activateSkill(info);
|
|
977
|
+
}
|
|
978
|
+
// Snapshot the committed session (historyRef + turnsRef + settings refs)
|
|
979
|
+
// to ~/.atom/session.json. Disk errors are ignored (in-memory session
|
|
980
|
+
// still applies). Called only for committed state: completed turns and
|
|
981
|
+
// clean exit — never for rolled-back (failed/cancelled) turns.
|
|
982
|
+
function persistSession() {
|
|
983
|
+
try {
|
|
984
|
+
saveSession({
|
|
985
|
+
provider: providerRef.current,
|
|
986
|
+
model: modelRef.current,
|
|
987
|
+
effort: effortRef.current,
|
|
988
|
+
mode: modeRef.current,
|
|
989
|
+
usageTotals: usageRef.current,
|
|
990
|
+
history: historyRef.current,
|
|
991
|
+
turns: turnsRef.current,
|
|
992
|
+
}, authHome);
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
// ignore disk errors (in-memory session still applies)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// Compaction routine (atomic): split head/tail, summarize head with
|
|
999
|
+
// tools disabled + 4096 cap, then swap history := [system, summary, tail].
|
|
1000
|
+
// On success: boundary line, totals kept, load refreshed, save. On
|
|
1001
|
+
// failure: old history untouched + inline error (oversize retry-once
|
|
1002
|
+
// already handled inside requestCompactSummary, which appends /clear).
|
|
1003
|
+
// isAuto drives the thrash guard; manual resets the counter on success.
|
|
1004
|
+
async function doCompact(focusText, isAuto) {
|
|
1005
|
+
if (countUserTurns(historyRef.current) <= 1) {
|
|
1006
|
+
if (!isAuto)
|
|
1007
|
+
pushInfo("(nothing to compact)");
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
const split = splitHistoryForCompaction(historyRef.current);
|
|
1011
|
+
if (split.olderTurnCount <= 0 || split.head.length === 0) {
|
|
1012
|
+
if (!isAuto)
|
|
1013
|
+
pushInfo("(nothing to compact)");
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
const systemMsg = historyRef.current[0];
|
|
1017
|
+
const systemContent = typeof systemMsg.content === "string" ? systemMsg.content : systemPrompt;
|
|
1018
|
+
const submitKey = keyForProvider(providerRef.current) || activeApiKeyRef.current;
|
|
1019
|
+
if (!submitKey) {
|
|
1020
|
+
pushInfo(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
const baseURL = getStoredBaseURL(authRef.current, providerRef.current);
|
|
1024
|
+
try {
|
|
1025
|
+
const summary = await requestCompactSummary({
|
|
1026
|
+
provider: providerRef.current,
|
|
1027
|
+
apiKey: submitKey,
|
|
1028
|
+
model: modelRef.current,
|
|
1029
|
+
systemContent,
|
|
1030
|
+
head: split.head,
|
|
1031
|
+
focusText,
|
|
1032
|
+
baseURL,
|
|
1033
|
+
endpointOverride: activeEndpoint,
|
|
1034
|
+
onUsage: (u) => {
|
|
1035
|
+
// Totals keep accumulating (real summary spend); load source
|
|
1036
|
+
// untouched (summary prompt reflects head size, not new context).
|
|
1037
|
+
const prev = usageRef.current ?? {};
|
|
1038
|
+
const next = { ...prev };
|
|
1039
|
+
if (u.prompt_tokens !== undefined) {
|
|
1040
|
+
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
1041
|
+
}
|
|
1042
|
+
if (u.completion_tokens !== undefined) {
|
|
1043
|
+
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
1044
|
+
}
|
|
1045
|
+
if (u.total_tokens !== undefined) {
|
|
1046
|
+
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
1047
|
+
}
|
|
1048
|
+
// Only accumulate when the summary actually reported usage;
|
|
1049
|
+
// an empty onUsage keeps totals byte-identical.
|
|
1050
|
+
if (u.prompt_tokens !== undefined ||
|
|
1051
|
+
u.completion_tokens !== undefined ||
|
|
1052
|
+
u.total_tokens !== undefined) {
|
|
1053
|
+
setUsageBoth(next);
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
// Atomic swap: build the new history first, then replace.
|
|
1058
|
+
const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
|
|
1059
|
+
historyRef.current = next;
|
|
1060
|
+
appendTurns({ role: "tool", content: compactBoundaryLine(split.olderTurnCount) });
|
|
1061
|
+
// P% must drop immediately: the old lastPromptTokens reflects the
|
|
1062
|
+
// pre-compact context, so clear it and use the new-history estimate.
|
|
1063
|
+
lastPromptTokensRef.current = undefined;
|
|
1064
|
+
const newLoad = estimateTokensForChars(historyChars(historyRef.current));
|
|
1065
|
+
setContextLoadBoth(newLoad);
|
|
1066
|
+
if (isAuto) {
|
|
1067
|
+
const pct = compactPct();
|
|
1068
|
+
const window = contextWindowFor(modelRef.current);
|
|
1069
|
+
if (window !== undefined && newLoad / window < pct) {
|
|
1070
|
+
autoStreakRef.current = 0;
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
autoStreakRef.current += 1;
|
|
1074
|
+
if (isThrashDisabled(autoStreakRef.current)) {
|
|
1075
|
+
setAutoDisabledBoth(true);
|
|
1076
|
+
appendTurns({
|
|
1077
|
+
role: "tool",
|
|
1078
|
+
content: "(auto-compact thrashing — disabled, use /compact or /clear)",
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
autoStreakRef.current = 0;
|
|
1085
|
+
}
|
|
1086
|
+
persistSession();
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
// No partial swap: historyRef untouched. Inline error only.
|
|
1091
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1092
|
+
pushInfo(`compact failed: ${msg}`);
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// After a completed main turn: refresh load, reset the streak when below
|
|
1097
|
+
// threshold, else auto-compact (known-window models only, unless
|
|
1098
|
+
// thrash-disabled). Called while still busy, before the next turn.
|
|
1099
|
+
async function maybeAutoCompact() {
|
|
1100
|
+
const load = refreshContextLoad();
|
|
1101
|
+
if (load === null) {
|
|
1102
|
+
autoStreakRef.current = 0;
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
// Unknown window → no auto trigger (never invent a window); below
|
|
1106
|
+
// threshold → streak resets. shouldAutoCompact owns the pct math.
|
|
1107
|
+
if (!shouldAutoCompact(load, modelRef.current)) {
|
|
1108
|
+
// Distinguish unknown-window (streak untouched — irrelevant) from
|
|
1109
|
+
// below-threshold (streak resets). shouldAutoCompact is false for
|
|
1110
|
+
// both, so re-check the window for the reset.
|
|
1111
|
+
if (contextWindowFor(modelRef.current) !== undefined) {
|
|
1112
|
+
autoStreakRef.current = 0;
|
|
1113
|
+
}
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (autoDisabledRef.current)
|
|
1117
|
+
return;
|
|
1118
|
+
await doCompact("", true);
|
|
1119
|
+
}
|
|
1120
|
+
// /resume: restore turns + history + settings + usage from the save file.
|
|
1121
|
+
// Missing -> "(no saved session)"; corrupt -> unreadable notice, fresh.
|
|
1122
|
+
// Oversized saves run the normal truncation path after restore so the
|
|
1123
|
+
// budget caps hold; pairing stays valid (saved intact, turns never split).
|
|
1124
|
+
function doResume() {
|
|
1125
|
+
const result = loadSession(authHome);
|
|
1126
|
+
if (result.status === "missing") {
|
|
1127
|
+
pushInfo("(no saved session)");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (result.status === "corrupt") {
|
|
1131
|
+
pushInfo("(saved session unreadable — starting fresh)");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const s = result.session;
|
|
1135
|
+
setProviderBoth(s.provider);
|
|
1136
|
+
const baseURL = getStoredBaseURL(authRef.current, s.provider);
|
|
1137
|
+
if (s.provider === "openai-compatible") {
|
|
1138
|
+
setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
|
|
1139
|
+
}
|
|
1140
|
+
else if (s.provider === "opencode-zen") {
|
|
1141
|
+
setActiveEndpoint(endpoint);
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
setActiveEndpoint(chatEndpointFor(s.provider, baseURL));
|
|
1145
|
+
}
|
|
1146
|
+
setModelBoth(s.model);
|
|
1147
|
+
setEffortBoth(s.effort);
|
|
1148
|
+
setModeBoth(s.mode);
|
|
1149
|
+
setUsageBoth(s.usageTotals);
|
|
1150
|
+
historyRef.current = [...s.history];
|
|
1151
|
+
// Task 6: refresh the pinned env block on the restored system line
|
|
1152
|
+
// (strips the saved block, appends a fresh one) — keeps the restored
|
|
1153
|
+
// AGENTS overlay, never touches user content.
|
|
1154
|
+
refreshSystemEnv();
|
|
1155
|
+
// Restored load is the estimate (no prompt_tokens survived the save);
|
|
1156
|
+
// thrash state restarts fresh on resume.
|
|
1157
|
+
lastPromptTokensRef.current = undefined;
|
|
1158
|
+
if (!usageRef.current) {
|
|
1159
|
+
setContextLoadBoth(null);
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
|
|
1163
|
+
}
|
|
1164
|
+
autoStreakRef.current = 0;
|
|
1165
|
+
setAutoDisabledBoth(false);
|
|
1166
|
+
pendingCompactRef.current = null;
|
|
1167
|
+
const pendingNotices = [];
|
|
1168
|
+
truncateHistory(historyRef.current, (msg) => {
|
|
1169
|
+
pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
|
|
1170
|
+
});
|
|
1171
|
+
// Remount the turns <Static> (same mechanism as /clear and /new): Ink's
|
|
1172
|
+
// Static only renders newly appended indices, so restoring a transcript
|
|
1173
|
+
// over a non-empty rendered buffer (e.g. the /new boundary line) would
|
|
1174
|
+
// misalign and hide the first restored turn(s).
|
|
1175
|
+
setClearGen((g) => g + 1);
|
|
1176
|
+
setTurnsBoth([
|
|
1177
|
+
...s.turns,
|
|
1178
|
+
{
|
|
1179
|
+
role: "tool",
|
|
1180
|
+
content: `(resumed session from ${s.savedAt}: ${s.turns.length} turns)`,
|
|
1181
|
+
},
|
|
1182
|
+
...pendingNotices,
|
|
1183
|
+
]);
|
|
1184
|
+
}
|
|
1185
|
+
// /rewind conversation scope (ticket 01): truncate history + transcript to
|
|
1186
|
+
// the checkpoint's turn. The cut drops the whole containing turn (submit's
|
|
1187
|
+
// splice(rollbackTo) rollback semantics via conversationCutIndex), so
|
|
1188
|
+
// assistant/tool pairing can never split. The <Static> remount + load
|
|
1189
|
+
// refresh follow the /resume precedent. Files are untouched here.
|
|
1190
|
+
function rewindConversationTo(cp) {
|
|
1191
|
+
const cut = conversationCutIndex(historyRef.current.map((m) => ({
|
|
1192
|
+
role: m.role,
|
|
1193
|
+
hasToolCalls: m.role === "assistant" && m.tool_calls !== undefined,
|
|
1194
|
+
})), cp.historyLength);
|
|
1195
|
+
const droppedMessages = historyRef.current.length - cut;
|
|
1196
|
+
if (cut < historyRef.current.length) {
|
|
1197
|
+
historyRef.current.splice(cut);
|
|
1198
|
+
}
|
|
1199
|
+
const turnsCut = conversationCutIndex(turnsRef.current.map((t) => ({ role: t.role })), cp.turnsLength, 0);
|
|
1200
|
+
if (turnsCut < turnsRef.current.length) {
|
|
1201
|
+
setTurnsBoth(turnsRef.current.slice(0, turnsCut));
|
|
1202
|
+
}
|
|
1203
|
+
if (droppedMessages <= 0) {
|
|
1204
|
+
return `(already at checkpoint #${cp.seq} — conversation untouched)`;
|
|
1205
|
+
}
|
|
1206
|
+
// Same remount as /clear and /resume: the rewound tail leaves the test
|
|
1207
|
+
// frame while staying in real-terminal scrollback.
|
|
1208
|
+
setClearGen((g) => g + 1);
|
|
1209
|
+
refreshContextLoad();
|
|
1210
|
+
return `(rewound conversation to checkpoint #${cp.seq} — dropped ${droppedMessages} message(s))`;
|
|
1211
|
+
}
|
|
1212
|
+
// /rewind execution: files-only restores bytes (transcript keeps flowing);
|
|
1213
|
+
// conversation-only truncates (files untouched); both does files first so
|
|
1214
|
+
// the two info lines read in cause order. Restore also refreshes the
|
|
1215
|
+
// stale-read fingerprints (see tools.ts) so later edits don't false-refuse.
|
|
1216
|
+
async function runRewind(id, scope) {
|
|
1217
|
+
const cp = getCheckpoint(id);
|
|
1218
|
+
if (!cp) {
|
|
1219
|
+
pushInfo("(checkpoint no longer available)");
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (scope === "conversation only") {
|
|
1223
|
+
pushInfo(rewindConversationTo(cp));
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const filesMsg = await restoreCheckpointFiles(id, (abs, text) => {
|
|
1227
|
+
if (text === null)
|
|
1228
|
+
forgetReadFingerprint(abs);
|
|
1229
|
+
else
|
|
1230
|
+
refreshReadFingerprint(abs, text);
|
|
1231
|
+
});
|
|
1232
|
+
if (scope === "files + conversation") {
|
|
1233
|
+
// Truncate BEFORE pushing: rewindConversationTo slices the transcript
|
|
1234
|
+
// to the checkpoint turn, which would drop a files line pushed first.
|
|
1235
|
+
const convMsg = rewindConversationTo(cp);
|
|
1236
|
+
pushInfo(filesMsg);
|
|
1237
|
+
pushInfo(convMsg);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
pushInfo(filesMsg);
|
|
1241
|
+
}
|
|
1242
|
+
function warnEffortUnsupported(modelName) {
|
|
1243
|
+
pushInfo(`reasoning effort is not known to be supported by ${modelName} — setting kept, not sent`);
|
|
1244
|
+
}
|
|
1245
|
+
// Manual /compact entry: busy → set pending flag, run at turn end (drain
|
|
1246
|
+
// boundary, never mid-turn); idle → run now under the busy guard so a
|
|
1247
|
+
// concurrent submit cannot interleave. Works for unknown-window models.
|
|
1248
|
+
async function runCompactCommand(focusText) {
|
|
1249
|
+
if (busyRef.current) {
|
|
1250
|
+
pendingCompactRef.current = focusText;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
busyRef.current = true;
|
|
1254
|
+
setBusy(true);
|
|
1255
|
+
setError(null);
|
|
1256
|
+
try {
|
|
1257
|
+
await doCompact(focusText, false);
|
|
1258
|
+
}
|
|
1259
|
+
finally {
|
|
1260
|
+
pendingCompactRef.current = null;
|
|
1261
|
+
busyRef.current = false;
|
|
1262
|
+
setBusy(false);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Scoped rules surface (ticket 03): /allow + /deny add one `tool[:glob]`
|
|
1266
|
+
// rule, /rules lists, /rules clear wipes. Idle-only (callers gate on busy,
|
|
1267
|
+
// like every slash command except /compact). Rules gate write/edit/bash —
|
|
1268
|
+
// the approval tools — so a rule naming a read-only tool is accepted but
|
|
1269
|
+
// noted as inert (those calls never consult approve()).
|
|
1270
|
+
const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
|
|
1271
|
+
function runRulesCommand(raw) {
|
|
1272
|
+
const text = raw.trim();
|
|
1273
|
+
const space = text.indexOf(" ");
|
|
1274
|
+
const head = space === -1 ? text : text.slice(0, space);
|
|
1275
|
+
const arg = space === -1 ? "" : text.slice(space + 1).trim();
|
|
1276
|
+
if (head === "/rules") {
|
|
1277
|
+
if (arg === "") {
|
|
1278
|
+
pushInfo(formatRules(rulesRef.current));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (arg === "clear") {
|
|
1282
|
+
rulesRef.current = [];
|
|
1283
|
+
pushInfo("(rules cleared)");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
pushInfo(RULE_USAGE);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (head !== "/allow" && head !== "/deny")
|
|
1290
|
+
return;
|
|
1291
|
+
if (arg === "") {
|
|
1292
|
+
pushInfo(RULE_USAGE);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const kind = head === "/allow" ? "allow" : "deny";
|
|
1296
|
+
const parsed = parseRuleInput(arg, kind);
|
|
1297
|
+
if (!parsed) {
|
|
1298
|
+
pushInfo(`invalid rule ${JSON.stringify(arg)} — ${RULE_USAGE}`);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
rulesRef.current = [...rulesRef.current, parsed];
|
|
1302
|
+
const gatedNote = needsApproval(parsed.tool)
|
|
1303
|
+
? ""
|
|
1304
|
+
: ` (note: ${parsed.tool} is read-only and auto-runs — rules gate write/edit/bash)`;
|
|
1305
|
+
pushInfo(`${kind === "allow" ? "allowed" : "denied"}: ${parsed.pattern} (${rulesRef.current.length} rule(s))${gatedNote}`);
|
|
1306
|
+
}
|
|
1307
|
+
function runSlashCommand(cmd) {
|
|
1308
|
+
setInputBoth("");
|
|
1309
|
+
switch (cmd) {
|
|
1310
|
+
case "/exit":
|
|
1311
|
+
case "/quit":
|
|
1312
|
+
persistSession();
|
|
1313
|
+
exit();
|
|
1314
|
+
return;
|
|
1315
|
+
case "/clear":
|
|
1316
|
+
// Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
|
|
1317
|
+
// fresh env block.
|
|
1318
|
+
historyRef.current = [{ role: "system", content: withEnvBlock(systemPrompt) }];
|
|
1319
|
+
setTurnsBoth([]);
|
|
1320
|
+
setClearGen((g) => g + 1);
|
|
1321
|
+
setError(null);
|
|
1322
|
+
setDraft(null);
|
|
1323
|
+
setThinking(null);
|
|
1324
|
+
setToolHint(null);
|
|
1325
|
+
setPhase("idle");
|
|
1326
|
+
setPhaseDetail("");
|
|
1327
|
+
// /clear drops the transcript: load resets (no context), streak
|
|
1328
|
+
// resets, pending compact drains, and turn-scoped skill grants go
|
|
1329
|
+
// with it (no invisible auto-approvals survive a wiped transcript).
|
|
1330
|
+
// usageTotals + effort intentionally
|
|
1331
|
+
// kept: token totals and effort are per-session (see /help).
|
|
1332
|
+
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
1333
|
+
skillGrantsRef.current = new Set();
|
|
1334
|
+
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
1335
|
+
lastPromptTokensRef.current = undefined;
|
|
1336
|
+
setContextLoadBoth(null);
|
|
1337
|
+
autoStreakRef.current = 0;
|
|
1338
|
+
pendingCompactRef.current = null;
|
|
1339
|
+
return;
|
|
1340
|
+
case "/new":
|
|
1341
|
+
// Claude-Code semantics: end the current conversation and start
|
|
1342
|
+
// fresh in the same process while the old one stays restorable via
|
|
1343
|
+
// /resume. Save FIRST (the pre-/new conversation is what /resume
|
|
1344
|
+
// restores — same path/format as every completed turn, no new
|
|
1345
|
+
// schema). No sessions/ archive step: session.ts only has
|
|
1346
|
+
// session.json, so no archiving is invented here.
|
|
1347
|
+
persistSession();
|
|
1348
|
+
// Fresh system re-read (system.ts base + current AGENTS.md overlay)
|
|
1349
|
+
// plus a fresh Task 6 env block.
|
|
1350
|
+
historyRef.current = [{ role: "system", content: withEnvBlock(buildSystemPrompt()) }];
|
|
1351
|
+
setTurnsBoth([
|
|
1352
|
+
{
|
|
1353
|
+
role: "tool",
|
|
1354
|
+
content: "(new session started — previous conversation kept, /resume to restore it)",
|
|
1355
|
+
},
|
|
1356
|
+
]);
|
|
1357
|
+
setClearGen((g) => g + 1);
|
|
1358
|
+
setError(null);
|
|
1359
|
+
setDraft(null);
|
|
1360
|
+
setThinking(null);
|
|
1361
|
+
setToolHint(null);
|
|
1362
|
+
setPhase("idle");
|
|
1363
|
+
setPhaseDetail("");
|
|
1364
|
+
// /new-vs-/clear split: /clear wipes the transcript but KEEPS usage
|
|
1365
|
+
// totals; /new resets the counters too (fresh conversation). Session
|
|
1366
|
+
// SETTINGS (effort/mode/provider/model) are kept — only the
|
|
1367
|
+
// conversation + counters reset.
|
|
1368
|
+
setUsageBoth(null);
|
|
1369
|
+
lastPromptTokensRef.current = undefined;
|
|
1370
|
+
setContextLoadBoth(null);
|
|
1371
|
+
// Fresh conversation: the session checklist restarts too.
|
|
1372
|
+
clearTodos();
|
|
1373
|
+
setTodoSnap([]);
|
|
1374
|
+
skillGrantsRef.current = new Set();
|
|
1375
|
+
// Compaction state restarts fresh (unlike /clear, where the thrash
|
|
1376
|
+
// guard stays disabled for the session).
|
|
1377
|
+
autoStreakRef.current = 0;
|
|
1378
|
+
setAutoDisabledBoth(false);
|
|
1379
|
+
pendingCompactRef.current = null;
|
|
1380
|
+
return;
|
|
1381
|
+
case "/compact":
|
|
1382
|
+
// Bare /compact with no focus text (slash-menu path). Free-text
|
|
1383
|
+
// "/compact focus…" is handled in submit (prefix match) so focus
|
|
1384
|
+
// text survives; both funnel to the same busy/pending logic below.
|
|
1385
|
+
void runCompactCommand("");
|
|
1386
|
+
return;
|
|
1387
|
+
case "/model":
|
|
1388
|
+
setSelIndexBoth(Math.max(0, models.indexOf(model)));
|
|
1389
|
+
setSelecting(true);
|
|
1390
|
+
setSelectingEffort(false);
|
|
1391
|
+
setSelectingProvider(false);
|
|
1392
|
+
setKeyPromptBoth(null);
|
|
1393
|
+
setBaseURLPromptBoth(null);
|
|
1394
|
+
return;
|
|
1395
|
+
case "/provider":
|
|
1396
|
+
openProviderPicker();
|
|
1397
|
+
return;
|
|
1398
|
+
case "/effort":
|
|
1399
|
+
setEffortIndexBoth(Math.max(0, EFFORT_OPTIONS.indexOf(effortRef.current)));
|
|
1400
|
+
setSelectingEffort(true);
|
|
1401
|
+
setSelecting(false);
|
|
1402
|
+
setSelectingProvider(false);
|
|
1403
|
+
setKeyPromptBoth(null);
|
|
1404
|
+
setBaseURLPromptBoth(null);
|
|
1405
|
+
return;
|
|
1406
|
+
case "/tools":
|
|
1407
|
+
pushInfo(toolsListText());
|
|
1408
|
+
return;
|
|
1409
|
+
case "/skills":
|
|
1410
|
+
// Local filesystem read: never rejects (failures become warnings),
|
|
1411
|
+
// so no catch is needed to keep the input responsive.
|
|
1412
|
+
void skillsListText(skillDirs?.projectDir, skillDirs?.homeDir).then((text) => pushInfo(text));
|
|
1413
|
+
return;
|
|
1414
|
+
case "/mode":
|
|
1415
|
+
if (modeRef.current === "plan") {
|
|
1416
|
+
pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; /plan to approve + exit)");
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
pushInfo(trustAllRef.current
|
|
1420
|
+
? `mode: ${modeRef.current}+trust (write/edit/bash auto-approved; /trust revokes)`
|
|
1421
|
+
: `mode: ${modeRef.current}`);
|
|
1422
|
+
return;
|
|
1423
|
+
case "/trust": {
|
|
1424
|
+
// Plan is a deliberate safety mode: trust must not punch through it.
|
|
1425
|
+
// The flag is left untouched so exiting plan restores prior behavior.
|
|
1426
|
+
if (modeRef.current === "plan") {
|
|
1427
|
+
pushInfo("(plan mode is read-only — exit plan with /plan before /trust; trust unchanged)");
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const next = !trustAllRef.current;
|
|
1431
|
+
setTrustAllBoth(next);
|
|
1432
|
+
pushInfo(next
|
|
1433
|
+
? "trust: on — write/edit/bash auto-approved this session (/trust again revokes; audit lines still render)"
|
|
1434
|
+
: "trust: off — write/edit/bash ask again");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
case "/yolo": {
|
|
1438
|
+
// Same deliberate-safety rule as /trust: yolo must not punch through
|
|
1439
|
+
// plan mode (and Tab below never enters/exits plan either). The user
|
|
1440
|
+
// exits explicitly with /plan.
|
|
1441
|
+
if (modeRef.current === "plan") {
|
|
1442
|
+
pushInfo("(plan mode is read-only — exit plan with /plan before /yolo; mode unchanged)");
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const next = modeRef.current === "normal" ? "yolo" : "normal";
|
|
1446
|
+
setModeBoth(next);
|
|
1447
|
+
pushInfo(`mode: ${next}`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
case "/plan": {
|
|
1451
|
+
if (modeRef.current === "plan") {
|
|
1452
|
+
// Human approval: typing /plan to exit approves the recorded plan.
|
|
1453
|
+
// Always lands in normal (never yolo) so implementation starts
|
|
1454
|
+
// under asking permissions; the session checklist recorded while
|
|
1455
|
+
// planning survives the switch (todowrite handoff).
|
|
1456
|
+
setModeBoth("normal");
|
|
1457
|
+
const planned = getTodos().length;
|
|
1458
|
+
pushInfo(planned > 0
|
|
1459
|
+
? `(plan approved — ${planned} task(s) carry into implementation under normal permissions)`
|
|
1460
|
+
: "(plan mode off — no plan recorded)");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
setModeBoth("plan");
|
|
1464
|
+
pushInfo("plan mode: on — explore freely (read/grep/glob/web/todos/ask run free; write/edit/bash are blocked with a replan note). Record the plan with todowrite, then /plan to approve + exit into implementation.");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
case "/help":
|
|
1468
|
+
pushInfo(helpListText());
|
|
1469
|
+
return;
|
|
1470
|
+
case "/allow":
|
|
1471
|
+
case "/deny":
|
|
1472
|
+
case "/rules":
|
|
1473
|
+
// Bare exact match (slash-menu Enter on a partial prefix lands here):
|
|
1474
|
+
// usage for the add commands, the list for /rules.
|
|
1475
|
+
runRulesCommand(cmd);
|
|
1476
|
+
return;
|
|
1477
|
+
case "/resume":
|
|
1478
|
+
doResume();
|
|
1479
|
+
return;
|
|
1480
|
+
case "/rewind": {
|
|
1481
|
+
// Idle-only like every slash command except /compact (submit's busy
|
|
1482
|
+
// guard already routes here only when idle): restoring mid-turn would
|
|
1483
|
+
// race the loop's own history writes.
|
|
1484
|
+
const cps = listCheckpoints();
|
|
1485
|
+
if (cps.length === 0) {
|
|
1486
|
+
pushInfo("(no checkpoints yet — every write/edit snapshots automatically)");
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
pendingRewindRef.current = null;
|
|
1490
|
+
setRewindIndexBoth(cps.length - 1);
|
|
1491
|
+
setSelectingRewind(true);
|
|
1492
|
+
setSelecting(false);
|
|
1493
|
+
setSelectingEffort(false);
|
|
1494
|
+
setSelectingProvider(false);
|
|
1495
|
+
setKeyPromptBoth(null);
|
|
1496
|
+
setBaseURLPromptBoth(null);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
default:
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
// approve hook for runAgenticLoop: scoped rules first (deny refuses as a
|
|
1504
|
+
// standard "no" — pre-execution, model-visible denial result, audit line
|
|
1505
|
+
// via the untouched onToolActivity path — and wins over everything below,
|
|
1506
|
+
// including plan mode); plan mode second (mutations flow to the execute
|
|
1507
|
+
// gate, which refuses with a replan note — never a prompt here, so
|
|
1508
|
+
// allow/yolo/trust/always/skill grants cannot punch through); then allow,
|
|
1509
|
+
// yolo, session trust (/trust or [t]), and always-allowed tools run without
|
|
1510
|
+
// prompting; otherwise an Ink y/a/t/n prompt resolves the promise.
|
|
1511
|
+
// The promise also rejects with LoopCancelledError when the turn is
|
|
1512
|
+
// cancelled (Ctrl+C aborts the controller), so a cancel unblocks the loop
|
|
1513
|
+
// as a whole-turn cancel — never as a one-call denial.
|
|
1514
|
+
async function approve(name, args) {
|
|
1515
|
+
if (turnCancelRef.current?.signal.aborted)
|
|
1516
|
+
throw new LoopCancelledError();
|
|
1517
|
+
const verdict = checkRules(rulesRef.current, name, args);
|
|
1518
|
+
if (verdict === "deny")
|
|
1519
|
+
return "no";
|
|
1520
|
+
// Plan mode (ticket 04): read-only. Mutations skip the prompt entirely
|
|
1521
|
+
// and flow to guardedExecute, which refuses them pre-execution with a
|
|
1522
|
+
// replan-friendly note. Returning "once" here only routes past the prompt
|
|
1523
|
+
// — the gate below still blocks, so allow/yolo/trust/always/grants below
|
|
1524
|
+
// cannot punch through.
|
|
1525
|
+
if (modeRef.current === "plan" && needsApproval(name))
|
|
1526
|
+
return "once";
|
|
1527
|
+
if (verdict === "allow")
|
|
1528
|
+
return "once";
|
|
1529
|
+
if (modeRef.current === "yolo")
|
|
1530
|
+
return "once";
|
|
1531
|
+
if (trustAllRef.current)
|
|
1532
|
+
return "once";
|
|
1533
|
+
if (alwaysAllowedRef.current.has(name))
|
|
1534
|
+
return "once";
|
|
1535
|
+
if (skillGrantsRef.current.has(name))
|
|
1536
|
+
return "once";
|
|
1537
|
+
const signal = turnCancelRef.current?.signal ?? null;
|
|
1538
|
+
if (signal?.aborted)
|
|
1539
|
+
throw new LoopCancelledError();
|
|
1540
|
+
return new Promise((resolve, reject) => {
|
|
1541
|
+
approvalResolveRef.current = { resolve, reject };
|
|
1542
|
+
setPendingApproval({ name, args });
|
|
1543
|
+
if (signal) {
|
|
1544
|
+
const onAbort = () => {
|
|
1545
|
+
const h = approvalResolveRef.current;
|
|
1546
|
+
approvalResolveRef.current = null;
|
|
1547
|
+
setPendingApproval(null);
|
|
1548
|
+
h?.reject(new LoopCancelledError());
|
|
1549
|
+
};
|
|
1550
|
+
if (signal.aborted)
|
|
1551
|
+
onAbort();
|
|
1552
|
+
else
|
|
1553
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
function resolveApproval(decision) {
|
|
1558
|
+
if (decision === "always" && pendingApproval) {
|
|
1559
|
+
alwaysAllowedRef.current.add(pendingApproval.name);
|
|
1560
|
+
}
|
|
1561
|
+
const h = approvalResolveRef.current;
|
|
1562
|
+
approvalResolveRef.current = null;
|
|
1563
|
+
setPendingApproval(null);
|
|
1564
|
+
h?.resolve(decision);
|
|
1565
|
+
}
|
|
1566
|
+
// Trust-all from the approval prompt ([t]): approve this call and every
|
|
1567
|
+
// later write/edit/bash this session. Resolves as "once" (ApprovalDecision
|
|
1568
|
+
// is untouched — no zen.ts change); later calls skip the prompt via the
|
|
1569
|
+
// trustAllRef short-circuit in approve() above.
|
|
1570
|
+
function resolveTrustAll() {
|
|
1571
|
+
setTrustAllBoth(true);
|
|
1572
|
+
const h = approvalResolveRef.current;
|
|
1573
|
+
approvalResolveRef.current = null;
|
|
1574
|
+
setPendingApproval(null);
|
|
1575
|
+
h?.resolve("once");
|
|
1576
|
+
}
|
|
1577
|
+
// Plan-mode execute gate (ticket 04): the approve() plan branch above routes
|
|
1578
|
+
// write/edit/bash here with "once"; this refuses them pre-execution with a
|
|
1579
|
+
// replan-friendly result — never a prompt (approve never asked), never
|
|
1580
|
+
// silent (the ⚙ audit line + ↳ error line still render via the untouched
|
|
1581
|
+
// onToolActivity path). Starts with "Error:" so the loop's bookkeeping
|
|
1582
|
+
// treats it as unexecuted (no verification-gate arming, like denials).
|
|
1583
|
+
// Everything else delegates to the real executor untouched.
|
|
1584
|
+
function guardedExecute(name, args) {
|
|
1585
|
+
if (modeRef.current === "plan" && needsApproval(name)) {
|
|
1586
|
+
return Promise.resolve(`Error: plan mode is read-only — ${name} blocked (no writes while planning). ` +
|
|
1587
|
+
`Explore with read/grep/glob/web tools, record the plan with todowrite, then exit plan mode (/plan) to implement.`);
|
|
1588
|
+
}
|
|
1589
|
+
return executeTool(name, args);
|
|
1590
|
+
}
|
|
1591
|
+
// askUser hook for runAgenticLoop: modal select, resolved by the useInput
|
|
1592
|
+
// handler below (pick / custom text / Esc-cancel rejection). Ctrl+C aborts
|
|
1593
|
+
// the turn controller and rejects with LoopCancelledError (whole-turn
|
|
1594
|
+
// cancel), distinct from the Esc question-cancel result.
|
|
1595
|
+
async function askUser(question, options, allowCustom) {
|
|
1596
|
+
const signal = turnCancelRef.current?.signal ?? null;
|
|
1597
|
+
if (signal?.aborted)
|
|
1598
|
+
throw new LoopCancelledError();
|
|
1599
|
+
return new Promise((resolve, reject) => {
|
|
1600
|
+
askResolveRef.current = { resolve, reject };
|
|
1601
|
+
setAskSelIndexBoth(0);
|
|
1602
|
+
setAskCustomBoth("");
|
|
1603
|
+
setPendingQuestion({ question, options, allowCustom: allowCustom === true });
|
|
1604
|
+
if (signal) {
|
|
1605
|
+
const onAbort = () => {
|
|
1606
|
+
const h = askResolveRef.current;
|
|
1607
|
+
askResolveRef.current = null;
|
|
1608
|
+
setPendingQuestion(null);
|
|
1609
|
+
setAskCustomBoth("");
|
|
1610
|
+
h?.reject(new LoopCancelledError());
|
|
1611
|
+
};
|
|
1612
|
+
if (signal.aborted)
|
|
1613
|
+
onAbort();
|
|
1614
|
+
else
|
|
1615
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
function resolveAsk(answer) {
|
|
1620
|
+
const h = askResolveRef.current;
|
|
1621
|
+
askResolveRef.current = null;
|
|
1622
|
+
setPendingQuestion(null);
|
|
1623
|
+
setAskCustomBoth("");
|
|
1624
|
+
h?.resolve(answer);
|
|
1625
|
+
}
|
|
1626
|
+
function cancelAsk() {
|
|
1627
|
+
const h = askResolveRef.current;
|
|
1628
|
+
askResolveRef.current = null;
|
|
1629
|
+
setPendingQuestion(null);
|
|
1630
|
+
setAskCustomBoth("");
|
|
1631
|
+
h?.reject(new Error("question cancelled by user"));
|
|
1632
|
+
}
|
|
1633
|
+
// Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
|
|
1634
|
+
// above; each `SUBMIT STAGE n/4` marker below names its stage plus its
|
|
1635
|
+
// rollback-scope rule). Local "/" routing precedes the pipeline: exact
|
|
1636
|
+
// slash commands, /allow-/deny-/rules, and skill invocations never enter
|
|
1637
|
+
// it (no turn, no history, nothing to roll back).
|
|
1638
|
+
async function submit(value) {
|
|
1639
|
+
const text = value.trim();
|
|
1640
|
+
setInputBoth("");
|
|
1641
|
+
// /compact with optional focus text: prefix match ("/compact" or
|
|
1642
|
+
// "/compact focus…"). Busy → pending flag, run at turn end (drain
|
|
1643
|
+
// boundary, never mid-turn); idle → run now. This precedes the busy
|
|
1644
|
+
// guard so the pending flag can be set mid-turn.
|
|
1645
|
+
if (text === "/compact" || text.startsWith("/compact ")) {
|
|
1646
|
+
const focus = text === "/compact" ? "" : text.slice("/compact".length).trim();
|
|
1647
|
+
if (busyRef.current) {
|
|
1648
|
+
pendingCompactRef.current = focus;
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
await runCompactCommand(focus);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
// SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
|
|
1655
|
+
// nothing). Busy guard + API-key check: rejections return before any
|
|
1656
|
+
// history mutation, so there is nothing to roll back.
|
|
1657
|
+
if (!text || busyRef.current)
|
|
1658
|
+
return;
|
|
1659
|
+
// Scoped rules (ticket 03): exact or free-text forms (/allow bash:x,
|
|
1660
|
+
// /rules clear) route with args intact — SLASH_NAMES only holds exact
|
|
1661
|
+
// commands, and the skill fallback below must not swallow these.
|
|
1662
|
+
if (text === "/allow" || text.startsWith("/allow ") ||
|
|
1663
|
+
text === "/deny" || text.startsWith("/deny ") ||
|
|
1664
|
+
text === "/rules" || text.startsWith("/rules ")) {
|
|
1665
|
+
runRulesCommand(text);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
// Exact full-command + Enter runs it. A single-token "/name" not in
|
|
1669
|
+
// SLASH_NAMES resolves through the skill registry (ticket 03); anything
|
|
1670
|
+
// else starting with "/" still falls through as a model message.
|
|
1671
|
+
if (SLASH_NAMES.has(text)) {
|
|
1672
|
+
runSlashCommand(text);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
1676
|
+
if (skillName !== undefined) {
|
|
1677
|
+
void invokeSkillByName(skillName);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
// Missing key: guide to /provider instead of POSTing.
|
|
1681
|
+
const submitKey = keyForProvider(providerRef.current) || activeApiKeyRef.current;
|
|
1682
|
+
if (!submitKey) {
|
|
1683
|
+
setError(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
busyRef.current = true;
|
|
1687
|
+
setBusy(true);
|
|
1688
|
+
setError(null);
|
|
1689
|
+
setDraft(null);
|
|
1690
|
+
setThinking(null);
|
|
1691
|
+
// NOTE: skill grants are NOT cleared here — a manually armed skill
|
|
1692
|
+
// (loaded while idle) must survive into the turn it was armed for.
|
|
1693
|
+
// Expiry happens in the turn-end finally below, plus /clear + /new.
|
|
1694
|
+
try {
|
|
1695
|
+
draftThrottler().reset();
|
|
1696
|
+
}
|
|
1697
|
+
catch {
|
|
1698
|
+
// ignore (first token still paints; at worst one window late)
|
|
1699
|
+
}
|
|
1700
|
+
setToolHint(null);
|
|
1701
|
+
setPhase("thinking");
|
|
1702
|
+
setPhaseDetail("");
|
|
1703
|
+
// Phase 5: start the elapsed/stall timer (status-bar only, never the
|
|
1704
|
+
// transcript). Cleared in finally below and on unmount.
|
|
1705
|
+
startTurnTimer();
|
|
1706
|
+
// SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
|
|
1707
|
+
// survives failure). Refresh the pinned env block ONCE per turn (not per
|
|
1708
|
+
// POST — the loop reuses history[0] for all its POSTs, so this is the
|
|
1709
|
+
// only git call for the turn). Before the budget check so truncation
|
|
1710
|
+
// accounts for the fresh block size; before rollbackTo so the refresh
|
|
1711
|
+
// survives a failed-turn rollback (it is not part of the user turn).
|
|
1712
|
+
refreshSystemEnv();
|
|
1713
|
+
// SUBMIT STAGE 3/4 — budget-check (rollback scope: pre-rollbackTo,
|
|
1714
|
+
// survives failure). History budget at turn start, BEFORE the push +
|
|
1715
|
+
// rollbackTo capture below (so the existing splice-rollback indices stay
|
|
1716
|
+
// valid): drop oldest user-turns first, reserving room for the incoming user message
|
|
1717
|
+
// so the loop core's own budget check stays a no-op on entry — exactly
|
|
1718
|
+
// one dim notice per truncating turn. /clear drops the notice with the
|
|
1719
|
+
// transcript (usage totals still survive).
|
|
1720
|
+
truncateHistory(historyRef.current, (msg) => {
|
|
1721
|
+
appendTurns({ role: "tool", content: `⚠ ${msg}` });
|
|
1722
|
+
}, { messages: 1, chars: text.length });
|
|
1723
|
+
// SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
1724
|
+
// back on failure). Turn boundary: on POST failure (HTTP/network/empty/
|
|
1725
|
+
// truncated) the whole user turn (user message plus any partial
|
|
1726
|
+
// assistant/tool loop entries) is removed, so the next request starts
|
|
1727
|
+
// clean — same guarantee as the old single-pop. The streaming draft
|
|
1728
|
+
// lives outside `turns` until commit, so rollback just clears it (see
|
|
1729
|
+
// catch). Cancellation (LoopCancelledError) shares the same splice
|
|
1730
|
+
// contract.
|
|
1731
|
+
const rollbackTo = historyRef.current.length;
|
|
1732
|
+
const controller = new AbortController();
|
|
1733
|
+
turnCancelRef.current = controller;
|
|
1734
|
+
historyRef.current.push({ role: "user", content: text });
|
|
1735
|
+
appendTurns({ role: "user", content: text });
|
|
1736
|
+
// Skill auto-invoke (ticket 04): deterministic description match over a
|
|
1737
|
+
// fresh registry, inside the rollback scope so a failed turn removes
|
|
1738
|
+
// skill context too. Slash invocations skip it (manual path owns those).
|
|
1739
|
+
// Discovery/loading never throw; the guard only protects submit itself.
|
|
1740
|
+
if (!text.startsWith("/")) {
|
|
1741
|
+
try {
|
|
1742
|
+
const found = await discoverSkills({
|
|
1743
|
+
projectDir: skillDirs?.projectDir,
|
|
1744
|
+
homeDir: skillDirs?.homeDir,
|
|
1745
|
+
});
|
|
1746
|
+
for (const info of matchSkills(text, resolveSkills(found.skills).skills)) {
|
|
1747
|
+
await activateSkill(info);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
// ignore (a skill hiccup must never break submit)
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
const baseURL = getStoredBaseURL(authRef.current, providerRef.current);
|
|
1756
|
+
const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
|
|
1757
|
+
approve,
|
|
1758
|
+
askUser,
|
|
1759
|
+
// Plan-mode read-only gate (ticket 04): mutations are refused here
|
|
1760
|
+
// with a replan note; every other tool delegates to executeTool.
|
|
1761
|
+
execute: guardedExecute,
|
|
1762
|
+
reasoningEffort: effortRef.current,
|
|
1763
|
+
baseURL,
|
|
1764
|
+
endpointOverride: activeEndpoint,
|
|
1765
|
+
onToken: (partial) => {
|
|
1766
|
+
try {
|
|
1767
|
+
draftThrottler().push(partial);
|
|
1768
|
+
}
|
|
1769
|
+
catch {
|
|
1770
|
+
setDraft(partial);
|
|
1771
|
+
}
|
|
1772
|
+
noteTurnActivity();
|
|
1773
|
+
},
|
|
1774
|
+
onThinking: (partial) => {
|
|
1775
|
+
setThinking(partial);
|
|
1776
|
+
noteTurnActivity();
|
|
1777
|
+
},
|
|
1778
|
+
onPhase: (p, detail) => {
|
|
1779
|
+
setPhase(p);
|
|
1780
|
+
setPhaseDetail(detail ?? "");
|
|
1781
|
+
noteTurnActivity();
|
|
1782
|
+
if (p === "thinking") {
|
|
1783
|
+
// New POST: its thinking (if any) replaces the previous round's.
|
|
1784
|
+
setThinking(null);
|
|
1785
|
+
}
|
|
1786
|
+
else if (p === "tool" && detail) {
|
|
1787
|
+
setToolHint(detail);
|
|
1788
|
+
}
|
|
1789
|
+
else if (p === "retry") {
|
|
1790
|
+
const msg = detail ? `↻ retrying… ${detail}` : "↻ retrying…";
|
|
1791
|
+
appendTurns({ role: "tool", content: msg });
|
|
1792
|
+
}
|
|
1793
|
+
else if (p === "done") {
|
|
1794
|
+
setToolHint(null);
|
|
1795
|
+
flushDraft();
|
|
1796
|
+
}
|
|
1797
|
+
},
|
|
1798
|
+
onToolDelta: (name) => {
|
|
1799
|
+
setToolHint(name);
|
|
1800
|
+
},
|
|
1801
|
+
onUsage: (u) => {
|
|
1802
|
+
// Cumulative session spend from REAL reports only: every reporting
|
|
1803
|
+
// POST accumulates (tool-round POSTs and successful retries each
|
|
1804
|
+
// count once — each was billed; failed attempts report nothing, so
|
|
1805
|
+
// nothing is deduped). usageTotals drives NK only, never P%.
|
|
1806
|
+
const prev = usageRef.current ?? {};
|
|
1807
|
+
const next = { ...prev };
|
|
1808
|
+
if (u.prompt_tokens !== undefined) {
|
|
1809
|
+
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
1810
|
+
// Load metric source: last POST's reported prompt_tokens (the
|
|
1811
|
+
// per-POST value, NOT the accumulated total).
|
|
1812
|
+
lastPromptTokensRef.current = u.prompt_tokens;
|
|
1813
|
+
}
|
|
1814
|
+
if (u.completion_tokens !== undefined) {
|
|
1815
|
+
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
1816
|
+
}
|
|
1817
|
+
if (u.total_tokens !== undefined) {
|
|
1818
|
+
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
1819
|
+
}
|
|
1820
|
+
setUsageBoth(next);
|
|
1821
|
+
},
|
|
1822
|
+
onReasoning: (label) => {
|
|
1823
|
+
setReasoning(label);
|
|
1824
|
+
},
|
|
1825
|
+
onWarning: (msg) => {
|
|
1826
|
+
appendTurns({ role: "tool", content: `⚠ ${msg}` });
|
|
1827
|
+
},
|
|
1828
|
+
onToolActivity: (label, result, isError) => {
|
|
1829
|
+
const items = [{ role: "tool", content: label }];
|
|
1830
|
+
// Todo tools are session state, not side effects: their results
|
|
1831
|
+
// are short checklists, so successful ones join the transcript
|
|
1832
|
+
// (history fidelity — what did the list look like when?) and
|
|
1833
|
+
// refresh the live <TodoPanel> snapshot below the transcript.
|
|
1834
|
+
const isTodo = label === "⚙ todo_get" ||
|
|
1835
|
+
label.startsWith("⚙ todowrite ") ||
|
|
1836
|
+
label.startsWith("⚙ todo_update ");
|
|
1837
|
+
if (isTodo)
|
|
1838
|
+
setTodoSnap(getTodos());
|
|
1839
|
+
if (isError) {
|
|
1840
|
+
const firstLine = result.split("\n", 1)[0] ?? result;
|
|
1841
|
+
items.push({ role: "tool", content: ` ↳ ${firstLine}`, error: true });
|
|
1842
|
+
}
|
|
1843
|
+
else if (isTodo) {
|
|
1844
|
+
items.push({ role: "tool", content: result });
|
|
1845
|
+
}
|
|
1846
|
+
appendTurns(...items);
|
|
1847
|
+
noteTurnActivity();
|
|
1848
|
+
},
|
|
1849
|
+
signal: controller.signal,
|
|
1850
|
+
});
|
|
1851
|
+
// Turn-end flush: any trailing throttled partial paints before the
|
|
1852
|
+
// commit replaces the draft (byte-exact via `reply` regardless).
|
|
1853
|
+
flushDraft();
|
|
1854
|
+
appendTurns({ role: "assistant", content: reply });
|
|
1855
|
+
// The turn committed to history (final text, denial-as-result, or
|
|
1856
|
+
// stop-notice) — persist the kill-safe save. Rolled-back turns (catch
|
|
1857
|
+
// below) never reach here, so a failure can't clobber the last good save.
|
|
1858
|
+
persistSession();
|
|
1859
|
+
// Drain boundary (still busy, never mid-turn): pending manual /compact
|
|
1860
|
+
// first (it resets the thrash counter), else auto-compact when the
|
|
1861
|
+
// load is over threshold. Compaction persists via the normal save path.
|
|
1862
|
+
if (pendingCompactRef.current !== null) {
|
|
1863
|
+
const focus = pendingCompactRef.current;
|
|
1864
|
+
pendingCompactRef.current = null;
|
|
1865
|
+
await doCompact(focus, false);
|
|
1866
|
+
// A /compact that arrived during the compaction above drains now.
|
|
1867
|
+
if (pendingCompactRef.current !== null) {
|
|
1868
|
+
const focus2 = pendingCompactRef.current;
|
|
1869
|
+
pendingCompactRef.current = null;
|
|
1870
|
+
await doCompact(focus2, false);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
else {
|
|
1874
|
+
await maybeAutoCompact();
|
|
1875
|
+
if (pendingCompactRef.current !== null) {
|
|
1876
|
+
const focus = pendingCompactRef.current;
|
|
1877
|
+
pendingCompactRef.current = null;
|
|
1878
|
+
await doCompact(focus, false);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
catch (err) {
|
|
1883
|
+
const cancelled = err instanceof LoopCancelledError ||
|
|
1884
|
+
(err instanceof Error && err.name === "LoopCancelledError") ||
|
|
1885
|
+
controller.signal.aborted;
|
|
1886
|
+
historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
|
|
1887
|
+
if (cancelled) {
|
|
1888
|
+
// One dim line (tool role renders dim); not an error.
|
|
1889
|
+
// Rolled back above: no save, the last good save stays intact.
|
|
1890
|
+
appendTurns({ role: "tool", content: "(cancelled)" });
|
|
1891
|
+
}
|
|
1892
|
+
else {
|
|
1893
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1894
|
+
}
|
|
1895
|
+
// Turn-end drain even after failure/rollback: pending manual still
|
|
1896
|
+
// runs (it applies to the surviving history); auto never fires here
|
|
1897
|
+
// (load is meaningless for a rolled-back turn — just refresh it).
|
|
1898
|
+
if (pendingCompactRef.current !== null) {
|
|
1899
|
+
const focus = pendingCompactRef.current;
|
|
1900
|
+
pendingCompactRef.current = null;
|
|
1901
|
+
try {
|
|
1902
|
+
await doCompact(focus, false);
|
|
1903
|
+
}
|
|
1904
|
+
catch {
|
|
1905
|
+
// doCompact never throws (it reports inline), but stay safe.
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
else {
|
|
1909
|
+
refreshContextLoad();
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
finally {
|
|
1913
|
+
turnCancelRef.current = null;
|
|
1914
|
+
approvalResolveRef.current = null;
|
|
1915
|
+
setPendingApproval(null);
|
|
1916
|
+
askResolveRef.current = null;
|
|
1917
|
+
setPendingQuestion(null);
|
|
1918
|
+
setAskCustomBoth("");
|
|
1919
|
+
// Turn-scoped skill grants expire here: armed-while-idle and auto
|
|
1920
|
+
// skills cover exactly the turn that just ended (success, failure,
|
|
1921
|
+
// or cancel) — the next user message starts clean (ticket 06).
|
|
1922
|
+
skillGrantsRef.current = new Set();
|
|
1923
|
+
busyRef.current = false;
|
|
1924
|
+
setBusy(false);
|
|
1925
|
+
try {
|
|
1926
|
+
draftThrottleRef.current?.cancel();
|
|
1927
|
+
}
|
|
1928
|
+
catch {
|
|
1929
|
+
// ignore
|
|
1930
|
+
}
|
|
1931
|
+
setDraft(null);
|
|
1932
|
+
setThinking(null);
|
|
1933
|
+
setToolHint(null);
|
|
1934
|
+
clearTurnTimer();
|
|
1935
|
+
setStalled(false);
|
|
1936
|
+
setElapsedSecs(0);
|
|
1937
|
+
setPhase("idle");
|
|
1938
|
+
setPhaseDetail("");
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
function cancelTurn() {
|
|
1942
|
+
// Interrupt safety: stop after the current tool finishes (the loop
|
|
1943
|
+
// checks the signal before each new POST/execution), roll the partial
|
|
1944
|
+
// turn back, show `(cancelled)`, and clear busy/draft/modals.
|
|
1945
|
+
try {
|
|
1946
|
+
turnCancelRef.current?.abort();
|
|
1947
|
+
}
|
|
1948
|
+
catch {
|
|
1949
|
+
// ignore
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
useInput((ch, key) => {
|
|
1953
|
+
// Raw Ctrl+C / Ctrl+D bytes (ink-testing-library sends "\x03"/"\x04").
|
|
1954
|
+
const rawCancel = ch === "\u0003" || ch === "\u0004";
|
|
1955
|
+
const ctrlCancel = (key.ctrl && (ch === "c" || ch === "d" || ch === "C" || ch === "D")) || rawCancel;
|
|
1956
|
+
if (ctrlCancel) {
|
|
1957
|
+
// Mid-turn: cancel the whole turn (works during POST wait, tool
|
|
1958
|
+
// execution, and the approval/question modals). Idle: exit as before.
|
|
1959
|
+
if (turnCancelRef.current || busyRef.current) {
|
|
1960
|
+
cancelTurn();
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
persistSession();
|
|
1964
|
+
exit();
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (key.ctrl && (ch === "c" || ch === "d")) {
|
|
1968
|
+
persistSession();
|
|
1969
|
+
exit();
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
// 1. Tool approval prompt (normal mode): y = once, a = always this
|
|
1973
|
+
// session, t = trust all write/edit/bash this session, n/Esc = deny
|
|
1974
|
+
// (denial feeds back into the loop as a result).
|
|
1975
|
+
// Ctrl+C (handled above) cancels the whole turn instead.
|
|
1976
|
+
if (pendingApproval) {
|
|
1977
|
+
const k = (ch ?? "").toLowerCase();
|
|
1978
|
+
if (k === "y")
|
|
1979
|
+
resolveApproval("once");
|
|
1980
|
+
else if (k === "a")
|
|
1981
|
+
resolveApproval("always");
|
|
1982
|
+
else if (k === "t")
|
|
1983
|
+
resolveTrustAll();
|
|
1984
|
+
else if (k === "n" || key.escape)
|
|
1985
|
+
resolveApproval("no");
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
// 2. ask_question modal: arrows + Enter picks, typing + Enter submits
|
|
1989
|
+
// custom text (allowCustom only), Esc cancels.
|
|
1990
|
+
if (pendingQuestion) {
|
|
1991
|
+
const len = Math.max(pendingQuestion.options.length, 1);
|
|
1992
|
+
if (key.upArrow) {
|
|
1993
|
+
setAskSelIndexBoth((askSelIndexRef.current - 1 + len) % len);
|
|
1994
|
+
}
|
|
1995
|
+
else if (key.downArrow) {
|
|
1996
|
+
setAskSelIndexBoth((askSelIndexRef.current + 1) % len);
|
|
1997
|
+
}
|
|
1998
|
+
else if (key.escape) {
|
|
1999
|
+
cancelAsk();
|
|
2000
|
+
}
|
|
2001
|
+
else if (key.return || key.tab) {
|
|
2002
|
+
if (pendingQuestion.allowCustom && askCustomRef.current.trim().length > 0) {
|
|
2003
|
+
resolveAsk(askCustomRef.current);
|
|
2004
|
+
}
|
|
2005
|
+
else {
|
|
2006
|
+
const picked = pendingQuestion.options[askSelIndexRef.current];
|
|
2007
|
+
if (picked !== undefined)
|
|
2008
|
+
resolveAsk(picked);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
else if (key.backspace || key.delete) {
|
|
2012
|
+
if (pendingQuestion.allowCustom) {
|
|
2013
|
+
setAskCustomBoth(askCustomRef.current.slice(0, -1));
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
2017
|
+
if (pendingQuestion.allowCustom) {
|
|
2018
|
+
setAskCustomBoth(askCustomRef.current + ch);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
// 2b. Provider key prompt (masked • per char, paste works, Esc safe).
|
|
2024
|
+
const kp = keyPromptRef.current;
|
|
2025
|
+
if (kp && keyPrompt) {
|
|
2026
|
+
if (key.escape) {
|
|
2027
|
+
// Existing key: Esc keeps + switches. No existing: Esc back to picker.
|
|
2028
|
+
if (kp.existingMasked) {
|
|
2029
|
+
const keep = keyForProvider(kp.providerId);
|
|
2030
|
+
setKeyPromptBoth(null);
|
|
2031
|
+
if (keep) {
|
|
2032
|
+
void switchProviderWithKey(kp.providerId, keep);
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
openProviderPicker();
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
else {
|
|
2039
|
+
openProviderPicker();
|
|
2040
|
+
}
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (key.return) {
|
|
2044
|
+
const draft = kp.draft;
|
|
2045
|
+
if (!draft || kp.validating || busy)
|
|
2046
|
+
return;
|
|
2047
|
+
const baseURL = getStoredBaseURL(authRef.current, kp.providerId);
|
|
2048
|
+
setKeyPromptBoth({ ...kp, validating: true, error: null });
|
|
2049
|
+
void validateProviderKey(kp.providerId, draft, baseURL).then((res) => {
|
|
2050
|
+
const cur = keyPromptRef.current;
|
|
2051
|
+
if (!cur || cur.providerId !== kp.providerId)
|
|
2052
|
+
return;
|
|
2053
|
+
if (res.ok) {
|
|
2054
|
+
const next = setStoredKey(authRef.current, kp.providerId, draft);
|
|
2055
|
+
setAuthBoth(next);
|
|
2056
|
+
try {
|
|
2057
|
+
saveAuth(next, authHome);
|
|
2058
|
+
}
|
|
2059
|
+
catch {
|
|
2060
|
+
// ignore disk errors (in-memory switch still applies)
|
|
2061
|
+
}
|
|
2062
|
+
setKeyPromptBoth(null);
|
|
2063
|
+
void switchProviderWithKey(kp.providerId, draft);
|
|
2064
|
+
}
|
|
2065
|
+
else {
|
|
2066
|
+
// Failure: inline error, stay put for retry (Esc/back safe).
|
|
2067
|
+
setKeyPromptBoth({
|
|
2068
|
+
...cur,
|
|
2069
|
+
validating: false,
|
|
2070
|
+
error: res.error ?? "validation failed",
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (key.backspace || key.delete) {
|
|
2077
|
+
if (!kp.validating) {
|
|
2078
|
+
setKeyPromptBoth({ ...kp, draft: kp.draft.slice(0, -1), error: null });
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (ch && !key.ctrl && !key.meta && !key.tab && !kp.validating) {
|
|
2083
|
+
setKeyPromptBoth({ ...kp, draft: kp.draft + ch, error: null });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
// 2c. Provider baseURL prompt (openai-compatible, plain text, http(s)).
|
|
2089
|
+
const bp = baseURLPromptRef.current;
|
|
2090
|
+
if (bp && baseURLPrompt) {
|
|
2091
|
+
if (key.escape) {
|
|
2092
|
+
openProviderPicker();
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
if (key.return) {
|
|
2096
|
+
const draft = bp.draft.trim();
|
|
2097
|
+
const invalid = validateBaseURL(draft);
|
|
2098
|
+
if (invalid) {
|
|
2099
|
+
setBaseURLPromptBoth({ ...bp, error: invalid });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
const prev = authRef.current.providers[bp.providerId];
|
|
2103
|
+
const next = setStoredKey(authRef.current, bp.providerId, prev?.apiKey ?? "", draft);
|
|
2104
|
+
setAuthBoth(next);
|
|
2105
|
+
try {
|
|
2106
|
+
saveAuth(next, authHome);
|
|
2107
|
+
}
|
|
2108
|
+
catch {
|
|
2109
|
+
// ignore
|
|
2110
|
+
}
|
|
2111
|
+
setBaseURLPromptBoth(null);
|
|
2112
|
+
// BaseURL saved: if a key is on file, switch; else prompt the key.
|
|
2113
|
+
const existing = keyForProvider(bp.providerId);
|
|
2114
|
+
if (existing) {
|
|
2115
|
+
void switchProviderWithKey(bp.providerId, existing);
|
|
2116
|
+
}
|
|
2117
|
+
else {
|
|
2118
|
+
openKeyPrompt(bp.providerId);
|
|
2119
|
+
}
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (key.backspace || key.delete) {
|
|
2123
|
+
setBaseURLPromptBoth({ ...bp, draft: bp.draft.slice(0, -1), error: null });
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
2127
|
+
setBaseURLPromptBoth({ ...bp, draft: bp.draft + ch, error: null });
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
// 2d. Provider picker (✓ key / — no key markers, ↑/↓ + Enter, Esc).
|
|
2133
|
+
if (selectingProvider) {
|
|
2134
|
+
if (key.upArrow) {
|
|
2135
|
+
setProviderIndexBoth((providerIndexRef.current - 1 + PROVIDERS.length) % PROVIDERS.length);
|
|
2136
|
+
}
|
|
2137
|
+
else if (key.downArrow) {
|
|
2138
|
+
setProviderIndexBoth((providerIndexRef.current + 1) % PROVIDERS.length);
|
|
2139
|
+
}
|
|
2140
|
+
else if (key.escape) {
|
|
2141
|
+
setSelectingProvider(false);
|
|
2142
|
+
}
|
|
2143
|
+
else if (key.return) {
|
|
2144
|
+
const picked = PROVIDERS[providerIndexRef.current % PROVIDERS.length];
|
|
2145
|
+
if (!picked) {
|
|
2146
|
+
setSelectingProvider(false);
|
|
2147
|
+
}
|
|
2148
|
+
else if (picked.id === "openai-compatible" &&
|
|
2149
|
+
!getStoredBaseURL(authRef.current, picked.id)) {
|
|
2150
|
+
openBaseURLPrompt(picked.id);
|
|
2151
|
+
}
|
|
2152
|
+
else {
|
|
2153
|
+
// No key -> paste prompt; key on file -> replace prompt
|
|
2154
|
+
// (masked hint; typing replaces, Esc keeps + switches).
|
|
2155
|
+
openKeyPrompt(picked.id);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
// 3. Model picker (opening it replaces/closes the slash menu; Esc
|
|
2161
|
+
// returns to plain input, never to the slash menu).
|
|
2162
|
+
if (selecting) {
|
|
2163
|
+
if (key.upArrow) {
|
|
2164
|
+
setSelIndexBoth((selIndexRef.current - 1 + models.length) % models.length);
|
|
2165
|
+
}
|
|
2166
|
+
else if (key.downArrow) {
|
|
2167
|
+
setSelIndexBoth((selIndexRef.current + 1) % models.length);
|
|
2168
|
+
}
|
|
2169
|
+
else if (key.escape) {
|
|
2170
|
+
setSelecting(false);
|
|
2171
|
+
}
|
|
2172
|
+
else if (key.return) {
|
|
2173
|
+
const picked = models[selIndexRef.current];
|
|
2174
|
+
if (picked) {
|
|
2175
|
+
setModelBoth(picked);
|
|
2176
|
+
// Model switch resets the load latch (different tokenizer: the old
|
|
2177
|
+
// reported prompt_tokens no longer measures this context); the
|
|
2178
|
+
// estimate applies until the new model reports.
|
|
2179
|
+
resetContextLoadToEstimate();
|
|
2180
|
+
// Re-gate effort on every /model switch: setting persists, but a
|
|
2181
|
+
// non-Default effort on an unsupported model warns (kept, not sent).
|
|
2182
|
+
if (effortRef.current !== "default" && !isEffortSupported(picked)) {
|
|
2183
|
+
warnEffortUnsupported(picked);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
setSelecting(false);
|
|
2187
|
+
}
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
// 3b. Effort picker (/effort): same keyboard pattern as the /model
|
|
2191
|
+
// picker (↑/↓ + Enter, Esc cancels).
|
|
2192
|
+
if (selectingEffort) {
|
|
2193
|
+
if (key.upArrow) {
|
|
2194
|
+
setEffortIndexBoth((effortIndexRef.current - 1 + EFFORT_OPTIONS.length) % EFFORT_OPTIONS.length);
|
|
2195
|
+
}
|
|
2196
|
+
else if (key.downArrow) {
|
|
2197
|
+
setEffortIndexBoth((effortIndexRef.current + 1) % EFFORT_OPTIONS.length);
|
|
2198
|
+
}
|
|
2199
|
+
else if (key.escape) {
|
|
2200
|
+
setSelectingEffort(false);
|
|
2201
|
+
}
|
|
2202
|
+
else if (key.return) {
|
|
2203
|
+
const picked = EFFORT_OPTIONS[effortIndexRef.current];
|
|
2204
|
+
if (picked) {
|
|
2205
|
+
setEffortBoth(picked);
|
|
2206
|
+
if (picked !== "default" && !isEffortSupported(modelRef.current)) {
|
|
2207
|
+
warnEffortUnsupported(modelRef.current);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
setSelectingEffort(false);
|
|
2211
|
+
}
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
// 3c. /rewind pickers (ticket 01): checkpoint list, then restore scope
|
|
2215
|
+
// (↑/↓ + Enter, Esc cancels each step). The scope step runs the restore.
|
|
2216
|
+
if (selectingRewind) {
|
|
2217
|
+
const cps = listCheckpoints();
|
|
2218
|
+
if (cps.length === 0) {
|
|
2219
|
+
setSelectingRewind(false);
|
|
2220
|
+
}
|
|
2221
|
+
else if (key.upArrow) {
|
|
2222
|
+
setRewindIndexBoth((rewindIndexRef.current - 1 + cps.length) % cps.length);
|
|
2223
|
+
}
|
|
2224
|
+
else if (key.downArrow) {
|
|
2225
|
+
setRewindIndexBoth((rewindIndexRef.current + 1) % cps.length);
|
|
2226
|
+
}
|
|
2227
|
+
else if (key.escape) {
|
|
2228
|
+
setSelectingRewind(false);
|
|
2229
|
+
}
|
|
2230
|
+
else if (key.return) {
|
|
2231
|
+
const picked = cps[rewindIndexRef.current % cps.length];
|
|
2232
|
+
setSelectingRewind(false);
|
|
2233
|
+
if (picked) {
|
|
2234
|
+
pendingRewindRef.current = picked.id;
|
|
2235
|
+
setRewindScopeIndexBoth(0);
|
|
2236
|
+
setSelectingRewindScope(true);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (selectingRewindScope) {
|
|
2242
|
+
if (key.upArrow) {
|
|
2243
|
+
setRewindScopeIndexBoth((rewindScopeIndexRef.current - 1 + REWIND_SCOPES.length) % REWIND_SCOPES.length);
|
|
2244
|
+
}
|
|
2245
|
+
else if (key.downArrow) {
|
|
2246
|
+
setRewindScopeIndexBoth((rewindScopeIndexRef.current + 1) % REWIND_SCOPES.length);
|
|
2247
|
+
}
|
|
2248
|
+
else if (key.escape) {
|
|
2249
|
+
setSelectingRewindScope(false);
|
|
2250
|
+
pendingRewindRef.current = null;
|
|
2251
|
+
}
|
|
2252
|
+
else if (key.return) {
|
|
2253
|
+
const id = pendingRewindRef.current;
|
|
2254
|
+
const scope = REWIND_SCOPES[rewindScopeIndexRef.current % REWIND_SCOPES.length];
|
|
2255
|
+
setSelectingRewindScope(false);
|
|
2256
|
+
pendingRewindRef.current = null;
|
|
2257
|
+
if (id && scope) {
|
|
2258
|
+
void runRewind(id, scope);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
// 4. "/" slash menu (filter-as-you-type): ↑/↓ + Enter/Tab runs the
|
|
2264
|
+
// highlighted command, Esc dismisses back to plain input.
|
|
2265
|
+
const cur = inputRef.current;
|
|
2266
|
+
const matches = !slashDismissedRef.current && cur.startsWith("/")
|
|
2267
|
+
? filterSlashCommands(cur)
|
|
2268
|
+
: [];
|
|
2269
|
+
if (matches.length > 0) {
|
|
2270
|
+
if (key.leftArrow) {
|
|
2271
|
+
setCursorBoth(cursorRef.current - 1);
|
|
2272
|
+
}
|
|
2273
|
+
else if (key.rightArrow) {
|
|
2274
|
+
setCursorBoth(cursorRef.current + 1);
|
|
2275
|
+
}
|
|
2276
|
+
else if (key.home) {
|
|
2277
|
+
setCursorBoth(0);
|
|
2278
|
+
}
|
|
2279
|
+
else if (key.end) {
|
|
2280
|
+
setCursorBoth(inputRef.current.length);
|
|
2281
|
+
}
|
|
2282
|
+
else if (key.upArrow) {
|
|
2283
|
+
setSlashIndexBoth((slashIndexRef.current - 1 + matches.length) % matches.length);
|
|
2284
|
+
}
|
|
2285
|
+
else if (key.downArrow) {
|
|
2286
|
+
setSlashIndexBoth((slashIndexRef.current + 1) % matches.length);
|
|
2287
|
+
}
|
|
2288
|
+
else if (key.escape) {
|
|
2289
|
+
setSlashDismissedBoth(true);
|
|
2290
|
+
}
|
|
2291
|
+
else if (key.return || key.tab) {
|
|
2292
|
+
const pick = matches[slashIndexRef.current % matches.length];
|
|
2293
|
+
// /compact is allowed while busy (sets the pending flag for turn-end
|
|
2294
|
+
// drain); every other command still waits for idle.
|
|
2295
|
+
if (pick && (pick.name === "/compact" || !busyRef.current)) {
|
|
2296
|
+
if (pick.name === "/compact" && inputRef.current.startsWith("/compact ")) {
|
|
2297
|
+
// Preserve free-text focus when the menu is open on a prefix.
|
|
2298
|
+
const focus = inputRef.current.slice("/compact".length).trim();
|
|
2299
|
+
setInputBoth("");
|
|
2300
|
+
void runCompactCommand(focus);
|
|
2301
|
+
}
|
|
2302
|
+
else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
|
|
2303
|
+
inputRef.current.startsWith(pick.name)) {
|
|
2304
|
+
// Preserve the typed rule args (e.g. "/allow bash:npm test*");
|
|
2305
|
+
// a bare highlighted name falls through to usage/list.
|
|
2306
|
+
const raw = inputRef.current;
|
|
2307
|
+
setInputBoth("");
|
|
2308
|
+
runRulesCommand(raw);
|
|
2309
|
+
}
|
|
2310
|
+
else {
|
|
2311
|
+
runSlashCommand(pick.name);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
else if (key.backspace || key.delete) {
|
|
2316
|
+
if (key.delete && !key.backspace) {
|
|
2317
|
+
deleteAtCursor();
|
|
2318
|
+
}
|
|
2319
|
+
else {
|
|
2320
|
+
backspaceAtCursor();
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
2324
|
+
insertAtCursor(ch);
|
|
2325
|
+
}
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
// 4b. Esc stops a running response (opencode-style session_interrupt):
|
|
2329
|
+
// busy with every modal/menu/picker above dismissed → cancel the whole
|
|
2330
|
+
// turn, exactly like Ctrl+C (rollback + dim `(cancelled)`). The input
|
|
2331
|
+
// line is kept, not cleared, so a typed follow-up survives.
|
|
2332
|
+
if (key.escape && (turnCancelRef.current || busyRef.current)) {
|
|
2333
|
+
cancelTurn();
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
// 5. Plain input. Tab toggles normal<->yolo here; when the "/" slash
|
|
2337
|
+
// menu is open (section 4 above) Tab instead runs the highlighted
|
|
2338
|
+
// command and never reaches this branch.
|
|
2339
|
+
if (key.leftArrow) {
|
|
2340
|
+
setCursorBoth(cursorRef.current - 1);
|
|
2341
|
+
}
|
|
2342
|
+
else if (key.rightArrow) {
|
|
2343
|
+
setCursorBoth(cursorRef.current + 1);
|
|
2344
|
+
}
|
|
2345
|
+
else if (key.home) {
|
|
2346
|
+
setCursorBoth(0);
|
|
2347
|
+
}
|
|
2348
|
+
else if (key.end) {
|
|
2349
|
+
setCursorBoth(inputRef.current.length);
|
|
2350
|
+
}
|
|
2351
|
+
else if (key.return) {
|
|
2352
|
+
void submit(inputRef.current);
|
|
2353
|
+
}
|
|
2354
|
+
else if (key.tab) {
|
|
2355
|
+
// Tab toggles normal<->yolo only (pinned by tests/status.test.tsx): it
|
|
2356
|
+
// never enters or exits plan mode, so a stray keypress can't drop the
|
|
2357
|
+
// deliberate safety mode — use /plan. Silent no-op in plan (the status
|
|
2358
|
+
// line already shows mode: plan).
|
|
2359
|
+
if (modeRef.current !== "plan") {
|
|
2360
|
+
const next = modeRef.current === "normal" ? "yolo" : "normal";
|
|
2361
|
+
setModeBoth(next);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
else if (key.delete && !key.backspace) {
|
|
2365
|
+
deleteAtCursor();
|
|
2366
|
+
}
|
|
2367
|
+
else if (key.backspace) {
|
|
2368
|
+
backspaceAtCursor();
|
|
2369
|
+
}
|
|
2370
|
+
else if (key.escape) {
|
|
2371
|
+
setInputBoth("");
|
|
2372
|
+
}
|
|
2373
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
2374
|
+
insertAtCursor(ch);
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
const phaseLabel = phase === "thinking" || phase === "idle"
|
|
2378
|
+
? "thinking…"
|
|
2379
|
+
: phase === "streaming"
|
|
2380
|
+
? "streaming…"
|
|
2381
|
+
: phase === "tool"
|
|
2382
|
+
? phaseDetail
|
|
2383
|
+
? `calling ${phaseDetail}…`
|
|
2384
|
+
: "tool…"
|
|
2385
|
+
: phase === "retry"
|
|
2386
|
+
? phaseDetail
|
|
2387
|
+
? `retrying… ${phaseDetail}`
|
|
2388
|
+
: "retrying…"
|
|
2389
|
+
: phase === "done"
|
|
2390
|
+
? "done"
|
|
2391
|
+
: "thinking…";
|
|
2392
|
+
// Slash menu derived for render (mirrors the useInput computation above).
|
|
2393
|
+
const filteredSlash = !selecting &&
|
|
2394
|
+
!selectingEffort &&
|
|
2395
|
+
!selectingProvider &&
|
|
2396
|
+
!keyPrompt &&
|
|
2397
|
+
!baseURLPrompt &&
|
|
2398
|
+
!pendingApproval &&
|
|
2399
|
+
!pendingQuestion &&
|
|
2400
|
+
!selectingRewind &&
|
|
2401
|
+
!selectingRewindScope &&
|
|
2402
|
+
!slashDismissed &&
|
|
2403
|
+
input.startsWith("/")
|
|
2404
|
+
? filterSlashCommands(input)
|
|
2405
|
+
: [];
|
|
2406
|
+
const slashVisible = filteredSlash.length > 0;
|
|
2407
|
+
const slashHighlight = filteredSlash.length > 0 ? filteredSlash[slashIndex % filteredSlash.length]?.name : undefined;
|
|
2408
|
+
// Cursor block renders AT the cursor offset (defensive clamp: the ref is
|
|
2409
|
+
// the source of truth mid-tick and always stays in range via setCursorBoth,
|
|
2410
|
+
// but state may lag it by one render).
|
|
2411
|
+
const safeCursor = Math.max(0, Math.min(cursor, input.length));
|
|
2412
|
+
// Status-line reasoning segment wired to the effort session state:
|
|
2413
|
+
// non-Default shows the effort (plus " (unsupported)" when the model is
|
|
2414
|
+
// outside the verified-support set OR the provider is not opencode-zen);
|
|
2415
|
+
// Default shows response metadata or "default" as before.
|
|
2416
|
+
// reasoning_effort is sent ONLY for opencode-zen + supported model.
|
|
2417
|
+
const effortSupportedNow = effort === "default" ||
|
|
2418
|
+
(provider === "opencode-zen" && isEffortSupported(model));
|
|
2419
|
+
const reasoningDisplay = effort !== "default"
|
|
2420
|
+
? effortSupportedNow
|
|
2421
|
+
? effort
|
|
2422
|
+
: `${effort} (unsupported)`
|
|
2423
|
+
: (reasoning ?? "default");
|
|
2424
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [turns.length === 0 ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && turns.length === 0 ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, draft ? (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["ATOM>", " "] }), draft, _jsx(Text, { color: "gray", children: "\u258D" })] })) : null, thinking ? (_jsxs(Text, { dimColor: true, children: ["\uD83D\uDCAD ", thinking.length > THINKING_DISPLAY_CAP ? "…" + thinking.slice(-THINKING_DISPLAY_CAP) : thinking, _jsx(Text, { color: "gray", children: "\u258D" })] })) : null, busy && toolHint ? _jsxs(Text, { dimColor: true, children: ["\u25CC calling ", toolHint, "\u2026"] }) : null] }), error ? _jsxs(Text, { color: "red", children: ["error> ", error] }) : null, pendingApproval ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom permission \u2014 allow this tool?" }), _jsx(Text, { children: describeToolCall(pendingApproval.name, pendingApproval.args) }), _jsxs(Text, { children: ["[y]es once \u00B7 [a]lways allow ", pendingApproval.name, " this session \u00B7 [t]rust all write/edit/bash this session \u00B7 [n]o (Esc = no)"] })] })) : null, pendingQuestion ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Atom question \u2014 ", pendingQuestion.question] }), pendingQuestion.options.map((o, i) => (_jsxs(Text, { color: i === askSelIndex ? "magenta" : undefined, children: [i === askSelIndex ? "❯ " : " ", o] }, `${o}-${i}`))), pendingQuestion.allowCustom ? (_jsxs(Text, { dimColor: true, children: ["Type a custom answer + Enter to send it", askCustom ? `: ${askCustom}` : "", " \u00B7 \u2191/\u2193 + Enter picks \u00B7 Esc cancels"] })) : (_jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter to pick \u00B7 Esc cancels" }))] })) : null, _jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", marginTop: 1 }), _jsx(TodoPanel, { items: todoSnap }), selecting ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select model (up/down + Enter, Esc cancels):" }), models.map((m, i) => (_jsxs(Text, { color: i === selIndex ? "green" : undefined, children: [i === selIndex ? "❯ " : " ", m, m === model ? " (current)" : ""] }, `${m}-${i}`)))] })) : selectingProvider ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):" }), PROVIDERS.map((p, i) => {
|
|
2425
|
+
const has = keyForProvider(p.id).length > 0;
|
|
2426
|
+
return (_jsxs(Text, { color: i === providerIndex ? "green" : undefined, children: [i === providerIndex ? "❯ " : " ", p.name, " (", p.id, ") ", has ? "✓ key" : "— no key", p.id === provider ? " (current)" : ""] }, p.id));
|
|
2427
|
+
})] })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, 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" })), _jsxs(Text, { children: ["key: ", "•".repeat(keyPrompt.draft.length), _jsx(Text, { color: "gray", children: "\u2588" })] }), keyPrompt.validating ? _jsx(Text, { dimColor: true, children: "validating\u2026" }) : null, keyPrompt.error ? _jsx(Text, { color: "red", children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, 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: "gray", children: "\u2588" })] }), baseURLPrompt.error ? _jsx(Text, { color: "red", children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):" }), EFFORT_OPTIONS.map((o, i) => (_jsxs(Text, { color: i === effortIndex ? "green" : undefined, children: [i === effortIndex ? "❯ " : " ", 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(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):" }), listCheckpoints().map((c, i) => (_jsxs(Text, { color: i === rewindIndex ? "green" : undefined, children: [i === rewindIndex ? "❯ " : " ", "#", c.seq, " \u00B7 ", c.label, " \u00B7 ", 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(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):" }), REWIND_SCOPES.map((s, i) => (_jsxs(Text, { color: i === rewindScopeIndex ? "green" : undefined, children: [i === rewindScopeIndex ? "❯ " : " ", s] }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u203A", " "] }), _jsxs(Text, { children: [input.slice(0, safeCursor), _jsx(Text, { color: "gray", children: "\u2588" }), input.slice(safeCursor)] })] })), slashVisible ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Atom commands (\u2191/\u2193 + Enter/Tab to run, Esc dismisses):" }), filteredSlash.map((c) => (_jsxs(Text, { color: c.name === slashHighlight ? "cyan" : undefined, children: [c.name === slashHighlight ? "❯ " : " ", c.name, " \u2014 ", c.description] }, c.name)))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["provider: ", provider, " \u00B7 model: ", model, " \u00B7 ", formatTokens(usageTotals, model, contextLoad), " \u00B7 reasoning:", " ", reasoningDisplay, " \u00B7 mode: ", mode, trustAll && mode !== "plan" ? "+trust" : null, busy ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", phaseLabel, " ", elapsedSecs, "s \u00B7 esc stops"] }) : null, busy && stalled ? " · waiting…" : null] }) })] }));
|
|
2428
|
+
}
|