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.
Files changed (66) hide show
  1. package/README.md +11 -2
  2. package/dist/{index-bgh-k8Mv.d.ts → agent-JhicgLOV.d.ts} +2082 -1969
  3. package/dist/agent-JhicgLOV.d.ts.map +1 -0
  4. package/dist/chat.d.ts +340 -9
  5. package/dist/chat.d.ts.map +1 -1
  6. package/dist/chat.js +2 -2
  7. package/dist/contexts.d.ts +1 -1
  8. package/dist/{index-DRoG_udt.d.ts → index-2yLUyTbc.d.ts} +34 -4
  9. package/dist/{index-DRoG_udt.d.ts.map → index-2yLUyTbc.d.ts.map} +1 -1
  10. package/dist/{index-BB4kuRh3.d.ts → index-CXVvqTQj.d.ts} +1 -1
  11. package/dist/{index-BB4kuRh3.d.ts.map → index-CXVvqTQj.d.ts.map} +1 -1
  12. package/dist/{index-Ds5YpvfZ.d.ts → index-t_W9i7Ql.d.ts} +9 -4
  13. package/dist/index-t_W9i7Ql.d.ts.map +1 -0
  14. package/dist/index.d.ts +4 -4
  15. package/dist/index.js +6 -6
  16. package/dist/{interpolate-CukJwP2G.js → interpolate-Ck970-61.js} +11 -2
  17. package/dist/interpolate-Ck970-61.js.map +1 -0
  18. package/dist/{mcp-8wClKY-3.js → mcp-Dw-fRPVk.js} +61 -65
  19. package/dist/mcp-Dw-fRPVk.js.map +1 -0
  20. package/dist/mcp.d.ts +1 -1
  21. package/dist/mcp.js +1 -1
  22. package/dist/presets-BRFH2qsQ.js +90 -0
  23. package/dist/presets-BRFH2qsQ.js.map +1 -0
  24. package/dist/presets.d.ts +3 -2
  25. package/dist/presets.js +2 -2
  26. package/dist/providers.d.ts +1 -1
  27. package/dist/session/sqlite.d.ts +13 -2
  28. package/dist/session/sqlite.d.ts.map +1 -1
  29. package/dist/session/sqlite.js +96 -38
  30. package/dist/session/sqlite.js.map +1 -1
  31. package/dist/{session-Cn68UASv.js → session-791hhrFa.js} +65 -30
  32. package/dist/session-791hhrFa.js.map +1 -0
  33. package/dist/session.d.ts +1 -1
  34. package/dist/session.js +1 -1
  35. package/dist/skills.d.ts +2 -2
  36. package/dist/skills.js +1 -1
  37. package/dist/{stats-BT9l57RS.js → stats-DZIsGqzu.js} +15 -5
  38. package/dist/stats-DZIsGqzu.js.map +1 -0
  39. package/dist/theme-pJv47erq.d.ts +1202 -0
  40. package/dist/theme-pJv47erq.d.ts.map +1 -0
  41. package/dist/{tools-C8kDot0H.js → tools-CLazLRb4.js} +475 -318
  42. package/dist/tools-CLazLRb4.js.map +1 -0
  43. package/dist/tools.d.ts +2 -2
  44. package/dist/tools.js +1 -1
  45. package/dist/tui.d.ts +303 -18
  46. package/dist/tui.d.ts.map +1 -1
  47. package/dist/tui.js +3305 -509
  48. package/dist/tui.js.map +1 -1
  49. package/dist/turn-operations-5aQu4dJg.js +3587 -0
  50. package/dist/turn-operations-5aQu4dJg.js.map +1 -0
  51. package/dist/types.d.ts +3 -3
  52. package/dist/types.js +1 -1
  53. package/package.json +6 -1
  54. package/dist/index-Ds5YpvfZ.d.ts.map +0 -1
  55. package/dist/index-bgh-k8Mv.d.ts.map +0 -1
  56. package/dist/interpolate-CukJwP2G.js.map +0 -1
  57. package/dist/mcp-8wClKY-3.js.map +0 -1
  58. package/dist/presets-BzkJDW1K.js +0 -39
  59. package/dist/presets-BzkJDW1K.js.map +0 -1
  60. package/dist/session-Cn68UASv.js.map +0 -1
  61. package/dist/stats-BT9l57RS.js.map +0 -1
  62. package/dist/theme-BlXO6yHe.d.ts +0 -503
  63. package/dist/theme-BlXO6yHe.d.ts.map +0 -1
  64. package/dist/theme-context-MungM3SY.js +0 -1713
  65. package/dist/theme-context-MungM3SY.js.map +0 -1
  66. 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