@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
@@ -1,7 +1,10 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  export declare const FAKE_SUMMARY: ChangeSummary;
3
- export declare const FAKE_BEFORE_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0;color:#444\">Total: $48.00</p>\n</main></body></html>";
4
- export declare const FAKE_AFTER_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0 0 8px;color:#444\">Total: $48.00</p>\n <p style=\"margin:0;color:#0a7\">Refund status: pending</p>\n</main></body></html>";
3
+ export declare const FAKE_MOCKUP_CSS = ".page{padding:32px;background:var(--fq-surface)}\n.card{max-width:340px;padding:24px;background:var(--fq-surface-alt);border:1px solid var(--fq-border);border-radius:var(--fq-radius)}\n.card h1{margin:0 0 8px;font-family:var(--fq-font-display);font-size:20px}\n.card p{margin:0 0 8px;color:var(--fq-text-muted)}\n.card p.status{margin:0;color:var(--fq-positive)}";
4
+ export declare const FAKE_BEFORE_BODY = "<div class=\"page\"><div class=\"card\"><h1>Order #1042</h1><p>Total: $48.00</p></div></div>";
5
+ export declare const FAKE_AFTER_BODY = "<div class=\"page\"><div class=\"card\"><h1>Order #1042</h1><p>Total: $48.00</p><p class=\"status\">Refund status: pending</p></div></div>";
6
+ export declare const FAKE_DIAGRAM_CSS = ".cols{display:flex;gap:24px}\n.col{flex:1;padding:16px;border:1px solid var(--fq-border);border-radius:var(--fq-radius)}\n.step{padding:8px 12px;margin-bottom:8px;background:var(--fq-surface-alt);border-radius:var(--fq-radius-sm)}\n.step--new{background:var(--fq-accent-soft);color:var(--fq-accent)}";
7
+ export declare const FAKE_DIAGRAM_BODY = "<div class=\"cols\"><div class=\"col\"><div class=\"step\">Fetch order</div><div class=\"step\">Return payload</div></div><div class=\"col\"><div class=\"step\">Fetch order</div><div class=\"step step--new\">Attach refund status</div><div class=\"step\">Return payload</div></div></div>";
5
8
  export type CompleteOptions = {
6
9
  model?: string;
7
10
  };
@@ -18,25 +18,36 @@ export const FAKE_SUMMARY = {
18
18
  ],
19
19
  breaking_changes: [],
20
20
  visual_notes: "Show a simple status badge on the order card.",
21
+ visual_topics: [{ title: "Refund status on orders", item_indices: [0, 1] }],
21
22
  };
22
- export const FAKE_BEFORE_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
23
- <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
24
- <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
25
- <p style="margin:0;color:#444">Total: $48.00</p>
26
- </main></body></html>`;
27
- export const FAKE_AFTER_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
28
- <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
29
- <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
30
- <p style="margin:0 0 8px;color:#444">Total: $48.00</p>
31
- <p style="margin:0;color:#0a7">Refund status: pending</p>
32
- </main></body></html>`;
23
+ export const FAKE_MOCKUP_CSS = `.page{padding:32px;background:var(--fq-surface)}
24
+ .card{max-width:340px;padding:24px;background:var(--fq-surface-alt);border:1px solid var(--fq-border);border-radius:var(--fq-radius)}
25
+ .card h1{margin:0 0 8px;font-family:var(--fq-font-display);font-size:20px}
26
+ .card p{margin:0 0 8px;color:var(--fq-text-muted)}
27
+ .card p.status{margin:0;color:var(--fq-positive)}`;
28
+ export const FAKE_BEFORE_BODY = `<div class="page"><div class="card"><h1>Order #1042</h1><p>Total: $48.00</p></div></div>`;
29
+ export const FAKE_AFTER_BODY = `<div class="page"><div class="card"><h1>Order #1042</h1><p>Total: $48.00</p><p class="status">Refund status: pending</p></div></div>`;
30
+ export const FAKE_DIAGRAM_CSS = `.cols{display:flex;gap:24px}
31
+ .col{flex:1;padding:16px;border:1px solid var(--fq-border);border-radius:var(--fq-radius)}
32
+ .step{padding:8px 12px;margin-bottom:8px;background:var(--fq-surface-alt);border-radius:var(--fq-radius-sm)}
33
+ .step--new{background:var(--fq-accent-soft);color:var(--fq-accent)}`;
34
+ export const FAKE_DIAGRAM_BODY = `<div class="cols"><div class="col"><div class="step">Fetch order</div><div class="step">Return payload</div></div><div class="col"><div class="step">Fetch order</div><div class="step step--new">Attach refund status</div><div class="step">Return payload</div></div></div>`;
33
35
  export async function complete(prompt, _options = {}) {
34
- if (prompt.includes("before_html") || prompt.includes("feasible")) {
36
+ if (prompt.includes("before_body")) {
35
37
  return JSON.stringify({
36
38
  feasible: true,
37
- before_html: FAKE_BEFORE_HTML,
38
- after_html: FAKE_AFTER_HTML,
39
- viewport: { width: 900, height: 700 },
39
+ css: FAKE_MOCKUP_CSS,
40
+ before_body: FAKE_BEFORE_BODY,
41
+ after_body: FAKE_AFTER_BODY,
42
+ stage_width: 620,
43
+ });
44
+ }
45
+ if (prompt.includes("flowchart")) {
46
+ return JSON.stringify({
47
+ feasible: true,
48
+ css: FAKE_DIAGRAM_CSS,
49
+ body: FAKE_DIAGRAM_BODY,
50
+ stage_width: 900,
40
51
  });
41
52
  }
42
53
  return JSON.stringify(FAKE_SUMMARY);
@@ -19,4 +19,4 @@ export declare function detectProviders(): Promise<{
19
19
  }>;
20
20
  export declare function resolveProvider(options?: ResolveProviderOptions): Promise<Provider>;
21
21
  export declare function getProvider(name: ProviderName): Provider;
22
- export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
22
+ export { FAKE_SUMMARY, FAKE_MOCKUP_CSS, FAKE_BEFORE_BODY, FAKE_AFTER_BODY, FAKE_DIAGRAM_CSS, FAKE_DIAGRAM_BODY, } from "./fake.js";
@@ -37,4 +37,4 @@ export function getProvider(name) {
37
37
  return { name, complete: fake.complete };
38
38
  }
39
39
  }
40
- export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
40
+ export { FAKE_SUMMARY, FAKE_MOCKUP_CSS, FAKE_BEFORE_BODY, FAKE_AFTER_BODY, FAKE_DIAGRAM_CSS, FAKE_DIAGRAM_BODY, } from "./fake.js";
package/dist/schema.d.ts CHANGED
@@ -7,18 +7,32 @@ export declare const ChangeItemSchema: z.ZodObject<{
7
7
  why_it_matters: z.ZodOptional<z.ZodString>;
8
8
  files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
9
9
  }, "strip", z.ZodTypeAny, {
10
+ description: string;
10
11
  files: string[];
11
12
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
12
13
  title: string;
13
- description: string;
14
14
  why_it_matters?: string | undefined;
15
15
  }, {
16
+ description: string;
16
17
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
17
18
  title: string;
18
- description: string;
19
19
  files?: string[] | undefined;
20
20
  why_it_matters?: string | undefined;
21
21
  }>;
22
+ /**
23
+ * Grouping of items into visual topics, returned by the same call that writes
24
+ * the summary so the visual pipeline does not need its own round-trip.
25
+ */
26
+ export declare const VisualTopicSchema: z.ZodObject<{
27
+ title: z.ZodString;
28
+ item_indices: z.ZodArray<z.ZodNumber, "many">;
29
+ }, "strip", z.ZodTypeAny, {
30
+ title: string;
31
+ item_indices: number[];
32
+ }, {
33
+ title: string;
34
+ item_indices: number[];
35
+ }>;
22
36
  export declare const ChangeSummarySchema: z.ZodObject<{
23
37
  headline: z.ZodString;
24
38
  overview: z.ZodString;
@@ -29,46 +43,65 @@ export declare const ChangeSummarySchema: z.ZodObject<{
29
43
  why_it_matters: z.ZodOptional<z.ZodString>;
30
44
  files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
31
45
  }, "strip", z.ZodTypeAny, {
46
+ description: string;
32
47
  files: string[];
33
48
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
34
49
  title: string;
35
- description: string;
36
50
  why_it_matters?: string | undefined;
37
51
  }, {
52
+ description: string;
38
53
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
39
54
  title: string;
40
- description: string;
41
55
  files?: string[] | undefined;
42
56
  why_it_matters?: string | undefined;
43
57
  }>, "many">;
44
58
  breaking_changes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
45
59
  visual_notes: z.ZodOptional<z.ZodString>;
60
+ visual_topics: z.ZodOptional<z.ZodArray<z.ZodObject<{
61
+ title: z.ZodString;
62
+ item_indices: z.ZodArray<z.ZodNumber, "many">;
63
+ }, "strip", z.ZodTypeAny, {
64
+ title: string;
65
+ item_indices: number[];
66
+ }, {
67
+ title: string;
68
+ item_indices: number[];
69
+ }>, "many">>;
46
70
  }, "strip", z.ZodTypeAny, {
47
71
  headline: string;
48
72
  overview: string;
49
73
  items: {
74
+ description: string;
50
75
  files: string[];
51
76
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
52
77
  title: string;
53
- description: string;
54
78
  why_it_matters?: string | undefined;
55
79
  }[];
56
80
  breaking_changes: string[];
57
81
  visual_notes?: string | undefined;
82
+ visual_topics?: {
83
+ title: string;
84
+ item_indices: number[];
85
+ }[] | undefined;
58
86
  }, {
59
87
  headline: string;
60
88
  overview: string;
61
89
  items: {
90
+ description: string;
62
91
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
63
92
  title: string;
64
- description: string;
65
93
  files?: string[] | undefined;
66
94
  why_it_matters?: string | undefined;
67
95
  }[];
68
96
  breaking_changes?: string[] | undefined;
69
97
  visual_notes?: string | undefined;
98
+ visual_topics?: {
99
+ title: string;
100
+ item_indices: number[];
101
+ }[] | undefined;
70
102
  }>;
71
103
  export type ChangeSummary = z.infer<typeof ChangeSummarySchema>;
72
104
  export type ChangeItem = z.infer<typeof ChangeItemSchema>;
105
+ export type VisualTopicHint = z.infer<typeof VisualTopicSchema>;
73
106
  /** Compact schema description embedded in AI prompts. */
74
- export declare const CHANGE_SUMMARY_SCHEMA_PROMPT = "{\n \"headline\": \"string <=140 \u2014 one-line summary (PR title)\",\n \"overview\": \"string \u2014 1-3 sentences in requested tone\",\n \"items\": [{\n \"category\": \"feature|fix|improvement|refactor|chore|docs|perf|security\",\n \"title\": \"string <=120\",\n \"description\": \"string\",\n \"why_it_matters\": \"string (optional; required for client tone)\",\n \"files\": [\"string\"]\n }],\n \"breaking_changes\": [\"string\"],\n \"visual_notes\": \"string (optional)\"\n}";
107
+ export declare const CHANGE_SUMMARY_SCHEMA_PROMPT = "{\n \"headline\": \"string <=140 \u2014 one-line summary (PR title)\",\n \"overview\": \"string \u2014 1-3 sentences in requested tone\",\n \"items\": [{\n \"category\": \"feature|fix|improvement|refactor|chore|docs|perf|security\",\n \"title\": \"string <=120\",\n \"description\": \"string\",\n \"why_it_matters\": \"string (optional; required for client tone)\",\n \"files\": [\"string\"]\n }],\n \"breaking_changes\": [\"string\"],\n \"visual_notes\": \"string (optional)\",\n \"visual_topics\": [{\n \"title\": \"string <=80 \u2014 human topic name\",\n \"item_indices\": [0]\n }]\n}";
package/dist/schema.js CHANGED
@@ -16,12 +16,21 @@ export const ChangeItemSchema = z.object({
16
16
  why_it_matters: z.string().min(1).optional(),
17
17
  files: z.array(z.string()).default([]),
18
18
  });
19
+ /**
20
+ * Grouping of items into visual topics, returned by the same call that writes
21
+ * the summary so the visual pipeline does not need its own round-trip.
22
+ */
23
+ export const VisualTopicSchema = z.object({
24
+ title: z.string().min(1).max(80),
25
+ item_indices: z.array(z.number().int().nonnegative()).min(1),
26
+ });
19
27
  export const ChangeSummarySchema = z.object({
20
28
  headline: z.string().min(1).max(140),
21
29
  overview: z.string().min(1),
22
30
  items: z.array(ChangeItemSchema).min(1),
23
31
  breaking_changes: z.array(z.string()).default([]),
24
32
  visual_notes: z.string().min(1).optional(),
33
+ visual_topics: z.array(VisualTopicSchema).optional(),
25
34
  });
26
35
  /** Compact schema description embedded in AI prompts. */
27
36
  export const CHANGE_SUMMARY_SCHEMA_PROMPT = `{
@@ -35,5 +44,9 @@ export const CHANGE_SUMMARY_SCHEMA_PROMPT = `{
35
44
  "files": ["string"]
36
45
  }],
37
46
  "breaking_changes": ["string"],
38
- "visual_notes": "string (optional)"
47
+ "visual_notes": "string (optional)",
48
+ "visual_topics": [{
49
+ "title": "string <=80 — human topic name",
50
+ "item_indices": [0]
51
+ }]
39
52
  }`;
package/dist/summarize.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { extractJson } from "./extract-json.js";
2
2
  import { CHANGE_SUMMARY_SCHEMA_PROMPT, ChangeSummarySchema, } from "./schema.js";
3
+ import { MAX_VISUAL_TOPICS } from "./visual/cluster.js";
3
4
  export class SummarizeError extends Error {
4
5
  exitCode = 2;
5
6
  constructor(message) {
@@ -59,6 +60,12 @@ Rules:
59
60
  - Report only what the diff shows. Do not invent work.
60
61
  - ${toneRules}
61
62
  ${convention}
63
+ visual_topics — how the items get grouped into before/after visuals:
64
+ - Every item index must appear in exactly one topic. Use 0-based indices into your own items array.
65
+ - If all items advance the SAME feature or story, return exactly ONE topic containing every index, even across many files, tests, docs and plumbing.
66
+ - Only split when a reviewer would call them separate product changes (say, a UI redesign AND an unrelated billing API).
67
+ - At most ${MAX_VISUAL_TOPICS} topics. Fewer is better; one is right when unsure.
68
+
62
69
  Schema:
63
70
  ${CHANGE_SUMMARY_SCHEMA_PROMPT}
64
71
 
@@ -1,2 +1,3 @@
1
1
  export { createUi, type Ui } from "./logger.js";
2
+ export { labelFor, linesFor, type LineBank } from "./lines.js";
2
3
  export { brand, isInteractive } from "./theme.js";
package/dist/ui/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createUi } from "./logger.js";
2
+ export { labelFor, linesFor } from "./lines.js";
2
3
  export { brand, isInteractive } from "./theme.js";
@@ -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;