diffing 0.18.2 → 0.18.4

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.
@@ -1,6 +1,6 @@
1
1
  import { f as getProjectStorageDir, h as getRepoRoot } from "./git-BGj31GX-.mjs";
2
2
  import { i as loadSettings, n as formatModeLabel, s as saveSettings } from "./settings-DIa-3C8o.mjs";
3
- import { f as slugifyScreenId, r as formatPlanReview, t as formatMockupReview } from "./mockup-format-Dzc3e_10.mjs";
3
+ import { a as formatPlanReview, m as slugifyScreenId, r as formatMockupReview, t as formatSubmitHints } from "./mockup-lint-BRjhxXSw.mjs";
4
4
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
5
5
  import { u as resolveActiveServerLock, x as SESSION_TOKEN_HEADER } from "./server-lock-ACw2cKDI.mjs";
6
6
  import { i as reviewSessionUrl, t as appendSessionToken } from "./session-url-CyBpcLAH.mjs";
@@ -1146,6 +1146,13 @@ function warnMockupPathsInRepo(paths) {
1146
1146
  if (abs === root || abs.startsWith(rootPrefix)) console.error(`Warning: ${abs} is inside the consumer repo. Never write diffing mockups into the working tree. Submit HTML via stdin or MCP submit_mockup({ html }), or stage files under ${storage}/mockup-sources/.`);
1147
1147
  }
1148
1148
  }
1149
+ function printMockupHints(payload) {
1150
+ if (!payload || typeof payload !== "object") return;
1151
+ const hints = payload.hints;
1152
+ if (!hints?.length) return;
1153
+ const text = formatSubmitHints(hints).trim();
1154
+ if (text) console.error(text);
1155
+ }
1149
1156
  async function mockupSubmit(args) {
1150
1157
  const { values, positionals } = parseArgs({
1151
1158
  args,
@@ -1218,6 +1225,7 @@ async function mockupSubmit(args) {
1218
1225
  const mockup = await submitRes.json();
1219
1226
  console.error(`Submitted mockup ${mockup.id} (v${mockup.version}) — review at ${appendSessionToken(`${base}/mockup/${mockup.id}`, activeAuthToken)}`);
1220
1227
  if (mockup.sourcePath) console.error(`Source path: ${mockup.sourcePath}`);
1228
+ printMockupHints(mockup);
1221
1229
  if (!values.wait) {
1222
1230
  console.error(CLI_MOCKUP_SUBMIT_PARK_HINT);
1223
1231
  process.stdout.write(mockup.id + "\n");
@@ -1579,6 +1587,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1579
1587
  }
1580
1588
  const mockup = await res.json();
1581
1589
  console.error(`Upserted screen ${screenId} on ${mockupId} → v${mockup.version} (${mockup.screens.length} screens).`);
1590
+ printMockupHints(mockup);
1582
1591
  return EXIT_OK;
1583
1592
  }
1584
1593
  if (action === "remove") {
@@ -1616,6 +1625,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1616
1625
  }
1617
1626
  const data = await res.json();
1618
1627
  console.error(`Replaced region "${region}" on ${screenId} (${data.occurrences} match(es)) → v${data.mockup.version}.`);
1628
+ printMockupHints(data.mockup);
1619
1629
  return EXIT_OK;
1620
1630
  }
1621
1631
  const text = values.text;
@@ -1640,6 +1650,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1640
1650
  }
1641
1651
  const data = await res.json();
1642
1652
  console.error(`Patched ${screenId} on ${mockupId} (${data.occurrences} exact match(es)) → v${data.mockup.version}.`);
1653
+ printMockupHints(data.mockup);
1643
1654
  return EXIT_OK;
1644
1655
  }
1645
1656
  async function mockupThreadsCmd(args) {
@@ -1792,6 +1803,7 @@ Staging files, if needed, go under ~/.diffing/<repo>/mockup-sources/ only.
1792
1803
  One state per screen: never tabs/accordions/toggles/modals/JS content-swapping — each variant is a separate screen.
1793
1804
 
1794
1805
  submit [-] [--title T] [--screen id=path]... [--id ID] [--model M] [--wait]
1806
+ [--mode fragment|document] [--system ID] [--plan-id ID]
1795
1807
  # or a path already under ~/.diffing/.../mockup-sources/
1796
1808
  await [--timeout N]
1797
1809
  list [--json]
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as parseDiffOptions, i as validateEnvironment, l as printHelp, o as buildTuiGitDiffArgs, r as runTerminalDiff, s as intoShowMode, t as startServer } from "./server-BqCIWbuc.mjs";
2
+ import { a as buildTuiGitDiffArgs, c as printHelp, n as runTerminalDiff, o as intoShowMode, r as validateEnvironment, s as parseDiffOptions, t as startServer } from "./server-CH-UPGFR.mjs";
3
3
  import { h as getRepoRoot, r as getBranchName } from "./git-BGj31GX-.mjs";
4
4
  import { i as loadSettings } from "./settings-DIa-3C8o.mjs";
5
5
  import { _ as generateSessionToken, b as isWildcardBindHost, c as removeServerLockIfOwned, p as writeServerLock, r as diffScopeKey, t as acquireServerStartupLease, u as resolveActiveServerLock } from "./server-lock-ACw2cKDI.mjs";
@@ -563,11 +563,11 @@ See docs/cli.md §5 (MCP) for the full tool table.`);
563
563
  console.error("diffing mcp: --repo must be an absolute path");
564
564
  process.exit(5);
565
565
  }
566
- const { startMcpServer } = await import("./mcp-ClEsxza5.mjs");
566
+ const { startMcpServer } = await import("./mcp-45VPdWIr.mjs");
567
567
  await startMcpServer({ repoPath });
568
568
  await new Promise(() => {});
569
569
  }
570
- const { runSubcommand } = await import("./cli-agent-Cpusc3Fl.mjs");
570
+ const { runSubcommand } = await import("./cli-agent-DV5ZhMX1.mjs");
571
571
  const code = await runSubcommand(args[0], args.slice(1));
572
572
  process.exit(code);
573
573
  }
@@ -711,6 +711,7 @@ startupLease?.release();
711
711
  startupLease = null;
712
712
  const localUrl = appendSessionToken(`http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}${prMode ? "/gh/pr" : ""}`, authToken ?? void 0);
713
713
  console.log(`diffing server running at ${localUrl}`);
714
+ console.error(`DIFFING_READY ${localUrl} mode=${prMode ? "gh-pr" : "web"} pid=${process.pid}`);
714
715
  if (openModulePromise) try {
715
716
  const settings = loadSettings();
716
717
  const openUrl = appendSessionToken(`http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}${prMode ? "/gh/pr" : ""}`, authToken ?? void 0);
@@ -1,5 +1,5 @@
1
- import { a as buildGitDiffArgs, c as parseDiffOptions, n as formatSubmitHints, t as startServer } from "./server-BqCIWbuc.mjs";
2
- import { r as formatPlanReview, t as formatMockupReview } from "./mockup-format-Dzc3e_10.mjs";
1
+ import { i as buildGitDiffArgs, s as parseDiffOptions, t as startServer } from "./server-CH-UPGFR.mjs";
2
+ import { a as formatPlanReview, r as formatMockupReview, t as formatSubmitHints } from "./mockup-lint-BRjhxXSw.mjs";
3
3
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
4
4
  import { _ as generateSessionToken, c as removeServerLockIfOwned, i as isLockAlive, p as writeServerLock, r as diffScopeKey, s as removeServerLock, t as acquireServerStartupLease, u as resolveActiveServerLock, x as SESSION_TOKEN_HEADER, y as isLoopbackHost } from "./server-lock-ACw2cKDI.mjs";
5
5
  import { i as reviewSessionUrl, n as joinSessionApiUrl, r as reviewSessionBaseUrl, t as appendSessionToken } from "./session-url-CyBpcLAH.mjs";
@@ -326,4 +326,155 @@ function formatComment(comment, fallbackVersion, includeContext) {
326
326
  return parts.join("\n");
327
327
  }
328
328
  //#endregion
329
- export { MOCKUP_MAX_SCREEN_BYTES as a, defaultScreenLabel as c, normalizeSubmitScreens as d, slugifyScreenId as f, sectionTitleForLine as i, isMockupViewport as l, extractPlanLines as n, MOCKUP_SCREEN_ID_RE as o, formatPlanReview as r, commentViewport as s, formatMockupReview as t, normalizeMockupViewport as u };
329
+ //#region src/lib/mockup-lint.ts
330
+ const RULES = [
331
+ {
332
+ label: "tabs",
333
+ re: /role\s*=\s*["']tab(list|panel)?["']/i
334
+ },
335
+ {
336
+ label: "tabs",
337
+ re: /aria-selected\s*=/i
338
+ },
339
+ {
340
+ label: "tabs",
341
+ re: /data-(?:bs-)?toggle\s*=\s*["'](?:tab|pill)["']/i
342
+ },
343
+ {
344
+ label: "tabs",
345
+ re: /\bdata-tabs?\s*=/i
346
+ },
347
+ {
348
+ label: "accordion",
349
+ re: /<details[\s>]/i
350
+ },
351
+ {
352
+ label: "accordion",
353
+ re: /data-(?:bs-)?toggle\s*=\s*["']collapse["']/i
354
+ },
355
+ {
356
+ label: "accordion",
357
+ re: /class\s*=\s*["'][^"']*\baccordion\b/i
358
+ },
359
+ {
360
+ label: "modal",
361
+ re: /role\s*=\s*["'](?:alert)?dialog["']/i
362
+ },
363
+ {
364
+ label: "modal",
365
+ re: /data-(?:bs-)?toggle\s*=\s*["']modal["']/i
366
+ },
367
+ {
368
+ label: "modal",
369
+ re: /class\s*=\s*["'][^"']*\bmodal\b/i
370
+ },
371
+ {
372
+ label: "dropdown",
373
+ re: /role\s*=\s*["']menu(?:bar)?["']/i
374
+ },
375
+ {
376
+ label: "dropdown",
377
+ re: /data-(?:bs-)?toggle\s*=\s*["']dropdown["']/i
378
+ },
379
+ {
380
+ label: "dropdown",
381
+ re: /class\s*=\s*["'][^"']*\bdropdown\b/i
382
+ },
383
+ {
384
+ label: "toggle",
385
+ re: /role\s*=\s*["']switch["']/i
386
+ },
387
+ {
388
+ label: "toggle",
389
+ re: /class\s*=\s*["'][^"']*\b(?:toggle|switch)\b/i
390
+ }
391
+ ];
392
+ const ORDERED_LABELS = [
393
+ "tabs",
394
+ "accordion",
395
+ "modal",
396
+ "dropdown",
397
+ "toggle"
398
+ ];
399
+ /** Find the in-page state UI patterns present in one screen's HTML. */
400
+ function detectInPageState(html) {
401
+ const found = /* @__PURE__ */ new Set();
402
+ for (const rule of RULES) {
403
+ if (found.has(rule.label)) continue;
404
+ if (rule.re.test(html)) found.add(rule.label);
405
+ }
406
+ return ORDERED_LABELS.filter((label) => found.has(label));
407
+ }
408
+ const STYLE_RULES = [
409
+ {
410
+ label: "tailwind-cdn",
411
+ re: /cdn\.tailwindcss\.com/i
412
+ },
413
+ {
414
+ label: "tailwind-cdn",
415
+ re: /(?:unpkg\.com|cdn\.jsdelivr\.net|esm\.sh)\/(?:npm\/)?tailwindcss/i
416
+ },
417
+ {
418
+ label: "google-fonts",
419
+ re: /fonts\.googleapis\.com/i
420
+ },
421
+ {
422
+ label: "google-fonts",
423
+ re: /fonts\.gstatic\.com/i
424
+ }
425
+ ];
426
+ const GENERIC_FONT_RE = /font-family\s*:\s*[^;{]*\bInter\b/i;
427
+ const GENERIC_INDIGO_RE = /#(?:4f46e5|4338ca|6366f1|818cf8|7c3aed|8b5cf6|a78bfa)\b|\b(?:indigo|violet)-(?:[4-9]00|950)\b/i;
428
+ /** Off-brand / generic-AI styling that should not ship as the product look. */
429
+ function detectGenericStyle(html) {
430
+ const found = /* @__PURE__ */ new Set();
431
+ for (const rule of STYLE_RULES) {
432
+ if (found.has(rule.label)) continue;
433
+ if (rule.re.test(html)) found.add(rule.label);
434
+ }
435
+ if (GENERIC_FONT_RE.test(html) && GENERIC_INDIGO_RE.test(html)) found.add("generic-palette");
436
+ return [
437
+ "tailwind-cdn",
438
+ "google-fonts",
439
+ "generic-palette"
440
+ ].filter((label) => found.has(label));
441
+ }
442
+ /** Build submit-time hints for a set of screens (empty when all clean). */
443
+ function lintMockupScreens(screens) {
444
+ const hints = [];
445
+ for (const screen of screens) {
446
+ const state = detectInPageState(screen.html);
447
+ if (state.length > 0) hints.push({
448
+ screenId: screen.id,
449
+ kind: "state",
450
+ patterns: state,
451
+ message: `Screen "${screen.id}" contains in-page state UI (${state.join(", ")}). Split each state into its own screen instead.`
452
+ });
453
+ const style = detectGenericStyle(screen.html);
454
+ if (style.length > 0) hints.push({
455
+ screenId: screen.id,
456
+ kind: "style",
457
+ patterns: style,
458
+ message: `Screen "${screen.id}" looks generic (${style.join(", ")}). Drop CDN Tailwind / Google Fonts and do not invent Inter + indigo — match the product.`
459
+ });
460
+ }
461
+ return hints;
462
+ }
463
+ /** Compact submit/revise warning for CLI and MCP. */
464
+ function formatSubmitHints(hints) {
465
+ if (hints.length === 0) return "";
466
+ const state = hints.filter((h) => h.kind !== "style");
467
+ const style = hints.filter((h) => h.kind === "style");
468
+ const lines = [];
469
+ if (state.length > 0) {
470
+ lines.push("⚠️ In-page state UI detected — split each state into its own screen:");
471
+ for (const hint of state) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
472
+ }
473
+ if (style.length > 0) {
474
+ lines.push("⚠️ Generic styling — match the product (no CDN Tailwind, Google Fonts, or Inter+indigo):");
475
+ for (const hint of style) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
476
+ }
477
+ return `\n${lines.join("\n")}`;
478
+ }
479
+ //#endregion
480
+ export { formatPlanReview as a, MOCKUP_SCREEN_ID_RE as c, isMockupViewport as d, normalizeMockupViewport as f, extractPlanLines as i, commentViewport as l, slugifyScreenId as m, lintMockupScreens as n, sectionTitleForLine as o, normalizeSubmitScreens as p, formatMockupReview as r, MOCKUP_MAX_SCREEN_BYTES as s, formatSubmitHints as t, defaultScreenLabel as u };
@@ -1,7 +1,7 @@
1
1
  import { C as listRepoFiles, D as toSafeRelativePath, E as isSafePath, S as isGitRepo, T as revertHunk, _ as getShowDiff, a as getCommitSeriesSummary, b as getUntrackedFilePathsAsync, d as getMergeStatus, f as getProjectStorageDir, h as getRepoRoot, l as getGitDiffAsync, m as getRepoMetadataAsync, o as getCustomGitDiffAsync, s as getFileContent, u as getHunkHistory, x as gitAddFile, y as getTabSizeForFilesAsync } from "./git-BGj31GX-.mjs";
2
2
  import { a as searchFiles, i as searchContent, o as searchSymbols, r as searchAll, s as trackSelection, t as getSearchStatus } from "./search-3R2E5xxr.mjs";
3
3
  import { c as writeJsonAtomically, i as loadSettings, s as saveSettings } from "./settings-DIa-3C8o.mjs";
4
- import { a as MOCKUP_MAX_SCREEN_BYTES, c as defaultScreenLabel, d as normalizeSubmitScreens, i as sectionTitleForLine, l as isMockupViewport, n as extractPlanLines, o as MOCKUP_SCREEN_ID_RE, r as formatPlanReview, s as commentViewport, t as formatMockupReview, u as normalizeMockupViewport } from "./mockup-format-Dzc3e_10.mjs";
4
+ import { a as formatPlanReview, c as MOCKUP_SCREEN_ID_RE, d as isMockupViewport, f as normalizeMockupViewport, i as extractPlanLines, l as commentViewport, n as lintMockupScreens, o as sectionTitleForLine, p as normalizeSubmitScreens, r as formatMockupReview, s as MOCKUP_MAX_SCREEN_BYTES, u as defaultScreenLabel } from "./mockup-lint-BRjhxXSw.mjs";
5
5
  import { a as slugifyDesignId, i as emptyTokens, o as snapshotRevision, r as cloneTokens, s as tokensToCss, t as DEFAULT_DESIGN_SYSTEM_ID } from "./design-system-types-uiP9W2uj.mjs";
6
6
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
7
7
  import { g as createServerAuthMiddleware, h as buildSessionTokenSetCookieValue, v as injectSessionTokenIntoHtml } from "./server-lock-ACw2cKDI.mjs";
@@ -3006,157 +3006,6 @@ function injectMockupProbe(html, opts = {}) {
3006
3006
  return script + html;
3007
3007
  }
3008
3008
  //#endregion
3009
- //#region src/lib/mockup-lint.ts
3010
- const RULES$1 = [
3011
- {
3012
- label: "tabs",
3013
- re: /role\s*=\s*["']tab(list|panel)?["']/i
3014
- },
3015
- {
3016
- label: "tabs",
3017
- re: /aria-selected\s*=/i
3018
- },
3019
- {
3020
- label: "tabs",
3021
- re: /data-(?:bs-)?toggle\s*=\s*["'](?:tab|pill)["']/i
3022
- },
3023
- {
3024
- label: "tabs",
3025
- re: /\bdata-tabs?\s*=/i
3026
- },
3027
- {
3028
- label: "accordion",
3029
- re: /<details[\s>]/i
3030
- },
3031
- {
3032
- label: "accordion",
3033
- re: /data-(?:bs-)?toggle\s*=\s*["']collapse["']/i
3034
- },
3035
- {
3036
- label: "accordion",
3037
- re: /class\s*=\s*["'][^"']*\baccordion\b/i
3038
- },
3039
- {
3040
- label: "modal",
3041
- re: /role\s*=\s*["'](?:alert)?dialog["']/i
3042
- },
3043
- {
3044
- label: "modal",
3045
- re: /data-(?:bs-)?toggle\s*=\s*["']modal["']/i
3046
- },
3047
- {
3048
- label: "modal",
3049
- re: /class\s*=\s*["'][^"']*\bmodal\b/i
3050
- },
3051
- {
3052
- label: "dropdown",
3053
- re: /role\s*=\s*["']menu(?:bar)?["']/i
3054
- },
3055
- {
3056
- label: "dropdown",
3057
- re: /data-(?:bs-)?toggle\s*=\s*["']dropdown["']/i
3058
- },
3059
- {
3060
- label: "dropdown",
3061
- re: /class\s*=\s*["'][^"']*\bdropdown\b/i
3062
- },
3063
- {
3064
- label: "toggle",
3065
- re: /role\s*=\s*["']switch["']/i
3066
- },
3067
- {
3068
- label: "toggle",
3069
- re: /class\s*=\s*["'][^"']*\b(?:toggle|switch)\b/i
3070
- }
3071
- ];
3072
- const ORDERED_LABELS = [
3073
- "tabs",
3074
- "accordion",
3075
- "modal",
3076
- "dropdown",
3077
- "toggle"
3078
- ];
3079
- /** Find the in-page state UI patterns present in one screen's HTML. */
3080
- function detectInPageState(html) {
3081
- const found = /* @__PURE__ */ new Set();
3082
- for (const rule of RULES$1) {
3083
- if (found.has(rule.label)) continue;
3084
- if (rule.re.test(html)) found.add(rule.label);
3085
- }
3086
- return ORDERED_LABELS.filter((label) => found.has(label));
3087
- }
3088
- const STYLE_RULES = [
3089
- {
3090
- label: "tailwind-cdn",
3091
- re: /cdn\.tailwindcss\.com/i
3092
- },
3093
- {
3094
- label: "tailwind-cdn",
3095
- re: /(?:unpkg\.com|cdn\.jsdelivr\.net|esm\.sh)\/(?:npm\/)?tailwindcss/i
3096
- },
3097
- {
3098
- label: "google-fonts",
3099
- re: /fonts\.googleapis\.com/i
3100
- },
3101
- {
3102
- label: "google-fonts",
3103
- re: /fonts\.gstatic\.com/i
3104
- }
3105
- ];
3106
- const GENERIC_FONT_RE = /font-family\s*:\s*[^;{]*\bInter\b/i;
3107
- const GENERIC_INDIGO_RE = /#(?:4f46e5|4338ca|6366f1|818cf8|7c3aed|8b5cf6|a78bfa)\b|\b(?:indigo|violet)-(?:[4-9]00|950)\b/i;
3108
- /** Off-brand / generic-AI styling that should not ship as the product look. */
3109
- function detectGenericStyle(html) {
3110
- const found = /* @__PURE__ */ new Set();
3111
- for (const rule of STYLE_RULES) {
3112
- if (found.has(rule.label)) continue;
3113
- if (rule.re.test(html)) found.add(rule.label);
3114
- }
3115
- if (GENERIC_FONT_RE.test(html) && GENERIC_INDIGO_RE.test(html)) found.add("generic-palette");
3116
- return [
3117
- "tailwind-cdn",
3118
- "google-fonts",
3119
- "generic-palette"
3120
- ].filter((label) => found.has(label));
3121
- }
3122
- /** Build submit-time hints for a set of screens (empty when all clean). */
3123
- function lintMockupScreens(screens) {
3124
- const hints = [];
3125
- for (const screen of screens) {
3126
- const state = detectInPageState(screen.html);
3127
- if (state.length > 0) hints.push({
3128
- screenId: screen.id,
3129
- kind: "state",
3130
- patterns: state,
3131
- message: `Screen "${screen.id}" contains in-page state UI (${state.join(", ")}). Split each state into its own screen instead.`
3132
- });
3133
- const style = detectGenericStyle(screen.html);
3134
- if (style.length > 0) hints.push({
3135
- screenId: screen.id,
3136
- kind: "style",
3137
- patterns: style,
3138
- message: `Screen "${screen.id}" looks generic (${style.join(", ")}). Drop CDN Tailwind / Google Fonts and do not invent Inter + indigo — match the product.`
3139
- });
3140
- }
3141
- return hints;
3142
- }
3143
- /** Compact submit/revise warning for CLI and MCP. */
3144
- function formatSubmitHints(hints) {
3145
- if (hints.length === 0) return "";
3146
- const state = hints.filter((h) => h.kind !== "style");
3147
- const style = hints.filter((h) => h.kind === "style");
3148
- const lines = [];
3149
- if (state.length > 0) {
3150
- lines.push("⚠️ In-page state UI detected — split each state into its own screen:");
3151
- for (const hint of state) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
3152
- }
3153
- if (style.length > 0) {
3154
- lines.push("⚠️ Generic styling — match the product (no CDN Tailwind, Google Fonts, or Inter+indigo):");
3155
- for (const hint of style) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
3156
- }
3157
- return `\n${lines.join("\n")}`;
3158
- }
3159
- //#endregion
3160
3009
  //#region src/lib/design-system.ts
3161
3010
  function newId() {
3162
3011
  return crypto.randomUUID();
@@ -7769,4 +7618,4 @@ async function startServer(options) {
7769
7618
  });
7770
7619
  }
7771
7620
  //#endregion
7772
- export { buildGitDiffArgs as a, parseDiffOptions as c, validateEnvironment as i, printHelp as l, formatSubmitHints as n, buildTuiGitDiffArgs as o, runTerminalDiff as r, intoShowMode as s, startServer as t };
7621
+ export { buildTuiGitDiffArgs as a, printHelp as c, buildGitDiffArgs as i, runTerminalDiff as n, intoShowMode as o, validateEnvironment as r, parseDiffOptions as s, startServer as t };
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Bridges the local-first diffing review loop into pi:
5
5
  * - Structured LLM tools mirroring the diffing MCP surface (status, review
6
- * start, comments, reply/resolve, plan loop, mockup loop, progress,
7
- * sessions, GH PR).
6
+ * start, comments, reply/resolve, plan loop, mockup loop, design system,
7
+ * progress, sessions, GH PR).
8
8
  * - `/diffing` command to open/reuse the review UI for the current repo.
9
9
  * - Footer status showing the active review session.
10
10
  * - Skill self-heal: keeps `~/.agents/skills/diffing*` as symlinks to the
@@ -20,12 +20,9 @@
20
20
  import { spawn } from "node:child_process";
21
21
  import {
22
22
  existsSync,
23
- lstatSync,
24
23
  mkdtempSync,
25
24
  readFileSync,
26
- realpathSync,
27
25
  rmSync,
28
- symlinkSync,
29
26
  writeFileSync,
30
27
  } from "node:fs";
31
28
  import { homedir, tmpdir } from "node:os";
@@ -37,30 +34,19 @@ import type {
37
34
  ExtensionContext,
38
35
  } from "@earendil-works/pi-coding-agent";
39
36
  import { Type } from "typebox";
37
+ import {
38
+ selfHealSkillLinks,
39
+ SKILLS_REL,
40
+ } from "./skill-heal.ts";
40
41
 
41
42
  // ────────────────────────────────────────────────────────────────────────────
42
43
  // Constants
43
44
  // ────────────────────────────────────────────────────────────────────────────
44
45
 
45
- const SKILL_NAMES = [
46
- "diffing",
47
- "diffing-finish-review",
48
- "diffing-mockup-author",
49
- "diffing-mockup-review",
50
- "diffing-plan-review",
51
- "diffing-pr-address",
52
- "diffing-pr-read",
53
- "diffing-release",
54
- "diffing-review",
55
- "diffing-start-review",
56
- ] as const;
57
-
58
46
  const MAX_OUTPUT_BYTES = 48 * 1024;
59
47
  const REVIEW_START_TIMEOUT_MS = 15_000;
60
48
  const REVIEW_START_POLL_MS = 400;
61
49
 
62
- const SKILLS_REL = join(".agents", "skills");
63
-
64
50
  // ────────────────────────────────────────────────────────────────────────────
65
51
  // CLI runner
66
52
  // ────────────────────────────────────────────────────────────────────────────
@@ -208,6 +194,45 @@ async function refreshStatus(ctx: ExtensionContext): Promise<void> {
208
194
  }
209
195
  }
210
196
 
197
+ // ────────────────────────────────────────────────────────────────────────────
198
+ // Verdict notices (herdr-visible)
199
+ //
200
+ // After a review/plan/mockup await returns a real verdict, surface a compact,
201
+ // greppable `DIFFING_VERDICT …` line in the pi pane so a sibling herdr pane can
202
+ // observe review state via `herdr pane read` / `herdr wait output`. We use pi's
203
+ // own UI hooks (notify + widget) rather than `herdr pane send-text`, which would
204
+ // type raw bytes into the pi TUI input box instead of the rendered scrollback.
205
+ // ────────────────────────────────────────────────────────────────────────────
206
+
207
+ interface VerdictNotice {
208
+ kind: "plan" | "mockup" | "review";
209
+ decision: string;
210
+ }
211
+
212
+ function verdictNotice(stderr: string): VerdictNotice | null {
213
+ const plan = stderr.match(/DIFFING_PLAN_DECISION=(\S+)/);
214
+ if (plan) return { kind: "plan", decision: plan[1] };
215
+ const mockup = stderr.match(/DIFFING_MOCKUP_DECISION=(\S+)/);
216
+ if (mockup) return { kind: "mockup", decision: mockup[1] };
217
+ const review = stderr.match(/DIFFING_REVIEW_ROUND=(\S+)/);
218
+ if (review) return { kind: "review", decision: `released round=${review[1]}` };
219
+ return null;
220
+ }
221
+
222
+ function emitVerdictNotice(ctx: ExtensionContext, stderr: string): void {
223
+ const notice = verdictNotice(stderr);
224
+ if (!notice) return;
225
+ const line = `DIFFING_VERDICT ${notice.kind} decision=${notice.decision}`;
226
+ try {
227
+ if (ctx.hasUI) {
228
+ ctx.ui.notify(line, "info");
229
+ ctx.ui.setWidget("diffing-verdict", [line]);
230
+ }
231
+ } catch {
232
+ // UI hooks are best-effort; never break the tool result.
233
+ }
234
+ }
235
+
211
236
  // ────────────────────────────────────────────────────────────────────────────
212
237
  // Canonical skill root + self-heal
213
238
  // ────────────────────────────────────────────────────────────────────────────
@@ -248,35 +273,6 @@ function findCanonicalRoot(cwd: string): string | null {
248
273
  return null;
249
274
  }
250
275
 
251
- /**
252
- * Keep the `~/.agents/skills/diffing*` entries as symlinks to the canonical
253
- * checkout's `.agents/skills`. pi dedupes skills by canonical realpath, so
254
- * symlinked home entries merge silently with the repo's project skills and no
255
- * `[Skill conflicts]` banner appears. Repairs stale real copies left behind by
256
- * `diffing setup skills` / `npx skills add --copy`. Only touches the known
257
- * names in SKILL_NAMES; never deletes anything else.
258
- */
259
- function selfHealSkillLinks(canonical: string): void {
260
- const homeSkills = join(homedir(), ".agents", "skills");
261
- if (!existsSync(homeSkills)) return;
262
- for (const name of SKILL_NAMES) {
263
- const target = join(canonical, SKILLS_REL, name);
264
- if (!existsSync(target)) continue;
265
- const link = join(homeSkills, name);
266
- try {
267
- if (existsSync(link) || lstatSync(link)) {
268
- if (lstatSync(link).isSymbolicLink()) {
269
- const real = realpathSync(link);
270
- if (real === realpathSync(target)) continue; // already correct
271
- }
272
- rmSync(link, { recursive: true, force: true });
273
- }
274
- symlinkSync(target, link, "dir");
275
- } catch {
276
- // best-effort; a failed heal must not break the session
277
- }
278
- }
279
- }
280
276
 
281
277
  // ────────────────────────────────────────────────────────────────────────────
282
278
  // Detached review server start
@@ -340,7 +336,9 @@ export default function (pi: ExtensionAPI) {
340
336
  // Keep the home skill links canonical for every session (idempotent).
341
337
  pi.on("session_start", async (_event, ctx) => {
342
338
  const canonical = findCanonicalRoot(ctx.cwd);
343
- if (canonical) selfHealSkillLinks(canonical);
339
+ if (canonical) {
340
+ selfHealSkillLinks(canonical, join(homedir(), ".agents", "skills"));
341
+ }
344
342
  await refreshStatus(ctx);
345
343
  });
346
344
 
@@ -601,6 +599,7 @@ export default function (pi: ExtensionAPI) {
601
599
  { parked: true, exitCode: result.exitCode },
602
600
  );
603
601
  }
602
+ emitVerdictNotice(ctx, result.stderr);
604
603
  return textResult(describe(result, "diffing await-review"), {
605
604
  parked: false,
606
605
  exitCode: result.exitCode,
@@ -708,6 +707,7 @@ export default function (pi: ExtensionAPI) {
708
707
  { parked: true, exitCode: result.exitCode },
709
708
  );
710
709
  }
710
+ emitVerdictNotice(ctx, result.stderr);
711
711
  return textResult(describe(result, "diffing plan await"), {
712
712
  parked: false,
713
713
  exitCode: result.exitCode,
@@ -825,7 +825,7 @@ export default function (pi: ExtensionAPI) {
825
825
  name: "diffing_mockup_submit",
826
826
  label: "Diffing Mockup Submit",
827
827
  description:
828
- "Submit HTML mockup screen(s) for visual review. Pass a single html body, a file/dir path, or an inline screens array [{id, html}]. Share the URL and park unless asked to wait. Resubmit with mockupId to bump the version and reset the verdict.",
828
+ "Submit HTML mockup screen(s) for visual review. HARD RULE one state per screen: never tabs/accordions/toggles/modals/JS swaps. Pass html, a file/dir already under ~/.diffing/…/mockup-sources/, or screens [{id, html}]. Call diffing_design show first. Share the URL and park unless asked to wait. Resubmit with mockupId to bump the version. Fix any in-page-state or generic-style hints before parking.",
829
829
  parameters: Type.Object({
830
830
  html: Type.Optional(
831
831
  Type.String({ description: "Single-screen HTML body." }),
@@ -854,6 +854,23 @@ export default function (pi: ExtensionAPI) {
854
854
  ),
855
855
  model: Type.Optional(Type.String()),
856
856
  source: Type.Optional(Type.String()),
857
+ mode: Type.Optional(
858
+ StringEnum(["fragment", "document"] as const, {
859
+ description:
860
+ "fragment = body contents wrapped by the published design-system shell. document = full HTML, no wrap. Default: server default.",
861
+ }),
862
+ ),
863
+ designSystem: Type.Optional(
864
+ Type.String({
865
+ description:
866
+ "Design-system id to bind (default system if omitted).",
867
+ }),
868
+ ),
869
+ planId: Type.Optional(
870
+ Type.String({
871
+ description: "Optional plan id this mockup illustrates.",
872
+ }),
873
+ ),
857
874
  }),
858
875
  async execute(_id, params, _signal, _onUpdate, ctx) {
859
876
  const args = ["mockup", "submit"];
@@ -896,6 +913,9 @@ export default function (pi: ExtensionAPI) {
896
913
  if (params.mockupId) args.push("--id", params.mockupId);
897
914
  if (params.model) args.push("--model", params.model);
898
915
  if (params.source) args.push("--source", params.source);
916
+ if (params.mode) args.push("--mode", params.mode);
917
+ if (params.designSystem) args.push("--system", params.designSystem);
918
+ if (params.planId) args.push("--plan-id", params.planId);
899
919
  try {
900
920
  const result = await runDiffing(args, ctx.cwd, { stdin });
901
921
  const stdout = result.stdout.trim();
@@ -941,6 +961,7 @@ export default function (pi: ExtensionAPI) {
941
961
  { parked: true, exitCode: result.exitCode },
942
962
  );
943
963
  }
964
+ emitVerdictNotice(ctx, result.stderr);
944
965
  return textResult(describe(result, "diffing mockup await"), {
945
966
  parked: false,
946
967
  exitCode: result.exitCode,
@@ -1079,12 +1100,15 @@ export default function (pi: ExtensionAPI) {
1079
1100
  name: "diffing_mockup_inspect",
1080
1101
  label: "Diffing Mockup Inspect",
1081
1102
  description:
1082
- "Read compact, bounded mockup data without transferring screen HTML. view=summary (headline stats), comments (paged comment list), comment (one thread), screen (screen source). Filter by comment scope: status, screenId, viewport (desktop|tablet|mobile), version. context=none|anchor|source controls anchor detail. Prefer this over diffing_mockup_show.",
1103
+ "Read compact, bounded mockup data without transferring screen HTML. view=summary (headline stats), comments (paged comment list), comment (one thread), screen (screen source), preview (rendered preview metadata). Filter by comment scope: status, screenId, viewport (desktop|tablet|mobile), version. context=none|anchor|source controls anchor detail. Prefer this over diffing_mockup_show.",
1083
1104
  parameters: Type.Object({
1084
- view: StringEnum(["summary", "comments", "comment", "screen"] as const, {
1085
- description: "What to read. Default: summary.",
1086
- default: "summary",
1087
- }),
1105
+ view: StringEnum(
1106
+ ["summary", "comments", "comment", "screen", "preview"] as const,
1107
+ {
1108
+ description: "What to read. Default: summary.",
1109
+ default: "summary",
1110
+ },
1111
+ ),
1088
1112
  mockupId: Type.Optional(
1089
1113
  Type.String({ description: "Mockup id. Default: latest." }),
1090
1114
  ),
@@ -1152,11 +1176,12 @@ export default function (pi: ExtensionAPI) {
1152
1176
  name: "diffing_mockup_screen",
1153
1177
  label: "Diffing Mockup Screen",
1154
1178
  description:
1155
- "One-screen revision of an HTML mockup. action=upsert adds/replaces a screen (html or file), remove deletes a screen, patch replaces the first exact occurrence of text with replacement. Every success bumps the mockup version; pass expectedVersion to guard racing edits (409 version-mismatch, nothing applied). For multi-screen revisions, resubmit via diffing_mockup_submit with the same mockupId.",
1179
+ "One-screen revision of an HTML mockup. action=upsert adds/replaces a screen (html or file), remove deletes a screen, patch replaces the first exact occurrence of text with replacement, replace-region replaces the inner HTML of the first [data-diffing=region] element (prefer this when the comment has a data-diffing target). Every success bumps the mockup version; pass expectedVersion to guard racing edits (409 version-mismatch, nothing applied). For multi-screen revisions, resubmit via diffing_mockup_submit with the same mockupId.",
1156
1180
  parameters: Type.Object({
1157
- action: StringEnum(["upsert", "remove", "patch"] as const, {
1158
- description: "Revision op.",
1159
- }),
1181
+ action: StringEnum(
1182
+ ["upsert", "remove", "patch", "replace-region"] as const,
1183
+ { description: "Revision op." },
1184
+ ),
1160
1185
  mockupId: Type.String({ description: "Mockup id." }),
1161
1186
  screenId: Type.String({ description: "Screen id." }),
1162
1187
  html: Type.Optional(
@@ -1177,8 +1202,17 @@ export default function (pi: ExtensionAPI) {
1177
1202
  description: "For patch: exact text to replace (first occurrence).",
1178
1203
  }),
1179
1204
  ),
1205
+ region: Type.Optional(
1206
+ Type.String({
1207
+ description:
1208
+ "For replace-region: data-diffing attribute value of the block to replace.",
1209
+ }),
1210
+ ),
1180
1211
  replacement: Type.Optional(
1181
- Type.String({ description: "For patch: replacement text." }),
1212
+ Type.String({
1213
+ description:
1214
+ "For patch: replacement text. For replace-region: new inner HTML.",
1215
+ }),
1182
1216
  ),
1183
1217
  expectedVersion: Type.Optional(
1184
1218
  Type.Number({
@@ -1202,6 +1236,10 @@ export default function (pi: ExtensionAPI) {
1202
1236
  stdin = params.html;
1203
1237
  }
1204
1238
  if (params.label) args.push("--label", params.label);
1239
+ } else if (params.action === "replace-region") {
1240
+ if (params.region) args.push("--region", params.region);
1241
+ if (params.replacement !== undefined)
1242
+ args.push("--replacement", params.replacement);
1205
1243
  } else if (params.action === "patch") {
1206
1244
  if (params.text !== undefined) args.push("--text", params.text);
1207
1245
  if (params.replacement !== undefined)
@@ -1264,6 +1302,77 @@ export default function (pi: ExtensionAPI) {
1264
1302
  },
1265
1303
  });
1266
1304
 
1305
+ pi.registerTool({
1306
+ name: "diffing_mockup_handoff",
1307
+ label: "Diffing Mockup Handoff",
1308
+ description:
1309
+ "Compact implementation handoff after a mockup is approved: tokens, screens with intent, components used, leftover nits. Prefer this over dumping every screen's HTML. Call after decision=approved, before writing product UI.",
1310
+ parameters: Type.Object({
1311
+ mockupId: Type.Optional(
1312
+ Type.String({ description: "Mockup id. Default: latest." }),
1313
+ ),
1314
+ json: Type.Optional(
1315
+ Type.Boolean({ description: "Emit raw JSON. Default: XML handoff." }),
1316
+ ),
1317
+ }),
1318
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1319
+ const args = ["mockup", "handoff"];
1320
+ if (params.mockupId) args.push(params.mockupId);
1321
+ if (params.json) args.push("--json");
1322
+ const result = await runDiffing(args, ctx.cwd);
1323
+ return textResult(describe(result, "diffing mockup handoff"), {
1324
+ exitCode: result.exitCode,
1325
+ stderr: result.stderr.trim() || undefined,
1326
+ });
1327
+ },
1328
+ });
1329
+
1330
+ pi.registerTool({
1331
+ name: "diffing_design",
1332
+ label: "Diffing Design System",
1333
+ description:
1334
+ "Per-repo design system used before authoring mockup HTML. show/list: read tokens, guidelines, components. extract: scan the consumer repo and write a draft (does not publish). propose: update the draft. publish: human action only — do not publish unless the human asked. Omit id for the default system.",
1335
+ parameters: Type.Object({
1336
+ action: StringEnum(
1337
+ ["show", "list", "extract", "propose", "publish"] as const,
1338
+ { description: "Design-system op. Default: show." },
1339
+ ),
1340
+ id: Type.Optional(
1341
+ Type.String({ description: "System id. Default: default." }),
1342
+ ),
1343
+ json: Type.Optional(
1344
+ Type.Boolean({ description: "Emit raw JSON. Default: false." }),
1345
+ ),
1346
+ from: Type.Optional(
1347
+ StringEnum(["css", "text"] as const, {
1348
+ description: "For extract: scan source. Default: css.",
1349
+ }),
1350
+ ),
1351
+ title: Type.Optional(
1352
+ Type.String({ description: "For extract/propose: system title." }),
1353
+ ),
1354
+ guidelines: Type.Optional(
1355
+ Type.String({
1356
+ description: "For propose: markdown guidelines to write on the draft.",
1357
+ }),
1358
+ ),
1359
+ }),
1360
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1361
+ const action = params.action ?? "show";
1362
+ const args = ["design", action];
1363
+ if (params.id) args.push(params.id);
1364
+ if (params.json) args.push("--json");
1365
+ if (params.from) args.push("--from", params.from);
1366
+ if (params.title) args.push("--title", params.title);
1367
+ if (params.guidelines) args.push("--guidelines", params.guidelines);
1368
+ const result = await runDiffing(args, ctx.cwd);
1369
+ return textResult(describe(result, `diffing design ${action}`), {
1370
+ exitCode: result.exitCode,
1371
+ stderr: result.stderr.trim() || undefined,
1372
+ });
1373
+ },
1374
+ });
1375
+
1267
1376
  pi.registerTool({
1268
1377
  name: "diffing_url",
1269
1378
  label: "Diffing URL",
@@ -26,7 +26,7 @@ declare module "@earendil-works/pi-coding-agent" {
26
26
  text: string,
27
27
  level?: "info" | "error" | "success" | "warning",
28
28
  ): void;
29
- setWidget?(id: string, lines: string[]): void;
29
+ setWidget(id: string, lines: string[] | undefined): void;
30
30
  };
31
31
  }
32
32
 
@@ -0,0 +1,73 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ realpathSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export const DIFFING_SKILL_NAMES = [
11
+ "diffing",
12
+ "diffing-finish-review",
13
+ "diffing-mockup-author",
14
+ "diffing-mockup-review",
15
+ "diffing-plan-review",
16
+ "diffing-pr-address",
17
+ "diffing-pr-read",
18
+ "diffing-release",
19
+ "diffing-review",
20
+ "diffing-start-review",
21
+ ] as const;
22
+
23
+ export const SKILLS_REL = join(".agents", "skills");
24
+
25
+ /**
26
+ * Point `link` at `target`. Creates a missing link, repairs a stale/dangling
27
+ * one, and leaves a correct symlink alone. `existsSync` is false for both
28
+ * missing paths and dangling symlinks, so this uses `lstat` and treats ENOENT
29
+ * as "create".
30
+ */
31
+ export function healSkillLink(
32
+ link: string,
33
+ target: string,
34
+ ): "ok" | "created" | "repaired" {
35
+ if (!existsSync(target)) return "ok";
36
+ let st: ReturnType<typeof lstatSync> | null = null;
37
+ try {
38
+ st = lstatSync(link);
39
+ } catch {
40
+ st = null;
41
+ }
42
+ if (st) {
43
+ if (st.isSymbolicLink()) {
44
+ try {
45
+ if (realpathSync(link) === realpathSync(target)) return "ok";
46
+ } catch {
47
+ // dangling or unreadable — fall through and replace
48
+ }
49
+ }
50
+ rmSync(link, { recursive: true, force: true });
51
+ symlinkSync(target, link, "dir");
52
+ return "repaired";
53
+ }
54
+ symlinkSync(target, link, "dir");
55
+ return "created";
56
+ }
57
+
58
+ /** Keep `~/.agents/skills/diffing*` as symlinks to the canonical checkout. */
59
+ export function selfHealSkillLinks(
60
+ canonical: string,
61
+ homeSkills: string,
62
+ ): void {
63
+ if (!existsSync(homeSkills)) return;
64
+ for (const name of DIFFING_SKILL_NAMES) {
65
+ const target = join(canonical, SKILLS_REL, name);
66
+ if (!existsSync(target)) continue;
67
+ try {
68
+ healSkillLink(join(homeSkills, name), target);
69
+ } catch {
70
+ // best-effort; a failed heal must not break the session
71
+ }
72
+ }
73
+ }
@@ -5,10 +5,11 @@
5
5
  "moduleResolution": "bundler",
6
6
  "strict": true,
7
7
  "noEmit": true,
8
+ "allowImportingTsExtensions": true,
8
9
  "skipLibCheck": true,
9
10
  "esModuleInterop": true,
10
11
  "forceConsistentCasingInFileNames": true,
11
12
  "types": ["node"]
12
13
  },
13
- "include": ["index.ts", "pi-modules.d.ts"]
14
+ "include": ["index.ts", "skill-heal.ts", "pi-modules.d.ts"]
14
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffing",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "local-first CLI for reviewing, navigating, and discussing git diffs with AI",
5
5
  "type": "module",
6
6
  "license": "MIT",