@cam5/baby-bird 0.1.0 → 0.1.1

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({
@@ -139,22 +139,27 @@ var CLAUDE_BASE = ["claude", "-p", "--no-session-persistence", "--setting-source
139
139
  var BUILTIN_PRESETS = Object.freeze({
140
140
  claude: {
141
141
  command: [...CLAUDE_BASE],
142
+ kind: "claude",
142
143
  description: "Claude Code CLI with its default model"
143
144
  },
144
145
  "claude-sonnet": {
145
146
  command: [...CLAUDE_BASE, "--model", "sonnet", "--effort", "high"],
147
+ kind: "claude",
146
148
  description: "Claude Code CLI, Sonnet at high effort"
147
149
  },
148
150
  "claude-opus": {
149
151
  command: [...CLAUDE_BASE, "--model", "opus", "--effort", "high"],
152
+ kind: "claude",
150
153
  description: "Claude Code CLI, Opus at high effort"
151
154
  },
152
155
  "claude-fable": {
153
156
  command: [...CLAUDE_BASE, "--model", "fable", "--effort", "high"],
157
+ kind: "claude",
154
158
  description: "Claude Code CLI, Fable at high effort"
155
159
  },
156
160
  "claude-haiku": {
157
161
  command: [...CLAUDE_BASE, "--model", "haiku"],
162
+ kind: "claude",
158
163
  description: "Claude Code CLI, Haiku (fast and cheap)"
159
164
  },
160
165
  llm: {
@@ -163,9 +168,11 @@ var BUILTIN_PRESETS = Object.freeze({
163
168
  }
164
169
  });
165
170
  var PromptViaSchema = z2.enum(["stdin", "arg"]);
171
+ var LlmKindSchema = z2.enum(["plain", "claude"]);
166
172
  var LlmPresetSchema = z2.object({
167
173
  command: z2.array(z2.string()).min(1),
168
174
  promptVia: PromptViaSchema.optional(),
175
+ kind: LlmKindSchema.optional(),
169
176
  description: z2.string().optional()
170
177
  });
171
178
  var ConfigSchema = z2.object({
@@ -175,6 +182,9 @@ var ConfigSchema = z2.object({
175
182
  args: z2.array(z2.string()),
176
183
  command: z2.array(z2.string()).min(1).nullable(),
177
184
  promptVia: PromptViaSchema.nullable(),
185
+ kind: LlmKindSchema.nullable(),
186
+ /** Claude kind only: pass --json-schema so the CLI validates the answer itself. Off by default (see README). */
187
+ jsonSchema: z2.boolean(),
178
188
  timeoutMs: z2.number().int().positive(),
179
189
  maxPromptBytes: z2.number().int().positive(),
180
190
  env: z2.record(z2.string(), z2.string())
@@ -190,7 +200,11 @@ var ConfigSchema = z2.object({
190
200
  color: z2.enum(["auto", "always", "never"]),
191
201
  pager: z2.enum(["auto", "always", "never"]),
192
202
  maxExcerptLines: z2.number().int().positive(),
193
- width: z2.number().int().positive().nullable()
203
+ width: z2.number().int().positive().nullable(),
204
+ /** Language-aware token colors in excerpts (needs a 256-color terminal for the add/del tints). */
205
+ highlight: z2.enum(["auto", "always", "never"]),
206
+ /** Background tint palette for added/removed lines. */
207
+ theme: z2.enum(["dark", "light"])
194
208
  }),
195
209
  cache: z2.object({
196
210
  enabled: z2.boolean(),
@@ -211,6 +225,8 @@ var DEFAULT_CONFIG = {
211
225
  args: [],
212
226
  command: null,
213
227
  promptVia: null,
228
+ kind: null,
229
+ jsonSchema: false,
214
230
  timeoutMs: 18e4,
215
231
  maxPromptBytes: 2e5,
216
232
  env: {}
@@ -229,7 +245,7 @@ var DEFAULT_CONFIG = {
229
245
  "**/*.map"
230
246
  ]
231
247
  },
232
- render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null },
248
+ render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null, highlight: "auto", theme: "dark" },
233
249
  cache: { enabled: true, dir: null }
234
250
  };
235
251
  function userConfigPath(env = process.env) {
@@ -343,9 +359,9 @@ function allPresets(config) {
343
359
  }
344
360
  function resolveLlm(config) {
345
361
  const { llm } = config;
346
- const common = { timeoutMs: llm.timeoutMs, maxPromptBytes: llm.maxPromptBytes, env: llm.env };
362
+ const common2 = { timeoutMs: llm.timeoutMs, maxPromptBytes: llm.maxPromptBytes, env: llm.env, jsonSchema: llm.jsonSchema };
347
363
  if (llm.command) {
348
- return { command: [...llm.command, ...llm.args], promptVia: llm.promptVia ?? "stdin", preset: null, ...common };
364
+ return { command: [...llm.command, ...llm.args], promptVia: llm.promptVia ?? "stdin", kind: llm.kind ?? "plain", preset: null, ...common2 };
349
365
  }
350
366
  const presets = allPresets(config);
351
367
  const preset = presets[llm.preset];
@@ -356,8 +372,9 @@ function resolveLlm(config) {
356
372
  return {
357
373
  command: [...preset.command, ...llm.args],
358
374
  promptVia: llm.promptVia ?? preset.promptVia ?? "stdin",
375
+ kind: llm.kind ?? preset.kind ?? "plain",
359
376
  preset: llm.preset,
360
- ...common
377
+ ...common2
361
378
  };
362
379
  }
363
380
  function shellSplit(input) {
@@ -767,7 +784,8 @@ function materializeTour(input) {
767
784
  continue;
768
785
  }
769
786
  files.add(hit.file.path);
770
- const lines = sliceHunk(hit.hunk, ref.lines, input.maxExcerptLines);
787
+ const range = ref.lines && ref.lines.length >= 2 ? [ref.lines[0], ref.lines[1]] : void 0;
788
+ const lines = sliceHunk(hit.hunk, range, input.maxExcerptLines);
771
789
  const excerpt = {
772
790
  file: hit.file.path,
773
791
  hunkId: hit.hunk.id,
@@ -851,8 +869,8 @@ function sliceHunk(hunk, range, maxLines) {
851
869
  }
852
870
 
853
871
  // 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.
872
+ var PROMPT_VERSION = 3;
873
+ var PROMPT_HEADER_TEXT = `You are writing a guided code tour of a change for a reviewer who has not seen it before.
856
874
 
857
875
  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
876
 
@@ -862,7 +880,7 @@ A code tour is an ordered list of sections. Each section explains one coherent p
862
880
  - Group by concept, not by file. A section may span many files, and a file may appear in several sections.
863
881
  - 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
882
  - 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.
883
+ - 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
884
  - Give each excerpt a short "note" (under 15 words) saying what to look at.
867
885
  - 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
886
  - Use the pull request description and commit messages as evidence of intent, but trust the diff over them when they disagree.
@@ -871,7 +889,7 @@ A code tour is an ordered list of sections. Each section explains one coherent p
871
889
 
872
890
  ## Output
873
891
 
874
- Respond with ONLY a JSON object: no prose before or after it, and no code fences.
892
+ %%OUTPUT_INSTRUCTION%%
875
893
 
876
894
  {
877
895
  "title": "Short imperative title",
@@ -889,6 +907,12 @@ Respond with ONLY a JSON object: no prose before or after it, and no code fences
889
907
  ]
890
908
  }
891
909
  `;
910
+ var OUTPUT_PLAIN = "Respond with ONLY a JSON object: no prose before or after it, and no code fences.";
911
+ 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.";
912
+ function promptHeader(structured) {
913
+ return PROMPT_HEADER_TEXT.replace("%%OUTPUT_INSTRUCTION%%", structured ? OUTPUT_STRUCTURED : OUTPUT_PLAIN);
914
+ }
915
+ var PROMPT_HEADER = promptHeader(false);
892
916
  var REPAIR_SUFFIX = (reason) => `
893
917
 
894
918
  ---
@@ -901,6 +925,7 @@ Respond again with ONLY the JSON object described above. No prose, no code fence
901
925
  var KEEP_LINES = 40;
902
926
  var MAX_PR_BODY_BYTES = 12e3;
903
927
  function buildPrompt(input) {
928
+ const header = promptHeader(Boolean(input.structured));
904
929
  const context = renderContext(input);
905
930
  const truncation = { truncated: [], omitted: [] };
906
931
  const blocks = input.diff.files.map((file) => {
@@ -917,7 +942,7 @@ function buildPrompt(input) {
917
942
  });
918
943
  const text = (b) => b.mode === "full" ? b.full : b.mode === "truncated" ? b.truncated : b.omitted;
919
944
  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;
945
+ const budget = input.maxBytes - bytes(header) - bytes(context) - 8;
921
946
  if (total() > budget) {
922
947
  const bySize = [...blocks].sort((a, b) => bytes(b.full) - bytes(a.full));
923
948
  for (const block of bySize) {
@@ -934,7 +959,7 @@ function buildPrompt(input) {
934
959
  truncation.omitted.push(block.file.path);
935
960
  }
936
961
  }
937
- const prompt = [PROMPT_HEADER, context, diffPreamble(truncation), ...blocks.map(text)].join("\n");
962
+ const prompt = [header, context, diffPreamble(truncation), ...blocks.map(text)].join("\n");
938
963
  return { prompt, truncation };
939
964
  }
940
965
  function bytes(s) {
@@ -1326,32 +1351,237 @@ async function nearestAncestorBranch(branch, headSha, defaultBranch, cwd, debug)
1326
1351
  return best?.name ?? null;
1327
1352
  }
1328
1353
 
1354
+ // src/core/prompt/json-schema.ts
1355
+ var TOUR_JSON_SCHEMA = {
1356
+ type: "object",
1357
+ description: "The tour itself. Its fields (title, summary, sections) are the top-level properties of this object; do not wrap them in another key.",
1358
+ additionalProperties: false,
1359
+ required: ["title", "summary", "sections"],
1360
+ properties: {
1361
+ title: { type: "string", description: "Short imperative title naming the change, like a good commit subject." },
1362
+ summary: { type: "string", description: "2 to 4 sentences describing the whole change and its motivation." },
1363
+ sections: {
1364
+ type: "array",
1365
+ description: "Ordered sections of the tour, 2 to 8 of them.",
1366
+ items: {
1367
+ type: "object",
1368
+ additionalProperties: false,
1369
+ required: ["title", "description", "files", "excerpts"],
1370
+ properties: {
1371
+ title: { type: "string" },
1372
+ description: { type: "string", description: "Why, then what, then what to watch. 2 to 5 sentences." },
1373
+ files: { type: "array", items: { type: "string" }, description: "Paths this section is about, exactly as listed." },
1374
+ excerpts: {
1375
+ type: "array",
1376
+ description: "1 to 3 hunks that best show the idea.",
1377
+ items: {
1378
+ type: "object",
1379
+ additionalProperties: false,
1380
+ required: ["hunk", "lines", "note"],
1381
+ properties: {
1382
+ hunk: { type: "string", description: 'A hunk id exactly as given in the diff, e.g. "F2.H1".' },
1383
+ lines: {
1384
+ type: "array",
1385
+ items: { type: "integer" },
1386
+ description: "Empty for the whole hunk, or [start, end] new-file line numbers to narrow it."
1387
+ },
1388
+ note: { type: "string", description: "Under 15 words: what to look at. May be empty." }
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+ };
1397
+
1398
+ // src/llm/claude-stream.ts
1399
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1400
+ var ClaudeStreamParser = class {
1401
+ constructor(onEvent = () => {
1402
+ }) {
1403
+ this.onEvent = onEvent;
1404
+ }
1405
+ onEvent;
1406
+ buffer = "";
1407
+ text = "";
1408
+ partialJson = "";
1409
+ messageStructured = null;
1410
+ resultStructured = null;
1411
+ resultText = null;
1412
+ isError = false;
1413
+ errorMessage;
1414
+ usage = {};
1415
+ unparsed = 0;
1416
+ push(chunk) {
1417
+ this.buffer += chunk;
1418
+ let idx;
1419
+ while ((idx = this.buffer.indexOf("\n")) !== -1) {
1420
+ const line = this.buffer.slice(0, idx);
1421
+ this.buffer = this.buffer.slice(idx + 1);
1422
+ this.handleLine(line);
1423
+ }
1424
+ }
1425
+ finish() {
1426
+ if (this.buffer.trim()) this.handleLine(this.buffer);
1427
+ this.buffer = "";
1428
+ const result = this.resultStructured ?? this.messageStructured ?? (this.partialJson.trim() || null) ?? (this.resultText?.trim() || null) ?? (this.text.trim() || null);
1429
+ const outcome = { result, isError: this.isError, usage: this.usage, unparsedLines: this.unparsed };
1430
+ if (this.errorMessage) outcome.errorMessage = this.errorMessage;
1431
+ return outcome;
1432
+ }
1433
+ handleLine(line) {
1434
+ const trimmed = line.trim();
1435
+ if (!trimmed) return;
1436
+ let ev;
1437
+ try {
1438
+ ev = JSON.parse(trimmed);
1439
+ } catch {
1440
+ this.unparsed++;
1441
+ return;
1442
+ }
1443
+ if (!isObject(ev)) return;
1444
+ switch (ev.type) {
1445
+ case "stream_event":
1446
+ if (isObject(ev.event)) this.handleStreamEvent(ev.event);
1447
+ break;
1448
+ case "assistant":
1449
+ if (isObject(ev.message)) this.handleAssistant(ev.message);
1450
+ break;
1451
+ case "user":
1452
+ if (isObject(ev.message)) this.handleUser(ev.message);
1453
+ break;
1454
+ case "result":
1455
+ this.handleResult(ev);
1456
+ break;
1457
+ default:
1458
+ break;
1459
+ }
1460
+ }
1461
+ handleStreamEvent(event) {
1462
+ switch (event.type) {
1463
+ case "message_start": {
1464
+ const model = isObject(event.message) && typeof event.message.model === "string" ? event.message.model : void 0;
1465
+ if (model) {
1466
+ this.usage.model = model;
1467
+ this.onEvent({ type: "usage", model });
1468
+ }
1469
+ break;
1470
+ }
1471
+ case "content_block_start": {
1472
+ const block = isObject(event.content_block) ? event.content_block : {};
1473
+ if (block.type === "text" || block.type === "tool_use") {
1474
+ this.text = "";
1475
+ this.partialJson = "";
1476
+ this.onEvent({ type: "answer-start" });
1477
+ }
1478
+ break;
1479
+ }
1480
+ case "content_block_delta": {
1481
+ const delta = isObject(event.delta) ? event.delta : {};
1482
+ if (delta.type === "thinking_delta") {
1483
+ const text = typeof delta.thinking === "string" ? delta.thinking : "";
1484
+ this.onEvent(text ? { type: "thinking", text } : { type: "thinking-pulse" });
1485
+ } else if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text) {
1486
+ this.text += delta.text;
1487
+ this.onEvent({ type: "text", text: delta.text });
1488
+ } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && delta.partial_json) {
1489
+ this.partialJson += delta.partial_json;
1490
+ this.onEvent({ type: "text", text: delta.partial_json });
1491
+ }
1492
+ break;
1493
+ }
1494
+ case "message_delta": {
1495
+ if (isObject(event.usage)) this.recordUsage(event.usage);
1496
+ break;
1497
+ }
1498
+ default:
1499
+ break;
1500
+ }
1501
+ }
1502
+ handleAssistant(message) {
1503
+ if (!Array.isArray(message.content)) return;
1504
+ for (const block of message.content) {
1505
+ if (isObject(block) && block.type === "tool_use" && isObject(block.input)) {
1506
+ this.messageStructured = JSON.stringify(block.input);
1507
+ }
1508
+ }
1509
+ }
1510
+ /** Tool results the CLI feeds back, e.g. a structured-output validation failure. */
1511
+ handleUser(message) {
1512
+ if (!Array.isArray(message.content)) return;
1513
+ for (const block of message.content) {
1514
+ if (!isObject(block) || block.type !== "tool_result" || block.is_error !== true) continue;
1515
+ const content = typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "");
1516
+ 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` });
1517
+ }
1518
+ }
1519
+ handleResult(ev) {
1520
+ if (isObject(ev.structured_output)) this.resultStructured = JSON.stringify(ev.structured_output);
1521
+ if (typeof ev.result === "string") this.resultText = ev.result;
1522
+ const subtype = typeof ev.subtype === "string" ? ev.subtype : "";
1523
+ if (ev.is_error === true || subtype.startsWith("error")) {
1524
+ this.isError = true;
1525
+ const errors = Array.isArray(ev.errors) ? ev.errors.filter((e) => typeof e === "string").join("; ") : "";
1526
+ this.errorMessage = errors || typeof ev.result === "string" && ev.result || subtype || "unknown error";
1527
+ }
1528
+ if (isObject(ev.usage)) this.recordUsage(ev.usage);
1529
+ if (typeof ev.total_cost_usd === "number") this.usage.costUsd = ev.total_cost_usd;
1530
+ this.onEvent({ type: "usage", ...this.usage });
1531
+ }
1532
+ recordUsage(usage) {
1533
+ if (typeof usage.output_tokens === "number") this.usage.outputTokens = usage.output_tokens;
1534
+ const details = usage.output_tokens_details;
1535
+ if (isObject(details) && typeof details.thinking_tokens === "number") this.usage.thinkingTokens = details.thinking_tokens;
1536
+ this.onEvent({ type: "usage", ...this.usage });
1537
+ }
1538
+ };
1539
+
1329
1540
  // src/llm/command.ts
1330
1541
  import { spawn } from "child_process";
1542
+ import { StringDecoder } from "string_decoder";
1331
1543
  var STDERR_TAIL_LINES = 20;
1544
+ var CLAUDE_STREAM_ARGS = ["--output-format", "stream-json", "--verbose", "--include-partial-messages"];
1332
1545
  var CommandProvider = class {
1333
1546
  constructor(opts) {
1334
1547
  this.opts = opts;
1335
1548
  if (opts.command.length === 0) throw new LlmFailedError("LLM command is empty.");
1336
1549
  }
1337
1550
  opts;
1551
+ get kind() {
1552
+ return this.opts.kind ?? "plain";
1553
+ }
1338
1554
  describe() {
1339
1555
  return this.opts.command.map(shellQuote).join(" ");
1340
1556
  }
1341
- complete(prompt) {
1557
+ /** The full argv that will run, including any flags added for the command kind. */
1558
+ argv(prompt) {
1559
+ let argv = [...this.opts.command];
1560
+ if (this.kind === "claude") {
1561
+ argv.push(...CLAUDE_STREAM_ARGS);
1562
+ if (this.opts.jsonSchema) argv.push("--json-schema", JSON.stringify(this.opts.jsonSchema));
1563
+ }
1564
+ if (prompt !== void 0 && this.opts.promptVia === "arg") argv = argv.map((a) => a.replaceAll("{prompt}", prompt));
1565
+ return argv;
1566
+ }
1567
+ complete(prompt, opts = {}) {
1342
1568
  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;
1569
+ const onEvent = opts.onEvent ?? (() => {
1570
+ });
1571
+ const [bin, ...args] = this.argv(prompt);
1345
1572
  const debug = this.opts.debug ?? (() => {
1346
1573
  });
1347
1574
  const started = Date.now();
1348
- debug(`running ${this.describe()} (prompt ${Buffer.byteLength(prompt)} bytes via ${promptVia})`);
1575
+ debug(`running ${this.describe()}${this.kind === "claude" ? " (+stream-json)" : ""} (prompt ${Buffer.byteLength(prompt)} bytes via ${promptVia})`);
1349
1576
  return new Promise((resolve, reject) => {
1350
1577
  const env = { ...process.env, ...this.opts.env };
1351
1578
  delete env.CLAUDECODE;
1352
1579
  const child = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
1580
+ const parser = this.kind === "claude" ? new ClaudeStreamParser(onEvent) : null;
1581
+ const decoder = new StringDecoder("utf8");
1353
1582
  const out = [];
1354
1583
  const err = [];
1584
+ let outBytes = 0;
1355
1585
  let settled = false;
1356
1586
  const finish = (fn) => {
1357
1587
  if (settled) return;
@@ -1365,7 +1595,16 @@ var CommandProvider = class {
1365
1595
  hint: "Raise llm.timeoutMs in your config, or pick a faster preset."
1366
1596
  })));
1367
1597
  }, timeoutMs);
1368
- child.stdout.on("data", (b) => out.push(b));
1598
+ onEvent({ type: "started" });
1599
+ child.stdout.on("data", (b) => {
1600
+ outBytes += b.length;
1601
+ if (parser) {
1602
+ parser.push(decoder.write(b));
1603
+ } else {
1604
+ out.push(b);
1605
+ onEvent({ type: "output", bytes: outBytes });
1606
+ }
1607
+ });
1369
1608
  child.stderr.on("data", (b) => err.push(b));
1370
1609
  child.on("error", (e) => {
1371
1610
  finish(() => {
@@ -1378,15 +1617,33 @@ var CommandProvider = class {
1378
1617
  });
1379
1618
  child.on("close", (code, signal) => {
1380
1619
  finish(() => {
1381
- const stdout = Buffer.concat(out).toString("utf8");
1382
1620
  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`);
1621
+ const tail = stderr.trim().split("\n").slice(-STDERR_TAIL_LINES).join("\n");
1622
+ debug(`command exited with ${signal ? `signal ${signal}` : `code ${code}`} after ${Date.now() - started}ms; ${outBytes} bytes of stdout`);
1623
+ if (parser) {
1624
+ parser.push(decoder.end());
1625
+ const outcome = parser.finish();
1626
+ if (outcome.unparsedLines) debug(`${outcome.unparsedLines} non-JSON line(s) on stdout were ignored`);
1627
+ if (outcome.isError) {
1628
+ reject(new LlmFailedError(`Claude reported an error: ${outcome.errorMessage ?? "unknown"}`, { hint: tail || void 0 }));
1629
+ return;
1630
+ }
1631
+ if (code !== 0) {
1632
+ reject(new LlmFailedError(`LLM command failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${this.describe()}${tail ? "\n" + tail : ""}`));
1633
+ return;
1634
+ }
1635
+ if (!outcome.result) {
1636
+ reject(new LlmFailedError("Claude produced no result.", { hint: "Run with --debug to see what came back." }));
1637
+ return;
1638
+ }
1639
+ resolve(outcome.result);
1640
+ return;
1641
+ }
1384
1642
  if (code !== 0) {
1385
- const tail = stderr.trim().split("\n").slice(-STDERR_TAIL_LINES).join("\n");
1386
1643
  reject(new LlmFailedError(`LLM command failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${this.describe()}${tail ? "\n" + tail : ""}`));
1387
1644
  return;
1388
1645
  }
1389
- resolve(stdout);
1646
+ resolve(Buffer.concat(out).toString("utf8"));
1390
1647
  });
1391
1648
  });
1392
1649
  if (promptVia === "stdin") {
@@ -1410,21 +1667,30 @@ function createProvider(llm, opts = {}) {
1410
1667
  command: llm.command,
1411
1668
  promptVia: llm.promptVia,
1412
1669
  timeoutMs: llm.timeoutMs,
1670
+ kind: llm.kind,
1671
+ jsonSchema: llm.kind === "claude" && llm.jsonSchema ? TOUR_JSON_SCHEMA : void 0,
1413
1672
  env: llm.env,
1414
1673
  cwd: opts.cwd,
1415
1674
  debug: opts.debug
1416
1675
  });
1417
1676
  }
1418
1677
 
1678
+ // src/core/progress.ts
1679
+ var noProgress = { phase() {
1680
+ }, llm() {
1681
+ } };
1682
+
1419
1683
  // src/core/tour.ts
1420
1684
  async function prepareTour(opts) {
1421
1685
  const debug = opts.debug ?? (() => {
1422
1686
  });
1423
1687
  const warn = opts.warn ?? (() => {
1424
1688
  });
1689
+ const progress = opts.progress ?? noProgress;
1425
1690
  const { config } = opts;
1426
1691
  const llm = resolveLlm(config);
1427
1692
  const codehost = opts.codehost ?? createCodeHost(config, { debug });
1693
+ progress.phase("Working out what to tour");
1428
1694
  const resolved = await resolveRange(opts.range ?? {}, {
1429
1695
  cwd: opts.cwd,
1430
1696
  exclude: config.git.exclude,
@@ -1433,6 +1699,7 @@ async function prepareTour(opts) {
1433
1699
  debug
1434
1700
  });
1435
1701
  debug(`source: ${JSON.stringify(resolved.source)}`);
1702
+ progress.phase("Collecting the diff");
1436
1703
  const diff = await collectDiff(resolved.source, { cwd: opts.cwd, exclude: config.git.exclude, warn });
1437
1704
  if (diff.files.length === 0) {
1438
1705
  throw new NoChangesError("The selected range has no changes (after excludes).", "Check git.exclude in your config, or pass a different range.");
@@ -1440,7 +1707,8 @@ async function prepareTour(opts) {
1440
1707
  const commits = await collectCommits(resolved.source, opts.cwd);
1441
1708
  const context = { source: resolved.source, branch: resolved.branch, diff, commits };
1442
1709
  if (resolved.pullRequest) context.pullRequest = resolved.pullRequest;
1443
- const built = buildPrompt({ ...context, maxBytes: llm.maxPromptBytes });
1710
+ progress.phase("Building the prompt");
1711
+ const built = buildPrompt({ ...context, maxBytes: llm.maxPromptBytes, structured: llm.kind === "claude" && llm.jsonSchema });
1444
1712
  if (built.truncation.truncated.length || built.truncation.omitted.length) {
1445
1713
  warn(`Prompt exceeded ${llm.maxPromptBytes} bytes; truncated ${built.truncation.truncated.length} and omitted ${built.truncation.omitted.length} file diff(s).`);
1446
1714
  }
@@ -1454,11 +1722,13 @@ async function generateTour(opts, prepared) {
1454
1722
  });
1455
1723
  const warn = opts.warn ?? (() => {
1456
1724
  });
1725
+ const progress = opts.progress ?? noProgress;
1457
1726
  const { config } = opts;
1458
1727
  const prep = prepared ?? await prepareTour(opts);
1459
1728
  const useCache = config.cache.enabled && !opts.noCache;
1460
1729
  const cache = useCache ? new TourCache(opts.cacheDir) : null;
1461
1730
  if (cache && !opts.refresh) {
1731
+ progress.phase("Checking the cache");
1462
1732
  const hit = await cache.get(prep.cacheKey);
1463
1733
  if (hit) {
1464
1734
  debug(`cache hit: ${cache.pathFor(prep.cacheKey)}`);
@@ -1468,7 +1738,9 @@ async function generateTour(opts, prepared) {
1468
1738
  }
1469
1739
  const llm = resolveLlm(config);
1470
1740
  const provider = opts.provider ?? createProvider(llm, { cwd: opts.cwd, debug });
1471
- const output = await completeWithRepair(provider, prep.built.prompt, debug);
1741
+ progress.phase(`Asking ${llm.preset ?? llm.command[0]}`);
1742
+ const output = await completeWithRepair(provider, prep.built.prompt, debug, progress);
1743
+ progress.phase("Assembling the tour");
1472
1744
  const tour = materializeTour({
1473
1745
  output,
1474
1746
  diff: prep.context.diff,
@@ -1485,14 +1757,16 @@ async function generateTour(opts, prepared) {
1485
1757
  }
1486
1758
  return { tour, fromCache: false, cacheKey: cache ? prep.cacheKey : null, cachePath, prompt: prep.built.prompt };
1487
1759
  }
1488
- async function completeWithRepair(provider, prompt, debug) {
1489
- let raw = await provider.complete(prompt);
1760
+ async function completeWithRepair(provider, prompt, debug, progress) {
1761
+ const onEvent = (event) => progress.llm(event);
1762
+ let raw = await provider.complete(prompt, { onEvent });
1490
1763
  debug(`raw model output (attempt 1):
1491
1764
  ${raw}`);
1492
1765
  const first = parseOutput(raw);
1493
1766
  if (first.ok) return first.value;
1494
1767
  debug(`attempt 1 unusable: ${first.reason}; retrying with repair prompt`);
1495
- raw = await provider.complete(prompt + REPAIR_SUFFIX(first.reason));
1768
+ progress.phase("Asking again (the first answer was not valid JSON)");
1769
+ raw = await provider.complete(prompt + REPAIR_SUFFIX(first.reason), { onEvent });
1496
1770
  debug(`raw model output (attempt 2):
1497
1771
  ${raw}`);
1498
1772
  const second = parseOutput(raw);
@@ -1513,12 +1787,222 @@ function parseOutput(raw) {
1513
1787
 
1514
1788
  // src/render/cli.ts
1515
1789
  import pc from "picocolors";
1790
+
1791
+ // src/render/highlight.ts
1792
+ import { common, createLowlight } from "lowlight";
1793
+ var lowlight = createLowlight(common);
1794
+ var BY_EXT = {
1795
+ ts: "typescript",
1796
+ mts: "typescript",
1797
+ cts: "typescript",
1798
+ tsx: "typescript",
1799
+ js: "javascript",
1800
+ mjs: "javascript",
1801
+ cjs: "javascript",
1802
+ jsx: "javascript",
1803
+ json: "json",
1804
+ jsonc: "json",
1805
+ json5: "json",
1806
+ py: "python",
1807
+ pyi: "python",
1808
+ rb: "ruby",
1809
+ rake: "ruby",
1810
+ gemspec: "ruby",
1811
+ go: "go",
1812
+ rs: "rust",
1813
+ java: "java",
1814
+ kt: "kotlin",
1815
+ kts: "kotlin",
1816
+ swift: "swift",
1817
+ scala: "scala",
1818
+ c: "c",
1819
+ h: "c",
1820
+ cc: "cpp",
1821
+ cpp: "cpp",
1822
+ cxx: "cpp",
1823
+ hpp: "cpp",
1824
+ hh: "cpp",
1825
+ cs: "csharp",
1826
+ m: "objectivec",
1827
+ mm: "objectivec",
1828
+ php: "php",
1829
+ pl: "perl",
1830
+ pm: "perl",
1831
+ lua: "lua",
1832
+ r: "r",
1833
+ dart: "dart",
1834
+ sh: "bash",
1835
+ bash: "bash",
1836
+ zsh: "bash",
1837
+ fish: "bash",
1838
+ yml: "yaml",
1839
+ yaml: "yaml",
1840
+ toml: "ini",
1841
+ ini: "ini",
1842
+ cfg: "ini",
1843
+ conf: "ini",
1844
+ md: "markdown",
1845
+ markdown: "markdown",
1846
+ html: "xml",
1847
+ htm: "xml",
1848
+ xml: "xml",
1849
+ svg: "xml",
1850
+ vue: "xml",
1851
+ css: "css",
1852
+ scss: "scss",
1853
+ less: "less",
1854
+ sql: "sql",
1855
+ graphql: "graphql",
1856
+ gql: "graphql",
1857
+ diff: "diff",
1858
+ patch: "diff",
1859
+ makefile: "makefile",
1860
+ mk: "makefile",
1861
+ vb: "vbnet",
1862
+ vbnet: "vbnet",
1863
+ wasm: "wasm"
1864
+ };
1865
+ var BY_NAME = {
1866
+ makefile: "makefile",
1867
+ gnumakefile: "makefile",
1868
+ dockerfile: "bash",
1869
+ rakefile: "ruby",
1870
+ gemfile: "ruby",
1871
+ ".bashrc": "bash",
1872
+ ".zshrc": "bash",
1873
+ ".profile": "bash",
1874
+ "cmakelists.txt": "cmake"
1875
+ };
1876
+ function languageForPath(path) {
1877
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
1878
+ const byName = BY_NAME[base];
1879
+ if (byName && lowlight.registered(byName)) return byName;
1880
+ const dot = base.lastIndexOf(".");
1881
+ if (dot === -1) return null;
1882
+ const lang = BY_EXT[base.slice(dot + 1)];
1883
+ return lang && lowlight.registered(lang) ? lang : null;
1884
+ }
1885
+ function stylers(c) {
1886
+ return {
1887
+ keyword: c.magenta,
1888
+ "selector-tag": c.magenta,
1889
+ "template-tag": c.magenta,
1890
+ doctag: c.magenta,
1891
+ string: c.yellow,
1892
+ regexp: c.yellow,
1893
+ "template-variable": c.yellow,
1894
+ subst: (s) => s,
1895
+ comment: c.gray,
1896
+ quote: c.gray,
1897
+ number: c.cyan,
1898
+ literal: c.cyan,
1899
+ symbol: c.cyan,
1900
+ bullet: c.cyan,
1901
+ link: c.cyan,
1902
+ title: c.blue,
1903
+ section: c.blue,
1904
+ "selector-id": c.blue,
1905
+ "selector-class": c.blue,
1906
+ type: c.cyan,
1907
+ built_in: c.cyan,
1908
+ class: c.cyan,
1909
+ attr: c.blue,
1910
+ attribute: c.blue,
1911
+ property: c.blue,
1912
+ variable: (s) => s,
1913
+ params: (s) => s,
1914
+ meta: c.gray,
1915
+ tag: c.blue,
1916
+ name: c.blue,
1917
+ "selector-attr": c.blue,
1918
+ "selector-pseudo": c.blue,
1919
+ addition: (s) => s,
1920
+ deletion: (s) => s,
1921
+ emphasis: c.italic,
1922
+ strong: c.bold
1923
+ };
1924
+ }
1925
+ function highlightCode(code, lang, c) {
1926
+ if (!lowlight.registered(lang)) return null;
1927
+ let tree;
1928
+ try {
1929
+ tree = lowlight.highlight(lang, code);
1930
+ } catch {
1931
+ return null;
1932
+ }
1933
+ const map = stylers(c);
1934
+ const lines = [""];
1935
+ const visit = (node, style) => {
1936
+ if (node.type === "text" && typeof node.value === "string") {
1937
+ const parts = node.value.split("\n");
1938
+ parts.forEach((part, i) => {
1939
+ if (i > 0) lines.push("");
1940
+ if (part) lines[lines.length - 1] += style ? style(part) : part;
1941
+ });
1942
+ return;
1943
+ }
1944
+ let own = style;
1945
+ for (const cls of node.properties?.className ?? []) {
1946
+ const scope = cls.startsWith("hljs-") ? cls.slice(5) : cls;
1947
+ const s = map[scope];
1948
+ if (s) {
1949
+ own = s;
1950
+ break;
1951
+ }
1952
+ }
1953
+ for (const child of node.children ?? []) visit(child, own);
1954
+ };
1955
+ visit(tree, null);
1956
+ const expected = code.split("\n").length;
1957
+ if (lines.length !== expected) return null;
1958
+ return lines;
1959
+ }
1960
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
1961
+ function stripAnsi(s) {
1962
+ return s.replace(ANSI_RE, "");
1963
+ }
1964
+ function visibleLength(s) {
1965
+ return stripAnsi(s).length;
1966
+ }
1967
+ var SOFT_RESET = "\x1B[39m\x1B[22m\x1B[23m\x1B[24m";
1968
+ function truncateAnsi(s, max) {
1969
+ if (visibleLength(s) <= max) return s;
1970
+ let out = "";
1971
+ let seen = 0;
1972
+ const limit = Math.max(0, max - 1);
1973
+ for (let i = 0; i < s.length; ) {
1974
+ if (s[i] === "\x1B") {
1975
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
1976
+ if (m) {
1977
+ out += m[0];
1978
+ i += m[0].length;
1979
+ continue;
1980
+ }
1981
+ }
1982
+ if (seen >= limit) break;
1983
+ out += s[i];
1984
+ seen++;
1985
+ i++;
1986
+ }
1987
+ return out + SOFT_RESET + "\u2026";
1988
+ }
1989
+ function lineTint(type, theme) {
1990
+ const code = theme === "dark" ? type === "add" ? 22 : 52 : type === "add" ? 194 : 224;
1991
+ return { open: `\x1B[48;5;${code}m`, close: "\x1B[49m" };
1992
+ }
1993
+ function supports256Colors(env = process.env) {
1994
+ if (env.TERM === "dumb") return false;
1995
+ return Boolean(env.COLORTERM) || /256color|truecolor|direct/i.test(env.TERM ?? "") || Boolean(env.TERM_PROGRAM) || Boolean(env.WT_SESSION);
1996
+ }
1997
+
1998
+ // src/render/cli.ts
1516
1999
  var MIN_WIDTH = 40;
1517
2000
  var MAX_WIDTH = 120;
1518
2001
  var CliRenderer = class {
1519
2002
  render(tour, opts) {
1520
2003
  const c = pc.createColors(opts.color);
1521
2004
  const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, opts.width));
2005
+ const style = { highlight: Boolean(opts.color && opts.highlight), theme: opts.theme ?? "dark" };
1522
2006
  const out = [];
1523
2007
  out.push(...renderHeader(tour, c, width, opts.fromCache ?? false));
1524
2008
  out.push("");
@@ -1527,12 +2011,12 @@ var CliRenderer = class {
1527
2011
  if (!section) {
1528
2012
  throw new UsageError(`No section ${opts.section}; this tour has ${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}.`);
1529
2013
  }
1530
- out.push(...renderSection(section, opts.section, tour.sections.length, c, width));
2014
+ out.push(...renderSection(section, opts.section, tour.sections.length, c, width, style));
1531
2015
  } else {
1532
2016
  out.push(...renderToc(tour, c, width));
1533
2017
  out.push("");
1534
2018
  tour.sections.forEach((s, i) => {
1535
- out.push(...renderSection(s, i + 1, tour.sections.length, c, width));
2019
+ out.push(...renderSection(s, i + 1, tour.sections.length, c, width, style));
1536
2020
  out.push("");
1537
2021
  });
1538
2022
  }
@@ -1566,12 +2050,12 @@ function renderToc(tour, c, width) {
1566
2050
  const label = ` ${n}. ${s.title}`;
1567
2051
  const right = `${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 ${statsLine(s.stats, c, true)}`;
1568
2052
  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);
2053
+ const gap = Math.max(2, width - visibleLength2(label) - rightPlain.length);
1570
2054
  lines.push(label + " ".repeat(gap) + c.dim(right));
1571
2055
  }
1572
2056
  return lines;
1573
2057
  }
1574
- function renderSection(s, index, count, c, width) {
2058
+ function renderSection(s, index, count, c, width, style) {
1575
2059
  const lines = [];
1576
2060
  lines.push(c.dim("\u2500".repeat(width)));
1577
2061
  lines.push(c.bold(`${index}. ${s.title}`) + c.dim(` (${index}/${count})`));
@@ -1586,26 +2070,41 @@ function renderSection(s, index, count, c, width) {
1586
2070
  }
1587
2071
  for (const e of s.excerpts) {
1588
2072
  lines.push("");
1589
- lines.push(...renderExcerpt(e, c, width));
2073
+ lines.push(...renderExcerpt(e, c, width, style));
1590
2074
  }
1591
2075
  return lines;
1592
2076
  }
1593
- function renderExcerpt(e, c, width) {
2077
+ function renderExcerpt(e, c, width, style) {
1594
2078
  const lines = [];
1595
2079
  const title = ` ${c.cyan(e.file)}${c.dim(":" + e.newStart)}`;
1596
2080
  lines.push(e.note ? `${title} ${c.italic(c.dim(e.note))}` : title);
1597
2081
  const maxNo = Math.max(...e.lines.map((l) => Math.max(l.oldNo ?? 0, l.newNo ?? 0)), 1);
1598
2082
  const w = String(maxNo).length;
1599
- const budget = Math.max(20, width - (3 + w * 2 + 5));
1600
- for (const l of e.lines) {
2083
+ const budget = Math.max(20, width - (3 + w * 2 + 3) - 1);
2084
+ const texts = e.lines.map((l) => expandTabs(l.text));
2085
+ const lang = style.highlight ? languageForPath(e.file) : null;
2086
+ const highlighted = lang ? highlightCode(texts.join("\n"), lang, c) : null;
2087
+ e.lines.forEach((l, i) => {
1601
2088
  const oldNo = l.oldNo === void 0 ? " ".repeat(w) : String(l.oldNo).padStart(w);
1602
2089
  const newNo = l.newNo === void 0 ? " ".repeat(w) : String(l.newNo).padStart(w);
1603
2090
  const sign = l.type === "add" ? "+" : l.type === "del" ? "-" : " ";
1604
- const text = truncate(expandTabs(l.text), budget);
1605
2091
  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
- }
2092
+ const plain = texts[i];
2093
+ if (!highlighted) {
2094
+ const body = `${sign}${truncate(plain, budget)}`;
2095
+ lines.push(`${gutter}${l.type === "add" ? c.green(body) : l.type === "del" ? c.red(body) : body}`);
2096
+ return;
2097
+ }
2098
+ const text = truncateAnsi(highlighted[i], budget);
2099
+ const pad = " ".repeat(Math.max(0, budget - visibleLength(text)));
2100
+ if (l.type === "ctx") {
2101
+ lines.push(`${gutter} ${text}`);
2102
+ return;
2103
+ }
2104
+ const tint = lineTint(l.type, style.theme);
2105
+ const signStyled = l.type === "add" ? c.green(c.bold("+")) : c.red(c.bold("-"));
2106
+ lines.push(`${gutter}${tint.open}${signStyled}${text}${pad}${tint.close}`);
2107
+ });
1609
2108
  return lines;
1610
2109
  }
1611
2110
  function statsLine(stats, c, omitFiles = false) {
@@ -1645,7 +2144,7 @@ function truncate(s, max) {
1645
2144
  function expandTabs(s) {
1646
2145
  return s.replaceAll(" ", " ");
1647
2146
  }
1648
- function visibleLength(s) {
2147
+ function visibleLength2(s) {
1649
2148
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1650
2149
  }
1651
2150
  function relativeTime(iso, now = /* @__PURE__ */ new Date()) {
@@ -1727,6 +2226,7 @@ export {
1727
2226
  materializeTour,
1728
2227
  sliceHunk,
1729
2228
  PROMPT_VERSION,
2229
+ promptHeader,
1730
2230
  buildPrompt,
1731
2231
  describeSource,
1732
2232
  GhCodeHost,
@@ -1739,10 +2239,14 @@ export {
1739
2239
  collectCommits,
1740
2240
  resolveRange,
1741
2241
  detectDefaultBranch,
2242
+ TOUR_JSON_SCHEMA,
2243
+ ClaudeStreamParser,
1742
2244
  CommandProvider,
1743
2245
  createProvider,
2246
+ noProgress,
1744
2247
  prepareTour,
1745
2248
  generateTour,
2249
+ supports256Colors,
1746
2250
  CliRenderer,
1747
2251
  describeSource2,
1748
2252
  wrap,
@@ -1750,4 +2254,4 @@ export {
1750
2254
  writeMaybePaged,
1751
2255
  writeStdout
1752
2256
  };
1753
- //# sourceMappingURL=chunk-RWSCST2I.js.map
2257
+ //# sourceMappingURL=chunk-7ALXKEST.js.map