@1agh/maude 0.45.1 → 0.46.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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -0,0 +1,269 @@
1
+ // feature-2-print-artboards T1 — the single source for print unit conversion
2
+ // + paper presets.
3
+ //
4
+ // Authoring stays at CSS px @96dpi (DDR-027 — mm never enters JSX geometry).
5
+ // This module is the ONLY place mm/in ↔ px conversion happens: the on-canvas
6
+ // overlay (T3), the Inspector print knobs (T2), and both exporters (T4/T5)
7
+ // all import from here. Two mm→px sites would let the on-canvas bleed
8
+ // silently desync from the exported PDF — see the plan's T1 gotcha.
9
+
10
+ export const CSS_DPI = 96;
11
+ export const MM_PER_INCH = 25.4;
12
+ export const PT_PER_INCH = 72;
13
+
14
+ export function mmToPx(mm: number, dpi: number = CSS_DPI): number {
15
+ return (mm * dpi) / MM_PER_INCH;
16
+ }
17
+
18
+ export function inToPx(inches: number, dpi: number = CSS_DPI): number {
19
+ return inches * dpi;
20
+ }
21
+
22
+ export function pxToMm(px: number, dpi: number = CSS_DPI): number {
23
+ return (px * MM_PER_INCH) / dpi;
24
+ }
25
+
26
+ export function pxToIn(px: number, dpi: number = CSS_DPI): number {
27
+ return px / dpi;
28
+ }
29
+
30
+ /** px → pt (1pt = 1/72in) — the unit pdf-lib's PDFPage boxes expect. */
31
+ export function pxToPt(px: number, dpi: number = CSS_DPI): number {
32
+ return (px / dpi) * PT_PER_INCH;
33
+ }
34
+
35
+ export function mmToPt(mm: number): number {
36
+ return (mm / MM_PER_INCH) * PT_PER_INCH;
37
+ }
38
+
39
+ export type PaperUnit = 'mm' | 'in';
40
+ export type PaperCategory = 'iso' | 'us' | 'envelope' | 'card' | 'poster';
41
+
42
+ export interface PaperPreset {
43
+ id: string;
44
+ label: string;
45
+ category: PaperCategory;
46
+ unit: PaperUnit;
47
+ /** Portrait width × height, in `unit`. */
48
+ width: number;
49
+ height: number;
50
+ }
51
+
52
+ export const PAPER_PRESETS: readonly PaperPreset[] = [
53
+ // ISO A series (mm)
54
+ { id: 'a6', label: 'A6', category: 'iso', unit: 'mm', width: 105, height: 148 },
55
+ { id: 'a5', label: 'A5', category: 'iso', unit: 'mm', width: 148, height: 210 },
56
+ { id: 'a4', label: 'A4', category: 'iso', unit: 'mm', width: 210, height: 297 },
57
+ { id: 'a3', label: 'A3', category: 'iso', unit: 'mm', width: 297, height: 420 },
58
+ { id: 'a2', label: 'A2', category: 'iso', unit: 'mm', width: 420, height: 594 },
59
+ { id: 'a1', label: 'A1', category: 'iso', unit: 'mm', width: 594, height: 841 },
60
+ { id: 'a0', label: 'A0', category: 'iso', unit: 'mm', width: 841, height: 1189 },
61
+ // US (in)
62
+ { id: 'letter', label: 'Letter', category: 'us', unit: 'in', width: 8.5, height: 11 },
63
+ { id: 'legal', label: 'Legal', category: 'us', unit: 'in', width: 8.5, height: 14 },
64
+ { id: 'tabloid', label: 'Tabloid', category: 'us', unit: 'in', width: 11, height: 17 },
65
+ // Envelopes
66
+ { id: 'dl', label: 'DL envelope', category: 'envelope', unit: 'mm', width: 110, height: 220 },
67
+ { id: 'c5', label: 'C5 envelope', category: 'envelope', unit: 'mm', width: 162, height: 229 },
68
+ // Business cards
69
+ {
70
+ id: 'card-eu',
71
+ label: 'Business card (EU)',
72
+ category: 'card',
73
+ unit: 'mm',
74
+ width: 85,
75
+ height: 55,
76
+ },
77
+ {
78
+ id: 'card-us',
79
+ label: 'Business card (US)',
80
+ category: 'card',
81
+ unit: 'in',
82
+ width: 3.5,
83
+ height: 2,
84
+ },
85
+ // Posters
86
+ {
87
+ id: 'poster-18x24',
88
+ label: 'Poster 18×24"',
89
+ category: 'poster',
90
+ unit: 'in',
91
+ width: 18,
92
+ height: 24,
93
+ },
94
+ {
95
+ id: 'poster-24x36',
96
+ label: 'Poster 24×36"',
97
+ category: 'poster',
98
+ unit: 'in',
99
+ width: 24,
100
+ height: 36,
101
+ },
102
+ ];
103
+
104
+ export function getPaperPreset(id: string): PaperPreset | undefined {
105
+ return PAPER_PRESETS.find((p) => p.id === id);
106
+ }
107
+
108
+ export type Orientation = 'portrait' | 'landscape';
109
+
110
+ /** Trim size in mm, orientation-applied (swap width/height for landscape). */
111
+ export function trimSizeMm(
112
+ preset: PaperPreset,
113
+ orientation: Orientation
114
+ ): { widthMm: number; heightMm: number } {
115
+ const wMm = preset.unit === 'mm' ? preset.width : preset.width * MM_PER_INCH;
116
+ const hMm = preset.unit === 'mm' ? preset.height : preset.height * MM_PER_INCH;
117
+ return orientation === 'landscape'
118
+ ? { widthMm: hMm, heightMm: wMm }
119
+ : { widthMm: wMm, heightMm: hMm };
120
+ }
121
+
122
+ /** Default bleed (mm) by preset's unit family — 3mm EU (mm-unit presets), 0.125in (in-unit presets). */
123
+ export function defaultBleedMm(preset: PaperPreset): number {
124
+ return preset.unit === 'mm' ? 3 : 0.125 * MM_PER_INCH;
125
+ }
126
+
127
+ export const DEFAULT_SAFE_MARGIN_MM = 5;
128
+
129
+ /** Generous but finite bound on bleed/margin mm values — guards a
130
+ * malicious/broken value (a hand-edited or hub-synced `print` prop is
131
+ * DDR-054 untrusted canvas content) from producing a negative trim box
132
+ * (rect.w - 2×bleedPx) or a degenerate PDF geometry. Enforced at WRITE
133
+ * time by `setArtboardPrintOp` (api.ts) AND at READ time by
134
+ * `resolveBleedMm`/`resolveMarginsMm` below — a `print` prop can also
135
+ * reach the exporter via disk/hub-sync without ever passing through the
136
+ * write-time validator, so the read side needs its own clamp. */
137
+ export const MAX_PRINT_MM = 200;
138
+
139
+ function clampMm(v: number): number {
140
+ if (!Number.isFinite(v)) return 0;
141
+ return Math.min(Math.max(v, 0), MAX_PRINT_MM);
142
+ }
143
+
144
+ /** Crop/registration mark geometry — length ~3.5mm, stroke offset from trim = bleed. */
145
+ export const MARK_LENGTH_MM = 3.5;
146
+ export const MARK_STROKE_MM = 0.25;
147
+
148
+ export interface PrintMargins {
149
+ top: number;
150
+ right: number;
151
+ bottom: number;
152
+ left: number;
153
+ }
154
+
155
+ /**
156
+ * The `print` JSX prop on `DCArtboard` (T2) — authoring-time intent, kept
157
+ * alongside the resolved px `width`/`height` DDR-027 already requires (this
158
+ * prop does NOT re-derive geometry at render time; the paper picker writes
159
+ * both this prop and the resolved px size together, see resolvePrintArtboard).
160
+ * Consumed by the Inspector's print picker (T2), the on-canvas overlay (T3),
161
+ * and both exporters (T4/T5) — all via THIS module, never a local copy.
162
+ */
163
+ export interface ArtboardPrintProp {
164
+ /** Paper preset id — one of PAPER_PRESETS. */
165
+ paper: string;
166
+ orientation?: Orientation;
167
+ /** Bleed extension, mm. Omitted ⇒ defaultBleedMm(preset). */
168
+ bleedMm?: number;
169
+ marginsMm?: Partial<PrintMargins>;
170
+ }
171
+
172
+ export interface ResolvedPrintArtboard {
173
+ paper: string;
174
+ orientation: Orientation;
175
+ /** Trim (final cut) size, in mm. */
176
+ trimWidthMm: number;
177
+ trimHeightMm: number;
178
+ /** Bleed extension, in mm (uniform on all 4 sides). */
179
+ bleedMm: number;
180
+ /** Artboard px size = trim + 2×bleed — the artboard IS the bleed box (Design Decision 1). */
181
+ widthPx: number;
182
+ heightPx: number;
183
+ /** Trim box, in px, inset from the artboard edges by bleedPx on every side. */
184
+ trimWidthPx: number;
185
+ trimHeightPx: number;
186
+ bleedPx: number;
187
+ marginsMm: PrintMargins;
188
+ }
189
+
190
+ /**
191
+ * Resolve a possibly-partial `marginsMm` override against the 5mm default —
192
+ * the SAME resolution `resolvePrintArtboard` applies, factored out so the
193
+ * on-canvas overlay (T3) and the PDF post-pass (T5) apply identical defaults
194
+ * without re-deriving them from a full `resolvePrintArtboard` call (which
195
+ * needs a paper size neither of those call sites actually has to hand — both
196
+ * work from the artboard's own already-resolved px rect instead, per
197
+ * DDR-027).
198
+ */
199
+ export function resolveMarginsMm(marginsMm?: Partial<PrintMargins>): PrintMargins {
200
+ const m = marginsMm ?? {};
201
+ return {
202
+ top: typeof m.top === 'number' ? clampMm(m.top) : DEFAULT_SAFE_MARGIN_MM,
203
+ right: typeof m.right === 'number' ? clampMm(m.right) : DEFAULT_SAFE_MARGIN_MM,
204
+ bottom: typeof m.bottom === 'number' ? clampMm(m.bottom) : DEFAULT_SAFE_MARGIN_MM,
205
+ left: typeof m.left === 'number' ? clampMm(m.left) : DEFAULT_SAFE_MARGIN_MM,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Resolve an `ArtboardPrintProp`'s bleed to a concrete mm value — explicit
211
+ * `bleedMm` wins, else the paper preset's unit-family default (3mm EU /
212
+ * 0.125in US). Unknown paper id with no explicit bleedMm falls back to 3mm
213
+ * rather than throwing — the overlay/exporter must never hard-fail a render
214
+ * over a bad paper id (resolvePrintArtboard is the strict, throwing entry
215
+ * point used at WRITE time; this one is the lenient, read-time counterpart).
216
+ */
217
+ export function resolveBleedMm(print: { paper: string; bleedMm?: number }): number {
218
+ if (typeof print.bleedMm === 'number') return clampMm(print.bleedMm);
219
+ const preset = getPaperPreset(print.paper);
220
+ return preset ? defaultBleedMm(preset) : 3;
221
+ }
222
+
223
+ /** mm → whole px, rounded — the ONE rounding rule bleed/trim/margin geometry
224
+ * uses everywhere (overlay, exporter, resolvePrintArtboard). */
225
+ export function resolveMmPx(mm: number, dpi: number = CSS_DPI): number {
226
+ return Math.round(mmToPx(mm, dpi));
227
+ }
228
+
229
+ /**
230
+ * Resolve a `print` prop's paper/orientation/bleed into concrete px geometry.
231
+ * The artboard IS the bleed box: bleed lives INSIDE the artboard bounds so
232
+ * background content can reach the cut edge under today's clip-at-artboard
233
+ * -bounds rendering model (the alternative — artboard=trim, bleed outside —
234
+ * can't render bleeding content at all).
235
+ *
236
+ * px values are rounded to the nearest integer (CSS `width`/`height` must be
237
+ * whole px); overlay + exporter both re-derive px from the same mm fields
238
+ * on this result rather than re-rounding independently, so trim/bleed/margin
239
+ * geometry stays pixel-identical between what's drawn and what's exported.
240
+ */
241
+ export function resolvePrintArtboard(args: {
242
+ paper: string;
243
+ orientation?: Orientation;
244
+ bleedMm?: number;
245
+ marginsMm?: Partial<PrintMargins>;
246
+ }): ResolvedPrintArtboard {
247
+ const preset = getPaperPreset(args.paper);
248
+ if (!preset) throw new Error(`unknown paper preset: ${args.paper}`);
249
+ const orientation = args.orientation ?? 'portrait';
250
+ const { widthMm: trimWidthMm, heightMm: trimHeightMm } = trimSizeMm(preset, orientation);
251
+ const bleedMm = args.bleedMm ?? defaultBleedMm(preset);
252
+ const marginsMm = resolveMarginsMm(args.marginsMm);
253
+ const bleedPx = resolveMmPx(bleedMm);
254
+ const trimWidthPx = resolveMmPx(trimWidthMm);
255
+ const trimHeightPx = resolveMmPx(trimHeightMm);
256
+ return {
257
+ paper: preset.id,
258
+ orientation,
259
+ trimWidthMm,
260
+ trimHeightMm,
261
+ bleedMm,
262
+ widthPx: trimWidthPx + 2 * bleedPx,
263
+ heightPx: trimHeightPx + 2 * bleedPx,
264
+ trimWidthPx,
265
+ trimHeightPx,
266
+ bleedPx,
267
+ marginsMm,
268
+ };
269
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file print-overlay-content.tsx — feature-2-print-artboards T3.
3
+ * @scope apps/studio/print-overlay-content.tsx
4
+ * @purpose Registers the 'print' kind's on-canvas guide chrome into the
5
+ * foundation's kindOverlayRegistry (artboard-guides-overlay.tsx):
6
+ * bleed zone (red tint), trim line (solid), safe-margin line
7
+ * (magenta dashed) — the Adobe-convention colors Design
8
+ * Decision 3 specifies. Decorative — aria-hidden, pointer-events
9
+ * none, flat divs/borders only (no filters/blends — WKWebView
10
+ * doesn't GPU-accelerate those; same constraint the foundation
11
+ * overlay itself documents).
12
+ *
13
+ * Geometry reads print/units.ts ONLY (never a local mm→px copy —
14
+ * the T1 single-source invariant: bleedPx here must match the
15
+ * PDF post-pass's bleedPx (T5) exactly, or the on-canvas guide
16
+ * and the exported TrimBox visibly disagree).
17
+ */
18
+
19
+ import { type KindOverlayRenderFn, registerKindOverlay } from './artboard-guides-overlay.tsx';
20
+ import { resolveBleedMm, resolveMarginsMm, resolveMmPx } from './print/units.ts';
21
+
22
+ // Adobe print-tool convention (Design Decision 3): bleed = red, trim = solid
23
+ // dark line, margins/safe-area = magenta dashed. Chrome-engine constants, not
24
+ // DS tokens — guides are never part of exported artboard content, same
25
+ // reasoning as the foundation's own COLUMN_FILL/GRID_LINE constants.
26
+ const BLEED_TINT = 'color-mix(in oklab, oklch(55% 0.22 25) 14%, transparent)';
27
+ const TRIM_LINE = 'oklch(25% 0 0)';
28
+ const MARGIN_LINE = 'oklch(58% 0.24 340)';
29
+
30
+ const PrintOverlayContent: KindOverlayRenderFn = ({ rect, print, visibility }) => {
31
+ if (visibility.print !== true || !print) return null;
32
+
33
+ const bleedMm = resolveBleedMm(print);
34
+ const bleedPx = resolveMmPx(bleedMm);
35
+ const margins = resolveMarginsMm(print.marginsMm);
36
+ const marginPx = {
37
+ top: resolveMmPx(margins.top),
38
+ right: resolveMmPx(margins.right),
39
+ bottom: resolveMmPx(margins.bottom),
40
+ left: resolveMmPx(margins.left),
41
+ };
42
+
43
+ // Trim box — inset from the artboard (= bleed box, Design Decision 1) by
44
+ // bleedPx on every side.
45
+ const trimW = Math.max(0, rect.w - 2 * bleedPx);
46
+ const trimH = Math.max(0, rect.h - 2 * bleedPx);
47
+ // Safe/margin box — inset further from the trim box by marginPx per side.
48
+ const marginW = Math.max(0, trimW - marginPx.left - marginPx.right);
49
+ const marginH = Math.max(0, trimH - marginPx.top - marginPx.bottom);
50
+
51
+ return (
52
+ <>
53
+ {bleedPx > 0 ? (
54
+ <>
55
+ {/* Bleed zone — the annulus between the artboard edge and the trim
56
+ line, drawn as 4 tinted strips (never a single translucent box
57
+ over the whole artboard, which would also tint the live content
58
+ inside the trim box). */}
59
+ <div
60
+ style={{
61
+ position: 'absolute',
62
+ left: 0,
63
+ top: 0,
64
+ width: rect.w,
65
+ height: bleedPx,
66
+ background: BLEED_TINT,
67
+ }}
68
+ />
69
+ <div
70
+ style={{
71
+ position: 'absolute',
72
+ left: 0,
73
+ bottom: 0,
74
+ width: rect.w,
75
+ height: bleedPx,
76
+ background: BLEED_TINT,
77
+ }}
78
+ />
79
+ <div
80
+ style={{
81
+ position: 'absolute',
82
+ left: 0,
83
+ top: bleedPx,
84
+ width: bleedPx,
85
+ height: trimH,
86
+ background: BLEED_TINT,
87
+ }}
88
+ />
89
+ <div
90
+ style={{
91
+ position: 'absolute',
92
+ right: 0,
93
+ top: bleedPx,
94
+ width: bleedPx,
95
+ height: trimH,
96
+ background: BLEED_TINT,
97
+ }}
98
+ />
99
+ </>
100
+ ) : null}
101
+ {/* Trim line — the final cut edge, solid. */}
102
+ <div
103
+ style={{
104
+ position: 'absolute',
105
+ left: bleedPx,
106
+ top: bleedPx,
107
+ width: trimW,
108
+ height: trimH,
109
+ border: `1px solid ${TRIM_LINE}`,
110
+ boxSizing: 'border-box',
111
+ }}
112
+ />
113
+ {/* Safe-area / margin line — inset from trim, dashed. */}
114
+ {marginW > 0 && marginH > 0 ? (
115
+ <div
116
+ style={{
117
+ position: 'absolute',
118
+ left: bleedPx + marginPx.left,
119
+ top: bleedPx + marginPx.top,
120
+ width: marginW,
121
+ height: marginH,
122
+ border: `1px dashed ${MARGIN_LINE}`,
123
+ boxSizing: 'border-box',
124
+ }}
125
+ />
126
+ ) : null}
127
+ </>
128
+ );
129
+ };
130
+ PrintOverlayContent.displayName = 'PrintOverlayContent';
131
+
132
+ registerKindOverlay('print', PrintOverlayContent);
@@ -52,7 +52,7 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
52
52
  }
53
53
  }, 15000);
54
54
 
55
- test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
55
+ test('model/effort are no longer env-at-spawn — a config change never respawns the running adapter', async () => {
56
56
  process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
57
57
  process.env.MAUDE_ACP_RUNTIME = process.execPath;
58
58
  process.env.MAUDE_CLAUDE_BIN = process.execPath;
@@ -60,19 +60,24 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
60
60
  const updates: unknown[] = [];
61
61
  const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
62
62
  try {
63
+ // A persisted pick this mock doesn't advertise (it has no configOptions at
64
+ // all) is skipped best-effort, never forwarded to ANTHROPIC_MODEL/env.
63
65
  bridge.setConfig('opus', 'thorough');
64
66
  await bridge.prompt('hi', 'c1');
67
+ const sid = bridge.sessionId;
65
68
  const first = JSON.stringify(updates);
66
- expect(first).toContain('model=opus');
67
- expect(first).toContain('thinking=31999');
69
+ expect(first).toContain('model=<unset>');
70
+ expect(first).toContain('thinking=<unset>');
68
71
 
69
- // Changing the config re-spawns with the new env on the next prompt.
72
+ // Changing the config again must NOT tear down / respawn the live process
73
+ // (Task A3 — the old configChanged()-triggered stop() is gone).
70
74
  updates.length = 0;
71
75
  bridge.setConfig(null, 'fast');
72
76
  await bridge.prompt('again', 'c1');
77
+ expect(bridge.sessionId).toBe(sid); // same session — no respawn happened
73
78
  const second = JSON.stringify(updates);
74
- expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
75
- expect(second).toContain('thinking=0'); // fast → thinking disabled
79
+ expect(second).toContain('model=<unset>');
80
+ expect(second).toContain('thinking=<unset>');
76
81
  } finally {
77
82
  await bridge.stop();
78
83
  }
@@ -0,0 +1,123 @@
1
+ // Pure unit tests for client/panels/acp-capabilities.js — the parsing +
2
+ // persisted-pick-resolution helpers the dynamic CapabilityBar renders from.
3
+ // No hardcoded model/effort/mode list is under test here BY DESIGN: these
4
+ // functions must work for whatever the session actually advertises.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ flattenSelectOptions,
10
+ parseConfigOptions,
11
+ parseModes,
12
+ resolvePersistedPick,
13
+ } from '../client/panels/acp-capabilities.js';
14
+
15
+ describe('parseConfigOptions', () => {
16
+ test('identifies model/effort/fast by id, excludes the mode-mirror entry, and buckets everything else into others', () => {
17
+ const configOptions = [
18
+ { id: 'model', category: 'model', currentValue: 'sonnet', options: [] },
19
+ { id: 'effort', category: 'thought_level', currentValue: 'default', options: [] },
20
+ { id: 'mode', category: 'mode', currentValue: 'default', options: [] },
21
+ { id: 'fast', category: 'model_config', currentValue: 'off', options: [] },
22
+ { id: 'agent', currentValue: 'default', options: [] }, // no category — the documented gap
23
+ { id: 'some-future-option', category: '_custom', currentValue: 'x', options: [] },
24
+ ];
25
+ const { model, effort, fast, others } = parseConfigOptions(configOptions);
26
+ expect(model?.id).toBe('model');
27
+ expect(effort?.id).toBe('effort');
28
+ expect(fast?.id).toBe('fast');
29
+ expect(others.map((o) => o.id)).toEqual(['agent', 'some-future-option']);
30
+ });
31
+
32
+ test('tolerates missing/empty/malformed input', () => {
33
+ expect(parseConfigOptions(undefined)).toEqual({
34
+ model: null,
35
+ effort: null,
36
+ fast: null,
37
+ others: [],
38
+ });
39
+ expect(parseConfigOptions(null)).toEqual({ model: null, effort: null, fast: null, others: [] });
40
+ expect(parseConfigOptions([])).toEqual({ model: null, effort: null, fast: null, others: [] });
41
+ // A non-object entry must not throw.
42
+ expect(parseConfigOptions([null, 'x', 42]).others).toEqual([]);
43
+ });
44
+
45
+ test('a session with only a subset of options omits the rest as null, not a stale default', () => {
46
+ const { model, effort, fast } = parseConfigOptions([{ id: 'model', options: [] }]);
47
+ expect(model).not.toBeNull();
48
+ expect(effort).toBeNull();
49
+ expect(fast).toBeNull();
50
+ });
51
+ });
52
+
53
+ describe('parseModes', () => {
54
+ test('returns current + available from a real SessionModeState', () => {
55
+ const modes = {
56
+ currentModeId: 'plan',
57
+ availableModes: [
58
+ { id: 'default', name: 'Manual' },
59
+ { id: 'plan', name: 'Plan Mode' },
60
+ ],
61
+ };
62
+ expect(parseModes(modes)).toEqual({
63
+ current: 'plan',
64
+ available: modes.availableModes,
65
+ });
66
+ });
67
+
68
+ test('an agent that never advertises modes yields an empty, non-throwing shape', () => {
69
+ expect(parseModes(null)).toEqual({ current: null, available: [] });
70
+ expect(parseModes(undefined)).toEqual({ current: null, available: [] });
71
+ expect(parseModes({})).toEqual({ current: null, available: [] });
72
+ });
73
+ });
74
+
75
+ describe('flattenSelectOptions', () => {
76
+ test('passes through a flat option list unchanged', () => {
77
+ const options = [
78
+ { value: 'a', name: 'A' },
79
+ { value: 'b', name: 'B' },
80
+ ];
81
+ expect(flattenSelectOptions(options)).toEqual(options);
82
+ });
83
+
84
+ test('flattens grouped SessionConfigSelectGroup[] into one leaf list', () => {
85
+ const options = [
86
+ { group: 'g1', name: 'Group 1', options: [{ value: 'a', name: 'A' }] },
87
+ {
88
+ group: 'g2',
89
+ name: 'Group 2',
90
+ options: [
91
+ { value: 'b', name: 'B' },
92
+ { value: 'c', name: 'C' },
93
+ ],
94
+ },
95
+ ];
96
+ expect(flattenSelectOptions(options).map((o) => o.value)).toEqual(['a', 'b', 'c']);
97
+ });
98
+
99
+ test('tolerates missing input', () => {
100
+ expect(flattenSelectOptions(undefined)).toEqual([]);
101
+ expect(flattenSelectOptions(null)).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe('resolvePersistedPick', () => {
106
+ test('prefers the saved pick when the session still offers it', () => {
107
+ expect(resolvePersistedPick(['opus', 'sonnet'], 'opus', 'sonnet')).toBe('opus');
108
+ });
109
+
110
+ test('falls back to the session default when the saved pick is no longer offered', () => {
111
+ expect(resolvePersistedPick(['sonnet', 'haiku'], 'opus', 'sonnet')).toBe('sonnet');
112
+ });
113
+
114
+ test('falls back to null when there is neither a valid saved pick nor a default', () => {
115
+ expect(resolvePersistedPick([], 'opus', undefined)).toBeNull();
116
+ expect(resolvePersistedPick([], null, null)).toBeNull();
117
+ });
118
+
119
+ test('a null/undefined saved value always defers to the default', () => {
120
+ expect(resolvePersistedPick(['a', 'b'], null, 'b')).toBe('b');
121
+ expect(resolvePersistedPick(['a', 'b'], undefined, 'a')).toBe('a');
122
+ });
123
+ });