@cam5/baby-bird 0.1.1 → 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.
package/README.md CHANGED
@@ -61,6 +61,8 @@ bb --dump-prompt print the prompt instead of calling the model
61
61
  bb --refresh regenerate even if a cached tour exists
62
62
  bb --preset claude-sonnet
63
63
  bb --no-progress no live status block
64
+ bb ask "why is the cache keyed by command?" chat about the current branch's tour
65
+ bb ask HEAD~1 "what is going on in progress.ts?" chat about a specific range
64
66
  ```
65
67
 
66
68
  ### What does bare `bb` tour?
@@ -72,6 +74,25 @@ bb --no-progress no live status block
72
74
 
73
75
  The header of every tour says which one was used.
74
76
 
77
+ ### Asking questions about a tour
78
+
79
+ `bb ask` opens an interactive chat with the same LLM CLI, primed with the tour:
80
+
81
+ ```
82
+ bb ask the tour bare `bb` would show
83
+ bb ask "where is the retry handled?" with an opening question
84
+ bb ask HEAD~1 "what is going on in progress.ts?"
85
+ bb ask main..feature --preset claude-opus
86
+ bb ask --staged "is this safe to commit?"
87
+ bb ask --dump-context print what the chat would be given, and exit
88
+ ```
89
+
90
+ It takes the same range arguments and flags as `bb`, then an opening message. The first word is treated as a range only when git can resolve it (`HEAD~3`, `main..feature`); anything else is part of the message, so quoting is optional for plain questions.
91
+
92
+ The tour is generated first, or taken from the cache: run `bb HEAD~1` in one window, and `bb ask HEAD~1 ...` in another reuses that tour and opens the chat straight away. The chat gets, as a system prompt: a short framing, the whole tour (sections, descriptions, excerpts with line numbers, the base and head SHAs), and then the same labeled diff the tour was built from, shortened to `llm.maxPromptBytes` like the prompt. With the Claude presets the chat is a normal interactive Claude Code session in the repository, so it can also open files and run git. `bb ask` exits with the chat's exit code.
93
+
94
+ The command that opens the chat is the preset's `chatCommand` (see [LLM presets](#llm-presets)); presets without one cannot be asked.
95
+
75
96
  ## Configuration
76
97
 
77
98
  Config is JSON, merged in this order (later wins; objects merge, arrays replace):
@@ -80,7 +101,7 @@ Config is JSON, merged in this order (later wins; objects merge, arrays replace)
80
101
  2. `$XDG_CONFIG_HOME/baby-bird/config.json` (default `~/.config/baby-bird/config.json`)
81
102
  3. `<repo>/.baby-bird/config.json` (create one with `bb init`)
82
103
  4. environment: `BB_PRESET`, `BB_LLM_COMMAND`, `BB_CODEHOST`, `BB_CACHE_DIR`, `BB_NO_CACHE`, `NO_COLOR`
83
- 5. flags: `--preset`, `--color/--no-color`, `--no-cache`, `--no-pager`
104
+ 5. flags: `--preset`, `--color/--no-color`, `--no-cache`, `--no-pager` (before or after a subcommand name)
84
105
 
85
106
  `bb config` shows every layer, what it contributed, the resolved LLM command, and all presets.
86
107
 
@@ -93,6 +114,7 @@ All keys, with defaults:
93
114
  "presets": {}, // your own presets; same names shadow built-ins
94
115
  "args": [], // extra argv appended to the preset's command
95
116
  "command": null, // a whole custom invocation; when set, "preset" is ignored
117
+ "chatCommand": null, // what `bb ask` runs; replaces the preset's (see below)
96
118
  "promptVia": null, // "stdin" (default) or "arg" (replace {prompt} in argv)
97
119
  "kind": null, // "claude" (streams progress from the Claude Code CLI) or "plain"; default from the preset
98
120
  "jsonSchema": false, // Claude kind: also pass --json-schema so the CLI validates the answer (see below)
@@ -108,8 +130,7 @@ All keys, with defaults:
108
130
  },
109
131
  "render": {
110
132
  "color": "auto", "pager": "auto", "maxExcerptLines": 60, "width": null,
111
- "highlight": "auto", // syntax colors in excerpts on 256-color terminals; "always" | "never"
112
- "theme": "dark" // background tint palette for added/removed lines; "light" for light terminals
133
+ "highlight": "auto" // syntax colors in excerpts; "always" | "never"
113
134
  },
114
135
  "cache": { "enabled": true, "dir": null } // default $XDG_CACHE_HOME/baby-bird
115
136
  }
@@ -134,6 +155,8 @@ The model is just a command: the prompt goes in on stdin, the answer comes out o
134
155
  | `claude-haiku` | the above plus `--model haiku` |
135
156
  | `llm` | `llm` ([Simon Willison's CLI](https://llm.datasette.io/)) |
136
157
 
158
+ Each preset also has a `chatCommand`, which is what `bb ask` runs. For the Claude presets it is `claude [model and effort flags] --append-system-prompt-file {context-file} -- {message}`: a regular interactive session (your settings and CLAUDE.md apply) with the tour appended to the system prompt and the question as the first message. The `llm` preset uses `llm chat -s {context}`; `llm chat` cannot take an opening message, so `bb` says so and you type the question into the chat. In a chat command, `{context-file}` is replaced by the path of a temporary file holding the context, `{context}` by the text itself (mind your OS's argument size limits with big diffs), and `{message}` by the opening message; an argv entry that is only `{message}` is dropped when there is none.
159
+
137
160
  Add flags to a preset, define your own, or replace the command entirely:
138
161
 
139
162
  ```jsonc
@@ -143,12 +166,18 @@ Add flags to a preset, define your own, or replace the command entirely:
143
166
  "args": ["--fallback-model", "haiku"],
144
167
  "presets": {
145
168
  "local": { "command": ["ollama", "run", "qwen2.5-coder:14b"] },
146
- "gemini": { "command": ["gemini", "-p", "{prompt}"], "promptVia": "arg" }
169
+ "gemini": {
170
+ "command": ["gemini", "-p", "{prompt}"],
171
+ "promptVia": "arg",
172
+ "chatCommand": ["gemini", "-i", "{message}"]
173
+ }
147
174
  }
148
175
  }
149
176
  }
150
177
  ```
151
178
 
179
+ `llm.args` only applies to the tour command. A custom `llm.command` has no chat command of its own; set `llm.chatCommand` next to it if you want `bb ask` to work.
180
+
152
181
  ```sh
153
182
  bb --preset local
154
183
  BB_LLM_COMMAND='llm -m gpt-4.1' bb
@@ -162,7 +191,7 @@ The effective command is part of the cache key, so switching presets regenerates
162
191
 
163
192
  ### Excerpt highlighting
164
193
 
165
- On a 256-color terminal, excerpts get language-aware token colors (keywords, strings, comments) chosen by file extension, and added/removed lines are marked by a bold `+`/`-` sign plus a subtle green/red row tint underneath, so both signals read at once. The token palette never uses green or red. Turn it off with `--no-highlight` or `render.highlight: "never"`, and pick `--theme light` (or `render.theme`) on a light terminal background. Files with no known language keep the plain green/red rendering.
194
+ Excerpts get language-aware token colors (keywords, strings, comments) chosen by file extension, and added/removed lines are always marked by a green/red `+`/`-` sign no row background and no bold (some terminal themes render bold color via a washed-out "bright" palette slot), so it reads well on any terminal theme. The token palette never uses green or red, so the sign stays unambiguous. Turn syntax coloring off with `--no-highlight` or `render.highlight: "never"`; files with no known language keep the same green/red sign with plain text.
166
195
 
167
196
  ### Code host
168
197
 
@@ -190,7 +219,7 @@ bb cache path
190
219
 
191
220
  ## Using it as a library
192
221
 
193
- The CLI is one consumer of a small core. `import { generateTour, CliRenderer, type Tour } from '@cam5/baby-bird'` gives you the same pipeline for a TUI, a web view, or a bot; a `Tour` is plain JSON (sections, stats, excerpts) with no git or LLM dependencies.
222
+ The CLI is one consumer of a small core. `import { generateTour, CliRenderer, type Tour } from '@cam5/baby-bird'` gives you the same pipeline for a TUI, a web view, or a bot; a `Tour` is plain JSON (sections, stats, excerpts) with no git or LLM dependencies. `buildChatContext` produces the text `bb ask` hands to a chat, and `launchChat` runs a chat command with it.
194
223
 
195
224
  ## Development
196
225
 
@@ -221,6 +250,8 @@ git push --follow-tags
221
250
  | 4 | the LLM command failed to run |
222
251
  | 5 | the model's output was unusable even after a repair attempt |
223
252
 
253
+ `bb ask` exits with the chat command's own exit code once the chat ends.
254
+
224
255
  ## License
225
256
 
226
257
  MIT
@@ -136,34 +136,43 @@ 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(),
142
145
  kind: "claude",
143
146
  description: "Claude Code CLI with its default model"
144
147
  },
145
148
  "claude-sonnet": {
146
149
  command: [...CLAUDE_BASE, "--model", "sonnet", "--effort", "high"],
150
+ chatCommand: claudeChat("--model", "sonnet", "--effort", "high"),
147
151
  kind: "claude",
148
152
  description: "Claude Code CLI, Sonnet at high effort"
149
153
  },
150
154
  "claude-opus": {
151
155
  command: [...CLAUDE_BASE, "--model", "opus", "--effort", "high"],
156
+ chatCommand: claudeChat("--model", "opus", "--effort", "high"),
152
157
  kind: "claude",
153
158
  description: "Claude Code CLI, Opus at high effort"
154
159
  },
155
160
  "claude-fable": {
156
161
  command: [...CLAUDE_BASE, "--model", "fable", "--effort", "high"],
162
+ chatCommand: claudeChat("--model", "fable", "--effort", "high"),
157
163
  kind: "claude",
158
164
  description: "Claude Code CLI, Fable at high effort"
159
165
  },
160
166
  "claude-haiku": {
161
167
  command: [...CLAUDE_BASE, "--model", "haiku"],
168
+ chatCommand: claudeChat("--model", "haiku"),
162
169
  kind: "claude",
163
170
  description: "Claude Code CLI, Haiku (fast and cheap)"
164
171
  },
165
172
  llm: {
166
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}"],
167
176
  description: "Simon Willison's llm CLI with its default model"
168
177
  }
169
178
  });
@@ -171,6 +180,7 @@ var PromptViaSchema = z2.enum(["stdin", "arg"]);
171
180
  var LlmKindSchema = z2.enum(["plain", "claude"]);
172
181
  var LlmPresetSchema = z2.object({
173
182
  command: z2.array(z2.string()).min(1),
183
+ chatCommand: z2.array(z2.string()).min(1).optional(),
174
184
  promptVia: PromptViaSchema.optional(),
175
185
  kind: LlmKindSchema.optional(),
176
186
  description: z2.string().optional()
@@ -181,6 +191,8 @@ var ConfigSchema = z2.object({
181
191
  presets: z2.record(z2.string(), LlmPresetSchema),
182
192
  args: z2.array(z2.string()),
183
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(),
184
196
  promptVia: PromptViaSchema.nullable(),
185
197
  kind: LlmKindSchema.nullable(),
186
198
  /** Claude kind only: pass --json-schema so the CLI validates the answer itself. Off by default (see README). */
@@ -201,10 +213,8 @@ var ConfigSchema = z2.object({
201
213
  pager: z2.enum(["auto", "always", "never"]),
202
214
  maxExcerptLines: z2.number().int().positive(),
203
215
  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"])
216
+ /** Language-aware token colors in excerpts. */
217
+ highlight: z2.enum(["auto", "always", "never"])
208
218
  }),
209
219
  cache: z2.object({
210
220
  enabled: z2.boolean(),
@@ -224,6 +234,7 @@ var DEFAULT_CONFIG = {
224
234
  presets: {},
225
235
  args: [],
226
236
  command: null,
237
+ chatCommand: null,
227
238
  promptVia: null,
228
239
  kind: null,
229
240
  jsonSchema: false,
@@ -245,7 +256,7 @@ var DEFAULT_CONFIG = {
245
256
  "**/*.map"
246
257
  ]
247
258
  },
248
- render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null, highlight: "auto", theme: "dark" },
259
+ render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null, highlight: "auto" },
249
260
  cache: { enabled: true, dir: null }
250
261
  };
251
262
  function userConfigPath(env = process.env) {
@@ -377,26 +388,44 @@ function resolveLlm(config) {
377
388
  ...common2
378
389
  };
379
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
+ }
380
409
  function shellSplit(input) {
381
410
  const out = [];
382
411
  let cur = "";
383
412
  let inToken = false;
384
- let quote = null;
413
+ let quote2 = null;
385
414
  for (let i = 0; i < input.length; i++) {
386
415
  const ch = input[i];
387
- if (quote === "'") {
388
- if (ch === "'") quote = null;
416
+ if (quote2 === "'") {
417
+ if (ch === "'") quote2 = null;
389
418
  else cur += ch;
390
419
  continue;
391
420
  }
392
- if (quote === '"') {
393
- if (ch === '"') quote = null;
421
+ if (quote2 === '"') {
422
+ if (ch === '"') quote2 = null;
394
423
  else if (ch === "\\" && i + 1 < input.length && '"\\$`'.includes(input[i + 1])) cur += input[++i];
395
424
  else cur += ch;
396
425
  continue;
397
426
  }
398
427
  if (ch === "'" || ch === '"') {
399
- quote = ch;
428
+ quote2 = ch;
400
429
  inToken = true;
401
430
  } else if (ch === "\\" && i + 1 < input.length) {
402
431
  cur += input[++i];
@@ -412,7 +441,7 @@ function shellSplit(input) {
412
441
  inToken = true;
413
442
  }
414
443
  }
415
- if (quote) throw new ConfigError(`Unterminated quote in command: ${input}`);
444
+ if (quote2) throw new ConfigError(`Unterminated quote in command: ${input}`);
416
445
  if (inToken) out.push(cur);
417
446
  return out;
418
447
  }
@@ -926,6 +955,10 @@ var KEEP_LINES = 40;
926
955
  var MAX_PR_BODY_BYTES = 12e3;
927
956
  function buildPrompt(input) {
928
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) {
929
962
  const context = renderContext(input);
930
963
  const truncation = { truncated: [], omitted: [] };
931
964
  const blocks = input.diff.files.map((file) => {
@@ -942,7 +975,7 @@ function buildPrompt(input) {
942
975
  });
943
976
  const text = (b) => b.mode === "full" ? b.full : b.mode === "truncated" ? b.truncated : b.omitted;
944
977
  const total = () => bytes(diffPreamble(truncation)) + blocks.reduce((n, b) => n + bytes(text(b)), 0);
945
- const budget = input.maxBytes - bytes(header) - bytes(context) - 8;
978
+ const budget = maxBytes - bytes(context);
946
979
  if (total() > budget) {
947
980
  const bySize = [...blocks].sort((a, b) => bytes(b.full) - bytes(a.full));
948
981
  for (const block of bySize) {
@@ -959,8 +992,7 @@ function buildPrompt(input) {
959
992
  truncation.omitted.push(block.file.path);
960
993
  }
961
994
  }
962
- const prompt = [header, context, diffPreamble(truncation), ...blocks.map(text)].join("\n");
963
- return { prompt, truncation };
995
+ return { text: [context, diffPreamble(truncation), ...blocks.map(text)].join("\n"), truncation };
964
996
  }
965
997
  function bytes(s) {
966
998
  return Buffer.byteLength(s, "utf8");
@@ -1785,6 +1817,128 @@ function parseOutput(raw) {
1785
1817
  return { ok: true, value: parsed.data };
1786
1818
  }
1787
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
+
1788
1942
  // src/render/cli.ts
1789
1943
  import pc from "picocolors";
1790
1944
 
@@ -1986,14 +2140,6 @@ function truncateAnsi(s, max) {
1986
2140
  }
1987
2141
  return out + SOFT_RESET + "\u2026";
1988
2142
  }
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
2143
 
1998
2144
  // src/render/cli.ts
1999
2145
  var MIN_WIDTH = 40;
@@ -2002,7 +2148,7 @@ var CliRenderer = class {
2002
2148
  render(tour, opts) {
2003
2149
  const c = pc.createColors(opts.color);
2004
2150
  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" };
2151
+ const style = { highlight: Boolean(opts.color && opts.highlight) };
2006
2152
  const out = [];
2007
2153
  out.push(...renderHeader(tour, c, width, opts.fromCache ?? false));
2008
2154
  out.push("");
@@ -2011,12 +2157,12 @@ var CliRenderer = class {
2011
2157
  if (!section) {
2012
2158
  throw new UsageError(`No section ${opts.section}; this tour has ${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}.`);
2013
2159
  }
2014
- out.push(...renderSection(section, opts.section, tour.sections.length, c, width, style));
2160
+ out.push(...renderSection2(section, opts.section, tour.sections.length, c, width, style));
2015
2161
  } else {
2016
2162
  out.push(...renderToc(tour, c, width));
2017
2163
  out.push("");
2018
2164
  tour.sections.forEach((s, i) => {
2019
- out.push(...renderSection(s, i + 1, tour.sections.length, c, width, style));
2165
+ out.push(...renderSection2(s, i + 1, tour.sections.length, c, width, style));
2020
2166
  out.push("");
2021
2167
  });
2022
2168
  }
@@ -2055,7 +2201,7 @@ function renderToc(tour, c, width) {
2055
2201
  }
2056
2202
  return lines;
2057
2203
  }
2058
- function renderSection(s, index, count, c, width, style) {
2204
+ function renderSection2(s, index, count, c, width, style) {
2059
2205
  const lines = [];
2060
2206
  lines.push(c.dim("\u2500".repeat(width)));
2061
2207
  lines.push(c.bold(`${index}. ${s.title}`) + c.dim(` (${index}/${count})`));
@@ -2070,11 +2216,11 @@ function renderSection(s, index, count, c, width, style) {
2070
2216
  }
2071
2217
  for (const e of s.excerpts) {
2072
2218
  lines.push("");
2073
- lines.push(...renderExcerpt(e, c, width, style));
2219
+ lines.push(...renderExcerpt2(e, c, width, style));
2074
2220
  }
2075
2221
  return lines;
2076
2222
  }
2077
- function renderExcerpt(e, c, width, style) {
2223
+ function renderExcerpt2(e, c, width, style) {
2078
2224
  const lines = [];
2079
2225
  const title = ` ${c.cyan(e.file)}${c.dim(":" + e.newStart)}`;
2080
2226
  lines.push(e.note ? `${title} ${c.italic(c.dim(e.note))}` : title);
@@ -2091,19 +2237,17 @@ function renderExcerpt(e, c, width, style) {
2091
2237
  const gutter = c.dim(` ${oldNo} ${newNo} \u2502`);
2092
2238
  const plain = texts[i];
2093
2239
  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}`);
2240
+ const signStyled2 = l.type === "add" ? c.green(sign) : l.type === "del" ? c.red(sign) : sign;
2241
+ lines.push(`${gutter}${signStyled2}${truncate(plain, budget)}`);
2096
2242
  return;
2097
2243
  }
2098
2244
  const text = truncateAnsi(highlighted[i], budget);
2099
- const pad = " ".repeat(Math.max(0, budget - visibleLength(text)));
2100
2245
  if (l.type === "ctx") {
2101
2246
  lines.push(`${gutter} ${text}`);
2102
2247
  return;
2103
2248
  }
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}`);
2249
+ const signStyled = l.type === "add" ? c.green("+") : c.red("-");
2250
+ lines.push(`${gutter}${signStyled}${text}`);
2107
2251
  });
2108
2252
  return lines;
2109
2253
  }
@@ -2161,7 +2305,7 @@ function relativeTime(iso, now = /* @__PURE__ */ new Date()) {
2161
2305
  }
2162
2306
 
2163
2307
  // src/render/pager.ts
2164
- import { spawn as spawn2 } from "child_process";
2308
+ import { spawn as spawn3 } from "child_process";
2165
2309
  async function writeMaybePaged(output, opts) {
2166
2310
  const env = opts.env ?? process.env;
2167
2311
  const lineCount = output.split("\n").length;
@@ -2173,7 +2317,7 @@ async function writeMaybePaged(output, opts) {
2173
2317
  const pagerCmd = env.PAGER && env.PAGER.trim() || "less";
2174
2318
  const cmd = pagerCmd === "less" && !env.LESS ? "less -RFX" : pagerCmd;
2175
2319
  await new Promise((resolve) => {
2176
- const child = spawn2(cmd, { shell: true, stdio: ["pipe", "inherit", "inherit"], env });
2320
+ const child = spawn3(cmd, { shell: true, stdio: ["pipe", "inherit", "inherit"], env });
2177
2321
  let fellBack = false;
2178
2322
  child.on("error", () => {
2179
2323
  fellBack = true;
@@ -2217,6 +2361,7 @@ export {
2217
2361
  loadConfig,
2218
2362
  allPresets,
2219
2363
  resolveLlm,
2364
+ resolveChat,
2220
2365
  shellSplit,
2221
2366
  TourCache,
2222
2367
  extractJson,
@@ -2228,10 +2373,12 @@ export {
2228
2373
  PROMPT_VERSION,
2229
2374
  promptHeader,
2230
2375
  buildPrompt,
2376
+ renderChange,
2231
2377
  describeSource,
2232
2378
  GhCodeHost,
2233
2379
  NoCodeHost,
2234
2380
  createCodeHost,
2381
+ git,
2235
2382
  gitRoot,
2236
2383
  revParse,
2237
2384
  currentBranch,
@@ -2246,7 +2393,10 @@ export {
2246
2393
  noProgress,
2247
2394
  prepareTour,
2248
2395
  generateTour,
2249
- supports256Colors,
2396
+ buildChatContext,
2397
+ chatArgv,
2398
+ chatSlots,
2399
+ launchChat,
2250
2400
  CliRenderer,
2251
2401
  describeSource2,
2252
2402
  wrap,
@@ -2254,4 +2404,4 @@ export {
2254
2404
  writeMaybePaged,
2255
2405
  writeStdout
2256
2406
  };
2257
- //# sourceMappingURL=chunk-7ALXKEST.js.map
2407
+ //# sourceMappingURL=chunk-YI2UL4IC.js.map