@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
@@ -1,3 +1,21 @@
1
1
  import type { ProviderName } from "../config.js";
2
- export type LineBank = "summarize" | "visual" | "open" | "diff";
2
+ /**
3
+ * Rotating status lines. `{p}` is replaced with the provider name so a line
4
+ * always says who is doing the work. Keep templates <= 37 chars so the longest
5
+ * provider name still fits the one-line spinner.
6
+ */
7
+ declare const BANKS: {
8
+ diff: string[];
9
+ summarize: string[];
10
+ topics: string[];
11
+ mockup: string[];
12
+ diagram: string[];
13
+ shoot: string[];
14
+ visual: string[];
15
+ open: string[];
16
+ };
17
+ export type LineBank = keyof typeof BANKS;
3
18
  export declare function linesFor(bank: LineBank, provider?: ProviderName): string[];
19
+ /** Short, honest name for the stage — the witty line sits next to it. */
20
+ export declare function labelFor(bank: LineBank): string;
21
+ export {};
package/dist/ui/lines.js CHANGED
@@ -1,48 +1,90 @@
1
- const SUMMARIZE = {
2
- claude: [
3
- "claude is reading the diff…",
4
- "claude is grouping the mess…",
5
- "claude is writing the blurb…",
6
- ],
7
- opencode: [
8
- "opencode is reading the diff…",
9
- "opencode is grouping the mess…",
10
- "opencode is writing the blurb…",
11
- ],
12
- fake: [
13
- "fake is vibing on hard mode…",
14
- "fake is inventing nothing new…",
15
- "fake is shipping canned vibes…",
1
+ /**
2
+ * Rotating status lines. `{p}` is replaced with the provider name so a line
3
+ * always says who is doing the work. Keep templates <= 37 chars so the longest
4
+ * provider name still fits the one-line spinner.
5
+ */
6
+ const BANKS = {
7
+ diff: [
8
+ "gathering the diff…",
9
+ "asking git politely…",
10
+ "reading what you actually did…",
11
+ "diffing against the merge base…",
12
+ "counting the damage…",
16
13
  ],
17
- };
18
- const VISUAL = {
19
- claude: [
20
- "claude is sketching a mockup…",
21
- "claude is boxing a flowchart…",
22
- "chrome is taking the shot…",
14
+ summarize: [
15
+ "{p} is reading the diff…",
16
+ "{p} is grouping the mess…",
17
+ "{p} is finding the story…",
18
+ "{p} is naming things (hard part)…",
19
+ "{p} is deciding what matters…",
20
+ "{p} is skipping the boring parts…",
21
+ "{p} is drafting your PR title…",
22
+ "{p} is writing the blurb…",
23
23
  ],
24
- opencode: [
25
- "opencode is sketching a mockup…",
26
- "opencode is boxing a flowchart…",
27
- "chrome is taking the shot…",
24
+ topics: [
25
+ "planning the visuals…",
26
+ "splitting the story into scenes…",
27
+ "deciding how many pictures…",
28
+ "grouping changes into topics…",
29
+ ],
30
+ mockup: [
31
+ "{p} is sketching the before…",
32
+ "{p} is sketching the after…",
33
+ "{p} is laying out the mockup…",
34
+ "{p} is writing placeholder copy…",
35
+ "{p} is obeying the design tokens…",
36
+ "{p} is resisting a redesign…",
37
+ "{p} is measuring the spacing…",
28
38
  ],
29
- fake: [
30
- "fake is drawing boxes…",
31
- "fake is pretending to design…",
39
+ diagram: [
40
+ "{p} is boxing a flowchart…",
41
+ "{p} is drawing arrows…",
42
+ "{p} is explaining it with boxes…",
43
+ "{p} is keeping it conceptual…",
44
+ "{p} is deleting a wall of text…",
45
+ ],
46
+ shoot: [
32
47
  "chrome is taking the shot…",
48
+ "chrome is scaling it to fit…",
49
+ "waiting on headless chrome…",
50
+ "pressing the shutter…",
51
+ ],
52
+ visual: [
53
+ "{p} is drawing the difference…",
54
+ "{p} is picking a composition…",
55
+ "chrome is warming up…",
56
+ "making the change look obvious…",
57
+ ],
58
+ open: [
59
+ "talking to gh…",
60
+ "filling the PR template…",
61
+ "uploading the visuals…",
62
+ "shipping the PR body…",
63
+ "almost there…",
33
64
  ],
34
65
  };
35
- const OPEN = [
36
- "talking to gh…",
37
- "shipping the PR body…",
38
- "almost there…",
39
- ];
40
66
  export function linesFor(bank, provider) {
41
- if (bank === "diff") {
42
- return ["gathering the diff…", "asking git politely…"];
67
+ const name = provider ?? "claude";
68
+ return BANKS[bank].map((line) => line.replaceAll("{p}", name));
69
+ }
70
+ /** Short, honest name for the stage — the witty line sits next to it. */
71
+ export function labelFor(bank) {
72
+ switch (bank) {
73
+ case "diff":
74
+ return "diff";
75
+ case "summarize":
76
+ return "summary";
77
+ case "topics":
78
+ return "planning";
79
+ case "mockup":
80
+ return "mockup";
81
+ case "diagram":
82
+ return "diagram";
83
+ case "shoot":
84
+ return "capture";
85
+ case "visual":
86
+ return "visuals";
87
+ case "open":
88
+ return "pull request";
43
89
  }
44
- if (bank === "open")
45
- return OPEN;
46
- const p = provider ?? "claude";
47
- return bank === "summarize" ? SUMMARIZE[p] : VISUAL[p];
48
90
  }
@@ -1,11 +1,18 @@
1
1
  import type { ProviderName } from "../config.js";
2
2
  import { type LineBank } from "./lines.js";
3
3
  import { type SpinnerHandle } from "./spinner.js";
4
+ export type StageOptions = {
5
+ provider?: ProviderName;
6
+ /** Override the stage label shown before the witty line. */
7
+ label?: string;
8
+ };
4
9
  export type Ui = {
10
+ /** Declare how many stages this run will have, so steps read as [2/4]. */
11
+ plan: (totalSteps: number) => void;
5
12
  /** Plain note (no spinner). */
6
13
  note: (text: string) => void;
7
- /** Start a spinning stage with optional rotating witty lines. */
8
- stage: (bank: LineBank, provider?: ProviderName) => SpinnerHandle;
14
+ /** Start a numbered stage with rotating witty lines. */
15
+ stage: (bank: LineBank, options?: StageOptions | ProviderName) => SpinnerHandle;
9
16
  error: (text: string) => void;
10
17
  success: (text: string) => void;
11
18
  };
package/dist/ui/logger.js CHANGED
@@ -1,14 +1,25 @@
1
- import { linesFor } from "./lines.js";
1
+ import { labelFor, linesFor } from "./lines.js";
2
2
  import { startSpinner } from "./spinner.js";
3
3
  import { brand, fail, muted, ok } from "./theme.js";
4
4
  export function createUi() {
5
+ let total = 0;
6
+ let step = 0;
5
7
  return {
8
+ plan(totalSteps) {
9
+ total = Math.max(0, Math.floor(totalSteps));
10
+ step = 0;
11
+ },
6
12
  note(text) {
7
13
  process.stderr.write(`${brand()} ${muted(text)}\n`);
8
14
  },
9
- stage(bank, provider) {
10
- const lines = linesFor(bank, provider);
11
- return startSpinner(lines[0] ?? "working…", lines);
15
+ stage(bank, options) {
16
+ const opts = typeof options === "string" ? { provider: options } : (options ?? {});
17
+ step++;
18
+ return startSpinner({
19
+ lines: linesFor(bank, opts.provider),
20
+ label: opts.label ?? labelFor(bank),
21
+ step: total > 0 ? { index: Math.min(step, total), total } : undefined,
22
+ });
12
23
  },
13
24
  error(text) {
14
25
  process.stderr.write(`${brand()} ${fail(text)}\n`);
@@ -1,7 +1,20 @@
1
1
  export type SpinnerHandle = {
2
- update: (text: string) => void;
2
+ /** Replace the stage label (rarely needed). */
3
+ update: (label: string) => void;
4
+ /** Swap the rotating witty lines mid-stage, e.g. summarize → mockup. */
5
+ setLines: (lines: string[]) => void;
6
+ /** Concrete sub-stage status shown after the witty line. */
7
+ detail: (text: string | null) => void;
3
8
  succeed: (text: string) => void;
4
9
  fail: (text: string) => void;
5
10
  stop: () => void;
6
11
  };
7
- export declare function startSpinner(initial: string, rotateLines?: string[]): SpinnerHandle;
12
+ export type SpinnerOptions = {
13
+ lines: string[];
14
+ label?: string;
15
+ step?: {
16
+ index: number;
17
+ total: number;
18
+ };
19
+ };
20
+ export declare function startSpinner(options: SpinnerOptions): SpinnerHandle;
@@ -1,46 +1,86 @@
1
- import { brand, accent, isInteractive, muted, ok, fail } from "./theme.js";
1
+ import { accent, brand, dim, fail, isInteractive, muted, ok } from "./theme.js";
2
2
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
- export function startSpinner(initial, rotateLines) {
3
+ const FRAME_MS = 80;
4
+ const ROTATE_MS = 2600;
5
+ /** Only surface a timer once a stage is slow enough to feel slow. */
6
+ const ELAPSED_AFTER_MS = 3000;
7
+ export function startSpinner(options) {
4
8
  const interactive = isInteractive();
5
- let text = initial;
6
- let i = 0;
9
+ const started = Date.now();
10
+ let label = options.label ?? "working";
11
+ let lines = rotateOrder(options.lines);
7
12
  let lineIdx = 0;
13
+ let detailText = null;
14
+ let frame = 0;
8
15
  let timer = null;
9
16
  let lineTimer = null;
10
17
  let stopped = false;
18
+ let lastStatic = "";
19
+ const stepTag = options.step
20
+ ? `[${options.step.index}/${options.step.total}] `
21
+ : "";
22
+ const elapsedSeconds = () => (Date.now() - started) / 1000;
23
+ const compose = () => {
24
+ const parts = [lines[lineIdx] ?? label];
25
+ if (detailText)
26
+ parts.push(detailText);
27
+ const elapsed = Date.now() - started >= ELAPSED_AFTER_MS
28
+ ? `${Math.round(elapsedSeconds())}s`
29
+ : "";
30
+ const body = parts.join(dim(" · "));
31
+ const tail = elapsed ? `${dim(" · ")}${dim(elapsed)}` : "";
32
+ return `${accent(stepTag)}${label}${dim(" · ")}${muted(body)}${tail}`;
33
+ };
11
34
  const render = () => {
12
35
  if (!interactive || stopped)
13
36
  return;
14
- const frame = accent(FRAMES[i % FRAMES.length]);
15
- process.stderr.write(`\r\x1b[2K${brand()} ${frame} ${text}`);
16
- i++;
37
+ const line = `${brand()} ${accent(FRAMES[frame % FRAMES.length])} ${compose()}`;
38
+ process.stderr.write(`\r\x1b[2K${truncate(line)}`);
39
+ frame++;
40
+ };
41
+ const printStatic = () => {
42
+ const next = detailText ? `${label} · ${detailText}` : label;
43
+ if (next === lastStatic)
44
+ return;
45
+ lastStatic = next;
46
+ process.stderr.write(`${brand()} ${muted(`${stepTag}${next}`)}\n`);
17
47
  };
18
48
  if (interactive) {
19
49
  render();
20
- timer = setInterval(render, 80);
21
- if (rotateLines && rotateLines.length > 1) {
50
+ timer = setInterval(render, FRAME_MS);
51
+ if (lines.length > 1) {
22
52
  lineTimer = setInterval(() => {
23
- lineIdx = (lineIdx + 1) % rotateLines.length;
24
- text = rotateLines[lineIdx];
25
- }, 2000);
53
+ lineIdx = (lineIdx + 1) % lines.length;
54
+ }, ROTATE_MS);
26
55
  }
27
56
  }
28
57
  else {
29
- process.stderr.write(`${brand()} ${muted(text)}\n`);
58
+ printStatic();
30
59
  }
31
- return {
60
+ const handle = {
32
61
  update(next) {
33
- text = next;
62
+ label = next;
63
+ if (!interactive)
64
+ printStatic();
65
+ },
66
+ setLines(next) {
67
+ if (next.length === 0)
68
+ return;
69
+ lines = rotateOrder(next);
70
+ lineIdx = 0;
71
+ },
72
+ detail(text) {
73
+ detailText = text && text.trim() ? text.trim() : null;
34
74
  if (!interactive)
35
- process.stderr.write(`${brand()} ${muted(text)}\n`);
75
+ printStatic();
36
76
  },
37
- succeed(next) {
38
- this.stop();
39
- process.stderr.write(`${brand()} ${ok(next)}\n`);
77
+ succeed(text) {
78
+ handle.stop();
79
+ process.stderr.write(`${brand()} ${ok(text)}${dim(` ${formatDuration(elapsedSeconds())}`)}\n`);
40
80
  },
41
- fail(next) {
42
- this.stop();
43
- process.stderr.write(`${brand()} ${fail(next)}\n`);
81
+ fail(text) {
82
+ handle.stop();
83
+ process.stderr.write(`${brand()} ${fail(text)}\n`);
44
84
  },
45
85
  stop() {
46
86
  if (stopped)
@@ -54,4 +94,47 @@ export function startSpinner(initial, rotateLines) {
54
94
  process.stderr.write("\r\x1b[2K");
55
95
  },
56
96
  };
97
+ return handle;
98
+ }
99
+ function formatDuration(seconds) {
100
+ if (seconds < 10)
101
+ return `${seconds.toFixed(1)}s`;
102
+ if (seconds < 60)
103
+ return `${Math.round(seconds)}s`;
104
+ const mins = Math.floor(seconds / 60);
105
+ return `${mins}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
106
+ }
107
+ /** Keep the first line stable, shuffle the rest so reruns feel different. */
108
+ function rotateOrder(lines) {
109
+ if (lines.length < 3)
110
+ return [...lines];
111
+ const [head, ...tail] = lines;
112
+ for (let i = tail.length - 1; i > 0; i--) {
113
+ const j = Math.floor(Math.random() * (i + 1));
114
+ [tail[i], tail[j]] = [tail[j], tail[i]];
115
+ }
116
+ return [head, ...tail];
117
+ }
118
+ /** Trim to the terminal width, ignoring ANSI escapes when measuring. */
119
+ function truncate(line) {
120
+ const columns = process.stderr.columns ?? 0;
121
+ if (!columns || columns < 20)
122
+ return line;
123
+ const max = columns - 1;
124
+ let visible = 0;
125
+ let i = 0;
126
+ while (i < line.length) {
127
+ if (line[i] === "\x1b") {
128
+ const end = line.indexOf("m", i);
129
+ if (end === -1)
130
+ break;
131
+ i = end + 1;
132
+ continue;
133
+ }
134
+ if (visible === max)
135
+ return `${line.slice(0, i)}…\x1b[0m`;
136
+ visible++;
137
+ i++;
138
+ }
139
+ return line;
57
140
  }
@@ -3,4 +3,5 @@ export declare function accent(text: string): string;
3
3
  export declare function ok(text: string): string;
4
4
  export declare function fail(text: string): string;
5
5
  export declare function muted(text: string): string;
6
+ export declare function dim(text: string): string;
6
7
  export declare function isInteractive(): boolean;
package/dist/ui/theme.js CHANGED
@@ -14,6 +14,9 @@ export function fail(text) {
14
14
  export function muted(text) {
15
15
  return pc.dim(text);
16
16
  }
17
+ export function dim(text) {
18
+ return pc.dim(text);
19
+ }
17
20
  export function isInteractive() {
18
21
  return Boolean(process.stderr.isTTY) && process.env.CI !== "true";
19
22
  }
@@ -2,6 +2,7 @@ import { execa } from "execa";
2
2
  import { platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { resolveExecutable, ToolNotFoundError, } from "../tools.js";
5
+ import { clampViewport } from "./viewport.js";
5
6
  const TIMEOUT_MS = 30_000;
6
7
  export class ChromeError extends Error {
7
8
  constructor(message) {
@@ -9,7 +10,16 @@ export class ChromeError extends Error {
9
10
  this.name = "ChromeError";
10
11
  }
11
12
  }
13
+ let cachedChromePath = null;
12
14
  export function resolveChrome(env = process.env) {
15
+ if (env === process.env && cachedChromePath)
16
+ return cachedChromePath;
17
+ const path = lookupChrome(env);
18
+ if (env === process.env)
19
+ cachedChromePath = path;
20
+ return path;
21
+ }
22
+ function lookupChrome(env) {
13
23
  const local = env.LOCALAPPDATA ?? "";
14
24
  const pf = env.ProgramFiles ?? "C:\\Program Files";
15
25
  const pf86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
@@ -55,13 +65,22 @@ export function resolveChrome(env = process.env) {
55
65
  }
56
66
  export async function screenshotHtml(options) {
57
67
  const chrome = options.chromePath ?? resolveChrome();
58
- const width = options.width ?? 1280;
59
- const height = options.height ?? 900;
68
+ const { width, height } = clampViewport({
69
+ width: options.width,
70
+ height: options.height,
71
+ });
60
72
  try {
61
73
  await execa(chrome, [
62
74
  "--headless=new",
63
75
  "--disable-gpu",
64
76
  "--hide-scrollbars",
77
+ "--no-first-run",
78
+ "--no-default-browser-check",
79
+ "--disable-extensions",
80
+ "--disable-background-networking",
81
+ "--disable-sync",
82
+ "--disable-dev-shm-usage",
83
+ "--force-color-profile=srgb",
65
84
  `--window-size=${width},${height}`,
66
85
  `--screenshot=${options.outPath}`,
67
86
  options.url,
@@ -0,0 +1,29 @@
1
+ import type { ChangeItem, ChangeSummary } from "../schema.js";
2
+ import type { Provider } from "../providers/index.js";
3
+ export declare const MAX_VISUAL_TOPICS = 5;
4
+ export type VisualTopic = {
5
+ id: number;
6
+ title: string;
7
+ items: ChangeItem[];
8
+ files: string[];
9
+ };
10
+ /**
11
+ * Topic grouping, cheapest source first:
12
+ * 1. visual_topics returned by the summarize call (free — no extra request)
13
+ * 2. a dedicated cheap-model call
14
+ * 3. file-overlap union-find
15
+ * Same-theme items (one feature across many files) → one topic.
16
+ * Truly unrelated domains → separate topics.
17
+ */
18
+ export declare function clusterVisualTopics(summary: ChangeSummary, options?: {
19
+ provider?: Provider;
20
+ model?: string;
21
+ maxTopics?: number;
22
+ log?: (msg: string) => void;
23
+ }): Promise<VisualTopic[]>;
24
+ /** File-overlap union-find (fallback). Exported for tests. */
25
+ export declare function clusterByFileOverlap(summary: ChangeSummary): VisualTopic[];
26
+ /** Merge the smallest topics until at most `limit` remain. */
27
+ export declare function capTopics(topics: VisualTopic[], limit?: number): VisualTopic[];
28
+ /** Build topics from model JSON; null if invalid. Exported for tests. */
29
+ export declare function topicsFromIntentJson(summary: ChangeSummary, raw: unknown): VisualTopic[] | null;