@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,274 @@
1
+ // Proves the session-capabilities channel end-to-end against a mock ACP agent
2
+ // (fixtures/mock-acp-agent-caps.mjs) — no real `claude` needed. Covers Tasks
3
+ // A1/A2/A3/A4 of feature-acp-panel-dynamic-claude-code-capabilities:
4
+ // • AcpBridge captures modes/configOptions from session/new and fires onCaps
5
+ // once the resume-replay window is closed.
6
+ // • AcpBridge.setMode/setConfigOption drive LIVE changes on an established
7
+ // session (no respawn) and the full refreshed configOptions echoes back.
8
+ // • setConfig's persisted model/effort/mode picks are applied ONCE, best-
9
+ // effort, right after a session establishes (never onto an already-live one).
10
+ // • The Acp manager (index.ts) validates `set-mode`/`set-config` frames
11
+ // against the bridge's last-advertised caps before ever calling the bridge
12
+ // (DDR-125 F1 — the dynamic replacement for VALID_MODELS/VALID_EFFORT).
13
+
14
+ import { afterEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtemp, rm } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ import type { ServerWebSocket } from 'bun';
20
+
21
+ import { AcpBridge } from '../acp/bridge.ts';
22
+ import { createAcp } from '../acp/index.ts';
23
+ import type { Context } from '../context.ts';
24
+ import type { WsData } from '../ws.ts';
25
+
26
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-caps.mjs');
27
+ const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
28
+
29
+ function useMockAgent() {
30
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
31
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
32
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
33
+ }
34
+
35
+ afterEach(() => {
36
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
37
+ });
38
+
39
+ async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
40
+ const start = performance.now();
41
+ for (;;) {
42
+ const v = fn();
43
+ if (v !== undefined) return v;
44
+ if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
45
+ await new Promise((r) => setTimeout(r, 25));
46
+ }
47
+ }
48
+
49
+ function fakeWs(id: string) {
50
+ const frames: Array<Record<string, unknown>> = [];
51
+ const ws = {
52
+ data: { id } as WsData,
53
+ send: (raw: string) => {
54
+ frames.push(JSON.parse(raw));
55
+ },
56
+ } as unknown as ServerWebSocket<WsData>;
57
+ return { ws, frames };
58
+ }
59
+
60
+ describe('AcpBridge — capability channel (Tasks A1/A2/A3)', () => {
61
+ test('onCaps fires with the initial modes + configOptions once a session establishes', async () => {
62
+ useMockAgent();
63
+ const caps: Array<[unknown, unknown]> = [];
64
+ const bridge = new AcpBridge({
65
+ repoRoot: process.cwd(),
66
+ onUpdate: () => {},
67
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
68
+ });
69
+ try {
70
+ await bridge.warmUp('c1');
71
+ const [modes, configOptions] = await until(() =>
72
+ caps.length ? caps[caps.length - 1] : undefined
73
+ );
74
+ expect((modes as { currentModeId: string }).currentModeId).toBe('default');
75
+ expect(
76
+ (modes as { availableModes: Array<{ id: string }> }).availableModes.map((m) => m.id)
77
+ ).toEqual(['default', 'plan']);
78
+ const ids = (configOptions as Array<{ id: string }>).map((o) => o.id);
79
+ expect(ids).toEqual(['model', 'effort', 'mode']);
80
+ expect(bridge.modes?.currentModeId).toBe('default');
81
+ expect(bridge.configOptions.length).toBe(3);
82
+ } finally {
83
+ await bridge.stop();
84
+ }
85
+ }, 15000);
86
+
87
+ test('onSessionInfo fires with the agent-generated title', async () => {
88
+ useMockAgent();
89
+ const infos: Array<{ title?: string | null }> = [];
90
+ const bridge = new AcpBridge({
91
+ repoRoot: process.cwd(),
92
+ onUpdate: () => {},
93
+ onSessionInfo: (info) => infos.push(info),
94
+ });
95
+ try {
96
+ await bridge.warmUp('c1');
97
+ const info = await until(() => (infos.length ? infos[infos.length - 1] : undefined));
98
+ expect(info.title).toBe('New chat');
99
+ } finally {
100
+ await bridge.stop();
101
+ }
102
+ }, 15000);
103
+
104
+ test('setMode live-changes the mode on an established session (no respawn) and lastModes cross-derives from config_option_update', async () => {
105
+ useMockAgent();
106
+ const caps: Array<[unknown, unknown]> = [];
107
+ const bridge = new AcpBridge({
108
+ repoRoot: process.cwd(),
109
+ onUpdate: () => {},
110
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
111
+ });
112
+ try {
113
+ await bridge.warmUp('c1');
114
+ await until(() => (caps.length ? true : undefined));
115
+ const procBefore = bridge.connected;
116
+
117
+ await bridge.setMode('c1', 'plan');
118
+ await until(() => {
119
+ const last = caps[caps.length - 1];
120
+ const modes = last?.[0] as { currentModeId: string } | null;
121
+ return modes?.currentModeId === 'plan' ? true : undefined;
122
+ });
123
+
124
+ expect(bridge.connected).toBe(procBefore); // never tore down / respawned
125
+ expect(bridge.modes?.currentModeId).toBe('plan');
126
+ const modeOpt = bridge.configOptions.find((o) => o.id === 'mode') as
127
+ | { currentValue?: unknown }
128
+ | undefined;
129
+ expect(modeOpt?.currentValue).toBe('plan');
130
+ } finally {
131
+ await bridge.stop();
132
+ }
133
+ }, 15000);
134
+
135
+ test('setConfigOption live-changes the model and the FULL refreshed option set echoes back through onCaps', async () => {
136
+ useMockAgent();
137
+ const caps: Array<[unknown, unknown]> = [];
138
+ const bridge = new AcpBridge({
139
+ repoRoot: process.cwd(),
140
+ onUpdate: () => {},
141
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
142
+ });
143
+ try {
144
+ await bridge.warmUp('c1');
145
+ await until(() => (caps.length ? true : undefined));
146
+ expect(bridge.configOptions.some((o) => o.id === 'fast')).toBe(false);
147
+
148
+ await bridge.setConfigOption('c1', 'model', 'opus');
149
+ await until(() => (bridge.configOptions.some((o) => o.id === 'fast') ? true : undefined));
150
+
151
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
152
+ | { currentValue?: unknown }
153
+ | undefined;
154
+ expect(modelOpt?.currentValue).toBe('opus');
155
+ // Switching to opus offers a NEW "fast" option in this fixture — proves
156
+ // the bridge feeds back the full refreshed set, not just the changed field.
157
+ expect(bridge.configOptions.some((o) => o.id === 'fast')).toBe(true);
158
+ } finally {
159
+ await bridge.stop();
160
+ }
161
+ }, 15000);
162
+
163
+ test('setConfig persists a pick applied ONCE on a fresh session — never forced onto an already-live one', async () => {
164
+ useMockAgent();
165
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
166
+ try {
167
+ bridge.setConfig('opus', 'high', 'plan');
168
+ await bridge.warmUp('c1');
169
+ await until(() => (bridge.configOptions.length ? true : undefined));
170
+
171
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
172
+ | { currentValue?: unknown }
173
+ | undefined;
174
+ const effortOpt = bridge.configOptions.find((o) => o.id === 'effort') as
175
+ | { currentValue?: unknown }
176
+ | undefined;
177
+ expect(modelOpt?.currentValue).toBe('opus');
178
+ expect(effortOpt?.currentValue).toBe('high');
179
+ expect(bridge.modes?.currentModeId).toBe('plan');
180
+
181
+ // A DIFFERENT persisted pick set later must NOT retroactively change this
182
+ // already-established chat — it only affects the NEXT fresh session.
183
+ bridge.setConfig('sonnet', 'default', 'default');
184
+ await bridge.prompt('hi', 'c1');
185
+ const modelAfter = bridge.configOptions.find((o) => o.id === 'model') as
186
+ | { currentValue?: unknown }
187
+ | undefined;
188
+ expect(modelAfter?.currentValue).toBe('opus'); // unchanged — no forced respawn
189
+ } finally {
190
+ await bridge.stop();
191
+ }
192
+ }, 15000);
193
+
194
+ test('an unsupported persisted pick is skipped, not thrown — the session keeps its own default', async () => {
195
+ useMockAgent();
196
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
197
+ try {
198
+ bridge.setConfig('does-not-exist-model', null, 'does-not-exist-mode');
199
+ await bridge.warmUp('c1'); // must not throw
200
+ await until(() => (bridge.configOptions.length ? true : undefined));
201
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
202
+ | { currentValue?: unknown }
203
+ | undefined;
204
+ expect(modelOpt?.currentValue).toBe('sonnet'); // session's own default, untouched
205
+ expect(bridge.modes?.currentModeId).toBe('default');
206
+ } finally {
207
+ await bridge.stop();
208
+ }
209
+ }, 15000);
210
+ });
211
+
212
+ describe('Acp manager — set-mode/set-config frames validate against last-advertised caps (DDR-125 F1)', () => {
213
+ test('a caps frame is broadcast on establish; set-mode/set-config apply live; unadvertised values are rejected silently', async () => {
214
+ useMockAgent();
215
+ const designRoot = await mkdtemp(join(tmpdir(), 'acp-caps-test-'));
216
+ const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
217
+ const acp = createAcp(ctx);
218
+
219
+ const a = fakeWs('caps-ws-a');
220
+ try {
221
+ acp.onOpen(a.ws);
222
+ acp.onMessage(a.ws, JSON.stringify({ t: 'warm', chat: 'c1' }));
223
+ const capsFrame = await until(() => a.frames.find((f) => f.t === 'caps'));
224
+ expect((capsFrame.modes as { currentModeId: string }).currentModeId).toBe('default');
225
+
226
+ // Valid live mode change — advertised by the caps frame above.
227
+ acp.onMessage(a.ws, JSON.stringify({ t: 'set-mode', chat: 'c1', modeId: 'plan' }));
228
+ await until(() =>
229
+ a.frames.find(
230
+ (f) =>
231
+ f.t === 'caps' &&
232
+ (f.modes as { currentModeId?: string } | null)?.currentModeId === 'plan'
233
+ )
234
+ );
235
+
236
+ // An unadvertised modeId must be rejected BEFORE ever reaching the bridge
237
+ // — no new caps frame, no error frame, no crash.
238
+ const framesBefore = a.frames.length;
239
+ acp.onMessage(
240
+ a.ws,
241
+ JSON.stringify({ t: 'set-mode', chat: 'c1', modeId: 'bypassPermissions' })
242
+ );
243
+ await new Promise((r) => setTimeout(r, 150));
244
+ expect(a.frames.length).toBe(framesBefore); // nothing new sent
245
+
246
+ // An unadvertised config value is likewise rejected pre-call.
247
+ acp.onMessage(
248
+ a.ws,
249
+ JSON.stringify({ t: 'set-config', chat: 'c1', configId: 'model', value: 'gpt-5' })
250
+ );
251
+ await new Promise((r) => setTimeout(r, 150));
252
+ expect(a.frames.length).toBe(framesBefore);
253
+
254
+ // A valid config change still works after the rejected attempts.
255
+ acp.onMessage(
256
+ a.ws,
257
+ JSON.stringify({ t: 'set-config', chat: 'c1', configId: 'model', value: 'opus' })
258
+ );
259
+ await until(() =>
260
+ a.frames.find(
261
+ (f) =>
262
+ f.t === 'caps' &&
263
+ (f.configOptions as Array<{ id: string; currentValue?: unknown }>).find(
264
+ (o) => o.id === 'model'
265
+ )?.currentValue === 'opus'
266
+ )
267
+ );
268
+ } finally {
269
+ acp.onClose(a.ws);
270
+ await new Promise((r) => setTimeout(r, 50));
271
+ await rm(designRoot, { recursive: true, force: true });
272
+ }
273
+ }, 20000);
274
+ });