@ahmedalbarghouti/farq 0.0.2 → 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 (51) hide show
  1. package/README.md +51 -13
  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 +116 -33
  9. package/dist/open-pr.d.ts +6 -1
  10. package/dist/open-pr.js +42 -16
  11. package/dist/providers/claude.d.ts +1 -0
  12. package/dist/providers/claude.js +49 -32
  13. package/dist/providers/fake.d.ts +5 -2
  14. package/dist/providers/fake.js +26 -15
  15. package/dist/providers/index.d.ts +1 -1
  16. package/dist/providers/index.js +1 -1
  17. package/dist/render/pr.d.ts +6 -0
  18. package/dist/render/pr.js +29 -7
  19. package/dist/schema.d.ts +40 -7
  20. package/dist/schema.js +14 -1
  21. package/dist/summarize.js +7 -0
  22. package/dist/template.d.ts +6 -0
  23. package/dist/template.js +40 -19
  24. package/dist/ui/index.d.ts +1 -0
  25. package/dist/ui/index.js +1 -0
  26. package/dist/ui/lines.d.ts +19 -1
  27. package/dist/ui/lines.js +81 -39
  28. package/dist/ui/logger.d.ts +9 -2
  29. package/dist/ui/logger.js +15 -4
  30. package/dist/ui/spinner.d.ts +15 -2
  31. package/dist/ui/spinner.js +105 -22
  32. package/dist/ui/theme.d.ts +1 -0
  33. package/dist/ui/theme.js +3 -0
  34. package/dist/visual/chrome.js +21 -2
  35. package/dist/visual/cluster.d.ts +29 -0
  36. package/dist/visual/cluster.js +204 -0
  37. package/dist/visual/compose.d.ts +8 -1
  38. package/dist/visual/compose.js +17 -32
  39. package/dist/visual/css-scope.d.ts +6 -0
  40. package/dist/visual/css-scope.js +162 -0
  41. package/dist/visual/design.d.ts +103 -0
  42. package/dist/visual/design.js +327 -0
  43. package/dist/visual/diagram.d.ts +8 -0
  44. package/dist/visual/diagram.js +36 -14
  45. package/dist/visual/mockup.d.ts +16 -11
  46. package/dist/visual/mockup.js +45 -22
  47. package/dist/visual/pipeline.d.ts +35 -1
  48. package/dist/visual/pipeline.js +191 -69
  49. package/dist/visual/viewport.d.ts +16 -0
  50. package/dist/visual/viewport.js +19 -0
  51. package/package.json +1 -1
package/README.md CHANGED
@@ -5,13 +5,13 @@
5
5
  One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
6
6
 
7
7
  ```bash
8
- npx @ahmedalbarghouti/farq@0.0.2 pr # title + markdown body (+ image when feasible)
8
+ npx @ahmedalbarghouti/farq@0.0.3 pr # title + markdown body (+ image when feasible)
9
9
  npx farq slack # Slack mrkdwn daily update
10
10
  npx farq json # structured JSON
11
11
  npx farq pr --open # fill PR template + create with gh
12
12
  ```
13
13
 
14
- > **0.0.2** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
14
+ > **0.0.3** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
15
15
 
16
16
  ## Install
17
17
 
@@ -23,7 +23,7 @@ farq --help
23
23
  Or run without installing:
24
24
 
25
25
  ```bash
26
- npx @ahmedalbarghouti/farq@0.0.2 --help
26
+ npx @ahmedalbarghouti/farq@0.0.3 --help
27
27
  ```
28
28
 
29
29
  Generated images land in a **user cache directory outside the repo** by default (no `.gitignore` change needed). Use `--out .farq` if you want them in-tree.
@@ -76,7 +76,7 @@ farq pr --open
76
76
 
77
77
  1. Reads `.github` PR template if present and fills known sections
78
78
  2. Infers title style from recent merged PR titles when `gh` works
79
- 3. Best-effort uploads the composed image as a prerelease asset and embeds the URL
79
+ 3. Best-effort uploads composed image(s) as prerelease assets and embeds the URL(s)
80
80
  4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
81
81
 
82
82
  On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
@@ -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. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. If the model cannot produce a faithful preview, **no image** is produced (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,22 +165,36 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
141
165
 
142
166
  Progress → **stderr**. Artifact → **stdout**.
143
167
 
144
- ## Publishing notes (maintainers)
168
+ ## Progress output
145
169
 
146
- Tag-triggered publish via GitHub Actions (npm provenance):
170
+ Each stage is numbered, names what it is doing, and reports how long it took:
147
171
 
148
- ```bash
149
- git tag v0.0.2
150
- git push origin v0.0.2
151
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`).
152
190
 
153
- Requires repo secret `NPM_TOKEN`. Package name: `@ahmedalbarghouti/farq`.
191
+ If a run still feels slow, the lever with the most travel is `--model-cheap` (for example `haiku`), followed by `--max-visuals 1`.
154
192
 
155
193
  ## Repository ops
156
194
 
157
195
  - **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
158
196
  - **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
159
- - **Releases** are tag-triggered: create `v0.0.2` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
197
+ - **Releases** are tag-triggered: create `v0.0.3` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
160
198
  - **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
161
199
 
162
200
  ## Roadmap
@@ -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 {
@@ -87,8 +108,50 @@ async function run(type, opts) {
87
108
  : undefined;
88
109
  let imagePath = null;
89
110
  let images = [];
90
- if (imagesEnabled || (opts.before && opts.after)) {
91
- const visSpin = ui.stage("visual", provider.name);
111
+ let imageMeta = [];
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
+ };
92
155
  try {
93
156
  const visual = await runVisualPipeline({
94
157
  cwd,
@@ -97,20 +160,28 @@ async function run(type, opts) {
97
160
  diff,
98
161
  provider,
99
162
  modelCheap: cheapModel,
100
- noImages: noImages && !(opts.before && opts.after),
163
+ noImages: noImages && !manualShots,
101
164
  before: opts.before,
102
165
  after: opts.after,
166
+ theme: resolveTheme(config.visual ?? {}),
167
+ maxTopics: config.visual?.maxTopics,
168
+ concurrency: config.visual?.concurrency,
169
+ visualFiles,
103
170
  verbose: opts.verbose,
104
171
  log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
172
+ onProgress,
105
173
  });
106
174
  imagePath = visual.imagePath;
107
175
  images = visual.images;
108
- if (visual.warning) {
176
+ imageMeta = visual.imageMeta;
177
+ if (visual.warning && images.length === 0) {
109
178
  visSpin.succeed("visuals skipped");
110
179
  ui.note(visual.warning);
111
180
  }
112
181
  else if (imagePath) {
113
- visSpin.succeed("visuals ready");
182
+ visSpin.succeed(`${images.length} visual${images.length === 1 ? "" : "s"} ready`);
183
+ if (visual.warning)
184
+ ui.note(visual.warning);
114
185
  }
115
186
  else {
116
187
  visSpin.succeed("no visual (ok)");
@@ -118,7 +189,7 @@ async function run(type, opts) {
118
189
  }
119
190
  catch (err) {
120
191
  const msg = err instanceof Error ? err.message : String(err);
121
- if (err instanceof ChromeError && opts.before && opts.after) {
192
+ if (err instanceof ChromeError && manualShots) {
122
193
  visSpin.fail(msg);
123
194
  return 1;
124
195
  }
@@ -126,10 +197,10 @@ async function run(type, opts) {
126
197
  ui.note(`${msg} (continuing without image)`);
127
198
  }
128
199
  }
129
- const relImage = imagePath != null ? displayImageRef(cwd, imagePath) : null;
200
+ const prImages = (imageMeta.length > 0 ? imageMeta : images.map((p) => ({ path: p, title: "before / after" }))).map((img) => ({ path: displayImageRef(cwd, img.path), title: img.title }));
130
201
  let artifact = "";
131
202
  if (type === "pr") {
132
- artifact = renderPr({ summary, imagePath: relImage });
203
+ artifact = renderPr({ summary, images: prImages });
133
204
  }
134
205
  else if (type === "slack") {
135
206
  artifact = renderSlack(summary);
@@ -147,6 +218,8 @@ async function run(type, opts) {
147
218
  ? artifact.split("\n\n").slice(1).join("\n\n")
148
219
  : artifact,
149
220
  imagePath,
221
+ imagePaths: images,
222
+ imageTitles: imageMeta.map((m) => m.title),
150
223
  });
151
224
  if (result.skipped) {
152
225
  openSpin.succeed(result.reason);
@@ -169,6 +242,13 @@ async function run(type, opts) {
169
242
  process.stdout.write(artifact);
170
243
  return 0;
171
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
+ }
172
252
  function addShared(cmd) {
173
253
  return cmd
174
254
  .option("-r, --range <range>", "git range, e.g. main..feature")
@@ -179,6 +259,9 @@ function addShared(cmd) {
179
259
  .option("--no-images", "skip image generation/composition")
180
260
  .option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
181
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)")
182
265
  .option("-v, --verbose", "verbose logging", false);
183
266
  }
184
267
  async function main() {
@@ -186,7 +269,7 @@ async function main() {
186
269
  program
187
270
  .name("farq")
188
271
  .description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
189
- .version("0.0.2");
272
+ .version("0.0.3");
190
273
  addShared(program
191
274
  .command("pr", { isDefault: true })
192
275
  .description("PR title + body markdown")
package/dist/open-pr.d.ts CHANGED
@@ -10,6 +10,7 @@ export type OpenPrResult = {
10
10
  title: string;
11
11
  body: string;
12
12
  imageUrl?: string | null;
13
+ imageUrls?: string[];
13
14
  };
14
15
  /** Prerelease tag used to host a branch's composed PNG. */
15
16
  export declare function assetTagForBranch(branch: string): string;
@@ -26,11 +27,15 @@ export declare function openPullRequest(options: {
26
27
  summary: ChangeSummary;
27
28
  bodyMarkdown: string;
28
29
  imagePath?: string | null;
30
+ imagePaths?: string[];
31
+ imageTitles?: string[];
29
32
  }): Promise<OpenPrResult>;
30
33
  /**
31
34
  * Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
32
35
  * Always keeps `keepTag` (the just-uploaded asset).
33
36
  */
34
37
  export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
35
- /** Best-effort: prerelease asset URL for the PNG. */
38
+ /** Best-effort: prerelease asset URL for one PNG. */
36
39
  export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
40
+ /** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
41
+ export declare function uploadPrImages(cwd: string, imagePaths: string[], branch: string): Promise<string[]>;