zidane 4.1.8 → 5.0.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 +11 -2
- package/dist/{index-bgh-k8Mv.d.ts → agent-JhicgLOV.d.ts} +2082 -1969
- package/dist/agent-JhicgLOV.d.ts.map +1 -0
- package/dist/chat.d.ts +340 -9
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +2 -2
- package/dist/contexts.d.ts +1 -1
- package/dist/{index-DRoG_udt.d.ts → index-2yLUyTbc.d.ts} +34 -4
- package/dist/{index-DRoG_udt.d.ts.map → index-2yLUyTbc.d.ts.map} +1 -1
- package/dist/{index-BB4kuRh3.d.ts → index-CXVvqTQj.d.ts} +1 -1
- package/dist/{index-BB4kuRh3.d.ts.map → index-CXVvqTQj.d.ts.map} +1 -1
- package/dist/{index-Ds5YpvfZ.d.ts → index-t_W9i7Ql.d.ts} +9 -4
- package/dist/index-t_W9i7Ql.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +6 -6
- package/dist/{interpolate-CukJwP2G.js → interpolate-Ck970-61.js} +11 -2
- package/dist/interpolate-Ck970-61.js.map +1 -0
- package/dist/{mcp-8wClKY-3.js → mcp-Dw-fRPVk.js} +61 -65
- package/dist/mcp-Dw-fRPVk.js.map +1 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/presets-BRFH2qsQ.js +90 -0
- package/dist/presets-BRFH2qsQ.js.map +1 -0
- package/dist/presets.d.ts +3 -2
- package/dist/presets.js +2 -2
- package/dist/providers.d.ts +1 -1
- package/dist/session/sqlite.d.ts +13 -2
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +96 -38
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-Cn68UASv.js → session-791hhrFa.js} +65 -30
- package/dist/session-791hhrFa.js.map +1 -0
- package/dist/session.d.ts +1 -1
- package/dist/session.js +1 -1
- package/dist/skills.d.ts +2 -2
- package/dist/skills.js +1 -1
- package/dist/{stats-BT9l57RS.js → stats-DZIsGqzu.js} +15 -5
- package/dist/stats-DZIsGqzu.js.map +1 -0
- package/dist/theme-pJv47erq.d.ts +1202 -0
- package/dist/theme-pJv47erq.d.ts.map +1 -0
- package/dist/{tools-C8kDot0H.js → tools-CLazLRb4.js} +475 -318
- package/dist/tools-CLazLRb4.js.map +1 -0
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +1 -1
- package/dist/tui.d.ts +303 -18
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +3305 -509
- package/dist/tui.js.map +1 -1
- package/dist/turn-operations-5aQu4dJg.js +3587 -0
- package/dist/turn-operations-5aQu4dJg.js.map +1 -0
- package/dist/types.d.ts +3 -3
- package/dist/types.js +1 -1
- package/package.json +6 -1
- package/dist/index-Ds5YpvfZ.d.ts.map +0 -1
- package/dist/index-bgh-k8Mv.d.ts.map +0 -1
- package/dist/interpolate-CukJwP2G.js.map +0 -1
- package/dist/mcp-8wClKY-3.js.map +0 -1
- package/dist/presets-BzkJDW1K.js +0 -39
- package/dist/presets-BzkJDW1K.js.map +0 -1
- package/dist/session-Cn68UASv.js.map +0 -1
- package/dist/stats-BT9l57RS.js.map +0 -1
- package/dist/theme-BlXO6yHe.d.ts +0 -503
- package/dist/theme-BlXO6yHe.d.ts.map +0 -1
- package/dist/theme-context-MungM3SY.js +0 -1713
- package/dist/theme-context-MungM3SY.js.map +0 -1
- package/dist/tools-C8kDot0H.js.map +0 -1
|
@@ -0,0 +1,3587 @@
|
|
|
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-CLazLRb4.js";
|
|
2
|
+
import { n as toolResultToText } from "./types-Bx_F8jet.js";
|
|
3
|
+
import { r as normalizeMcpServers } from "./mcp-Dw-fRPVk.js";
|
|
4
|
+
import { a as discoverSkills } from "./interpolate-Ck970-61.js";
|
|
5
|
+
import { n as formatTokenUsage } from "./stats-DZIsGqzu.js";
|
|
6
|
+
import { n as definePreset } from "./presets-BRFH2qsQ.js";
|
|
7
|
+
import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CCDvIXGJ.js";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
|
14
|
+
import { getModel, getModels } from "@mariozechner/pi-ai";
|
|
15
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { jsx } from "react/jsx-runtime";
|
|
17
|
+
//#region src/chat/agents.ts
|
|
18
|
+
/** Read-only tool slice shared by the Plan profile and any host-built read-only variant. */
|
|
19
|
+
const READ_ONLY_TOOLS = {
|
|
20
|
+
readFile: readFile$1,
|
|
21
|
+
listFiles,
|
|
22
|
+
glob,
|
|
23
|
+
grep
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Build agent — the default profile. Full read/write/shell access plus
|
|
27
|
+
* subagent spawning. Mirrors the legacy `basic` preset so existing TUI
|
|
28
|
+
* behavior is preserved when `agents` is omitted.
|
|
29
|
+
*/
|
|
30
|
+
const BUILD_AGENT = {
|
|
31
|
+
id: "build",
|
|
32
|
+
label: "Build",
|
|
33
|
+
description: "full tool access — read, write, edit, shell, and spawn subagents",
|
|
34
|
+
accent: "accent",
|
|
35
|
+
preset: definePreset({
|
|
36
|
+
name: "build",
|
|
37
|
+
system: "You are a helpful coding assistant with full access to the workspace: shell, file reading, file writing, surgical and multi-edit tools, directory listing, codebase search, and sub-agent spawning. Prefer `edit` / `multi_edit` for in-place changes and `write_file` for full file overwrites. Spawn subagents for parallelizable work.",
|
|
38
|
+
tools: {
|
|
39
|
+
shell,
|
|
40
|
+
readFile: readFile$1,
|
|
41
|
+
writeFile: writeFile$1,
|
|
42
|
+
listFiles,
|
|
43
|
+
edit,
|
|
44
|
+
multiEdit,
|
|
45
|
+
glob,
|
|
46
|
+
grep,
|
|
47
|
+
spawn: createSpawnTool({ persist: true })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Plan agent — read-only exploration mode. Locked down to file reading and
|
|
53
|
+
* codebase search; no shell, no edits, no spawning. The system prompt
|
|
54
|
+
* frames the conversation as planning rather than execution so the model
|
|
55
|
+
* outputs proposals instead of attempting mutations.
|
|
56
|
+
*/
|
|
57
|
+
const PLAN_AGENT = {
|
|
58
|
+
id: "plan",
|
|
59
|
+
label: "Plan",
|
|
60
|
+
description: "read-only — explore, analyze, and propose without modifying anything",
|
|
61
|
+
accent: "model",
|
|
62
|
+
preset: definePreset({
|
|
63
|
+
name: "plan",
|
|
64
|
+
system: "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 the problem, and propose a clear plan in detail. The user will switch to Build mode to execute it.",
|
|
65
|
+
tools: READ_ONLY_TOOLS
|
|
66
|
+
})
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Default registry shipped with `zidane/tui`. Insertion order = picker order.
|
|
70
|
+
* Hosts that want only one profile can pass `{ agents: { build: BUILD_AGENT } }`,
|
|
71
|
+
* or a fully custom registry of their own profiles.
|
|
72
|
+
*/
|
|
73
|
+
const BUILTIN_AGENTS = {
|
|
74
|
+
build: BUILD_AGENT,
|
|
75
|
+
plan: PLAN_AGENT
|
|
76
|
+
};
|
|
77
|
+
/** Id of the profile activated on first launch when nothing is persisted. */
|
|
78
|
+
const DEFAULT_AGENT_ID = "build";
|
|
79
|
+
/**
|
|
80
|
+
* Resolve an agent id against a registry with sensible fallbacks: the
|
|
81
|
+
* requested id wins when present, then `defaultId`, then the first key.
|
|
82
|
+
* Returns `null` when the registry is empty (host misconfiguration —
|
|
83
|
+
* the caller should surface this rather than silently failing).
|
|
84
|
+
*/
|
|
85
|
+
function resolveAgentId(registry, requestedId, defaultId) {
|
|
86
|
+
if (requestedId && registry[requestedId]) return requestedId;
|
|
87
|
+
if (defaultId && registry[defaultId]) return defaultId;
|
|
88
|
+
return Object.keys(registry)[0] ?? null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Wrap a legacy single `Preset` into a single-profile registry. Used by
|
|
92
|
+
* `resolveConfig` when the host passed `preset` without `agents`, so older
|
|
93
|
+
* call sites keep working — they get a one-entry registry whose picker is
|
|
94
|
+
* a no-op (only one option).
|
|
95
|
+
*/
|
|
96
|
+
function singleAgentRegistry(preset) {
|
|
97
|
+
return { default: {
|
|
98
|
+
id: "default",
|
|
99
|
+
label: typeof preset.name === "string" && preset.name.length > 0 ? preset.name : "Default",
|
|
100
|
+
description: "host-provided preset",
|
|
101
|
+
preset,
|
|
102
|
+
accent: "accent"
|
|
103
|
+
} };
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/chat/providers.ts
|
|
107
|
+
/** Convenience accessor — returns `credentialFileKey ?? key`. */
|
|
108
|
+
function credKeyOf(desc) {
|
|
109
|
+
return desc.credentialFileKey ?? desc.key;
|
|
110
|
+
}
|
|
111
|
+
/** Convenience accessor — returns `piProviderId ?? key`. */
|
|
112
|
+
function piIdOf(desc) {
|
|
113
|
+
return desc.piProviderId ?? desc.key;
|
|
114
|
+
}
|
|
115
|
+
const anthropicDescriptor = {
|
|
116
|
+
key: "anthropic",
|
|
117
|
+
label: "Anthropic",
|
|
118
|
+
factory: anthropic,
|
|
119
|
+
defaultModel: "claude-opus-4-7",
|
|
120
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
121
|
+
apiKeyPlaceholder: "sk-ant-…",
|
|
122
|
+
oauthProvider: anthropicOAuthProvider,
|
|
123
|
+
oauthHint: "Claude Pro/Max subscription"
|
|
124
|
+
};
|
|
125
|
+
const openaiDescriptor = {
|
|
126
|
+
key: "openai",
|
|
127
|
+
label: "OpenAI Codex",
|
|
128
|
+
factory: openai,
|
|
129
|
+
defaultModel: "gpt-5.4",
|
|
130
|
+
envKey: "OPENAI_CODEX_API_KEY",
|
|
131
|
+
credentialFileKey: "openai-codex",
|
|
132
|
+
piProviderId: "openai-codex",
|
|
133
|
+
apiKeyPlaceholder: "sk-… or eyJ… (Codex)",
|
|
134
|
+
oauthProvider: openaiCodexOAuthProvider
|
|
135
|
+
};
|
|
136
|
+
const openrouterDescriptor = {
|
|
137
|
+
key: "openrouter",
|
|
138
|
+
label: "OpenRouter",
|
|
139
|
+
factory: openrouter,
|
|
140
|
+
defaultModel: "anthropic/claude-sonnet-4-6",
|
|
141
|
+
envKey: "OPENROUTER_API_KEY",
|
|
142
|
+
apiKeyPlaceholder: "sk-or-…"
|
|
143
|
+
};
|
|
144
|
+
const cerebrasDescriptor = {
|
|
145
|
+
key: "cerebras",
|
|
146
|
+
label: "Cerebras",
|
|
147
|
+
factory: cerebras,
|
|
148
|
+
defaultModel: "zai-glm-4.7",
|
|
149
|
+
envKey: "CEREBRAS_API_KEY",
|
|
150
|
+
apiKeyPlaceholder: "csk-…"
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Default provider registry. Passed verbatim when `runTui` is invoked without
|
|
154
|
+
* an explicit `providers` option. Hosts that want to override per-provider
|
|
155
|
+
* metadata can spread this and replace specific entries:
|
|
156
|
+
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* runTui({ providers: { ...BUILTIN_PROVIDERS, anthropic: myOwnAnthropicDescriptor } })
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
const BUILTIN_PROVIDERS = {
|
|
162
|
+
anthropic: anthropicDescriptor,
|
|
163
|
+
openai: openaiDescriptor,
|
|
164
|
+
openrouter: openrouterDescriptor,
|
|
165
|
+
cerebras: cerebrasDescriptor
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the model list for a given provider. Honors `descriptor.models`
|
|
169
|
+
* when set; otherwise queries pi-ai via `descriptor.piProviderId`. Returns
|
|
170
|
+
* `[]` for descriptors with no known mapping (custom providers without a
|
|
171
|
+
* model list) — callers should hide the model picker in that case.
|
|
172
|
+
*/
|
|
173
|
+
function modelsForDescriptor(descriptor) {
|
|
174
|
+
if (descriptor.models) return descriptor.models;
|
|
175
|
+
try {
|
|
176
|
+
return getModels(piIdOf(descriptor));
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Look up the model's max context window via the descriptor's model source.
|
|
183
|
+
* Returns `null` when the model isn't known (custom slugs, providers without
|
|
184
|
+
* a registry); callers should hide the context indicator in that case.
|
|
185
|
+
*/
|
|
186
|
+
function getContextWindow(descriptor, modelId) {
|
|
187
|
+
if (descriptor.models) return descriptor.models.find((m) => m.id === modelId)?.contextWindow ?? null;
|
|
188
|
+
try {
|
|
189
|
+
return getModel(piIdOf(descriptor), modelId)?.contextWindow ?? null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/chat/credentials.ts
|
|
196
|
+
/** POSIX mode for the credentials file. Ignored on Windows. */
|
|
197
|
+
const FILE_MODE = 384;
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the credentials file path given the resolved TUI data directory
|
|
200
|
+
* (typically `~/.zidane`, i.e. `config.paths.dir`).
|
|
201
|
+
*
|
|
202
|
+
* Matches the convention used elsewhere in the TUI (sessions.db, state.json)
|
|
203
|
+
* so a single `ZIDANE_STORAGE_DIR` override moves the entire data root.
|
|
204
|
+
*/
|
|
205
|
+
function credentialsPath(dataDir) {
|
|
206
|
+
return resolve(dataDir, "credentials.json");
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Read credentials from disk.
|
|
210
|
+
*
|
|
211
|
+
* Returns `{}` when the file is missing or corrupt (last-ditch tolerance —
|
|
212
|
+
* a hand-edit gone wrong shouldn't lock the user out of re-authing). On first
|
|
213
|
+
* call with no file present, attempts a migration from `cwd/.credentials.json`
|
|
214
|
+
* (the legacy location used by `bun run auth`).
|
|
215
|
+
*/
|
|
216
|
+
function readCredentials(dataDir) {
|
|
217
|
+
const path = credentialsPath(dataDir);
|
|
218
|
+
if (!existsSync(path)) {
|
|
219
|
+
const migrated = migrateLegacyFile(path);
|
|
220
|
+
if (migrated) return migrated;
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const raw = readFileSync(path, "utf-8");
|
|
225
|
+
const parsed = JSON.parse(raw);
|
|
226
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
227
|
+
return parsed;
|
|
228
|
+
} catch {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Read a single provider's credential (translating via the descriptor). */
|
|
233
|
+
function readProviderCredential(dataDir, descriptor) {
|
|
234
|
+
return readCredentials(dataDir)[credKeyOf(descriptor)];
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Write credentials atomically (write-then-rename) with mode 0o600.
|
|
238
|
+
*
|
|
239
|
+
* Atomic on the same filesystem — readers either see the previous file or the
|
|
240
|
+
* new one, never a half-written intermediate. Creates the parent dir if needed
|
|
241
|
+
* (first launch on a fresh machine: `~/.zidane/` may not exist yet).
|
|
242
|
+
*/
|
|
243
|
+
function writeCredentials(dataDir, creds) {
|
|
244
|
+
const path = credentialsPath(dataDir);
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
246
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
247
|
+
writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE });
|
|
248
|
+
renameSync(tmp, path);
|
|
249
|
+
}
|
|
250
|
+
function setProviderCredential(dataDir, descriptor, cred) {
|
|
251
|
+
const all = readCredentials(dataDir);
|
|
252
|
+
all[credKeyOf(descriptor)] = cred;
|
|
253
|
+
writeCredentials(dataDir, all);
|
|
254
|
+
}
|
|
255
|
+
function removeProviderCredential(dataDir, descriptor) {
|
|
256
|
+
const all = readCredentials(dataDir);
|
|
257
|
+
const fileKey = credKeyOf(descriptor);
|
|
258
|
+
if (!(fileKey in all)) return;
|
|
259
|
+
delete all[fileKey];
|
|
260
|
+
writeCredentials(dataDir, all);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Inject API-key credentials into `process.env` so the harness providers pick
|
|
264
|
+
* them up via their existing env-var resolution. Called once at TUI launch
|
|
265
|
+
* after the credentials file has been resolved. OAuth credentials are NOT
|
|
266
|
+
* injected — those reach providers via `ZIDANE_CREDENTIALS_PATH` + the file
|
|
267
|
+
* reader in `src/providers/oauth.ts`.
|
|
268
|
+
*
|
|
269
|
+
* Does not overwrite env vars that are already set — explicit user-provided
|
|
270
|
+
* env values win over stored API keys.
|
|
271
|
+
*
|
|
272
|
+
* Descriptors without an `envKey` (OAuth-only providers, custom providers
|
|
273
|
+
* that bypass env-var resolution) are skipped silently.
|
|
274
|
+
*/
|
|
275
|
+
function applyApiKeyEnv(dataDir, registry) {
|
|
276
|
+
const creds = readCredentials(dataDir);
|
|
277
|
+
for (const descriptor of Object.values(registry)) {
|
|
278
|
+
if (!descriptor.envKey || process.env[descriptor.envKey]) continue;
|
|
279
|
+
const cred = creds[credKeyOf(descriptor)];
|
|
280
|
+
if (cred?.kind === "apikey" && cred.value) process.env[descriptor.envKey] = cred.value;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* `bun run auth` (pre-TUI) wrote `cwd/.credentials.json` with an entry per
|
|
285
|
+
* provider mapping directly to an OAuthCredentials payload, e.g.:
|
|
286
|
+
*
|
|
287
|
+
* {
|
|
288
|
+
* "anthropic": { "access": "...", "refresh": "...", "expires": 123 },
|
|
289
|
+
* "openai-codex": { "access": "...", "refresh": "...", "expires": 123, "accountId": "..." }
|
|
290
|
+
* }
|
|
291
|
+
*
|
|
292
|
+
* We don't delete the legacy file — it might still be used by a host that
|
|
293
|
+
* imports the harness directly. We just copy its contents into the new
|
|
294
|
+
* location under the kind-tagged shape so the TUI picks them up.
|
|
295
|
+
*
|
|
296
|
+
* Migration is provider-agnostic: any top-level entry with an `access` field
|
|
297
|
+
* is preserved verbatim (extras included), under the same key. The TUI's
|
|
298
|
+
* detection then looks them up via the matching descriptor's `credentialFileKey`.
|
|
299
|
+
*
|
|
300
|
+
* Returns the migrated credentials when the migration ran, or `null` when
|
|
301
|
+
* there's no legacy file to migrate.
|
|
302
|
+
*/
|
|
303
|
+
function migrateLegacyFile(targetPath) {
|
|
304
|
+
const legacyPath = resolve(process.cwd(), ".credentials.json");
|
|
305
|
+
if (!existsSync(legacyPath)) return null;
|
|
306
|
+
let legacy;
|
|
307
|
+
try {
|
|
308
|
+
legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return null;
|
|
313
|
+
const migrated = {};
|
|
314
|
+
for (const [fileKey, value] of Object.entries(legacy)) {
|
|
315
|
+
if (!isOAuthLegacy(value)) continue;
|
|
316
|
+
const { access, refresh, expires, ...extras } = value;
|
|
317
|
+
migrated[fileKey] = {
|
|
318
|
+
kind: "oauth",
|
|
319
|
+
access,
|
|
320
|
+
...typeof refresh === "string" ? { refresh } : {},
|
|
321
|
+
...typeof expires === "number" ? { expires } : {},
|
|
322
|
+
...extras
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (Object.keys(migrated).length === 0) return null;
|
|
326
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
327
|
+
const tmp = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
328
|
+
writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE });
|
|
329
|
+
renameSync(tmp, targetPath);
|
|
330
|
+
return migrated;
|
|
331
|
+
}
|
|
332
|
+
function isOAuthLegacy(value) {
|
|
333
|
+
return typeof value === "object" && value !== null && "access" in value && typeof value.access === "string";
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/chat/auth.ts
|
|
337
|
+
/**
|
|
338
|
+
* Detect available auth for every registered provider.
|
|
339
|
+
*
|
|
340
|
+
* Resolution order per provider (a method appears in `methods` for each
|
|
341
|
+
* layer that has a credential — the agent itself resolves them in the same
|
|
342
|
+
* order via its provider factories):
|
|
343
|
+
*
|
|
344
|
+
* 1. `kind: 'apikey'` from `credentials.json` (injected into env at TUI launch)
|
|
345
|
+
* 2. explicit env var (descriptor's `envKey`)
|
|
346
|
+
* 3. `kind: 'oauth'` from `credentials.json` (or legacy `cwd/.credentials.json`)
|
|
347
|
+
*
|
|
348
|
+
* Pure read — never refreshes or rewrites the credentials file.
|
|
349
|
+
*/
|
|
350
|
+
function detectAuth(dataDir, registry, env = process.env) {
|
|
351
|
+
const creds = readCredentials(dataDir);
|
|
352
|
+
return Object.values(registry).map((descriptor) => {
|
|
353
|
+
const methods = [];
|
|
354
|
+
const fileEntry = creds[credKeyOf(descriptor)];
|
|
355
|
+
if (fileEntry?.kind === "apikey" && fileEntry.value) methods.push({
|
|
356
|
+
source: "apikey",
|
|
357
|
+
detail: "credentials.json"
|
|
358
|
+
});
|
|
359
|
+
if (descriptor.envKey && env[descriptor.envKey]) methods.push({
|
|
360
|
+
source: "env",
|
|
361
|
+
detail: descriptor.envKey
|
|
362
|
+
});
|
|
363
|
+
if (fileEntry?.kind === "oauth" && fileEntry.access) {
|
|
364
|
+
const detail = typeof fileEntry.expires === "number" ? `oauth · expires ${new Date(fileEntry.expires).toLocaleString()}` : "oauth · credentials.json";
|
|
365
|
+
methods.push({
|
|
366
|
+
source: "oauth",
|
|
367
|
+
detail
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
key: descriptor.key,
|
|
372
|
+
label: descriptor.label,
|
|
373
|
+
available: methods.length > 0,
|
|
374
|
+
methods
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/chat/completion.ts
|
|
380
|
+
/**
|
|
381
|
+
* Prompt autocompletion framework.
|
|
382
|
+
*
|
|
383
|
+
* Renderer-agnostic. Providers plug in by registering a `trigger` character
|
|
384
|
+
* (e.g. `/` for skills, `@` for files) and exposing two operations:
|
|
385
|
+
*
|
|
386
|
+
* 1. `suggest(query)` — return ranked items for the live query.
|
|
387
|
+
* 2. `parseReferences(text)` — find all references to the provider's
|
|
388
|
+
* items in arbitrary text. Used to highlight in-prompt mentions and
|
|
389
|
+
* drive submit-time side effects (activate the skill, attach the
|
|
390
|
+
* file, …).
|
|
391
|
+
*
|
|
392
|
+
* The TUI consumes `useCompletion()` to drive a popover above the textarea.
|
|
393
|
+
* A future GUI consumes the same hook to drive a dropdown. The popup
|
|
394
|
+
* component itself only reads `label` + `description` on each item, so
|
|
395
|
+
* provider-specific typing (`TItem`) stays at the registration boundary
|
|
396
|
+
* and never leaks into the renderer.
|
|
397
|
+
*/
|
|
398
|
+
/**
|
|
399
|
+
* Resolve the provider trigger active at `cursor`, or `null` when none fits.
|
|
400
|
+
*
|
|
401
|
+
* Rules:
|
|
402
|
+
* - The trigger character must sit at position 0 of the buffer OR be
|
|
403
|
+
* preceded by whitespace. This prevents `http://` from triggering the
|
|
404
|
+
* `/`-bound skills provider mid-URL.
|
|
405
|
+
* - The cursor must be at or past the trigger position.
|
|
406
|
+
* - Nothing between the trigger and the cursor may be whitespace (the
|
|
407
|
+
* query is one contiguous token).
|
|
408
|
+
* - The query length is bounded — `maxQueryLength` defaults to 64 — so
|
|
409
|
+
* a runaway buffer scan can't pin the renderer.
|
|
410
|
+
*/
|
|
411
|
+
function findActiveTrigger(text, cursor, providers, options = {}) {
|
|
412
|
+
if (providers.length === 0) return null;
|
|
413
|
+
const max = options.maxQueryLength ?? 64;
|
|
414
|
+
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
|
415
|
+
const isWhitespace = (ch) => ch === void 0 ? false : /\s/.test(ch);
|
|
416
|
+
for (let i = safeCursor - 1; i >= 0 && safeCursor - i <= max + 1; i--) {
|
|
417
|
+
const ch = text[i];
|
|
418
|
+
if (isWhitespace(ch)) return null;
|
|
419
|
+
const provider = providers.find((p) => p.trigger === ch);
|
|
420
|
+
if (!provider) continue;
|
|
421
|
+
const before = i > 0 ? text[i - 1] : "";
|
|
422
|
+
if (before !== "" && !isWhitespace(before)) continue;
|
|
423
|
+
return {
|
|
424
|
+
provider,
|
|
425
|
+
query: text.slice(i + 1, safeCursor),
|
|
426
|
+
span: {
|
|
427
|
+
start: i,
|
|
428
|
+
end: safeCursor
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Replace `[span.start, span.end)` in `text` with `insertText`. Returns the
|
|
436
|
+
* mutated text and the new cursor position (end of insertion).
|
|
437
|
+
*/
|
|
438
|
+
function applyInsert(text, span, insertText) {
|
|
439
|
+
return {
|
|
440
|
+
text: text.slice(0, span.start) + insertText + text.slice(span.end),
|
|
441
|
+
cursor: span.start + insertText.length
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Merge reference lists from multiple providers into one ordered list with
|
|
446
|
+
* earlier-start-wins disambiguation when spans overlap. Ties broken by
|
|
447
|
+
* insertion order. Spans are sorted ascending so renderers can walk them
|
|
448
|
+
* sequentially with a cursor through the source string.
|
|
449
|
+
*/
|
|
450
|
+
function mergeReferences(refs) {
|
|
451
|
+
const sorted = [...refs].sort((a, b) => a.start - b.start);
|
|
452
|
+
const merged = [];
|
|
453
|
+
let lastEnd = -1;
|
|
454
|
+
for (const ref of sorted) {
|
|
455
|
+
if (ref.start < lastEnd) continue;
|
|
456
|
+
merged.push(ref);
|
|
457
|
+
lastEnd = ref.end;
|
|
458
|
+
}
|
|
459
|
+
return merged;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Collect every provider's references in one pass. Convenience wrapper —
|
|
463
|
+
* the TUI textarea component calls this on every keystroke to highlight
|
|
464
|
+
* in-prompt mentions.
|
|
465
|
+
*/
|
|
466
|
+
function collectReferences(text, providers, cursor = text.length) {
|
|
467
|
+
const ctx = {
|
|
468
|
+
text,
|
|
469
|
+
cursor
|
|
470
|
+
};
|
|
471
|
+
const refs = [];
|
|
472
|
+
for (const p of providers) for (const ref of p.parseReferences(text, ctx)) refs.push(ref);
|
|
473
|
+
return mergeReferences(refs);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Drive a prompt-completion popover from React state.
|
|
477
|
+
*
|
|
478
|
+
* Inputs are pull-style — the caller owns `text` + `cursor` (typically
|
|
479
|
+
* mirroring an `OpenTUI TextareaRenderable` or a `<textarea>` element) and
|
|
480
|
+
* passes them in each render. Providers are matched by their `trigger`
|
|
481
|
+
* character; the engine picks at most one active provider per cursor
|
|
482
|
+
* position.
|
|
483
|
+
*
|
|
484
|
+
* Asynchronous suggest calls are aborted when the query changes or the
|
|
485
|
+
* popover closes, so providers that hit a backend don't leak.
|
|
486
|
+
*/
|
|
487
|
+
function useCompletion(input, providers, options = {}) {
|
|
488
|
+
const { text, cursor } = input;
|
|
489
|
+
const { maxQueryLength } = options;
|
|
490
|
+
const [dismissedSignature, setDismissedSignature] = useState(null);
|
|
491
|
+
const active = useMemo(() => {
|
|
492
|
+
const a = findActiveTrigger(text, cursor, providers, { maxQueryLength });
|
|
493
|
+
if (!a) return null;
|
|
494
|
+
if (dismissedSignature === `${text.length}:${cursor}`) return null;
|
|
495
|
+
return a;
|
|
496
|
+
}, [
|
|
497
|
+
text,
|
|
498
|
+
cursor,
|
|
499
|
+
providers,
|
|
500
|
+
maxQueryLength,
|
|
501
|
+
dismissedSignature
|
|
502
|
+
]);
|
|
503
|
+
const [items, setItems] = useState([]);
|
|
504
|
+
const [loading, setLoading] = useState(false);
|
|
505
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
506
|
+
const abortRef = useRef(null);
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
abortRef.current?.abort();
|
|
509
|
+
if (!active) {
|
|
510
|
+
setItems([]);
|
|
511
|
+
setLoading(false);
|
|
512
|
+
setSelectedIndex(0);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
abortRef.current = controller;
|
|
517
|
+
const ctx = {
|
|
518
|
+
text,
|
|
519
|
+
cursor
|
|
520
|
+
};
|
|
521
|
+
let cancelled = false;
|
|
522
|
+
const out = active.provider.suggest(active.query, ctx, controller.signal);
|
|
523
|
+
if (Array.isArray(out)) {
|
|
524
|
+
setItems(out);
|
|
525
|
+
setSelectedIndex(0);
|
|
526
|
+
setLoading(false);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
setLoading(true);
|
|
530
|
+
out.then((next) => {
|
|
531
|
+
if (cancelled) return;
|
|
532
|
+
setItems(next);
|
|
533
|
+
setSelectedIndex(0);
|
|
534
|
+
setLoading(false);
|
|
535
|
+
}, () => {
|
|
536
|
+
if (cancelled) return;
|
|
537
|
+
setItems([]);
|
|
538
|
+
setLoading(false);
|
|
539
|
+
});
|
|
540
|
+
return () => {
|
|
541
|
+
cancelled = true;
|
|
542
|
+
controller.abort();
|
|
543
|
+
};
|
|
544
|
+
}, [
|
|
545
|
+
active,
|
|
546
|
+
text,
|
|
547
|
+
cursor
|
|
548
|
+
]);
|
|
549
|
+
const references = useMemo(() => collectReferences(text, providers, cursor), [text, providers]);
|
|
550
|
+
const selectNext = useCallback(() => {
|
|
551
|
+
setSelectedIndex((i) => items.length === 0 ? 0 : (i + 1) % items.length);
|
|
552
|
+
}, [items.length]);
|
|
553
|
+
const selectPrev = useCallback(() => {
|
|
554
|
+
setSelectedIndex((i) => items.length === 0 ? 0 : (i - 1 + items.length) % items.length);
|
|
555
|
+
}, [items.length]);
|
|
556
|
+
const dismiss = useCallback(() => {
|
|
557
|
+
setDismissedSignature(`${text.length}:${cursor}`);
|
|
558
|
+
}, [text.length, cursor]);
|
|
559
|
+
const commit = useCallback(() => {
|
|
560
|
+
if (!active || items.length === 0) return null;
|
|
561
|
+
const item = items[Math.min(selectedIndex, items.length - 1)];
|
|
562
|
+
return applyInsert(text, active.span, item.insertText);
|
|
563
|
+
}, [
|
|
564
|
+
active,
|
|
565
|
+
items,
|
|
566
|
+
selectedIndex,
|
|
567
|
+
text
|
|
568
|
+
]);
|
|
569
|
+
return useMemo(() => ({
|
|
570
|
+
active,
|
|
571
|
+
items,
|
|
572
|
+
loading,
|
|
573
|
+
selectedIndex,
|
|
574
|
+
selectNext,
|
|
575
|
+
selectPrev,
|
|
576
|
+
commit,
|
|
577
|
+
dismiss,
|
|
578
|
+
references
|
|
579
|
+
}), [
|
|
580
|
+
active,
|
|
581
|
+
items,
|
|
582
|
+
loading,
|
|
583
|
+
selectedIndex,
|
|
584
|
+
selectNext,
|
|
585
|
+
selectPrev,
|
|
586
|
+
commit,
|
|
587
|
+
dismiss,
|
|
588
|
+
references
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/chat/completion-files.ts
|
|
593
|
+
/** Trigger character — `@` is the conventional file-mention prefix in chat UIs. */
|
|
594
|
+
const FILES_TRIGGER = "@";
|
|
595
|
+
/** Cap on returned items. Keeps the popover compact + render-cheap. */
|
|
596
|
+
const DEFAULT_RESULT_LIMIT = 50;
|
|
597
|
+
/**
|
|
598
|
+
* Build an `@`-prefixed files completion provider against a *live* catalog.
|
|
599
|
+
*
|
|
600
|
+
* The factory captures a getter so the catalog can be re-scanned (cwd
|
|
601
|
+
* change, manual refresh) without re-instantiating the provider — the
|
|
602
|
+
* App keeps one provider for the lifetime of the prompt block and just
|
|
603
|
+
* mutates the underlying state.
|
|
604
|
+
*
|
|
605
|
+
* `limit` caps the result list so the popover stays bounded on huge
|
|
606
|
+
* monorepos. Filtering is substring on `path` + `name`, case-insensitive;
|
|
607
|
+
* ranking prefers (in order): exact name match, name prefix, name
|
|
608
|
+
* substring, path substring, alphabetical.
|
|
609
|
+
*/
|
|
610
|
+
function createFilesCompletionProvider(opts) {
|
|
611
|
+
const limit = opts.limit ?? DEFAULT_RESULT_LIMIT;
|
|
612
|
+
return {
|
|
613
|
+
id: "files",
|
|
614
|
+
trigger: "@",
|
|
615
|
+
label: "Files",
|
|
616
|
+
suggest(query) {
|
|
617
|
+
const catalog = opts.getCatalog();
|
|
618
|
+
const q = query.trim().toLowerCase();
|
|
619
|
+
const scored = [];
|
|
620
|
+
for (const file of catalog) {
|
|
621
|
+
const name = file.name.toLowerCase();
|
|
622
|
+
const path = file.path.toLowerCase();
|
|
623
|
+
if (q.length === 0) {
|
|
624
|
+
scored.push({
|
|
625
|
+
entry: file,
|
|
626
|
+
rank: 4
|
|
627
|
+
});
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (name === q) {
|
|
631
|
+
scored.push({
|
|
632
|
+
entry: file,
|
|
633
|
+
rank: 0
|
|
634
|
+
});
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (name.startsWith(q)) {
|
|
638
|
+
scored.push({
|
|
639
|
+
entry: file,
|
|
640
|
+
rank: 1
|
|
641
|
+
});
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (name.includes(q)) {
|
|
645
|
+
scored.push({
|
|
646
|
+
entry: file,
|
|
647
|
+
rank: 2
|
|
648
|
+
});
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (path.includes(q)) {
|
|
652
|
+
scored.push({
|
|
653
|
+
entry: file,
|
|
654
|
+
rank: 3
|
|
655
|
+
});
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
scored.sort((a, b) => {
|
|
660
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
661
|
+
return a.entry.path.localeCompare(b.entry.path);
|
|
662
|
+
});
|
|
663
|
+
return scored.slice(0, limit).map(({ entry }) => ({
|
|
664
|
+
id: entry.path,
|
|
665
|
+
label: entry.name,
|
|
666
|
+
description: parentDir(entry.path),
|
|
667
|
+
insertText: `@${entry.path} `,
|
|
668
|
+
data: entry
|
|
669
|
+
}));
|
|
670
|
+
},
|
|
671
|
+
parseReferences(text, _ctx) {
|
|
672
|
+
const catalog = opts.getCatalog();
|
|
673
|
+
if (catalog.length === 0) return [];
|
|
674
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
675
|
+
for (const file of catalog) byPath.set(file.path, file);
|
|
676
|
+
const refs = [];
|
|
677
|
+
const rx = /(^|\s)@(\S+)/g;
|
|
678
|
+
let m;
|
|
679
|
+
while ((m = rx.exec(text)) !== null) {
|
|
680
|
+
const rawCandidate = m[2];
|
|
681
|
+
const stripped = byPath.has(rawCandidate) ? rawCandidate : rawCandidate.replace(/[.,;:)\]}!?]+$/, "");
|
|
682
|
+
const file = byPath.get(stripped);
|
|
683
|
+
if (!file) continue;
|
|
684
|
+
const start = m.index + m[1].length;
|
|
685
|
+
const trimmedLen = 1 + stripped.length;
|
|
686
|
+
refs.push({
|
|
687
|
+
providerId: "files",
|
|
688
|
+
start,
|
|
689
|
+
end: start + trimmedLen,
|
|
690
|
+
itemId: file.path,
|
|
691
|
+
data: file
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return refs;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/** Return the parent directory of a forward-slashed path, or `''` for root entries. */
|
|
699
|
+
function parentDir(path) {
|
|
700
|
+
const lastSlash = path.lastIndexOf("/");
|
|
701
|
+
return lastSlash <= 0 ? "" : path.slice(0, lastSlash);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Walk a reference list and return the deduplicated set of files in
|
|
705
|
+
* first-mention order — input to "attach these files to the prompt"
|
|
706
|
+
* downstream logic.
|
|
707
|
+
*/
|
|
708
|
+
function uniqueFilesFromReferences(references) {
|
|
709
|
+
const out = [];
|
|
710
|
+
const seen = /* @__PURE__ */ new Set();
|
|
711
|
+
for (const ref of references) {
|
|
712
|
+
if (ref.providerId !== "files") continue;
|
|
713
|
+
if (seen.has(ref.itemId)) continue;
|
|
714
|
+
seen.add(ref.itemId);
|
|
715
|
+
out.push(ref.data);
|
|
716
|
+
}
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/chat/completion-skills.ts
|
|
721
|
+
/** Trigger character — slash-commands convention. */
|
|
722
|
+
const SKILLS_TRIGGER = "/";
|
|
723
|
+
/** Valid skill-name shape (matches the parser): lowercase alnum + dashes. */
|
|
724
|
+
const SKILL_NAME_RX = /^[a-z0-9][a-z0-9-]*$/;
|
|
725
|
+
/**
|
|
726
|
+
* Build a slash-command completion provider against a *live* skills
|
|
727
|
+
* catalog. The factory captures a getter so the catalog can change across
|
|
728
|
+
* renders (toggles, reload) without re-instantiating the provider.
|
|
729
|
+
*
|
|
730
|
+
* Pass `getEnabled` to additionally hide skills the user has toggled off
|
|
731
|
+
* — when undefined, every catalog entry is offered.
|
|
732
|
+
*/
|
|
733
|
+
function createSkillsCompletionProvider(opts) {
|
|
734
|
+
const visible = () => {
|
|
735
|
+
const all = opts.getCatalog();
|
|
736
|
+
const enabled = opts.getEnabled?.();
|
|
737
|
+
if (enabled === void 0) return [...all];
|
|
738
|
+
const allow = new Set(enabled);
|
|
739
|
+
return all.filter((s) => allow.has(s.name));
|
|
740
|
+
};
|
|
741
|
+
return {
|
|
742
|
+
id: "skills",
|
|
743
|
+
trigger: "/",
|
|
744
|
+
label: "Skills",
|
|
745
|
+
suggest(query) {
|
|
746
|
+
const q = query.trim().toLowerCase();
|
|
747
|
+
return visible().filter((skill) => SKILL_NAME_RX.test(skill.name)).filter((skill) => {
|
|
748
|
+
if (q.length === 0) return true;
|
|
749
|
+
return skill.name.toLowerCase().includes(q) || skill.description.toLowerCase().includes(q);
|
|
750
|
+
}).sort((a, b) => {
|
|
751
|
+
const an = a.name.toLowerCase();
|
|
752
|
+
const bn = b.name.toLowerCase();
|
|
753
|
+
if (q) {
|
|
754
|
+
const aPrefix = an.startsWith(q);
|
|
755
|
+
if (aPrefix !== bn.startsWith(q)) return aPrefix ? -1 : 1;
|
|
756
|
+
}
|
|
757
|
+
return an.localeCompare(bn);
|
|
758
|
+
}).map((skill) => ({
|
|
759
|
+
id: skill.name,
|
|
760
|
+
label: skill.name,
|
|
761
|
+
description: skill.description,
|
|
762
|
+
insertText: `/${skill.name} `,
|
|
763
|
+
data: skill
|
|
764
|
+
}));
|
|
765
|
+
},
|
|
766
|
+
parseReferences(text, _ctx) {
|
|
767
|
+
const catalog = visible();
|
|
768
|
+
if (catalog.length === 0) return [];
|
|
769
|
+
const byName = /* @__PURE__ */ new Map();
|
|
770
|
+
for (const skill of catalog) byName.set(skill.name, skill);
|
|
771
|
+
const refs = [];
|
|
772
|
+
const rx = /(^|\s)(\/([a-z0-9][a-z0-9-]*))/g;
|
|
773
|
+
let m;
|
|
774
|
+
while ((m = rx.exec(text)) !== null) {
|
|
775
|
+
const name = m[3];
|
|
776
|
+
const skill = byName.get(name);
|
|
777
|
+
if (!skill) continue;
|
|
778
|
+
const start = m.index + m[1].length;
|
|
779
|
+
refs.push({
|
|
780
|
+
providerId: "skills",
|
|
781
|
+
start,
|
|
782
|
+
end: start + m[2].length,
|
|
783
|
+
itemId: skill.name,
|
|
784
|
+
data: skill
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return refs;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Walk a parsed prompt for skill references and return the deduplicated
|
|
793
|
+
* list of skill names — input to `agent.activateSkill(name)` calls on
|
|
794
|
+
* submit.
|
|
795
|
+
*/
|
|
796
|
+
function uniqueSkillNamesFromReferences(references) {
|
|
797
|
+
const out = [];
|
|
798
|
+
const seen = /* @__PURE__ */ new Set();
|
|
799
|
+
for (const ref of references) {
|
|
800
|
+
if (ref.providerId !== "skills") continue;
|
|
801
|
+
if (seen.has(ref.itemId)) continue;
|
|
802
|
+
seen.add(ref.itemId);
|
|
803
|
+
out.push(ref.itemId);
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/chat/project-root.ts
|
|
809
|
+
/**
|
|
810
|
+
* Git root detection — walks parents from `cwd` looking for a `.git`
|
|
811
|
+
* entry, returning the absolute path of the repo root or `null` when
|
|
812
|
+
* the search reaches the filesystem root.
|
|
813
|
+
*
|
|
814
|
+
* Why parent-walk over `git rev-parse --show-toplevel`:
|
|
815
|
+
*
|
|
816
|
+
* - Zero dependencies (no shell-out, no git binary requirement,
|
|
817
|
+
* works even when `git` is missing from `PATH`).
|
|
818
|
+
* - Pure-sync, deterministic, easy to test against tmp dirs.
|
|
819
|
+
* - Equally fast in practice — most lookups are a single `existsSync`.
|
|
820
|
+
*
|
|
821
|
+
* Recognizes `.git` as either:
|
|
822
|
+
*
|
|
823
|
+
* - A directory (standard repo layout).
|
|
824
|
+
* - A file containing `gitdir: …` (worktrees + submodules).
|
|
825
|
+
*
|
|
826
|
+
* Bare repos (no working tree) are intentionally NOT detected here —
|
|
827
|
+
* the TUI's data dir contract is "project working tree", and a bare
|
|
828
|
+
* repo has no working files to scope sessions against.
|
|
829
|
+
*/
|
|
830
|
+
/**
|
|
831
|
+
* Walk parents of `cwd` looking for a `.git` entry. Returns the
|
|
832
|
+
* absolute path of the directory containing `.git`, or `null` when no
|
|
833
|
+
* git repo is found above `cwd`.
|
|
834
|
+
*
|
|
835
|
+
* Stops at the filesystem root (`/`) — no infinite loop on `dirname`'s
|
|
836
|
+
* idempotent root case (`dirname('/')` returns `/`).
|
|
837
|
+
*/
|
|
838
|
+
function findGitRoot$1(cwd = process.cwd()) {
|
|
839
|
+
let dir = resolve(cwd);
|
|
840
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
841
|
+
if (hasGitMarker(dir)) return dir;
|
|
842
|
+
const parent = dirname(dir);
|
|
843
|
+
if (parent === dir) return null;
|
|
844
|
+
dir = parent;
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* `.git` exists as a directory (standard repo) OR as a file containing
|
|
850
|
+
* a `gitdir:` pointer (worktree / submodule). Tolerant on stat failures
|
|
851
|
+
* — a permission error on a parent directory shouldn't crash the walk.
|
|
852
|
+
*/
|
|
853
|
+
function hasGitMarker(dir) {
|
|
854
|
+
const candidate = resolve(dir, ".git");
|
|
855
|
+
try {
|
|
856
|
+
if (!existsSync(candidate)) return false;
|
|
857
|
+
const stat = statSync(candidate);
|
|
858
|
+
return stat.isDirectory() || stat.isFile();
|
|
859
|
+
} catch {
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/chat/store.ts
|
|
865
|
+
function ensureStateDir(path) {
|
|
866
|
+
const dir = dirname(path);
|
|
867
|
+
if (existsSync(dir)) return;
|
|
868
|
+
try {
|
|
869
|
+
mkdirSync(dir, { recursive: true });
|
|
870
|
+
} catch (err) {
|
|
871
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
872
|
+
throw new Error(`Could not create TUI state directory at "${dir}". Override the location via \`runTui({ storageDir, prefix })\` or the \`ZIDANE_STORAGE_DIR\` env var. Original error: ${message}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function createStateStore(path) {
|
|
876
|
+
return {
|
|
877
|
+
load: () => loadState(path),
|
|
878
|
+
save: (state) => saveState(path, state)
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function loadState(path) {
|
|
882
|
+
if (!existsSync(path)) return {};
|
|
883
|
+
try {
|
|
884
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
885
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
886
|
+
} catch {}
|
|
887
|
+
return {};
|
|
888
|
+
}
|
|
889
|
+
function saveState(path, state) {
|
|
890
|
+
ensureStateDir(path);
|
|
891
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
892
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
893
|
+
renameSync(tmp, path);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Load every session and project it to the compact `SessionMeta` shape used by
|
|
897
|
+
* the picker. Sorted by recency via the underlying store's `list()` contract
|
|
898
|
+
* (sqlite store returns by `updated_at DESC`).
|
|
899
|
+
*
|
|
900
|
+
* Robust to per-row failures: `Promise.allSettled` so a single corrupt or
|
|
901
|
+
* unreadable row (malformed JSON, partial write, transient I/O error)
|
|
902
|
+
* doesn't take down the entire picker. Failed rows are silently skipped;
|
|
903
|
+
* a stderr line is emitted under `ZIDANE_DEBUG` for diagnosis.
|
|
904
|
+
*/
|
|
905
|
+
async function listSessionMeta(store, filter) {
|
|
906
|
+
const ids = await store.list(filter);
|
|
907
|
+
const settled = await Promise.allSettled(ids.map(async (id) => {
|
|
908
|
+
const data = await store.load(id);
|
|
909
|
+
if (!data) return null;
|
|
910
|
+
return {
|
|
911
|
+
id,
|
|
912
|
+
title: deriveSessionTitle(data.turns, data.metadata),
|
|
913
|
+
turnCount: data.turns.length,
|
|
914
|
+
userMessageCount: data.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
|
|
915
|
+
runCount: data.runs.length,
|
|
916
|
+
...data.projectRoot ? { projectRoot: data.projectRoot } : {},
|
|
917
|
+
updatedAt: data.updatedAt
|
|
918
|
+
};
|
|
919
|
+
}));
|
|
920
|
+
const metas = [];
|
|
921
|
+
for (let i = 0; i < settled.length; i++) {
|
|
922
|
+
const result = settled[i];
|
|
923
|
+
if (result.status === "fulfilled" && result.value) metas.push(result.value);
|
|
924
|
+
else if (result.status === "rejected" && process.env.ZIDANE_DEBUG) {
|
|
925
|
+
const cause = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
926
|
+
process.stderr.write(`[zidane/chat] failed to load session "${ids[i]}": ${cause}\n`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return metas;
|
|
930
|
+
}
|
|
931
|
+
/** Derive a short title from the first user message — returns null when empty. */
|
|
932
|
+
function titleFromTurns(turns) {
|
|
933
|
+
const first = turns.find((t) => t.role === "user");
|
|
934
|
+
if (!first) return null;
|
|
935
|
+
for (const block of first.content) if (block.type === "text" && block.text.trim()) {
|
|
936
|
+
const oneLine = block.text.replace(/\s+/g, " ").trim();
|
|
937
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Display title for a session — preferred sources, in order:
|
|
943
|
+
*
|
|
944
|
+
* 1. `metadata.title` if it's a non-empty string (typically set by
|
|
945
|
+
* the session-details modal's "generate title" action).
|
|
946
|
+
* 2. {@link titleFromTurns} — the first user message, one-line + clipped.
|
|
947
|
+
* 3. The literal `'untitled'`.
|
|
948
|
+
*
|
|
949
|
+
* Total / pure / defensive on non-string metadata values.
|
|
950
|
+
*/
|
|
951
|
+
function deriveSessionTitle(turns, metadata) {
|
|
952
|
+
const stored = typeof metadata?.title === "string" ? metadata.title.trim() : "";
|
|
953
|
+
if (stored.length > 0) return stored;
|
|
954
|
+
return titleFromTurns(turns) ?? "untitled";
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Replay persisted turns as a viewable transcript. Mirrors the event shape
|
|
958
|
+
* produced live by the agent hooks so loaded and streaming history render
|
|
959
|
+
* identically — including subagent ancestry when `runs` is supplied.
|
|
960
|
+
*
|
|
961
|
+
* Subagent reconstruction:
|
|
962
|
+
* - Every turn carries a `runId`. We look that up in `runs` to get the
|
|
963
|
+
* run's `depth` and tag the resulting events with `{ depth, childId }`
|
|
964
|
+
* — the same shape the live `child:*` bubble hooks produce.
|
|
965
|
+
* - We synthesize `spawn-start` / `spawn-end` markers at each child-run
|
|
966
|
+
* boundary so the transcript reads the same as a live run did
|
|
967
|
+
* (`🌱 [run-id] task` … child events … `🌳 [run-id] done · tokens`).
|
|
968
|
+
* - For child runs (`depth > 0`), the user-role "task" text is suppressed
|
|
969
|
+
* because `spawn-start` already shows it.
|
|
970
|
+
*
|
|
971
|
+
* Without `runs` (legacy callers / tests), the function falls back to the
|
|
972
|
+
* old behavior: depth-0 events with no subagent grouping.
|
|
973
|
+
*/
|
|
974
|
+
function eventsFromTurns(turns, runs = []) {
|
|
975
|
+
const runById = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const run of runs) runById.set(run.id, run);
|
|
977
|
+
const childLabelByRunId = /* @__PURE__ */ new Map();
|
|
978
|
+
runs.filter((r) => (r.depth ?? 0) > 0).slice().sort((a, b) => a.startedAt - b.startedAt).forEach((r, i) => childLabelByRunId.set(r.id, `child-${i + 1}`));
|
|
979
|
+
const labelFor = (runId) => childLabelByRunId.get(runId) ?? runId;
|
|
980
|
+
const toolByCallId = /* @__PURE__ */ new Map();
|
|
981
|
+
for (const turn of turns) {
|
|
982
|
+
if (turn.role !== "assistant") continue;
|
|
983
|
+
for (const block of turn.content) if (block.type === "tool_call") toolByCallId.set(block.id, block.name);
|
|
984
|
+
}
|
|
985
|
+
const ancestryOf = (turnRunId) => {
|
|
986
|
+
if (!turnRunId) return [];
|
|
987
|
+
const chain = [];
|
|
988
|
+
let cursor = runById.get(turnRunId);
|
|
989
|
+
const seen = /* @__PURE__ */ new Set();
|
|
990
|
+
while (cursor) {
|
|
991
|
+
if (seen.has(cursor.id)) break;
|
|
992
|
+
seen.add(cursor.id);
|
|
993
|
+
if ((cursor.depth ?? 0) > 0) chain.unshift(cursor);
|
|
994
|
+
cursor = cursor.parentRunId ? runById.get(cursor.parentRunId) : void 0;
|
|
995
|
+
}
|
|
996
|
+
return chain;
|
|
997
|
+
};
|
|
998
|
+
const events = [];
|
|
999
|
+
/** Currently-open child runs, root-of-tree first, innermost last. */
|
|
1000
|
+
const openStack = [];
|
|
1001
|
+
/** True iff anything has been emitted at the active depth-0 level. Used to decide separators between top-level turns. */
|
|
1002
|
+
let lastDepthAtEmission = -1;
|
|
1003
|
+
const pushSpawnEnd = (run) => {
|
|
1004
|
+
const tag = run.status === "aborted" || run.status === "error" ? run.status : "done";
|
|
1005
|
+
const usage = formatTokenUsage({
|
|
1006
|
+
totalIn: run.tokensIn ?? run.totalUsage?.input ?? 0,
|
|
1007
|
+
totalOut: run.tokensOut ?? run.totalUsage?.output ?? 0,
|
|
1008
|
+
totalCacheRead: run.totalUsage?.cacheRead ?? 0,
|
|
1009
|
+
totalCacheCreation: run.totalUsage?.cacheCreation ?? 0
|
|
1010
|
+
});
|
|
1011
|
+
events.push({
|
|
1012
|
+
kind: "spawn-end",
|
|
1013
|
+
text: `${tag} ${usage}`,
|
|
1014
|
+
childId: labelFor(run.id),
|
|
1015
|
+
depth: run.depth ?? 1
|
|
1016
|
+
});
|
|
1017
|
+
};
|
|
1018
|
+
const pushSpawnStart = (run) => {
|
|
1019
|
+
const taskPreview = run.prompt.length > 80 ? `${run.prompt.slice(0, 80)}…` : run.prompt;
|
|
1020
|
+
events.push({
|
|
1021
|
+
kind: "spawn-start",
|
|
1022
|
+
text: taskPreview,
|
|
1023
|
+
childId: labelFor(run.id),
|
|
1024
|
+
depth: run.depth ?? 1
|
|
1025
|
+
});
|
|
1026
|
+
};
|
|
1027
|
+
for (let i = 0; i < turns.length; i++) {
|
|
1028
|
+
const turn = turns[i];
|
|
1029
|
+
const targetChain = ancestryOf(turn.runId);
|
|
1030
|
+
const depth = targetChain.length;
|
|
1031
|
+
let common = 0;
|
|
1032
|
+
while (common < openStack.length && common < targetChain.length && openStack[common].id === targetChain[common].id) common++;
|
|
1033
|
+
while (openStack.length > common) pushSpawnEnd(openStack.pop());
|
|
1034
|
+
while (openStack.length < targetChain.length) {
|
|
1035
|
+
const run = targetChain[openStack.length];
|
|
1036
|
+
pushSpawnStart(run);
|
|
1037
|
+
openStack.push(run);
|
|
1038
|
+
}
|
|
1039
|
+
if (depth === 0 && lastDepthAtEmission === 0) events.push({
|
|
1040
|
+
kind: "separator",
|
|
1041
|
+
text: ""
|
|
1042
|
+
});
|
|
1043
|
+
const subagentTag = depth > 0 && turn.runId ? {
|
|
1044
|
+
childId: labelFor(turn.runId),
|
|
1045
|
+
depth
|
|
1046
|
+
} : void 0;
|
|
1047
|
+
const tag = {
|
|
1048
|
+
turnId: turn.id,
|
|
1049
|
+
...subagentTag
|
|
1050
|
+
};
|
|
1051
|
+
if (turn.role === "user") {
|
|
1052
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) {
|
|
1053
|
+
if (depth === 0) events.push({
|
|
1054
|
+
kind: "user-prompt",
|
|
1055
|
+
text: block.text,
|
|
1056
|
+
turnId: turn.id
|
|
1057
|
+
});
|
|
1058
|
+
} else if (block.type === "tool_result") {
|
|
1059
|
+
const tool = toolByCallId.get(block.callId);
|
|
1060
|
+
const raw = toolResultText(block.output);
|
|
1061
|
+
const text = tool === "spawn" ? stripSpawnTokensLine(raw) : raw;
|
|
1062
|
+
events.push({
|
|
1063
|
+
kind: "tool-result",
|
|
1064
|
+
text,
|
|
1065
|
+
...tool ? { tool } : {},
|
|
1066
|
+
...tag
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
lastDepthAtEmission = depth;
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
if (turn.role === "assistant") {
|
|
1073
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
|
|
1074
|
+
kind: "markdown",
|
|
1075
|
+
text: block.text,
|
|
1076
|
+
streaming: false,
|
|
1077
|
+
...tag
|
|
1078
|
+
});
|
|
1079
|
+
else if (block.type === "tool_call") events.push({
|
|
1080
|
+
kind: "tool",
|
|
1081
|
+
text: toolCallPreview(block.name, block.input),
|
|
1082
|
+
tool: block.name,
|
|
1083
|
+
...tag
|
|
1084
|
+
});
|
|
1085
|
+
lastDepthAtEmission = depth;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
while (openStack.length > 0) pushSpawnEnd(openStack.pop());
|
|
1089
|
+
return events;
|
|
1090
|
+
}
|
|
1091
|
+
/** Shared formatter for the `↳ name(args)` line shown on tool calls. */
|
|
1092
|
+
function toolCallPreview(name, input) {
|
|
1093
|
+
const args = JSON.stringify(input);
|
|
1094
|
+
return args && args !== "{}" ? `${name}(${args})` : name;
|
|
1095
|
+
}
|
|
1096
|
+
/** Render tool output as plain text, whether it's a string or structured content. */
|
|
1097
|
+
function toolResultText(output) {
|
|
1098
|
+
return typeof output === "string" ? output : toolResultToText(output);
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Strip the `Tokens: …` line from a spawn tool-result. The spawn-end marker
|
|
1102
|
+
* displayed right above already shows the same stats; keeping the line in the
|
|
1103
|
+
* rendered tool-result body just produces a visible duplicate (and, on
|
|
1104
|
+
* reloaded pre-fix sessions, an *inconsistent* duplicate — the persisted line
|
|
1105
|
+
* uses the old `13 in / 4075 out` shape while the freshly synthesized
|
|
1106
|
+
* spawn-end uses the cache-aware `in 92615 (cache 92602) / 4075 out` shape).
|
|
1107
|
+
*
|
|
1108
|
+
* Display-only: the persisted tool_result content is untouched, so the LLM
|
|
1109
|
+
* still sees the full string in its context window. Anchored to start-of-line
|
|
1110
|
+
* and matches both `Tokens: 13 in / 4075 out` (legacy) and `Tokens: in 13 …`
|
|
1111
|
+
* (post-`formatTokenUsage`) shapes.
|
|
1112
|
+
*/
|
|
1113
|
+
function stripSpawnTokensLine(text) {
|
|
1114
|
+
return text.replace(/(^\[sub-agent [^\n]+\n)Tokens:[^\n]*\n?/gm, "$1");
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Deduplicated, in-order list of **parent-conversation** turn ids that appear
|
|
1118
|
+
* in a rendered transcript — the navigation index for the TUI's select-turn
|
|
1119
|
+
* mode. Subagent (`childId` set) events are deliberately skipped:
|
|
1120
|
+
*
|
|
1121
|
+
* - With `hideSubagentOutput: true` (default), child events are filtered
|
|
1122
|
+
* out of the transcript by `isVisible`, so allowing the user to "select"
|
|
1123
|
+
* them would highlight nothing and scroll to nowhere.
|
|
1124
|
+
* - With `hideSubagentOutput: false`, child events are visible but they're
|
|
1125
|
+
* nested execution detail; the user's mental model of a "message" is
|
|
1126
|
+
* the conversational exchange, not each spawn turn.
|
|
1127
|
+
*
|
|
1128
|
+
* Synthetic events (separator, spawn-start, spawn-end) have no `turnId` and
|
|
1129
|
+
* are skipped naturally. Pure function over the event stream — no settings
|
|
1130
|
+
* dependency, no host concerns — so it composes the same in TUI / SDK
|
|
1131
|
+
* consumers.
|
|
1132
|
+
*/
|
|
1133
|
+
function selectableTurnIds(events) {
|
|
1134
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1135
|
+
const ordered = [];
|
|
1136
|
+
for (const e of events) {
|
|
1137
|
+
if (!e.turnId) continue;
|
|
1138
|
+
if (e.childId) continue;
|
|
1139
|
+
if (seen.has(e.turnId)) continue;
|
|
1140
|
+
seen.add(e.turnId);
|
|
1141
|
+
ordered.push(e.turnId);
|
|
1142
|
+
}
|
|
1143
|
+
return ordered;
|
|
1144
|
+
}
|
|
1145
|
+
/** Effective context size of the most recent assistant turn — drives the footer indicator. */
|
|
1146
|
+
/**
|
|
1147
|
+
* Walk from the end of `turns` and return the cache-aware input-token total
|
|
1148
|
+
* of the most recent **parent** (depth-0) assistant turn. Subagent turns
|
|
1149
|
+
* are skipped because their context window is the child's, not the parent's
|
|
1150
|
+
* — surfacing a subagent's `usage.input` as the footer's "context used"
|
|
1151
|
+
* after a session ends mid-spawn would be misleading. The next prompt
|
|
1152
|
+
* feeds the parent, so the parent's last view is what matters.
|
|
1153
|
+
*
|
|
1154
|
+
* `runs` is optional for backwards compatibility (older call sites and
|
|
1155
|
+
* tests that don't have ancestry info). Without it, this falls back to the
|
|
1156
|
+
* pre-fix behavior — depth is unknown so every assistant turn qualifies.
|
|
1157
|
+
*/
|
|
1158
|
+
function lastContextSizeFromTurns(turns, runs = []) {
|
|
1159
|
+
const childRunIds = /* @__PURE__ */ new Set();
|
|
1160
|
+
for (const run of runs) if ((run.depth ?? 0) > 0) childRunIds.add(run.id);
|
|
1161
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
1162
|
+
const turn = turns[i];
|
|
1163
|
+
if (turn.role !== "assistant" || !turn.usage) continue;
|
|
1164
|
+
if (turn.runId && childRunIds.has(turn.runId)) continue;
|
|
1165
|
+
return (turn.usage.input ?? 0) + (turn.usage.cacheRead ?? 0) + (turn.usage.cacheCreation ?? 0);
|
|
1166
|
+
}
|
|
1167
|
+
return 0;
|
|
1168
|
+
}
|
|
1169
|
+
Object.freeze({ projectDb: true });
|
|
1170
|
+
/**
|
|
1171
|
+
* Build the absolute path to `~/.{prefix}/config.json` given a user
|
|
1172
|
+
* directory (`<storageDir>/<prefix>`). Exported so callers debugging a
|
|
1173
|
+
* "why isn't my override taking effect?" can print the resolved path.
|
|
1174
|
+
*/
|
|
1175
|
+
function userConfigPath(userDir) {
|
|
1176
|
+
return resolve(userDir, "config.json");
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Read + parse the user config file. Returns an empty config (defaults
|
|
1180
|
+
* apply) on any failure — the function never throws. Set `ZIDANE_DEBUG`
|
|
1181
|
+
* to surface parse failures to stderr.
|
|
1182
|
+
*/
|
|
1183
|
+
function loadUserConfig(userDir) {
|
|
1184
|
+
const path = userConfigPath(userDir);
|
|
1185
|
+
if (!existsSync(path)) return {};
|
|
1186
|
+
try {
|
|
1187
|
+
const raw = readFileSync(path, "utf8");
|
|
1188
|
+
const parsed = JSON.parse(raw);
|
|
1189
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1190
|
+
return normalizeUserConfig(parsed);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
if (process.env.ZIDANE_DEBUG) {
|
|
1193
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1194
|
+
process.stderr.write(`[zidane/chat] user-config: failed to read "${path}": ${msg}\n`);
|
|
1195
|
+
}
|
|
1196
|
+
return {};
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Narrow an arbitrary JSON object to the `UserConfig` shape. Drops
|
|
1201
|
+
* unknown keys (so a future zidane version's config field doesn't
|
|
1202
|
+
* leak into the typed surface) and validates types per-field.
|
|
1203
|
+
*/
|
|
1204
|
+
function normalizeUserConfig(raw) {
|
|
1205
|
+
const out = {};
|
|
1206
|
+
if (typeof raw.projectDb === "boolean") out.projectDb = raw.projectDb;
|
|
1207
|
+
return out;
|
|
1208
|
+
}
|
|
1209
|
+
//#endregion
|
|
1210
|
+
//#region src/chat/config.ts
|
|
1211
|
+
/** Resolve user options into a fully-bound runtime config. Pure aside from disk reads. */
|
|
1212
|
+
function resolveConfig(options) {
|
|
1213
|
+
const prefix = options.prefix ?? process.env.ZIDANE_PREFIX ?? ".zidane";
|
|
1214
|
+
const storageDir = options.storageDir ?? process.env.ZIDANE_STORAGE_DIR ?? homedir();
|
|
1215
|
+
const paths = resolveStoragePaths({
|
|
1216
|
+
prefix,
|
|
1217
|
+
storageDir,
|
|
1218
|
+
cwd: options.cwd ?? process.cwd(),
|
|
1219
|
+
projectDb: options.projectDb
|
|
1220
|
+
});
|
|
1221
|
+
if (!options.store) throw new Error("resolveConfig: `store` is required. Pass a `SessionStore` instance, or a factory — terminal hosts can use `createTuiStore` from `zidane/session/sqlite`.");
|
|
1222
|
+
const store = typeof options.store === "function" ? options.store(paths) : options.store;
|
|
1223
|
+
const stateStore = createStateStore(paths.state);
|
|
1224
|
+
const initialState = stateStore.load();
|
|
1225
|
+
const providers = options.providers ?? BUILTIN_PROVIDERS;
|
|
1226
|
+
if (options.agents && options.preset) throw new Error("resolveConfig: pass either `preset` (single-agent legacy) or `agents` (multi-profile registry), not both. Migrate `preset` into an entry inside `agents` if you want both worlds.");
|
|
1227
|
+
const agents = options.agents ?? (options.preset ? singleAgentRegistry(options.preset) : BUILTIN_AGENTS);
|
|
1228
|
+
if (Object.keys(agents).length === 0) throw new Error("resolveConfig: `agents` registry is empty — at least one profile is required.");
|
|
1229
|
+
process.env.ZIDANE_CREDENTIALS_PATH = credentialsPath(paths.userDir);
|
|
1230
|
+
applyApiKeyEnv(paths.userDir, providers);
|
|
1231
|
+
const modelsFor = makeModelsResolver(providers);
|
|
1232
|
+
const resumeProvider = resolveResumeProvider(initialState, providers, paths.userDir);
|
|
1233
|
+
const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
|
|
1234
|
+
const initialAgentId = resolveAgentId(agents, initialState.lastAgent, options.defaultAgent ?? "build");
|
|
1235
|
+
if (!initialAgentId) throw new Error("resolveConfig: failed to resolve an initial agent id from a non-empty registry.");
|
|
1236
|
+
return {
|
|
1237
|
+
prefix,
|
|
1238
|
+
storageDir,
|
|
1239
|
+
paths,
|
|
1240
|
+
providers,
|
|
1241
|
+
agents,
|
|
1242
|
+
initialAgentId,
|
|
1243
|
+
store,
|
|
1244
|
+
stateStore,
|
|
1245
|
+
modelsFor,
|
|
1246
|
+
initialState,
|
|
1247
|
+
initialSettings: initialState.settings ?? {},
|
|
1248
|
+
resumeProvider,
|
|
1249
|
+
initialPicked
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Compute the on-disk layout for a single TUI session. Public so hosts
|
|
1254
|
+
* (CLI tools, tests, scripted bootstraps) can ask "where will sessions
|
|
1255
|
+
* land?" without going through `resolveConfig`'s full machinery.
|
|
1256
|
+
*
|
|
1257
|
+
* Decision logic for project mode (least → most specific):
|
|
1258
|
+
*
|
|
1259
|
+
* 1. Default: ON (`projectDb: true`).
|
|
1260
|
+
* 2. `~/.{prefix}/config.json` `projectDb` flag (when present).
|
|
1261
|
+
* 3. `ZIDANE_PROJECT_DB` env var (`'0'` / `'false'` / `'off'` → off,
|
|
1262
|
+
* anything else true if set).
|
|
1263
|
+
* 4. `options.projectDb` (host call site).
|
|
1264
|
+
*
|
|
1265
|
+
* Project mode activates only when the resolution above lands on
|
|
1266
|
+
* `true` AND a git repo is found above `cwd`. Outside git or with the
|
|
1267
|
+
* flag off, sessions + state stay at the user dir.
|
|
1268
|
+
*
|
|
1269
|
+
* Auto-creates the project `.{prefix}/` directory when mode resolves
|
|
1270
|
+
* to ON (silent — mirrors `ensureStateDir`). User dir is created
|
|
1271
|
+
* lazily by the state-store + credentials writers; we don't pre-touch
|
|
1272
|
+
* it here so a read-only invocation stays read-only.
|
|
1273
|
+
*/
|
|
1274
|
+
function resolveStoragePaths(opts) {
|
|
1275
|
+
const userDir = resolve(opts.storageDir, opts.prefix);
|
|
1276
|
+
const userConfig = loadUserConfig(userDir);
|
|
1277
|
+
const envFlag = parseEnvFlag(process.env.ZIDANE_PROJECT_DB);
|
|
1278
|
+
const gitRoot = opts.projectDb ?? envFlag ?? userConfig.projectDb ?? false ? findGitRoot$1(opts.cwd) : null;
|
|
1279
|
+
const projectDir = gitRoot ? resolve(gitRoot, opts.prefix) : null;
|
|
1280
|
+
if (projectDir && !existsSync(projectDir)) try {
|
|
1281
|
+
mkdirSync(projectDir, { recursive: true });
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
if (process.env.ZIDANE_DEBUG) {
|
|
1284
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
1285
|
+
process.stderr.write(`[zidane/chat] project-db: mkdir "${projectDir}" failed: ${cause}\n`);
|
|
1286
|
+
}
|
|
1287
|
+
return userOnlyPaths(userDir);
|
|
1288
|
+
}
|
|
1289
|
+
const effective = projectDir ?? userDir;
|
|
1290
|
+
return {
|
|
1291
|
+
dir: effective,
|
|
1292
|
+
userDir,
|
|
1293
|
+
projectDir,
|
|
1294
|
+
db: resolve(effective, "sessions.db"),
|
|
1295
|
+
state: resolve(effective, "state.json")
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function userOnlyPaths(userDir) {
|
|
1299
|
+
return {
|
|
1300
|
+
dir: userDir,
|
|
1301
|
+
userDir,
|
|
1302
|
+
projectDir: null,
|
|
1303
|
+
db: resolve(userDir, "sessions.db"),
|
|
1304
|
+
state: resolve(userDir, "state.json")
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
/** Tri-state parse for env-var bools — explicit `null` keeps absence from forcing a default. */
|
|
1308
|
+
function parseEnvFlag(raw) {
|
|
1309
|
+
if (raw === void 0) return void 0;
|
|
1310
|
+
const v = raw.trim().toLowerCase();
|
|
1311
|
+
if (v === "0" || v === "false" || v === "off" || v === "no") return false;
|
|
1312
|
+
if (v === "1" || v === "true" || v === "on" || v === "yes") return true;
|
|
1313
|
+
}
|
|
1314
|
+
function makeModelsResolver(registry) {
|
|
1315
|
+
return (key) => {
|
|
1316
|
+
const descriptor = registry[key];
|
|
1317
|
+
return descriptor ? modelsForDescriptor(descriptor) : [];
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
function resolveResumeProvider(state, providers, storageDir) {
|
|
1321
|
+
if (!state.lastProvider) return null;
|
|
1322
|
+
if (!providers[state.lastProvider]) return null;
|
|
1323
|
+
return detectAuth(storageDir, providers).find((p) => p.key === state.lastProvider && p.available) ?? null;
|
|
1324
|
+
}
|
|
1325
|
+
function pickInitial(auth, providers, state) {
|
|
1326
|
+
const descriptor = providers[auth.key];
|
|
1327
|
+
if (!descriptor) return null;
|
|
1328
|
+
const model = state.lastModelByProvider?.[auth.key] ?? descriptor.defaultModel ?? safeFactoryDefault(descriptor);
|
|
1329
|
+
return model ? {
|
|
1330
|
+
provider: auth,
|
|
1331
|
+
model
|
|
1332
|
+
} : null;
|
|
1333
|
+
}
|
|
1334
|
+
function safeFactoryDefault(descriptor) {
|
|
1335
|
+
try {
|
|
1336
|
+
return descriptor.factory().meta.defaultModel;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
1342
|
+
//#region src/chat/config-context.tsx
|
|
1343
|
+
const ConfigContext = createContext(null);
|
|
1344
|
+
function ConfigProvider({ config, children }) {
|
|
1345
|
+
return /* @__PURE__ */ jsx(ConfigContext.Provider, {
|
|
1346
|
+
value: config,
|
|
1347
|
+
children
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function useConfig() {
|
|
1351
|
+
const ctx = useContext(ConfigContext);
|
|
1352
|
+
if (!ctx) throw new Error("useConfig must be used inside <ConfigProvider>");
|
|
1353
|
+
return ctx;
|
|
1354
|
+
}
|
|
1355
|
+
//#endregion
|
|
1356
|
+
//#region src/chat/themes/catppuccin.ts
|
|
1357
|
+
const LATTE = {
|
|
1358
|
+
rosewater: "#dc8a78",
|
|
1359
|
+
flamingo: "#dd7878",
|
|
1360
|
+
pink: "#ea76cb",
|
|
1361
|
+
mauve: "#8839ef",
|
|
1362
|
+
red: "#d20f39",
|
|
1363
|
+
maroon: "#e64553",
|
|
1364
|
+
peach: "#fe640b",
|
|
1365
|
+
yellow: "#df8e1d",
|
|
1366
|
+
green: "#40a02b",
|
|
1367
|
+
teal: "#179299",
|
|
1368
|
+
sky: "#04a5e5",
|
|
1369
|
+
sapphire: "#209fb5",
|
|
1370
|
+
blue: "#1e66f5",
|
|
1371
|
+
lavender: "#7287fd",
|
|
1372
|
+
text: "#4c4f69",
|
|
1373
|
+
subtext1: "#5c5f77",
|
|
1374
|
+
subtext0: "#6c6f85",
|
|
1375
|
+
overlay2: "#7c7f93",
|
|
1376
|
+
overlay1: "#8c8fa1",
|
|
1377
|
+
overlay0: "#9ca0b0",
|
|
1378
|
+
surface2: "#acb0be",
|
|
1379
|
+
surface1: "#bcc0cc",
|
|
1380
|
+
surface0: "#ccd0da",
|
|
1381
|
+
base: "#eff1f5",
|
|
1382
|
+
mantle: "#e6e9ef",
|
|
1383
|
+
crust: "#dce0e8"
|
|
1384
|
+
};
|
|
1385
|
+
const FRAPPE = {
|
|
1386
|
+
rosewater: "#f2d5cf",
|
|
1387
|
+
flamingo: "#eebebe",
|
|
1388
|
+
pink: "#f4b8e4",
|
|
1389
|
+
mauve: "#ca9ee6",
|
|
1390
|
+
red: "#e78284",
|
|
1391
|
+
maroon: "#ea999c",
|
|
1392
|
+
peach: "#ef9f76",
|
|
1393
|
+
yellow: "#e5c890",
|
|
1394
|
+
green: "#a6d189",
|
|
1395
|
+
teal: "#81c8be",
|
|
1396
|
+
sky: "#99d1db",
|
|
1397
|
+
sapphire: "#85c1dc",
|
|
1398
|
+
blue: "#8caaee",
|
|
1399
|
+
lavender: "#babbf1",
|
|
1400
|
+
text: "#c6d0f5",
|
|
1401
|
+
subtext1: "#b5bfe2",
|
|
1402
|
+
subtext0: "#a5adce",
|
|
1403
|
+
overlay2: "#949cbb",
|
|
1404
|
+
overlay1: "#838ba7",
|
|
1405
|
+
overlay0: "#737994",
|
|
1406
|
+
surface2: "#626880",
|
|
1407
|
+
surface1: "#51576d",
|
|
1408
|
+
surface0: "#414559",
|
|
1409
|
+
base: "#303446",
|
|
1410
|
+
mantle: "#292c3c",
|
|
1411
|
+
crust: "#232634"
|
|
1412
|
+
};
|
|
1413
|
+
const MACCHIATO = {
|
|
1414
|
+
rosewater: "#f4dbd6",
|
|
1415
|
+
flamingo: "#f0c6c6",
|
|
1416
|
+
pink: "#f5bde6",
|
|
1417
|
+
mauve: "#c6a0f6",
|
|
1418
|
+
red: "#ed8796",
|
|
1419
|
+
maroon: "#ee99a0",
|
|
1420
|
+
peach: "#f5a97f",
|
|
1421
|
+
yellow: "#eed49f",
|
|
1422
|
+
green: "#a6da95",
|
|
1423
|
+
teal: "#8bd5ca",
|
|
1424
|
+
sky: "#91d7e3",
|
|
1425
|
+
sapphire: "#7dc4e4",
|
|
1426
|
+
blue: "#8aadf4",
|
|
1427
|
+
lavender: "#b7bdf8",
|
|
1428
|
+
text: "#cad3f5",
|
|
1429
|
+
subtext1: "#b8c0e0",
|
|
1430
|
+
subtext0: "#a5adcb",
|
|
1431
|
+
overlay2: "#939ab7",
|
|
1432
|
+
overlay1: "#8087a2",
|
|
1433
|
+
overlay0: "#6e738d",
|
|
1434
|
+
surface2: "#5b6078",
|
|
1435
|
+
surface1: "#494d64",
|
|
1436
|
+
surface0: "#363a4f",
|
|
1437
|
+
base: "#24273a",
|
|
1438
|
+
mantle: "#1e2030",
|
|
1439
|
+
crust: "#181926"
|
|
1440
|
+
};
|
|
1441
|
+
const MOCHA = {
|
|
1442
|
+
rosewater: "#f5e0dc",
|
|
1443
|
+
flamingo: "#f2cdcd",
|
|
1444
|
+
pink: "#f5c2e7",
|
|
1445
|
+
mauve: "#cba6f7",
|
|
1446
|
+
red: "#f38ba8",
|
|
1447
|
+
maroon: "#eba0ac",
|
|
1448
|
+
peach: "#fab387",
|
|
1449
|
+
yellow: "#f9e2af",
|
|
1450
|
+
green: "#a6e3a1",
|
|
1451
|
+
teal: "#94e2d5",
|
|
1452
|
+
sky: "#89dceb",
|
|
1453
|
+
sapphire: "#74c7ec",
|
|
1454
|
+
blue: "#89b4fa",
|
|
1455
|
+
lavender: "#b4befe",
|
|
1456
|
+
text: "#cdd6f4",
|
|
1457
|
+
subtext1: "#bac2de",
|
|
1458
|
+
subtext0: "#a6adc8",
|
|
1459
|
+
overlay2: "#9399b2",
|
|
1460
|
+
overlay1: "#7f849c",
|
|
1461
|
+
overlay0: "#6c7086",
|
|
1462
|
+
surface2: "#585b70",
|
|
1463
|
+
surface1: "#45475a",
|
|
1464
|
+
surface0: "#313244",
|
|
1465
|
+
base: "#1e1e2e",
|
|
1466
|
+
mantle: "#181825",
|
|
1467
|
+
crust: "#11111b"
|
|
1468
|
+
};
|
|
1469
|
+
/**
|
|
1470
|
+
* Compose a `Theme` from a Catppuccin palette flavor.
|
|
1471
|
+
*
|
|
1472
|
+
* Role-color picks follow the upstream Catppuccin styleguide:
|
|
1473
|
+
* - `mauve` is the canonical accent — used here as the brand color.
|
|
1474
|
+
* - `green` / `red` / `yellow` keep their universal semantic meaning.
|
|
1475
|
+
* - `blue` carries function / model identity (matches the VSCode plugin's
|
|
1476
|
+
* `support.function` mapping).
|
|
1477
|
+
* - `subtext1` / `overlay0` form the dim/mute pair (one step apart so the
|
|
1478
|
+
* two tiers stay visually distinct on every flavor).
|
|
1479
|
+
* - `surface1` / `overlay0` give the resting/active border pair.
|
|
1480
|
+
*
|
|
1481
|
+
* Syntax token mappings line up with the official Catppuccin token rules
|
|
1482
|
+
* (keyword = mauve, string = green, function = blue, type = yellow, …) so
|
|
1483
|
+
* code fences match what users see in their editor.
|
|
1484
|
+
*/
|
|
1485
|
+
function catppuccinTheme(id, label, p) {
|
|
1486
|
+
return {
|
|
1487
|
+
id,
|
|
1488
|
+
label,
|
|
1489
|
+
colors: {
|
|
1490
|
+
brand: p.mauve,
|
|
1491
|
+
accent: p.green,
|
|
1492
|
+
model: p.blue,
|
|
1493
|
+
warn: p.yellow,
|
|
1494
|
+
error: p.red,
|
|
1495
|
+
dim: p.subtext1,
|
|
1496
|
+
mute: p.overlay0,
|
|
1497
|
+
border: p.surface1,
|
|
1498
|
+
borderActive: p.overlay0
|
|
1499
|
+
},
|
|
1500
|
+
select: {
|
|
1501
|
+
backgroundColor: "transparent",
|
|
1502
|
+
focusedBackgroundColor: "transparent",
|
|
1503
|
+
selectedBackgroundColor: "transparent",
|
|
1504
|
+
selectedTextColor: p.mauve,
|
|
1505
|
+
textColor: p.subtext1,
|
|
1506
|
+
descriptionColor: p.overlay0,
|
|
1507
|
+
selectedDescriptionColor: p.subtext0
|
|
1508
|
+
},
|
|
1509
|
+
surfaces: {
|
|
1510
|
+
modal: p.mantle,
|
|
1511
|
+
chips: {
|
|
1512
|
+
default: {
|
|
1513
|
+
bg: p.mauve,
|
|
1514
|
+
fg: p.base
|
|
1515
|
+
},
|
|
1516
|
+
skills: {
|
|
1517
|
+
bg: p.mauve,
|
|
1518
|
+
fg: p.base
|
|
1519
|
+
},
|
|
1520
|
+
files: {
|
|
1521
|
+
bg: p.blue,
|
|
1522
|
+
fg: p.base
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
selection: p.surface0
|
|
1526
|
+
},
|
|
1527
|
+
syntax: {
|
|
1528
|
+
"default": { fg: p.text },
|
|
1529
|
+
"markup.heading": {
|
|
1530
|
+
fg: p.mauve,
|
|
1531
|
+
bold: true
|
|
1532
|
+
},
|
|
1533
|
+
"markup.heading.1": {
|
|
1534
|
+
fg: p.mauve,
|
|
1535
|
+
bold: true
|
|
1536
|
+
},
|
|
1537
|
+
"markup.heading.2": {
|
|
1538
|
+
fg: p.lavender,
|
|
1539
|
+
bold: true
|
|
1540
|
+
},
|
|
1541
|
+
"markup.heading.3": {
|
|
1542
|
+
fg: p.blue,
|
|
1543
|
+
bold: true
|
|
1544
|
+
},
|
|
1545
|
+
"markup.bold": {
|
|
1546
|
+
fg: p.text,
|
|
1547
|
+
bold: true
|
|
1548
|
+
},
|
|
1549
|
+
"markup.strong": {
|
|
1550
|
+
fg: p.text,
|
|
1551
|
+
bold: true
|
|
1552
|
+
},
|
|
1553
|
+
"markup.italic": {
|
|
1554
|
+
fg: p.text,
|
|
1555
|
+
italic: true
|
|
1556
|
+
},
|
|
1557
|
+
"markup.link": {
|
|
1558
|
+
fg: p.sky,
|
|
1559
|
+
underline: true
|
|
1560
|
+
},
|
|
1561
|
+
"markup.link.url": {
|
|
1562
|
+
fg: p.sky,
|
|
1563
|
+
underline: true
|
|
1564
|
+
},
|
|
1565
|
+
"markup.list": { fg: p.peach },
|
|
1566
|
+
"markup.raw": { fg: p.green },
|
|
1567
|
+
"markup.raw.block": { fg: p.green },
|
|
1568
|
+
"markup.quote": {
|
|
1569
|
+
fg: p.overlay2,
|
|
1570
|
+
italic: true
|
|
1571
|
+
},
|
|
1572
|
+
"keyword": {
|
|
1573
|
+
fg: p.mauve,
|
|
1574
|
+
bold: true
|
|
1575
|
+
},
|
|
1576
|
+
"keyword.import": {
|
|
1577
|
+
fg: p.pink,
|
|
1578
|
+
bold: true
|
|
1579
|
+
},
|
|
1580
|
+
"keyword.operator": { fg: p.sky },
|
|
1581
|
+
"string": { fg: p.green },
|
|
1582
|
+
"string.escape": {
|
|
1583
|
+
fg: p.pink,
|
|
1584
|
+
bold: true
|
|
1585
|
+
},
|
|
1586
|
+
"character": { fg: p.teal },
|
|
1587
|
+
"comment": {
|
|
1588
|
+
fg: p.overlay1,
|
|
1589
|
+
italic: true
|
|
1590
|
+
},
|
|
1591
|
+
"number": { fg: p.peach },
|
|
1592
|
+
"boolean": { fg: p.peach },
|
|
1593
|
+
"constant": { fg: p.peach },
|
|
1594
|
+
"constant.builtin": { fg: p.peach },
|
|
1595
|
+
"function": { fg: p.blue },
|
|
1596
|
+
"function.call": { fg: p.blue },
|
|
1597
|
+
"function.method": { fg: p.blue },
|
|
1598
|
+
"function.method.call": { fg: p.blue },
|
|
1599
|
+
"function.builtin": { fg: p.blue },
|
|
1600
|
+
"function.macro": { fg: p.teal },
|
|
1601
|
+
"type": { fg: p.yellow },
|
|
1602
|
+
"type.builtin": { fg: p.yellow },
|
|
1603
|
+
"constructor": { fg: p.yellow },
|
|
1604
|
+
"attribute": { fg: p.yellow },
|
|
1605
|
+
"tag": { fg: p.lavender },
|
|
1606
|
+
"variable": { fg: p.text },
|
|
1607
|
+
"variable.builtin": { fg: p.red },
|
|
1608
|
+
"variable.parameter": {
|
|
1609
|
+
fg: p.maroon,
|
|
1610
|
+
italic: true
|
|
1611
|
+
},
|
|
1612
|
+
"variable.member": { fg: p.text },
|
|
1613
|
+
"property": { fg: p.lavender },
|
|
1614
|
+
"operator": { fg: p.sky },
|
|
1615
|
+
"punctuation": { fg: p.overlay2 },
|
|
1616
|
+
"punctuation.bracket": { fg: p.overlay2 },
|
|
1617
|
+
"punctuation.delimiter": { fg: p.overlay2 },
|
|
1618
|
+
"label": { fg: p.sapphire }
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
const CATPPUCCIN_MOCHA = catppuccinTheme("catppuccin-mocha", "Catppuccin Mocha", MOCHA);
|
|
1623
|
+
const CATPPUCCIN_MACCHIATO = catppuccinTheme("catppuccin-macchiato", "Catppuccin Macchiato", MACCHIATO);
|
|
1624
|
+
const CATPPUCCIN_FRAPPE = catppuccinTheme("catppuccin-frappe", "Catppuccin Frappé", FRAPPE);
|
|
1625
|
+
const CATPPUCCIN_LATTE = catppuccinTheme("catppuccin-latte", "Catppuccin Latte (light)", LATTE);
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/chat/themes/vaporwave.ts
|
|
1628
|
+
const PALETTE = {
|
|
1629
|
+
pink: "#E95378",
|
|
1630
|
+
pinkBright: "#FF71CE",
|
|
1631
|
+
cyan: "#01CDFE",
|
|
1632
|
+
cyanBright: "#59E1E3",
|
|
1633
|
+
blue: "#94D0FF",
|
|
1634
|
+
green: "#29D398",
|
|
1635
|
+
greenBright: "#09F7A0",
|
|
1636
|
+
yellow: "#FFFB96",
|
|
1637
|
+
red: "#F43E5C",
|
|
1638
|
+
text: "#D5D8DA",
|
|
1639
|
+
/**
|
|
1640
|
+
* ~70% of `text` — used for captions / helper / "dim" text so that
|
|
1641
|
+
* secondary copy is visually distinct from primary copy. Matches the
|
|
1642
|
+
* upstream theme's sidebar foreground (`#b7c5d3`) tonality.
|
|
1643
|
+
*/
|
|
1644
|
+
textDim: "#B7C5D3",
|
|
1645
|
+
textBright: "#EEFFFF",
|
|
1646
|
+
comment: "#BBBBBB",
|
|
1647
|
+
muted: "#6C6F93",
|
|
1648
|
+
surface: "#2E303E",
|
|
1649
|
+
panel: "#232530"
|
|
1650
|
+
};
|
|
1651
|
+
const VAPORWAVE_THEME = {
|
|
1652
|
+
id: "vaporwave",
|
|
1653
|
+
label: "Vaporwave",
|
|
1654
|
+
colors: {
|
|
1655
|
+
brand: PALETTE.pink,
|
|
1656
|
+
accent: PALETTE.greenBright,
|
|
1657
|
+
model: PALETTE.blue,
|
|
1658
|
+
warn: PALETTE.yellow,
|
|
1659
|
+
error: PALETTE.red,
|
|
1660
|
+
dim: PALETTE.textDim,
|
|
1661
|
+
mute: PALETTE.muted,
|
|
1662
|
+
border: PALETTE.surface,
|
|
1663
|
+
borderActive: PALETTE.muted
|
|
1664
|
+
},
|
|
1665
|
+
select: {
|
|
1666
|
+
backgroundColor: "transparent",
|
|
1667
|
+
focusedBackgroundColor: "transparent",
|
|
1668
|
+
selectedBackgroundColor: "transparent",
|
|
1669
|
+
selectedTextColor: PALETTE.pink,
|
|
1670
|
+
textColor: PALETTE.text,
|
|
1671
|
+
descriptionColor: PALETTE.muted,
|
|
1672
|
+
selectedDescriptionColor: PALETTE.text
|
|
1673
|
+
},
|
|
1674
|
+
surfaces: {
|
|
1675
|
+
modal: PALETTE.panel,
|
|
1676
|
+
chips: {
|
|
1677
|
+
default: {
|
|
1678
|
+
bg: PALETTE.pink,
|
|
1679
|
+
fg: PALETTE.panel
|
|
1680
|
+
},
|
|
1681
|
+
skills: {
|
|
1682
|
+
bg: PALETTE.pink,
|
|
1683
|
+
fg: PALETTE.panel
|
|
1684
|
+
},
|
|
1685
|
+
files: {
|
|
1686
|
+
bg: PALETTE.cyan,
|
|
1687
|
+
fg: PALETTE.panel
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
selection: PALETTE.surface
|
|
1691
|
+
},
|
|
1692
|
+
syntax: {
|
|
1693
|
+
"default": { fg: PALETTE.text },
|
|
1694
|
+
"markup.heading": {
|
|
1695
|
+
fg: PALETTE.pink,
|
|
1696
|
+
bold: true
|
|
1697
|
+
},
|
|
1698
|
+
"markup.heading.1": {
|
|
1699
|
+
fg: PALETTE.pink,
|
|
1700
|
+
bold: true
|
|
1701
|
+
},
|
|
1702
|
+
"markup.heading.2": {
|
|
1703
|
+
fg: PALETTE.pinkBright,
|
|
1704
|
+
bold: true
|
|
1705
|
+
},
|
|
1706
|
+
"markup.heading.3": {
|
|
1707
|
+
fg: PALETTE.blue,
|
|
1708
|
+
bold: true
|
|
1709
|
+
},
|
|
1710
|
+
"markup.bold": {
|
|
1711
|
+
fg: PALETTE.textBright,
|
|
1712
|
+
bold: true
|
|
1713
|
+
},
|
|
1714
|
+
"markup.strong": {
|
|
1715
|
+
fg: PALETTE.textBright,
|
|
1716
|
+
bold: true
|
|
1717
|
+
},
|
|
1718
|
+
"markup.italic": {
|
|
1719
|
+
fg: PALETTE.greenBright,
|
|
1720
|
+
italic: true
|
|
1721
|
+
},
|
|
1722
|
+
"markup.link": {
|
|
1723
|
+
fg: PALETTE.yellow,
|
|
1724
|
+
underline: true
|
|
1725
|
+
},
|
|
1726
|
+
"markup.link.url": {
|
|
1727
|
+
fg: PALETTE.yellow,
|
|
1728
|
+
underline: true
|
|
1729
|
+
},
|
|
1730
|
+
"markup.list": { fg: PALETTE.textBright },
|
|
1731
|
+
"markup.raw": { fg: PALETTE.yellow },
|
|
1732
|
+
"markup.raw.block": { fg: PALETTE.yellow },
|
|
1733
|
+
"markup.quote": {
|
|
1734
|
+
fg: PALETTE.yellow,
|
|
1735
|
+
italic: true
|
|
1736
|
+
},
|
|
1737
|
+
"keyword": {
|
|
1738
|
+
fg: PALETTE.pinkBright,
|
|
1739
|
+
bold: true
|
|
1740
|
+
},
|
|
1741
|
+
"keyword.import": {
|
|
1742
|
+
fg: PALETTE.pinkBright,
|
|
1743
|
+
bold: true
|
|
1744
|
+
},
|
|
1745
|
+
"keyword.operator": { fg: PALETTE.comment },
|
|
1746
|
+
"string": { fg: PALETTE.yellow },
|
|
1747
|
+
"string.escape": {
|
|
1748
|
+
fg: PALETTE.greenBright,
|
|
1749
|
+
bold: true
|
|
1750
|
+
},
|
|
1751
|
+
"character": { fg: PALETTE.yellow },
|
|
1752
|
+
"comment": {
|
|
1753
|
+
fg: PALETTE.comment,
|
|
1754
|
+
italic: true
|
|
1755
|
+
},
|
|
1756
|
+
"number": { fg: PALETTE.textBright },
|
|
1757
|
+
"boolean": { fg: PALETTE.textBright },
|
|
1758
|
+
"constant": { fg: PALETTE.textBright },
|
|
1759
|
+
"constant.builtin": { fg: PALETTE.textBright },
|
|
1760
|
+
"function": { fg: PALETTE.greenBright },
|
|
1761
|
+
"function.call": { fg: PALETTE.greenBright },
|
|
1762
|
+
"function.method": { fg: PALETTE.greenBright },
|
|
1763
|
+
"function.method.call": { fg: PALETTE.greenBright },
|
|
1764
|
+
"function.builtin": { fg: PALETTE.greenBright },
|
|
1765
|
+
"function.macro": { fg: PALETTE.greenBright },
|
|
1766
|
+
"type": { fg: PALETTE.greenBright },
|
|
1767
|
+
"type.builtin": { fg: PALETTE.greenBright },
|
|
1768
|
+
"constructor": { fg: PALETTE.greenBright },
|
|
1769
|
+
"attribute": { fg: PALETTE.yellow },
|
|
1770
|
+
"tag": { fg: PALETTE.blue },
|
|
1771
|
+
"variable": { fg: PALETTE.blue },
|
|
1772
|
+
"variable.builtin": { fg: PALETTE.cyanBright },
|
|
1773
|
+
"variable.parameter": { fg: PALETTE.text },
|
|
1774
|
+
"variable.member": { fg: PALETTE.blue },
|
|
1775
|
+
"property": { fg: PALETTE.blue },
|
|
1776
|
+
"operator": { fg: PALETTE.comment },
|
|
1777
|
+
"punctuation": { fg: PALETTE.comment },
|
|
1778
|
+
"punctuation.bracket": { fg: PALETTE.textBright },
|
|
1779
|
+
"punctuation.delimiter": { fg: PALETTE.comment },
|
|
1780
|
+
"label": { fg: PALETTE.cyan }
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/chat/theme.ts
|
|
1785
|
+
/**
|
|
1786
|
+
* Renderer-agnostic theme system.
|
|
1787
|
+
*
|
|
1788
|
+
* A `Theme` bundles every variable that can change visually between
|
|
1789
|
+
* "themes" — colors, select-row styling, code/markdown syntax highlight
|
|
1790
|
+
* tokens, panel backgrounds. Plain JSON: no OpenTUI dependency, no React,
|
|
1791
|
+
* no functions. The TUI consumes the theme by reading from `useTheme()`
|
|
1792
|
+
* and converting hex strings into OpenTUI's `RGBA`/`SyntaxStyle`; a future
|
|
1793
|
+
* GUI consumes the same theme by converting into CSS variables or Tailwind
|
|
1794
|
+
* tokens.
|
|
1795
|
+
*
|
|
1796
|
+
* Components should always read theme values through `useTheme()` /
|
|
1797
|
+
* `useColors()` so a runtime theme switch (Settings → Theme) re-paints
|
|
1798
|
+
* the whole tree. Importing a raw theme object directly bypasses the
|
|
1799
|
+
* context and pins the component to a single look.
|
|
1800
|
+
*
|
|
1801
|
+
* Built-in flavors (Catppuccin, Vaporwave) live in `./themes/`. This file
|
|
1802
|
+
* just defines the shape + the default theme + the registry.
|
|
1803
|
+
*/
|
|
1804
|
+
/**
|
|
1805
|
+
* Look up the chip color pair for a provider id, falling back to
|
|
1806
|
+
* `chips.default` when the theme has no kind-specific entry. Mirrors
|
|
1807
|
+
* `resolveChipStyleId` so both rendering surfaces (submitted echo +
|
|
1808
|
+
* live textarea) share one canonical lookup rule.
|
|
1809
|
+
*/
|
|
1810
|
+
function resolveChipColor(chips, providerId) {
|
|
1811
|
+
return chips[providerId] ?? chips.default;
|
|
1812
|
+
}
|
|
1813
|
+
const DEFAULT_COLORS = {
|
|
1814
|
+
brand: "#2ba6ff",
|
|
1815
|
+
accent: "#22c55e",
|
|
1816
|
+
model: "#8adaff",
|
|
1817
|
+
warn: "#53c4ff",
|
|
1818
|
+
error: "#ef4444",
|
|
1819
|
+
dim: "#a3a3ac",
|
|
1820
|
+
mute: "#525258",
|
|
1821
|
+
border: "#3c3c41",
|
|
1822
|
+
borderActive: "#1488fc"
|
|
1823
|
+
};
|
|
1824
|
+
const DEFAULT_THEME = {
|
|
1825
|
+
id: "default",
|
|
1826
|
+
label: "Default",
|
|
1827
|
+
colors: DEFAULT_COLORS,
|
|
1828
|
+
select: {
|
|
1829
|
+
backgroundColor: "transparent",
|
|
1830
|
+
focusedBackgroundColor: "transparent",
|
|
1831
|
+
selectedBackgroundColor: "transparent",
|
|
1832
|
+
selectedTextColor: DEFAULT_COLORS.brand,
|
|
1833
|
+
textColor: DEFAULT_COLORS.dim,
|
|
1834
|
+
descriptionColor: DEFAULT_COLORS.mute,
|
|
1835
|
+
selectedDescriptionColor: DEFAULT_COLORS.dim
|
|
1836
|
+
},
|
|
1837
|
+
surfaces: {
|
|
1838
|
+
modal: "#111114",
|
|
1839
|
+
chips: {
|
|
1840
|
+
default: {
|
|
1841
|
+
bg: DEFAULT_COLORS.brand,
|
|
1842
|
+
fg: "#eef9ff"
|
|
1843
|
+
},
|
|
1844
|
+
skills: {
|
|
1845
|
+
bg: DEFAULT_COLORS.brand,
|
|
1846
|
+
fg: "#eef9ff"
|
|
1847
|
+
},
|
|
1848
|
+
files: {
|
|
1849
|
+
bg: "#8adaff",
|
|
1850
|
+
fg: "#122f59"
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
selection: "#142737"
|
|
1854
|
+
},
|
|
1855
|
+
syntax: {
|
|
1856
|
+
"default": { fg: "#fefeff" },
|
|
1857
|
+
"markup.heading": {
|
|
1858
|
+
fg: "#2ba6ff",
|
|
1859
|
+
bold: true
|
|
1860
|
+
},
|
|
1861
|
+
"markup.heading.1": {
|
|
1862
|
+
fg: "#2ba6ff",
|
|
1863
|
+
bold: true
|
|
1864
|
+
},
|
|
1865
|
+
"markup.heading.2": {
|
|
1866
|
+
fg: "#53c4ff",
|
|
1867
|
+
bold: true
|
|
1868
|
+
},
|
|
1869
|
+
"markup.heading.3": {
|
|
1870
|
+
fg: "#8adaff",
|
|
1871
|
+
bold: true
|
|
1872
|
+
},
|
|
1873
|
+
"markup.bold": {
|
|
1874
|
+
fg: "#ffffff",
|
|
1875
|
+
bold: true
|
|
1876
|
+
},
|
|
1877
|
+
"markup.strong": {
|
|
1878
|
+
fg: "#ffffff",
|
|
1879
|
+
bold: true
|
|
1880
|
+
},
|
|
1881
|
+
"markup.italic": {
|
|
1882
|
+
fg: "#fefeff",
|
|
1883
|
+
italic: true
|
|
1884
|
+
},
|
|
1885
|
+
"markup.link": {
|
|
1886
|
+
fg: DEFAULT_COLORS.model,
|
|
1887
|
+
underline: true
|
|
1888
|
+
},
|
|
1889
|
+
"markup.link.url": {
|
|
1890
|
+
fg: DEFAULT_COLORS.model,
|
|
1891
|
+
underline: true
|
|
1892
|
+
},
|
|
1893
|
+
"markup.list": { fg: "#fb923c" },
|
|
1894
|
+
"markup.raw": { fg: "#bae7ff" },
|
|
1895
|
+
"markup.raw.block": { fg: "#bae7ff" },
|
|
1896
|
+
"markup.quote": {
|
|
1897
|
+
fg: DEFAULT_COLORS.dim,
|
|
1898
|
+
italic: true
|
|
1899
|
+
},
|
|
1900
|
+
"keyword": {
|
|
1901
|
+
fg: "#f87171",
|
|
1902
|
+
bold: true
|
|
1903
|
+
},
|
|
1904
|
+
"keyword.import": {
|
|
1905
|
+
fg: "#f87171",
|
|
1906
|
+
bold: true
|
|
1907
|
+
},
|
|
1908
|
+
"keyword.operator": { fg: "#f87171" },
|
|
1909
|
+
"string": { fg: "#bae7ff" },
|
|
1910
|
+
"string.escape": {
|
|
1911
|
+
fg: "#bae7ff",
|
|
1912
|
+
bold: true
|
|
1913
|
+
},
|
|
1914
|
+
"character": { fg: "#bae7ff" },
|
|
1915
|
+
"comment": {
|
|
1916
|
+
fg: "#73737b",
|
|
1917
|
+
italic: true
|
|
1918
|
+
},
|
|
1919
|
+
"number": { fg: "#8adaff" },
|
|
1920
|
+
"boolean": { fg: "#8adaff" },
|
|
1921
|
+
"constant": { fg: "#8adaff" },
|
|
1922
|
+
"constant.builtin": { fg: "#8adaff" },
|
|
1923
|
+
"function": { fg: "#86efac" },
|
|
1924
|
+
"function.call": { fg: "#86efac" },
|
|
1925
|
+
"function.method": { fg: "#86efac" },
|
|
1926
|
+
"function.method.call": { fg: "#86efac" },
|
|
1927
|
+
"function.builtin": { fg: "#86efac" },
|
|
1928
|
+
"function.macro": { fg: "#86efac" },
|
|
1929
|
+
"type": { fg: "#fb923c" },
|
|
1930
|
+
"type.builtin": { fg: "#fb923c" },
|
|
1931
|
+
"constructor": { fg: "#fb923c" },
|
|
1932
|
+
"attribute": { fg: "#fb923c" },
|
|
1933
|
+
"tag": { fg: "#4ade80" },
|
|
1934
|
+
"variable": { fg: "#fefeff" },
|
|
1935
|
+
"variable.builtin": { fg: "#8adaff" },
|
|
1936
|
+
"variable.parameter": { fg: "#fb923c" },
|
|
1937
|
+
"variable.member": { fg: "#8adaff" },
|
|
1938
|
+
"property": { fg: "#8adaff" },
|
|
1939
|
+
"operator": { fg: "#f87171" },
|
|
1940
|
+
"punctuation": { fg: DEFAULT_COLORS.mute },
|
|
1941
|
+
"punctuation.bracket": { fg: "#fefeff" },
|
|
1942
|
+
"punctuation.delimiter": { fg: "#d4d4dd" },
|
|
1943
|
+
"label": { fg: "#8adaff" }
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
/**
|
|
1947
|
+
* Built-in theme registry, keyed by `theme.id`. The TUI looks up the active
|
|
1948
|
+
* theme here using `Settings.theme`; unknown ids fall back to
|
|
1949
|
+
* `DEFAULT_THEME`. Hosts can extend this by passing additional themes to a
|
|
1950
|
+
* future `runTui({ themes })` option (not yet wired).
|
|
1951
|
+
*
|
|
1952
|
+
* Insertion order is the picker cycle order — keep `default` first so a
|
|
1953
|
+
* fresh install (no `theme` in `state.json`) sees the familiar yellow theme
|
|
1954
|
+
* before the others.
|
|
1955
|
+
*/
|
|
1956
|
+
const BUILTIN_THEMES = {
|
|
1957
|
+
[DEFAULT_THEME.id]: DEFAULT_THEME,
|
|
1958
|
+
[CATPPUCCIN_MOCHA.id]: CATPPUCCIN_MOCHA,
|
|
1959
|
+
[CATPPUCCIN_MACCHIATO.id]: CATPPUCCIN_MACCHIATO,
|
|
1960
|
+
[CATPPUCCIN_FRAPPE.id]: CATPPUCCIN_FRAPPE,
|
|
1961
|
+
[CATPPUCCIN_LATTE.id]: CATPPUCCIN_LATTE,
|
|
1962
|
+
[VAPORWAVE_THEME.id]: VAPORWAVE_THEME
|
|
1963
|
+
};
|
|
1964
|
+
/** Resolve a theme id to its full `Theme`, falling back to default on unknown ids. */
|
|
1965
|
+
function resolveTheme(id) {
|
|
1966
|
+
if (id && BUILTIN_THEMES[id]) return BUILTIN_THEMES[id];
|
|
1967
|
+
return DEFAULT_THEME;
|
|
1968
|
+
}
|
|
1969
|
+
//#endregion
|
|
1970
|
+
//#region src/chat/settings-context.tsx
|
|
1971
|
+
const DEFAULT_SETTINGS = {
|
|
1972
|
+
showThinking: true,
|
|
1973
|
+
showToolCalls: true,
|
|
1974
|
+
showToolResults: true,
|
|
1975
|
+
safeMode: true,
|
|
1976
|
+
hideSubagentOutput: true,
|
|
1977
|
+
theme: DEFAULT_THEME.id,
|
|
1978
|
+
showAllProjects: false
|
|
1979
|
+
};
|
|
1980
|
+
const SettingsContext = createContext(null);
|
|
1981
|
+
function SettingsProvider({ initial, onChange, children }) {
|
|
1982
|
+
const [settings, setSettings] = useState(initial);
|
|
1983
|
+
const onChangeRef = useRef(onChange);
|
|
1984
|
+
onChangeRef.current = onChange;
|
|
1985
|
+
const initialMount = useRef(true);
|
|
1986
|
+
useEffect(() => {
|
|
1987
|
+
if (initialMount.current) {
|
|
1988
|
+
initialMount.current = false;
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
onChangeRef.current?.(settings);
|
|
1992
|
+
}, [settings]);
|
|
1993
|
+
const toggle = useCallback((key) => {
|
|
1994
|
+
setSettings((prev) => ({
|
|
1995
|
+
...prev,
|
|
1996
|
+
[key]: !prev[key]
|
|
1997
|
+
}));
|
|
1998
|
+
}, []);
|
|
1999
|
+
const setSetting = useCallback((key, value) => {
|
|
2000
|
+
setSettings((prev) => prev[key] === value ? prev : {
|
|
2001
|
+
...prev,
|
|
2002
|
+
[key]: value
|
|
2003
|
+
});
|
|
2004
|
+
}, []);
|
|
2005
|
+
const value = useMemo(() => ({
|
|
2006
|
+
settings,
|
|
2007
|
+
toggle,
|
|
2008
|
+
setSetting
|
|
2009
|
+
}), [
|
|
2010
|
+
settings,
|
|
2011
|
+
toggle,
|
|
2012
|
+
setSetting
|
|
2013
|
+
]);
|
|
2014
|
+
return /* @__PURE__ */ jsx(SettingsContext.Provider, {
|
|
2015
|
+
value,
|
|
2016
|
+
children
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
function useSettings() {
|
|
2020
|
+
const ctx = useContext(SettingsContext);
|
|
2021
|
+
if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
|
|
2022
|
+
return ctx;
|
|
2023
|
+
}
|
|
2024
|
+
const SETTINGS_TOGGLES = [
|
|
2025
|
+
{
|
|
2026
|
+
key: "safeMode",
|
|
2027
|
+
label: "Safe mode",
|
|
2028
|
+
description: "prompt before each tool call (unless safelisted)"
|
|
2029
|
+
},
|
|
2030
|
+
{
|
|
2031
|
+
key: "hideSubagentOutput",
|
|
2032
|
+
label: "Hide subagent output",
|
|
2033
|
+
description: "collapse subagent runs to start/done markers"
|
|
2034
|
+
},
|
|
2035
|
+
{
|
|
2036
|
+
key: "showAllProjects",
|
|
2037
|
+
label: "All projects",
|
|
2038
|
+
description: "list sessions from every project (off = current only)"
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
key: "showThinking",
|
|
2042
|
+
label: "Thinking blocks",
|
|
2043
|
+
description: "agent reasoning shown inline"
|
|
2044
|
+
},
|
|
2045
|
+
{
|
|
2046
|
+
key: "showToolCalls",
|
|
2047
|
+
label: "Tool calls",
|
|
2048
|
+
description: "the ↳ name(args) lines"
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
key: "showToolResults",
|
|
2052
|
+
label: "Tool outputs",
|
|
2053
|
+
description: "the ┃ result blocks under tool calls"
|
|
2054
|
+
}
|
|
2055
|
+
];
|
|
2056
|
+
const SETTINGS_CHOICES = [{
|
|
2057
|
+
key: "theme",
|
|
2058
|
+
label: "Theme",
|
|
2059
|
+
description: "colors + markdown / syntax styles",
|
|
2060
|
+
options: Object.values(BUILTIN_THEMES).map((t) => ({
|
|
2061
|
+
value: t.id,
|
|
2062
|
+
label: t.label
|
|
2063
|
+
}))
|
|
2064
|
+
}];
|
|
2065
|
+
//#endregion
|
|
2066
|
+
//#region src/chat/enabled-toggle-set.ts
|
|
2067
|
+
/**
|
|
2068
|
+
* Renderer-agnostic state machine for an "enabled allowlist" — the shape
|
|
2069
|
+
* used by `settings.enabledSkills` / `settings.enabledMcps` (and any future
|
|
2070
|
+
* `enabledX` toggle backed by a `Settings` field).
|
|
2071
|
+
*
|
|
2072
|
+
* Semantics, kept identical across all three call sites:
|
|
2073
|
+
* - `undefined` → every catalog entry is enabled (default — user has
|
|
2074
|
+
* never opened the picker; new entries flow in).
|
|
2075
|
+
* - `[]` → the whole subsystem is off.
|
|
2076
|
+
* - `[names]` → explicit allowlist; the persisted shape stays a
|
|
2077
|
+
* concrete array once the user has toggled anything.
|
|
2078
|
+
*
|
|
2079
|
+
* The hook exposes a live `enabledSet` (Set<string>) + a `toggle(name)`
|
|
2080
|
+
* callback that persists the result through `useSettings().setSetting`.
|
|
2081
|
+
* The first toggle seeds the persisted allowlist from the current catalog
|
|
2082
|
+
* so newly-added entries don't silently drop on the next launch.
|
|
2083
|
+
*
|
|
2084
|
+
* Renderer-neutral: works for the TUI's `<ToggleListModal>`, a GUI
|
|
2085
|
+
* checkbox list, a CLI flag tool. Hook tests live alongside the chat
|
|
2086
|
+
* suite; the rendering layer is tested where it lives.
|
|
2087
|
+
*/
|
|
2088
|
+
/**
|
|
2089
|
+
* Bind an "enabled allowlist" setting to a discovered catalog.
|
|
2090
|
+
*
|
|
2091
|
+
* `keyOf` extracts the persisted identity from a catalog entry (skill name,
|
|
2092
|
+
* MCP server name, …). Pass a stable, collision-free key — the persisted
|
|
2093
|
+
* allowlist is keyed against it forever.
|
|
2094
|
+
*/
|
|
2095
|
+
function useEnabledToggleSet(opts) {
|
|
2096
|
+
const { settings, setSetting } = useSettings();
|
|
2097
|
+
const { catalog, keyOf, settingKey } = opts;
|
|
2098
|
+
const persisted = settings[settingKey];
|
|
2099
|
+
const enabledSet = useMemo(() => {
|
|
2100
|
+
if (persisted === void 0) return new Set(catalog.map(keyOf));
|
|
2101
|
+
return new Set(persisted);
|
|
2102
|
+
}, [
|
|
2103
|
+
persisted,
|
|
2104
|
+
catalog,
|
|
2105
|
+
keyOf
|
|
2106
|
+
]);
|
|
2107
|
+
const catalogKeys = useMemo(() => new Set(catalog.map(keyOf)), [catalog, keyOf]);
|
|
2108
|
+
return {
|
|
2109
|
+
enabledSet,
|
|
2110
|
+
toggle: useCallback((name) => {
|
|
2111
|
+
const next = new Set(enabledSet);
|
|
2112
|
+
if (next.has(name)) next.delete(name);
|
|
2113
|
+
else next.add(name);
|
|
2114
|
+
setSetting(settingKey, [...next].filter((n) => catalogKeys.has(n)).sort());
|
|
2115
|
+
}, [
|
|
2116
|
+
enabledSet,
|
|
2117
|
+
catalogKeys,
|
|
2118
|
+
settingKey,
|
|
2119
|
+
setSetting
|
|
2120
|
+
])
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
//#endregion
|
|
2124
|
+
//#region src/chat/files-discovery.ts
|
|
2125
|
+
/**
|
|
2126
|
+
* Hard cap on the discovered file count. Bigger than any reasonable repo's
|
|
2127
|
+
* source tree, small enough that a runaway scan can't pin the renderer's
|
|
2128
|
+
* filter. Provider's substring match runs over the full list per
|
|
2129
|
+
* keystroke; 10k entries × cheap `.includes()` is well under one frame.
|
|
2130
|
+
*/
|
|
2131
|
+
const DEFAULT_MAX_FILES = 1e4;
|
|
2132
|
+
/**
|
|
2133
|
+
* Names skipped during the fs-walk fallback. Mirrors what `git ls-files`
|
|
2134
|
+
* would exclude via the default `.gitignore` shipped in most repos —
|
|
2135
|
+
* `node_modules`, `dist`, build caches — plus the `.git` dir itself.
|
|
2136
|
+
* Best-effort; the git path is the authoritative one.
|
|
2137
|
+
*/
|
|
2138
|
+
const FS_WALK_SKIP = new Set([
|
|
2139
|
+
".git",
|
|
2140
|
+
".hg",
|
|
2141
|
+
".svn",
|
|
2142
|
+
"node_modules",
|
|
2143
|
+
"dist",
|
|
2144
|
+
"build",
|
|
2145
|
+
"out",
|
|
2146
|
+
"coverage",
|
|
2147
|
+
".next",
|
|
2148
|
+
".nuxt",
|
|
2149
|
+
".cache",
|
|
2150
|
+
".turbo",
|
|
2151
|
+
".parcel-cache",
|
|
2152
|
+
".vercel",
|
|
2153
|
+
".svelte-kit",
|
|
2154
|
+
".expo",
|
|
2155
|
+
".gradle",
|
|
2156
|
+
"target",
|
|
2157
|
+
"__pycache__",
|
|
2158
|
+
".pytest_cache",
|
|
2159
|
+
".venv",
|
|
2160
|
+
"venv",
|
|
2161
|
+
".idea",
|
|
2162
|
+
".vscode-test"
|
|
2163
|
+
]);
|
|
2164
|
+
/**
|
|
2165
|
+
* Discover every non-ignored file under `cwd`. Tries `git ls-files` first;
|
|
2166
|
+
* on failure (no git, not a repo, abort) walks the fs with a hand-rolled
|
|
2167
|
+
* skip list.
|
|
2168
|
+
*
|
|
2169
|
+
* Errors are not thrown — the function always returns an array (possibly
|
|
2170
|
+
* empty). Callers wanting failure diagnostics can opt into them via
|
|
2171
|
+
* `ZIDANE_DEBUG`.
|
|
2172
|
+
*/
|
|
2173
|
+
async function listProjectFiles(opts = {}) {
|
|
2174
|
+
const cwd = resolve(opts.cwd ?? process.cwd());
|
|
2175
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
2176
|
+
const signal = opts.signal;
|
|
2177
|
+
try {
|
|
2178
|
+
return toEntries(await listViaGit(cwd, signal), "git", maxFiles);
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
if (process.env.ZIDANE_DEBUG) {
|
|
2181
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
2182
|
+
process.stderr.write(`[zidane/chat] git ls-files failed (${cause}) — falling back to fs walk\n`);
|
|
2183
|
+
}
|
|
2184
|
+
try {
|
|
2185
|
+
return toEntries(await listViaFs(cwd, maxFiles, signal), "fs", maxFiles);
|
|
2186
|
+
} catch (fsErr) {
|
|
2187
|
+
if (process.env.ZIDANE_DEBUG) {
|
|
2188
|
+
const cause = fsErr instanceof Error ? fsErr.message : String(fsErr);
|
|
2189
|
+
process.stderr.write(`[zidane/chat] fs walk failed: ${cause}\n`);
|
|
2190
|
+
}
|
|
2191
|
+
return [];
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Run `git ls-files --cached --others --exclude-standard` and return paths.
|
|
2197
|
+
*
|
|
2198
|
+
* - `--cached` — tracked files.
|
|
2199
|
+
* - `--others` — untracked but not ignored.
|
|
2200
|
+
* - `--exclude-standard` — apply `.gitignore`, `.git/info/exclude`, the
|
|
2201
|
+
* user's global excludes file, AND skip files marked `assume-unchanged`.
|
|
2202
|
+
*
|
|
2203
|
+
* Throws on non-zero exit (not a repo / no git) so the fallback path can
|
|
2204
|
+
* pick up. Aborts cleanly on `signal.aborted`.
|
|
2205
|
+
*/
|
|
2206
|
+
async function listViaGit(cwd, signal) {
|
|
2207
|
+
return new Promise((resolveP, rejectP) => {
|
|
2208
|
+
const child = spawn("git", [
|
|
2209
|
+
"ls-files",
|
|
2210
|
+
"--cached",
|
|
2211
|
+
"--others",
|
|
2212
|
+
"--exclude-standard",
|
|
2213
|
+
"-z"
|
|
2214
|
+
], {
|
|
2215
|
+
cwd,
|
|
2216
|
+
stdio: [
|
|
2217
|
+
"ignore",
|
|
2218
|
+
"pipe",
|
|
2219
|
+
"pipe"
|
|
2220
|
+
]
|
|
2221
|
+
});
|
|
2222
|
+
const stdoutChunks = [];
|
|
2223
|
+
const stderrChunks = [];
|
|
2224
|
+
const onAbort = () => child.kill("SIGTERM");
|
|
2225
|
+
if (signal) {
|
|
2226
|
+
if (signal.aborted) {
|
|
2227
|
+
child.kill("SIGTERM");
|
|
2228
|
+
rejectP(/* @__PURE__ */ new Error("aborted"));
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2232
|
+
}
|
|
2233
|
+
child.stdout.setEncoding("utf8");
|
|
2234
|
+
child.stderr.setEncoding("utf8");
|
|
2235
|
+
child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
2236
|
+
child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
2237
|
+
child.on("error", (err) => {
|
|
2238
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2239
|
+
rejectP(err);
|
|
2240
|
+
});
|
|
2241
|
+
child.on("close", (code) => {
|
|
2242
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2243
|
+
if (signal?.aborted) {
|
|
2244
|
+
rejectP(/* @__PURE__ */ new Error("aborted"));
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (code !== 0) {
|
|
2248
|
+
rejectP(/* @__PURE__ */ new Error(`git ls-files exited ${code}: ${stderrChunks.join("").trim()}`));
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
resolveP(stdoutChunks.join("").split("\0").filter((p) => p.length > 0));
|
|
2252
|
+
});
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Depth-first fs walk fallback. Used when git is unavailable. Honors a
|
|
2257
|
+
* hard-coded skip list; doesn't read `.gitignore`. Acceptable for hosts
|
|
2258
|
+
* outside a git checkout (e.g. a freshly-`unzip`ped project).
|
|
2259
|
+
*/
|
|
2260
|
+
async function listViaFs(cwd, maxFiles, signal) {
|
|
2261
|
+
const out = [];
|
|
2262
|
+
const stack = ["."];
|
|
2263
|
+
while (stack.length > 0) {
|
|
2264
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
2265
|
+
const rel = stack.pop();
|
|
2266
|
+
const abs = rel === "." ? cwd : resolve(cwd, rel);
|
|
2267
|
+
let entries;
|
|
2268
|
+
try {
|
|
2269
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
2270
|
+
} catch {
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
for (const e of entries) {
|
|
2274
|
+
if (FS_WALK_SKIP.has(e.name)) continue;
|
|
2275
|
+
const childRel = rel === "." ? e.name : `${rel}${sep}${e.name}`;
|
|
2276
|
+
if (e.isDirectory()) stack.push(childRel);
|
|
2277
|
+
else if (e.isFile()) {
|
|
2278
|
+
out.push(toForwardSlash(childRel));
|
|
2279
|
+
if (out.length >= maxFiles) return out;
|
|
2280
|
+
} else if (e.isSymbolicLink()) {
|
|
2281
|
+
let st;
|
|
2282
|
+
try {
|
|
2283
|
+
st = await stat(resolve(abs, e.name));
|
|
2284
|
+
} catch {
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
if (st.isFile()) {
|
|
2288
|
+
out.push(toForwardSlash(childRel));
|
|
2289
|
+
if (out.length >= maxFiles) return out;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
return out;
|
|
2295
|
+
}
|
|
2296
|
+
function toForwardSlash(p) {
|
|
2297
|
+
return sep === "/" ? p : p.replaceAll(sep, "/");
|
|
2298
|
+
}
|
|
2299
|
+
function toEntries(paths, source, maxFiles) {
|
|
2300
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2301
|
+
const out = [];
|
|
2302
|
+
for (const raw of paths) {
|
|
2303
|
+
const path = toForwardSlash(raw);
|
|
2304
|
+
if (path.length === 0 || seen.has(path)) continue;
|
|
2305
|
+
seen.add(path);
|
|
2306
|
+
const lastSlash = path.lastIndexOf("/");
|
|
2307
|
+
const name = lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
|
|
2308
|
+
out.push({
|
|
2309
|
+
path,
|
|
2310
|
+
name,
|
|
2311
|
+
source
|
|
2312
|
+
});
|
|
2313
|
+
if (out.length >= maxFiles) break;
|
|
2314
|
+
}
|
|
2315
|
+
return out;
|
|
2316
|
+
}
|
|
2317
|
+
//#endregion
|
|
2318
|
+
//#region src/chat/format.ts
|
|
2319
|
+
/** Compact token formatter — 12_415 → "12.4k", 1_234_567 → "1.23M". */
|
|
2320
|
+
function fmtTokens(n) {
|
|
2321
|
+
if (n < 1e3) return String(n);
|
|
2322
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
|
|
2323
|
+
return `${(n / 1e6).toFixed(2)}M`;
|
|
2324
|
+
}
|
|
2325
|
+
/** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
|
|
2326
|
+
function ageString(ts, now = Date.now()) {
|
|
2327
|
+
const m = Math.floor((now - ts) / 6e4);
|
|
2328
|
+
if (m < 1) return "just now";
|
|
2329
|
+
if (m < 60) return `${m}m ago`;
|
|
2330
|
+
const h = Math.floor(m / 60);
|
|
2331
|
+
if (h < 24) return `${h}h ago`;
|
|
2332
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
2333
|
+
}
|
|
2334
|
+
/** Six-char short form of a session id for headers and lists. */
|
|
2335
|
+
function shortId(id) {
|
|
2336
|
+
return id.replace(/-/g, "").slice(0, 6);
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Compact an absolute path for display: replace the user's `$HOME`
|
|
2340
|
+
* prefix with `~` (so `/Users/yael/Code/zidane` → `~/Code/zidane`),
|
|
2341
|
+
* and optionally left-truncate with an ellipsis when the result
|
|
2342
|
+
* still exceeds `maxWidth` (so the path's *tail* — the part the user
|
|
2343
|
+
* recognizes — stays visible: `…/zidane` rather than `/Users/yaeluil…`).
|
|
2344
|
+
*
|
|
2345
|
+
* `maxWidth` is the maximum *display width* in cells. Omit to skip
|
|
2346
|
+
* truncation. Paths outside `$HOME` are returned verbatim modulo
|
|
2347
|
+
* truncation. The ellipsis (`…`) counts as one cell.
|
|
2348
|
+
*
|
|
2349
|
+
* `home` overrides `os.homedir()` for tests; production callers leave
|
|
2350
|
+
* it undefined and pay the cheap one-syscall lookup per call.
|
|
2351
|
+
*/
|
|
2352
|
+
function compactPath(path, maxWidth, home) {
|
|
2353
|
+
const h = home ?? homedir();
|
|
2354
|
+
let display = path;
|
|
2355
|
+
if (h) {
|
|
2356
|
+
if (path === h) display = "~";
|
|
2357
|
+
else if (path.startsWith(`${h}/`)) display = `~${path.slice(h.length)}`;
|
|
2358
|
+
}
|
|
2359
|
+
if (maxWidth !== void 0 && maxWidth > 1 && display.length > maxWidth) return `…${display.slice(display.length - maxWidth + 1)}`;
|
|
2360
|
+
return display;
|
|
2361
|
+
}
|
|
2362
|
+
//#endregion
|
|
2363
|
+
//#region src/chat/generate-title.ts
|
|
2364
|
+
/** Hard cap on the result length. Anything longer is truncated client-side. */
|
|
2365
|
+
const TITLE_MAX_CHARS = 60;
|
|
2366
|
+
/** Default turn count fed to the model. 10 covers most exchanges without bloat. */
|
|
2367
|
+
const DEFAULT_MAX_TURNS = 10;
|
|
2368
|
+
/** Tokens budgeted for the model's reply. 64 fits any sane title with headroom. */
|
|
2369
|
+
const TITLE_RESPONSE_MAX_TOKENS = 64;
|
|
2370
|
+
/** Per-turn body cap when assembling the prompt — keeps the request payload tight. */
|
|
2371
|
+
const PROMPT_TURN_CHAR_MAX = 2e3;
|
|
2372
|
+
/**
|
|
2373
|
+
* Drive the provider's `stream()` once and return a concise title for
|
|
2374
|
+
* the conversation represented by `turns`. Throws when:
|
|
2375
|
+
* - `turns` contains no text-bearing content (nothing to summarize),
|
|
2376
|
+
* - the provider stream completes with no output text,
|
|
2377
|
+
* - `signal` is aborted (rethrown verbatim).
|
|
2378
|
+
*
|
|
2379
|
+
* Output is trimmed, single-line, with surrounding quotes / trailing
|
|
2380
|
+
* punctuation stripped, and truncated to `TITLE_MAX_CHARS`.
|
|
2381
|
+
*/
|
|
2382
|
+
async function generateSessionTitle({ provider, model, turns, maxTurns = DEFAULT_MAX_TURNS, signal }) {
|
|
2383
|
+
const transcript = renderTurnsForPrompt(turns.slice(-Math.max(1, maxTurns)));
|
|
2384
|
+
if (!transcript) throw new Error("No text content in the recent turns to summarize.");
|
|
2385
|
+
const system = [
|
|
2386
|
+
"You generate concise, descriptive titles for chat conversations.",
|
|
2387
|
+
"Reply with ONLY the title — no quotes, no markdown, no trailing punctuation,",
|
|
2388
|
+
"no preamble (do not say \"Title:\" or similar).",
|
|
2389
|
+
`Aim for 3 to 7 words and stay under ${TITLE_MAX_CHARS} characters.`
|
|
2390
|
+
].join(" ");
|
|
2391
|
+
const userPrompt = `Generate a title for this conversation:\n\n${transcript}`;
|
|
2392
|
+
let text = "";
|
|
2393
|
+
const result = await provider.stream({
|
|
2394
|
+
model,
|
|
2395
|
+
system,
|
|
2396
|
+
tools: [],
|
|
2397
|
+
messages: [provider.userMessage(userPrompt)],
|
|
2398
|
+
maxTokens: TITLE_RESPONSE_MAX_TOKENS,
|
|
2399
|
+
...signal ? { signal } : {}
|
|
2400
|
+
}, { onText: (delta) => {
|
|
2401
|
+
text += delta;
|
|
2402
|
+
} });
|
|
2403
|
+
return cleanTitle(text.trim().length > 0 ? text : result.text);
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Compact a turn list into a transcript-style prompt:
|
|
2407
|
+
*
|
|
2408
|
+
* user: …
|
|
2409
|
+
* assistant: …
|
|
2410
|
+
* user: …
|
|
2411
|
+
*
|
|
2412
|
+
* Tool calls and tool results are summarized to keep the request
|
|
2413
|
+
* payload small — the model needs the rough flow of the conversation,
|
|
2414
|
+
* not full JSON blobs. Per-turn text is clipped at `PROMPT_TURN_CHAR_MAX`
|
|
2415
|
+
* so a single huge code dump doesn't blow the budget.
|
|
2416
|
+
*
|
|
2417
|
+
* Returns an empty string when the slice has no text-bearing content
|
|
2418
|
+
* (e.g. a turn list that's only tool plumbing).
|
|
2419
|
+
*/
|
|
2420
|
+
function renderTurnsForPrompt(turns) {
|
|
2421
|
+
const lines = [];
|
|
2422
|
+
for (const turn of turns) {
|
|
2423
|
+
const body = textBodyOf(turn);
|
|
2424
|
+
if (!body) continue;
|
|
2425
|
+
const role = turn.role === "assistant" ? "assistant" : turn.role === "user" ? "user" : "system";
|
|
2426
|
+
lines.push(`${role}: ${clip(body, PROMPT_TURN_CHAR_MAX)}`);
|
|
2427
|
+
}
|
|
2428
|
+
return lines.join("\n\n");
|
|
2429
|
+
}
|
|
2430
|
+
function textBodyOf(turn) {
|
|
2431
|
+
const parts = [];
|
|
2432
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) parts.push(block.text.trim());
|
|
2433
|
+
else if (block.type === "tool_call") parts.push(`[tool: ${block.name}]`);
|
|
2434
|
+
else if (block.type === "tool_result") parts.push(`[tool result]`);
|
|
2435
|
+
return parts.join(" ");
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Normalize a model-generated title into the shape we want to persist:
|
|
2439
|
+
*
|
|
2440
|
+
* - Collapse internal whitespace + take the first line only (some
|
|
2441
|
+
* models emit "Title: foo\n\nReason: …" despite instructions).
|
|
2442
|
+
* - Strip surrounding quote characters (`"foo"`, `'foo'`, `` `foo` ``).
|
|
2443
|
+
* - Strip a leading `Title:` / `title -` prefix if the model added one.
|
|
2444
|
+
* - Strip trailing punctuation (`.`, `!`, `?`) — titles read cleaner
|
|
2445
|
+
* without it.
|
|
2446
|
+
* - Truncate to `TITLE_MAX_CHARS` with a trailing `…` when over.
|
|
2447
|
+
*
|
|
2448
|
+
* Exported as `cleanTitle` so tests can pin the normalization rules
|
|
2449
|
+
* without going through a mock provider.
|
|
2450
|
+
*/
|
|
2451
|
+
function cleanTitle(raw) {
|
|
2452
|
+
let s = raw.split("\n")[0]?.trim() ?? "";
|
|
2453
|
+
s = s.replace(/^\s*title\s*[:\-–—]\s*/i, "");
|
|
2454
|
+
if (s.length >= 2) {
|
|
2455
|
+
const first = s.charAt(0);
|
|
2456
|
+
const last = s.charAt(s.length - 1);
|
|
2457
|
+
if (first === "\"" && last === "\"" || first === "'" && last === "'" || first === "`" && last === "`" || first === "“" && last === "”") s = s.slice(1, -1).trim();
|
|
2458
|
+
}
|
|
2459
|
+
s = s.replace(/[.!?]+$/, "").trim();
|
|
2460
|
+
if (s.length === 0) throw new Error("Model returned an empty title.");
|
|
2461
|
+
return s.length > TITLE_MAX_CHARS ? `${s.slice(0, TITLE_MAX_CHARS - 1)}…` : s;
|
|
2462
|
+
}
|
|
2463
|
+
function clip(text, max) {
|
|
2464
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
2465
|
+
}
|
|
2466
|
+
//#endregion
|
|
2467
|
+
//#region src/chat/project-user-paths.ts
|
|
2468
|
+
/**
|
|
2469
|
+
* Shared search-path builder for project + user config discovery.
|
|
2470
|
+
*
|
|
2471
|
+
* Both Skills (`skills/<name>/SKILL.md`) and MCP servers (`mcps.json`)
|
|
2472
|
+
* follow the same first-found-wins order:
|
|
2473
|
+
*
|
|
2474
|
+
* 1. `{project-root}/.agents/<sub>` — project, agnostic convention
|
|
2475
|
+
* 2. `{project-root}/.{prefix}/<sub>` — project, zidane (`.zidane` default)
|
|
2476
|
+
* 3. `~/.agents/<sub>` — user, agnostic
|
|
2477
|
+
* 4. `~/.{prefix}/<sub>` — user, zidane
|
|
2478
|
+
*
|
|
2479
|
+
* `{project-root}` is the git root walked up from `cwd` when present,
|
|
2480
|
+
* else `cwd` itself. This pairs with the project-scoped session
|
|
2481
|
+
* storage (`resolveStoragePaths`) so all three project-aware surfaces
|
|
2482
|
+
* — sessions, mcps, skills — anchor at the same `.{prefix}/`.
|
|
2483
|
+
*
|
|
2484
|
+
* Centralizing the pattern keeps the four paths in one place — a third
|
|
2485
|
+
* discovery surface (hooks, rules, …) gets the same convention for free
|
|
2486
|
+
* and stays in lock-step on a prefix bump.
|
|
2487
|
+
*/
|
|
2488
|
+
/**
|
|
2489
|
+
* Resolve the four search paths for a given subdirectory (`skills`,
|
|
2490
|
+
* `mcps.json`, …). Returns absolute paths regardless of whether they
|
|
2491
|
+
* exist — the caller chooses to `existsSync`-filter or feed straight
|
|
2492
|
+
* into a tolerant discovery routine.
|
|
2493
|
+
*/
|
|
2494
|
+
function projectUserPaths(opts) {
|
|
2495
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2496
|
+
const home = opts.home ?? homedir();
|
|
2497
|
+
const projectRoot = findGitRoot$1(cwd) ?? cwd;
|
|
2498
|
+
const prefix = (opts.prefix ?? ".zidane").replace(/^\./, "");
|
|
2499
|
+
return [
|
|
2500
|
+
{
|
|
2501
|
+
path: resolve(projectRoot, `.agents/${opts.subPath}`),
|
|
2502
|
+
source: "project"
|
|
2503
|
+
},
|
|
2504
|
+
{
|
|
2505
|
+
path: resolve(projectRoot, `.${prefix}/${opts.subPath}`),
|
|
2506
|
+
source: "project"
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
path: resolve(home, `.agents/${opts.subPath}`),
|
|
2510
|
+
source: "user"
|
|
2511
|
+
},
|
|
2512
|
+
{
|
|
2513
|
+
path: resolve(home, `.${prefix}/${opts.subPath}`),
|
|
2514
|
+
source: "user"
|
|
2515
|
+
}
|
|
2516
|
+
];
|
|
2517
|
+
}
|
|
2518
|
+
//#endregion
|
|
2519
|
+
//#region src/chat/mcps-discovery.ts
|
|
2520
|
+
/**
|
|
2521
|
+
* Search order for `mcps.json` — see {@link projectUserPaths}. Project
|
|
2522
|
+
* beats user; the first file found for each (name) wins. Non-existent
|
|
2523
|
+
* files are skipped without error.
|
|
2524
|
+
*/
|
|
2525
|
+
function defaultMcpsConfigPaths(opts = {}) {
|
|
2526
|
+
return projectUserPaths({
|
|
2527
|
+
subPath: "mcps.json",
|
|
2528
|
+
...opts
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Parse one `mcps.json` file. Accepts:
|
|
2533
|
+
* - A raw `McpServerConfig[]` array — host-SDK convention.
|
|
2534
|
+
* - An object with `{ "mcpServers": { name: {...} } }` — Claude Desktop
|
|
2535
|
+
* + many client wrappers. The `mcpServers` value is unwrapped before
|
|
2536
|
+
* normalization (otherwise `normalizeMcpServers` would treat the whole
|
|
2537
|
+
* wrapper as a single server).
|
|
2538
|
+
* - A name-keyed map (`{ fs: {...}, github: {...} }`) — host-SDK
|
|
2539
|
+
* convenience shape.
|
|
2540
|
+
*
|
|
2541
|
+
* Validation flows through `normalizeMcpServers` so the result matches
|
|
2542
|
+
* what `createAgent` accepts. Throws on malformed JSON; the caller decides
|
|
2543
|
+
* whether to surface or swallow.
|
|
2544
|
+
*/
|
|
2545
|
+
function parseMcpsFile(text) {
|
|
2546
|
+
const raw = JSON.parse(text);
|
|
2547
|
+
return normalizeMcpServers(raw && typeof raw === "object" && !Array.isArray(raw) && "mcpServers" in raw ? raw.mcpServers : raw);
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Walk the default config paths, parse every file that exists, and return
|
|
2551
|
+
* `DiscoveredMcp[]` in source-priority order. Project entries appear
|
|
2552
|
+
* before user entries; first occurrence of a `name` wins (later
|
|
2553
|
+
* duplicates dropped).
|
|
2554
|
+
*
|
|
2555
|
+
* Parse errors are logged under `ZIDANE_DEBUG` and the file skipped so a
|
|
2556
|
+
* single malformed `mcps.json` can't take down the picker.
|
|
2557
|
+
*/
|
|
2558
|
+
function discoverProjectMcps(opts = {}) {
|
|
2559
|
+
const paths = defaultMcpsConfigPaths(opts).filter((p) => existsSync(p.path));
|
|
2560
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2561
|
+
const out = [];
|
|
2562
|
+
for (const { path, source } of paths) {
|
|
2563
|
+
let configs;
|
|
2564
|
+
try {
|
|
2565
|
+
configs = parseMcpsFile(readFileSync(path, "utf8"));
|
|
2566
|
+
} catch (err) {
|
|
2567
|
+
if (process.env.ZIDANE_DEBUG) {
|
|
2568
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
2569
|
+
process.stderr.write(`[zidane/chat] failed to parse "${path}": ${cause}\n`);
|
|
2570
|
+
}
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
for (const config of configs) {
|
|
2574
|
+
if (seen.has(config.name)) continue;
|
|
2575
|
+
seen.add(config.name);
|
|
2576
|
+
out.push({
|
|
2577
|
+
config,
|
|
2578
|
+
source,
|
|
2579
|
+
path
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return out;
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Map a user-toggled enable list onto the `mcpServers` array the agent
|
|
2587
|
+
* receives. Conventions match `buildSkillsConfig`:
|
|
2588
|
+
*
|
|
2589
|
+
* - `enabled === undefined` → every discovered server enabled (default).
|
|
2590
|
+
* - `enabled === []` → no servers (the agent runs MCP-less).
|
|
2591
|
+
* - `enabled === [names]` → allowlist; servers not in the list are
|
|
2592
|
+
* dropped.
|
|
2593
|
+
*
|
|
2594
|
+
* Returns a fresh array — callers can mutate without affecting the
|
|
2595
|
+
* underlying discovery list.
|
|
2596
|
+
*/
|
|
2597
|
+
function buildMcpServers(opts) {
|
|
2598
|
+
if (opts.enabled === void 0) return opts.discovered.map((d) => d.config);
|
|
2599
|
+
if (opts.enabled.length === 0) return [];
|
|
2600
|
+
const allow = new Set(opts.enabled);
|
|
2601
|
+
return opts.discovered.filter((d) => allow.has(d.config.name)).map((d) => d.config);
|
|
2602
|
+
}
|
|
2603
|
+
//#endregion
|
|
2604
|
+
//#region src/chat/oauth.ts
|
|
2605
|
+
function supportsOAuth(descriptor) {
|
|
2606
|
+
return descriptor.oauthProvider !== void 0;
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Run the OAuth login flow for a provider.
|
|
2610
|
+
*
|
|
2611
|
+
* Returns the OAuth credentials on success; caller persists them via
|
|
2612
|
+
* `setProviderCredential(dataDir, descriptor, { kind: 'oauth', ...credentials })`.
|
|
2613
|
+
* Throws when the descriptor has no `oauthProvider` configured.
|
|
2614
|
+
*/
|
|
2615
|
+
async function runOAuthLogin(descriptor, options) {
|
|
2616
|
+
if (!descriptor.oauthProvider) throw new Error(`OAuth not supported for ${descriptor.label} (${descriptor.key}) — use an API key instead.`);
|
|
2617
|
+
const callbacks = {
|
|
2618
|
+
onAuth: (info) => {
|
|
2619
|
+
options.onUrl(info.url, info.instructions);
|
|
2620
|
+
tryOpenBrowser(info.url);
|
|
2621
|
+
},
|
|
2622
|
+
onPrompt: async () => {
|
|
2623
|
+
if (!options.onCodeRequest) throw new Error("OAuth flow requires manual code input but no handler is wired.");
|
|
2624
|
+
return options.onCodeRequest();
|
|
2625
|
+
},
|
|
2626
|
+
onProgress: options.onProgress,
|
|
2627
|
+
signal: options.signal
|
|
2628
|
+
};
|
|
2629
|
+
return descriptor.oauthProvider.login(callbacks);
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Best-effort cross-platform browser open. macOS uses `open`, Linux uses
|
|
2633
|
+
* `xdg-open`, Windows uses `start`. Failures are swallowed — the callback
|
|
2634
|
+
* server is already listening, and the URL is displayed in the TUI for
|
|
2635
|
+
* manual click.
|
|
2636
|
+
*
|
|
2637
|
+
* Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
|
|
2638
|
+
* than interpolated into a shell command — no need to think about quoting
|
|
2639
|
+
* URLs that contain `&`, `?`, `"` or other shell metacharacters.
|
|
2640
|
+
*/
|
|
2641
|
+
function tryOpenBrowser(url) {
|
|
2642
|
+
const [cmd, ...args] = (() => {
|
|
2643
|
+
if (process.platform === "darwin") return ["open", url];
|
|
2644
|
+
if (process.platform === "win32") return [
|
|
2645
|
+
"cmd",
|
|
2646
|
+
"/c",
|
|
2647
|
+
"start",
|
|
2648
|
+
"",
|
|
2649
|
+
url
|
|
2650
|
+
];
|
|
2651
|
+
return ["xdg-open", url];
|
|
2652
|
+
})();
|
|
2653
|
+
try {
|
|
2654
|
+
const child = spawn(cmd, args, {
|
|
2655
|
+
stdio: "ignore",
|
|
2656
|
+
detached: true
|
|
2657
|
+
});
|
|
2658
|
+
child.on("error", () => {});
|
|
2659
|
+
child.unref();
|
|
2660
|
+
} catch {}
|
|
2661
|
+
}
|
|
2662
|
+
//#endregion
|
|
2663
|
+
//#region src/chat/prompt-segments.ts
|
|
2664
|
+
/**
|
|
2665
|
+
* Split a prompt buffer into word-sized atomic segments suitable for a
|
|
2666
|
+
* flex-row + flex-wrap renderer (TUI) or a `display: inline` flow with
|
|
2667
|
+
* inline-block chips (GUI). Each chip becomes one segment (atomic —
|
|
2668
|
+
* never broken across rows); each plain run is split into "word +
|
|
2669
|
+
* trailing space" units so wraps land at clean word boundaries.
|
|
2670
|
+
*
|
|
2671
|
+
* Robust to:
|
|
2672
|
+
* - Overlapping refs — sorted by start; later refs that overlap are
|
|
2673
|
+
* dropped via the first-wins rule.
|
|
2674
|
+
* - Out-of-bounds refs — dropped entirely when `end > text.length` or
|
|
2675
|
+
* `start >= text.length`. Partial clipping would silently truncate
|
|
2676
|
+
* a chip's label; the caller is in a better position to surface the
|
|
2677
|
+
* mismatch (typically a stale `refs` array referencing a previous text).
|
|
2678
|
+
* - Whitespace-only plain runs — emitted as their own plain segment
|
|
2679
|
+
* so chip-adjacent-to-chip cases keep the original spacing.
|
|
2680
|
+
*
|
|
2681
|
+
* Word splitter rationale: `\S+\s*` keeps trailing whitespace attached
|
|
2682
|
+
* to its preceding word so wrap boundaries land between words (cleanly).
|
|
2683
|
+
* A leading-whitespace-only segment is captured by `\s+` so we don't
|
|
2684
|
+
* drop it entirely when the plain run starts with a space.
|
|
2685
|
+
*/
|
|
2686
|
+
function splitPromptSegments(text, refs) {
|
|
2687
|
+
const sorted = [...refs].filter((r) => r.end > r.start && r.start < text.length && r.end <= text.length).sort((a, b) => a.start - b.start);
|
|
2688
|
+
const out = [];
|
|
2689
|
+
let cursor = 0;
|
|
2690
|
+
for (const ref of sorted) {
|
|
2691
|
+
if (ref.start < cursor) continue;
|
|
2692
|
+
if (ref.start > cursor) {
|
|
2693
|
+
const matches = text.slice(cursor, ref.start).match(/\S+\s*|\s+/g) ?? [];
|
|
2694
|
+
for (const m of matches) out.push({
|
|
2695
|
+
kind: "plain",
|
|
2696
|
+
text: m
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
out.push({
|
|
2700
|
+
kind: "chip",
|
|
2701
|
+
text: text.slice(ref.start, ref.end),
|
|
2702
|
+
providerId: ref.providerId
|
|
2703
|
+
});
|
|
2704
|
+
cursor = ref.end;
|
|
2705
|
+
}
|
|
2706
|
+
if (cursor < text.length) {
|
|
2707
|
+
const matches = text.slice(cursor).match(/\S+\s*|\s+/g) ?? [];
|
|
2708
|
+
for (const m of matches) out.push({
|
|
2709
|
+
kind: "plain",
|
|
2710
|
+
text: m
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
return out;
|
|
2714
|
+
}
|
|
2715
|
+
//#endregion
|
|
2716
|
+
//#region src/chat/safe-mode.ts
|
|
2717
|
+
/**
|
|
2718
|
+
* Safe-mode storage + matching for the TUI.
|
|
2719
|
+
*
|
|
2720
|
+
* Lives at `<dataDir>/projects.json` (default `~/.zidane/projects.json`). Each
|
|
2721
|
+
* top-level key is an absolute project directory; the value carries that
|
|
2722
|
+
* project's persisted tool-call `safelist`.
|
|
2723
|
+
*
|
|
2724
|
+
* ```json
|
|
2725
|
+
* {
|
|
2726
|
+
* "/Users/me/proj-a": { "safelist": ["read_file", "shell:git:*"] }
|
|
2727
|
+
* }
|
|
2728
|
+
* ```
|
|
2729
|
+
*
|
|
2730
|
+
* Two granularities for safelist entries:
|
|
2731
|
+
* - **bare tool name** — `"read_file"` matches every `read_file` call.
|
|
2732
|
+
* - **tool + first-arg token + wildcard** — `"shell:git:*"` matches `shell`
|
|
2733
|
+
* calls whose primary string argument starts with the token `git`
|
|
2734
|
+
* (followed by whitespace or end-of-string). Modelled on Claude Code's
|
|
2735
|
+
* `Bash(git:*)` syntax.
|
|
2736
|
+
*
|
|
2737
|
+
* A short list of read-only tools is **implicitly safe** without being
|
|
2738
|
+
* persisted — see {@link IMPLICITLY_SAFE_TOOLS}.
|
|
2739
|
+
*/
|
|
2740
|
+
/** Resolve `projects.json`'s on-disk path given the TUI data directory. */
|
|
2741
|
+
function projectsFilePath(dataDir) {
|
|
2742
|
+
return resolve(dataDir, "projects.json");
|
|
2743
|
+
}
|
|
2744
|
+
function readProjects(dataDir) {
|
|
2745
|
+
const path = projectsFilePath(dataDir);
|
|
2746
|
+
if (!existsSync(path)) return {};
|
|
2747
|
+
try {
|
|
2748
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
2749
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2750
|
+
} catch {}
|
|
2751
|
+
return {};
|
|
2752
|
+
}
|
|
2753
|
+
function ensureDir(path) {
|
|
2754
|
+
const dir = dirname(path);
|
|
2755
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2756
|
+
}
|
|
2757
|
+
/** Atomic write — tmp + rename so a crash never leaves a half-file. */
|
|
2758
|
+
function writeProjects(dataDir, file) {
|
|
2759
|
+
const path = projectsFilePath(dataDir);
|
|
2760
|
+
ensureDir(path);
|
|
2761
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2762
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2));
|
|
2763
|
+
renameSync(tmp, path);
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Append `entry` to the safelist for `projectDir`, dedup-aware. Returns the
|
|
2767
|
+
* updated entry list (post-write) so callers can render it without re-reading.
|
|
2768
|
+
*/
|
|
2769
|
+
function addToSafelist(dataDir, projectDir, entry) {
|
|
2770
|
+
const file = readProjects(dataDir);
|
|
2771
|
+
const existing = file[projectDir]?.safelist ?? [];
|
|
2772
|
+
if (existing.includes(entry)) return existing;
|
|
2773
|
+
const next = [...existing, entry];
|
|
2774
|
+
file[projectDir] = {
|
|
2775
|
+
...file[projectDir],
|
|
2776
|
+
safelist: next
|
|
2777
|
+
};
|
|
2778
|
+
writeProjects(dataDir, file);
|
|
2779
|
+
return next;
|
|
2780
|
+
}
|
|
2781
|
+
/** Read the safelist for one project. Returns `[]` for unknown projects. */
|
|
2782
|
+
function getSafelist(dataDir, projectDir) {
|
|
2783
|
+
return readProjects(dataDir)[projectDir]?.safelist ?? [];
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Tools that always pass without prompting — pure file/dir reads with no
|
|
2787
|
+
* side effects. Users who want to gate them must disable safe-mode entirely
|
|
2788
|
+
* (or fork this list in their own embedding).
|
|
2789
|
+
*/
|
|
2790
|
+
const IMPLICITLY_SAFE_TOOLS = [
|
|
2791
|
+
"read_file",
|
|
2792
|
+
"list_files",
|
|
2793
|
+
"glob",
|
|
2794
|
+
"grep"
|
|
2795
|
+
];
|
|
2796
|
+
/** Common input keys carrying the "primary argument" we scope safelists on. */
|
|
2797
|
+
const PRIMARY_ARG_KEYS = [
|
|
2798
|
+
"command",
|
|
2799
|
+
"path",
|
|
2800
|
+
"pattern",
|
|
2801
|
+
"query"
|
|
2802
|
+
];
|
|
2803
|
+
function primaryArgValue(input) {
|
|
2804
|
+
for (const key of PRIMARY_ARG_KEYS) {
|
|
2805
|
+
const v = input[key];
|
|
2806
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
2807
|
+
}
|
|
2808
|
+
return "";
|
|
2809
|
+
}
|
|
2810
|
+
/** Extract the first whitespace-delimited token of the primary arg. */
|
|
2811
|
+
function primaryArgToken(input) {
|
|
2812
|
+
return primaryArgValue(input).split(/\s+/)[0] ?? "";
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Shell metacharacters that turn a single command into a compound: pipes,
|
|
2816
|
+
* sequencing, redirects, substitutions, line breaks, subshells. A `shell:git:*`
|
|
2817
|
+
* entry is meant to greenlight "any git invocation" — without this guard,
|
|
2818
|
+
* `git status && rm -rf /` would tokenize to `git` and pass the safelist
|
|
2819
|
+
* unchallenged. Reject any command that's not a single program call.
|
|
2820
|
+
*
|
|
2821
|
+
* The regex is intentionally generous: false positives (e.g. `echo "hi & bye"`)
|
|
2822
|
+
* just prompt the user again, which is the safe failure mode.
|
|
2823
|
+
*/
|
|
2824
|
+
const SHELL_COMPOUND_RE = /[;&|<>`$\n\r()]/;
|
|
2825
|
+
function isCompoundShellCommand(command) {
|
|
2826
|
+
return SHELL_COMPOUND_RE.test(command);
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Test whether a `{ tool, input }` pair is covered by one safelist entry.
|
|
2830
|
+
*
|
|
2831
|
+
* Supported entry shapes:
|
|
2832
|
+
* - `"<tool>"` — broad match on tool name. For `shell` this still requires
|
|
2833
|
+
* a single-program command (compound forms always prompt).
|
|
2834
|
+
* - `"<tool>:<token>:*"` — match when the primary arg's first token equals
|
|
2835
|
+
* `<token>`. For `shell`, also requires the command to be free of
|
|
2836
|
+
* metacharacters (`;`, `&&`, `||`, `|`, `$(`, backticks, `>`, `<`,
|
|
2837
|
+
* newlines, subshells) — otherwise a `shell:git:*` entry would silently
|
|
2838
|
+
* greenlight `git status && rm -rf /`.
|
|
2839
|
+
*
|
|
2840
|
+
* Entries that don't fit either shape are ignored (forward-compat for future
|
|
2841
|
+
* pattern syntax — readers shouldn't choke on entries written by a newer
|
|
2842
|
+
* version of the TUI).
|
|
2843
|
+
*/
|
|
2844
|
+
function matchesSafelistEntry(entry, tool, input) {
|
|
2845
|
+
if (tool === "shell") {
|
|
2846
|
+
if (isCompoundShellCommand(typeof input.command === "string" ? input.command : "")) return false;
|
|
2847
|
+
}
|
|
2848
|
+
if (entry === tool) return true;
|
|
2849
|
+
const sep = entry.indexOf(":");
|
|
2850
|
+
if (sep <= 0) return false;
|
|
2851
|
+
if (entry.slice(0, sep) !== tool) return false;
|
|
2852
|
+
const scope = entry.slice(sep + 1);
|
|
2853
|
+
if (scope.endsWith(":*")) return primaryArgToken(input) === scope.slice(0, -2);
|
|
2854
|
+
return false;
|
|
2855
|
+
}
|
|
2856
|
+
/** True when a call matches ANY entry in the project's safelist (or is implicitly safe). */
|
|
2857
|
+
function isOnSafelist(entries, tool, input) {
|
|
2858
|
+
if (IMPLICITLY_SAFE_TOOLS.includes(tool)) return true;
|
|
2859
|
+
return entries.some((e) => matchesSafelistEntry(e, tool, input));
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Suggest the safelist entry to write when the user picks "accept and
|
|
2863
|
+
* remember" for a `{ tool, input }`. Heuristic:
|
|
2864
|
+
*
|
|
2865
|
+
* - `shell` → scope by first command token (`shell:git:*`).
|
|
2866
|
+
* - anything else → bare tool name (broad).
|
|
2867
|
+
*
|
|
2868
|
+
* Returning a string ensures the UI always has a concrete entry to display
|
|
2869
|
+
* as the button label.
|
|
2870
|
+
*/
|
|
2871
|
+
function suggestSafelistEntry(tool, input) {
|
|
2872
|
+
if (tool === "shell") {
|
|
2873
|
+
const token = primaryArgToken(input);
|
|
2874
|
+
if (token) return `${tool}:${token}:*`;
|
|
2875
|
+
}
|
|
2876
|
+
return tool;
|
|
2877
|
+
}
|
|
2878
|
+
//#endregion
|
|
2879
|
+
//#region src/chat/safe-mode-context.tsx
|
|
2880
|
+
const SafeModeQueueContext = createContext([]);
|
|
2881
|
+
const SafeModeActionsContext = createContext(null);
|
|
2882
|
+
let approvalIdCounter = 0;
|
|
2883
|
+
function nextApprovalId() {
|
|
2884
|
+
approvalIdCounter += 1;
|
|
2885
|
+
return `approval-${approvalIdCounter}`;
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Owns the queue + actions. Splits the value across two contexts so a queue
|
|
2889
|
+
* change doesn't invalidate every callback memo that closes over the actions.
|
|
2890
|
+
*/
|
|
2891
|
+
function SafeModeProvider({ children }) {
|
|
2892
|
+
const [queue, setQueue] = useState([]);
|
|
2893
|
+
const requestApproval = useCallback((tool, input) => new Promise((resolve) => {
|
|
2894
|
+
setQueue((prev) => [...prev, {
|
|
2895
|
+
id: nextApprovalId(),
|
|
2896
|
+
tool,
|
|
2897
|
+
input,
|
|
2898
|
+
resolve
|
|
2899
|
+
}]);
|
|
2900
|
+
}), []);
|
|
2901
|
+
const resolveHead = useCallback((decision) => {
|
|
2902
|
+
setQueue((prev) => {
|
|
2903
|
+
const [head, ...rest] = prev;
|
|
2904
|
+
if (head) head.resolve(decision);
|
|
2905
|
+
return rest;
|
|
2906
|
+
});
|
|
2907
|
+
}, []);
|
|
2908
|
+
const denyAll = useCallback(() => {
|
|
2909
|
+
setQueue((prev) => {
|
|
2910
|
+
for (const p of prev) p.resolve("deny");
|
|
2911
|
+
return [];
|
|
2912
|
+
});
|
|
2913
|
+
}, []);
|
|
2914
|
+
const actionsRef = useRef(null);
|
|
2915
|
+
if (!actionsRef.current) actionsRef.current = {
|
|
2916
|
+
requestApproval,
|
|
2917
|
+
resolveHead,
|
|
2918
|
+
denyAll
|
|
2919
|
+
};
|
|
2920
|
+
return /* @__PURE__ */ jsx(SafeModeActionsContext.Provider, {
|
|
2921
|
+
value: actionsRef.current,
|
|
2922
|
+
children: /* @__PURE__ */ jsx(SafeModeQueueContext.Provider, {
|
|
2923
|
+
value: queue,
|
|
2924
|
+
children
|
|
2925
|
+
})
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
function useSafeModeQueue() {
|
|
2929
|
+
return useContext(SafeModeQueueContext);
|
|
2930
|
+
}
|
|
2931
|
+
function useSafeModeActions() {
|
|
2932
|
+
const ctx = useContext(SafeModeActionsContext);
|
|
2933
|
+
if (!ctx) throw new Error("useSafeModeActions must be used inside <SafeModeProvider>");
|
|
2934
|
+
return ctx;
|
|
2935
|
+
}
|
|
2936
|
+
//#endregion
|
|
2937
|
+
//#region src/chat/session-export.ts
|
|
2938
|
+
const DEFAULT_PREFIX = ".zidane";
|
|
2939
|
+
/**
|
|
2940
|
+
* Resolve the export target for a session id + format. Pure: no fs
|
|
2941
|
+
* write happens here, the caller decides whether to `existsSync` the
|
|
2942
|
+
* directory or hand the path to `writeSessionExport`.
|
|
2943
|
+
*
|
|
2944
|
+
* Anchor selection walks upward from `cwd` looking for a `.git` entry;
|
|
2945
|
+
* the first hit anchors to that repo's root (`project`). When the walk
|
|
2946
|
+
* exits without finding `.git`, the destination anchors to `home`.
|
|
2947
|
+
*
|
|
2948
|
+
* @throws RangeError when `sessionId` would resolve to an empty / `..`
|
|
2949
|
+
* filename — defensive guard against malicious or buggy callers
|
|
2950
|
+
* crossing into a sibling directory via the id.
|
|
2951
|
+
*/
|
|
2952
|
+
function resolveSessionExportTarget(opts) {
|
|
2953
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2954
|
+
const home = opts.home ?? homedir();
|
|
2955
|
+
const prefix = normalizePrefix(opts.prefix);
|
|
2956
|
+
const filename = exportFilename(opts.sessionId, opts.format);
|
|
2957
|
+
const repoRoot = findGitRoot(cwd);
|
|
2958
|
+
const anchor = repoRoot ? "project" : "home";
|
|
2959
|
+
const dir = resolve(repoRoot ?? home, `.${prefix}`, "sessions");
|
|
2960
|
+
return {
|
|
2961
|
+
dir,
|
|
2962
|
+
filepath: join(dir, filename),
|
|
2963
|
+
anchor
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Render a session into a string in the requested format.
|
|
2968
|
+
*
|
|
2969
|
+
* `markdown` produces a human-readable transcript: a YAML-free header
|
|
2970
|
+
* block with the title and a one-line `id · created · turns` summary,
|
|
2971
|
+
* a stats section (turns / runs / tokens / cost / status), then a
|
|
2972
|
+
* `## Conversation` block where each turn renders as `### N. role ·
|
|
2973
|
+
* run_X · ISO-date` with text, thinking, tool calls, and tool results
|
|
2974
|
+
* formatted as appropriate fenced blocks. Useful for sharing a session
|
|
2975
|
+
* with a teammate or pasting into an issue tracker.
|
|
2976
|
+
*
|
|
2977
|
+
* `json` returns a 2-space-indented, deterministic dump of the full
|
|
2978
|
+
* `SessionData` blob. Useful for re-importing into the store or for
|
|
2979
|
+
* post-hoc analysis tooling.
|
|
2980
|
+
*/
|
|
2981
|
+
function renderSession(session, format) {
|
|
2982
|
+
if (format === "json") return `${JSON.stringify(session, null, 2)}\n`;
|
|
2983
|
+
return renderMarkdown(session);
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Render `session` and write the resulting bytes to disk. Returns the
|
|
2987
|
+
* resolved target so the caller can show the user where the file
|
|
2988
|
+
* landed. The parent directory is created on demand (`recursive: true`)
|
|
2989
|
+
* — first-time exports don't need any pre-flight setup.
|
|
2990
|
+
*/
|
|
2991
|
+
async function writeSessionExport(opts) {
|
|
2992
|
+
const target = resolveSessionExportTarget({
|
|
2993
|
+
sessionId: opts.session.id,
|
|
2994
|
+
format: opts.format,
|
|
2995
|
+
cwd: opts.cwd,
|
|
2996
|
+
home: opts.home,
|
|
2997
|
+
prefix: opts.prefix
|
|
2998
|
+
});
|
|
2999
|
+
const body = renderSession(opts.session, opts.format);
|
|
3000
|
+
mkdirSync(target.dir, { recursive: true });
|
|
3001
|
+
await writeFile(target.filepath, body, "utf8");
|
|
3002
|
+
return target;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Render a `SessionData` as a clean markdown transcript. Stable across
|
|
3006
|
+
* runs (no timestamps or random ids leak into the output beyond what
|
|
3007
|
+
* the session itself carries) — diffing two exports of the same
|
|
3008
|
+
* session is a no-op.
|
|
3009
|
+
*/
|
|
3010
|
+
function renderMarkdown(session) {
|
|
3011
|
+
const title = deriveSessionTitle(session.turns, session.metadata);
|
|
3012
|
+
const userTurns = session.turns.filter((t) => t.role === "user").length;
|
|
3013
|
+
const assistantTurns = session.turns.filter((t) => t.role === "assistant").length;
|
|
3014
|
+
const usage = aggregateUsage(session.runs);
|
|
3015
|
+
const lines = [];
|
|
3016
|
+
lines.push(`# ${escapeInline(title)}`);
|
|
3017
|
+
lines.push("");
|
|
3018
|
+
lines.push(`> Session \`${shortId(session.id)}\` · ${session.turns.length} turn${session.turns.length === 1 ? "" : "s"} · ${session.runs.length} run${session.runs.length === 1 ? "" : "s"}`);
|
|
3019
|
+
lines.push("");
|
|
3020
|
+
lines.push("## Metadata");
|
|
3021
|
+
lines.push("");
|
|
3022
|
+
lines.push(`- **id**: \`${session.id}\``);
|
|
3023
|
+
lines.push(`- **created**: ${new Date(session.createdAt).toISOString()}`);
|
|
3024
|
+
lines.push(`- **updated**: ${new Date(session.updatedAt).toISOString()}`);
|
|
3025
|
+
lines.push(`- **status**: ${session.status}`);
|
|
3026
|
+
lines.push(`- **turns**: ${session.turns.length} (${userTurns} user · ${assistantTurns} assistant)`);
|
|
3027
|
+
lines.push(`- **runs**: ${session.runs.length}`);
|
|
3028
|
+
if (usage.total > 0) {
|
|
3029
|
+
const tokenBits = [`in ${fmtTokens(usage.input)}`, `out ${fmtTokens(usage.output)}`];
|
|
3030
|
+
if (usage.cacheRead > 0) tokenBits.push(`cached ${fmtTokens(usage.cacheRead)}`);
|
|
3031
|
+
lines.push(`- **tokens**: ${fmtTokens(usage.total)} (${tokenBits.join(" · ")})`);
|
|
3032
|
+
if (usage.cost > 0) lines.push(`- **cost**: $${usage.cost.toFixed(usage.cost < .01 ? 4 : 2)}`);
|
|
3033
|
+
}
|
|
3034
|
+
lines.push("");
|
|
3035
|
+
lines.push("## Conversation");
|
|
3036
|
+
lines.push("");
|
|
3037
|
+
if (session.turns.length === 0) {
|
|
3038
|
+
lines.push("_No turns recorded yet._");
|
|
3039
|
+
lines.push("");
|
|
3040
|
+
return `${lines.join("\n")}\n`;
|
|
3041
|
+
}
|
|
3042
|
+
session.turns.forEach((turn, idx) => {
|
|
3043
|
+
lines.push(renderTurnHeader(turn, idx + 1));
|
|
3044
|
+
lines.push("");
|
|
3045
|
+
const body = renderTurnBody(turn);
|
|
3046
|
+
if (body) {
|
|
3047
|
+
lines.push(body);
|
|
3048
|
+
lines.push("");
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
return `${lines.join("\n").replace(/\n+$/, "\n")}\n`;
|
|
3052
|
+
}
|
|
3053
|
+
/** `### 1. user · run_1 · 2026-05-13T01:48:29.895Z` */
|
|
3054
|
+
function renderTurnHeader(turn, index) {
|
|
3055
|
+
const date = new Date(turn.createdAt).toISOString();
|
|
3056
|
+
const runFragment = turn.runId ? ` · \`${turn.runId}\`` : "";
|
|
3057
|
+
const usageFragment = turn.usage ? ` · ${formatTurnUsage(turn.usage)}` : "";
|
|
3058
|
+
return `### ${index}. ${turn.role}${runFragment} · ${date}${usageFragment}`;
|
|
3059
|
+
}
|
|
3060
|
+
/** `in 12 · out 48 · cached 1.2k` — only emits non-zero buckets. */
|
|
3061
|
+
function formatTurnUsage(usage) {
|
|
3062
|
+
const parts = [];
|
|
3063
|
+
if (usage.input > 0) parts.push(`in ${fmtTokens(usage.input)}`);
|
|
3064
|
+
if (usage.output > 0) parts.push(`out ${fmtTokens(usage.output)}`);
|
|
3065
|
+
if ((usage.cacheRead ?? 0) > 0) parts.push(`cached ${fmtTokens(usage.cacheRead ?? 0)}`);
|
|
3066
|
+
return parts.join(" · ");
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Render all blocks of a single turn into a markdown fragment.
|
|
3070
|
+
* Block ordering matches the on-disk turn so resume / replay semantics
|
|
3071
|
+
* are preserved: tool_call → tool_result pairs stay adjacent, thinking
|
|
3072
|
+
* blocks land right before the assistant text they precede.
|
|
3073
|
+
*/
|
|
3074
|
+
function renderTurnBody(turn) {
|
|
3075
|
+
const parts = [];
|
|
3076
|
+
for (const block of turn.content) {
|
|
3077
|
+
const chunk = renderBlock(block);
|
|
3078
|
+
if (chunk) parts.push(chunk);
|
|
3079
|
+
}
|
|
3080
|
+
return parts.join("\n\n");
|
|
3081
|
+
}
|
|
3082
|
+
function renderBlock(block) {
|
|
3083
|
+
switch (block.type) {
|
|
3084
|
+
case "text": return block.text.trim() ? block.text.trim() : "";
|
|
3085
|
+
case "thinking": {
|
|
3086
|
+
const text = block.text.trim();
|
|
3087
|
+
if (!text) return "";
|
|
3088
|
+
return `> **thinking**\n>\n${text.split("\n").map((l) => `> ${l}`).join("\n")}`;
|
|
3089
|
+
}
|
|
3090
|
+
case "tool_call": {
|
|
3091
|
+
const args = JSON.stringify(block.input, null, 2);
|
|
3092
|
+
return [
|
|
3093
|
+
`**Tool call** \`${block.name}\` · id \`${block.id}\``,
|
|
3094
|
+
"",
|
|
3095
|
+
"```json",
|
|
3096
|
+
args,
|
|
3097
|
+
"```"
|
|
3098
|
+
].join("\n");
|
|
3099
|
+
}
|
|
3100
|
+
case "tool_result": return [
|
|
3101
|
+
block.isError ? `**Tool result** ✗ error · id \`${block.callId}\`` : `**Tool result** · id \`${block.callId}\``,
|
|
3102
|
+
"",
|
|
3103
|
+
renderToolOutput(block.output)
|
|
3104
|
+
].join("\n");
|
|
3105
|
+
case "image": return `_[image · ${block.mediaType}]_`;
|
|
3106
|
+
default: return "";
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Tool output is either a flat string (the common case) or a structured
|
|
3111
|
+
* array of `ToolResultContent` (multimodal tools — screenshots, charts).
|
|
3112
|
+
* Strings render as a fenced code block; structured arrays render
|
|
3113
|
+
* their text blocks inline with `[image · …]` placeholders for the rest.
|
|
3114
|
+
*/
|
|
3115
|
+
function renderToolOutput(output) {
|
|
3116
|
+
if (typeof output === "string") {
|
|
3117
|
+
const fence = pickFence(output);
|
|
3118
|
+
return `${fence}\n${output}\n${fence}`;
|
|
3119
|
+
}
|
|
3120
|
+
const segments = [];
|
|
3121
|
+
for (const piece of output) if (piece.type === "text") {
|
|
3122
|
+
const fence = pickFence(piece.text);
|
|
3123
|
+
segments.push(`${fence}\n${piece.text}\n${fence}`);
|
|
3124
|
+
} else segments.push(`_[image · ${piece.mediaType}]_`);
|
|
3125
|
+
return segments.join("\n\n");
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* Pick a fence (`\`\`\`` or longer) that doesn't collide with any
|
|
3129
|
+
* backtick run inside `content`. The CommonMark rule is "a fenced
|
|
3130
|
+
* block ends at a fence of the same length or longer made of the same
|
|
3131
|
+
* char" — choosing a fence strictly longer than the longest run inside
|
|
3132
|
+
* the body avoids accidental termination.
|
|
3133
|
+
*/
|
|
3134
|
+
function pickFence(content) {
|
|
3135
|
+
let longestRun = 0;
|
|
3136
|
+
let current = 0;
|
|
3137
|
+
for (const ch of content) if (ch === "`") {
|
|
3138
|
+
current++;
|
|
3139
|
+
if (current > longestRun) longestRun = current;
|
|
3140
|
+
} else current = 0;
|
|
3141
|
+
const len = Math.max(3, longestRun + 1);
|
|
3142
|
+
return "`".repeat(len);
|
|
3143
|
+
}
|
|
3144
|
+
/** Strip control characters from a single-line inline (e.g. the title). */
|
|
3145
|
+
function escapeInline(s) {
|
|
3146
|
+
return s.replace(/[\r\n]+/g, " ").trim();
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Walk parents of `start` looking for a `.git` entry (file or
|
|
3150
|
+
* directory — `.git` is a file inside git worktrees). Returns the
|
|
3151
|
+
* directory holding `.git`, or `null` at the filesystem root.
|
|
3152
|
+
*/
|
|
3153
|
+
function findGitRoot(start) {
|
|
3154
|
+
let dir = resolve(start);
|
|
3155
|
+
for (let i = 0; i < 64; i++) {
|
|
3156
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
3157
|
+
const parent = dirname(dir);
|
|
3158
|
+
if (parent === dir) return null;
|
|
3159
|
+
dir = parent;
|
|
3160
|
+
}
|
|
3161
|
+
return null;
|
|
3162
|
+
}
|
|
3163
|
+
/** Trim a leading dot from `prefix`. */
|
|
3164
|
+
function normalizePrefix(prefix) {
|
|
3165
|
+
return (prefix ?? DEFAULT_PREFIX).replace(/^\./, "");
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* Compose the on-disk filename for `(sessionId, format)`. Rejects
|
|
3169
|
+
* empty ids and path-traversal-ish values so the caller can't escape
|
|
3170
|
+
* `.{prefix}/sessions/`.
|
|
3171
|
+
*/
|
|
3172
|
+
function exportFilename(sessionId, format) {
|
|
3173
|
+
const ext = format === "json" ? "json" : "md";
|
|
3174
|
+
const cleaned = sessionId.trim();
|
|
3175
|
+
if (!cleaned || cleaned.includes("/") || cleaned.includes("\\") || cleaned === "." || cleaned === "..") throw new RangeError(`Refusing to export session — invalid id "${sessionId}"`);
|
|
3176
|
+
return `${cleaned}.${ext}`;
|
|
3177
|
+
}
|
|
3178
|
+
function aggregateUsage(runs) {
|
|
3179
|
+
const acc = {
|
|
3180
|
+
input: 0,
|
|
3181
|
+
output: 0,
|
|
3182
|
+
cacheRead: 0,
|
|
3183
|
+
cost: 0
|
|
3184
|
+
};
|
|
3185
|
+
for (const run of runs) {
|
|
3186
|
+
if (run.totalUsage) {
|
|
3187
|
+
acc.input += run.totalUsage.input ?? 0;
|
|
3188
|
+
acc.output += run.totalUsage.output ?? 0;
|
|
3189
|
+
acc.cacheRead += run.totalUsage.cacheRead ?? 0;
|
|
3190
|
+
} else if (run.turnUsage) for (const u of run.turnUsage) {
|
|
3191
|
+
acc.input += u.input ?? 0;
|
|
3192
|
+
acc.output += u.output ?? 0;
|
|
3193
|
+
acc.cacheRead += u.cacheRead ?? 0;
|
|
3194
|
+
}
|
|
3195
|
+
if (run.cost) acc.cost += run.cost;
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
...acc,
|
|
3199
|
+
total: acc.input + acc.output
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
//#endregion
|
|
3203
|
+
//#region src/chat/skills-discovery.ts
|
|
3204
|
+
/**
|
|
3205
|
+
* Resolve the default skill scan paths for a project. First-found wins on
|
|
3206
|
+
* collision; earlier entries take precedence.
|
|
3207
|
+
*
|
|
3208
|
+
* Search order — see {@link projectUserPaths}. Non-existent paths are
|
|
3209
|
+
* returned so downstream code can choose whether to create them;
|
|
3210
|
+
* `discoverSkills` itself skips missing dirs without error.
|
|
3211
|
+
*/
|
|
3212
|
+
function defaultSkillScanPaths(opts = {}) {
|
|
3213
|
+
return projectUserPaths({
|
|
3214
|
+
subPath: "skills",
|
|
3215
|
+
...opts
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* Discover every skill reachable from the default scan paths. Returns
|
|
3220
|
+
* parsed `SkillConfig`s with `name`, `description`, frontmatter, and
|
|
3221
|
+
* lenient-load diagnostics. Pure I/O — does not activate or write.
|
|
3222
|
+
*
|
|
3223
|
+
* `signal` is forwarded to `discoverSkills` so the TUI's directory-watch
|
|
3224
|
+
* effect can cancel a long scan when the user switches `cwd` rapidly.
|
|
3225
|
+
*
|
|
3226
|
+
* Errors during parse are surfaced as `diagnostics` on the returned
|
|
3227
|
+
* skill, not thrown — keeps the picker usable even when a single
|
|
3228
|
+
* SKILL.md is malformed.
|
|
3229
|
+
*/
|
|
3230
|
+
async function discoverProjectSkills(opts = {}) {
|
|
3231
|
+
return discoverSkills(defaultSkillScanPaths(opts).filter((p) => existsSync(p.path)), opts.signal);
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Map a user-toggled enable list onto the format `createAgent` expects.
|
|
3235
|
+
*
|
|
3236
|
+
* Conventions:
|
|
3237
|
+
* - `enabled === undefined` → all discovered skills enabled (default).
|
|
3238
|
+
* - `enabled === []` → fully off; the agent will not scan or
|
|
3239
|
+
* inject the skills tools.
|
|
3240
|
+
* - `enabled === [names]` → allowlist; skills not in the list are
|
|
3241
|
+
* left out of the catalog.
|
|
3242
|
+
*
|
|
3243
|
+
* `scan` is the resolved scan-path list (passed through verbatim). Pass
|
|
3244
|
+
* `defaultSkillScanPaths()` for the standard project + user paths, or
|
|
3245
|
+
* supply a host-specific list.
|
|
3246
|
+
*/
|
|
3247
|
+
function buildSkillsConfig(opts) {
|
|
3248
|
+
const scan = opts.scan.map((p) => p.path);
|
|
3249
|
+
if (opts.enabled !== void 0 && opts.enabled.length === 0) return {
|
|
3250
|
+
scan,
|
|
3251
|
+
enabled: false
|
|
3252
|
+
};
|
|
3253
|
+
return {
|
|
3254
|
+
scan,
|
|
3255
|
+
...opts.enabled ? { enabled: [...opts.enabled] } : {}
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
//#endregion
|
|
3259
|
+
//#region src/chat/streaming.ts
|
|
3260
|
+
/**
|
|
3261
|
+
* Streaming flush cadence. Set to roughly half the renderer's 30fps target
|
|
3262
|
+
* (~16fps deltas) so OpenTUI's `MarkdownRenderable` has a full paint frame
|
|
3263
|
+
* between content updates. The trailing markdown block is `destroyRecursively`
|
|
3264
|
+
* + recreated on every content change while `streaming: true` (parser
|
|
3265
|
+
* marks the last 2 blocks unstable); landing those mutations one-per-frame
|
|
3266
|
+
* paints partial layout states, which renders as flicker. Pacing at ~60ms
|
|
3267
|
+
* gives the renderer time to settle without sacrificing live feel — tokens
|
|
3268
|
+
* still appear faster than the user can read.
|
|
3269
|
+
*/
|
|
3270
|
+
const FLUSH_INTERVAL_MS = 60;
|
|
3271
|
+
const PARENT_OWNER = "parent";
|
|
3272
|
+
function emptyBucket(owner, depth) {
|
|
3273
|
+
return {
|
|
3274
|
+
markdown: "",
|
|
3275
|
+
thinking: "",
|
|
3276
|
+
owner,
|
|
3277
|
+
depth
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
function applyBucket(prev, bucket) {
|
|
3281
|
+
let result = prev;
|
|
3282
|
+
if (bucket.thinking) result = appendThinkingLines(result, bucket.thinking, bucket.owner, bucket.depth, bucket.turnId);
|
|
3283
|
+
if (bucket.markdown) result = appendMarkdownDelta(result, bucket.markdown, bucket.owner, bucket.depth, bucket.turnId);
|
|
3284
|
+
return result;
|
|
3285
|
+
}
|
|
3286
|
+
function appendMarkdownDelta(prev, delta, owner, depth, turnId) {
|
|
3287
|
+
const last = prev[prev.length - 1];
|
|
3288
|
+
if (last && last.kind === "markdown" && last.streaming && ownerOf(last) === owner) {
|
|
3289
|
+
const next = prev.slice(0, -1);
|
|
3290
|
+
next.push({
|
|
3291
|
+
...last,
|
|
3292
|
+
text: last.text + delta
|
|
3293
|
+
});
|
|
3294
|
+
return next;
|
|
3295
|
+
}
|
|
3296
|
+
return [...prev, tagEvent({
|
|
3297
|
+
kind: "markdown",
|
|
3298
|
+
text: delta,
|
|
3299
|
+
streaming: true
|
|
3300
|
+
}, owner, depth, turnId)];
|
|
3301
|
+
}
|
|
3302
|
+
function appendThinkingLines(prev, delta, owner, depth, turnId) {
|
|
3303
|
+
const lines = delta.split("\n");
|
|
3304
|
+
const result = [...prev];
|
|
3305
|
+
const last = result[result.length - 1];
|
|
3306
|
+
if (last && last.kind === "thinking" && ownerOf(last) === owner) result[result.length - 1] = {
|
|
3307
|
+
...last,
|
|
3308
|
+
text: last.text + lines[0]
|
|
3309
|
+
};
|
|
3310
|
+
else if (lines[0] || lines.length > 1) result.push(tagEvent({
|
|
3311
|
+
kind: "thinking",
|
|
3312
|
+
text: lines[0]
|
|
3313
|
+
}, owner, depth, turnId));
|
|
3314
|
+
for (let i = 1; i < lines.length; i++) result.push(tagEvent({
|
|
3315
|
+
kind: "thinking",
|
|
3316
|
+
text: lines[i]
|
|
3317
|
+
}, owner, depth, turnId));
|
|
3318
|
+
return result;
|
|
3319
|
+
}
|
|
3320
|
+
function ownerOf(evt) {
|
|
3321
|
+
return evt.childId ?? PARENT_OWNER;
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Stamp owner (parent vs subagent) + depth + optional `turnId` onto a
|
|
3325
|
+
* freshly-minted event so consumers can identify the producer and the
|
|
3326
|
+
* source turn. Parent-owned events leave `childId` / `depth` off to match
|
|
3327
|
+
* the prior shape; `turnId` is added whenever the caller provided one.
|
|
3328
|
+
*/
|
|
3329
|
+
function tagEvent(evt, owner, depth, turnId) {
|
|
3330
|
+
const withTurn = turnId ? {
|
|
3331
|
+
...evt,
|
|
3332
|
+
turnId
|
|
3333
|
+
} : evt;
|
|
3334
|
+
if (owner === PARENT_OWNER) return withTurn;
|
|
3335
|
+
return {
|
|
3336
|
+
...withTurn,
|
|
3337
|
+
childId: owner,
|
|
3338
|
+
depth
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
/** Flip any trailing streaming markdown blocks (any owner) to finalized. */
|
|
3342
|
+
function finalizeStreamingMarkdown(events) {
|
|
3343
|
+
let changed = false;
|
|
3344
|
+
const next = events.map((e) => {
|
|
3345
|
+
if (e.kind === "markdown" && e.streaming) {
|
|
3346
|
+
changed = true;
|
|
3347
|
+
return {
|
|
3348
|
+
...e,
|
|
3349
|
+
streaming: false
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
return e;
|
|
3353
|
+
});
|
|
3354
|
+
return changed ? next : events;
|
|
3355
|
+
}
|
|
3356
|
+
/** Flip the trailing streaming markdown block for one specific owner. */
|
|
3357
|
+
function finalizeStreamingMarkdownForOwner(events, owner) {
|
|
3358
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
3359
|
+
const e = events[i];
|
|
3360
|
+
if (e.kind !== "markdown") continue;
|
|
3361
|
+
if (!e.streaming) continue;
|
|
3362
|
+
if (ownerOf(e) !== owner) continue;
|
|
3363
|
+
const next = events.slice();
|
|
3364
|
+
next[i] = {
|
|
3365
|
+
...e,
|
|
3366
|
+
streaming: false
|
|
3367
|
+
};
|
|
3368
|
+
return next;
|
|
3369
|
+
}
|
|
3370
|
+
return events;
|
|
3371
|
+
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Effective context size for a single turn.
|
|
3374
|
+
*
|
|
3375
|
+
* `usage.input` is misleading on its own when prompt caching is active: providers
|
|
3376
|
+
* (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
|
|
3377
|
+
* tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
|
|
3378
|
+
* tokens in `cacheCreation`. The model still saw all three buckets, so the real
|
|
3379
|
+
* context-window utilization is their sum.
|
|
3380
|
+
*
|
|
3381
|
+
* Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
|
|
3382
|
+
* collapses to plain `input` for them.
|
|
3383
|
+
*/
|
|
3384
|
+
function turnContextSize(usage) {
|
|
3385
|
+
if (!usage) return 0;
|
|
3386
|
+
return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheCreation ?? 0);
|
|
3387
|
+
}
|
|
3388
|
+
function useStreamBuffer(setEvents) {
|
|
3389
|
+
const bucketsRef = useRef(/* @__PURE__ */ new Map());
|
|
3390
|
+
const flushTimerRef = useRef(null);
|
|
3391
|
+
const drainPendingInto = useCallback((updater) => {
|
|
3392
|
+
if (flushTimerRef.current) {
|
|
3393
|
+
clearTimeout(flushTimerRef.current);
|
|
3394
|
+
flushTimerRef.current = null;
|
|
3395
|
+
}
|
|
3396
|
+
const buckets = Array.from(bucketsRef.current.values());
|
|
3397
|
+
bucketsRef.current.clear();
|
|
3398
|
+
if (!buckets.some((b) => b.markdown.length > 0 || b.thinking.length > 0) && !updater) return;
|
|
3399
|
+
setEvents((prev) => {
|
|
3400
|
+
let merged = prev;
|
|
3401
|
+
for (const bucket of buckets) merged = applyBucket(merged, bucket);
|
|
3402
|
+
return updater ? updater(merged) : merged;
|
|
3403
|
+
});
|
|
3404
|
+
}, [setEvents]);
|
|
3405
|
+
const flush = useCallback(() => drainPendingInto(), [drainPendingInto]);
|
|
3406
|
+
const flushAndUpdate = useCallback((update) => drainPendingInto(update), [drainPendingInto]);
|
|
3407
|
+
const appendImmediate = useCallback((evt) => drainPendingInto((events) => [...events, evt]), [drainPendingInto]);
|
|
3408
|
+
const queueStreamDelta = useCallback((kind, delta, source) => {
|
|
3409
|
+
if (!delta) return;
|
|
3410
|
+
const owner = source?.childId ?? PARENT_OWNER;
|
|
3411
|
+
const depth = source?.depth ?? 0;
|
|
3412
|
+
let bucket = bucketsRef.current.get(owner);
|
|
3413
|
+
if (!bucket) {
|
|
3414
|
+
bucket = emptyBucket(owner, depth);
|
|
3415
|
+
bucketsRef.current.set(owner, bucket);
|
|
3416
|
+
}
|
|
3417
|
+
bucket[kind] += delta;
|
|
3418
|
+
if (source?.turnId) bucket.turnId = source.turnId;
|
|
3419
|
+
if (!flushTimerRef.current) flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
3420
|
+
}, [flush]);
|
|
3421
|
+
const reset = useCallback(() => {
|
|
3422
|
+
if (flushTimerRef.current) {
|
|
3423
|
+
clearTimeout(flushTimerRef.current);
|
|
3424
|
+
flushTimerRef.current = null;
|
|
3425
|
+
}
|
|
3426
|
+
bucketsRef.current.clear();
|
|
3427
|
+
}, []);
|
|
3428
|
+
return useMemo(() => ({
|
|
3429
|
+
queueStreamDelta,
|
|
3430
|
+
appendImmediate,
|
|
3431
|
+
flushAndUpdate,
|
|
3432
|
+
flush,
|
|
3433
|
+
reset
|
|
3434
|
+
}), [
|
|
3435
|
+
queueStreamDelta,
|
|
3436
|
+
appendImmediate,
|
|
3437
|
+
flushAndUpdate,
|
|
3438
|
+
flush,
|
|
3439
|
+
reset
|
|
3440
|
+
]);
|
|
3441
|
+
}
|
|
3442
|
+
//#endregion
|
|
3443
|
+
//#region src/chat/theme-context.tsx
|
|
3444
|
+
const ThemeContext = createContext(DEFAULT_THEME);
|
|
3445
|
+
function ThemeProvider({ theme, children }) {
|
|
3446
|
+
return /* @__PURE__ */ jsx(ThemeContext.Provider, {
|
|
3447
|
+
value: theme,
|
|
3448
|
+
children
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
function useTheme() {
|
|
3452
|
+
return useContext(ThemeContext);
|
|
3453
|
+
}
|
|
3454
|
+
/** Color palette only — equivalent to `useTheme().colors`. */
|
|
3455
|
+
function useColors() {
|
|
3456
|
+
return useContext(ThemeContext).colors;
|
|
3457
|
+
}
|
|
3458
|
+
/** Select-row styling — equivalent to `useTheme().select`. */
|
|
3459
|
+
function useSelectStyle() {
|
|
3460
|
+
return useContext(ThemeContext).select;
|
|
3461
|
+
}
|
|
3462
|
+
/** Panel / surface backgrounds — equivalent to `useTheme().surfaces`. */
|
|
3463
|
+
function useSurfaces() {
|
|
3464
|
+
return useContext(ThemeContext).surfaces;
|
|
3465
|
+
}
|
|
3466
|
+
/** Raw syntax style table — `useTheme().syntax`. Renderer converts to its native style type. */
|
|
3467
|
+
function useSyntaxStyles() {
|
|
3468
|
+
return useContext(ThemeContext).syntax;
|
|
3469
|
+
}
|
|
3470
|
+
//#endregion
|
|
3471
|
+
//#region src/chat/turn-operations.ts
|
|
3472
|
+
/**
|
|
3473
|
+
* Fork — keep every turn up to and including `turnId`, then strip any
|
|
3474
|
+
* `tool_call` blocks left without a matching `tool_result` in the slice.
|
|
3475
|
+
*
|
|
3476
|
+
* Semantics:
|
|
3477
|
+
* - Include the selected turn ("branch from HERE" mental model — the
|
|
3478
|
+
* user wants the selected message to be the latest in the fork).
|
|
3479
|
+
* - If the selected turn is an assistant turn with unresolved
|
|
3480
|
+
* `tool_call` blocks (their `tool_result`s live in turns AFTER the
|
|
3481
|
+
* slice), strip those calls. Otherwise the fork would post an
|
|
3482
|
+
* assistant turn with no matching tool results, breaking the next
|
|
3483
|
+
* provider call.
|
|
3484
|
+
* - Drop turns that become empty (all blocks stripped).
|
|
3485
|
+
*
|
|
3486
|
+
* Returns `null` when `turnId` doesn't exist in `turns` — caller should
|
|
3487
|
+
* surface a "turn not found" error rather than silently no-op.
|
|
3488
|
+
*/
|
|
3489
|
+
function truncateTurnsAt(turns, turnId) {
|
|
3490
|
+
const idx = turns.findIndex((t) => t.id === turnId);
|
|
3491
|
+
if (idx === -1) return null;
|
|
3492
|
+
return stripOrphanToolBlocks(turns.slice(0, idx + 1));
|
|
3493
|
+
}
|
|
3494
|
+
/**
|
|
3495
|
+
* Delete — remove the turn with `turnId` and any tool blocks left
|
|
3496
|
+
* orphaned by the removal. Returns `null` when `turnId` doesn't exist.
|
|
3497
|
+
*
|
|
3498
|
+
* Strategy:
|
|
3499
|
+
* 1. Drop the target turn.
|
|
3500
|
+
* 2. Scan the remaining turns for `tool_call`s without a matching
|
|
3501
|
+
* `tool_result` (orphaned by removing the user turn that carried
|
|
3502
|
+
* the result), and `tool_result`s without a matching `tool_call`
|
|
3503
|
+
* (orphaned by removing the assistant turn that issued the call).
|
|
3504
|
+
* Strip both sides.
|
|
3505
|
+
* 3. Drop turns whose content is now empty.
|
|
3506
|
+
*
|
|
3507
|
+
* This guarantees the resulting history is protocol-clean — a follow-up
|
|
3508
|
+
* `agent.run()` against the modified session can post turns without the
|
|
3509
|
+
* provider rejecting the history.
|
|
3510
|
+
*/
|
|
3511
|
+
function deleteTurnSafely(turns, turnId) {
|
|
3512
|
+
const idx = turns.findIndex((t) => t.id === turnId);
|
|
3513
|
+
if (idx === -1) return null;
|
|
3514
|
+
return stripOrphanToolBlocks([...turns.slice(0, idx), ...turns.slice(idx + 1)]);
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Walk a turn list and remove any tool blocks whose counterpart is
|
|
3518
|
+
* missing. Drops turns left empty. Used by `truncateTurnsAt` (which can
|
|
3519
|
+
* leave `tool_call`s orphaned when their results are past the cut) and
|
|
3520
|
+
* `deleteTurnSafely` (which can orphan either side of a pair).
|
|
3521
|
+
*
|
|
3522
|
+
* Pure / total: returns a new array; never throws.
|
|
3523
|
+
*/
|
|
3524
|
+
function stripOrphanToolBlocks(turns) {
|
|
3525
|
+
const callIds = /* @__PURE__ */ new Set();
|
|
3526
|
+
const resultIds = /* @__PURE__ */ new Set();
|
|
3527
|
+
for (const turn of turns) for (const block of turn.content) if (block.type === "tool_call") callIds.add(block.id);
|
|
3528
|
+
else if (block.type === "tool_result") resultIds.add(block.callId);
|
|
3529
|
+
const result = [];
|
|
3530
|
+
for (const turn of turns) {
|
|
3531
|
+
const filtered = [];
|
|
3532
|
+
for (const block of turn.content) {
|
|
3533
|
+
if (block.type === "tool_call") {
|
|
3534
|
+
if (!resultIds.has(block.id)) continue;
|
|
3535
|
+
} else if (block.type === "tool_result") {
|
|
3536
|
+
if (!callIds.has(block.callId)) continue;
|
|
3537
|
+
}
|
|
3538
|
+
filtered.push(block);
|
|
3539
|
+
}
|
|
3540
|
+
if (filtered.length === 0) continue;
|
|
3541
|
+
result.push(filtered.length === turn.content.length ? turn : {
|
|
3542
|
+
...turn,
|
|
3543
|
+
content: filtered
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
return result;
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* Serialize a turn's content to a clean text representation suited for
|
|
3550
|
+
* the clipboard. Joins text + thinking blocks verbatim; tool calls and
|
|
3551
|
+
* tool results get bracketed labels so the user can paste a readable
|
|
3552
|
+
* record of what happened without losing structure.
|
|
3553
|
+
*
|
|
3554
|
+
* Empty turns return `''`.
|
|
3555
|
+
*/
|
|
3556
|
+
function turnAsText(turn) {
|
|
3557
|
+
const parts = [];
|
|
3558
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) parts.push(block.text);
|
|
3559
|
+
else if (block.type === "thinking" && block.text.trim()) parts.push(`[thinking]\n${block.text}`);
|
|
3560
|
+
else if (block.type === "tool_call") parts.push(`[tool call · ${block.name}]\n${stringifyArgs(block.input)}`);
|
|
3561
|
+
else if (block.type === "tool_result") parts.push(`[tool result]\n${typeof block.output === "string" ? block.output : JSON.stringify(block.output, null, 2)}`);
|
|
3562
|
+
return parts.join("\n\n");
|
|
3563
|
+
}
|
|
3564
|
+
function stringifyArgs(input) {
|
|
3565
|
+
try {
|
|
3566
|
+
return JSON.stringify(input, null, 2);
|
|
3567
|
+
} catch {
|
|
3568
|
+
return String(input);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Count turns before / after the one identified by `turnId` in the
|
|
3573
|
+
* given list. Returns `null` when the id is missing. Used to label the
|
|
3574
|
+
* turn-details modal with `N before · M after`.
|
|
3575
|
+
*/
|
|
3576
|
+
function countNeighbors(turnIds, turnId) {
|
|
3577
|
+
const idx = turnIds.indexOf(turnId);
|
|
3578
|
+
if (idx === -1) return null;
|
|
3579
|
+
return {
|
|
3580
|
+
before: idx,
|
|
3581
|
+
after: turnIds.length - 1 - idx
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
//#endregion
|
|
3585
|
+
export { BUILTIN_THEMES as $, BUILTIN_AGENTS as $t, readProjects as A, applyInsert as At, cleanTitle as B, removeProviderCredential as Bt, useSafeModeQueue as C, findGitRoot$1 as Ct, isOnSafelist as D, FILES_TRIGGER as Dt, getSafelist as E, uniqueSkillNamesFromReferences as Et, supportsOAuth as F, detectAuth as Ft, shortId as G, cerebrasDescriptor as Gt, ageString as H, writeCredentials as Ht, buildMcpServers as I, applyApiKeyEnv as It, DEFAULT_SETTINGS as J, modelsForDescriptor as Jt, listProjectFiles as K, credKeyOf as Kt, defaultMcpsConfigPaths as L, credentialsPath as Lt, writeProjects as M, findActiveTrigger as Mt, splitPromptSegments as N, mergeReferences as Nt, matchesSafelistEntry as O, createFilesCompletionProvider as Ot, runOAuthLogin as P, useCompletion as Pt, useSettings as Q, BUILD_AGENT as Qt, discoverProjectMcps as R, readCredentials as Rt, useSafeModeActions as S, toolResultText as St, addToSafelist as T, createSkillsCompletionProvider as Tt, compactPath as U, BUILTIN_PROVIDERS as Ut, generateSessionTitle as V, setProviderCredential as Vt, fmtTokens as W, anthropicDescriptor as Wt, SETTINGS_TOGGLES as X, openrouterDescriptor as Xt, SETTINGS_CHOICES as Y, openaiDescriptor as Yt, SettingsProvider as Z, piIdOf as Zt, discoverProjectSkills as _, saveState as _t, ThemeProvider as a, CATPPUCCIN_LATTE as at, writeSessionExport as b, titleFromTurns as bt, useSurfaces as c, ConfigProvider as ct, finalizeStreamingMarkdown as d, createStateStore as dt, DEFAULT_AGENT_ID as en, DEFAULT_THEME as et, finalizeStreamingMarkdownForOwner as f, deriveSessionTitle as ft, defaultSkillScanPaths as g, loadState as gt, buildSkillsConfig as h, listSessionMeta as ht, turnAsText as i, CATPPUCCIN_FRAPPE as it, suggestSafelistEntry as j, collectReferences as jt, projectsFilePath as k, uniqueFilesFromReferences as kt, useSyntaxStyles as l, useConfig as lt, useStreamBuffer as m, lastContextSizeFromTurns as mt, deleteTurnSafely as n, resolveAgentId as nn, resolveTheme as nt, useColors as o, CATPPUCCIN_MACCHIATO as ot, turnContextSize as p, eventsFromTurns as pt, useEnabledToggleSet as q, getContextWindow as qt, truncateTurnsAt as r, singleAgentRegistry as rn, VAPORWAVE_THEME as rt, useSelectStyle as s, CATPPUCCIN_MOCHA as st, countNeighbors as t, PLAN_AGENT as tn, resolveChipColor as tt, useTheme as u, resolveConfig as ut, renderSession as v, selectableTurnIds as vt, IMPLICITLY_SAFE_TOOLS as w, SKILLS_TRIGGER as wt, SafeModeProvider as x, toolCallPreview as xt, resolveSessionExportTarget as y, stripSpawnTokensLine as yt, parseMcpsFile as z, readProviderCredential as zt };
|
|
3586
|
+
|
|
3587
|
+
//# sourceMappingURL=turn-operations-5aQu4dJg.js.map
|