atom-agent 0.3.0 → 1.1.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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
package/dist/App.js
CHANGED
|
@@ -1,42 +1,74 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Ink (React) TUI for the minimal Atom chatbot.
|
|
3
3
|
// Hand-rolled input + dropdowns via useInput (no extra deps).
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { Box, Text, useApp, useInput, usePaste, useStdout } from "ink";
|
|
9
|
+
import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, openTodoNeedles, runAgenticLoopForProvider, } from "./zen.js";
|
|
10
|
+
import { createContextManager, trackHistory, } from "./context-manager.js";
|
|
11
|
+
import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
|
|
12
|
+
import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
|
|
13
|
+
import { classifyTurnOutcome, createTelemetryRecorder, loadTelemetrySessions, resolveTelemetryEnabled, summarizeTelemetry, telemetryDir, } from "./telemetry.js";
|
|
14
|
+
import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
|
|
15
|
+
import { formatRules, parseRuleInput, } from "./permissions.js";
|
|
16
|
+
import { decidePolicy, skillGrantsFor } from "./policy.js";
|
|
17
|
+
import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
|
|
18
|
+
import { contextWindowFor } from "./context-windows.js";
|
|
19
|
+
import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
|
|
20
|
+
import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
|
|
21
|
+
import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
|
|
13
22
|
import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
|
|
14
23
|
import { validateProviderKey } from "./adapters.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
24
|
+
import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
|
|
25
|
+
import { getGitInfo, withEnvBlock } from "./env-block.js";
|
|
26
|
+
import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
|
|
27
|
+
import { loadAtomConfig } from "./config.js";
|
|
28
|
+
import { cancelledTurnLine } from "./rollback.js";
|
|
29
|
+
import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
|
|
18
30
|
import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
|
|
31
|
+
import { InputBox } from "./ui/input.js";
|
|
32
|
+
import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
|
|
33
|
+
import { LiveTail } from "./ui/live-tail.js";
|
|
34
|
+
import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
|
|
35
|
+
import { activityText } from "./ui/activity.js";
|
|
36
|
+
import { ApprovalBox, QuestionBox } from "./ui/modals.js";
|
|
37
|
+
import { PalettePanel } from "./ui/palette.js";
|
|
38
|
+
import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
|
|
39
|
+
import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
|
|
40
|
+
import { StatusBar, shortenCwd } from "./ui/status-bar.js";
|
|
41
|
+
import { theme } from "./ui/theme.js";
|
|
42
|
+
import { TodoPanel } from "./ui/todo-panel.js";
|
|
43
|
+
import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
|
|
19
44
|
// Single registry for the "/" autocomplete menu and the exact-command path.
|
|
20
45
|
export const SLASH_COMMANDS = [
|
|
21
46
|
{ name: "/model", description: "Open the model picker." },
|
|
47
|
+
{ name: "/models", description: "Refresh local model discovery (Ollama, LM Studio, llama.cpp)." },
|
|
22
48
|
{ name: "/provider", description: "Pick AI provider, paste API key once, chat." },
|
|
23
49
|
{
|
|
24
50
|
name: "/effort",
|
|
25
51
|
description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
|
|
26
52
|
},
|
|
27
|
-
{ name: "/tools", description: "List the
|
|
53
|
+
{ name: "/tools", description: "List the tools with one-line descriptions." },
|
|
28
54
|
{ name: "/skills", description: "List installed skills (project + global)." },
|
|
29
|
-
{ name: "/
|
|
30
|
-
{ name: "/
|
|
55
|
+
{ name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
|
|
56
|
+
{ name: "/mode", description: "Print the current permission mode (Tab cycles normal → yolo → plan)." },
|
|
31
57
|
{ 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
58
|
{ name: "/allow", description: "Pre-approve a tool pattern this session (e.g. /allow bash:npm test*)." },
|
|
34
59
|
{ name: "/deny", description: "Forbid a tool pattern this session — deny wins over trust/yolo (e.g. /deny bash:rm *)." },
|
|
35
60
|
{ name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
|
|
36
|
-
{ name: "/clear", description: "Clear the conversation history (keeps session token totals)." },
|
|
61
|
+
{ name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
|
|
37
62
|
{ name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
|
|
38
63
|
{ name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
|
|
64
|
+
{ name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
|
|
65
|
+
{ name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
|
|
66
|
+
{ name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
|
|
67
|
+
{ name: "/autoscroll", description: "Follow new output as it arrives (/autoscroll on|off; off freezes the view mid-turn)." },
|
|
68
|
+
{ name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
|
|
39
69
|
{ name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
|
|
70
|
+
{ name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
|
|
71
|
+
{ name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
|
|
40
72
|
{ name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
|
|
41
73
|
{ name: "/help", description: "List commands with one-liners." },
|
|
42
74
|
{ name: "/exit", description: "Exit Atom." },
|
|
@@ -71,8 +103,198 @@ export const SUBMIT_PIPELINE_STAGES = [
|
|
|
71
103
|
rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
|
|
72
104
|
},
|
|
73
105
|
];
|
|
106
|
+
// Shared usage strings: the exact texts the commands print, hoisted to
|
|
107
|
+
// module scope so the slash-menu argument hints reuse them (no second
|
|
108
|
+
// implementation).
|
|
109
|
+
export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (list with /skills, e.g. /skill:code-review)";
|
|
110
|
+
export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
|
|
111
|
+
export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
|
|
112
|
+
export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
|
|
113
|
+
export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll prints the current state.";
|
|
114
|
+
export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: shows or hides the committed thinking blocks; the turn, history, and telemetry are untouched).";
|
|
74
115
|
export function filterSlashCommands(prefix) {
|
|
75
|
-
|
|
116
|
+
const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
|
|
117
|
+
// Exact match wins outright: a fully-typed command collapses the menu
|
|
118
|
+
// to itself, so prefix-siblings (/skill vs /skills, /model vs /models)
|
|
119
|
+
// never read as duplicates and Enter stays deterministic. Partial
|
|
120
|
+
// input keeps the prefix-then-fuzzy tiers below untouched.
|
|
121
|
+
const full = `/${q}`;
|
|
122
|
+
const exact = SLASH_COMMANDS.find((c) => c.name === full);
|
|
123
|
+
if (exact)
|
|
124
|
+
return [exact];
|
|
125
|
+
const pre = [];
|
|
126
|
+
const fuzzy = [];
|
|
127
|
+
for (const c of SLASH_COMMANDS) {
|
|
128
|
+
const name = c.name.slice(1);
|
|
129
|
+
if (name.startsWith(q)) {
|
|
130
|
+
pre.push(c);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const s = fuzzyScore(q, name);
|
|
134
|
+
if (s !== null)
|
|
135
|
+
fuzzy.push({ c, s });
|
|
136
|
+
}
|
|
137
|
+
// Prefix tier keeps registry order (stable, muscle memory); fuzzy tier
|
|
138
|
+
// ranks by score, ties by name.
|
|
139
|
+
fuzzy.sort((a, b) => a.s - b.s || (a.c.name < b.c.name ? -1 : 1));
|
|
140
|
+
return [...pre, ...fuzzy.map((f) => f.c)];
|
|
141
|
+
}
|
|
142
|
+
// Command palette model (Ctrl+P): one searchable index over the SAME
|
|
143
|
+
// SLASH_COMMANDS registry the menu and runner use — no second command
|
|
144
|
+
// implementation. Filtering reuses fuzzyScore (prefix tier stable, fuzzy
|
|
145
|
+
// scored, description substring); display types live in ui/palette.
|
|
146
|
+
export function paletteEntries(query) {
|
|
147
|
+
const q = query.trim().toLowerCase().replace(/^\//, "");
|
|
148
|
+
const out = [];
|
|
149
|
+
SLASH_COMMANDS.forEach((c, idx) => {
|
|
150
|
+
const name = c.name.slice(1).toLowerCase();
|
|
151
|
+
if (!q) {
|
|
152
|
+
out.push({ c, tier: 0, score: 0, idx });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (name.startsWith(q)) {
|
|
156
|
+
out.push({ c, tier: 1, score: 0, idx });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const s = fuzzyScore(q, name);
|
|
160
|
+
if (s !== null) {
|
|
161
|
+
out.push({ c, tier: 2, score: s, idx });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (c.description.toLowerCase().includes(q))
|
|
165
|
+
out.push({ c, tier: 3, score: 0, idx });
|
|
166
|
+
});
|
|
167
|
+
const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
|
|
168
|
+
out.sort((a, b) => a.tier - b.tier ||
|
|
169
|
+
(a.tier === 0
|
|
170
|
+
? catOrder(a.c.name) - catOrder(b.c.name) || a.idx - b.idx
|
|
171
|
+
: a.score - b.score || a.idx - b.idx));
|
|
172
|
+
return out.map(({ c }) => ({
|
|
173
|
+
name: c.name,
|
|
174
|
+
description: c.description,
|
|
175
|
+
category: paletteCategory(c.name),
|
|
176
|
+
hint: PALETTE_HINTS[c.name] ?? null,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
// Busy-gate shared by the slash menu and the palette: /compact sets the
|
|
180
|
+
// pending flag for turn-end drain; /queue + /steer manage the running turn;
|
|
181
|
+
// /autoscroll and /thinking only flip view flags (never touch the turn).
|
|
182
|
+
// Every other command waits idle.
|
|
183
|
+
export function slashRunsWhileBusy(name) {
|
|
184
|
+
return (name === "/compact" ||
|
|
185
|
+
name === "/queue" ||
|
|
186
|
+
name === "/steer" ||
|
|
187
|
+
name === "/autoscroll" ||
|
|
188
|
+
name === "/thinking");
|
|
189
|
+
}
|
|
190
|
+
// Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
|
|
191
|
+
// better; null = no match). Pure — shared by the command filter, the skill
|
|
192
|
+
// tier, and the palette, so there is exactly one matcher.
|
|
193
|
+
export function fuzzyScore(query, target) {
|
|
194
|
+
const q = query.toLowerCase();
|
|
195
|
+
const t = target.toLowerCase();
|
|
196
|
+
if (!q)
|
|
197
|
+
return 0;
|
|
198
|
+
let ti = 0;
|
|
199
|
+
let score = 0;
|
|
200
|
+
let last = -1;
|
|
201
|
+
for (let qi = 0; qi < q.length; qi++) {
|
|
202
|
+
const found = t.indexOf(q[qi], ti);
|
|
203
|
+
if (found === -1)
|
|
204
|
+
return null;
|
|
205
|
+
score += last === -1 ? found : found - last - 1;
|
|
206
|
+
if (found === 0 || /[-_/:]/.test(t[found - 1]))
|
|
207
|
+
score -= 2;
|
|
208
|
+
if (found === last + 1)
|
|
209
|
+
score -= 1;
|
|
210
|
+
last = found;
|
|
211
|
+
ti = found + 1;
|
|
212
|
+
}
|
|
213
|
+
return score;
|
|
214
|
+
}
|
|
215
|
+
// Max skill rows in the menu: the command list always renders whole, skills
|
|
216
|
+
// narrow as you type — the menu can never take over the screen.
|
|
217
|
+
export const SLASH_MENU_SKILL_CAP = 8;
|
|
218
|
+
// Pure filter for the /skills picker (unit-tested): case-insensitive
|
|
219
|
+
// substring over the skill name (a search popup narrows harder than the
|
|
220
|
+
// prefix-only slash menu). Empty query returns everything as-is.
|
|
221
|
+
export function filterSkillPicker(entries, query) {
|
|
222
|
+
const q = query.trim().toLowerCase();
|
|
223
|
+
if (!q)
|
|
224
|
+
return entries;
|
|
225
|
+
return entries.filter((e) => e.name.toLowerCase().includes(q));
|
|
226
|
+
}
|
|
227
|
+
// Pure menu builder (unit-tested): matching commands first (prefix tier in
|
|
228
|
+
// stable order, then fuzzy by score), then matching skills as `/skill:name`
|
|
229
|
+
// entries (prefix tier stable, then fuzzy). Skills join only once the query
|
|
230
|
+
// is non-trivial (input length ≥ 2 — a bare `/` lists commands only), and
|
|
231
|
+
// match by skill-name prefix, full `/skill:name` prefix, or fuzzy on the
|
|
232
|
+
// name. Skill rows carry a truncated description for discovery. Pure — the
|
|
233
|
+
// App feeds it the cached registry snapshot.
|
|
234
|
+
export const SKILL_MENU_DESC_CHARS = 60;
|
|
235
|
+
export function buildSlashMenu(input, skills) {
|
|
236
|
+
const items = filterSlashCommands(input).map((c) => ({
|
|
237
|
+
name: c.name,
|
|
238
|
+
description: c.description,
|
|
239
|
+
}));
|
|
240
|
+
if (input.length < 2)
|
|
241
|
+
return { items, moreSkills: 0 };
|
|
242
|
+
const q = input.slice(1);
|
|
243
|
+
const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
|
|
244
|
+
const pushSkill = (s, shown, more) => {
|
|
245
|
+
const entry = `/skill:${s.name}`;
|
|
246
|
+
const desc = s.description.length > SKILL_MENU_DESC_CHARS
|
|
247
|
+
? `${s.description.slice(0, SKILL_MENU_DESC_CHARS)}…`
|
|
248
|
+
: s.description;
|
|
249
|
+
if (shown.n < SLASH_MENU_SKILL_CAP) {
|
|
250
|
+
items.push({ name: entry, description: desc, skill: s.name });
|
|
251
|
+
shown.n += 1;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
more.n += 1;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const shown = { n: 0 };
|
|
258
|
+
const more = { n: 0 };
|
|
259
|
+
const fuzzy = [];
|
|
260
|
+
for (const s of skills) {
|
|
261
|
+
const entry = `/skill:${s.name}`;
|
|
262
|
+
if (s.name.startsWith(skillQ) || entry.startsWith(input))
|
|
263
|
+
continue;
|
|
264
|
+
const score = fuzzyScore(skillQ, s.name);
|
|
265
|
+
if (score !== null)
|
|
266
|
+
fuzzy.push({ s, score });
|
|
267
|
+
}
|
|
268
|
+
for (const s of skills) {
|
|
269
|
+
const entry = `/skill:${s.name}`;
|
|
270
|
+
if (!s.name.startsWith(skillQ) && !entry.startsWith(input))
|
|
271
|
+
continue;
|
|
272
|
+
pushSkill(s, shown, more);
|
|
273
|
+
}
|
|
274
|
+
fuzzy.sort((a, b) => a.score - b.score || (a.s.name < b.s.name ? -1 : 1));
|
|
275
|
+
for (const f of fuzzy)
|
|
276
|
+
pushSkill(f.s, shown, more);
|
|
277
|
+
return { items, moreSkills: more.n };
|
|
278
|
+
}
|
|
279
|
+
// Argument hints for commands that take them, reusing the exact usage
|
|
280
|
+
// strings the commands themselves print (no second implementation).
|
|
281
|
+
export function commandUsage(name) {
|
|
282
|
+
switch (name) {
|
|
283
|
+
case "/allow":
|
|
284
|
+
case "/deny":
|
|
285
|
+
case "/rules":
|
|
286
|
+
return RULE_USAGE;
|
|
287
|
+
case "/queue":
|
|
288
|
+
return QUEUE_USAGE;
|
|
289
|
+
case "/steer":
|
|
290
|
+
return STEER_USAGE;
|
|
291
|
+
case "/skill":
|
|
292
|
+
return SKILL_USAGE;
|
|
293
|
+
case "/compact":
|
|
294
|
+
return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
|
|
295
|
+
default:
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
76
298
|
}
|
|
77
299
|
// Phase 5 observability + latency polish (surgical, three items only):
|
|
78
300
|
// - TURN_TICK_MS: elapsed-time resolution while busy (1s).
|
|
@@ -81,10 +303,12 @@ export function filterSlashCommands(prefix) {
|
|
|
81
303
|
export const TURN_TICK_MS = 1000;
|
|
82
304
|
export const TURN_STALL_AFTER_MS = 3000;
|
|
83
305
|
// Phase 5 models-list session cache key: provider id (+baseURL for
|
|
84
|
-
// openai-compatible, whose list depends on the custom endpoint
|
|
306
|
+
// openai-compatible, whose list depends on the custom endpoint, and for
|
|
307
|
+
// local runtimes, whose list depends on the loopback server probed).
|
|
85
308
|
export function modelsCacheKey(providerId, baseURL) {
|
|
86
|
-
if (providerId === "openai-compatible")
|
|
309
|
+
if (providerId === "openai-compatible" || isLocalProviderId(providerId)) {
|
|
87
310
|
return `${providerId}|${baseURL ?? ""}`;
|
|
311
|
+
}
|
|
88
312
|
return providerId;
|
|
89
313
|
}
|
|
90
314
|
// Pure helpers for the elapsed/stall indicator (injectable now for tests).
|
|
@@ -94,6 +318,56 @@ export function elapsedSecsSince(startMs, nowMs) {
|
|
|
94
318
|
export function isStalledSince(lastActivityMs, nowMs) {
|
|
95
319
|
return nowMs - lastActivityMs > TURN_STALL_AFTER_MS;
|
|
96
320
|
}
|
|
321
|
+
export function modelPickerEntries(opts) {
|
|
322
|
+
const out = [];
|
|
323
|
+
const activeLocal = isLocalProviderId(opts.activeProvider);
|
|
324
|
+
for (const m of opts.activeModels) {
|
|
325
|
+
out.push({
|
|
326
|
+
providerId: opts.activeProvider,
|
|
327
|
+
model: m,
|
|
328
|
+
...(activeLocal ? { local: true } : {}),
|
|
329
|
+
...(opts.activeProvider === "kilo" && isFreeKiloModel(m) ? { free: true } : {}),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
for (const p of PROVIDERS) {
|
|
333
|
+
if (p.id === opts.activeProvider)
|
|
334
|
+
continue;
|
|
335
|
+
if (!opts.keyFor(p.id) && providerNeedsKey(p.id))
|
|
336
|
+
continue;
|
|
337
|
+
if (p.id === "openai-compatible" && !opts.baseURLFor(p.id))
|
|
338
|
+
continue;
|
|
339
|
+
const list = opts.cached(p.id, opts.baseURLFor(p.id)) ?? p.fallbackModels;
|
|
340
|
+
for (const m of list) {
|
|
341
|
+
out.push({
|
|
342
|
+
providerId: p.id,
|
|
343
|
+
model: m,
|
|
344
|
+
...(isLocalProviderId(p.id) ? { local: true } : {}),
|
|
345
|
+
...(p.id === "kilo" && isFreeKiloModel(m) ? { free: true } : {}),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
// Case-insensitive substring filter over the model id (the provider id is
|
|
352
|
+
// included so "openai" narrows to that section; "free" matches free Kilo
|
|
353
|
+
// models). Empty query returns the list as-is.
|
|
354
|
+
export function filterModelEntries(entries, query) {
|
|
355
|
+
const q = query.trim().toLowerCase();
|
|
356
|
+
if (!q)
|
|
357
|
+
return entries;
|
|
358
|
+
return entries.filter((e) => e.model.toLowerCase().includes(q) ||
|
|
359
|
+
e.providerId.toLowerCase().includes(q) ||
|
|
360
|
+
(e.free === true && "free".includes(q)));
|
|
361
|
+
}
|
|
362
|
+
// Visible window for the picker: at most MODEL_PICKER_VISIBLE rows, scrolled
|
|
363
|
+
// so the highlight stays visible (centered while scrolling, pinned at both
|
|
364
|
+
// ends). Single implementation in ui/pickers (shared by every windowed
|
|
365
|
+
// popup); re-exported here so existing import sites keep working.
|
|
366
|
+
export { MODEL_PICKER_VISIBLE, pickerWindow } from "./ui/pickers.js";
|
|
367
|
+
// Follow-up queue cap: Enter while busy queues instead of submitting, and
|
|
368
|
+
// the turn-end drain auto-sends while non-empty. Bounded so a held-down key
|
|
369
|
+
// can never flood the session; /queue manages, /queue clear wipes.
|
|
370
|
+
export const QUEUE_CAP = 10;
|
|
97
371
|
// Task B smoothness (a): streaming-draft throttle. Token bursts (many
|
|
98
372
|
// onToken calls per frame) would otherwise re-render the whole tree per
|
|
99
373
|
// token; paints coalesce to at most one per trailing window, with
|
|
@@ -181,122 +455,112 @@ export function createDraftThrottler(opts) {
|
|
|
181
455
|
},
|
|
182
456
|
};
|
|
183
457
|
}
|
|
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
458
|
function toolsListText() {
|
|
209
459
|
const lines = Object.entries(TOOL_ONE_LINERS).map(([n, d]) => `${n} — ${d}`);
|
|
210
460
|
return `Tools (${lines.length}):\n${lines.join("\n")}`;
|
|
211
461
|
}
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// anywhere else — thinking stays
|
|
215
|
-
|
|
216
|
-
//
|
|
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
|
-
}
|
|
462
|
+
// Live thinking block: reasoning streams in full, exactly as it arrives —
|
|
463
|
+
// the output stays as-is no matter how long it runs (no tail window, no
|
|
464
|
+
// truncation). The full text is never stored anywhere else — thinking stays
|
|
465
|
+
// transient like the answer draft (cleared on every turn boundary, never
|
|
466
|
+
// committed to the transcript or model history).
|
|
231
467
|
export function helpListText() {
|
|
232
468
|
const lines = SLASH_COMMANDS.map((c) => `${c.name} — ${c.description}`);
|
|
233
469
|
return (`Commands:\n${lines.join("\n")}` +
|
|
234
|
-
`\nTab
|
|
235
|
-
`\
|
|
470
|
+
`\nTab is the only mode switcher: normal → yolo → plan → normal (in the / command menu, Tab runs the highlighted command instead). Yolo runs tools without asking; plan is read-only.` +
|
|
471
|
+
`\nPlan mode is the read-only 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 (/trust while in plan stays read-only with a notice — Tab out first). Exiting plan is the human approval: Tab from plan mode returns to normal (never yolo) and the todowrite checklist recorded while planning carries into implementation.` +
|
|
236
472
|
`\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
473
|
`\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
474
|
`\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
475
|
`\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
476
|
`\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
477
|
`\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.` +
|
|
478
|
+
`\n/provider: pick kilo|opencode-zen|openai|anthropic|deepseek|mistral|google-gemini|openai-compatible, paste a key once (stored in ~/.atom/auth.json, env wins). Kilo is the default: its free :free models (e.g. kilo-auto/free) work with no key; a Kilo key unlocks the full catalog. Switching provider keeps session history text; system prompt stays.` +
|
|
243
479
|
`\n/effort options: Default/Low/Medium/High/Max (wire: default/low/medium/high/max; Default omits reasoning_effort).` +
|
|
244
480
|
`\nNote: xhigh was requested but only Max is verified, so the top setting is Max, sent as max.` +
|
|
245
481
|
`\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).
|
|
482
|
+
`\n/resume: restores the last saved session (turns, history, provider/model/effort/mode, usage totals). The conversation never auto-restores — sending a message without /resume starts fresh, and the next completed turn overwrites the save. Your provider/model/effort picks DO persist across restarts automatically (saved on every completed turn and on clean exit; explicit OPENCODE_ZEN_MODEL wins over the saved model). /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
483
|
`\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
484
|
`\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
485
|
`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.`
|
|
486
|
+
`\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.` +
|
|
487
|
+
`\nQueue + steer (follow-ups without losing flow): Enter while busy queues the message (visible Queued line, auto-sent when the turn ends cleanly — never after a cancel); /queue lists, /queue clear wipes (cap ${QUEUE_CAP}, in-memory only). /steer <text> injects into the RUNNING turn at the next step boundary (the current action finishes first — nothing is aborted); when idle it just sends. A steer stranded by a failed/cancelled turn rejoins the queue front instead of vanishing.` +
|
|
488
|
+
`\nInput: Ctrl+J inserts a newline (Enter always sends, even multiline); ↑/↓ recalls past prompts across lines (in-memory only, never saved); paste lands verbatim via bracketed paste and never submits; Ctrl+A/E line ends, Ctrl+K/U/W kills; Ctrl+P opens the searchable command palette.` +
|
|
489
|
+
`\nCtrl+O opens the tool-output inspector (browse past tool calls with full output, durations, and error detail: ↑/↓ selects, Enter expands/collapses, PgUp/PgDn scrolls, Esc closes). Read-only — safe in plan mode.` +
|
|
490
|
+
`\nObservability (local-only, on by default): every turn is traced — iterations, model calls (API-reported tokens only), tool calls (measured durations, ok/fail per tool), retries, and outcomes — into ~/.atom/telemetry/sessions/ (one small JSON file per session, 0600 POSIX, truncated + secret-scrubbed previews, never full prompts/results). /telemetry prints the summary; /dashboard writes the drill-down page (session → turn → iteration → model/tool call → result, with aggregates, charts, filters, timelines) to ~/.atom/telemetry/dashboard.html — open it in a browser, nothing is uploaded. Off via ATOM_TELEMETRY=0 or "telemetry": {"enabled": false} in atom.json. n/a means not reported (never estimated); cost is always n/a until a provider reports it; tool durations span dispatch→result (including any approval-prompt wait in normal mode).`);
|
|
251
491
|
}
|
|
252
492
|
// Session token totals, accumulated ONLY from usage payloads the API
|
|
253
493
|
// actually reported. Null until the first usage payload arrives (rendered
|
|
254
494
|
// as `token: n/a` — never 0, which would imply measurement). The segment
|
|
255
495
|
// itself lives in ./context-windows.js (single source for the exact
|
|
256
|
-
// `token: (P%) NK` format);
|
|
257
|
-
// P% tracks CURRENT context load (last prompt_tokens, else
|
|
258
|
-
// estimate); NK tracks cumulative session spend.
|
|
259
|
-
function
|
|
260
|
-
return
|
|
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))) }));
|
|
496
|
+
// `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
|
|
497
|
+
// surface. P% tracks CURRENT context load (last prompt_tokens, else
|
|
498
|
+
// 4ch/token estimate); NK tracks cumulative session spend.
|
|
499
|
+
function formatKEst(chars) {
|
|
500
|
+
return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
|
|
278
501
|
}
|
|
279
502
|
// Error screen for a missing key (never print the key itself).
|
|
280
503
|
export function MissingKey() {
|
|
281
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color:
|
|
504
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: theme.color.error, 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
505
|
}
|
|
283
|
-
|
|
506
|
+
// Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
|
|
507
|
+
// size is a constant (avoids re-serializing ~15KB on every manager build).
|
|
508
|
+
const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
|
|
509
|
+
export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
|
|
284
510
|
const { exit } = useApp();
|
|
285
|
-
|
|
286
|
-
|
|
511
|
+
// Measured terminal width for the status bar's fit-or-drop branch logic.
|
|
512
|
+
// Unknown (piped output) falls back to the bar's own default.
|
|
513
|
+
let termColumns;
|
|
514
|
+
try {
|
|
515
|
+
termColumns = useStdout()?.stdout?.columns;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
termColumns = undefined;
|
|
519
|
+
}
|
|
520
|
+
// Saved preferences (provider/model/effort + resolved key/endpoint), loaded
|
|
521
|
+
// once when restorePrefs is on (prod). Explicit props always win; without
|
|
522
|
+
// prefs the CLI defaults apply. Null in tests (flag off) and on any
|
|
523
|
+
// missing/corrupt/unusable save — startup then behaves exactly as before.
|
|
524
|
+
const [prefs] = useState(() => (restorePrefs ? loadPrefs(authHome, endpoint) : null));
|
|
525
|
+
// atom.json (project + global, per-key merge): first-run defaults sitting
|
|
526
|
+
// between saved prefs and compiled defaults —
|
|
527
|
+
// env/props > save > project > global > default.
|
|
528
|
+
const [atomConfigLoad] = useState(() => loadAtomConfig(configDirs?.projectDir, configDirs?.homeDir ?? authHome));
|
|
529
|
+
const atomConfig = atomConfigLoad.config;
|
|
530
|
+
const resolvedInitialProvider = initialProvider && isProviderId(initialProvider)
|
|
531
|
+
? initialProvider
|
|
532
|
+
: (prefs?.provider ?? atomConfig.provider ?? DEFAULT_PROVIDER);
|
|
533
|
+
// Fresh-install bootstrap: Kilo (the default) starts on its free routing
|
|
534
|
+
// model until discovery + the user's pick say otherwise — no paid
|
|
535
|
+
// credentials required. Other providers keep the compiled default.
|
|
536
|
+
const resolvedInitialModel = initialModel ??
|
|
537
|
+
prefs?.model ??
|
|
538
|
+
atomConfig.model ??
|
|
539
|
+
(resolvedInitialProvider === "kilo"
|
|
540
|
+
? (getProvider("kilo")?.defaultModel ?? DEFAULT_MODEL)
|
|
541
|
+
: DEFAULT_MODEL);
|
|
542
|
+
const [model, setModel] = useState(resolvedInitialModel);
|
|
543
|
+
const modelRef = useRef(resolvedInitialModel);
|
|
287
544
|
const [models, setModels] = useState(initialModels ?? [...FALLBACK_MODELS]);
|
|
288
|
-
// Active provider (
|
|
289
|
-
const [provider, setProvider] = useState(
|
|
290
|
-
const providerRef = useRef(
|
|
545
|
+
// Active provider (explicit prop wins, then saved prefs, then zen default).
|
|
546
|
+
const [provider, setProvider] = useState(resolvedInitialProvider);
|
|
547
|
+
const providerRef = useRef(resolvedInitialProvider);
|
|
291
548
|
// Auth store (env wins at resolve time; file holds pasted keys).
|
|
292
549
|
const [auth, setAuth] = useState(() => loadAuth(authHome));
|
|
293
550
|
const authRef = useRef(auth);
|
|
294
551
|
// Resolved keys/endpoints per active provider. apiKey/endpoint props seed
|
|
295
|
-
// the zen defaults (tests pass test-key; prod passes env-resolved values)
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
//
|
|
552
|
+
// the zen defaults (tests pass test-key; prod passes env-resolved values);
|
|
553
|
+
// with restorePrefs the saved key/endpoint seed a restored provider instead.
|
|
554
|
+
const [activeApiKey, setActiveApiKey] = useState(prefs?.apiKey ?? apiKey);
|
|
555
|
+
const activeApiKeyRef = useRef(prefs?.apiKey ?? apiKey);
|
|
556
|
+
// Owner of the seeded/active key: the apiKey/endpoint props seed the zen
|
|
557
|
+
// defaults (tests pass test-key; prod passes env-resolved values), while
|
|
558
|
+
// restorePrefs seeds the saved provider's key instead. The fallback below
|
|
559
|
+
// must never hand one provider's seed to another (e.g. the zen seed to an
|
|
560
|
+
// anonymous Kilo session) — it applies only to its owner.
|
|
561
|
+
const activeKeyProviderRef = useRef(prefs?.provider ?? "opencode-zen");
|
|
562
|
+
const [activeEndpoint, setActiveEndpoint] = useState(prefs?.endpoint ?? endpoint);
|
|
563
|
+
// Permission mode (normal default, Tab cycles normal → yolo → plan). The footer
|
|
300
564
|
// status line always shows it (plus +trust when the session trust tier is
|
|
301
565
|
// on); modeRef mirrors it for async loop callbacks.
|
|
302
566
|
const [mode, setMode] = useState("normal");
|
|
@@ -344,17 +608,95 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
344
608
|
// synchronous ref mirror (cursor edits must compose within one tick).
|
|
345
609
|
const [cursor, setCursor] = useState(0);
|
|
346
610
|
const cursorRef = useRef(0);
|
|
611
|
+
// Submitted-prompt history (↑/↓ recall): in-memory only, never persisted
|
|
612
|
+
// (prompts may carry pasted secrets). histIndex null = editing fresh;
|
|
613
|
+
// otherwise an index into inputHist. histStash preserves the unsent draft
|
|
614
|
+
// while browsing (Down past newest restores it).
|
|
615
|
+
const inputHistRef = useRef([]);
|
|
616
|
+
const histIndexRef = useRef(null);
|
|
617
|
+
const histStashRef = useRef("");
|
|
618
|
+
function pushInputHistory(text) {
|
|
619
|
+
inputHistRef.current = pushInputHistoryList(inputHistRef.current, text);
|
|
620
|
+
histIndexRef.current = null;
|
|
621
|
+
histStashRef.current = "";
|
|
622
|
+
}
|
|
623
|
+
function browseHistoryInput(dir) {
|
|
624
|
+
const hist = inputHistRef.current;
|
|
625
|
+
if (hist.length === 0)
|
|
626
|
+
return;
|
|
627
|
+
const cur = histIndexRef.current;
|
|
628
|
+
if (cur === null) {
|
|
629
|
+
if (dir === 1)
|
|
630
|
+
return; // already at the newest (fresh draft)
|
|
631
|
+
histStashRef.current = inputRef.current;
|
|
632
|
+
const idx = historyOlderIndex(hist, null) ?? hist.length - 1;
|
|
633
|
+
histIndexRef.current = idx;
|
|
634
|
+
setInputBoth(hist[idx]);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const next = dir === -1 ? historyOlderIndex(hist, cur) : historyNewerIndex(hist, cur);
|
|
638
|
+
if (next === null) {
|
|
639
|
+
histIndexRef.current = null;
|
|
640
|
+
const stash = histStashRef.current;
|
|
641
|
+
histStashRef.current = "";
|
|
642
|
+
setInputBoth(stash);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
histIndexRef.current = next;
|
|
646
|
+
setInputBoth(hist[next]);
|
|
647
|
+
}
|
|
648
|
+
// ↑/↓ in plain input: move between lines when multiline, else browse
|
|
649
|
+
// submitted-prompt history (recall at the first/last line edge).
|
|
650
|
+
function moveOrRecall(dir) {
|
|
651
|
+
const t = inputRef.current;
|
|
652
|
+
if (t.includes("\n")) {
|
|
653
|
+
const r = moveVertically(t, cursorRef.current, dir);
|
|
654
|
+
if (!r.edge) {
|
|
655
|
+
setCursorBoth(r.offset);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
browseHistoryInput(dir);
|
|
660
|
+
}
|
|
347
661
|
const [selecting, setSelecting] = useState(false);
|
|
348
662
|
const [selIndex, setSelIndex] = useState(0);
|
|
349
663
|
// Same synchronous mirror for the dropdown highlight.
|
|
350
664
|
const selIndexRef = useRef(0);
|
|
665
|
+
// /model picker type-to-filter (unified cross-provider list): the query
|
|
666
|
+
// narrows entries live; highlight resets to the top on every keystroke.
|
|
667
|
+
// Cleared on open and on close (Esc/Enter) so every open starts unfiltered.
|
|
668
|
+
const [modelFilter, setModelFilter] = useState("");
|
|
669
|
+
const modelFilterRef = useRef("");
|
|
670
|
+
function setModelFilterBoth(next) {
|
|
671
|
+
modelFilterRef.current = next;
|
|
672
|
+
setModelFilter(next);
|
|
673
|
+
}
|
|
674
|
+
// /skills picker (opencode-style searchable popup): type-to-filter over the
|
|
675
|
+
// resolved registry, ↑/↓ + Enter to load, Esc cancels, windowed like the
|
|
676
|
+
// model picker so any library size stays navigable. Snapshot state (the
|
|
677
|
+
// registry always renders — names only, never descriptions).
|
|
678
|
+
const [selectingSkills, setSelectingSkills] = useState(false);
|
|
679
|
+
const [skillPickerItems, setSkillPickerItems] = useState([]);
|
|
680
|
+
const [skillIndex, setSkillIndex] = useState(0);
|
|
681
|
+
const skillIndexRef = useRef(0);
|
|
682
|
+
const [skillFilter, setSkillFilter] = useState("");
|
|
683
|
+
const skillFilterRef = useRef("");
|
|
684
|
+
function setSkillIndexBoth(next) {
|
|
685
|
+
skillIndexRef.current = next;
|
|
686
|
+
setSkillIndex(next);
|
|
687
|
+
}
|
|
688
|
+
function setSkillFilterBoth(next) {
|
|
689
|
+
skillFilterRef.current = next;
|
|
690
|
+
setSkillFilter(next);
|
|
691
|
+
}
|
|
351
692
|
// Reasoning-effort picker (/effort): same pattern as the /model picker
|
|
352
|
-
// (↑/↓ + Enter, Esc cancels).
|
|
693
|
+
// (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
|
|
694
|
+
// else the atom.json default, else Default.
|
|
353
695
|
const [selectingEffort, setSelectingEffort] = useState(false);
|
|
354
696
|
const [effortIndex, setEffortIndex] = useState(0);
|
|
355
697
|
const effortIndexRef = useRef(0);
|
|
356
|
-
const [effort, setEffort] = useState("default");
|
|
357
|
-
const effortRef = useRef("default");
|
|
698
|
+
const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
|
|
699
|
+
const effortRef = useRef(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
|
|
358
700
|
// /provider picker + key/baseURL prompts (same keyboard pattern).
|
|
359
701
|
const [selectingProvider, setSelectingProvider] = useState(false);
|
|
360
702
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
@@ -383,11 +725,201 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
383
725
|
const slashIndexRef = useRef(0);
|
|
384
726
|
const [slashDismissed, setSlashDismissed] = useState(false);
|
|
385
727
|
const slashDismissedRef = useRef(false);
|
|
728
|
+
// Follow-up queue (Claude-Code-style): Enter while busy appends instead of
|
|
729
|
+
// submitting; the turn-end drain auto-sends while non-empty and the turn
|
|
730
|
+
// wasn't cancelled. In-memory only, never persisted (like rules). Rendered
|
|
731
|
+
// as one dim line above the input so a queued thought is never lost.
|
|
732
|
+
const [queue, setQueue] = useState([]);
|
|
733
|
+
const queueRef = useRef([]);
|
|
734
|
+
function setQueueBoth(next) {
|
|
735
|
+
queueRef.current = next;
|
|
736
|
+
setQueue(next);
|
|
737
|
+
}
|
|
738
|
+
// Steer (inject into the ACTIVE turn, Claude-Code-style): /steer text while
|
|
739
|
+
// busy sits here until the loop's next step boundary drains it into history
|
|
740
|
+
// + transcript (see drainSteer below). Null when idle or nothing pending;
|
|
741
|
+
// rendered as one dim line while set.
|
|
742
|
+
const [steerPending, setSteerPending] = useState(null);
|
|
743
|
+
const steerRef = useRef(null);
|
|
744
|
+
function setSteerPendingBoth(next) {
|
|
745
|
+
steerRef.current = next;
|
|
746
|
+
setSteerPending(next);
|
|
747
|
+
}
|
|
748
|
+
// Cancellation latch for the queue drain: a cancelled turn keeps its queue
|
|
749
|
+
// visible but never auto-sends (the user decides what runs next). Reset at
|
|
750
|
+
// every submit, set in the cancel path.
|
|
751
|
+
const turnCancelledRef = useRef(false);
|
|
752
|
+
// Tool-output inspector (display-only): retained results for the browse +
|
|
753
|
+
// expand panel (Ctrl+O). Records are appended in onToolActivity from the
|
|
754
|
+
// existing payloads — execution, ordering, and turn content are untouched.
|
|
755
|
+
const toolLogRef = useRef([]);
|
|
756
|
+
const toolSeqRef = useRef(0);
|
|
757
|
+
const [inspecting, setInspecting] = useState(false);
|
|
758
|
+
const inspectingRef = useRef(false);
|
|
759
|
+
function setInspectingBoth(next) {
|
|
760
|
+
inspectingRef.current = next;
|
|
761
|
+
setInspecting(next);
|
|
762
|
+
}
|
|
763
|
+
const [inspectIndex, setInspectIndex] = useState(0);
|
|
764
|
+
const inspectIndexRef = useRef(0);
|
|
765
|
+
function setInspectIndexBoth(next) {
|
|
766
|
+
inspectIndexRef.current = next;
|
|
767
|
+
setInspectIndex(next);
|
|
768
|
+
}
|
|
769
|
+
const [inspectExpanded, setInspectExpanded] = useState(false);
|
|
770
|
+
const inspectExpandedRef = useRef(false);
|
|
771
|
+
function setInspectExpandedBoth(next) {
|
|
772
|
+
inspectExpandedRef.current = next;
|
|
773
|
+
setInspectExpanded(next);
|
|
774
|
+
}
|
|
775
|
+
const [inspectScroll, setInspectScroll] = useState(0);
|
|
776
|
+
const inspectScrollRef = useRef(0);
|
|
777
|
+
function setInspectScrollBoth(next) {
|
|
778
|
+
inspectScrollRef.current = next;
|
|
779
|
+
setInspectScroll(next);
|
|
780
|
+
}
|
|
781
|
+
function openInspector() {
|
|
782
|
+
if (toolLogRef.current.length === 0) {
|
|
783
|
+
pushInfo("(no tool calls yet — run something first, then Ctrl+O to inspect)");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
setInspectIndexBoth(0);
|
|
787
|
+
setInspectExpandedBoth(false);
|
|
788
|
+
setInspectScrollBoth(0);
|
|
789
|
+
setInspectingBoth(true);
|
|
790
|
+
}
|
|
791
|
+
function closeInspector() {
|
|
792
|
+
setInspectingBoth(false);
|
|
793
|
+
}
|
|
794
|
+
// Command palette (Ctrl+P): unified searchable commands. Own filter +
|
|
795
|
+
// highlight (the main input stays untouched behind it); Enter runs
|
|
796
|
+
// through runSlashCommand with the shared busy-gate.
|
|
797
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
798
|
+
const paletteOpenRef = useRef(false);
|
|
799
|
+
function setPaletteOpenBoth(next) {
|
|
800
|
+
paletteOpenRef.current = next;
|
|
801
|
+
setPaletteOpen(next);
|
|
802
|
+
}
|
|
803
|
+
const [paletteFilter, setPaletteFilter] = useState("");
|
|
804
|
+
const paletteFilterRef = useRef("");
|
|
805
|
+
function setPaletteFilterBoth(next) {
|
|
806
|
+
paletteFilterRef.current = next;
|
|
807
|
+
setPaletteFilter(next);
|
|
808
|
+
}
|
|
809
|
+
const [paletteIndex, setPaletteIndex] = useState(0);
|
|
810
|
+
const paletteIndexRef = useRef(0);
|
|
811
|
+
function setPaletteIndexBoth(next) {
|
|
812
|
+
paletteIndexRef.current = next;
|
|
813
|
+
setPaletteIndex(next);
|
|
814
|
+
}
|
|
815
|
+
function openPalette() {
|
|
816
|
+
setPaletteFilterBoth("");
|
|
817
|
+
setPaletteIndexBoth(0);
|
|
818
|
+
setPaletteOpenBoth(true);
|
|
819
|
+
}
|
|
820
|
+
function closePalette() {
|
|
821
|
+
setPaletteOpenBoth(false);
|
|
822
|
+
}
|
|
823
|
+
// Transcript scrollback viewport (null = follow the bottom; a number =
|
|
824
|
+
// viewed end index = held/manual mode). New turns extend a following view
|
|
825
|
+
// automatically and accumulate below a held one (the `↓ N new` indicator
|
|
826
|
+
// offers the jump back). Held also freezes the live tail's growing
|
|
827
|
+
// draft/thinking blocks to one static line (see LiveTail `held`), so a
|
|
828
|
+
// streaming turn stops yanking the terminal while the user reads back.
|
|
829
|
+
// List replacement (/clear, /resume, /new) and rewind-truncate re-follow
|
|
830
|
+
// explicitly. The inspector never touches this (position preserved while
|
|
831
|
+
// inspecting).
|
|
832
|
+
const [scrollEnd, setScrollEnd] = useState(null);
|
|
833
|
+
const scrollEndRef = useRef(null);
|
|
834
|
+
function setScrollEndBoth(next) {
|
|
835
|
+
scrollEndRef.current = next;
|
|
836
|
+
setScrollEnd(next);
|
|
837
|
+
}
|
|
838
|
+
// Thinking visibility (the /thinking toggle, rendering-only, default
|
|
839
|
+
// hidden): committed thinking turns + the live thinking block show only
|
|
840
|
+
// while on. Never touches the turn, history, or telemetry — purely paint.
|
|
841
|
+
const [showThinking, setShowThinking] = useState(false);
|
|
842
|
+
const showThinkingRef = useRef(false);
|
|
843
|
+
function setShowThinkingBoth(next) {
|
|
844
|
+
showThinkingRef.current = next;
|
|
845
|
+
setShowThinking(next);
|
|
846
|
+
}
|
|
847
|
+
// /autoscroll (session-only, default on). On = today's behavior: a
|
|
848
|
+
// following view extends with every appended turn. Off = appends during a
|
|
849
|
+
// busy turn freeze a following view at its current end instead of yanking
|
|
850
|
+
// it (the `↓ N new` indicator offers the jump back; End resumes). Idle
|
|
851
|
+
// appends always follow — freezing only matters while output streams.
|
|
852
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
853
|
+
const autoScrollRef = useRef(true);
|
|
854
|
+
function setAutoScrollBoth(next) {
|
|
855
|
+
autoScrollRef.current = next;
|
|
856
|
+
setAutoScroll(next);
|
|
857
|
+
}
|
|
858
|
+
// Skill registry (cached metadata): one instance per App, scoped to the
|
|
859
|
+
// same dirs the suite injects via skillDirs. Every discovery path below
|
|
860
|
+
// reads through it — refresh() revalidates by stat (mtime+size) and only
|
|
861
|
+
// re-reads added/modified entries, so per-message cost drops from ~1MB of
|
|
862
|
+
// SKILL.md reads to a directory listing plus stats. Bodies stay lazy
|
|
863
|
+
// (activateSkill → loadSkillBody, on demand only).
|
|
864
|
+
const [skillRegistry] = useState(() => createSkillRegistry({ projectDir: skillDirs?.projectDir, homeDir: skillDirs?.homeDir }));
|
|
865
|
+
// Skill entries for the slash menu (namespaced `/skill:name` commands):
|
|
866
|
+
// a snapshot of user-invocable skills (name + description), refreshed on
|
|
867
|
+
// mount, /skills, /clear, and /new — never per keystroke (disk I/O stays
|
|
868
|
+
// out of the typing path). Empty until the first refresh lands.
|
|
869
|
+
const [skillMenu, setSkillMenu] = useState([]);
|
|
870
|
+
async function refreshSkillMenu() {
|
|
871
|
+
try {
|
|
872
|
+
const found = await skillRegistry.refresh();
|
|
873
|
+
const { skills } = resolveSkills(found.skills);
|
|
874
|
+
setSkillMenu(skills
|
|
875
|
+
.filter((s) => s.userInvocable)
|
|
876
|
+
.map((s) => ({ name: s.name, description: s.description })));
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// menu keeps its previous snapshot (a hiccup must never break input)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Pending transcript diff (display-only): the approve-time write/edit
|
|
883
|
+
// preview plus full-file BEFORE/AFTER capture, held for the matching
|
|
884
|
+
// onToolActivity commit. Single slot is exact — the scheduler never
|
|
885
|
+
// parallel-batches writes (writes conflict globally; parallel members
|
|
886
|
+
// never prompt), and every execution commits exactly one activity entry
|
|
887
|
+
// in call order. Lifetime ⊆ one turn: set in approve(),
|
|
888
|
+
// consumed-or-cleared by the matching activity, and cleared on
|
|
889
|
+
// deny/cancel/turn boundaries so a stale preview can never attach to
|
|
890
|
+
// a later call.
|
|
891
|
+
// - write: BEFORE reuses the preview's pre-read; AFTER is the new
|
|
892
|
+
// content arg (exactly what the tool writes) — zero extra reads.
|
|
893
|
+
// - edit: BEFORE is a best-effort full-file read here (pre-execution);
|
|
894
|
+
// AFTER is read at commit time. Two reads, each once, never in render.
|
|
895
|
+
const pendingDiffRef = useRef(null);
|
|
896
|
+
// Best-effort full-file read for diff capture: null on missing dir,
|
|
897
|
+
// oversize, or any I/O failure. Never throws — capture degrades to the
|
|
898
|
+
// arg-block preview pair instead of breaking approval.
|
|
899
|
+
function readFileForDiff(absPath) {
|
|
900
|
+
try {
|
|
901
|
+
const st = fs.statSync(absPath);
|
|
902
|
+
if (!st.isFile() || st.size > APPROVAL_PREVIEW_MAX_BYTES)
|
|
903
|
+
return null;
|
|
904
|
+
return fs.readFileSync(absPath, "utf8");
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
386
910
|
// Tool approval prompt (normal mode, write/edit/bash): the loop waits on
|
|
387
911
|
// the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
|
|
388
912
|
// (LoopCancelledError) instead of denying one call.
|
|
389
913
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
390
914
|
const approvalResolveRef = useRef(null);
|
|
915
|
+
// Approval highlight (0..3 = once/always/trust-all/deny): arrows + Enter
|
|
916
|
+
// select, y/a/t/n shortcut (unchanged). Display-only; reset on every open.
|
|
917
|
+
const [approveIndex, setApproveIndex] = useState(0);
|
|
918
|
+
const approveIndexRef = useRef(0);
|
|
919
|
+
function setApproveIndexBoth(next) {
|
|
920
|
+
approveIndexRef.current = next;
|
|
921
|
+
setApproveIndex(next);
|
|
922
|
+
}
|
|
391
923
|
// ask_question modal: the loop waits until the user picks, types a custom
|
|
392
924
|
// answer (allowCustom), cancels with Esc (question-cancel result), or
|
|
393
925
|
// cancels the whole turn with Ctrl+C (LoopCancelledError).
|
|
@@ -403,7 +935,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
403
935
|
// tool finishes — no new POSTs, no new executions — then the turn rolls
|
|
404
936
|
// back and a dim `(cancelled)` line renders.
|
|
405
937
|
const turnCancelRef = useRef(null);
|
|
938
|
+
// Display-only tool clock (TUI timing, never execution logic): wall-ms
|
|
939
|
+
// when the current tool call started, via the injectable `now` clock.
|
|
940
|
+
// Consumed by onToolActivity into Turn.ms and by the live running line;
|
|
941
|
+
// parallel batches share it (last start wins — approximate, display-only).
|
|
942
|
+
const toolStartRef = useRef(null);
|
|
943
|
+
// Latest streamed answer text (display bookkeeping only): if the turn
|
|
944
|
+
// FAILS after streaming (rate limits, dead network), the catch path
|
|
945
|
+
// commits this as a marked partial turn so the output never vanishes.
|
|
946
|
+
// History still rolls back (the model never sees it); the transcript
|
|
947
|
+
// keeps what the user already read. Cleared at every turn start.
|
|
948
|
+
const lastPartialRef = useRef("");
|
|
406
949
|
const [error, setError] = useState(null);
|
|
950
|
+
// Local observability recorder (src/telemetry.ts): one telemetry session
|
|
951
|
+
// per App mount. Best-effort and never throwing; off via ATOM_TELEMETRY=0
|
|
952
|
+
// or atom.json telemetry.enabled=false. The loop reports into it through a
|
|
953
|
+
// per-turn sink (see submit); one small file per session lands under
|
|
954
|
+
// ~/.atom/telemetry/sessions/ on turn boundaries.
|
|
955
|
+
const [telemetry] = useState(() => createTelemetryRecorder({
|
|
956
|
+
home: authHome,
|
|
957
|
+
enabled: resolveTelemetryEnabled(process.env, atomConfig.telemetry?.enabled),
|
|
958
|
+
provider: resolvedInitialProvider,
|
|
959
|
+
model: resolvedInitialModel,
|
|
960
|
+
secrets: providerSecrets,
|
|
961
|
+
}));
|
|
407
962
|
// Session token totals from real API usage payloads only (null = none
|
|
408
963
|
// reported yet -> `token: n/a`). Survives /clear by design (see /help).
|
|
409
964
|
const [usageTotals, setUsageTotals] = useState(null);
|
|
@@ -414,6 +969,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
414
969
|
// completes. NK stays cumulative; P must NOT use the cumulative total.
|
|
415
970
|
const [contextLoad, setContextLoad] = useState(null);
|
|
416
971
|
const contextLoadRef = useRef(null);
|
|
972
|
+
// Git identity for the status bar (branch only, no status porcelain):
|
|
973
|
+
// refreshed at turn boundaries (a turn's bash may switch branches), read
|
|
974
|
+
// from render. Null outside git repos — the bar then shows cwd alone.
|
|
975
|
+
const [gitInfo, setGitInfo] = useState(null);
|
|
976
|
+
function refreshGitInfo() {
|
|
977
|
+
try {
|
|
978
|
+
setGitInfo(getGitInfo(process.cwd()));
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
setGitInfo(null);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
417
984
|
// Last POST's reported prompt_tokens (load metric source). Summary-request
|
|
418
985
|
// usage never touches this — only main-loop POSTs do.
|
|
419
986
|
const lastPromptTokensRef = useRef(undefined);
|
|
@@ -427,7 +994,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
427
994
|
// never mid-turn. Null = none pending.
|
|
428
995
|
const pendingCompactRef = useRef(null);
|
|
429
996
|
// Startup hint: a save file exists from a previous session. Rendered once
|
|
430
|
-
// as a dim line while the transcript is empty;
|
|
997
|
+
// as a dim line while the transcript is empty; the conversation itself
|
|
998
|
+
// never auto-restores (only provider/model/effort do, via restorePrefs).
|
|
431
999
|
const [sessionHint] = useState(() => sessionExists(authHome));
|
|
432
1000
|
// Reasoning label from response metadata (via onReasoning). The status
|
|
433
1001
|
// line shows the session effort when non-Default (plus " (unsupported)"
|
|
@@ -440,10 +1008,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
440
1008
|
// tool name before its execution line lands.
|
|
441
1009
|
const [draft, setDraft] = useState(null);
|
|
442
1010
|
// Thinking channel (onThinking): reasoning text streamed apart from the
|
|
443
|
-
// answer, rendered in its own dim block below.
|
|
444
|
-
// cleared on every turn boundary below —
|
|
445
|
-
// transcript
|
|
1011
|
+
// answer, rendered in its own dim block below. The live value is transient
|
|
1012
|
+
// like `draft` — cleared on every turn boundary below — but each completed
|
|
1013
|
+
// round commits to the transcript via commitThinking (stays in the TUI,
|
|
1014
|
+
// never the model history) instead of being replaced and lost.
|
|
446
1015
|
const [thinking, setThinking] = useState(null);
|
|
1016
|
+
const thinkingRef = useRef(null);
|
|
1017
|
+
// Move the accumulated round thinking into the transcript as a quiet
|
|
1018
|
+
// annotation turn (no-op when empty). Called when a new POST starts and at
|
|
1019
|
+
// turn end, so every round's reasoning stays visible; the /thinking toggle
|
|
1020
|
+
// only controls rendering, never this record.
|
|
1021
|
+
function commitThinking() {
|
|
1022
|
+
const text = thinkingRef.current;
|
|
1023
|
+
thinkingRef.current = null;
|
|
1024
|
+
setThinking(null);
|
|
1025
|
+
if (typeof text === "string" && text.length > 0) {
|
|
1026
|
+
appendTurns({ role: "assistant", content: text, thinking: true });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function clearThinking() {
|
|
1030
|
+
thinkingRef.current = null;
|
|
1031
|
+
setThinking(null);
|
|
1032
|
+
}
|
|
447
1033
|
const [phase, setPhase] = useState("idle");
|
|
448
1034
|
const [phaseDetail, setPhaseDetail] = useState("");
|
|
449
1035
|
const [toolHint, setToolHint] = useState(null);
|
|
@@ -477,6 +1063,72 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
477
1063
|
// Phase 5: models-list session cache (successful live lists only, keyed
|
|
478
1064
|
// by modelsCacheKey). Failures fall back uncached, exactly as before.
|
|
479
1065
|
const modelsCacheRef = useRef(new Map());
|
|
1066
|
+
// Local model discovery (Ollama / LM Studio / llama.cpp): one instance
|
|
1067
|
+
// per App (injectable for tests), probed in the background on mount.
|
|
1068
|
+
// Results land in the session models cache above, so the /model picker
|
|
1069
|
+
// serves them through the standard entries path — no parallel registry.
|
|
1070
|
+
const localDiscoveryRef = useRef(localDiscovery ?? createLocalDiscovery());
|
|
1071
|
+
const [localSnap, setLocalSnap] = useState(() => localDiscoveryRef.current.snapshot());
|
|
1072
|
+
const localSnapRef = useRef(localSnap);
|
|
1073
|
+
function setLocalSnapBoth(next) {
|
|
1074
|
+
localSnapRef.current = next;
|
|
1075
|
+
setLocalSnap(next);
|
|
1076
|
+
}
|
|
1077
|
+
// Fold a discovery snapshot into the models cache: reachable providers
|
|
1078
|
+
// contribute their model ids (keyed with their loopback baseURL);
|
|
1079
|
+
// unreachable providers are evicted so stale entries vanish on refresh.
|
|
1080
|
+
function applyLocalSnapshot(snap) {
|
|
1081
|
+
for (const id of ["ollama", "lmstudio", "llamacpp"]) {
|
|
1082
|
+
const r = snap.results[id];
|
|
1083
|
+
const key = modelsCacheKey(id, r.baseURL);
|
|
1084
|
+
if (r.ok) {
|
|
1085
|
+
modelsCacheRef.current.set(key, r.models.map((m) => m.id));
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
modelsCacheRef.current.delete(key);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
setLocalSnapBoth(snap);
|
|
1092
|
+
}
|
|
1093
|
+
// Non-blocking refresh kick: shared in-flight promise dedupes overlapping
|
|
1094
|
+
// calls (mount + picker-open + /models), so servers are never probed twice.
|
|
1095
|
+
function kickLocalDiscovery() {
|
|
1096
|
+
if (initialModels)
|
|
1097
|
+
return;
|
|
1098
|
+
// Suites stay hermetic regardless of loopback servers on the dev
|
|
1099
|
+
// machine: under Vitest only an explicitly injected fake may probe.
|
|
1100
|
+
if (!localDiscovery && process.env.VITEST)
|
|
1101
|
+
return;
|
|
1102
|
+
void localDiscoveryRef.current.refresh().then((snap) => {
|
|
1103
|
+
applyLocalSnapshot(snap);
|
|
1104
|
+
}, () => {
|
|
1105
|
+
// Discovery never rejects (per-provider isolation), but a defensive
|
|
1106
|
+
// catch keeps an unexpected throw from surfacing unhandled.
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
// Clear error when the active local server is known-unreachable: the
|
|
1110
|
+
// server disappeared after discovery (or was never reachable). Returns
|
|
1111
|
+
// the notice, or null when local routing is fine.
|
|
1112
|
+
function localUnreachableNotice(id) {
|
|
1113
|
+
if (!isLocalProviderId(id))
|
|
1114
|
+
return null;
|
|
1115
|
+
const r = localSnapRef.current.results[id];
|
|
1116
|
+
if (r.ok)
|
|
1117
|
+
return null;
|
|
1118
|
+
const name = getProvider(id)?.name ?? id;
|
|
1119
|
+
return `${name} is unreachable at ${r.baseURL} — start the server, then run /models refresh.`;
|
|
1120
|
+
}
|
|
1121
|
+
// Loopback baseURL for chat/submit paths (env override wins, else the
|
|
1122
|
+
// probed snapshot base, else the compiled default).
|
|
1123
|
+
function localBaseURL(id) {
|
|
1124
|
+
const snapBase = localSnapRef.current.results[id]?.baseURL;
|
|
1125
|
+
return localBaseURLFor(id, snapBase);
|
|
1126
|
+
}
|
|
1127
|
+
function chatBaseURL(id) {
|
|
1128
|
+
return isLocalProviderId(id)
|
|
1129
|
+
? localBaseURL(id)
|
|
1130
|
+
: getStoredBaseURL(authRef.current, id);
|
|
1131
|
+
}
|
|
480
1132
|
// Phase 5: elapsed + stall indicator (status-bar only, never transcript).
|
|
481
1133
|
// `elapsedSecs` ticks at 1s resolution while busy; `stalled` turns true
|
|
482
1134
|
// when no token/tool/phase activity arrives for >3s mid-turn and clears
|
|
@@ -493,9 +1145,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
493
1145
|
// subset. Failed user turns are popped (rollback) — but only when the
|
|
494
1146
|
// HTTP POST itself fails; tool errors are results the model sees and
|
|
495
1147
|
// are never rolled back.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1148
|
+
// Tracked from birth: every later push/splice/index-assign flows through
|
|
1149
|
+
// the ContextLedger traps, so per-step accounting stays O(1). Replacements
|
|
1150
|
+
// below re-wrap via trackHistory (never assign a raw array here).
|
|
1151
|
+
const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
|
|
499
1152
|
// Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
|
|
500
1153
|
// pinned to history[0] (the only slot truncateHistory never drops), NEVER
|
|
501
1154
|
// to user content. Refreshed once per turn in submit() + after doResume, so
|
|
@@ -537,7 +1190,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
537
1190
|
}
|
|
538
1191
|
else {
|
|
539
1192
|
const p = providerRef.current;
|
|
540
|
-
|
|
1193
|
+
// Local runtimes resolve loopback baseURLs here too, so the mount
|
|
1194
|
+
// fetch and the discovery refresh share one cache key per server.
|
|
1195
|
+
const baseURL = chatBaseURL(p);
|
|
541
1196
|
const cacheKey = modelsCacheKey(p, baseURL);
|
|
542
1197
|
const cached = modelsCacheRef.current.get(cacheKey);
|
|
543
1198
|
if (cached) {
|
|
@@ -546,7 +1201,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
546
1201
|
cancelled = true;
|
|
547
1202
|
};
|
|
548
1203
|
}
|
|
549
|
-
|
|
1204
|
+
// Owner-gated seed: an anonymous Kilo mount must not inherit the
|
|
1205
|
+
// zen-seeded prop key (see keyForProvider).
|
|
1206
|
+
const k = keyForProvider(p);
|
|
550
1207
|
void fetchModelsForProviderWithStatus(p, k, baseURL, endpoint).then(({ models: list, ok }) => {
|
|
551
1208
|
if (cancelled)
|
|
552
1209
|
return;
|
|
@@ -559,8 +1216,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
559
1216
|
cancelled = true;
|
|
560
1217
|
};
|
|
561
1218
|
}, [endpoint, apiKey, initialModels]);
|
|
1219
|
+
// Slash-menu skill snapshot once on mount (local disk reads only —
|
|
1220
|
+
// zero fetches; refreshed on /skills, /clear, /new below).
|
|
1221
|
+
useEffect(() => {
|
|
1222
|
+
void refreshSkillMenu();
|
|
1223
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1224
|
+
}, []);
|
|
1225
|
+
// Local model discovery once on mount (background, non-blocking: the
|
|
1226
|
+
// probes race with first paint and merge into the models cache when they
|
|
1227
|
+
// land — the /model picker then serves local entries with zero extra
|
|
1228
|
+
// fetches. Skipped in tests via initialModels, like the live list above).
|
|
1229
|
+
useEffect(() => {
|
|
1230
|
+
kickLocalDiscovery();
|
|
1231
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1232
|
+
}, []);
|
|
562
1233
|
// Phase 5: turn timer helpers (injectable now/timers for tests). The
|
|
563
1234
|
// interval handle is always cleared on turn end and on unmount.
|
|
1235
|
+
// clockNow shares the injectable `now` (display timestamps only).
|
|
1236
|
+
function clockNow() {
|
|
1237
|
+
try {
|
|
1238
|
+
return (now ?? Date.now)();
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
return Date.now();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
564
1244
|
function clearTurnTimer() {
|
|
565
1245
|
const h = turnTimerRef.current;
|
|
566
1246
|
if (h !== null) {
|
|
@@ -621,6 +1301,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
621
1301
|
// in that gap; runLoopWithChat checks the signal before the first POST).
|
|
622
1302
|
useEffect(() => {
|
|
623
1303
|
return () => {
|
|
1304
|
+
// Local observability: close the session trace on unmount (covers
|
|
1305
|
+
// every exit path — /exit, Ctrl+C idle, test teardown) and flush.
|
|
1306
|
+
// Best-effort, never throws; idempotent with closeTelemetry callers.
|
|
1307
|
+
try {
|
|
1308
|
+
telemetry.endSession();
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
// ignore
|
|
1312
|
+
}
|
|
1313
|
+
persistTelemetry();
|
|
624
1314
|
try {
|
|
625
1315
|
turnCancelRef.current?.abort();
|
|
626
1316
|
}
|
|
@@ -658,16 +1348,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
658
1348
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
659
1349
|
}, []);
|
|
660
1350
|
function setInputAndCursor(next, cursorPos) {
|
|
1351
|
+
const prev = inputRef.current;
|
|
661
1352
|
inputRef.current = next;
|
|
662
1353
|
setInput(next);
|
|
663
1354
|
const clamped = Math.max(0, Math.min(cursorPos, next.length));
|
|
664
1355
|
cursorRef.current = clamped;
|
|
665
1356
|
setCursor(clamped);
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1357
|
+
// Slash-prefixed edits restart menu filtering from the top and re-open
|
|
1358
|
+
// the menu; plain-text edits skip the two menu setStates entirely so a
|
|
1359
|
+
// keystroke is always input+cursor only (one batched render).
|
|
1360
|
+
if (next.startsWith("/") || prev.startsWith("/")) {
|
|
1361
|
+
slashIndexRef.current = 0;
|
|
1362
|
+
setSlashIndex(0);
|
|
1363
|
+
slashDismissedRef.current = false;
|
|
1364
|
+
setSlashDismissed(false);
|
|
1365
|
+
}
|
|
671
1366
|
}
|
|
672
1367
|
function setInputBoth(next) {
|
|
673
1368
|
// Append-style callers (and clear/Esc/submit reset): cursor to end.
|
|
@@ -679,10 +1374,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
679
1374
|
setCursor(clamped);
|
|
680
1375
|
}
|
|
681
1376
|
// Cursor-aware edits (plain input + slash menu): typing inserts AT the
|
|
682
|
-
// cursor, backspace deletes BEFORE it, Delete removes AT it.
|
|
1377
|
+
// cursor, backspace deletes BEFORE it, Delete removes AT it. Every edit
|
|
1378
|
+
// abandons history browse (typing starts a fresh draft; the stash keeps
|
|
1379
|
+
// the pre-browse text for Down-past-newest, which re-stashes on re-entry).
|
|
1380
|
+
function exitHistoryBrowse() {
|
|
1381
|
+
histIndexRef.current = null;
|
|
1382
|
+
}
|
|
683
1383
|
function insertAtCursor(text) {
|
|
684
1384
|
if (!text)
|
|
685
1385
|
return;
|
|
1386
|
+
exitHistoryBrowse();
|
|
686
1387
|
const cur = inputRef.current;
|
|
687
1388
|
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
688
1389
|
setInputAndCursor(cur.slice(0, at) + text + cur.slice(at), at + text.length);
|
|
@@ -692,6 +1393,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
692
1393
|
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
693
1394
|
if (at <= 0)
|
|
694
1395
|
return;
|
|
1396
|
+
exitHistoryBrowse();
|
|
695
1397
|
setInputAndCursor(cur.slice(0, at - 1) + cur.slice(at), at - 1);
|
|
696
1398
|
}
|
|
697
1399
|
function deleteAtCursor() {
|
|
@@ -699,6 +1401,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
699
1401
|
const at = Math.max(0, Math.min(cursorRef.current, cur.length));
|
|
700
1402
|
if (at >= cur.length)
|
|
701
1403
|
return;
|
|
1404
|
+
exitHistoryBrowse();
|
|
702
1405
|
setInputAndCursor(cur.slice(0, at) + cur.slice(at + 1), at);
|
|
703
1406
|
}
|
|
704
1407
|
function setSelIndexBoth(next) {
|
|
@@ -749,9 +1452,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
749
1452
|
authRef.current = next;
|
|
750
1453
|
setAuth(next);
|
|
751
1454
|
}
|
|
752
|
-
function setActiveKeyBoth(next) {
|
|
1455
|
+
function setActiveKeyBoth(next, owner) {
|
|
753
1456
|
activeApiKeyRef.current = next;
|
|
754
1457
|
setActiveApiKey(next);
|
|
1458
|
+
if (owner !== undefined)
|
|
1459
|
+
activeKeyProviderRef.current = owner;
|
|
755
1460
|
}
|
|
756
1461
|
function setProviderIndexBoth(next) {
|
|
757
1462
|
providerIndexRef.current = next;
|
|
@@ -779,13 +1484,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
779
1484
|
const resolved = resolveApiKey(id, authRef.current);
|
|
780
1485
|
if (resolved)
|
|
781
1486
|
return resolved;
|
|
782
|
-
|
|
1487
|
+
// Seeded-key fallback, owner-gated (see activeKeyProviderRef): the
|
|
1488
|
+
// startup seed belongs to exactly one provider and must never leak
|
|
1489
|
+
// into another's requests (notably anonymous Kilo sessions).
|
|
1490
|
+
if (id === providerRef.current &&
|
|
1491
|
+
id === activeKeyProviderRef.current &&
|
|
1492
|
+
activeApiKeyRef.current) {
|
|
783
1493
|
return activeApiKeyRef.current;
|
|
784
1494
|
}
|
|
785
1495
|
return "";
|
|
786
1496
|
}
|
|
787
1497
|
function closeAllPickers() {
|
|
788
1498
|
setSelecting(false);
|
|
1499
|
+
setModelFilterBoth("");
|
|
1500
|
+
setSelectingSkills(false);
|
|
1501
|
+
setSkillFilterBoth("");
|
|
789
1502
|
setSelectingEffort(false);
|
|
790
1503
|
setSelectingProvider(false);
|
|
791
1504
|
setKeyPromptBoth(null);
|
|
@@ -794,6 +1507,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
794
1507
|
setSelectingRewindScope(false);
|
|
795
1508
|
pendingRewindRef.current = null;
|
|
796
1509
|
}
|
|
1510
|
+
// /skills picker (opencode-style searchable popup): opens on the fresh
|
|
1511
|
+
// registry (names only), filters as you type, loads on Enter. Local disk
|
|
1512
|
+
// reads only — zero fetches. Idle-only (history injection mid-turn would
|
|
1513
|
+
// break the loop's assistant/tool pairing).
|
|
1514
|
+
function openSkillPicker() {
|
|
1515
|
+
setInputBoth("");
|
|
1516
|
+
closeAllPickers();
|
|
1517
|
+
setSkillFilterBoth("");
|
|
1518
|
+
setSkillIndexBoth(0);
|
|
1519
|
+
void skillRegistry.refresh().then((found) => {
|
|
1520
|
+
if (busyRef.current) {
|
|
1521
|
+
pushInfo("Skills load when idle — wait for the turn to finish.");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
const { skills } = resolveSkills(found.skills);
|
|
1525
|
+
setSkillPickerItems(skills.map((s) => ({ name: s.name, userInvocable: s.userInvocable, source: s.source })));
|
|
1526
|
+
setSelectingSkills(true);
|
|
1527
|
+
void refreshSkillMenu();
|
|
1528
|
+
}, () => {
|
|
1529
|
+
// Discovery never throws by contract, but a rejection must never
|
|
1530
|
+
// become an unhandled rejection (Node kills the process) — surface it.
|
|
1531
|
+
pushInfo("(skill discovery failed — no skills listed)");
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
// Unified /model entries for this render: active provider's current list
|
|
1535
|
+
// first, then every other keyed provider's cached-or-fallback list (pure,
|
|
1536
|
+
// local-only — see modelPickerEntries). Called from the /model open path,
|
|
1537
|
+
// the picker input branch, and the picker render: all three run on the
|
|
1538
|
+
// same render's state, so open/highlight/filter/paint always agree.
|
|
1539
|
+
function buildModelEntries() {
|
|
1540
|
+
return modelPickerEntries({
|
|
1541
|
+
activeProvider: providerRef.current,
|
|
1542
|
+
activeModels: models,
|
|
1543
|
+
cached: (id, baseURL) => modelsCacheRef.current.get(modelsCacheKey(id, baseURL)),
|
|
1544
|
+
keyFor: (id) => keyForProvider(id),
|
|
1545
|
+
// Local runtimes resolve loopback baseURLs (env/defaults), so cache
|
|
1546
|
+
// reads hit the same keys discovery writes.
|
|
1547
|
+
baseURLFor: (id) => chatBaseURL(id),
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
797
1550
|
function openProviderPicker() {
|
|
798
1551
|
setSelecting(false);
|
|
799
1552
|
setSelectingEffort(false);
|
|
@@ -831,10 +1584,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
831
1584
|
// reuse the cached live list when available (zero fetches), else fetch
|
|
832
1585
|
// the live list and cache successes; failures fall back uncached.
|
|
833
1586
|
// /model always reflects the switched-to provider instantly from cache
|
|
834
|
-
// when available. Keep current model if valid else provider default
|
|
835
|
-
|
|
1587
|
+
// when available. Keep current model if valid else provider default —
|
|
1588
|
+
// unless preserveModel names an explicit pick (cross-provider /model
|
|
1589
|
+
// selection), which wins unconditionally so the user's pick sticks even
|
|
1590
|
+
// before the background live refresh lands.
|
|
1591
|
+
async function switchProviderWithKey(pickedId, apiKeyValue, preserveModel) {
|
|
836
1592
|
const def = getProvider(pickedId);
|
|
837
|
-
const baseURL =
|
|
1593
|
+
const baseURL = chatBaseURL(pickedId);
|
|
838
1594
|
const cacheKey = modelsCacheKey(pickedId, baseURL);
|
|
839
1595
|
const cached = modelsCacheRef.current.get(cacheKey);
|
|
840
1596
|
let list;
|
|
@@ -853,16 +1609,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
853
1609
|
}
|
|
854
1610
|
}
|
|
855
1611
|
setModels(list);
|
|
856
|
-
const nextModel = list.includes(modelRef.current)
|
|
1612
|
+
const nextModel = preserveModel ?? (list.includes(modelRef.current)
|
|
857
1613
|
? modelRef.current
|
|
858
|
-
: def.defaultModel;
|
|
1614
|
+
: def.defaultModel);
|
|
859
1615
|
setModelBoth(nextModel);
|
|
860
1616
|
setProviderBoth(pickedId);
|
|
861
1617
|
// Provider switch resets the load latch: the last reported prompt_tokens
|
|
862
1618
|
// belonged to the old provider/model tokenizer, so the estimate applies
|
|
863
1619
|
// until the new provider reports (usageTotals spend is untouched).
|
|
864
1620
|
resetContextLoadToEstimate();
|
|
865
|
-
setActiveKeyBoth(apiKeyValue);
|
|
1621
|
+
setActiveKeyBoth(apiKeyValue, pickedId);
|
|
866
1622
|
if (pickedId === "openai-compatible") {
|
|
867
1623
|
setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
|
|
868
1624
|
}
|
|
@@ -881,6 +1637,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
881
1637
|
setTurns(next);
|
|
882
1638
|
}
|
|
883
1639
|
function appendTurns(...items) {
|
|
1640
|
+
// Autoscroll off + busy + following: freeze the view at its current end
|
|
1641
|
+
// BEFORE appending, so streaming output accumulates below instead of
|
|
1642
|
+
// yanking the viewport (smooth-scroll hold). Idle appends and already-
|
|
1643
|
+
// held views pass through untouched.
|
|
1644
|
+
if (!autoScrollRef.current && busyRef.current && scrollEndRef.current === null) {
|
|
1645
|
+
setScrollEndBoth(turnsRef.current.length);
|
|
1646
|
+
}
|
|
884
1647
|
const next = [...turnsRef.current, ...items];
|
|
885
1648
|
turnsRef.current = next;
|
|
886
1649
|
setTurns(next);
|
|
@@ -905,7 +1668,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
905
1668
|
setContextLoadBoth(null);
|
|
906
1669
|
return null;
|
|
907
1670
|
}
|
|
908
|
-
const load =
|
|
1671
|
+
const load = contextManager().usage(historyRef.current, lastPromptTokensRef.current).loadTokens;
|
|
909
1672
|
setContextLoadBoth(load);
|
|
910
1673
|
return load;
|
|
911
1674
|
}
|
|
@@ -925,48 +1688,128 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
925
1688
|
function pushInfo(content) {
|
|
926
1689
|
appendTurns({ role: "tool", content });
|
|
927
1690
|
}
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
1691
|
+
// The session's ContextManager: window-derived budgets for the active
|
|
1692
|
+
// model, measured tool schemas, configured ceilings. Built fresh per call
|
|
1693
|
+
// (pure math, no I/O beyond the resolved ceiling sources) so it always sees
|
|
1694
|
+
// the current model; history is measured live on every use. The schema size
|
|
1695
|
+
// is memoized once — TOOL_DEFINITIONS never changes at runtime, so every
|
|
1696
|
+
// turn must not re-serialize 15KB to ask.
|
|
1697
|
+
function contextManager() {
|
|
1698
|
+
return createContextManager({
|
|
1699
|
+
model: modelRef.current,
|
|
1700
|
+
toolsChars: TOOLS_SCHEMA_CHARS,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
// /context (Claude-Code-style visibility): where the tokens are going, by
|
|
1704
|
+
// source — system prompt, tool schemas, history, skill injections — plus
|
|
1705
|
+
// the budget and compaction posture. All estimates use the 4ch/token
|
|
1706
|
+
// heuristic (honest `token: n/a` accounting is untouched); all reads are
|
|
1707
|
+
// local, so the command costs zero fetches.
|
|
1708
|
+
function buildContextText() {
|
|
1709
|
+
const hist = historyRef.current;
|
|
1710
|
+
const first = hist[0];
|
|
1711
|
+
const sysChars = first && typeof first.content === "string"
|
|
1712
|
+
? messageChars(first)
|
|
1713
|
+
: 0;
|
|
1714
|
+
const toolsChars = TOOLS_SCHEMA_CHARS;
|
|
1715
|
+
const mgr = contextManager();
|
|
1716
|
+
const u = mgr.usage(hist, lastPromptTokensRef.current);
|
|
1717
|
+
const b = mgr.budget(hist);
|
|
1718
|
+
const skillLoads = hist.filter((m) => m?.role === "user" &&
|
|
1719
|
+
typeof m.content === "string" &&
|
|
1720
|
+
m.content.includes('[skill "')).length;
|
|
1721
|
+
const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
|
|
1722
|
+
? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
|
|
1723
|
+
: `load: ${formatKEst(u.historyChars)} (no verified window — auto-compact off, use /compact manually)`;
|
|
1724
|
+
const cfg = atomConfigLoad;
|
|
1725
|
+
const cfgSources = cfg.sources.project && cfg.sources.global
|
|
1726
|
+
? "project + global"
|
|
1727
|
+
: cfg.sources.project
|
|
1728
|
+
? "project"
|
|
1729
|
+
: cfg.sources.global
|
|
1730
|
+
? "global"
|
|
1731
|
+
: "none";
|
|
1732
|
+
const cfgKeys = Object.keys(atomConfig).length;
|
|
1733
|
+
const cfgLine = `config: atom.json (${cfgSources}${cfgKeys > 0 ? `, ${cfgKeys} key${cfgKeys === 1 ? "" : "s"}` : ", defaults"})` +
|
|
1734
|
+
(cfg.warnings.length > 0 ? `\nconfig warnings:\n${cfg.warnings.map((w) => `- ${w}`).join("\n")}` : "");
|
|
1735
|
+
// Prefix-cache instrumentation (estimates + reported-only hits — a
|
|
1736
|
+
// provider that reports nothing shows "(not reported)", never zeros).
|
|
1737
|
+
// stableTokens are tokens; formatKEst takes chars, hence the ×4 round-trip.
|
|
1738
|
+
const caps = providerCacheSupport(providerRef.current);
|
|
1739
|
+
const prefix = first && typeof first.content === "string"
|
|
1740
|
+
? assemblePrefix({
|
|
1741
|
+
systemContent: first.content,
|
|
1742
|
+
toolsJson: JSON.stringify(TOOL_DEFINITIONS),
|
|
1743
|
+
})
|
|
1744
|
+
: null;
|
|
1745
|
+
const totals = usageRef.current;
|
|
1746
|
+
const cacheHits = totals?.cacheReadTokens !== undefined || totals?.cacheWriteTokens !== undefined
|
|
1747
|
+
? `read ${formatKEst((totals?.cacheReadTokens ?? 0) * 4)} / written ${formatKEst((totals?.cacheWriteTokens ?? 0) * 4)}`
|
|
1748
|
+
: "not reported by provider";
|
|
1749
|
+
const cacheLine = prefix !== null
|
|
1750
|
+
? `cache: ${formatKEst(prefix.stableTokens * 4)} stable/cacheable (fp ${prefix.fingerprint.slice(0, 12)}) + ${formatKEst(prefix.dynamicSystem !== null ? prefix.dynamicSystem.length : 0)} dynamic env · ${caps.explicitBreakpoints ? "explicit breakpoints" : caps.implicitPrefix ? "implicit prefix" : "no caching assumed"} · hits: ${cacheHits}`
|
|
1751
|
+
: `cache: (no system message) · hits: ${cacheHits}`;
|
|
1752
|
+
return (`Context (model ${modelRef.current}):\n` +
|
|
1753
|
+
`system: ${formatKEst(sysChars)} (base + AGENTS overlay + env block)\n` +
|
|
1754
|
+
`tools: ${TOOL_DEFINITIONS.length} defs, ${formatKEst(toolsChars)}\n` +
|
|
1755
|
+
`history: ${u.historyMessages} messages / ${u.userTurns} user turns, ${formatKEst(u.historyChars)}\n` +
|
|
1756
|
+
`skill injections live in history: ${skillLoads}\n` +
|
|
1757
|
+
`${cfgLine}\n` +
|
|
1758
|
+
`${cacheLine}\n` +
|
|
1759
|
+
`${loadLine} · budget: ${b.effectiveMaxMessages} msgs / ${(b.effectiveMaxChars / 1000).toFixed(0)}K chars`);
|
|
1760
|
+
}
|
|
1761
|
+
// Load a resolved skill into the session (tickets 03/06) with
|
|
1762
|
+
// progressive-disclosure tiers (Claude-Code-style):
|
|
1763
|
+
// - manual (explicit user invocation): full body + inlined references enter
|
|
1764
|
+
// model history as one marked message (the user asked for the whole skill).
|
|
1765
|
+
// - auto (description match): Tier 2 only — body without inlined references
|
|
1766
|
+
// (the model reads references/<…> via read when needed), capped at
|
|
1767
|
+
// AUTO_SKILL_BODY_CAP so a trigger can never flood the window.
|
|
1768
|
+
// Both paths print ONE transcript line (never the body — the TUI stays
|
|
1769
|
+
// calm no matter how large the skill is); `allowed-tools` become
|
|
1770
|
+
// turn-scoped grants per the skill-grant trust policy (global skills arm,
|
|
1771
|
+
// project skills never do — see skillGrantsFor). Never throws:
|
|
1772
|
+
// loadSkillBody degrades to empty text, surfaced plainly.
|
|
1773
|
+
async function activateSkill(info, opts) {
|
|
1774
|
+
const auto = opts?.auto === true;
|
|
1775
|
+
const loaded = await loadSkillBody(info, auto ? { inlineRefs: false } : undefined);
|
|
935
1776
|
if (loaded.text.trim().length === 0) {
|
|
936
1777
|
pushInfo(`Skill "${info.name}" has an empty body — nothing loaded.`);
|
|
937
1778
|
return;
|
|
938
1779
|
}
|
|
939
|
-
|
|
1780
|
+
const { grants, blocked } = skillGrantsFor(info.source, loaded.info.allowedTools);
|
|
1781
|
+
for (const t of grants)
|
|
940
1782
|
skillGrantsRef.current.add(t);
|
|
1783
|
+
const contextText = auto ? capSkillBodyForAuto(loaded.text, info.dir) : loaded.text;
|
|
941
1784
|
historyRef.current.push({
|
|
942
1785
|
role: "user",
|
|
943
|
-
content: `[skill "${info.name}" loaded — follow these instructions]\n${
|
|
1786
|
+
content: `[skill "${info.name}" loaded — follow these instructions]\n${contextText}`,
|
|
944
1787
|
});
|
|
945
|
-
const grantNote =
|
|
946
|
-
? ` (tools pre-approved this turn: ${
|
|
947
|
-
:
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
//
|
|
953
|
-
//
|
|
1788
|
+
const grantNote = grants.length > 0
|
|
1789
|
+
? ` (tools pre-approved this turn: ${grants.join(", ")})`
|
|
1790
|
+
: blocked.length > 0
|
|
1791
|
+
? ` (project skill: ${blocked.join(", ")} still needs approval)`
|
|
1792
|
+
: "";
|
|
1793
|
+
pushInfo(`${info.name} loaded${grantNote}`);
|
|
1794
|
+
}
|
|
1795
|
+
// Manual skill invocation (ticket 03; `/skill-name` legacy form and the
|
|
1796
|
+
// namespaced `/skill:name` form both land here with the bare name).
|
|
1797
|
+
// Idle-only: injecting history mid-turn would break the loop's
|
|
1798
|
+
// assistant/tool pairing. Unknown names get a helpful error (not a model
|
|
1799
|
+
// message); model-only skills refuse with a pointer instead of loading.
|
|
954
1800
|
async function invokeSkillByName(name) {
|
|
955
1801
|
if (busyRef.current) {
|
|
956
1802
|
pushInfo("Skills load when idle — wait for the turn to finish.");
|
|
957
1803
|
return;
|
|
958
1804
|
}
|
|
959
|
-
const found = await
|
|
960
|
-
projectDir: skillDirs?.projectDir,
|
|
961
|
-
homeDir: skillDirs?.homeDir,
|
|
962
|
-
});
|
|
1805
|
+
const found = await skillRegistry.refresh();
|
|
963
1806
|
const { skills } = resolveSkills(found.skills);
|
|
964
1807
|
const info = skills.find((s) => s.name === name);
|
|
965
1808
|
if (!info) {
|
|
966
|
-
const available = skills.filter((s) => s.userInvocable).map((s) =>
|
|
1809
|
+
const available = skills.filter((s) => s.userInvocable).map((s) => `/skill:${s.name}`);
|
|
967
1810
|
pushInfo(available.length > 0
|
|
968
|
-
? `Unknown skill "
|
|
969
|
-
: `Unknown skill "
|
|
1811
|
+
? `Unknown skill "/skill:${name}". Available: ${available.join(", ")}`
|
|
1812
|
+
: `Unknown skill "/skill:${name}" (no skills installed).`);
|
|
970
1813
|
return;
|
|
971
1814
|
}
|
|
972
1815
|
if (!info.userInvocable) {
|
|
@@ -978,7 +1821,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
978
1821
|
// Snapshot the committed session (historyRef + turnsRef + settings refs)
|
|
979
1822
|
// to ~/.atom/session.json. Disk errors are ignored (in-memory session
|
|
980
1823
|
// still applies). Called only for committed state: completed turns and
|
|
981
|
-
// clean exit — never for rolled-back (failed/cancelled) turns.
|
|
1824
|
+
// clean exit — never for rolled-back (failed/cancelled) turns. The
|
|
1825
|
+
// committed diff previews (Turn.diff) are display-only and never saved:
|
|
1826
|
+
// they can hold whole file contents (bloat) and would render stale
|
|
1827
|
+
// after later edits, so /resume restores label-only turns.
|
|
982
1828
|
function persistSession() {
|
|
983
1829
|
try {
|
|
984
1830
|
saveSession({
|
|
@@ -988,13 +1834,66 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
988
1834
|
mode: modeRef.current,
|
|
989
1835
|
usageTotals: usageRef.current,
|
|
990
1836
|
history: historyRef.current,
|
|
991
|
-
turns: turnsRef.current
|
|
1837
|
+
turns: turnsRef.current.map((t) => {
|
|
1838
|
+
const { diff: _dropped, ...rest } = t;
|
|
1839
|
+
return rest;
|
|
1840
|
+
}),
|
|
992
1841
|
}, authHome);
|
|
993
1842
|
}
|
|
994
1843
|
catch {
|
|
995
1844
|
// ignore disk errors (in-memory session still applies)
|
|
996
1845
|
}
|
|
997
1846
|
}
|
|
1847
|
+
// Local observability persistence: flush the current telemetry session
|
|
1848
|
+
// file (atomic, best-effort). Called on turn boundaries and session events —
|
|
1849
|
+
// never in the hot path, never throwing.
|
|
1850
|
+
function persistTelemetry() {
|
|
1851
|
+
try {
|
|
1852
|
+
telemetry.flush();
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
// ignore disk errors (telemetry never breaks the session)
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
function closeTelemetry() {
|
|
1859
|
+
try {
|
|
1860
|
+
telemetry.endSession();
|
|
1861
|
+
}
|
|
1862
|
+
catch {
|
|
1863
|
+
// ignore
|
|
1864
|
+
}
|
|
1865
|
+
persistTelemetry();
|
|
1866
|
+
}
|
|
1867
|
+
// One-line /telemetry summary: current-session progress plus stored totals.
|
|
1868
|
+
// Token figures are API-reported only; unavailable values say n/a with the
|
|
1869
|
+
// reason (never zero, never estimated).
|
|
1870
|
+
function telemetrySummaryText() {
|
|
1871
|
+
try {
|
|
1872
|
+
if (!telemetry.isEnabled()) {
|
|
1873
|
+
return "(telemetry off — ATOM_TELEMETRY=0 or atom.json telemetry.enabled=false; no traces recorded)";
|
|
1874
|
+
}
|
|
1875
|
+
const snap = telemetry.getSnapshot();
|
|
1876
|
+
const { sessions } = loadTelemetrySessions(authHome);
|
|
1877
|
+
const agg = summarizeTelemetry(sessions);
|
|
1878
|
+
const tokens = agg.usageReported
|
|
1879
|
+
? [
|
|
1880
|
+
agg.usage.prompt_tokens !== undefined ? `in ${agg.usage.prompt_tokens}` : null,
|
|
1881
|
+
agg.usage.completion_tokens !== undefined ? `out ${agg.usage.completion_tokens}` : null,
|
|
1882
|
+
agg.usage.total_tokens !== undefined ? `total ${agg.usage.total_tokens}` : null,
|
|
1883
|
+
]
|
|
1884
|
+
.filter((p) => p !== null)
|
|
1885
|
+
.join(" · ") || "reported (empty)"
|
|
1886
|
+
: "n/a (no usage reported yet)";
|
|
1887
|
+
const rate = agg.toolSuccessRate !== null ? `${(agg.toolSuccessRate * 100).toFixed(1)}%` : "n/a (no tool calls)";
|
|
1888
|
+
return (`Telemetry: on · this session ${snap.sessionId} (${snap.turns.length} turn(s)) · ` +
|
|
1889
|
+
`store ${telemetryDir(authHome)} (${agg.sessions} session(s), ${agg.turns} turn(s), ` +
|
|
1890
|
+
`${agg.modelCalls} model call(s), ${agg.toolCalls} tool call(s), success ${rate}, tokens ${tokens}, ` +
|
|
1891
|
+
`${agg.retries} retries) · /dashboard writes the full drill-down page.`);
|
|
1892
|
+
}
|
|
1893
|
+
catch {
|
|
1894
|
+
return "(telemetry unavailable)";
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
998
1897
|
// Compaction routine (atomic): split head/tail, summarize head with
|
|
999
1898
|
// tools disabled + 4096 cap, then swap history := [system, summary, tail].
|
|
1000
1899
|
// On success: boundary line, totals kept, load refreshed, save. On
|
|
@@ -1015,12 +1914,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1015
1914
|
}
|
|
1016
1915
|
const systemMsg = historyRef.current[0];
|
|
1017
1916
|
const systemContent = typeof systemMsg.content === "string" ? systemMsg.content : systemPrompt;
|
|
1018
|
-
|
|
1019
|
-
|
|
1917
|
+
// Owner-gated (see keyForProvider): never borrow another provider's
|
|
1918
|
+
// seed key — anonymous Kilo sessions POST keyless.
|
|
1919
|
+
const submitKey = keyForProvider(providerRef.current);
|
|
1920
|
+
// Local runtimes need no key; a known-unreachable server fails fast
|
|
1921
|
+
// with a clear notice instead of a bare connection error mid-turn.
|
|
1922
|
+
const compactLocalNotice = localUnreachableNotice(providerRef.current);
|
|
1923
|
+
if (compactLocalNotice) {
|
|
1924
|
+
pushInfo(compactLocalNotice);
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
if (!submitKey && providerNeedsKey(providerRef.current)) {
|
|
1020
1928
|
pushInfo(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
|
|
1021
1929
|
return false;
|
|
1022
1930
|
}
|
|
1023
|
-
const baseURL =
|
|
1931
|
+
const baseURL = chatBaseURL(providerRef.current);
|
|
1024
1932
|
try {
|
|
1025
1933
|
const summary = await requestCompactSummary({
|
|
1026
1934
|
provider: providerRef.current,
|
|
@@ -1045,19 +1953,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1045
1953
|
if (u.total_tokens !== undefined) {
|
|
1046
1954
|
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
1047
1955
|
}
|
|
1956
|
+
if (u.cacheReadTokens !== undefined) {
|
|
1957
|
+
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
1958
|
+
}
|
|
1959
|
+
if (u.cacheWriteTokens !== undefined) {
|
|
1960
|
+
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
1961
|
+
}
|
|
1048
1962
|
// Only accumulate when the summary actually reported usage;
|
|
1049
1963
|
// an empty onUsage keeps totals byte-identical.
|
|
1050
1964
|
if (u.prompt_tokens !== undefined ||
|
|
1051
1965
|
u.completion_tokens !== undefined ||
|
|
1052
|
-
u.total_tokens !== undefined
|
|
1966
|
+
u.total_tokens !== undefined ||
|
|
1967
|
+
u.cacheReadTokens !== undefined ||
|
|
1968
|
+
u.cacheWriteTokens !== undefined) {
|
|
1053
1969
|
setUsageBoth(next);
|
|
1054
1970
|
}
|
|
1971
|
+
// Local observability: compaction spend is session-level (it
|
|
1972
|
+
// summarizes many turns and often lands after its turn ended), so
|
|
1973
|
+
// it is kept separate from per-turn usage. No-op when empty.
|
|
1974
|
+
telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
|
|
1055
1975
|
},
|
|
1056
1976
|
});
|
|
1057
1977
|
// Atomic swap: build the new history first, then replace.
|
|
1058
1978
|
const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
|
|
1059
|
-
|
|
1979
|
+
// Replacement: re-wrap so the ledger restarts from the compacted array
|
|
1980
|
+
// (the old ledger is discarded with the old array).
|
|
1981
|
+
historyRef.current = trackHistory(next);
|
|
1060
1982
|
appendTurns({ role: "tool", content: compactBoundaryLine(split.olderTurnCount) });
|
|
1983
|
+
// New lineage (see src/rollback.ts): the atomic swap invalidates
|
|
1984
|
+
// checkpoint marks — drop them, loudly when non-empty. The summary
|
|
1985
|
+
// keeps the story; stale marks must never truncate the new tail.
|
|
1986
|
+
const compactDrops = clearSnapshots();
|
|
1987
|
+
if (compactDrops > 0) {
|
|
1988
|
+
pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
|
|
1989
|
+
}
|
|
1061
1990
|
// P% must drop immediately: the old lastPromptTokens reflects the
|
|
1062
1991
|
// pre-compact context, so clear it and use the new-history estimate.
|
|
1063
1992
|
lastPromptTokensRef.current = undefined;
|
|
@@ -1103,8 +2032,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1103
2032
|
return;
|
|
1104
2033
|
}
|
|
1105
2034
|
// Unknown window → no auto trigger (never invent a window); below
|
|
1106
|
-
// threshold → streak resets.
|
|
1107
|
-
|
|
2035
|
+
// threshold → streak resets. The manager owns the pct math, so a false
|
|
2036
|
+
// there means either case — re-check the window for the reset.
|
|
2037
|
+
if (!contextManager().needsCompaction(load)) {
|
|
1108
2038
|
// Distinguish unknown-window (streak untouched — irrelevant) from
|
|
1109
2039
|
// below-threshold (streak resets). shouldAutoCompact is false for
|
|
1110
2040
|
// both, so re-check the window for the reset.
|
|
@@ -1133,7 +2063,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1133
2063
|
}
|
|
1134
2064
|
const s = result.session;
|
|
1135
2065
|
setProviderBoth(s.provider);
|
|
1136
|
-
const baseURL =
|
|
2066
|
+
const baseURL = chatBaseURL(s.provider);
|
|
1137
2067
|
if (s.provider === "openai-compatible") {
|
|
1138
2068
|
setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
|
|
1139
2069
|
}
|
|
@@ -1147,7 +2077,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1147
2077
|
setEffortBoth(s.effort);
|
|
1148
2078
|
setModeBoth(s.mode);
|
|
1149
2079
|
setUsageBoth(s.usageTotals);
|
|
1150
|
-
|
|
2080
|
+
// Replacement: wrap the restored array (see the init comment).
|
|
2081
|
+
historyRef.current = trackHistory([...s.history]);
|
|
1151
2082
|
// Task 6: refresh the pinned env block on the restored system line
|
|
1152
2083
|
// (strips the saved block, appends a fresh one) — keeps the restored
|
|
1153
2084
|
// AGENTS overlay, never touches user content.
|
|
@@ -1165,13 +2096,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1165
2096
|
setAutoDisabledBoth(false);
|
|
1166
2097
|
pendingCompactRef.current = null;
|
|
1167
2098
|
const pendingNotices = [];
|
|
1168
|
-
|
|
2099
|
+
// New lineage (see src/rollback.ts): the restored history replaces the
|
|
2100
|
+
// live array, so live checkpoint marks are stale — drop them. Disk files
|
|
2101
|
+
// are untouched; only undo evidence goes.
|
|
2102
|
+
const resumedDrops = clearSnapshots();
|
|
2103
|
+
if (resumedDrops > 0) {
|
|
2104
|
+
pendingNotices.push({
|
|
2105
|
+
role: "tool",
|
|
2106
|
+
content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
// Same window-derived caps as the live path (the restored model is
|
|
2110
|
+
// already in modelRef above).
|
|
2111
|
+
contextManager().trimForSend(historyRef.current, (msg) => {
|
|
1169
2112
|
pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
|
|
1170
|
-
});
|
|
2113
|
+
}, undefined, openTodoNeedles());
|
|
1171
2114
|
// Remount the turns <Static> (same mechanism as /clear and /new): Ink's
|
|
1172
2115
|
// Static only renders newly appended indices, so restoring a transcript
|
|
1173
2116
|
// over a non-empty rendered buffer (e.g. the /new boundary line) would
|
|
1174
|
-
// misalign and hide the first restored turn(s).
|
|
2117
|
+
// misalign and hide the first restored turn(s). Restored list replaces
|
|
2118
|
+
// the live one, so a held view re-follows (see /clear).
|
|
2119
|
+
setScrollEndBoth(null);
|
|
1175
2120
|
setClearGen((g) => g + 1);
|
|
1176
2121
|
setTurnsBoth([
|
|
1177
2122
|
...s.turns,
|
|
@@ -1181,6 +2126,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1181
2126
|
},
|
|
1182
2127
|
...pendingNotices,
|
|
1183
2128
|
]);
|
|
2129
|
+
telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
|
|
2130
|
+
persistTelemetry();
|
|
1184
2131
|
}
|
|
1185
2132
|
// /rewind conversation scope (ticket 01): truncate history + transcript to
|
|
1186
2133
|
// the checkpoint's turn. The cut drops the whole containing turn (submit's
|
|
@@ -1199,6 +2146,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1199
2146
|
const turnsCut = conversationCutIndex(turnsRef.current.map((t) => ({ role: t.role })), cp.turnsLength, 0);
|
|
1200
2147
|
if (turnsCut < turnsRef.current.length) {
|
|
1201
2148
|
setTurnsBoth(turnsRef.current.slice(0, turnsCut));
|
|
2149
|
+
// Truncation can strand a held end past the new bottom — re-follow.
|
|
2150
|
+
setScrollEndBoth(null);
|
|
1202
2151
|
}
|
|
1203
2152
|
if (droppedMessages <= 0) {
|
|
1204
2153
|
return `(already at checkpoint #${cp.seq} — conversation untouched)`;
|
|
@@ -1267,7 +2216,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1267
2216
|
// like every slash command except /compact). Rules gate write/edit/bash —
|
|
1268
2217
|
// the approval tools — so a rule naming a read-only tool is accepted but
|
|
1269
2218
|
// 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
2219
|
function runRulesCommand(raw) {
|
|
1272
2220
|
const text = raw.trim();
|
|
1273
2221
|
const space = text.indexOf(" ");
|
|
@@ -1304,6 +2252,157 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1304
2252
|
: ` (note: ${parsed.tool} is read-only and auto-runs — rules gate write/edit/bash)`;
|
|
1305
2253
|
pushInfo(`${kind === "allow" ? "allowed" : "denied"}: ${parsed.pattern} (${rulesRef.current.length} rule(s))${gatedNote}`);
|
|
1306
2254
|
}
|
|
2255
|
+
// Queue + steer surface (Claude-Code-style follow-ups): /queue lists,
|
|
2256
|
+
// /queue clear wipes, /steer <text> steers the running turn (or sends when
|
|
2257
|
+
// idle). Idle-only except /steer-with-text and /queue reads, which also run
|
|
2258
|
+
// while busy — that is their entire purpose. Callers gate on busy like
|
|
2259
|
+
// every slash command except /compact (submit's busy branch routes here).
|
|
2260
|
+
function runQueueCommand(raw) {
|
|
2261
|
+
const text = raw.trim();
|
|
2262
|
+
if (text === "/queue") {
|
|
2263
|
+
if (queueRef.current.length === 0 && !steerRef.current) {
|
|
2264
|
+
pushInfo("(queue empty — type + Enter while busy to queue a follow-up)");
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
const lines = queueRef.current.map((q, i) => `${i + 1}. ${q}`);
|
|
2268
|
+
if (steerRef.current)
|
|
2269
|
+
lines.unshift(`(steering now: ${steerRef.current})`);
|
|
2270
|
+
pushInfo(`Queued (${queueRef.current.length}):\n${lines.join("\n")}`);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (text === "/queue clear") {
|
|
2274
|
+
setQueueBoth([]);
|
|
2275
|
+
pushInfo("(queue cleared)");
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (text === "/steer") {
|
|
2279
|
+
pushInfo(STEER_USAGE);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (text.startsWith("/steer ")) {
|
|
2283
|
+
const msg = text.slice("/steer".length).trim();
|
|
2284
|
+
if (!msg) {
|
|
2285
|
+
pushInfo(STEER_USAGE);
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
if (!busyRef.current) {
|
|
2289
|
+
// Idle: steering is just sending (same pipeline as typed input).
|
|
2290
|
+
void submit(msg);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
// Busy: steer the active turn — drained at the next loop step boundary
|
|
2294
|
+
// (see drainSteer). A second steer while one is pending queues behind
|
|
2295
|
+
// it instead of clobbering it.
|
|
2296
|
+
if (steerRef.current) {
|
|
2297
|
+
if (queueRef.current.length >= QUEUE_CAP) {
|
|
2298
|
+
pushInfo(`(queue full — ${QUEUE_CAP} pending; /queue lists, /queue clear wipes)`);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
setQueueBoth([...queueRef.current, msg]);
|
|
2302
|
+
pushInfo(`(steer pending — queued behind it (${queueRef.current.length}))`);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
setSteerPendingBoth(msg);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
pushInfo(QUEUE_USAGE);
|
|
2309
|
+
}
|
|
2310
|
+
// /thinking: rendering-only visibility toggle for model thinking (both
|
|
2311
|
+
// the committed transcript blocks and the live thinking block). Pure
|
|
2312
|
+
// paint — safe while busy (never touches the turn, like /autoscroll).
|
|
2313
|
+
// Bare toggles; anything appended prints usage (there are no arguments).
|
|
2314
|
+
function runThinkingCommand(raw) {
|
|
2315
|
+
if (raw.trim() !== "/thinking") {
|
|
2316
|
+
pushInfo(THINKING_USAGE);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
const next = !showThinkingRef.current;
|
|
2320
|
+
setShowThinkingBoth(next);
|
|
2321
|
+
pushInfo(next
|
|
2322
|
+
? "(thinking shown — model reasoning stays visible in the transcript)"
|
|
2323
|
+
: "(thinking hidden — reasoning still runs, it just isn't rendered)");
|
|
2324
|
+
}
|
|
2325
|
+
// /autoscroll [on|off]: follow switch for the scrollback viewport. View-
|
|
2326
|
+
// only state — safe while busy (never touches the turn, like /queue).
|
|
2327
|
+
// Bare prints the state; on jumps to the latest; off freezes a following
|
|
2328
|
+
// view at its current end (mid-turn appends then accumulate below).
|
|
2329
|
+
function runAutoScrollCommand(raw) {
|
|
2330
|
+
const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
|
|
2331
|
+
if (arg === "") {
|
|
2332
|
+
pushInfo(autoScrollRef.current
|
|
2333
|
+
? "(autoscroll on — following new output as it arrives)"
|
|
2334
|
+
: "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (arg === "on") {
|
|
2338
|
+
if (autoScrollRef.current) {
|
|
2339
|
+
pushInfo("(autoscroll already on)");
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
setAutoScrollBoth(true);
|
|
2343
|
+
setScrollEndBoth(null);
|
|
2344
|
+
pushInfo("(autoscroll on — following the latest)");
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if (arg === "off") {
|
|
2348
|
+
if (!autoScrollRef.current) {
|
|
2349
|
+
pushInfo("(autoscroll already off)");
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
// Confirm FIRST while still following (visible), then flip the switch:
|
|
2353
|
+
// flipping first would freeze this very confirm below the viewport
|
|
2354
|
+
// (appendTurns freezes busy appends once off). Already-held views stay
|
|
2355
|
+
// held; the next busy append freezes a following view via appendTurns.
|
|
2356
|
+
pushInfo("(autoscroll off — the view freezes while a turn runs; End follows the latest)");
|
|
2357
|
+
setAutoScrollBoth(false);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
pushInfo(AUTOSCROLL_USAGE);
|
|
2361
|
+
}
|
|
2362
|
+
// /models: local-discovery status + refresh. Bare `/models` reports the
|
|
2363
|
+
// last snapshot (kicking a first probe when discovery never ran);
|
|
2364
|
+
// `/models refresh` re-probes all three runtimes, then reports. Results
|
|
2365
|
+
// merge into the models cache, so the /model picker serves them with no
|
|
2366
|
+
// extra fetches — one dim summary line, never transcript spam.
|
|
2367
|
+
async function runModelsCommand(arg) {
|
|
2368
|
+
const a = arg.trim().toLowerCase();
|
|
2369
|
+
if (a !== "" && a !== "refresh") {
|
|
2370
|
+
pushInfo("usage: /models [refresh] — probe local model servers (Ollama, LM Studio, llama.cpp).");
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
// Manual Kilo refresh: clear the gateway catalog cache and re-fetch.
|
|
2374
|
+
// The current model sticks when still listed; otherwise the fresh
|
|
2375
|
+
// catalog's preferred free model wins (never a hardcoded id).
|
|
2376
|
+
if (providerRef.current === "kilo" && a === "refresh") {
|
|
2377
|
+
pushInfo("(refreshing Kilo model catalog…)");
|
|
2378
|
+
clearKiloModelsCache();
|
|
2379
|
+
try {
|
|
2380
|
+
const res = await fetchModelsForProviderWithStatus("kilo", keyForProvider("kilo"), "", endpoint);
|
|
2381
|
+
if (res.ok)
|
|
2382
|
+
modelsCacheRef.current.set(modelsCacheKey("kilo"), [...res.models]);
|
|
2383
|
+
setModels(res.models);
|
|
2384
|
+
if (!res.models.includes(modelRef.current)) {
|
|
2385
|
+
setModelBoth(preferFreeKiloModel(res.models, modelRef.current));
|
|
2386
|
+
}
|
|
2387
|
+
pushInfo(`Kilo models refreshed: ${res.models.length} available${res.ok ? "" : " (offline list)"}.`);
|
|
2388
|
+
}
|
|
2389
|
+
catch {
|
|
2390
|
+
pushInfo("Kilo models refresh failed — keeping the current list.");
|
|
2391
|
+
}
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
if (localSnapRef.current.version === 0 || a === "refresh") {
|
|
2395
|
+
pushInfo("(probing local model servers…)");
|
|
2396
|
+
try {
|
|
2397
|
+
const snap = await localDiscoveryRef.current.refresh();
|
|
2398
|
+
applyLocalSnapshot(snap);
|
|
2399
|
+
}
|
|
2400
|
+
catch {
|
|
2401
|
+
// Discovery never rejects (per-provider isolation); defensive only.
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
pushInfo(summarizeLocalSnapshot(localSnapRef.current));
|
|
2405
|
+
}
|
|
1307
2406
|
function runSlashCommand(cmd) {
|
|
1308
2407
|
setInputBoth("");
|
|
1309
2408
|
switch (cmd) {
|
|
@@ -1314,13 +2413,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1314
2413
|
return;
|
|
1315
2414
|
case "/clear":
|
|
1316
2415
|
// Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
|
|
1317
|
-
// fresh env block.
|
|
1318
|
-
historyRef.current = [
|
|
2416
|
+
// fresh env block. Fresh array → fresh ledger via trackHistory.
|
|
2417
|
+
historyRef.current = trackHistory([
|
|
2418
|
+
{ role: "system", content: withEnvBlock(systemPrompt) },
|
|
2419
|
+
]);
|
|
1319
2420
|
setTurnsBoth([]);
|
|
2421
|
+
// List replacement re-follows a held view (the frozen end no longer
|
|
2422
|
+
// exists — render clamping would follow the window but leave a
|
|
2423
|
+
// stale held indicator).
|
|
2424
|
+
setScrollEndBoth(null);
|
|
1320
2425
|
setClearGen((g) => g + 1);
|
|
1321
2426
|
setError(null);
|
|
1322
2427
|
setDraft(null);
|
|
1323
|
-
|
|
2428
|
+
clearThinking();
|
|
1324
2429
|
setToolHint(null);
|
|
1325
2430
|
setPhase("idle");
|
|
1326
2431
|
setPhaseDetail("");
|
|
@@ -1331,11 +2436,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1331
2436
|
// kept: token totals and effort are per-session (see /help).
|
|
1332
2437
|
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
1333
2438
|
skillGrantsRef.current = new Set();
|
|
2439
|
+
// Lineage rule (see src/rollback.ts): the fresh history invalidates
|
|
2440
|
+
// checkpoint marks, so /rewind undos never cross a cleared
|
|
2441
|
+
// conversation. Silent when there was nothing to drop.
|
|
2442
|
+
const clearedDrops = clearSnapshots();
|
|
2443
|
+
if (clearedDrops > 0) {
|
|
2444
|
+
pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
|
|
2445
|
+
}
|
|
1334
2446
|
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
1335
2447
|
lastPromptTokensRef.current = undefined;
|
|
1336
2448
|
setContextLoadBoth(null);
|
|
1337
2449
|
autoStreakRef.current = 0;
|
|
1338
2450
|
pendingCompactRef.current = null;
|
|
2451
|
+
telemetry.recordEvent("clear", "conversation cleared (token totals kept)");
|
|
2452
|
+
persistTelemetry();
|
|
2453
|
+
void refreshSkillMenu();
|
|
1339
2454
|
return;
|
|
1340
2455
|
case "/new":
|
|
1341
2456
|
// Claude-Code semantics: end the current conversation and start
|
|
@@ -1346,18 +2461,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1346
2461
|
// session.json, so no archiving is invented here.
|
|
1347
2462
|
persistSession();
|
|
1348
2463
|
// Fresh system re-read (system.ts base + current AGENTS.md overlay)
|
|
1349
|
-
// plus a fresh Task 6 env block.
|
|
1350
|
-
historyRef.current = [
|
|
2464
|
+
// plus a fresh Task 6 env block. Fresh array → fresh ledger.
|
|
2465
|
+
historyRef.current = trackHistory([
|
|
2466
|
+
{ role: "system", content: withEnvBlock(buildSystemPrompt()) },
|
|
2467
|
+
]);
|
|
1351
2468
|
setTurnsBoth([
|
|
1352
2469
|
{
|
|
1353
2470
|
role: "tool",
|
|
1354
2471
|
content: "(new session started — previous conversation kept, /resume to restore it)",
|
|
1355
2472
|
},
|
|
1356
2473
|
]);
|
|
2474
|
+
// Fresh list: a held view has nothing to hold onto — re-follow.
|
|
2475
|
+
setScrollEndBoth(null);
|
|
1357
2476
|
setClearGen((g) => g + 1);
|
|
1358
2477
|
setError(null);
|
|
1359
2478
|
setDraft(null);
|
|
1360
|
-
|
|
2479
|
+
clearThinking();
|
|
1361
2480
|
setToolHint(null);
|
|
1362
2481
|
setPhase("idle");
|
|
1363
2482
|
setPhaseDetail("");
|
|
@@ -1372,11 +2491,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1372
2491
|
clearTodos();
|
|
1373
2492
|
setTodoSnap([]);
|
|
1374
2493
|
skillGrantsRef.current = new Set();
|
|
2494
|
+
// New lineage (see src/rollback.ts): yesterday's checkpoint marks
|
|
2495
|
+
// cannot index the fresh history — drop them, loudly when non-empty.
|
|
2496
|
+
const newDrops = clearSnapshots();
|
|
2497
|
+
if (newDrops > 0) {
|
|
2498
|
+
pushInfo(`(/new — discarded ${newDrops} file checkpoint(s); undos do not cross sessions)`);
|
|
2499
|
+
}
|
|
1375
2500
|
// Compaction state restarts fresh (unlike /clear, where the thrash
|
|
1376
2501
|
// guard stays disabled for the session).
|
|
1377
2502
|
autoStreakRef.current = 0;
|
|
1378
2503
|
setAutoDisabledBoth(false);
|
|
1379
2504
|
pendingCompactRef.current = null;
|
|
2505
|
+
telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
|
|
2506
|
+
persistTelemetry();
|
|
2507
|
+
void refreshSkillMenu();
|
|
1380
2508
|
return;
|
|
1381
2509
|
case "/compact":
|
|
1382
2510
|
// Bare /compact with no focus text (slash-menu path). Free-text
|
|
@@ -1384,14 +2512,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1384
2512
|
// text survives; both funnel to the same busy/pending logic below.
|
|
1385
2513
|
void runCompactCommand("");
|
|
1386
2514
|
return;
|
|
1387
|
-
case "/model":
|
|
1388
|
-
|
|
2515
|
+
case "/model": {
|
|
2516
|
+
// Unified picker opens unfiltered with the highlight on the current
|
|
2517
|
+
// model (active provider's section first, so same-provider rises
|
|
2518
|
+
// stay index-stable when other keyed providers add sections below).
|
|
2519
|
+
// Late lifecycle: if discovery never ran (slow/no startup probe),
|
|
2520
|
+
// kick it now so local sections fill in behind the open picker.
|
|
2521
|
+
if (localSnapRef.current.version === 0)
|
|
2522
|
+
kickLocalDiscovery();
|
|
2523
|
+
setModelFilterBoth("");
|
|
2524
|
+
const entries = buildModelEntries();
|
|
2525
|
+
const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
|
|
2526
|
+
setSelIndexBoth(Math.max(0, at));
|
|
1389
2527
|
setSelecting(true);
|
|
1390
2528
|
setSelectingEffort(false);
|
|
1391
2529
|
setSelectingProvider(false);
|
|
1392
2530
|
setKeyPromptBoth(null);
|
|
1393
2531
|
setBaseURLPromptBoth(null);
|
|
1394
2532
|
return;
|
|
2533
|
+
}
|
|
1395
2534
|
case "/provider":
|
|
1396
2535
|
openProviderPicker();
|
|
1397
2536
|
return;
|
|
@@ -1407,13 +2546,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1407
2546
|
pushInfo(toolsListText());
|
|
1408
2547
|
return;
|
|
1409
2548
|
case "/skills":
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
2549
|
+
// Searchable picker (names only, type to filter, Enter loads).
|
|
2550
|
+
// Local reads only — zero fetches, like the model picker.
|
|
2551
|
+
openSkillPicker();
|
|
2552
|
+
return;
|
|
2553
|
+
case "/skill":
|
|
2554
|
+
pushInfo(SKILL_USAGE);
|
|
2555
|
+
return;
|
|
2556
|
+
case "/context":
|
|
2557
|
+
pushInfo(buildContextText());
|
|
2558
|
+
return;
|
|
2559
|
+
case "/queue":
|
|
2560
|
+
runQueueCommand("/queue");
|
|
2561
|
+
return;
|
|
2562
|
+
case "/steer":
|
|
2563
|
+
runQueueCommand("/steer");
|
|
2564
|
+
return;
|
|
2565
|
+
case "/thinking":
|
|
2566
|
+
runThinkingCommand("/thinking");
|
|
2567
|
+
return;
|
|
2568
|
+
case "/autoscroll":
|
|
2569
|
+
runAutoScrollCommand("/autoscroll");
|
|
1413
2570
|
return;
|
|
1414
2571
|
case "/mode":
|
|
1415
2572
|
if (modeRef.current === "plan") {
|
|
1416
|
-
pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note;
|
|
2573
|
+
pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
|
|
1417
2574
|
return;
|
|
1418
2575
|
}
|
|
1419
2576
|
pushInfo(trustAllRef.current
|
|
@@ -1424,7 +2581,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1424
2581
|
// Plan is a deliberate safety mode: trust must not punch through it.
|
|
1425
2582
|
// The flag is left untouched so exiting plan restores prior behavior.
|
|
1426
2583
|
if (modeRef.current === "plan") {
|
|
1427
|
-
pushInfo("(plan mode is read-only —
|
|
2584
|
+
pushInfo("(plan mode is read-only — Tab out of plan before /trust; trust unchanged)");
|
|
1428
2585
|
return;
|
|
1429
2586
|
}
|
|
1430
2587
|
const next = !trustAllRef.current;
|
|
@@ -1434,34 +2591,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1434
2591
|
: "trust: off — write/edit/bash ask again");
|
|
1435
2592
|
return;
|
|
1436
2593
|
}
|
|
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
|
-
}
|
|
2594
|
+
case "/yolo":
|
|
1450
2595
|
case "/plan": {
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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.");
|
|
2596
|
+
// Retired: Tab is the only mode switcher (it cycles
|
|
2597
|
+
// normal → yolo → plan → normal). Kept as explicit cases so typing
|
|
2598
|
+
// them explains instead of falling through to skill lookup.
|
|
2599
|
+
pushInfo("(retired — Tab cycles the permission mode: normal → yolo → plan → normal)");
|
|
1465
2600
|
return;
|
|
1466
2601
|
}
|
|
1467
2602
|
case "/help":
|
|
@@ -1477,6 +2612,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1477
2612
|
case "/resume":
|
|
1478
2613
|
doResume();
|
|
1479
2614
|
return;
|
|
2615
|
+
case "/telemetry":
|
|
2616
|
+
pushInfo(telemetrySummaryText());
|
|
2617
|
+
return;
|
|
2618
|
+
case "/dashboard": {
|
|
2619
|
+
const out = writeTelemetryDashboard(authHome);
|
|
2620
|
+
pushInfo(out
|
|
2621
|
+
? `(observability dashboard written to ${out} — open it in a browser. Local file, nothing uploaded.)`
|
|
2622
|
+
: "(dashboard failed to write — telemetry store unavailable)");
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
1480
2625
|
case "/rewind": {
|
|
1481
2626
|
// Idle-only like every slash command except /compact (submit's busy
|
|
1482
2627
|
// guard already routes here only when idle): restoring mid-turn would
|
|
@@ -1514,37 +2659,57 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1514
2659
|
async function approve(name, args) {
|
|
1515
2660
|
if (turnCancelRef.current?.signal.aborted)
|
|
1516
2661
|
throw new LoopCancelledError();
|
|
1517
|
-
|
|
1518
|
-
|
|
2662
|
+
// Stage the transcript-diff preview for write/edit (all outcomes):
|
|
2663
|
+
// the modal below reuses it, and onToolActivity consumes it when the
|
|
2664
|
+
// matching execution commits. Deny/cancel paths clear it (execution
|
|
2665
|
+
// never happens, so nothing must linger for a later call). Full-file
|
|
2666
|
+
// BEFORE is captured here (pre-execution); AFTER resolves at commit
|
|
2667
|
+
// (write content arg, or a post-execution disk read for edit).
|
|
2668
|
+
const stagedDiff = name === "write" || name === "edit" ? previewDiffForApproval(name, args) : null;
|
|
2669
|
+
if (name === "write" || name === "edit") {
|
|
2670
|
+
const toolPath = typeof args["path"] === "string" ? args["path"] : null;
|
|
2671
|
+
const beforeFull = name === "write"
|
|
2672
|
+
? (stagedDiff?.oldText ?? null) // preview already pre-read it: no second read
|
|
2673
|
+
: toolPath !== null
|
|
2674
|
+
? readFileForDiff(path.resolve(process.cwd(), toolPath))
|
|
2675
|
+
: null;
|
|
2676
|
+
const afterArg = name === "write" && typeof args["content"] === "string"
|
|
2677
|
+
? args["content"]
|
|
2678
|
+
: null;
|
|
2679
|
+
pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
|
|
2680
|
+
}
|
|
2681
|
+
// Policy layer owns the decision order (deny → plan → allow → yolo →
|
|
2682
|
+
// trust → always → skill grants → prompt); this function owns cancel
|
|
2683
|
+
// handling and the interactive prompt plumbing around it.
|
|
2684
|
+
const outcome = decidePolicy(name, args, {
|
|
2685
|
+
mode: modeRef.current,
|
|
2686
|
+
trustAll: trustAllRef.current,
|
|
2687
|
+
rules: rulesRef.current,
|
|
2688
|
+
alwaysAllowed: alwaysAllowedRef.current,
|
|
2689
|
+
skillGrants: skillGrantsRef.current,
|
|
2690
|
+
approvalGated: needsApproval(name),
|
|
2691
|
+
});
|
|
2692
|
+
if (outcome.kind === "deny") {
|
|
2693
|
+
pendingDiffRef.current = null;
|
|
1519
2694
|
return "no";
|
|
1520
|
-
|
|
1521
|
-
|
|
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))
|
|
2695
|
+
}
|
|
2696
|
+
if (outcome.kind === "allow")
|
|
1536
2697
|
return "once";
|
|
1537
2698
|
const signal = turnCancelRef.current?.signal ?? null;
|
|
1538
|
-
if (signal?.aborted)
|
|
2699
|
+
if (signal?.aborted) {
|
|
2700
|
+
pendingDiffRef.current = null;
|
|
1539
2701
|
throw new LoopCancelledError();
|
|
2702
|
+
}
|
|
1540
2703
|
return new Promise((resolve, reject) => {
|
|
1541
2704
|
approvalResolveRef.current = { resolve, reject };
|
|
1542
|
-
|
|
2705
|
+
setApproveIndexBoth(0);
|
|
2706
|
+
setPendingApproval({ name, args, diff: stagedDiff });
|
|
1543
2707
|
if (signal) {
|
|
1544
2708
|
const onAbort = () => {
|
|
1545
2709
|
const h = approvalResolveRef.current;
|
|
1546
2710
|
approvalResolveRef.current = null;
|
|
1547
2711
|
setPendingApproval(null);
|
|
2712
|
+
pendingDiffRef.current = null;
|
|
1548
2713
|
h?.reject(new LoopCancelledError());
|
|
1549
2714
|
};
|
|
1550
2715
|
if (signal.aborted)
|
|
@@ -1558,6 +2723,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1558
2723
|
if (decision === "always" && pendingApproval) {
|
|
1559
2724
|
alwaysAllowedRef.current.add(pendingApproval.name);
|
|
1560
2725
|
}
|
|
2726
|
+
if (decision === "no")
|
|
2727
|
+
pendingDiffRef.current = null;
|
|
1561
2728
|
const h = approvalResolveRef.current;
|
|
1562
2729
|
approvalResolveRef.current = null;
|
|
1563
2730
|
setPendingApproval(null);
|
|
@@ -1584,7 +2751,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1584
2751
|
function guardedExecute(name, args) {
|
|
1585
2752
|
if (modeRef.current === "plan" && needsApproval(name)) {
|
|
1586
2753
|
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
|
|
2754
|
+
`Explore with read/grep/glob/web tools, record the plan with todowrite, then Tab out of plan mode to implement.`);
|
|
1588
2755
|
}
|
|
1589
2756
|
return executeTool(name, args);
|
|
1590
2757
|
}
|
|
@@ -1654,8 +2821,56 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1654
2821
|
// SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
|
|
1655
2822
|
// nothing). Busy guard + API-key check: rejections return before any
|
|
1656
2823
|
// history mutation, so there is nothing to roll back.
|
|
1657
|
-
if (!text
|
|
2824
|
+
if (!text)
|
|
2825
|
+
return;
|
|
2826
|
+
// Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
|
|
2827
|
+
// the thought is never lost); /queue + /steer manage and inject. Other
|
|
2828
|
+
// "/" input still needs idle (pickers/modals would race the turn), so it
|
|
2829
|
+
// drops silently exactly as before.
|
|
2830
|
+
if (busyRef.current) {
|
|
2831
|
+
if (text === "/queue" || text.startsWith("/queue ") ||
|
|
2832
|
+
text === "/steer" || text.startsWith("/steer ")) {
|
|
2833
|
+
runQueueCommand(text);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
// /autoscroll and /thinking are view-only state (never touch the
|
|
2837
|
+
// turn), so they run while busy like /queue + /steer (see
|
|
2838
|
+
// slashRunsWhileBusy).
|
|
2839
|
+
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
2840
|
+
runAutoScrollCommand(text);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
2844
|
+
runThinkingCommand(text);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
if (text.startsWith("/"))
|
|
2848
|
+
return;
|
|
2849
|
+
if (queueRef.current.length >= QUEUE_CAP) {
|
|
2850
|
+
pushInfo(`(queue full — ${QUEUE_CAP} pending; /queue lists, /queue clear wipes)`);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
setQueueBoth([...queueRef.current, text]);
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
// Queue + steer routing (idle): exact or free-text forms, mirroring the
|
|
2857
|
+
// /allow pattern above — SLASH_NAMES only holds exact commands.
|
|
2858
|
+
if (text === "/queue" || text.startsWith("/queue ") ||
|
|
2859
|
+
text === "/steer" || text.startsWith("/steer ")) {
|
|
2860
|
+
runQueueCommand(text);
|
|
1658
2861
|
return;
|
|
2862
|
+
}
|
|
2863
|
+
// /autoscroll takes an optional subcommand (/autoscroll on|off), like the
|
|
2864
|
+
// /queue family — SLASH_NAMES only holds the exact command. /thinking
|
|
2865
|
+
// is bare-toggle-only; anything appended prints its usage.
|
|
2866
|
+
if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
|
|
2867
|
+
runAutoScrollCommand(text);
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
2871
|
+
runThinkingCommand(text);
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
1659
2874
|
// Scoped rules (ticket 03): exact or free-text forms (/allow bash:x,
|
|
1660
2875
|
// /rules clear) route with args intact — SLASH_NAMES only holds exact
|
|
1661
2876
|
// commands, and the skill fallback below must not swallow these.
|
|
@@ -1665,21 +2880,54 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1665
2880
|
runRulesCommand(text);
|
|
1666
2881
|
return;
|
|
1667
2882
|
}
|
|
2883
|
+
// /models takes an optional subcommand (/models refresh), like the
|
|
2884
|
+
// /allow family — SLASH_NAMES only holds exact commands.
|
|
2885
|
+
if (text === "/models" || text.startsWith("/models ")) {
|
|
2886
|
+
const arg = text === "/models" ? "" : text.slice("/models ".length);
|
|
2887
|
+
void runModelsCommand(arg);
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
1668
2890
|
// Exact full-command + Enter runs it. A single-token "/name" not in
|
|
1669
|
-
// SLASH_NAMES resolves through the skill registry (ticket 03
|
|
2891
|
+
// SLASH_NAMES resolves through the skill registry (ticket 03, legacy
|
|
2892
|
+
// form — the namespaced `/skill:name` below is canonical); anything
|
|
1670
2893
|
// else starting with "/" still falls through as a model message.
|
|
1671
2894
|
if (SLASH_NAMES.has(text)) {
|
|
1672
2895
|
runSlashCommand(text);
|
|
1673
2896
|
return;
|
|
1674
2897
|
}
|
|
2898
|
+
// Retired commands explain instead of falling through to skill lookup.
|
|
2899
|
+
if (text === "/yolo" || text === "/plan") {
|
|
2900
|
+
runSlashCommand(text);
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
// Namespaced skill invocation: `/skill:name` (bare `/skill` shows usage
|
|
2904
|
+
// via the registry path above). Resolves through the same registry as
|
|
2905
|
+
// the legacy `/name` form and the slash menu.
|
|
2906
|
+
if (text === "/skill" || text === "/skill:") {
|
|
2907
|
+
pushInfo(SKILL_USAGE);
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
const namespaced = /^\/skill:([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
2911
|
+
if (namespaced !== undefined) {
|
|
2912
|
+
void invokeSkillByName(namespaced);
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
1675
2915
|
const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
1676
2916
|
if (skillName !== undefined) {
|
|
1677
2917
|
void invokeSkillByName(skillName);
|
|
1678
2918
|
return;
|
|
1679
2919
|
}
|
|
1680
|
-
// Missing key: guide to /provider instead of POSTing
|
|
1681
|
-
|
|
1682
|
-
|
|
2920
|
+
// Missing key: guide to /provider instead of POSTing (local runtimes
|
|
2921
|
+
// and Kilo need no key — Kilo serves anonymous free models; a
|
|
2922
|
+
// known-unreachable server fails fast with a clear error instead of a
|
|
2923
|
+
// bare connection error mid-turn). Owner-gated (see keyForProvider).
|
|
2924
|
+
const submitKey = keyForProvider(providerRef.current);
|
|
2925
|
+
const submitLocalNotice = localUnreachableNotice(providerRef.current);
|
|
2926
|
+
if (submitLocalNotice) {
|
|
2927
|
+
setError(submitLocalNotice);
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (!submitKey && providerNeedsKey(providerRef.current)) {
|
|
1683
2931
|
setError(`Missing API key for ${providerRef.current} — run /provider to paste one (stored in ~/.atom/auth.json).`);
|
|
1684
2932
|
return;
|
|
1685
2933
|
}
|
|
@@ -1687,7 +2935,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1687
2935
|
setBusy(true);
|
|
1688
2936
|
setError(null);
|
|
1689
2937
|
setDraft(null);
|
|
1690
|
-
|
|
2938
|
+
clearThinking();
|
|
2939
|
+
// Fresh turn, fresh latch: the queue drain at the end auto-sends only
|
|
2940
|
+
// when this turn was NOT cancelled (see the turn-end finally).
|
|
2941
|
+
turnCancelledRef.current = false;
|
|
1691
2942
|
// NOTE: skill grants are NOT cleared here — a manually armed skill
|
|
1692
2943
|
// (loaded while idle) must survive into the turn it was armed for.
|
|
1693
2944
|
// Expiry happens in the turn-end finally below, plus /clear + /new.
|
|
@@ -1703,6 +2954,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1703
2954
|
// Phase 5: start the elapsed/stall timer (status-bar only, never the
|
|
1704
2955
|
// transcript). Cleared in finally below and on unmount.
|
|
1705
2956
|
startTurnTimer();
|
|
2957
|
+
// Display-only tool clock: no tool is running at turn start, so any
|
|
2958
|
+
// stale timestamp from a previous turn must not leak into this one.
|
|
2959
|
+
toolStartRef.current = null;
|
|
2960
|
+
// Same for the transcript-diff slot: a previous turn's unconsumed
|
|
2961
|
+
// preview (cancelled mid-execution) must never attach to this turn.
|
|
2962
|
+
pendingDiffRef.current = null;
|
|
2963
|
+
lastPartialRef.current = "";
|
|
2964
|
+
refreshGitInfo();
|
|
1706
2965
|
// SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
|
|
1707
2966
|
// survives failure). Refresh the pinned env block ONCE per turn (not per
|
|
1708
2967
|
// POST — the loop reuses history[0] for all its POSTs, so this is the
|
|
@@ -1714,12 +2973,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1714
2973
|
// survives failure). History budget at turn start, BEFORE the push +
|
|
1715
2974
|
// rollbackTo capture below (so the existing splice-rollback indices stay
|
|
1716
2975
|
// 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
|
|
2976
|
+
// so the loop core's own budget check stays a no-op on entry - exactly
|
|
1718
2977
|
// one dim notice per truncating turn. /clear drops the notice with the
|
|
1719
|
-
// transcript (usage totals still survive).
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
2978
|
+
// transcript (usage totals still survive). Caps come from the session
|
|
2979
|
+
// ContextManager (window-derived), with the same live todo pinning.
|
|
2980
|
+
contextManager().trimForSend(historyRef.current, (msg) => {
|
|
2981
|
+
appendTurns({ role: "tool", content: `? ${msg}` });
|
|
2982
|
+
}, { messages: 1, chars: text.length }, openTodoNeedles());
|
|
1723
2983
|
// SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
|
|
1724
2984
|
// back on failure). Turn boundary: on POST failure (HTTP/network/empty/
|
|
1725
2985
|
// truncated) the whole user turn (user message plus any partial
|
|
@@ -1729,22 +2989,43 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1729
2989
|
// catch). Cancellation (LoopCancelledError) shares the same splice
|
|
1730
2990
|
// contract.
|
|
1731
2991
|
const rollbackTo = historyRef.current.length;
|
|
2992
|
+
// Local observability: open this turn's trace (no-op when disabled).
|
|
2993
|
+
// Provider/model switches surface here per turn; session-level switches
|
|
2994
|
+
// are derived from the same updates (see setSessionMeta).
|
|
2995
|
+
telemetry.setSessionMeta({ provider: providerRef.current, model: modelRef.current });
|
|
2996
|
+
const telemetryTurnId = telemetry.startTurn(text, {
|
|
2997
|
+
provider: providerRef.current,
|
|
2998
|
+
model: modelRef.current,
|
|
2999
|
+
effort: effortRef.current,
|
|
3000
|
+
mode: modeRef.current,
|
|
3001
|
+
});
|
|
3002
|
+
const telemetrySink = {
|
|
3003
|
+
onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
|
|
3004
|
+
onToolCall: (info) => telemetry.recordToolCall(telemetryTurnId, info),
|
|
3005
|
+
};
|
|
1732
3006
|
const controller = new AbortController();
|
|
1733
3007
|
turnCancelRef.current = controller;
|
|
1734
3008
|
historyRef.current.push({ role: "user", content: text });
|
|
1735
3009
|
appendTurns({ role: "user", content: text });
|
|
1736
|
-
// Skill auto-invoke (ticket 04): deterministic
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
3010
|
+
// Skill auto-invoke (ticket 04, progressive disclosure): deterministic
|
|
3011
|
+
// whole-word match over a fresh registry with a high bar (3 distinct
|
|
3012
|
+
// word hits, at most 1 skill per turn), inside the rollback scope so a
|
|
3013
|
+
// failed turn removes skill context too. Slash invocations skip it
|
|
3014
|
+
// (manual path owns those). Auto loads Tier 2 only (body, no inlined
|
|
3015
|
+
// references, 12KB cap — see activateSkill), so a trigger can never flood
|
|
3016
|
+
// the window. Discovery/loading never throw; the guard only protects
|
|
3017
|
+
// submit itself.
|
|
1740
3018
|
if (!text.startsWith("/")) {
|
|
1741
3019
|
try {
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
3020
|
+
// Registry refresh: stat-level revalidation (cheap), then the pure
|
|
3021
|
+
// deterministic match over metadata — no LLM, no body reads.
|
|
3022
|
+
const found = await skillRegistry.refresh();
|
|
3023
|
+
for (const info of matchSkills(text, resolveSkills(found.skills).skills, {
|
|
3024
|
+
max: 1,
|
|
3025
|
+
minHits: 3,
|
|
3026
|
+
wholeWords: true,
|
|
3027
|
+
})) {
|
|
3028
|
+
await activateSkill(info, { auto: true });
|
|
1748
3029
|
}
|
|
1749
3030
|
}
|
|
1750
3031
|
catch {
|
|
@@ -1752,10 +3033,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1752
3033
|
}
|
|
1753
3034
|
}
|
|
1754
3035
|
try {
|
|
1755
|
-
const baseURL =
|
|
3036
|
+
const baseURL = chatBaseURL(providerRef.current);
|
|
1756
3037
|
const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
|
|
1757
3038
|
approve,
|
|
1758
3039
|
askUser,
|
|
3040
|
+
// Local observability sink: the loop reports completed model/tool
|
|
3041
|
+
// calls (iterations, durations, usage) into the open turn trace.
|
|
3042
|
+
telemetry: telemetrySink,
|
|
3043
|
+
// Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
|
|
3044
|
+
// bottleneck, context growth) attach to the same open turn trace.
|
|
3045
|
+
// Fires once per turn — including failed/cancelled turns, whose
|
|
3046
|
+
// endTurn below still records the outcome alongside these stats.
|
|
3047
|
+
onLoopStats: (s) => telemetry.recordLoopStats(telemetryTurnId, s),
|
|
1759
3048
|
// Plan-mode read-only gate (ticket 04): mutations are refused here
|
|
1760
3049
|
// with a replan note; every other tool delegates to executeTool.
|
|
1761
3050
|
execute: guardedExecute,
|
|
@@ -1769,9 +3058,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1769
3058
|
catch {
|
|
1770
3059
|
setDraft(partial);
|
|
1771
3060
|
}
|
|
3061
|
+
lastPartialRef.current = partial;
|
|
1772
3062
|
noteTurnActivity();
|
|
1773
3063
|
},
|
|
1774
3064
|
onThinking: (partial) => {
|
|
3065
|
+
thinkingRef.current = partial;
|
|
1775
3066
|
setThinking(partial);
|
|
1776
3067
|
noteTurnActivity();
|
|
1777
3068
|
},
|
|
@@ -1780,15 +3071,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1780
3071
|
setPhaseDetail(detail ?? "");
|
|
1781
3072
|
noteTurnActivity();
|
|
1782
3073
|
if (p === "thinking") {
|
|
1783
|
-
// New POST:
|
|
1784
|
-
|
|
3074
|
+
// New POST: the previous round's thinking (if any) commits to
|
|
3075
|
+
// the transcript so it stays in the TUI instead of being
|
|
3076
|
+
// replaced and lost; the fresh round streams into the live block.
|
|
3077
|
+
commitThinking();
|
|
1785
3078
|
}
|
|
1786
3079
|
else if (p === "tool" && detail) {
|
|
1787
3080
|
setToolHint(detail);
|
|
3081
|
+
toolStartRef.current = clockNow();
|
|
1788
3082
|
}
|
|
1789
3083
|
else if (p === "retry") {
|
|
1790
3084
|
const msg = detail ? `↻ retrying… ${detail}` : "↻ retrying…";
|
|
1791
3085
|
appendTurns({ role: "tool", content: msg });
|
|
3086
|
+
// Local observability: transport retries attach to the model call
|
|
3087
|
+
// they precede (the recorder buffers them until it completes).
|
|
3088
|
+
telemetry.recordRetry(telemetryTurnId, detail ?? "");
|
|
1792
3089
|
}
|
|
1793
3090
|
else if (p === "done") {
|
|
1794
3091
|
setToolHint(null);
|
|
@@ -1797,8 +3094,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1797
3094
|
},
|
|
1798
3095
|
onToolDelta: (name) => {
|
|
1799
3096
|
setToolHint(name);
|
|
3097
|
+
toolStartRef.current = clockNow();
|
|
1800
3098
|
},
|
|
1801
3099
|
onUsage: (u) => {
|
|
3100
|
+
// Local observability: per-turn usage accumulates inside the
|
|
3101
|
+
// recorder when the loop reports the completed model call (same
|
|
3102
|
+
// payload) — recording it here too would count every POST twice.
|
|
1802
3103
|
// Cumulative session spend from REAL reports only: every reporting
|
|
1803
3104
|
// POST accumulates (tool-round POSTs and successful retries each
|
|
1804
3105
|
// count once — each was billed; failed attempts report nothing, so
|
|
@@ -1817,6 +3118,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1817
3118
|
if (u.total_tokens !== undefined) {
|
|
1818
3119
|
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
1819
3120
|
}
|
|
3121
|
+
// Prefix-cache counters accumulate like spend (real reports only;
|
|
3122
|
+
// absent fields mean "not reported", never zero).
|
|
3123
|
+
if (u.cacheReadTokens !== undefined) {
|
|
3124
|
+
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
3125
|
+
}
|
|
3126
|
+
if (u.cacheWriteTokens !== undefined) {
|
|
3127
|
+
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
3128
|
+
}
|
|
1820
3129
|
setUsageBoth(next);
|
|
1821
3130
|
},
|
|
1822
3131
|
onReasoning: (label) => {
|
|
@@ -1825,8 +3134,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1825
3134
|
onWarning: (msg) => {
|
|
1826
3135
|
appendTurns({ role: "tool", content: `⚠ ${msg}` });
|
|
1827
3136
|
},
|
|
3137
|
+
// Steering seam: drains one pending /steer message into history +
|
|
3138
|
+
// transcript at each loop step boundary (see drainSteer in zen.ts).
|
|
3139
|
+
// The message joins the turn's fate — a failed turn rolls it back
|
|
3140
|
+
// with everything else (same splice contract as the user turn).
|
|
3141
|
+
drainSteer: () => {
|
|
3142
|
+
const s = steerRef.current;
|
|
3143
|
+
if (!s)
|
|
3144
|
+
return;
|
|
3145
|
+
steerRef.current = null;
|
|
3146
|
+
setSteerPending(null);
|
|
3147
|
+
historyRef.current.push({ role: "user", content: s });
|
|
3148
|
+
appendTurns({ role: "user", content: s });
|
|
3149
|
+
},
|
|
1828
3150
|
onToolActivity: (label, result, isError) => {
|
|
1829
|
-
|
|
3151
|
+
// Display-only duration: wall time since the tool started (see
|
|
3152
|
+
// toolStartRef). Attached as Turn.ms for the `· Ns` suffix; the
|
|
3153
|
+
// label text itself stays byte-identical to the loop's audit line.
|
|
3154
|
+
const started = toolStartRef.current;
|
|
3155
|
+
toolStartRef.current = null;
|
|
3156
|
+
const ms = started !== null ? Math.max(0, clockNow() - started) : 0;
|
|
3157
|
+
const items = [{ role: "tool", content: label, ms }];
|
|
3158
|
+
// Inspector retention (display-only): keep the full result for
|
|
3159
|
+
// later browsing. Capped count; stored text char-capped inside
|
|
3160
|
+
// the record with an explicit truncation flag.
|
|
3161
|
+
toolLogRef.current.push(createToolRecord(toolSeqRef.current++, label, result, isError, ms));
|
|
3162
|
+
if (toolLogRef.current.length > MAX_TOOL_RECORDS) {
|
|
3163
|
+
toolLogRef.current.splice(0, toolLogRef.current.length - MAX_TOOL_RECORDS);
|
|
3164
|
+
}
|
|
1830
3165
|
// Todo tools are session state, not side effects: their results
|
|
1831
3166
|
// are short checklists, so successful ones join the transcript
|
|
1832
3167
|
// (history fidelity — what did the list look like when?) and
|
|
@@ -1843,19 +3178,66 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1843
3178
|
else if (isTodo) {
|
|
1844
3179
|
items.push({ role: "tool", content: result });
|
|
1845
3180
|
}
|
|
3181
|
+
// Committed transcript diff: the approve-time capture for this
|
|
3182
|
+
// exact execution rides on the label turn. Consume-or-clear on
|
|
3183
|
+
// every matching activity (success or failure) so a stale
|
|
3184
|
+
// capture can never leak onto a later call; render only on
|
|
3185
|
+
// success with a real payload (failures keep the ↳ line only).
|
|
3186
|
+
// Full-file BEFORE→AFTER is preferred (aligned panes with
|
|
3187
|
+
// context); when either side is unavailable (unreadable file,
|
|
3188
|
+
// oversize), fall back to the arg-block preview pair.
|
|
3189
|
+
const slot = pendingDiffRef.current;
|
|
3190
|
+
if (slot !== null &&
|
|
3191
|
+
(label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `))) {
|
|
3192
|
+
pendingDiffRef.current = null;
|
|
3193
|
+
if (!isError) {
|
|
3194
|
+
let afterFull = null;
|
|
3195
|
+
if (slot.name === "write") {
|
|
3196
|
+
afterFull = slot.afterArg;
|
|
3197
|
+
}
|
|
3198
|
+
else if (slot.path !== null) {
|
|
3199
|
+
afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
|
|
3200
|
+
}
|
|
3201
|
+
const beforeFull = slot.beforeFull;
|
|
3202
|
+
if (beforeFull !== null && afterFull !== null) {
|
|
3203
|
+
items[0].diff = {
|
|
3204
|
+
oldText: beforeFull,
|
|
3205
|
+
newText: afterFull,
|
|
3206
|
+
lang: slot.diff?.lang ?? null,
|
|
3207
|
+
path: slot.path,
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
else if (slot.diff !== null) {
|
|
3211
|
+
items[0].diff = slot.diff;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
1846
3215
|
appendTurns(...items);
|
|
1847
3216
|
noteTurnActivity();
|
|
1848
3217
|
},
|
|
1849
3218
|
signal: controller.signal,
|
|
3219
|
+
// Window-aware trim caps for this model's real window (the loop
|
|
3220
|
+
// falls back to legacy caps without it — see AgenticOpts.context).
|
|
3221
|
+
context: {
|
|
3222
|
+
model: modelRef.current,
|
|
3223
|
+
toolsChars: TOOLS_SCHEMA_CHARS,
|
|
3224
|
+
},
|
|
1850
3225
|
});
|
|
1851
3226
|
// Turn-end flush: any trailing throttled partial paints before the
|
|
1852
|
-
// commit replaces the draft (byte-exact via `reply` regardless).
|
|
3227
|
+
// commit replaces the draft (byte-exact via `reply` regardless). The
|
|
3228
|
+
// final round's thinking commits first (chronological: reasoning, then
|
|
3229
|
+
// the answer it produced).
|
|
1853
3230
|
flushDraft();
|
|
3231
|
+
commitThinking();
|
|
1854
3232
|
appendTurns({ role: "assistant", content: reply });
|
|
1855
3233
|
// The turn committed to history (final text, denial-as-result, or
|
|
1856
3234
|
// stop-notice) — persist the kill-safe save. Rolled-back turns (catch
|
|
1857
3235
|
// below) never reach here, so a failure can't clobber the last good save.
|
|
1858
3236
|
persistSession();
|
|
3237
|
+
// Local observability: close the turn trace with the loop's own outcome
|
|
3238
|
+
// labels (completed / blocked / unverified / budget-exceeded) and flush.
|
|
3239
|
+
telemetry.endTurn(telemetryTurnId, classifyTurnOutcome(reply), reply);
|
|
3240
|
+
persistTelemetry();
|
|
1859
3241
|
// Drain boundary (still busy, never mid-turn): pending manual /compact
|
|
1860
3242
|
// first (it resets the thrash counter), else auto-compact when the
|
|
1861
3243
|
// load is over threshold. Compaction persists via the normal save path.
|
|
@@ -1884,12 +3266,38 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1884
3266
|
(err instanceof Error && err.name === "LoopCancelledError") ||
|
|
1885
3267
|
controller.signal.aborted;
|
|
1886
3268
|
historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
|
|
3269
|
+
turnCancelledRef.current = cancelled;
|
|
3270
|
+
// The turn never happened: drop live thinking with it (a failed turn
|
|
3271
|
+
// commits nothing — same scope as the history rollback above).
|
|
3272
|
+
clearThinking();
|
|
3273
|
+
// Local observability: failed/cancelled turns still record what was
|
|
3274
|
+
// attempted (model/tool calls so far) with their outcome, then flush.
|
|
3275
|
+
// Like the save above, the telemetry file only ever gains completed
|
|
3276
|
+
// turn traces plus these explicit failure markers — never partial
|
|
3277
|
+
// transcript state.
|
|
3278
|
+
telemetry.endTurn(telemetryTurnId, cancelled ? "cancelled" : "failed", err instanceof Error ? err.message : String(err));
|
|
3279
|
+
persistTelemetry();
|
|
1887
3280
|
if (cancelled) {
|
|
1888
3281
|
// One dim line (tool role renders dim); not an error.
|
|
1889
3282
|
// Rolled back above: no save, the last good save stays intact.
|
|
1890
|
-
|
|
3283
|
+
// The line states the rollback scope outright (see src/rollback.ts):
|
|
3284
|
+
// conversation only — disk and processes were NOT reverted.
|
|
3285
|
+
appendTurns({ role: "tool", content: cancelledTurnLine() });
|
|
1891
3286
|
}
|
|
1892
3287
|
else {
|
|
3288
|
+
// Failed (not cancelled): the streamed answer so far is committed
|
|
3289
|
+
// as a marked partial turn BEFORE the error. Without this, a rate
|
|
3290
|
+
// limit or dead network after 30s of streaming wipes everything the
|
|
3291
|
+
// user already read. History stays rolled back (model never sees
|
|
3292
|
+
// it); only the display transcript keeps the partial.
|
|
3293
|
+
const partial = lastPartialRef.current.trim();
|
|
3294
|
+
lastPartialRef.current = "";
|
|
3295
|
+
if (partial) {
|
|
3296
|
+
appendTurns({
|
|
3297
|
+
role: "assistant",
|
|
3298
|
+
content: `${partial}\n\n(request failed before completing — partial output preserved)`,
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
1893
3301
|
setError(err instanceof Error ? err.message : String(err));
|
|
1894
3302
|
}
|
|
1895
3303
|
// Turn-end drain even after failure/rollback: pending manual still
|
|
@@ -1913,6 +3321,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1913
3321
|
turnCancelRef.current = null;
|
|
1914
3322
|
approvalResolveRef.current = null;
|
|
1915
3323
|
setPendingApproval(null);
|
|
3324
|
+
// Safety net: the slot is normally consumed by onToolActivity or
|
|
3325
|
+
// cleared on deny/cancel — never let it cross a turn boundary.
|
|
3326
|
+
pendingDiffRef.current = null;
|
|
1916
3327
|
askResolveRef.current = null;
|
|
1917
3328
|
setPendingQuestion(null);
|
|
1918
3329
|
setAskCustomBoth("");
|
|
@@ -1922,6 +3333,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1922
3333
|
skillGrantsRef.current = new Set();
|
|
1923
3334
|
busyRef.current = false;
|
|
1924
3335
|
setBusy(false);
|
|
3336
|
+
refreshGitInfo();
|
|
1925
3337
|
try {
|
|
1926
3338
|
draftThrottleRef.current?.cancel();
|
|
1927
3339
|
}
|
|
@@ -1929,13 +3341,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1929
3341
|
// ignore
|
|
1930
3342
|
}
|
|
1931
3343
|
setDraft(null);
|
|
1932
|
-
|
|
3344
|
+
clearThinking();
|
|
1933
3345
|
setToolHint(null);
|
|
1934
3346
|
clearTurnTimer();
|
|
1935
3347
|
setStalled(false);
|
|
1936
3348
|
setElapsedSecs(0);
|
|
1937
3349
|
setPhase("idle");
|
|
1938
3350
|
setPhaseDetail("");
|
|
3351
|
+
// Queue drain (Claude-Code-style): a clean turn auto-sends the next
|
|
3352
|
+
// queued follow-up (chaining while the queue is non-empty); a cancelled
|
|
3353
|
+
// turn keeps its queue visible but never auto-sends. A steer stranded
|
|
3354
|
+
// by a failed/cancelled turn rejoins the queue front — the thought is
|
|
3355
|
+
// preserved, the user decides when it runs. Runs after busy resets
|
|
3356
|
+
// above so the chained submit enters a clean turn.
|
|
3357
|
+
const stranded = steerRef.current;
|
|
3358
|
+
if (stranded) {
|
|
3359
|
+
steerRef.current = null;
|
|
3360
|
+
setSteerPending(null);
|
|
3361
|
+
setQueueBoth([stranded, ...queueRef.current]);
|
|
3362
|
+
}
|
|
3363
|
+
if (!turnCancelledRef.current && queueRef.current.length > 0) {
|
|
3364
|
+
const next = queueRef.current[0];
|
|
3365
|
+
setQueueBoth(queueRef.current.slice(1));
|
|
3366
|
+
void submit(next);
|
|
3367
|
+
}
|
|
1939
3368
|
}
|
|
1940
3369
|
}
|
|
1941
3370
|
function cancelTurn() {
|
|
@@ -1954,6 +3383,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1954
3383
|
const rawCancel = ch === "\u0003" || ch === "\u0004";
|
|
1955
3384
|
const ctrlCancel = (key.ctrl && (ch === "c" || ch === "d" || ch === "C" || ch === "D")) || rawCancel;
|
|
1956
3385
|
if (ctrlCancel) {
|
|
3386
|
+
// Inspector open (idle): Ctrl+C closes it — never exits the app from
|
|
3387
|
+
// inside the inspector.
|
|
3388
|
+
if (inspectingRef.current) {
|
|
3389
|
+
closeInspector();
|
|
3390
|
+
return;
|
|
3391
|
+
}
|
|
1957
3392
|
// Mid-turn: cancel the whole turn (works during POST wait, tool
|
|
1958
3393
|
// execution, and the approval/question modals). Idle: exit as before.
|
|
1959
3394
|
if (turnCancelRef.current || busyRef.current) {
|
|
@@ -1969,11 +3404,32 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1969
3404
|
exit();
|
|
1970
3405
|
return;
|
|
1971
3406
|
}
|
|
1972
|
-
// 1. Tool approval prompt (normal mode):
|
|
3407
|
+
// 1. Tool approval prompt (normal mode): arrows + Enter select
|
|
3408
|
+
// (highlight starts at allow-once), y = once, a = always this
|
|
1973
3409
|
// session, t = trust all write/edit/bash this session, n/Esc = deny
|
|
1974
3410
|
// (denial feeds back into the loop as a result).
|
|
1975
3411
|
// Ctrl+C (handled above) cancels the whole turn instead.
|
|
1976
3412
|
if (pendingApproval) {
|
|
3413
|
+
if (key.upArrow) {
|
|
3414
|
+
setApproveIndexBoth((approveIndexRef.current + 3) % 4);
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
if (key.downArrow) {
|
|
3418
|
+
setApproveIndexBoth((approveIndexRef.current + 1) % 4);
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
if (key.return) {
|
|
3422
|
+
const i = approveIndexRef.current;
|
|
3423
|
+
if (i === 1)
|
|
3424
|
+
resolveApproval("always");
|
|
3425
|
+
else if (i === 2)
|
|
3426
|
+
resolveTrustAll();
|
|
3427
|
+
else if (i === 3)
|
|
3428
|
+
resolveApproval("no");
|
|
3429
|
+
else
|
|
3430
|
+
resolveApproval("once");
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
1977
3433
|
const k = (ch ?? "").toLowerCase();
|
|
1978
3434
|
if (k === "y")
|
|
1979
3435
|
resolveApproval("once");
|
|
@@ -2042,8 +3498,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2042
3498
|
}
|
|
2043
3499
|
if (key.return) {
|
|
2044
3500
|
const draft = kp.draft;
|
|
2045
|
-
if (
|
|
3501
|
+
if (kp.validating || busy)
|
|
2046
3502
|
return;
|
|
3503
|
+
// Kilo keys are optional: empty Enter continues anonymously on
|
|
3504
|
+
// free models (Esc also works — see above).
|
|
3505
|
+
if (!draft) {
|
|
3506
|
+
if (kp.providerId === "kilo") {
|
|
3507
|
+
const keep = keyForProvider(kp.providerId);
|
|
3508
|
+
setKeyPromptBoth(null);
|
|
3509
|
+
void switchProviderWithKey(kp.providerId, keep);
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
2047
3514
|
const baseURL = getStoredBaseURL(authRef.current, kp.providerId);
|
|
2048
3515
|
setKeyPromptBoth({ ...kp, validating: true, error: null });
|
|
2049
3516
|
void validateProviderKey(kp.providerId, draft, baseURL).then((res) => {
|
|
@@ -2149,6 +3616,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2149
3616
|
!getStoredBaseURL(authRef.current, picked.id)) {
|
|
2150
3617
|
openBaseURLPrompt(picked.id);
|
|
2151
3618
|
}
|
|
3619
|
+
else if (picked.id === "kilo" && !keyForProvider(picked.id)) {
|
|
3620
|
+
// Kilo without a key: offer the optional key prompt — anonymous
|
|
3621
|
+
// free models stay one (empty) Enter away inside it.
|
|
3622
|
+
openKeyPrompt(picked.id);
|
|
3623
|
+
}
|
|
3624
|
+
else if (!providerNeedsKey(picked.id)) {
|
|
3625
|
+
// Local runtime (or keyed Kilo): no key to paste — switch
|
|
3626
|
+
// straight in (standard switch path refreshes its model list
|
|
3627
|
+
// in the background).
|
|
3628
|
+
setSelectingProvider(false);
|
|
3629
|
+
void switchProviderWithKey(picked.id, keyForProvider(picked.id));
|
|
3630
|
+
}
|
|
2152
3631
|
else {
|
|
2153
3632
|
// No key -> paste prompt; key on file -> replace prompt
|
|
2154
3633
|
// (masked hint; typing replaces, Esc keeps + switches).
|
|
@@ -2157,34 +3636,119 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2157
3636
|
}
|
|
2158
3637
|
return;
|
|
2159
3638
|
}
|
|
2160
|
-
// 3. Model picker (
|
|
2161
|
-
// returns to plain input,
|
|
3639
|
+
// 3. Model picker (unified cross-provider list, type-to-filter, windowed;
|
|
3640
|
+
// opening it replaces/closes the slash menu; Esc returns to plain input,
|
|
3641
|
+
// never to the slash menu).
|
|
2162
3642
|
if (selecting) {
|
|
3643
|
+
// Rebuilt per keypress from the same render's state the paint uses, so
|
|
3644
|
+
// highlight/filter/paint never disagree mid-tick.
|
|
3645
|
+
const entries = filterModelEntries(buildModelEntries(), modelFilterRef.current);
|
|
2163
3646
|
if (key.upArrow) {
|
|
2164
|
-
|
|
3647
|
+
if (entries.length > 0) {
|
|
3648
|
+
setSelIndexBoth((selIndexRef.current - 1 + entries.length) % entries.length);
|
|
3649
|
+
}
|
|
2165
3650
|
}
|
|
2166
3651
|
else if (key.downArrow) {
|
|
2167
|
-
|
|
3652
|
+
if (entries.length > 0) {
|
|
3653
|
+
setSelIndexBoth((selIndexRef.current + 1) % entries.length);
|
|
3654
|
+
}
|
|
2168
3655
|
}
|
|
2169
3656
|
else if (key.escape) {
|
|
3657
|
+
setModelFilterBoth("");
|
|
2170
3658
|
setSelecting(false);
|
|
2171
3659
|
}
|
|
2172
3660
|
else if (key.return) {
|
|
2173
|
-
const picked =
|
|
3661
|
+
const picked = entries[selIndexRef.current];
|
|
3662
|
+
setModelFilterBoth("");
|
|
2174
3663
|
if (picked) {
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
3664
|
+
if (picked.providerId === providerRef.current) {
|
|
3665
|
+
setModelBoth(picked.model);
|
|
3666
|
+
// Model switch resets the load latch (different tokenizer: the old
|
|
3667
|
+
// reported prompt_tokens no longer measures this context); the
|
|
3668
|
+
// estimate applies until the new model reports.
|
|
3669
|
+
resetContextLoadToEstimate();
|
|
3670
|
+
// Re-gate effort on every /model switch: setting persists, but a
|
|
3671
|
+
// non-Default effort on an unsupported model warns (kept, not sent).
|
|
3672
|
+
if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
|
|
3673
|
+
warnEffortUnsupported(picked.model);
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
else {
|
|
3677
|
+
// Cross-provider pick: switch with the resolved key (env wins,
|
|
3678
|
+
// else stored — remote sections only render for keyed providers;
|
|
3679
|
+
// local sections need no key) and keep the picked model; the
|
|
3680
|
+
// live refresh lands in the background via the standard switch
|
|
3681
|
+
// path. reasoning_effort is zen-only, so a non-Default effort
|
|
3682
|
+
// warns (kept, not sent).
|
|
3683
|
+
const switchedKey = keyForProvider(picked.providerId);
|
|
3684
|
+
if (switchedKey || !providerNeedsKey(picked.providerId)) {
|
|
3685
|
+
const pickedProvider = picked.providerId;
|
|
3686
|
+
const pickedModel = picked.model;
|
|
3687
|
+
void (async () => {
|
|
3688
|
+
await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
|
|
3689
|
+
if (effortRef.current !== "default") {
|
|
3690
|
+
warnEffortUnsupported(pickedModel);
|
|
3691
|
+
}
|
|
3692
|
+
})();
|
|
3693
|
+
}
|
|
2184
3694
|
}
|
|
2185
3695
|
}
|
|
2186
3696
|
setSelecting(false);
|
|
2187
3697
|
}
|
|
3698
|
+
else if (key.backspace || key.delete) {
|
|
3699
|
+
setModelFilterBoth(modelFilterRef.current.slice(0, -1));
|
|
3700
|
+
setSelIndexBoth(0);
|
|
3701
|
+
}
|
|
3702
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
3703
|
+
setModelFilterBoth(modelFilterRef.current + ch);
|
|
3704
|
+
setSelIndexBoth(0);
|
|
3705
|
+
}
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
// 3a. Skills picker (searchable popup, same pattern as /model, but
|
|
3709
|
+
// selection STAGES for confirm: type to filter, ↑/↓ + Enter/Tab puts
|
|
3710
|
+
// `/skill:name` into the input (nothing is sent), Esc cancels.
|
|
3711
|
+
// Backspace edits the filter.
|
|
3712
|
+
if (selectingSkills) {
|
|
3713
|
+
// Rebuilt per keypress from the same render's state the paint uses, so
|
|
3714
|
+
// highlight/filter/paint never disagree mid-tick.
|
|
3715
|
+
const entries = filterSkillPicker(skillPickerItems, skillFilterRef.current);
|
|
3716
|
+
if (key.upArrow) {
|
|
3717
|
+
if (entries.length > 0) {
|
|
3718
|
+
setSkillIndexBoth((skillIndexRef.current - 1 + entries.length) % entries.length);
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
else if (key.downArrow) {
|
|
3722
|
+
if (entries.length > 0) {
|
|
3723
|
+
setSkillIndexBoth((skillIndexRef.current + 1) % entries.length);
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
else if (key.escape) {
|
|
3727
|
+
setSkillFilterBoth("");
|
|
3728
|
+
setSelectingSkills(false);
|
|
3729
|
+
}
|
|
3730
|
+
else if (key.return || key.tab) {
|
|
3731
|
+
const picked = entries[skillIndexRef.current];
|
|
3732
|
+
const name = picked?.name;
|
|
3733
|
+
setSkillFilterBoth("");
|
|
3734
|
+
setSelectingSkills(false);
|
|
3735
|
+
// Stage for confirm, never auto-send: the exact command lands in the
|
|
3736
|
+
// input and a second Enter runs it through the normal submit path
|
|
3737
|
+
// (exact `/skill:name` executes). Model-only entries refuse there,
|
|
3738
|
+
// same as a typed /skill:name.
|
|
3739
|
+
if (name) {
|
|
3740
|
+
exitHistoryBrowse();
|
|
3741
|
+
setInputBoth(`/skill:${name}`);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
else if (key.backspace || key.delete) {
|
|
3745
|
+
setSkillFilterBoth(skillFilterRef.current.slice(0, -1));
|
|
3746
|
+
setSkillIndexBoth(0);
|
|
3747
|
+
}
|
|
3748
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab) {
|
|
3749
|
+
setSkillFilterBoth(skillFilterRef.current + ch);
|
|
3750
|
+
setSkillIndexBoth(0);
|
|
3751
|
+
}
|
|
2188
3752
|
return;
|
|
2189
3753
|
}
|
|
2190
3754
|
// 3b. Effort picker (/effort): same keyboard pattern as the /model
|
|
@@ -2260,12 +3824,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2260
3824
|
}
|
|
2261
3825
|
return;
|
|
2262
3826
|
}
|
|
2263
|
-
// 4. "/" slash menu (filter-as-you-type):
|
|
2264
|
-
//
|
|
3827
|
+
// 4. "/" slash menu (filter-as-you-type): commands first, then matching
|
|
3828
|
+
// skills as namespaced `/skill:name` entries. ↑/↓ + Enter/Tab runs the
|
|
3829
|
+
// highlighted entry, Esc dismisses back to plain input. Single-line only:
|
|
3830
|
+
// a newline anywhere means free text (slash commands never span lines).
|
|
2265
3831
|
const cur = inputRef.current;
|
|
2266
|
-
const
|
|
2267
|
-
?
|
|
2268
|
-
: [];
|
|
3832
|
+
const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
|
|
3833
|
+
? buildSlashMenu(cur, skillMenu)
|
|
3834
|
+
: { items: [], moreSkills: 0 };
|
|
3835
|
+
const matches = menu.items;
|
|
2269
3836
|
if (matches.length > 0) {
|
|
2270
3837
|
if (key.leftArrow) {
|
|
2271
3838
|
setCursorBoth(cursorRef.current - 1);
|
|
@@ -2290,15 +3857,38 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2290
3857
|
}
|
|
2291
3858
|
else if (key.return || key.tab) {
|
|
2292
3859
|
const pick = matches[slashIndexRef.current % matches.length];
|
|
2293
|
-
// /compact
|
|
2294
|
-
//
|
|
2295
|
-
if (pick && (pick.name
|
|
2296
|
-
if (pick.
|
|
3860
|
+
// /compact, /queue, /steer, and /autoscroll run while busy (see
|
|
3861
|
+
// slashRunsWhileBusy); every other entry still waits idle.
|
|
3862
|
+
if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
|
|
3863
|
+
if (pick.skill) {
|
|
3864
|
+
// Skill entries stage for confirm (opencode-style): Enter/Tab
|
|
3865
|
+
// completes `/skill:name` into the input — nothing is sent.
|
|
3866
|
+
// A second Enter on the exact text runs it; a fully typed
|
|
3867
|
+
// `/skill:name` or legacy `/name` runs immediately (unambiguous).
|
|
3868
|
+
const staged = `/skill:${pick.skill}`;
|
|
3869
|
+
const typed = inputRef.current.trim();
|
|
3870
|
+
if (typed === staged || typed === `/${pick.skill}`) {
|
|
3871
|
+
setInputBoth("");
|
|
3872
|
+
void invokeSkillByName(pick.skill);
|
|
3873
|
+
}
|
|
3874
|
+
else {
|
|
3875
|
+
setInputBoth(staged);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
else if (pick.name === "/compact" && inputRef.current.startsWith("/compact ")) {
|
|
2297
3879
|
// Preserve free-text focus when the menu is open on a prefix.
|
|
2298
3880
|
const focus = inputRef.current.slice("/compact".length).trim();
|
|
2299
3881
|
setInputBoth("");
|
|
2300
3882
|
void runCompactCommand(focus);
|
|
2301
3883
|
}
|
|
3884
|
+
else if (pick.name === "/autoscroll" &&
|
|
3885
|
+
(inputRef.current === "/autoscroll" || inputRef.current.startsWith("/autoscroll "))) {
|
|
3886
|
+
// Preserve the on/off arg when the menu is open on a prefix
|
|
3887
|
+
// (bare highlighted name alone would drop it).
|
|
3888
|
+
const raw = inputRef.current;
|
|
3889
|
+
setInputBoth("");
|
|
3890
|
+
runAutoScrollCommand(raw);
|
|
3891
|
+
}
|
|
2302
3892
|
else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
|
|
2303
3893
|
inputRef.current.startsWith(pick.name)) {
|
|
2304
3894
|
// Preserve the typed rule args (e.g. "/allow bash:npm test*");
|
|
@@ -2333,32 +3923,208 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2333
3923
|
cancelTurn();
|
|
2334
3924
|
return;
|
|
2335
3925
|
}
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
//
|
|
3926
|
+
// 4c. Tool-output inspector (read-only: safe in any mode incl. plan).
|
|
3927
|
+
// Ctrl+O toggles when fully idle (no turn, modal, or picker open);
|
|
3928
|
+
// arrows/Enter/Esc drive it while open. Inspector keys never reach the
|
|
3929
|
+
// input, and input keys never reach the inspector.
|
|
3930
|
+
if (key.ctrl && (ch === "o" || ch === "O")) {
|
|
3931
|
+
if (!busyRef.current && !turnCancelRef.current &&
|
|
3932
|
+
!pendingApproval && !pendingQuestion &&
|
|
3933
|
+
!selecting && !selectingSkills && !selectingProvider &&
|
|
3934
|
+
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
3935
|
+
!selectingRewind && !selectingRewindScope) {
|
|
3936
|
+
if (inspectingRef.current)
|
|
3937
|
+
closeInspector();
|
|
3938
|
+
else
|
|
3939
|
+
openInspector();
|
|
3940
|
+
}
|
|
3941
|
+
return;
|
|
3942
|
+
}
|
|
3943
|
+
if (inspectingRef.current) {
|
|
3944
|
+
const last = Math.max(0, toolLogRef.current.length - 1);
|
|
3945
|
+
if (key.escape) {
|
|
3946
|
+
if (inspectExpandedRef.current) {
|
|
3947
|
+
setInspectExpandedBoth(false);
|
|
3948
|
+
setInspectScrollBoth(0);
|
|
3949
|
+
}
|
|
3950
|
+
else {
|
|
3951
|
+
closeInspector();
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
else if (key.return) {
|
|
3955
|
+
setInspectExpandedBoth(!inspectExpandedRef.current);
|
|
3956
|
+
setInspectScrollBoth(0);
|
|
3957
|
+
}
|
|
3958
|
+
else if (key.upArrow) {
|
|
3959
|
+
if (inspectExpandedRef.current) {
|
|
3960
|
+
setInspectScrollBoth(Math.max(0, inspectScrollRef.current - 1));
|
|
3961
|
+
}
|
|
3962
|
+
else if (inspectIndexRef.current > 0) {
|
|
3963
|
+
setInspectIndexBoth(inspectIndexRef.current - 1);
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
else if (key.downArrow) {
|
|
3967
|
+
if (inspectExpandedRef.current) {
|
|
3968
|
+
setInspectScrollBoth(inspectScrollRef.current + 1);
|
|
3969
|
+
}
|
|
3970
|
+
else if (inspectIndexRef.current < last) {
|
|
3971
|
+
setInspectIndexBoth(inspectIndexRef.current + 1);
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
else if (key.pageUp) {
|
|
3975
|
+
if (inspectExpandedRef.current) {
|
|
3976
|
+
setInspectScrollBoth(Math.max(0, inspectScrollRef.current - VIEWPORT_LINES));
|
|
3977
|
+
}
|
|
3978
|
+
else {
|
|
3979
|
+
setInspectIndexBoth(Math.max(0, inspectIndexRef.current - 5));
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
else if (key.pageDown) {
|
|
3983
|
+
if (inspectExpandedRef.current) {
|
|
3984
|
+
setInspectScrollBoth(inspectScrollRef.current + VIEWPORT_LINES);
|
|
3985
|
+
}
|
|
3986
|
+
else {
|
|
3987
|
+
setInspectIndexBoth(Math.min(last, inspectIndexRef.current + 5));
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
// 4d. Command palette (Ctrl+P toggles; Ctrl+K stays kill-to-end).
|
|
3993
|
+
// Opens over idle or busy turns alike (no modal/picker/inspector may be
|
|
3994
|
+
// open); Enter runs through the shared busy-gate, so only
|
|
3995
|
+
// compact/queue/steer fire while busy.
|
|
3996
|
+
if (key.ctrl && (ch === "p" || ch === "P")) {
|
|
3997
|
+
if (!pendingApproval && !pendingQuestion &&
|
|
3998
|
+
!selecting && !selectingSkills && !selectingProvider &&
|
|
3999
|
+
!keyPrompt && !baseURLPrompt && !selectingEffort &&
|
|
4000
|
+
!selectingRewind && !selectingRewindScope && !inspecting) {
|
|
4001
|
+
if (paletteOpenRef.current)
|
|
4002
|
+
closePalette();
|
|
4003
|
+
else
|
|
4004
|
+
openPalette();
|
|
4005
|
+
}
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
if (paletteOpenRef.current) {
|
|
4009
|
+
const entries = paletteEntries(paletteFilterRef.current);
|
|
4010
|
+
if (key.escape) {
|
|
4011
|
+
closePalette();
|
|
4012
|
+
}
|
|
4013
|
+
else if (key.return) {
|
|
4014
|
+
const pick = entries[paletteIndexRef.current];
|
|
4015
|
+
if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
|
|
4016
|
+
const name = pick.name;
|
|
4017
|
+
closePalette();
|
|
4018
|
+
runSlashCommand(name);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
else if (key.upArrow) {
|
|
4022
|
+
if (entries.length > 0) {
|
|
4023
|
+
setPaletteIndexBoth((paletteIndexRef.current - 1 + entries.length) % entries.length);
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
else if (key.downArrow) {
|
|
4027
|
+
if (entries.length > 0) {
|
|
4028
|
+
setPaletteIndexBoth((paletteIndexRef.current + 1) % entries.length);
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
else if (key.backspace) {
|
|
4032
|
+
setPaletteFilterBoth(paletteFilterRef.current.slice(0, -1));
|
|
4033
|
+
setPaletteIndexBoth(0);
|
|
4034
|
+
}
|
|
4035
|
+
else if (ch && !key.ctrl && !key.meta && !key.tab && ch !== "\n") {
|
|
4036
|
+
setPaletteFilterBoth(paletteFilterRef.current + ch);
|
|
4037
|
+
setPaletteIndexBoth(0);
|
|
4038
|
+
}
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
// 5. Plain input (multiline-aware). Tab toggles normal<->yolo here;
|
|
4042
|
+
// when the "/" slash menu is open (section 4 above) Tab instead runs the
|
|
4043
|
+
// highlighted command and never reaches this branch. Enter ALWAYS sends
|
|
4044
|
+
// (even multiline); Ctrl+J (ch "\n") inserts a newline. ↑/↓ move between
|
|
4045
|
+
// lines, falling through to history recall at the first/last line.
|
|
2339
4046
|
if (key.leftArrow) {
|
|
2340
4047
|
setCursorBoth(cursorRef.current - 1);
|
|
2341
4048
|
}
|
|
2342
4049
|
else if (key.rightArrow) {
|
|
2343
4050
|
setCursorBoth(cursorRef.current + 1);
|
|
2344
4051
|
}
|
|
2345
|
-
else if (key.
|
|
2346
|
-
|
|
4052
|
+
else if (key.pageUp) {
|
|
4053
|
+
// Transcript scrollback (idle and busy alike): PgUp holds the view —
|
|
4054
|
+
// the window freezes and the growing draft/thinking blocks collapse
|
|
4055
|
+
// to one static line, so the terminal stops yanking mid-turn and
|
|
4056
|
+
// scrollback stays readable. End (or PgDn at the bottom) follows
|
|
4057
|
+
// again; /clear, /resume, /new, and rewind-truncate re-follow too.
|
|
4058
|
+
setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "pageUp" }));
|
|
4059
|
+
}
|
|
4060
|
+
else if (key.pageDown) {
|
|
4061
|
+
setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "pageDown" }));
|
|
4062
|
+
}
|
|
4063
|
+
else if (key.home && inputRef.current.length === 0) {
|
|
4064
|
+
setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "home" }));
|
|
4065
|
+
}
|
|
4066
|
+
else if (key.end && inputRef.current.length === 0) {
|
|
4067
|
+
setScrollEndBoth(applyScrollAction(scrollEndRef.current, turnsRef.current.length, { kind: "end" }));
|
|
2347
4068
|
}
|
|
2348
|
-
else if (key.
|
|
2349
|
-
|
|
4069
|
+
else if (key.home || (key.ctrl && (ch === "a" || ch === "A"))) {
|
|
4070
|
+
const t = inputRef.current;
|
|
4071
|
+
const { line } = lineColOf(t, cursorRef.current);
|
|
4072
|
+
setCursorBoth(offsetOfLines(splitInputLines(t), line, 0));
|
|
4073
|
+
}
|
|
4074
|
+
else if (key.end || (key.ctrl && (ch === "e" || ch === "E"))) {
|
|
4075
|
+
const t = inputRef.current;
|
|
4076
|
+
const lines = splitInputLines(t);
|
|
4077
|
+
const { line } = lineColOf(t, cursorRef.current);
|
|
4078
|
+
setCursorBoth(offsetOfLines(lines, line, lines[line].length));
|
|
4079
|
+
}
|
|
4080
|
+
else if (key.upArrow) {
|
|
4081
|
+
moveOrRecall(-1);
|
|
4082
|
+
}
|
|
4083
|
+
else if (key.downArrow) {
|
|
4084
|
+
moveOrRecall(1);
|
|
4085
|
+
}
|
|
4086
|
+
else if (ch === "\n" || (key.ctrl && (ch === "j" || ch === "J"))) {
|
|
4087
|
+
insertAtCursor("\n");
|
|
4088
|
+
}
|
|
4089
|
+
else if (key.ctrl && (ch === "k" || ch === "K")) {
|
|
4090
|
+
const r = killToLineEnd(inputRef.current, cursorRef.current);
|
|
4091
|
+
exitHistoryBrowse();
|
|
4092
|
+
setInputAndCursor(r.text, r.offset);
|
|
4093
|
+
}
|
|
4094
|
+
else if (key.ctrl && (ch === "u" || ch === "U")) {
|
|
4095
|
+
const r = killToLineStart(inputRef.current, cursorRef.current);
|
|
4096
|
+
exitHistoryBrowse();
|
|
4097
|
+
setInputAndCursor(r.text, r.offset);
|
|
4098
|
+
}
|
|
4099
|
+
else if (key.ctrl && (ch === "w" || ch === "W")) {
|
|
4100
|
+
const r = killWordBefore(inputRef.current, cursorRef.current);
|
|
4101
|
+
exitHistoryBrowse();
|
|
4102
|
+
setInputAndCursor(r.text, r.offset);
|
|
2350
4103
|
}
|
|
2351
4104
|
else if (key.return) {
|
|
4105
|
+
pushInputHistory(inputRef.current);
|
|
2352
4106
|
void submit(inputRef.current);
|
|
2353
4107
|
}
|
|
2354
4108
|
else if (key.tab) {
|
|
2355
|
-
// Tab
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
//
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
4109
|
+
// Tab is the only mode switcher: normal → yolo → plan → normal.
|
|
4110
|
+
// normal→yolo stays silent (the status line shows it); plan
|
|
4111
|
+
// transitions announce, since entering arms read-only mode and
|
|
4112
|
+
// exiting approves the recorded todowrite plan into implementation
|
|
4113
|
+
// (always landing in normal, never yolo — the checklist survives).
|
|
4114
|
+
const cur = modeRef.current;
|
|
4115
|
+
if (cur === "normal") {
|
|
4116
|
+
setModeBoth("yolo");
|
|
4117
|
+
}
|
|
4118
|
+
else if (cur === "yolo") {
|
|
4119
|
+
setModeBoth("plan");
|
|
4120
|
+
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 Tab to approve + exit into implementation.");
|
|
4121
|
+
}
|
|
4122
|
+
else {
|
|
4123
|
+
setModeBoth("normal");
|
|
4124
|
+
const planned = getTodos().length;
|
|
4125
|
+
pushInfo(planned > 0
|
|
4126
|
+
? `(plan approved — ${planned} task(s) carry into implementation under normal permissions)`
|
|
4127
|
+
: "(plan mode off — no plan recorded)");
|
|
2362
4128
|
}
|
|
2363
4129
|
}
|
|
2364
4130
|
else if (key.delete && !key.backspace) {
|
|
@@ -2374,23 +4140,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2374
4140
|
insertAtCursor(ch);
|
|
2375
4141
|
}
|
|
2376
4142
|
});
|
|
4143
|
+
// Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
|
|
4144
|
+
// including newlines — inserts at the cursor verbatim and NEVER submits,
|
|
4145
|
+
// so multiline pastes can't fire mid-paste. Separate channel from
|
|
4146
|
+
// useInput above. Active exactly when plain input is focused (idle or
|
|
4147
|
+
// busy queue-draft; never inside a modal, picker, or the inspector).
|
|
4148
|
+
usePaste((text) => {
|
|
4149
|
+
insertAtCursor(normalizePaste(text));
|
|
4150
|
+
}, {
|
|
4151
|
+
isActive: !pendingApproval &&
|
|
4152
|
+
!pendingQuestion &&
|
|
4153
|
+
!selecting &&
|
|
4154
|
+
!selectingSkills &&
|
|
4155
|
+
!selectingProvider &&
|
|
4156
|
+
!keyPrompt &&
|
|
4157
|
+
!baseURLPrompt &&
|
|
4158
|
+
!selectingEffort &&
|
|
4159
|
+
!selectingRewind &&
|
|
4160
|
+
!selectingRewindScope &&
|
|
4161
|
+
!inspecting,
|
|
4162
|
+
});
|
|
2377
4163
|
const phaseLabel = phase === "thinking" || phase === "idle"
|
|
2378
|
-
?
|
|
4164
|
+
? `thinking${theme.symbol.ellipsis}`
|
|
2379
4165
|
: phase === "streaming"
|
|
2380
|
-
?
|
|
4166
|
+
? `streaming${theme.symbol.ellipsis}`
|
|
2381
4167
|
: phase === "tool"
|
|
2382
4168
|
? phaseDetail
|
|
2383
|
-
? `calling ${phaseDetail}
|
|
2384
|
-
:
|
|
4169
|
+
? `calling ${phaseDetail}${theme.symbol.ellipsis}`
|
|
4170
|
+
: `tool${theme.symbol.ellipsis}`
|
|
2385
4171
|
: phase === "retry"
|
|
2386
4172
|
? phaseDetail
|
|
2387
|
-
? `retrying
|
|
2388
|
-
:
|
|
4173
|
+
? `retrying${theme.symbol.ellipsis} ${phaseDetail}`
|
|
4174
|
+
: `retrying${theme.symbol.ellipsis}`
|
|
2389
4175
|
: phase === "done"
|
|
2390
4176
|
? "done"
|
|
2391
|
-
:
|
|
2392
|
-
// Slash menu derived for render (mirrors the useInput computation above)
|
|
2393
|
-
|
|
4177
|
+
: `thinking${theme.symbol.ellipsis}`;
|
|
4178
|
+
// Slash menu derived for render (mirrors the useInput computation above):
|
|
4179
|
+
// commands first, then matching skills as `/skill:name` entries.
|
|
4180
|
+
const slashMenu = !selecting &&
|
|
4181
|
+
!selectingSkills &&
|
|
2394
4182
|
!selectingEffort &&
|
|
2395
4183
|
!selectingProvider &&
|
|
2396
4184
|
!keyPrompt &&
|
|
@@ -2400,15 +4188,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2400
4188
|
!selectingRewind &&
|
|
2401
4189
|
!selectingRewindScope &&
|
|
2402
4190
|
!slashDismissed &&
|
|
2403
|
-
input.startsWith("/")
|
|
2404
|
-
|
|
2405
|
-
|
|
4191
|
+
input.startsWith("/") &&
|
|
4192
|
+
!input.includes("\n")
|
|
4193
|
+
? buildSlashMenu(input, skillMenu)
|
|
4194
|
+
: { items: [], moreSkills: 0 };
|
|
4195
|
+
const filteredSlash = slashMenu.items;
|
|
2406
4196
|
const slashVisible = filteredSlash.length > 0;
|
|
2407
|
-
const
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
4197
|
+
const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
|
|
4198
|
+
const slashHighlight = filteredSlash.length > 0 ? filteredSlash[slashHi]?.name : undefined;
|
|
4199
|
+
const slashWin = pickerWindow(filteredSlash.length, slashHi);
|
|
4200
|
+
const slashHasSkills = filteredSlash.some((c) => c.skill !== undefined);
|
|
4201
|
+
// Argument hint for the highlighted command (reused usage strings only).
|
|
4202
|
+
const slashUsage = filteredSlash.length > 0 && filteredSlash[slashHi]?.skill === undefined
|
|
4203
|
+
? commandUsage(filteredSlash[slashHi].name)
|
|
4204
|
+
: null;
|
|
4205
|
+
// Unified /model picker derived for render (mirrors the useInput
|
|
4206
|
+
// computation above): full entries, filtered entries, clamped highlight,
|
|
4207
|
+
// and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
|
|
4208
|
+
// rows no matter how many models providers list.
|
|
4209
|
+
const modelEntriesAll = selecting ? buildModelEntries() : [];
|
|
4210
|
+
const modelEntries = selecting ? filterModelEntries(modelEntriesAll, modelFilter) : [];
|
|
4211
|
+
const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
|
|
4212
|
+
const modelWin = pickerWindow(modelEntries.length, modelHi);
|
|
4213
|
+
const modelTitle = `Atom — Select model (${modelEntries.length}` +
|
|
4214
|
+
(modelFilter ? ` of ${modelEntriesAll.length}, filter: "${modelFilter}"` : "") +
|
|
4215
|
+
`) — type to filter, up/down + Enter, Esc cancels:`;
|
|
4216
|
+
// /skills picker derived for render (mirrors the useInput computation
|
|
4217
|
+
// above): names only, filtered, clamped highlight, visible window. The
|
|
4218
|
+
// title keeps the `Skills (` prefix the registry header always had.
|
|
4219
|
+
const skillEntriesAll = selectingSkills ? skillPickerItems : [];
|
|
4220
|
+
const skillEntries = selectingSkills ? filterSkillPicker(skillEntriesAll, skillFilter) : [];
|
|
4221
|
+
const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
|
|
4222
|
+
const skillWin = pickerWindow(skillEntries.length, skillHi);
|
|
4223
|
+
const skillTitle = `Skills (${skillEntries.length}` +
|
|
4224
|
+
(skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
|
|
4225
|
+
`) — type to filter, up/down + Enter, Esc cancels:`;
|
|
4226
|
+
// Memoized render derivations (flicker fix): these rebuild arrays on every
|
|
4227
|
+
// App render (token paints, keystrokes, 1s ticks), which defeats the memo
|
|
4228
|
+
// on the leaf panels below. Memoized, the leaves skip everything but real
|
|
4229
|
+
// changes. Checkpoint listing reads the snapshot dir — never per frame.
|
|
4230
|
+
const paletteEntriesMemo = useMemo(() => (paletteOpen ? paletteEntries(paletteFilter) : []), [paletteOpen, paletteFilter]);
|
|
4231
|
+
const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
|
|
4232
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4233
|
+
[selectingRewind]);
|
|
4234
|
+
// (The cursor clamp lives inside the memoized InputBox now, next to its
|
|
4235
|
+
// only use — App body no longer reads cursor state for paint.)
|
|
2412
4236
|
// Status-line reasoning segment wired to the effort session state:
|
|
2413
4237
|
// non-Default shows the effort (plus " (unsupported)" when the model is
|
|
2414
4238
|
// outside the verified-support set OR the provider is not opencode-zen);
|
|
@@ -2421,8 +4245,44 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2421
4245
|
? effort
|
|
2422
4246
|
: `${effort} (unsupported)`
|
|
2423
4247
|
: (reasoning ?? "default");
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
4248
|
+
// Display-only live tool elapsed: wall-clock now ≈ turn start + elapsed
|
|
4249
|
+
// ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
|
|
4250
|
+
// tool is running — the running line then paints with no duration.
|
|
4251
|
+
const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
|
|
4252
|
+
? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
|
|
4253
|
+
: null;
|
|
4254
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: describeToolCall(pendingApproval.name, pendingApproval.args), selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
|
|
4255
|
+
const i = modelWin.start + k;
|
|
4256
|
+
const entryLocal = e.local === true;
|
|
4257
|
+
const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
|
|
4258
|
+
const showGroup = i === 0 || prevLocal !== entryLocal;
|
|
4259
|
+
const showHeader = showGroup || modelEntries[i - 1]?.providerId !== e.providerId;
|
|
4260
|
+
const def = getProvider(e.providerId);
|
|
4261
|
+
return (_jsxs(React.Fragment, { children: [showGroup ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", entryLocal ? "Local" : "Remote"] })) : null, showHeader ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", def?.name ?? e.providerId, entryLocal && isLocalProviderId(e.providerId)
|
|
4262
|
+
? ` ${theme.symbol.separator} ${localBaseURLFor(e.providerId)}`
|
|
4263
|
+
: null, e.providerId === provider ? " (current)" : ""] })) : null, _jsxs(PickerRow, { highlighted: i === modelHi, children: [e.model, e.free === true ? _jsx(Text, { dimColor: true, children: " (free)" }) : null, e.providerId === provider && e.model === model ? " (current)" : ""] })] }, `${e.providerId}-${e.model}-${i}`));
|
|
4264
|
+
}), _jsx(PickerMoreBelow, { count: modelEntries.length - modelWin.end }), modelEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models match \u2014 backspace to widen the filter." })) : null] })) : selectingSkills ? (_jsxs(PickerShell, { title: skillTitle, children: [_jsx(PickerMoreAbove, { count: skillWin.start }), skillEntries.slice(skillWin.start, skillWin.end).map((e, k) => {
|
|
4265
|
+
const i = skillWin.start + k;
|
|
4266
|
+
return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
|
|
4267
|
+
}), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
|
|
4268
|
+
? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
|
|
4269
|
+
: "No skills match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
|
|
4270
|
+
const has = keyForProvider(p.id).length > 0;
|
|
4271
|
+
const keyMark = isLocalProviderId(p.id)
|
|
4272
|
+
? `local ${theme.symbol.descSeparator} no key needed`
|
|
4273
|
+
: has
|
|
4274
|
+
? `${theme.symbol.keyPresent} key`
|
|
4275
|
+
: p.id === "kilo"
|
|
4276
|
+
? `${theme.symbol.descSeparator} key optional — free models need none`
|
|
4277
|
+
: `${theme.symbol.descSeparator} no key`;
|
|
4278
|
+
return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
|
|
4279
|
+
}) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "default" ? "Default" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Top is Max (sent as max); xhigh is not a verified value." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
|
|
4280
|
+
// The input is the one boxed, prominent surface (see the memoized
|
|
4281
|
+
// InputBox above): a quiet gray frame sets it apart from the
|
|
4282
|
+
// transcript above and the status line below. Pickers and modals
|
|
4283
|
+
// replace it (never stack with it), each carrying their own semantic
|
|
4284
|
+
// border color.
|
|
4285
|
+
_jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
|
|
4286
|
+
? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
|
|
4287
|
+
: `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBar, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, columns: termColumns })] }));
|
|
2428
4288
|
}
|