@ahmedalbarghouti/farq 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +51 -2
  2. package/dist/concurrency.d.ts +2 -0
  3. package/dist/concurrency.js +16 -0
  4. package/dist/config.d.ts +16 -0
  5. package/dist/config.js +25 -0
  6. package/dist/git.d.ts +7 -3
  7. package/dist/git.js +36 -27
  8. package/dist/index.js +109 -30
  9. package/dist/providers/claude.d.ts +1 -0
  10. package/dist/providers/claude.js +49 -32
  11. package/dist/providers/fake.d.ts +5 -2
  12. package/dist/providers/fake.js +26 -15
  13. package/dist/providers/index.d.ts +1 -1
  14. package/dist/providers/index.js +1 -1
  15. package/dist/schema.d.ts +40 -7
  16. package/dist/schema.js +14 -1
  17. package/dist/summarize.js +7 -0
  18. package/dist/ui/index.d.ts +1 -0
  19. package/dist/ui/index.js +1 -0
  20. package/dist/ui/lines.d.ts +19 -1
  21. package/dist/ui/lines.js +81 -39
  22. package/dist/ui/logger.d.ts +9 -2
  23. package/dist/ui/logger.js +15 -4
  24. package/dist/ui/spinner.d.ts +15 -2
  25. package/dist/ui/spinner.js +105 -22
  26. package/dist/ui/theme.d.ts +1 -0
  27. package/dist/ui/theme.js +3 -0
  28. package/dist/visual/chrome.js +16 -0
  29. package/dist/visual/cluster.d.ts +8 -2
  30. package/dist/visual/cluster.js +36 -12
  31. package/dist/visual/compose.d.ts +6 -1
  32. package/dist/visual/compose.js +11 -28
  33. package/dist/visual/css-scope.d.ts +6 -0
  34. package/dist/visual/css-scope.js +162 -0
  35. package/dist/visual/design.d.ts +103 -0
  36. package/dist/visual/design.js +327 -0
  37. package/dist/visual/diagram.d.ts +7 -0
  38. package/dist/visual/diagram.js +36 -18
  39. package/dist/visual/mockup.d.ts +15 -12
  40. package/dist/visual/mockup.js +45 -26
  41. package/dist/visual/pipeline.d.ts +30 -1
  42. package/dist/visual/pipeline.js +71 -35
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -91,12 +91,25 @@ Validated change summary plus an `images` array of produced file paths.
91
91
 
92
92
  ## Images (honesty policy)
93
93
 
94
- - Visuals come from the diff only. A cheap model groups summary items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate). File-overlap is the fallback if that grouping fails. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. Screenshots are capped at **1280×720** so content must fit the frame (no tall cut-off pages). If a topic is infeasible, it is skipped (still exit 0).
95
- - Generated compositions include a small **generated preview** badge.
94
+ - Visuals come from the diff only. The summarize call also returns the grouping of items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate), so no extra model round-trip is spent on it. A dedicated grouping call, then file-overlap, are the fallbacks. UI markup gets a mockup attempt; everything else gets a small concept flowchart. If a topic is infeasible, it is skipped (still exit 0).
95
+ - Every visual is **1280×720** and carries a small **generated preview** badge.
96
96
  - Diagrams stay conceptual — no code dumps.
97
97
  - Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
98
98
  - A local image path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload (and rewrites stdout to the hosted URL).
99
99
 
100
+ ### Consistent visual style
101
+
102
+ farq owns the page frame — background, header, Before/After labels, badge, typography and the colour palette. The model only fills in the two panel interiors, and it must draw every colour, font and radius from a fixed set of CSS custom properties (`--fq-accent`, `--fq-text-muted`, `--fq-surface`, …). The only literals it may use are colours the diff states outright. That is what keeps two runs on two branches looking like the same product.
103
+
104
+ Two palettes ship today, `midnight` (default) and `daylight`:
105
+
106
+ ```bash
107
+ farq pr --theme daylight
108
+ farq pr --accent "#ff5722"
109
+ ```
110
+
111
+ Content is measured and scaled to fit the frame after rendering, so a tall mockup shrinks instead of getting clipped, and the model is not asked to guess at pixel budgets.
112
+
100
113
  ## Config
101
114
 
102
115
  Precedence: **flags → project → global**.
@@ -111,10 +124,18 @@ Precedence: **flags → project → global**.
111
124
  "models": {
112
125
  "claudeCheap": "haiku",
113
126
  "opencodeCheap": "provider/model"
127
+ },
128
+ "visual": {
129
+ "theme": "midnight",
130
+ "accent": "#4dd0e1",
131
+ "maxTopics": 3,
132
+ "concurrency": 3
114
133
  }
115
134
  }
116
135
  ```
117
136
 
137
+ `visual.maxTopics` caps how many images a run may generate (each costs one model call), and `visual.concurrency` is how many are generated at once. `visual.fontImport` can point at a webfont stylesheet; leaving it unset keeps rendering offline and fast.
138
+
118
139
  If both `claude` and `opencode` are installed and nothing is configured, farq uses **claude** and prints how to override (no interactive prompt).
119
140
 
120
141
  ## Flags
@@ -128,6 +149,9 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
128
149
  | `--no-images` | skip visuals |
129
150
  | `-o, --out` | image output dir (default: OS user cache, outside the repo) |
130
151
  | `--model-cheap` | cheap model id for visuals |
152
+ | `--theme` | visual palette: `midnight` \| `daylight` |
153
+ | `--accent` | override the theme accent colour |
154
+ | `--max-visuals` | cap generated visuals (1–5) |
131
155
  | `-v, --verbose` | verbose logs (incl. `feasible: false` reasons) |
132
156
  | `--open` | (`pr` only) create PR with `gh` |
133
157
 
@@ -141,6 +165,31 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
141
165
 
142
166
  Progress → **stderr**. Artifact → **stdout**.
143
167
 
168
+ ## Progress output
169
+
170
+ Each stage is numbered, names what it is doing, and reports how long it took:
171
+
172
+ ```
173
+ farq ⠹ [2/3] summary · claude is naming things (hard part)… · 11s
174
+ farq ✓ summarized with claude 12.4s
175
+ farq ⠸ [3/3] visuals · claude is sketching the after… · 1/3 · Refund status badge · 8s
176
+ farq ✓ 3 visuals ready 41.2s
177
+ ```
178
+
179
+ Non-interactive terminals (CI, pipes) get one plain line per stage transition instead of a spinner.
180
+
181
+ ## Speed
182
+
183
+ A `farq pr` run makes as few model calls as it can, and overlaps everything that does not depend on the model:
184
+
185
+ - Provider detection, the diff and the `gh` title lookup all start at once.
186
+ - Topic grouping rides along on the summarize response instead of being its own request.
187
+ - File contents for the visuals are read while the summary is still being written.
188
+ - Each visual is a **single** HTML document with one shared stylesheet — roughly half the tokens of emitting two standalone pages, and one Chrome screenshot instead of three.
189
+ - Visuals for different topics are generated in parallel (`visual.concurrency`).
190
+
191
+ If a run still feels slow, the lever with the most travel is `--model-cheap` (for example `haiku`), followed by `--max-visuals 1`.
192
+
144
193
  ## Repository ops
145
194
 
146
195
  - **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
@@ -0,0 +1,2 @@
1
+ /** Map over items with a bounded number of in-flight tasks, preserving order. */
2
+ export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,16 @@
1
+ /** Map over items with a bounded number of in-flight tasks, preserving order. */
2
+ export async function mapWithConcurrency(items, limit, fn) {
3
+ const max = Math.max(1, Math.floor(limit));
4
+ const results = new Array(items.length);
5
+ let cursor = 0;
6
+ async function worker() {
7
+ while (true) {
8
+ const index = cursor++;
9
+ if (index >= items.length)
10
+ return;
11
+ results[index] = await fn(items[index], index);
12
+ }
13
+ }
14
+ await Promise.all(Array.from({ length: Math.min(max, items.length) }, () => worker()));
15
+ return results;
16
+ }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,20 @@
1
+ import { type ThemeName } from "./visual/design.js";
1
2
  export type ProviderName = "claude" | "opencode" | "fake";
2
3
  export type ToneName = "technical" | "client";
4
+ export type VisualConfig = {
5
+ /** Palette used by every generated mockup and diagram. */
6
+ theme?: ThemeName;
7
+ /** Override the single accent color within the chosen theme. */
8
+ accent?: string;
9
+ /** Optional webfont stylesheet; omitted keeps renders offline and fast. */
10
+ fontImport?: string;
11
+ fontSans?: string;
12
+ fontDisplay?: string;
13
+ /** Upper bound on generated visuals (each one costs a model call). */
14
+ maxTopics?: number;
15
+ /** How many visuals to generate at once. */
16
+ concurrency?: number;
17
+ };
3
18
  export type FarqConfig = {
4
19
  provider?: ProviderName;
5
20
  tone?: ToneName;
@@ -7,6 +22,7 @@ export type FarqConfig = {
7
22
  claudeCheap?: string;
8
23
  opencodeCheap?: string;
9
24
  };
25
+ visual?: VisualConfig;
10
26
  };
11
27
  export type LoadConfigOptions = {
12
28
  cwd?: string;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { isThemeName } from "./visual/design.js";
4
5
  export function defaultGlobalDir() {
5
6
  return join(homedir(), ".config", "farq");
6
7
  }
@@ -21,12 +22,18 @@ export function mergeConfig(base, override) {
21
22
  ...base.models,
22
23
  ...stripUndefined(override.models ?? {}),
23
24
  };
25
+ const visual = {
26
+ ...base.visual,
27
+ ...stripUndefined(override.visual ?? {}),
28
+ };
24
29
  if (provider !== undefined)
25
30
  out.provider = provider;
26
31
  if (tone !== undefined)
27
32
  out.tone = tone;
28
33
  if (Object.keys(models).length > 0)
29
34
  out.models = models;
35
+ if (Object.keys(visual).length > 0)
36
+ out.visual = visual;
30
37
  return out;
31
38
  }
32
39
  function stripUndefined(obj) {
@@ -70,5 +77,23 @@ function sanitize(data) {
70
77
  if (Object.keys(models).length > 0)
71
78
  out.models = models;
72
79
  }
80
+ if (data.visual && typeof data.visual === "object") {
81
+ const visual = {};
82
+ if (isThemeName(data.visual.theme))
83
+ visual.theme = data.visual.theme;
84
+ for (const key of ["accent", "fontImport", "fontSans", "fontDisplay"]) {
85
+ const value = data.visual[key];
86
+ if (typeof value === "string" && value.trim())
87
+ visual[key] = value.trim();
88
+ }
89
+ for (const key of ["maxTopics", "concurrency"]) {
90
+ const value = data.visual[key];
91
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
92
+ visual[key] = Math.floor(value);
93
+ }
94
+ }
95
+ if (Object.keys(visual).length > 0)
96
+ out.visual = visual;
97
+ }
73
98
  return out;
74
99
  }
package/dist/git.d.ts CHANGED
@@ -28,9 +28,13 @@ export type GatherDiffOptions = {
28
28
  };
29
29
  export declare function gatherDiff(options?: GatherDiffOptions): Promise<GatherDiffResult>;
30
30
  export declare function getFileAtRef(cwd: string, ref: string, filePath: string): Promise<string | null>;
31
- /** Size-capped before/after pairs for visual generation. */
32
- export declare function gatherVisualFileContents(cwd: string, files: DiffFile[], baseRef: string | null, budget?: number): Promise<Array<{
31
+ export type VisualFile = {
33
32
  path: string;
34
33
  before: string;
35
34
  after: string;
36
- }>>;
35
+ };
36
+ /** Size-capped before/after pairs for visual generation. */
37
+ export declare function gatherVisualFileContents(cwd: string, files: DiffFile[], baseRef: string | null, options?: {
38
+ budget?: number;
39
+ mode?: GatherDiffResult["mode"];
40
+ }): Promise<VisualFile[]>;
package/dist/git.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { execa } from "execa";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { mapWithConcurrency } from "./concurrency.js";
2
5
  export const SUMMARY_DIFF_BUDGET = 150_000;
3
6
  export const VISUAL_DIFF_BUDGET = 60_000;
4
7
  const GIT_TIMEOUT_MS = 30_000;
@@ -47,26 +50,40 @@ export async function getFileAtRef(cwd, ref, filePath) {
47
50
  return null;
48
51
  }
49
52
  }
53
+ /** Cap on git subprocesses spawned to build visual context. */
54
+ const MAX_VISUAL_FILES = 40;
55
+ const VISUAL_READ_CONCURRENCY = 8;
50
56
  /** Size-capped before/after pairs for visual generation. */
51
- export async function gatherVisualFileContents(cwd, files, baseRef, budget = VISUAL_DIFF_BUDGET) {
57
+ export async function gatherVisualFileContents(cwd, files, baseRef, options = {}) {
58
+ const budget = options.budget ?? VISUAL_DIFF_BUDGET;
59
+ const worktree = options.mode === "worktree";
60
+ const targets = files.slice(0, MAX_VISUAL_FILES);
61
+ const fetched = await mapWithConcurrency(targets, VISUAL_READ_CONCURRENCY, async (file) => {
62
+ const [before, after] = await Promise.all([
63
+ baseRef ? getFileAtRef(cwd, baseRef, file.path) : Promise.resolve(""),
64
+ // In worktree mode HEAD still holds the *old* content, so read from disk.
65
+ worktree
66
+ ? readWorktreeFile(cwd, file.path)
67
+ : getFileAtRef(cwd, "HEAD", file.path),
68
+ ]);
69
+ return {
70
+ path: file.path,
71
+ before: before ?? "",
72
+ after: after ?? (await readWorktreeFile(cwd, file.path)) ?? "",
73
+ };
74
+ });
52
75
  const out = [];
53
76
  let used = 0;
54
- for (const file of files) {
77
+ for (const file of fetched) {
55
78
  if (used >= budget)
56
79
  break;
57
- const before = baseRef
58
- ? ((await getFileAtRef(cwd, baseRef, file.path)) ?? "")
59
- : "";
60
- const after = (await getFileAtRef(cwd, "HEAD", file.path)) ??
61
- (await readWorktreeFile(cwd, file.path)) ??
62
- "";
63
- const chunk = before.length + after.length;
80
+ const chunk = file.before.length + file.after.length;
64
81
  if (used + chunk > budget && out.length > 0)
65
82
  break;
66
83
  out.push({
67
84
  path: file.path,
68
- before: before.slice(0, budget - used),
69
- after: after.slice(0, Math.max(0, budget - used - before.length)),
85
+ before: file.before.slice(0, budget - used),
86
+ after: file.after.slice(0, Math.max(0, budget - used - file.before.length)),
70
87
  });
71
88
  used += Math.min(chunk, budget - used);
72
89
  }
@@ -74,14 +91,11 @@ export async function gatherVisualFileContents(cwd, files, baseRef, budget = VIS
74
91
  }
75
92
  async function gatherRangeDiff(cwd, range, budget) {
76
93
  const [baseRef, headRef] = splitRange(range);
77
- const nameStatus = await git(cwd, [
78
- "diff",
79
- "--name-status",
80
- "--find-renames",
81
- range,
94
+ const [nameStatus, patch, commits] = await Promise.all([
95
+ git(cwd, ["diff", "--name-status", "--find-renames", range]),
96
+ git(cwd, ["diff", "--find-renames", "--unified=5", range]),
97
+ listCommits(cwd, range),
82
98
  ]);
83
- const patch = await git(cwd, ["diff", "--find-renames", "--unified=5", range]);
84
- const commits = await listCommits(cwd, range);
85
99
  const files = parseNameStatus(nameStatus.stdout, patch.stdout);
86
100
  const { text, truncated } = capText(patch.stdout, budget);
87
101
  return {
@@ -96,14 +110,11 @@ async function gatherRangeDiff(cwd, range, budget) {
96
110
  };
97
111
  }
98
112
  async function gatherWorktreeDiff(cwd, budget) {
99
- const nameStatus = await git(cwd, [
100
- "diff",
101
- "HEAD",
102
- "--name-status",
103
- "--find-renames",
113
+ const [nameStatus, patch, untracked] = await Promise.all([
114
+ git(cwd, ["diff", "HEAD", "--name-status", "--find-renames"]),
115
+ git(cwd, ["diff", "HEAD", "--find-renames", "--unified=5"]),
116
+ listUntracked(cwd),
104
117
  ]);
105
- const patch = await git(cwd, ["diff", "HEAD", "--find-renames", "--unified=5"]);
106
- const untracked = await listUntracked(cwd);
107
118
  const files = parseNameStatus(nameStatus.stdout, patch.stdout);
108
119
  let diffText = patch.stdout;
109
120
  for (const path of untracked) {
@@ -190,8 +201,6 @@ async function listUntracked(cwd) {
190
201
  }
191
202
  async function readWorktreeFile(cwd, filePath) {
192
203
  try {
193
- const { readFileSync } = await import("node:fs");
194
- const { join } = await import("node:path");
195
204
  return readFileSync(join(cwd, filePath), "utf8");
196
205
  }
197
206
  catch {
package/dist/index.js CHANGED
@@ -1,22 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { loadConfig, mergeConfig, } from "./config.js";
4
- import { gatherDiff } from "./git.js";
4
+ import { gatherDiff, gatherVisualFileContents } from "./git.js";
5
5
  import { resolveProvider } from "./providers/index.js";
6
6
  import { summarize } from "./summarize.js";
7
7
  import { inferTitleConvention } from "./title.js";
8
8
  import { fetchRecentPrTitles, openPullRequest } from "./open-pr.js";
9
9
  import { runVisualPipeline } from "./visual/pipeline.js";
10
10
  import { ChromeError } from "./visual/chrome.js";
11
+ import { DEFAULT_THEME, THEME_NAMES, isThemeName, resolveTheme, } from "./visual/design.js";
11
12
  import { renderPr } from "./render/pr.js";
12
13
  import { renderSlack } from "./render/slack.js";
13
14
  import { renderJson } from "./render/json.js";
14
- import { createUi } from "./ui/index.js";
15
+ import { createUi, linesFor } from "./ui/index.js";
15
16
  import { defaultOutDir, displayImageRef } from "./paths.js";
16
17
  async function run(type, opts) {
17
18
  const cwd = process.cwd();
18
19
  const ui = createUi();
19
20
  const fileConfig = loadConfig({ cwd });
21
+ if (opts.theme && !isThemeName(opts.theme)) {
22
+ ui.note(`unknown theme "${opts.theme}" — using ${DEFAULT_THEME} (options: ${THEME_NAMES.join(", ")})`);
23
+ }
24
+ const maxVisuals = Number(opts.maxVisuals);
20
25
  const flagConfig = {
21
26
  provider: opts.provider,
22
27
  tone: opts.tone,
@@ -26,44 +31,60 @@ async function run(type, opts) {
26
31
  opencodeCheap: opts.modelCheap,
27
32
  }
28
33
  : undefined,
34
+ visual: {
35
+ theme: isThemeName(opts.theme) ? opts.theme : undefined,
36
+ accent: opts.accent,
37
+ maxTopics: Number.isFinite(maxVisuals) && maxVisuals >= 1
38
+ ? Math.floor(maxVisuals)
39
+ : undefined,
40
+ },
29
41
  };
30
42
  const config = mergeConfig(fileConfig, flagConfig);
31
- let provider;
32
- try {
33
- provider = await resolveProvider({
34
- flag: config.provider,
35
- config,
36
- log: (msg) => ui.note(msg),
37
- });
38
- }
39
- catch (err) {
40
- ui.error(err instanceof Error ? err.message : String(err));
41
- return 1;
42
- }
43
43
  const tone = config.tone ?? "technical";
44
44
  const noImages = opts.noImages === true || opts.images === false;
45
+ const manualShots = Boolean(opts.before && opts.after);
45
46
  const imagesEnabled = type === "pr" ? !noImages : false;
47
+ const willVisual = imagesEnabled || manualShots;
48
+ ui.plan(2 + (willVisual ? 1 : 0) + (type === "pr" && opts.open ? 1 : 0));
49
+ // Provider detection, the diff and the `gh` title lookup are independent —
50
+ // start them together so the first model call is not waiting on subprocesses.
51
+ const providerNotes = [];
52
+ const providerPromise = resolveProvider({
53
+ flag: config.provider,
54
+ config,
55
+ log: (msg) => providerNotes.push(msg),
56
+ }).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
57
+ const titlesPromise = type === "pr" ? fetchRecentPrTitles(cwd).catch(() => []) : null;
46
58
  let diff;
47
59
  const diffSpin = ui.stage("diff");
48
60
  try {
49
61
  diff = await gatherDiff({ cwd, range: opts.range });
50
- diffSpin.succeed("diff ready");
62
+ diffSpin.succeed(`diff ready — ${describeDiff(diff.files.length)}`);
51
63
  }
52
64
  catch (err) {
53
65
  const msg = err instanceof Error ? err.message : String(err);
54
66
  diffSpin.fail(msg);
55
67
  return 1;
56
68
  }
57
- let titleBlurb = "";
58
- if (type === "pr") {
59
- try {
60
- const titles = await fetchRecentPrTitles(cwd);
61
- titleBlurb = inferTitleConvention(titles).blurb;
62
- }
63
- catch {
64
- // optional
65
- }
69
+ const resolved = await providerPromise;
70
+ for (const note of providerNotes)
71
+ ui.note(note);
72
+ if (!resolved.ok) {
73
+ ui.error(resolved.error instanceof Error
74
+ ? resolved.error.message
75
+ : String(resolved.error));
76
+ return 1;
66
77
  }
78
+ const provider = resolved.value;
79
+ // Read the before/after file bodies while the model writes the summary.
80
+ const visualFiles = imagesEnabled && !manualShots
81
+ ? gatherVisualFileContents(cwd, diff.files, diff.baseRef, {
82
+ mode: diff.mode,
83
+ }).catch(() => [])
84
+ : undefined;
85
+ const titleBlurb = titlesPromise
86
+ ? inferTitleConvention(await titlesPromise).blurb
87
+ : "";
67
88
  let summary;
68
89
  const sumSpin = ui.stage("summarize", provider.name);
69
90
  try {
@@ -88,8 +109,49 @@ async function run(type, opts) {
88
109
  let imagePath = null;
89
110
  let images = [];
90
111
  let imageMeta = [];
91
- if (imagesEnabled || (opts.before && opts.after)) {
92
- const visSpin = ui.stage("visual", provider.name);
112
+ if (willVisual) {
113
+ const visSpin = ui.stage("topics", {
114
+ provider: provider.name,
115
+ label: "visuals",
116
+ });
117
+ const track = { total: 0, done: 0, current: "" };
118
+ const refresh = () => {
119
+ const parts = [];
120
+ if (track.total > 1)
121
+ parts.push(`${track.done}/${track.total}`);
122
+ if (track.current)
123
+ parts.push(track.current);
124
+ visSpin.detail(parts.join(" · ") || null);
125
+ };
126
+ const onProgress = (event) => {
127
+ switch (event.kind) {
128
+ case "topics":
129
+ track.total = event.total;
130
+ visSpin.setLines(linesFor("visual", provider.name));
131
+ break;
132
+ // The rotating line already names the activity; the detail names the
133
+ // topic being worked on, plus a counter once there is more than one.
134
+ case "topic-start":
135
+ track.current = shorten(event.title);
136
+ visSpin.setLines(linesFor(event.mode, provider.name));
137
+ break;
138
+ case "topic-fallback":
139
+ track.current = shorten(event.title);
140
+ visSpin.setLines(linesFor("diagram", provider.name));
141
+ break;
142
+ case "topic-shot":
143
+ track.current = shorten(event.title);
144
+ visSpin.setLines(linesFor("shoot", provider.name));
145
+ break;
146
+ case "topic-done":
147
+ track.done = event.done;
148
+ track.total = event.total;
149
+ if (event.done === event.total)
150
+ track.current = "";
151
+ break;
152
+ }
153
+ refresh();
154
+ };
93
155
  try {
94
156
  const visual = await runVisualPipeline({
95
157
  cwd,
@@ -98,21 +160,28 @@ async function run(type, opts) {
98
160
  diff,
99
161
  provider,
100
162
  modelCheap: cheapModel,
101
- noImages: noImages && !(opts.before && opts.after),
163
+ noImages: noImages && !manualShots,
102
164
  before: opts.before,
103
165
  after: opts.after,
166
+ theme: resolveTheme(config.visual ?? {}),
167
+ maxTopics: config.visual?.maxTopics,
168
+ concurrency: config.visual?.concurrency,
169
+ visualFiles,
104
170
  verbose: opts.verbose,
105
171
  log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
172
+ onProgress,
106
173
  });
107
174
  imagePath = visual.imagePath;
108
175
  images = visual.images;
109
176
  imageMeta = visual.imageMeta;
110
- if (visual.warning) {
177
+ if (visual.warning && images.length === 0) {
111
178
  visSpin.succeed("visuals skipped");
112
179
  ui.note(visual.warning);
113
180
  }
114
181
  else if (imagePath) {
115
- visSpin.succeed("visuals ready");
182
+ visSpin.succeed(`${images.length} visual${images.length === 1 ? "" : "s"} ready`);
183
+ if (visual.warning)
184
+ ui.note(visual.warning);
116
185
  }
117
186
  else {
118
187
  visSpin.succeed("no visual (ok)");
@@ -120,7 +189,7 @@ async function run(type, opts) {
120
189
  }
121
190
  catch (err) {
122
191
  const msg = err instanceof Error ? err.message : String(err);
123
- if (err instanceof ChromeError && opts.before && opts.after) {
192
+ if (err instanceof ChromeError && manualShots) {
124
193
  visSpin.fail(msg);
125
194
  return 1;
126
195
  }
@@ -173,6 +242,13 @@ async function run(type, opts) {
173
242
  process.stdout.write(artifact);
174
243
  return 0;
175
244
  }
245
+ function describeDiff(fileCount) {
246
+ return `${fileCount} file${fileCount === 1 ? "" : "s"}`;
247
+ }
248
+ function shorten(text, max = 28) {
249
+ const clean = text.replace(/\s+/g, " ").trim();
250
+ return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
251
+ }
176
252
  function addShared(cmd) {
177
253
  return cmd
178
254
  .option("-r, --range <range>", "git range, e.g. main..feature")
@@ -183,6 +259,9 @@ function addShared(cmd) {
183
259
  .option("--no-images", "skip image generation/composition")
184
260
  .option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
185
261
  .option("--model-cheap <id>", "model for visual generation")
262
+ .option("--theme <name>", "visual palette: midnight | daylight")
263
+ .option("--accent <color>", "override the theme accent color")
264
+ .option("--max-visuals <n>", "cap generated visuals (1-5)")
186
265
  .option("-v, --verbose", "verbose logging", false);
187
266
  }
188
267
  async function main() {
@@ -1,3 +1,4 @@
1
1
  import type { CompleteOptions } from "./fake.js";
2
+ export declare const UNSUPPORTED_FLAG: RegExp;
2
3
  export declare function complete(prompt: string, options?: CompleteOptions): Promise<string>;
3
4
  export declare function isInstalled(): Promise<boolean>;
@@ -1,58 +1,75 @@
1
1
  import { execa } from "execa";
2
2
  const TIMEOUT_MS = 120_000;
3
+ /**
4
+ * farq only ever asks for JSON back, so the user's MCP servers, skills, hooks
5
+ * and project CLAUDE.md are pure startup cost — and a source of drift in the
6
+ * output. Skipping them roughly halves per-call latency.
7
+ */
8
+ const FAST_ARGS = ["--strict-mcp-config", "--setting-sources", ""];
9
+ /** Tried in order; each falls back to the next when the CLI rejects a flag. */
10
+ const ARG_VARIANTS = [
11
+ FAST_ARGS,
12
+ [],
13
+ // Empty allowedTools hangs on some versions, so it stays the last resort.
14
+ ["--allowedTools", ""],
15
+ ];
16
+ export const UNSUPPORTED_FLAG = /unknown option|unknown argument|unrecognized|allowedTools|setting-sources|strict-mcp-config/i;
3
17
  export async function complete(prompt, options = {}) {
4
18
  const baseArgs = ["-p", "--output-format", "json", "--max-turns", "1"];
5
19
  if (options.model)
6
20
  baseArgs.push("--model", options.model);
7
- // Prefer without allowedTools first — empty allowedTools hangs/fails on some versions.
8
- const attempts = [[...baseArgs], [...baseArgs, "--allowedTools", ""]];
9
21
  let lastErr;
10
- for (const args of attempts) {
22
+ for (let i = 0; i < ARG_VARIANTS.length; i++) {
23
+ const canRetry = i < ARG_VARIANTS.length - 1;
24
+ const args = [...baseArgs, ...ARG_VARIANTS[i]];
25
+ let result;
11
26
  try {
12
- const result = await execa("claude", args, {
27
+ result = await execa("claude", args, {
13
28
  input: prompt,
14
29
  timeout: TIMEOUT_MS,
15
30
  reject: false,
16
31
  stdout: "pipe",
17
32
  stderr: "pipe",
18
33
  });
19
- if (result.timedOut) {
20
- throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
21
- }
22
- const text = (result.stdout || result.stderr || "").trim();
23
- if (!text && result.exitCode !== 0) {
24
- throw new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
25
- }
26
- try {
27
- return parseClaudeOutput(text);
28
- }
29
- catch (parseErr) {
30
- // Retry next arg set only when allowedTools looks unsupported
31
- if (args.includes("--allowedTools") &&
32
- /allowedTools|unknown option|unexpected/i.test(`${result.stderr ?? ""} ${text}`)) {
33
- lastErr = parseErr;
34
- continue;
35
- }
36
- throw parseErr;
37
- }
38
34
  }
39
35
  catch (err) {
40
36
  const e = err;
41
- if (e.timedOut || /did not respond/i.test(e.message ?? "")) {
42
- throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
37
+ if (e.timedOut)
38
+ throw notResponding();
39
+ if (canRetry && UNSUPPORTED_FLAG.test(e.message ?? "")) {
40
+ lastErr = err;
41
+ continue;
43
42
  }
44
- lastErr = err;
45
- if (!args.includes("--allowedTools")) {
46
- // try with allowedTools only if first attempt was a flag-related failure
47
- const msg = e.message ?? "";
48
- if (/unknown option|allowedTools/i.test(msg))
49
- continue;
50
- throw err instanceof Error ? err : new Error(String(err));
43
+ throw err instanceof Error ? err : new Error(String(err));
44
+ }
45
+ if (result.timedOut)
46
+ throw notResponding();
47
+ const text = (result.stdout || result.stderr || "").trim();
48
+ const diagnostics = `${result.stderr ?? ""} ${text}`;
49
+ if (!text && result.exitCode !== 0) {
50
+ const err = new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
51
+ if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
52
+ lastErr = err;
53
+ continue;
51
54
  }
55
+ throw err;
56
+ }
57
+ try {
58
+ return parseClaudeOutput(text);
59
+ }
60
+ catch (parseErr) {
61
+ if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
62
+ lastErr = parseErr;
63
+ continue;
64
+ }
65
+ throw parseErr;
52
66
  }
53
67
  }
54
68
  throw lastErr instanceof Error ? lastErr : new Error("claude failed");
55
69
  }
70
+ function notResponding() {
71
+ return new Error("claude did not respond — check that it's authenticated (`claude` login)");
72
+ }
56
73
  function parseClaudeOutput(stdout) {
57
74
  try {
58
75
  const envelope = JSON.parse(stdout);