@cam5/baby-bird 0.1.0 → 0.1.2

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.
@@ -2,8 +2,8 @@
2
2
  import { z } from "zod";
3
3
  var LlmExcerptRefSchema = z.object({
4
4
  hunk: z.string().min(1),
5
- /** Optional [start, end] range of new-file line numbers to narrow the hunk. */
6
- lines: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]).optional(),
5
+ /** Optional [start, end] new-file line numbers to narrow the hunk; fewer than two numbers means the whole hunk. */
6
+ lines: z.array(z.number().int().nonnegative()).optional(),
7
7
  note: z.string().optional()
8
8
  });
9
9
  var LlmSectionSchema = z.object({
@@ -136,36 +136,53 @@ import { homedir } from "os";
136
136
  import { join } from "path";
137
137
  import { z as z2 } from "zod";
138
138
  var CLAUDE_BASE = ["claude", "-p", "--no-session-persistence", "--setting-sources", "", "--tools", ""];
139
+ var CLAUDE_CHAT = ["claude", "--append-system-prompt-file", "{context-file}", "--", "{message}"];
140
+ var claudeChat = (...flags) => [CLAUDE_CHAT[0], ...flags, ...CLAUDE_CHAT.slice(1)];
139
141
  var BUILTIN_PRESETS = Object.freeze({
140
142
  claude: {
141
143
  command: [...CLAUDE_BASE],
144
+ chatCommand: claudeChat(),
145
+ kind: "claude",
142
146
  description: "Claude Code CLI with its default model"
143
147
  },
144
148
  "claude-sonnet": {
145
149
  command: [...CLAUDE_BASE, "--model", "sonnet", "--effort", "high"],
150
+ chatCommand: claudeChat("--model", "sonnet", "--effort", "high"),
151
+ kind: "claude",
146
152
  description: "Claude Code CLI, Sonnet at high effort"
147
153
  },
148
154
  "claude-opus": {
149
155
  command: [...CLAUDE_BASE, "--model", "opus", "--effort", "high"],
156
+ chatCommand: claudeChat("--model", "opus", "--effort", "high"),
157
+ kind: "claude",
150
158
  description: "Claude Code CLI, Opus at high effort"
151
159
  },
152
160
  "claude-fable": {
153
161
  command: [...CLAUDE_BASE, "--model", "fable", "--effort", "high"],
162
+ chatCommand: claudeChat("--model", "fable", "--effort", "high"),
163
+ kind: "claude",
154
164
  description: "Claude Code CLI, Fable at high effort"
155
165
  },
156
166
  "claude-haiku": {
157
167
  command: [...CLAUDE_BASE, "--model", "haiku"],
168
+ chatCommand: claudeChat("--model", "haiku"),
169
+ kind: "claude",
158
170
  description: "Claude Code CLI, Haiku (fast and cheap)"
159
171
  },
160
172
  llm: {
161
173
  command: ["llm"],
174
+ // `llm chat` has no opening-message argument; bb warns and the question is typed into the chat.
175
+ chatCommand: ["llm", "chat", "-s", "{context}"],
162
176
  description: "Simon Willison's llm CLI with its default model"
163
177
  }
164
178
  });
165
179
  var PromptViaSchema = z2.enum(["stdin", "arg"]);
180
+ var LlmKindSchema = z2.enum(["plain", "claude"]);
166
181
  var LlmPresetSchema = z2.object({
167
182
  command: z2.array(z2.string()).min(1),
183
+ chatCommand: z2.array(z2.string()).min(1).optional(),
168
184
  promptVia: PromptViaSchema.optional(),
185
+ kind: LlmKindSchema.optional(),
169
186
  description: z2.string().optional()
170
187
  });
171
188
  var ConfigSchema = z2.object({
@@ -174,7 +191,12 @@ var ConfigSchema = z2.object({
174
191
  presets: z2.record(z2.string(), LlmPresetSchema),
175
192
  args: z2.array(z2.string()),
176
193
  command: z2.array(z2.string()).min(1).nullable(),
194
+ /** Interactive command for `bb ask`; when set it replaces the preset's chatCommand. Same tokens as a preset's. */
195
+ chatCommand: z2.array(z2.string()).min(1).nullable(),
177
196
  promptVia: PromptViaSchema.nullable(),
197
+ kind: LlmKindSchema.nullable(),
198
+ /** Claude kind only: pass --json-schema so the CLI validates the answer itself. Off by default (see README). */
199
+ jsonSchema: z2.boolean(),
178
200
  timeoutMs: z2.number().int().positive(),
179
201
  maxPromptBytes: z2.number().int().positive(),
180
202
  env: z2.record(z2.string(), z2.string())
@@ -190,7 +212,9 @@ var ConfigSchema = z2.object({
190
212
  color: z2.enum(["auto", "always", "never"]),
191
213
  pager: z2.enum(["auto", "always", "never"]),
192
214
  maxExcerptLines: z2.number().int().positive(),
193
- width: z2.number().int().positive().nullable()
215
+ width: z2.number().int().positive().nullable(),
216
+ /** Language-aware token colors in excerpts. */
217
+ highlight: z2.enum(["auto", "always", "never"])
194
218
  }),
195
219
  cache: z2.object({
196
220
  enabled: z2.boolean(),
@@ -210,7 +234,10 @@ var DEFAULT_CONFIG = {
210
234
  presets: {},
211
235
  args: [],
212
236
  command: null,
237
+ chatCommand: null,
213
238
  promptVia: null,
239
+ kind: null,
240
+ jsonSchema: false,
214
241
  timeoutMs: 18e4,
215
242
  maxPromptBytes: 2e5,
216
243
  env: {}
@@ -229,7 +256,7 @@ var DEFAULT_CONFIG = {
229
256
  "**/*.map"
230
257
  ]
231
258
  },
232
- render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null },
259
+ render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null, highlight: "auto" },
233
260
  cache: { enabled: true, dir: null }
234
261
  };
235
262
  function userConfigPath(env = process.env) {
@@ -343,9 +370,9 @@ function allPresets(config) {
343
370
  }
344
371
  function resolveLlm(config) {
345
372
  const { llm } = config;
346
- const common = { timeoutMs: llm.timeoutMs, maxPromptBytes: llm.maxPromptBytes, env: llm.env };
373
+ const common2 = { timeoutMs: llm.timeoutMs, maxPromptBytes: llm.maxPromptBytes, env: llm.env, jsonSchema: llm.jsonSchema };
347
374
  if (llm.command) {
348
- return { command: [...llm.command, ...llm.args], promptVia: llm.promptVia ?? "stdin", preset: null, ...common };
375
+ return { command: [...llm.command, ...llm.args], promptVia: llm.promptVia ?? "stdin", kind: llm.kind ?? "plain", preset: null, ...common2 };
349
376
  }
350
377
  const presets = allPresets(config);
351
378
  const preset = presets[llm.preset];
@@ -356,30 +383,49 @@ function resolveLlm(config) {
356
383
  return {
357
384
  command: [...preset.command, ...llm.args],
358
385
  promptVia: llm.promptVia ?? preset.promptVia ?? "stdin",
386
+ kind: llm.kind ?? preset.kind ?? "plain",
359
387
  preset: llm.preset,
360
- ...common
388
+ ...common2
361
389
  };
362
390
  }
391
+ function resolveChat(config) {
392
+ const { llm } = config;
393
+ if (llm.chatCommand) return { command: [...llm.chatCommand], preset: null, env: llm.env };
394
+ if (llm.command) {
395
+ throw new ConfigError("llm.command is set but llm.chatCommand is not, so there is nothing to chat with.", "Set llm.chatCommand (see README), or pick a preset with --preset.");
396
+ }
397
+ const presets = allPresets(config);
398
+ const preset = presets[llm.preset];
399
+ if (!preset) {
400
+ const names = Object.keys(presets).sort().join(", ");
401
+ throw new ConfigError(`Unknown LLM preset "${llm.preset}"`, `Available presets: ${names}. Define your own under llm.presets, or set llm.command.`);
402
+ }
403
+ if (!preset.chatCommand) {
404
+ const withChat = Object.entries(presets).filter(([, p]) => p.chatCommand).map(([n]) => n).sort().join(", ");
405
+ throw new ConfigError(`Preset "${llm.preset}" has no chatCommand.`, `Add one under llm.presets.${llm.preset}.chatCommand, set llm.chatCommand, or use a preset that has one: ${withChat}.`);
406
+ }
407
+ return { command: [...preset.chatCommand], preset: llm.preset, env: llm.env };
408
+ }
363
409
  function shellSplit(input) {
364
410
  const out = [];
365
411
  let cur = "";
366
412
  let inToken = false;
367
- let quote = null;
413
+ let quote2 = null;
368
414
  for (let i = 0; i < input.length; i++) {
369
415
  const ch = input[i];
370
- if (quote === "'") {
371
- if (ch === "'") quote = null;
416
+ if (quote2 === "'") {
417
+ if (ch === "'") quote2 = null;
372
418
  else cur += ch;
373
419
  continue;
374
420
  }
375
- if (quote === '"') {
376
- if (ch === '"') quote = null;
421
+ if (quote2 === '"') {
422
+ if (ch === '"') quote2 = null;
377
423
  else if (ch === "\\" && i + 1 < input.length && '"\\$`'.includes(input[i + 1])) cur += input[++i];
378
424
  else cur += ch;
379
425
  continue;
380
426
  }
381
427
  if (ch === "'" || ch === '"') {
382
- quote = ch;
428
+ quote2 = ch;
383
429
  inToken = true;
384
430
  } else if (ch === "\\" && i + 1 < input.length) {
385
431
  cur += input[++i];
@@ -395,7 +441,7 @@ function shellSplit(input) {
395
441
  inToken = true;
396
442
  }
397
443
  }
398
- if (quote) throw new ConfigError(`Unterminated quote in command: ${input}`);
444
+ if (quote2) throw new ConfigError(`Unterminated quote in command: ${input}`);
399
445
  if (inToken) out.push(cur);
400
446
  return out;
401
447
  }
@@ -767,7 +813,8 @@ function materializeTour(input) {
767
813
  continue;
768
814
  }
769
815
  files.add(hit.file.path);
770
- const lines = sliceHunk(hit.hunk, ref.lines, input.maxExcerptLines);
816
+ const range = ref.lines && ref.lines.length >= 2 ? [ref.lines[0], ref.lines[1]] : void 0;
817
+ const lines = sliceHunk(hit.hunk, range, input.maxExcerptLines);
771
818
  const excerpt = {
772
819
  file: hit.file.path,
773
820
  hunkId: hit.hunk.id,
@@ -851,8 +898,8 @@ function sliceHunk(hunk, range, maxLines) {
851
898
  }
852
899
 
853
900
  // src/core/prompt/template.ts
854
- var PROMPT_VERSION = 2;
855
- var PROMPT_HEADER = `You are writing a guided code tour of a change for a reviewer who has not seen it before.
901
+ var PROMPT_VERSION = 3;
902
+ var PROMPT_HEADER_TEXT = `You are writing a guided code tour of a change for a reviewer who has not seen it before.
856
903
 
857
904
  A code tour is an ordered list of sections. Each section explains one coherent part of the change: what it does, why it is there, and how it connects to the rest. Sections are ordered the way a reader should encounter them: start with the change that makes everything else make sense (a new type, an interface, a data model, a configuration knob), then the code that builds on it, then wiring and plumbing, then tests and housekeeping.
858
905
 
@@ -862,7 +909,7 @@ A code tour is an ordered list of sections. Each section explains one coherent p
862
909
  - Group by concept, not by file. A section may span many files, and a file may appear in several sections.
863
910
  - Every file in the change must be claimed by at least one section. Put unrelated housekeeping (formatting, generated code, renames, dependency bumps) in one short final section rather than sprinkling it around.
864
911
  - A section's description is 2 to 5 sentences of plain prose written for a colleague. Lead with the purpose (why), then what changed, then anything a reviewer should look at carefully: behavior changes, edge cases, risk. Do not narrate line by line and do not restate the diff.
865
- - Choose 1 to 3 excerpts per section: the hunks that best show the idea. Reference hunks by their id exactly as given (for example "F2.H1"). Optionally narrow a hunk with "lines": [start, end] using NEW-file line numbers as they appear in the diff. Never quote code in the JSON; the real diff is rendered from your references.
912
+ - Choose 1 to 3 excerpts per section: the hunks that best show the idea. Reference hunks by their id exactly as given (for example "F2.H1"). Narrow a hunk to the interesting part with "lines": [start, end] using NEW-file line numbers as they appear in the diff; prefer 10 to 30 lines over a whole hunk. Use an empty "lines" array for the whole hunk. Never quote code in the JSON; the real diff is rendered from your references.
866
913
  - Give each excerpt a short "note" (under 15 words) saying what to look at.
867
914
  - The tour "title" is a short imperative phrase naming the change, like a good commit subject. The "summary" is 2 to 4 sentences describing the whole change and its motivation.
868
915
  - Use the pull request description and commit messages as evidence of intent, but trust the diff over them when they disagree.
@@ -871,7 +918,7 @@ A code tour is an ordered list of sections. Each section explains one coherent p
871
918
 
872
919
  ## Output
873
920
 
874
- Respond with ONLY a JSON object: no prose before or after it, and no code fences.
921
+ %%OUTPUT_INSTRUCTION%%
875
922
 
876
923
  {
877
924
  "title": "Short imperative title",
@@ -889,6 +936,12 @@ Respond with ONLY a JSON object: no prose before or after it, and no code fences
889
936
  ]
890
937
  }
891
938
  `;
939
+ var OUTPUT_PLAIN = "Respond with ONLY a JSON object: no prose before or after it, and no code fences.";
940
+ var OUTPUT_STRUCTURED = "Submit the tour through the structured output tool. Its input is the tour object itself, with title, summary and sections as top-level fields exactly as shown below (do not nest them under another key). Do not write prose before it.";
941
+ function promptHeader(structured) {
942
+ return PROMPT_HEADER_TEXT.replace("%%OUTPUT_INSTRUCTION%%", structured ? OUTPUT_STRUCTURED : OUTPUT_PLAIN);
943
+ }
944
+ var PROMPT_HEADER = promptHeader(false);
892
945
  var REPAIR_SUFFIX = (reason) => `
893
946
 
894
947
  ---
@@ -901,6 +954,11 @@ Respond again with ONLY the JSON object described above. No prose, no code fence
901
954
  var KEEP_LINES = 40;
902
955
  var MAX_PR_BODY_BYTES = 12e3;
903
956
  function buildPrompt(input) {
957
+ const header = promptHeader(Boolean(input.structured));
958
+ const change = renderChange(input, input.maxBytes - bytes(header) - 8);
959
+ return { prompt: [header, change.text].join("\n"), truncation: change.truncation };
960
+ }
961
+ function renderChange(input, maxBytes) {
904
962
  const context = renderContext(input);
905
963
  const truncation = { truncated: [], omitted: [] };
906
964
  const blocks = input.diff.files.map((file) => {
@@ -917,7 +975,7 @@ function buildPrompt(input) {
917
975
  });
918
976
  const text = (b) => b.mode === "full" ? b.full : b.mode === "truncated" ? b.truncated : b.omitted;
919
977
  const total = () => bytes(diffPreamble(truncation)) + blocks.reduce((n, b) => n + bytes(text(b)), 0);
920
- const budget = input.maxBytes - bytes(PROMPT_HEADER) - bytes(context) - 8;
978
+ const budget = maxBytes - bytes(context);
921
979
  if (total() > budget) {
922
980
  const bySize = [...blocks].sort((a, b) => bytes(b.full) - bytes(a.full));
923
981
  for (const block of bySize) {
@@ -934,8 +992,7 @@ function buildPrompt(input) {
934
992
  truncation.omitted.push(block.file.path);
935
993
  }
936
994
  }
937
- const prompt = [PROMPT_HEADER, context, diffPreamble(truncation), ...blocks.map(text)].join("\n");
938
- return { prompt, truncation };
995
+ return { text: [context, diffPreamble(truncation), ...blocks.map(text)].join("\n"), truncation };
939
996
  }
940
997
  function bytes(s) {
941
998
  return Buffer.byteLength(s, "utf8");
@@ -1326,32 +1383,237 @@ async function nearestAncestorBranch(branch, headSha, defaultBranch, cwd, debug)
1326
1383
  return best?.name ?? null;
1327
1384
  }
1328
1385
 
1386
+ // src/core/prompt/json-schema.ts
1387
+ var TOUR_JSON_SCHEMA = {
1388
+ type: "object",
1389
+ description: "The tour itself. Its fields (title, summary, sections) are the top-level properties of this object; do not wrap them in another key.",
1390
+ additionalProperties: false,
1391
+ required: ["title", "summary", "sections"],
1392
+ properties: {
1393
+ title: { type: "string", description: "Short imperative title naming the change, like a good commit subject." },
1394
+ summary: { type: "string", description: "2 to 4 sentences describing the whole change and its motivation." },
1395
+ sections: {
1396
+ type: "array",
1397
+ description: "Ordered sections of the tour, 2 to 8 of them.",
1398
+ items: {
1399
+ type: "object",
1400
+ additionalProperties: false,
1401
+ required: ["title", "description", "files", "excerpts"],
1402
+ properties: {
1403
+ title: { type: "string" },
1404
+ description: { type: "string", description: "Why, then what, then what to watch. 2 to 5 sentences." },
1405
+ files: { type: "array", items: { type: "string" }, description: "Paths this section is about, exactly as listed." },
1406
+ excerpts: {
1407
+ type: "array",
1408
+ description: "1 to 3 hunks that best show the idea.",
1409
+ items: {
1410
+ type: "object",
1411
+ additionalProperties: false,
1412
+ required: ["hunk", "lines", "note"],
1413
+ properties: {
1414
+ hunk: { type: "string", description: 'A hunk id exactly as given in the diff, e.g. "F2.H1".' },
1415
+ lines: {
1416
+ type: "array",
1417
+ items: { type: "integer" },
1418
+ description: "Empty for the whole hunk, or [start, end] new-file line numbers to narrow it."
1419
+ },
1420
+ note: { type: "string", description: "Under 15 words: what to look at. May be empty." }
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+ }
1428
+ };
1429
+
1430
+ // src/llm/claude-stream.ts
1431
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1432
+ var ClaudeStreamParser = class {
1433
+ constructor(onEvent = () => {
1434
+ }) {
1435
+ this.onEvent = onEvent;
1436
+ }
1437
+ onEvent;
1438
+ buffer = "";
1439
+ text = "";
1440
+ partialJson = "";
1441
+ messageStructured = null;
1442
+ resultStructured = null;
1443
+ resultText = null;
1444
+ isError = false;
1445
+ errorMessage;
1446
+ usage = {};
1447
+ unparsed = 0;
1448
+ push(chunk) {
1449
+ this.buffer += chunk;
1450
+ let idx;
1451
+ while ((idx = this.buffer.indexOf("\n")) !== -1) {
1452
+ const line = this.buffer.slice(0, idx);
1453
+ this.buffer = this.buffer.slice(idx + 1);
1454
+ this.handleLine(line);
1455
+ }
1456
+ }
1457
+ finish() {
1458
+ if (this.buffer.trim()) this.handleLine(this.buffer);
1459
+ this.buffer = "";
1460
+ const result = this.resultStructured ?? this.messageStructured ?? (this.partialJson.trim() || null) ?? (this.resultText?.trim() || null) ?? (this.text.trim() || null);
1461
+ const outcome = { result, isError: this.isError, usage: this.usage, unparsedLines: this.unparsed };
1462
+ if (this.errorMessage) outcome.errorMessage = this.errorMessage;
1463
+ return outcome;
1464
+ }
1465
+ handleLine(line) {
1466
+ const trimmed = line.trim();
1467
+ if (!trimmed) return;
1468
+ let ev;
1469
+ try {
1470
+ ev = JSON.parse(trimmed);
1471
+ } catch {
1472
+ this.unparsed++;
1473
+ return;
1474
+ }
1475
+ if (!isObject(ev)) return;
1476
+ switch (ev.type) {
1477
+ case "stream_event":
1478
+ if (isObject(ev.event)) this.handleStreamEvent(ev.event);
1479
+ break;
1480
+ case "assistant":
1481
+ if (isObject(ev.message)) this.handleAssistant(ev.message);
1482
+ break;
1483
+ case "user":
1484
+ if (isObject(ev.message)) this.handleUser(ev.message);
1485
+ break;
1486
+ case "result":
1487
+ this.handleResult(ev);
1488
+ break;
1489
+ default:
1490
+ break;
1491
+ }
1492
+ }
1493
+ handleStreamEvent(event) {
1494
+ switch (event.type) {
1495
+ case "message_start": {
1496
+ const model = isObject(event.message) && typeof event.message.model === "string" ? event.message.model : void 0;
1497
+ if (model) {
1498
+ this.usage.model = model;
1499
+ this.onEvent({ type: "usage", model });
1500
+ }
1501
+ break;
1502
+ }
1503
+ case "content_block_start": {
1504
+ const block = isObject(event.content_block) ? event.content_block : {};
1505
+ if (block.type === "text" || block.type === "tool_use") {
1506
+ this.text = "";
1507
+ this.partialJson = "";
1508
+ this.onEvent({ type: "answer-start" });
1509
+ }
1510
+ break;
1511
+ }
1512
+ case "content_block_delta": {
1513
+ const delta = isObject(event.delta) ? event.delta : {};
1514
+ if (delta.type === "thinking_delta") {
1515
+ const text = typeof delta.thinking === "string" ? delta.thinking : "";
1516
+ this.onEvent(text ? { type: "thinking", text } : { type: "thinking-pulse" });
1517
+ } else if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text) {
1518
+ this.text += delta.text;
1519
+ this.onEvent({ type: "text", text: delta.text });
1520
+ } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && delta.partial_json) {
1521
+ this.partialJson += delta.partial_json;
1522
+ this.onEvent({ type: "text", text: delta.partial_json });
1523
+ }
1524
+ break;
1525
+ }
1526
+ case "message_delta": {
1527
+ if (isObject(event.usage)) this.recordUsage(event.usage);
1528
+ break;
1529
+ }
1530
+ default:
1531
+ break;
1532
+ }
1533
+ }
1534
+ handleAssistant(message) {
1535
+ if (!Array.isArray(message.content)) return;
1536
+ for (const block of message.content) {
1537
+ if (isObject(block) && block.type === "tool_use" && isObject(block.input)) {
1538
+ this.messageStructured = JSON.stringify(block.input);
1539
+ }
1540
+ }
1541
+ }
1542
+ /** Tool results the CLI feeds back, e.g. a structured-output validation failure. */
1543
+ handleUser(message) {
1544
+ if (!Array.isArray(message.content)) return;
1545
+ for (const block of message.content) {
1546
+ if (!isObject(block) || block.type !== "tool_result" || block.is_error !== true) continue;
1547
+ const content = typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "");
1548
+ this.onEvent({ type: "notice", text: `Answer rejected (${content.replace(/^Output does not match required schema:\s*/i, "schema: ").slice(0, 300)}); the model is retrying` });
1549
+ }
1550
+ }
1551
+ handleResult(ev) {
1552
+ if (isObject(ev.structured_output)) this.resultStructured = JSON.stringify(ev.structured_output);
1553
+ if (typeof ev.result === "string") this.resultText = ev.result;
1554
+ const subtype = typeof ev.subtype === "string" ? ev.subtype : "";
1555
+ if (ev.is_error === true || subtype.startsWith("error")) {
1556
+ this.isError = true;
1557
+ const errors = Array.isArray(ev.errors) ? ev.errors.filter((e) => typeof e === "string").join("; ") : "";
1558
+ this.errorMessage = errors || typeof ev.result === "string" && ev.result || subtype || "unknown error";
1559
+ }
1560
+ if (isObject(ev.usage)) this.recordUsage(ev.usage);
1561
+ if (typeof ev.total_cost_usd === "number") this.usage.costUsd = ev.total_cost_usd;
1562
+ this.onEvent({ type: "usage", ...this.usage });
1563
+ }
1564
+ recordUsage(usage) {
1565
+ if (typeof usage.output_tokens === "number") this.usage.outputTokens = usage.output_tokens;
1566
+ const details = usage.output_tokens_details;
1567
+ if (isObject(details) && typeof details.thinking_tokens === "number") this.usage.thinkingTokens = details.thinking_tokens;
1568
+ this.onEvent({ type: "usage", ...this.usage });
1569
+ }
1570
+ };
1571
+
1329
1572
  // src/llm/command.ts
1330
1573
  import { spawn } from "child_process";
1574
+ import { StringDecoder } from "string_decoder";
1331
1575
  var STDERR_TAIL_LINES = 20;
1576
+ var CLAUDE_STREAM_ARGS = ["--output-format", "stream-json", "--verbose", "--include-partial-messages"];
1332
1577
  var CommandProvider = class {
1333
1578
  constructor(opts) {
1334
1579
  this.opts = opts;
1335
1580
  if (opts.command.length === 0) throw new LlmFailedError("LLM command is empty.");
1336
1581
  }
1337
1582
  opts;
1583
+ get kind() {
1584
+ return this.opts.kind ?? "plain";
1585
+ }
1338
1586
  describe() {
1339
1587
  return this.opts.command.map(shellQuote).join(" ");
1340
1588
  }
1341
- complete(prompt) {
1589
+ /** The full argv that will run, including any flags added for the command kind. */
1590
+ argv(prompt) {
1591
+ let argv = [...this.opts.command];
1592
+ if (this.kind === "claude") {
1593
+ argv.push(...CLAUDE_STREAM_ARGS);
1594
+ if (this.opts.jsonSchema) argv.push("--json-schema", JSON.stringify(this.opts.jsonSchema));
1595
+ }
1596
+ if (prompt !== void 0 && this.opts.promptVia === "arg") argv = argv.map((a) => a.replaceAll("{prompt}", prompt));
1597
+ return argv;
1598
+ }
1599
+ complete(prompt, opts = {}) {
1342
1600
  const { promptVia, timeoutMs } = this.opts;
1343
- const argv = promptVia === "arg" ? this.opts.command.map((a) => a.replaceAll("{prompt}", prompt)) : this.opts.command;
1344
- const [bin, ...args] = argv;
1601
+ const onEvent = opts.onEvent ?? (() => {
1602
+ });
1603
+ const [bin, ...args] = this.argv(prompt);
1345
1604
  const debug = this.opts.debug ?? (() => {
1346
1605
  });
1347
1606
  const started = Date.now();
1348
- debug(`running ${this.describe()} (prompt ${Buffer.byteLength(prompt)} bytes via ${promptVia})`);
1607
+ debug(`running ${this.describe()}${this.kind === "claude" ? " (+stream-json)" : ""} (prompt ${Buffer.byteLength(prompt)} bytes via ${promptVia})`);
1349
1608
  return new Promise((resolve, reject) => {
1350
1609
  const env = { ...process.env, ...this.opts.env };
1351
1610
  delete env.CLAUDECODE;
1352
1611
  const child = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
1612
+ const parser = this.kind === "claude" ? new ClaudeStreamParser(onEvent) : null;
1613
+ const decoder = new StringDecoder("utf8");
1353
1614
  const out = [];
1354
1615
  const err = [];
1616
+ let outBytes = 0;
1355
1617
  let settled = false;
1356
1618
  const finish = (fn) => {
1357
1619
  if (settled) return;
@@ -1365,7 +1627,16 @@ var CommandProvider = class {
1365
1627
  hint: "Raise llm.timeoutMs in your config, or pick a faster preset."
1366
1628
  })));
1367
1629
  }, timeoutMs);
1368
- child.stdout.on("data", (b) => out.push(b));
1630
+ onEvent({ type: "started" });
1631
+ child.stdout.on("data", (b) => {
1632
+ outBytes += b.length;
1633
+ if (parser) {
1634
+ parser.push(decoder.write(b));
1635
+ } else {
1636
+ out.push(b);
1637
+ onEvent({ type: "output", bytes: outBytes });
1638
+ }
1639
+ });
1369
1640
  child.stderr.on("data", (b) => err.push(b));
1370
1641
  child.on("error", (e) => {
1371
1642
  finish(() => {
@@ -1378,15 +1649,33 @@ var CommandProvider = class {
1378
1649
  });
1379
1650
  child.on("close", (code, signal) => {
1380
1651
  finish(() => {
1381
- const stdout = Buffer.concat(out).toString("utf8");
1382
1652
  const stderr = Buffer.concat(err).toString("utf8");
1383
- debug(`command exited with ${signal ? `signal ${signal}` : `code ${code}`} after ${Date.now() - started}ms; ${stdout.length} bytes of stdout`);
1653
+ const tail = stderr.trim().split("\n").slice(-STDERR_TAIL_LINES).join("\n");
1654
+ debug(`command exited with ${signal ? `signal ${signal}` : `code ${code}`} after ${Date.now() - started}ms; ${outBytes} bytes of stdout`);
1655
+ if (parser) {
1656
+ parser.push(decoder.end());
1657
+ const outcome = parser.finish();
1658
+ if (outcome.unparsedLines) debug(`${outcome.unparsedLines} non-JSON line(s) on stdout were ignored`);
1659
+ if (outcome.isError) {
1660
+ reject(new LlmFailedError(`Claude reported an error: ${outcome.errorMessage ?? "unknown"}`, { hint: tail || void 0 }));
1661
+ return;
1662
+ }
1663
+ if (code !== 0) {
1664
+ reject(new LlmFailedError(`LLM command failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${this.describe()}${tail ? "\n" + tail : ""}`));
1665
+ return;
1666
+ }
1667
+ if (!outcome.result) {
1668
+ reject(new LlmFailedError("Claude produced no result.", { hint: "Run with --debug to see what came back." }));
1669
+ return;
1670
+ }
1671
+ resolve(outcome.result);
1672
+ return;
1673
+ }
1384
1674
  if (code !== 0) {
1385
- const tail = stderr.trim().split("\n").slice(-STDERR_TAIL_LINES).join("\n");
1386
1675
  reject(new LlmFailedError(`LLM command failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${this.describe()}${tail ? "\n" + tail : ""}`));
1387
1676
  return;
1388
1677
  }
1389
- resolve(stdout);
1678
+ resolve(Buffer.concat(out).toString("utf8"));
1390
1679
  });
1391
1680
  });
1392
1681
  if (promptVia === "stdin") {
@@ -1410,21 +1699,30 @@ function createProvider(llm, opts = {}) {
1410
1699
  command: llm.command,
1411
1700
  promptVia: llm.promptVia,
1412
1701
  timeoutMs: llm.timeoutMs,
1702
+ kind: llm.kind,
1703
+ jsonSchema: llm.kind === "claude" && llm.jsonSchema ? TOUR_JSON_SCHEMA : void 0,
1413
1704
  env: llm.env,
1414
1705
  cwd: opts.cwd,
1415
1706
  debug: opts.debug
1416
1707
  });
1417
1708
  }
1418
1709
 
1710
+ // src/core/progress.ts
1711
+ var noProgress = { phase() {
1712
+ }, llm() {
1713
+ } };
1714
+
1419
1715
  // src/core/tour.ts
1420
1716
  async function prepareTour(opts) {
1421
1717
  const debug = opts.debug ?? (() => {
1422
1718
  });
1423
1719
  const warn = opts.warn ?? (() => {
1424
1720
  });
1721
+ const progress = opts.progress ?? noProgress;
1425
1722
  const { config } = opts;
1426
1723
  const llm = resolveLlm(config);
1427
1724
  const codehost = opts.codehost ?? createCodeHost(config, { debug });
1725
+ progress.phase("Working out what to tour");
1428
1726
  const resolved = await resolveRange(opts.range ?? {}, {
1429
1727
  cwd: opts.cwd,
1430
1728
  exclude: config.git.exclude,
@@ -1433,6 +1731,7 @@ async function prepareTour(opts) {
1433
1731
  debug
1434
1732
  });
1435
1733
  debug(`source: ${JSON.stringify(resolved.source)}`);
1734
+ progress.phase("Collecting the diff");
1436
1735
  const diff = await collectDiff(resolved.source, { cwd: opts.cwd, exclude: config.git.exclude, warn });
1437
1736
  if (diff.files.length === 0) {
1438
1737
  throw new NoChangesError("The selected range has no changes (after excludes).", "Check git.exclude in your config, or pass a different range.");
@@ -1440,7 +1739,8 @@ async function prepareTour(opts) {
1440
1739
  const commits = await collectCommits(resolved.source, opts.cwd);
1441
1740
  const context = { source: resolved.source, branch: resolved.branch, diff, commits };
1442
1741
  if (resolved.pullRequest) context.pullRequest = resolved.pullRequest;
1443
- const built = buildPrompt({ ...context, maxBytes: llm.maxPromptBytes });
1742
+ progress.phase("Building the prompt");
1743
+ const built = buildPrompt({ ...context, maxBytes: llm.maxPromptBytes, structured: llm.kind === "claude" && llm.jsonSchema });
1444
1744
  if (built.truncation.truncated.length || built.truncation.omitted.length) {
1445
1745
  warn(`Prompt exceeded ${llm.maxPromptBytes} bytes; truncated ${built.truncation.truncated.length} and omitted ${built.truncation.omitted.length} file diff(s).`);
1446
1746
  }
@@ -1454,11 +1754,13 @@ async function generateTour(opts, prepared) {
1454
1754
  });
1455
1755
  const warn = opts.warn ?? (() => {
1456
1756
  });
1757
+ const progress = opts.progress ?? noProgress;
1457
1758
  const { config } = opts;
1458
1759
  const prep = prepared ?? await prepareTour(opts);
1459
1760
  const useCache = config.cache.enabled && !opts.noCache;
1460
1761
  const cache = useCache ? new TourCache(opts.cacheDir) : null;
1461
1762
  if (cache && !opts.refresh) {
1763
+ progress.phase("Checking the cache");
1462
1764
  const hit = await cache.get(prep.cacheKey);
1463
1765
  if (hit) {
1464
1766
  debug(`cache hit: ${cache.pathFor(prep.cacheKey)}`);
@@ -1468,7 +1770,9 @@ async function generateTour(opts, prepared) {
1468
1770
  }
1469
1771
  const llm = resolveLlm(config);
1470
1772
  const provider = opts.provider ?? createProvider(llm, { cwd: opts.cwd, debug });
1471
- const output = await completeWithRepair(provider, prep.built.prompt, debug);
1773
+ progress.phase(`Asking ${llm.preset ?? llm.command[0]}`);
1774
+ const output = await completeWithRepair(provider, prep.built.prompt, debug, progress);
1775
+ progress.phase("Assembling the tour");
1472
1776
  const tour = materializeTour({
1473
1777
  output,
1474
1778
  diff: prep.context.diff,
@@ -1485,14 +1789,16 @@ async function generateTour(opts, prepared) {
1485
1789
  }
1486
1790
  return { tour, fromCache: false, cacheKey: cache ? prep.cacheKey : null, cachePath, prompt: prep.built.prompt };
1487
1791
  }
1488
- async function completeWithRepair(provider, prompt, debug) {
1489
- let raw = await provider.complete(prompt);
1792
+ async function completeWithRepair(provider, prompt, debug, progress) {
1793
+ const onEvent = (event) => progress.llm(event);
1794
+ let raw = await provider.complete(prompt, { onEvent });
1490
1795
  debug(`raw model output (attempt 1):
1491
1796
  ${raw}`);
1492
1797
  const first = parseOutput(raw);
1493
1798
  if (first.ok) return first.value;
1494
1799
  debug(`attempt 1 unusable: ${first.reason}; retrying with repair prompt`);
1495
- raw = await provider.complete(prompt + REPAIR_SUFFIX(first.reason));
1800
+ progress.phase("Asking again (the first answer was not valid JSON)");
1801
+ raw = await provider.complete(prompt + REPAIR_SUFFIX(first.reason), { onEvent });
1496
1802
  debug(`raw model output (attempt 2):
1497
1803
  ${raw}`);
1498
1804
  const second = parseOutput(raw);
@@ -1511,14 +1817,338 @@ function parseOutput(raw) {
1511
1817
  return { ok: true, value: parsed.data };
1512
1818
  }
1513
1819
 
1820
+ // src/core/chat.ts
1821
+ var FRAMING = `# About this conversation
1822
+
1823
+ You are chatting with a developer about one specific change in the git repository you are running in. \`bb\` (the baby-bird CLI) generated the guided code tour below for that change; the developer has it open next to this chat, so treat it as shared context. Answer questions about the change using the tour, the diff after it, and the repository's files whenever you can read them. Refer to tour sections by number and title, and to code by file path and line number. If the diff below was shortened to fit, say so instead of guessing at what was left out.`;
1824
+ function buildChatContext(input) {
1825
+ const head = [FRAMING, "", ...renderTour(input.tour, input.change, input.tourPath ?? null), ""].join("\n");
1826
+ const change = renderChange(input.change, Math.max(0, input.maxBytes - Buffer.byteLength(head, "utf8") - 1));
1827
+ return { text: head + change.text, truncation: change.truncation };
1828
+ }
1829
+ function renderTour(tour, change, tourPath) {
1830
+ const lines = [`# The code tour: ${tour.title}`, ""];
1831
+ lines.push(`- Change: ${describeSource(change.source, change.branch)}`);
1832
+ lines.push(`- Revisions: ${describeRevisions(change.source)}`);
1833
+ if (tour.pullRequest) lines.push(`- Pull request #${tour.pullRequest.number}: ${tour.pullRequest.title}${tour.pullRequest.url ? ` (${tour.pullRequest.url})` : ""}`);
1834
+ lines.push(`- Size: ${tour.stats.files} file${tour.stats.files === 1 ? "" : "s"}, +${tour.stats.additions} -${tour.stats.deletions}, ${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}`);
1835
+ lines.push(`- Generated: ${tour.generatedAt} by ${tour.generator.preset ?? tour.generator.command[0] ?? "unknown"}`);
1836
+ if (tourPath) lines.push(`- Tour JSON: ${tourPath}`);
1837
+ if (tour.summary.trim()) lines.push("", tour.summary.trim());
1838
+ tour.sections.forEach((s, i) => lines.push("", ...renderSection(s, i + 1)));
1839
+ return lines;
1840
+ }
1841
+ function describeRevisions(source) {
1842
+ const head = source.headSha.slice(0, 12);
1843
+ if (source.kind === "working") return `HEAD is ${head}`;
1844
+ const base = (source.mergeBase ?? source.baseSha).slice(0, 12);
1845
+ return `base ${base}, head ${head}; \`git diff ${base} ${head}\` reproduces the diff`;
1846
+ }
1847
+ function renderSection(s, n) {
1848
+ const lines = [`## ${n}. ${s.title}`, "", `${s.stats.files} file${s.stats.files === 1 ? "" : "s"}, +${s.stats.additions} -${s.stats.deletions}`];
1849
+ if (s.description.trim()) lines.push("", s.description.trim());
1850
+ if (s.files.length) lines.push("", "Files:", ...s.files.map((f) => `- ${f}`));
1851
+ for (const e of s.excerpts) lines.push("", ...renderExcerpt(e));
1852
+ return lines;
1853
+ }
1854
+ function renderExcerpt(e) {
1855
+ const note = e.note ? ` \u2014 ${e.note}` : "";
1856
+ const lines = [`Excerpt \`${e.file}:${e.newStart}\` (hunk ${e.hunkId})${note}`, "", "```"];
1857
+ const maxNo = Math.max(...e.lines.map((l) => Math.max(l.oldNo ?? 0, l.newNo ?? 0)), 1);
1858
+ const w = String(maxNo).length;
1859
+ for (const l of e.lines) {
1860
+ const oldNo = l.oldNo === void 0 ? " ".repeat(w) : String(l.oldNo).padStart(w);
1861
+ const newNo = l.newNo === void 0 ? " ".repeat(w) : String(l.newNo).padStart(w);
1862
+ const sign = l.type === "add" ? "+" : l.type === "del" ? "-" : " ";
1863
+ lines.push(`${oldNo} ${newNo} \u2502${sign}${l.text}`);
1864
+ }
1865
+ lines.push("```");
1866
+ return lines;
1867
+ }
1868
+
1869
+ // src/llm/chat.ts
1870
+ import { spawn as spawn2 } from "child_process";
1871
+ import { mkdtemp, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1872
+ import { constants as osConstants, tmpdir } from "os";
1873
+ import { join as join4 } from "path";
1874
+ var CONTEXT_FILE_TOKEN = "{context-file}";
1875
+ var CONTEXT_TOKEN = "{context}";
1876
+ var MESSAGE_TOKEN = "{message}";
1877
+ function chatArgv(input) {
1878
+ const message = input.message?.trim() ?? "";
1879
+ const out = [];
1880
+ for (const arg of input.command) {
1881
+ if (arg === MESSAGE_TOKEN && !message) continue;
1882
+ out.push(arg.replaceAll(CONTEXT_FILE_TOKEN, input.contextFile).replaceAll(CONTEXT_TOKEN, input.context).replaceAll(MESSAGE_TOKEN, message));
1883
+ }
1884
+ return out;
1885
+ }
1886
+ function chatSlots(command) {
1887
+ const joined = command.join("\0");
1888
+ return { context: joined.includes(CONTEXT_FILE_TOKEN) || joined.includes(CONTEXT_TOKEN), message: joined.includes(MESSAGE_TOKEN) };
1889
+ }
1890
+ async function launchChat(opts) {
1891
+ const debug = opts.debug ?? (() => {
1892
+ });
1893
+ const dir = await mkdtemp(join4(tmpdir(), "bb-chat-"));
1894
+ const contextFile = join4(dir, "context.md");
1895
+ await writeFile2(contextFile, opts.context, { encoding: "utf8", mode: 384 });
1896
+ const [bin, ...args] = chatArgv({ command: opts.command, contextFile, context: opts.context, message: opts.message });
1897
+ debug(`launching ${describeArgv([bin, ...args], opts.context)} (context ${Buffer.byteLength(opts.context)} bytes in ${contextFile})`);
1898
+ try {
1899
+ return await new Promise((resolve, reject) => {
1900
+ const env = { ...process.env, ...opts.env };
1901
+ delete env.CLAUDECODE;
1902
+ const child = spawn2(bin, args, { cwd: opts.cwd, env, stdio: opts.stdio ?? "inherit" });
1903
+ const onInt = () => {
1904
+ };
1905
+ const onTerm = () => child.kill("SIGTERM");
1906
+ const onHup = () => child.kill("SIGHUP");
1907
+ process.on("SIGINT", onInt);
1908
+ process.on("SIGTERM", onTerm);
1909
+ process.on("SIGHUP", onHup);
1910
+ const cleanup = () => {
1911
+ process.off("SIGINT", onInt);
1912
+ process.off("SIGTERM", onTerm);
1913
+ process.off("SIGHUP", onHup);
1914
+ };
1915
+ child.on("error", (e) => {
1916
+ cleanup();
1917
+ if (e.code === "ENOENT") {
1918
+ reject(new LlmFailedError(`Chat command not found: ${bin}`, { hint: "Install it, or choose another preset with --preset / llm.preset.", cause: e }));
1919
+ } else {
1920
+ reject(new LlmFailedError(`Could not run ${bin}: ${e.message}`, { cause: e }));
1921
+ }
1922
+ });
1923
+ child.on("close", (code, signal) => {
1924
+ cleanup();
1925
+ debug(`chat exited with ${signal ? `signal ${signal}` : `code ${code}`}`);
1926
+ const exitCode = code ?? (signal ? 128 + (osConstants.signals[signal] ?? 0) : 1);
1927
+ resolve({ exitCode, signal });
1928
+ });
1929
+ });
1930
+ } finally {
1931
+ await rm2(dir, { recursive: true, force: true });
1932
+ }
1933
+ }
1934
+ function describeArgv(argv, context) {
1935
+ return argv.map((a) => context && a.includes(context) ? a.replace(context, "<context>") : a).map(quote).join(" ");
1936
+ }
1937
+ function quote(arg) {
1938
+ if (arg === "") return '""';
1939
+ return /^[\w@%+=:,./{}-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
1940
+ }
1941
+
1514
1942
  // src/render/cli.ts
1515
1943
  import pc from "picocolors";
1944
+
1945
+ // src/render/highlight.ts
1946
+ import { common, createLowlight } from "lowlight";
1947
+ var lowlight = createLowlight(common);
1948
+ var BY_EXT = {
1949
+ ts: "typescript",
1950
+ mts: "typescript",
1951
+ cts: "typescript",
1952
+ tsx: "typescript",
1953
+ js: "javascript",
1954
+ mjs: "javascript",
1955
+ cjs: "javascript",
1956
+ jsx: "javascript",
1957
+ json: "json",
1958
+ jsonc: "json",
1959
+ json5: "json",
1960
+ py: "python",
1961
+ pyi: "python",
1962
+ rb: "ruby",
1963
+ rake: "ruby",
1964
+ gemspec: "ruby",
1965
+ go: "go",
1966
+ rs: "rust",
1967
+ java: "java",
1968
+ kt: "kotlin",
1969
+ kts: "kotlin",
1970
+ swift: "swift",
1971
+ scala: "scala",
1972
+ c: "c",
1973
+ h: "c",
1974
+ cc: "cpp",
1975
+ cpp: "cpp",
1976
+ cxx: "cpp",
1977
+ hpp: "cpp",
1978
+ hh: "cpp",
1979
+ cs: "csharp",
1980
+ m: "objectivec",
1981
+ mm: "objectivec",
1982
+ php: "php",
1983
+ pl: "perl",
1984
+ pm: "perl",
1985
+ lua: "lua",
1986
+ r: "r",
1987
+ dart: "dart",
1988
+ sh: "bash",
1989
+ bash: "bash",
1990
+ zsh: "bash",
1991
+ fish: "bash",
1992
+ yml: "yaml",
1993
+ yaml: "yaml",
1994
+ toml: "ini",
1995
+ ini: "ini",
1996
+ cfg: "ini",
1997
+ conf: "ini",
1998
+ md: "markdown",
1999
+ markdown: "markdown",
2000
+ html: "xml",
2001
+ htm: "xml",
2002
+ xml: "xml",
2003
+ svg: "xml",
2004
+ vue: "xml",
2005
+ css: "css",
2006
+ scss: "scss",
2007
+ less: "less",
2008
+ sql: "sql",
2009
+ graphql: "graphql",
2010
+ gql: "graphql",
2011
+ diff: "diff",
2012
+ patch: "diff",
2013
+ makefile: "makefile",
2014
+ mk: "makefile",
2015
+ vb: "vbnet",
2016
+ vbnet: "vbnet",
2017
+ wasm: "wasm"
2018
+ };
2019
+ var BY_NAME = {
2020
+ makefile: "makefile",
2021
+ gnumakefile: "makefile",
2022
+ dockerfile: "bash",
2023
+ rakefile: "ruby",
2024
+ gemfile: "ruby",
2025
+ ".bashrc": "bash",
2026
+ ".zshrc": "bash",
2027
+ ".profile": "bash",
2028
+ "cmakelists.txt": "cmake"
2029
+ };
2030
+ function languageForPath(path) {
2031
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
2032
+ const byName = BY_NAME[base];
2033
+ if (byName && lowlight.registered(byName)) return byName;
2034
+ const dot = base.lastIndexOf(".");
2035
+ if (dot === -1) return null;
2036
+ const lang = BY_EXT[base.slice(dot + 1)];
2037
+ return lang && lowlight.registered(lang) ? lang : null;
2038
+ }
2039
+ function stylers(c) {
2040
+ return {
2041
+ keyword: c.magenta,
2042
+ "selector-tag": c.magenta,
2043
+ "template-tag": c.magenta,
2044
+ doctag: c.magenta,
2045
+ string: c.yellow,
2046
+ regexp: c.yellow,
2047
+ "template-variable": c.yellow,
2048
+ subst: (s) => s,
2049
+ comment: c.gray,
2050
+ quote: c.gray,
2051
+ number: c.cyan,
2052
+ literal: c.cyan,
2053
+ symbol: c.cyan,
2054
+ bullet: c.cyan,
2055
+ link: c.cyan,
2056
+ title: c.blue,
2057
+ section: c.blue,
2058
+ "selector-id": c.blue,
2059
+ "selector-class": c.blue,
2060
+ type: c.cyan,
2061
+ built_in: c.cyan,
2062
+ class: c.cyan,
2063
+ attr: c.blue,
2064
+ attribute: c.blue,
2065
+ property: c.blue,
2066
+ variable: (s) => s,
2067
+ params: (s) => s,
2068
+ meta: c.gray,
2069
+ tag: c.blue,
2070
+ name: c.blue,
2071
+ "selector-attr": c.blue,
2072
+ "selector-pseudo": c.blue,
2073
+ addition: (s) => s,
2074
+ deletion: (s) => s,
2075
+ emphasis: c.italic,
2076
+ strong: c.bold
2077
+ };
2078
+ }
2079
+ function highlightCode(code, lang, c) {
2080
+ if (!lowlight.registered(lang)) return null;
2081
+ let tree;
2082
+ try {
2083
+ tree = lowlight.highlight(lang, code);
2084
+ } catch {
2085
+ return null;
2086
+ }
2087
+ const map = stylers(c);
2088
+ const lines = [""];
2089
+ const visit = (node, style) => {
2090
+ if (node.type === "text" && typeof node.value === "string") {
2091
+ const parts = node.value.split("\n");
2092
+ parts.forEach((part, i) => {
2093
+ if (i > 0) lines.push("");
2094
+ if (part) lines[lines.length - 1] += style ? style(part) : part;
2095
+ });
2096
+ return;
2097
+ }
2098
+ let own = style;
2099
+ for (const cls of node.properties?.className ?? []) {
2100
+ const scope = cls.startsWith("hljs-") ? cls.slice(5) : cls;
2101
+ const s = map[scope];
2102
+ if (s) {
2103
+ own = s;
2104
+ break;
2105
+ }
2106
+ }
2107
+ for (const child of node.children ?? []) visit(child, own);
2108
+ };
2109
+ visit(tree, null);
2110
+ const expected = code.split("\n").length;
2111
+ if (lines.length !== expected) return null;
2112
+ return lines;
2113
+ }
2114
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
2115
+ function stripAnsi(s) {
2116
+ return s.replace(ANSI_RE, "");
2117
+ }
2118
+ function visibleLength(s) {
2119
+ return stripAnsi(s).length;
2120
+ }
2121
+ var SOFT_RESET = "\x1B[39m\x1B[22m\x1B[23m\x1B[24m";
2122
+ function truncateAnsi(s, max) {
2123
+ if (visibleLength(s) <= max) return s;
2124
+ let out = "";
2125
+ let seen = 0;
2126
+ const limit = Math.max(0, max - 1);
2127
+ for (let i = 0; i < s.length; ) {
2128
+ if (s[i] === "\x1B") {
2129
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
2130
+ if (m) {
2131
+ out += m[0];
2132
+ i += m[0].length;
2133
+ continue;
2134
+ }
2135
+ }
2136
+ if (seen >= limit) break;
2137
+ out += s[i];
2138
+ seen++;
2139
+ i++;
2140
+ }
2141
+ return out + SOFT_RESET + "\u2026";
2142
+ }
2143
+
2144
+ // src/render/cli.ts
1516
2145
  var MIN_WIDTH = 40;
1517
2146
  var MAX_WIDTH = 120;
1518
2147
  var CliRenderer = class {
1519
2148
  render(tour, opts) {
1520
2149
  const c = pc.createColors(opts.color);
1521
2150
  const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, opts.width));
2151
+ const style = { highlight: Boolean(opts.color && opts.highlight) };
1522
2152
  const out = [];
1523
2153
  out.push(...renderHeader(tour, c, width, opts.fromCache ?? false));
1524
2154
  out.push("");
@@ -1527,12 +2157,12 @@ var CliRenderer = class {
1527
2157
  if (!section) {
1528
2158
  throw new UsageError(`No section ${opts.section}; this tour has ${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}.`);
1529
2159
  }
1530
- out.push(...renderSection(section, opts.section, tour.sections.length, c, width));
2160
+ out.push(...renderSection2(section, opts.section, tour.sections.length, c, width, style));
1531
2161
  } else {
1532
2162
  out.push(...renderToc(tour, c, width));
1533
2163
  out.push("");
1534
2164
  tour.sections.forEach((s, i) => {
1535
- out.push(...renderSection(s, i + 1, tour.sections.length, c, width));
2165
+ out.push(...renderSection2(s, i + 1, tour.sections.length, c, width, style));
1536
2166
  out.push("");
1537
2167
  });
1538
2168
  }
@@ -1566,12 +2196,12 @@ function renderToc(tour, c, width) {
1566
2196
  const label = ` ${n}. ${s.title}`;
1567
2197
  const right = `${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 ${statsLine(s.stats, c, true)}`;
1568
2198
  const rightPlain = `${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 +${s.stats.additions} -${s.stats.deletions}`;
1569
- const gap = Math.max(2, width - visibleLength(label) - rightPlain.length);
2199
+ const gap = Math.max(2, width - visibleLength2(label) - rightPlain.length);
1570
2200
  lines.push(label + " ".repeat(gap) + c.dim(right));
1571
2201
  }
1572
2202
  return lines;
1573
2203
  }
1574
- function renderSection(s, index, count, c, width) {
2204
+ function renderSection2(s, index, count, c, width, style) {
1575
2205
  const lines = [];
1576
2206
  lines.push(c.dim("\u2500".repeat(width)));
1577
2207
  lines.push(c.bold(`${index}. ${s.title}`) + c.dim(` (${index}/${count})`));
@@ -1586,26 +2216,39 @@ function renderSection(s, index, count, c, width) {
1586
2216
  }
1587
2217
  for (const e of s.excerpts) {
1588
2218
  lines.push("");
1589
- lines.push(...renderExcerpt(e, c, width));
2219
+ lines.push(...renderExcerpt2(e, c, width, style));
1590
2220
  }
1591
2221
  return lines;
1592
2222
  }
1593
- function renderExcerpt(e, c, width) {
2223
+ function renderExcerpt2(e, c, width, style) {
1594
2224
  const lines = [];
1595
2225
  const title = ` ${c.cyan(e.file)}${c.dim(":" + e.newStart)}`;
1596
2226
  lines.push(e.note ? `${title} ${c.italic(c.dim(e.note))}` : title);
1597
2227
  const maxNo = Math.max(...e.lines.map((l) => Math.max(l.oldNo ?? 0, l.newNo ?? 0)), 1);
1598
2228
  const w = String(maxNo).length;
1599
- const budget = Math.max(20, width - (3 + w * 2 + 5));
1600
- for (const l of e.lines) {
2229
+ const budget = Math.max(20, width - (3 + w * 2 + 3) - 1);
2230
+ const texts = e.lines.map((l) => expandTabs(l.text));
2231
+ const lang = style.highlight ? languageForPath(e.file) : null;
2232
+ const highlighted = lang ? highlightCode(texts.join("\n"), lang, c) : null;
2233
+ e.lines.forEach((l, i) => {
1601
2234
  const oldNo = l.oldNo === void 0 ? " ".repeat(w) : String(l.oldNo).padStart(w);
1602
2235
  const newNo = l.newNo === void 0 ? " ".repeat(w) : String(l.newNo).padStart(w);
1603
2236
  const sign = l.type === "add" ? "+" : l.type === "del" ? "-" : " ";
1604
- const text = truncate(expandTabs(l.text), budget);
1605
2237
  const gutter = c.dim(` ${oldNo} ${newNo} \u2502`);
1606
- const body = `${sign}${text}`;
1607
- lines.push(`${gutter}${l.type === "add" ? c.green(body) : l.type === "del" ? c.red(body) : body}`);
1608
- }
2238
+ const plain = texts[i];
2239
+ if (!highlighted) {
2240
+ const signStyled2 = l.type === "add" ? c.green(sign) : l.type === "del" ? c.red(sign) : sign;
2241
+ lines.push(`${gutter}${signStyled2}${truncate(plain, budget)}`);
2242
+ return;
2243
+ }
2244
+ const text = truncateAnsi(highlighted[i], budget);
2245
+ if (l.type === "ctx") {
2246
+ lines.push(`${gutter} ${text}`);
2247
+ return;
2248
+ }
2249
+ const signStyled = l.type === "add" ? c.green("+") : c.red("-");
2250
+ lines.push(`${gutter}${signStyled}${text}`);
2251
+ });
1609
2252
  return lines;
1610
2253
  }
1611
2254
  function statsLine(stats, c, omitFiles = false) {
@@ -1645,7 +2288,7 @@ function truncate(s, max) {
1645
2288
  function expandTabs(s) {
1646
2289
  return s.replaceAll(" ", " ");
1647
2290
  }
1648
- function visibleLength(s) {
2291
+ function visibleLength2(s) {
1649
2292
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1650
2293
  }
1651
2294
  function relativeTime(iso, now = /* @__PURE__ */ new Date()) {
@@ -1662,7 +2305,7 @@ function relativeTime(iso, now = /* @__PURE__ */ new Date()) {
1662
2305
  }
1663
2306
 
1664
2307
  // src/render/pager.ts
1665
- import { spawn as spawn2 } from "child_process";
2308
+ import { spawn as spawn3 } from "child_process";
1666
2309
  async function writeMaybePaged(output, opts) {
1667
2310
  const env = opts.env ?? process.env;
1668
2311
  const lineCount = output.split("\n").length;
@@ -1674,7 +2317,7 @@ async function writeMaybePaged(output, opts) {
1674
2317
  const pagerCmd = env.PAGER && env.PAGER.trim() || "less";
1675
2318
  const cmd = pagerCmd === "less" && !env.LESS ? "less -RFX" : pagerCmd;
1676
2319
  await new Promise((resolve) => {
1677
- const child = spawn2(cmd, { shell: true, stdio: ["pipe", "inherit", "inherit"], env });
2320
+ const child = spawn3(cmd, { shell: true, stdio: ["pipe", "inherit", "inherit"], env });
1678
2321
  let fellBack = false;
1679
2322
  child.on("error", () => {
1680
2323
  fellBack = true;
@@ -1718,6 +2361,7 @@ export {
1718
2361
  loadConfig,
1719
2362
  allPresets,
1720
2363
  resolveLlm,
2364
+ resolveChat,
1721
2365
  shellSplit,
1722
2366
  TourCache,
1723
2367
  extractJson,
@@ -1727,11 +2371,14 @@ export {
1727
2371
  materializeTour,
1728
2372
  sliceHunk,
1729
2373
  PROMPT_VERSION,
2374
+ promptHeader,
1730
2375
  buildPrompt,
2376
+ renderChange,
1731
2377
  describeSource,
1732
2378
  GhCodeHost,
1733
2379
  NoCodeHost,
1734
2380
  createCodeHost,
2381
+ git,
1735
2382
  gitRoot,
1736
2383
  revParse,
1737
2384
  currentBranch,
@@ -1739,10 +2386,17 @@ export {
1739
2386
  collectCommits,
1740
2387
  resolveRange,
1741
2388
  detectDefaultBranch,
2389
+ TOUR_JSON_SCHEMA,
2390
+ ClaudeStreamParser,
1742
2391
  CommandProvider,
1743
2392
  createProvider,
2393
+ noProgress,
1744
2394
  prepareTour,
1745
2395
  generateTour,
2396
+ buildChatContext,
2397
+ chatArgv,
2398
+ chatSlots,
2399
+ launchChat,
1746
2400
  CliRenderer,
1747
2401
  describeSource2,
1748
2402
  wrap,
@@ -1750,4 +2404,4 @@ export {
1750
2404
  writeMaybePaged,
1751
2405
  writeStdout
1752
2406
  };
1753
- //# sourceMappingURL=chunk-RWSCST2I.js.map
2407
+ //# sourceMappingURL=chunk-YI2UL4IC.js.map