@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.
- package/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -0
|
@@ -385,6 +385,203 @@ function WhisperModelCard() {
|
|
|
385
385
|
);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// feature-scene-aware-keyframes — the video-analysis frame-selection engine, the
|
|
389
|
+
// sibling of the Subtitles section. Persists `keyframeEngine` to /_api/generate/prefs.
|
|
390
|
+
const KEYFRAME_ENGINES = [
|
|
391
|
+
{ id: 'auto', label: 'Auto', note: 'Use the best available: Gemma scout → ffmpeg → blind.' },
|
|
392
|
+
{ id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs Apple Silicon + mlx-vlm + a model.' },
|
|
393
|
+
{ id: 'ffmpeg', label: 'ffmpeg scene-detect', note: 'Scene cuts + endpoints. Needs ffmpeg. No model download.' },
|
|
394
|
+
{ id: 'blind', label: 'Blind (even-spaced)', note: 'Chromium fallback — works with neither ffmpeg nor Gemma.' },
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
function KeyframeEngineCard() {
|
|
398
|
+
const [engine, setEngine] = useState(null);
|
|
399
|
+
const [busy, setBusy] = useState(false);
|
|
400
|
+
const [status, setStatus] = useState(null);
|
|
401
|
+
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
fetch('/_api/generate/prefs')
|
|
404
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
405
|
+
.then((d) => setEngine(d?.keyframeEngine || 'auto'))
|
|
406
|
+
.catch(() => setEngine('auto'));
|
|
407
|
+
}, []);
|
|
408
|
+
|
|
409
|
+
async function choose(id) {
|
|
410
|
+
if (id === engine) return;
|
|
411
|
+
const prev = engine;
|
|
412
|
+
setEngine(id);
|
|
413
|
+
setBusy(true);
|
|
414
|
+
setStatus(null);
|
|
415
|
+
try {
|
|
416
|
+
const res = await fetch('/_api/generate/prefs', {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'content-type': 'text/plain' },
|
|
419
|
+
body: JSON.stringify({ keyframeEngine: id }),
|
|
420
|
+
});
|
|
421
|
+
if (!res.ok) throw new Error(await res.text());
|
|
422
|
+
setStatus({ ok: true, msg: 'Saved.' });
|
|
423
|
+
} catch (err) {
|
|
424
|
+
setEngine(prev);
|
|
425
|
+
setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
|
|
426
|
+
} finally {
|
|
427
|
+
setBusy(false);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<div className="st-provider-card">
|
|
433
|
+
<div className="st-provider-hd">
|
|
434
|
+
<span className="st-provider-name">Scene-aware keyframes — analysis engine</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="st-provider-notes">
|
|
437
|
+
How Maude picks frames when it analyzes a video (`/design:video-analyze`, `/design:reel`).
|
|
438
|
+
Scene-aware beats a blind frame rate. This is an explicit choice — a chosen-but-unavailable
|
|
439
|
+
engine tells you how to fix it; <strong>Auto</strong> just uses the best you have installed.
|
|
440
|
+
</div>
|
|
441
|
+
<div className="st-engine-radios" role="radiogroup" aria-label="Keyframe engine">
|
|
442
|
+
{KEYFRAME_ENGINES.map((e) => (
|
|
443
|
+
<label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
|
|
444
|
+
<input
|
|
445
|
+
type="radio"
|
|
446
|
+
name="keyframe-engine"
|
|
447
|
+
value={e.id}
|
|
448
|
+
checked={engine === e.id}
|
|
449
|
+
disabled={busy || engine === null}
|
|
450
|
+
onChange={() => choose(e.id)}
|
|
451
|
+
/>
|
|
452
|
+
<span className="st-engine-radio-body">
|
|
453
|
+
<span className="st-engine-radio-label">{e.label}</span>
|
|
454
|
+
<span className="st-engine-radio-note">{e.note}</span>
|
|
455
|
+
</span>
|
|
456
|
+
</label>
|
|
457
|
+
))}
|
|
458
|
+
</div>
|
|
459
|
+
{status && (
|
|
460
|
+
<div
|
|
461
|
+
className="st-provider-status"
|
|
462
|
+
style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
|
|
463
|
+
>
|
|
464
|
+
{status.msg}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The MODEL half of the Gemma scout — one click, mirroring WhisperModelCard. The
|
|
472
|
+
// RUNTIME (mlx-vlm) is a manual `pip install` the app can't do, so the download is
|
|
473
|
+
// gated on mlxVlmAvailable and the card says which tier will actually run.
|
|
474
|
+
function GemmaModelCard() {
|
|
475
|
+
const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable }
|
|
476
|
+
const [err, setErr] = useState(null);
|
|
477
|
+
|
|
478
|
+
const load = useCallback(() => {
|
|
479
|
+
fetch('/_api/generate/keyframe-model')
|
|
480
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
481
|
+
.then(setState)
|
|
482
|
+
.catch((e) => setErr(e && e.message ? e.message : 'failed to load models'));
|
|
483
|
+
}, []);
|
|
484
|
+
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
load();
|
|
487
|
+
}, [load]);
|
|
488
|
+
|
|
489
|
+
useEffect(() => {
|
|
490
|
+
if (!state?.downloading) return undefined;
|
|
491
|
+
const t = setInterval(load, 1500);
|
|
492
|
+
return () => clearInterval(t);
|
|
493
|
+
}, [state?.downloading, load]);
|
|
494
|
+
|
|
495
|
+
async function download(id) {
|
|
496
|
+
setErr(null);
|
|
497
|
+
try {
|
|
498
|
+
const res = await fetch('/_api/generate/keyframe-model', {
|
|
499
|
+
method: 'POST',
|
|
500
|
+
headers: { 'content-type': 'text/plain' },
|
|
501
|
+
body: JSON.stringify({ id }),
|
|
502
|
+
});
|
|
503
|
+
if (!res.ok) throw new Error(await res.text());
|
|
504
|
+
load();
|
|
505
|
+
} catch (e) {
|
|
506
|
+
setErr(e && e.message ? e.message : 'download failed');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const dl = state?.downloading;
|
|
511
|
+
const mlx = state?.mlxVlmAvailable;
|
|
512
|
+
const ff = state?.ffmpegAvailable;
|
|
513
|
+
const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
|
|
514
|
+
const activeTier = mlx ? 'Gemma scout' : ff ? 'ffmpeg scene-detect' : 'blind (even-spaced)';
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<div className="st-provider-card">
|
|
518
|
+
<div className="st-provider-hd">
|
|
519
|
+
<span className="st-provider-name">Gemma scout model (optional)</span>
|
|
520
|
+
</div>
|
|
521
|
+
<div className="st-provider-notes">
|
|
522
|
+
The <strong>Gemma</strong> tier adds semantic action-beat detection on top of scene cuts. It
|
|
523
|
+
needs an <strong>Apple-Silicon Mac</strong> and the <code>mlx-vlm</code> runtime
|
|
524
|
+
(<code>pip install mlx-vlm</code> — a manual step Maude can’t do for you). Downloading a model
|
|
525
|
+
below is the one-click half. Models live in your HuggingFace cache, never committed.
|
|
526
|
+
</div>
|
|
527
|
+
{state && (
|
|
528
|
+
<div className="st-provider-notes">
|
|
529
|
+
On this machine the active tier is <strong>{activeTier}</strong>
|
|
530
|
+
{!mlx && ff && ' — install mlx-vlm + download a model to unlock the Gemma scout.'}
|
|
531
|
+
{!mlx && !ff && ' — install ffmpeg for scene-aware frames, or mlx-vlm for the Gemma scout.'}
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
{err && (
|
|
535
|
+
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
536
|
+
{err}
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
{dl?.error && (
|
|
540
|
+
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
541
|
+
Download of {dl.id} failed: {dl.error}
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
544
|
+
{state === null && !err && <div className="st-settings-intro">Loading…</div>}
|
|
545
|
+
<div className="st-model-list">
|
|
546
|
+
{(state?.models || []).map((m) => {
|
|
547
|
+
const busy = dl && !dl.error && dl.id === m.id;
|
|
548
|
+
return (
|
|
549
|
+
<div key={m.id} className="st-model-row">
|
|
550
|
+
<div className="st-model-info">
|
|
551
|
+
<span className="st-model-label">
|
|
552
|
+
{m.label}
|
|
553
|
+
<span className="st-model-size">~{Math.round(m.sizeMB / 100) / 10} GB</span>
|
|
554
|
+
</span>
|
|
555
|
+
<span className="st-engine-radio-note">{m.note}</span>
|
|
556
|
+
{busy && (
|
|
557
|
+
<span className="st-model-progress">Downloading… {pctOf(dl.received, dl.total)}%</span>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
<div className="st-model-actions">
|
|
561
|
+
{m.downloaded ? (
|
|
562
|
+
<span className="st-provider-configured">
|
|
563
|
+
<Icon name="check" size={12} /> ready
|
|
564
|
+
</span>
|
|
565
|
+
) : (
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
className="st-btn"
|
|
569
|
+
disabled={!mlx || !!(dl && !dl.error)}
|
|
570
|
+
title={mlx ? '' : 'Install mlx-vlm first (pip install mlx-vlm)'}
|
|
571
|
+
onClick={() => download(m.id)}
|
|
572
|
+
>
|
|
573
|
+
{busy ? 'Downloading…' : mlx ? 'Download' : 'Needs mlx-vlm'}
|
|
574
|
+
</button>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
})}
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
388
585
|
// feature-unified-settings-modal — one modal for every Maude preference, laid
|
|
389
586
|
// out as a left vertical tab rail + an internally-scrolling pane. Categories:
|
|
390
587
|
const TABS = [
|
|
@@ -393,6 +590,7 @@ const TABS = [
|
|
|
393
590
|
{ id: 'layout', label: 'Layout' },
|
|
394
591
|
{ id: 'ai-generation', label: 'AI generation' },
|
|
395
592
|
{ id: 'subtitles', label: 'Subtitles' },
|
|
593
|
+
{ id: 'video', label: 'Video' },
|
|
396
594
|
];
|
|
397
595
|
const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
|
|
398
596
|
|
|
@@ -820,6 +1018,19 @@ export default function SettingsPanel({
|
|
|
820
1018
|
<TranscriptionEngineCard />
|
|
821
1019
|
<WhisperModelCard />
|
|
822
1020
|
</section>
|
|
1021
|
+
|
|
1022
|
+
{/* Video — scene-aware keyframe engine. Kept mounted so GemmaModelCard's
|
|
1023
|
+
download poll survives a tab switch (like Subtitles above). */}
|
|
1024
|
+
<section
|
|
1025
|
+
role="tabpanel"
|
|
1026
|
+
id="st-spanel-video"
|
|
1027
|
+
aria-labelledby="st-stab-video"
|
|
1028
|
+
hidden={tab !== 'video'}
|
|
1029
|
+
>
|
|
1030
|
+
<div className="st-rp-hd">Video</div>
|
|
1031
|
+
<KeyframeEngineCard />
|
|
1032
|
+
<GemmaModelCard />
|
|
1033
|
+
</section>
|
|
823
1034
|
</div>
|
|
824
1035
|
</div>
|
|
825
1036
|
</div>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Collapsed consecutive-tool-call summary row (Task C1) — folds a RUN of 2+
|
|
2
|
+
// back-to-back tool-call parts into one "Ran N tools" line, expandable to the
|
|
3
|
+
// individual ChatToolCards. A single isolated tool call renders as-is (no
|
|
4
|
+
// wrapper) so the common case stays exactly as it was.
|
|
5
|
+
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pure grouping fn (exported for tests): `parts` (a message's raw content
|
|
10
|
+
* array, or the continuation tail's parts) → an ordered list of
|
|
11
|
+
* `{ type:'single', part }` | `{ type:'tool-group', parts:[...] }`. Only
|
|
12
|
+
* CONSECUTIVE tool-call parts fold into a group; anything else (text,
|
|
13
|
+
* reasoning) — or a lone tool-call with no neighbor — passes through single.
|
|
14
|
+
*/
|
|
15
|
+
export function groupToolCalls(parts) {
|
|
16
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
17
|
+
const out = [];
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < list.length) {
|
|
20
|
+
const p = list[i];
|
|
21
|
+
if (p?.type === 'tool-call') {
|
|
22
|
+
const run = [];
|
|
23
|
+
let j = i;
|
|
24
|
+
while (j < list.length && list[j]?.type === 'tool-call') {
|
|
25
|
+
run.push(list[j]);
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
out.push(run.length > 1 ? { type: 'tool-group', parts: run } : { type: 'single', part: run[0] });
|
|
29
|
+
i = j;
|
|
30
|
+
} else {
|
|
31
|
+
out.push({ type: 'single', part: p });
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "Ran N × Write file" when every entry in the run shares one title, else a
|
|
39
|
+
* short "Ran N tools — A, B, C…" listing the distinct titles seen. */
|
|
40
|
+
export function summarizeGroup(parts) {
|
|
41
|
+
const names = parts.map((p) => p.toolName || 'tool');
|
|
42
|
+
const uniq = [...new Set(names)];
|
|
43
|
+
if (uniq.length === 1) return `Ran ${parts.length} × ${uniq[0]}`;
|
|
44
|
+
const shown = uniq.slice(0, 3).join(', ');
|
|
45
|
+
return `Ran ${parts.length} tools — ${shown}${uniq.length > 3 ? '…' : ''}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function ToolGroup({ parts, ToolCard, forceOpen = false, verbose = false }) {
|
|
49
|
+
const [open, setOpen] = useState(forceOpen);
|
|
50
|
+
const allDone = parts.every((p) => p.result !== undefined);
|
|
51
|
+
const anyError = parts.some((p) => p.isError);
|
|
52
|
+
return (
|
|
53
|
+
<details
|
|
54
|
+
className="chat-toolgroup"
|
|
55
|
+
open={forceOpen || open}
|
|
56
|
+
onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
57
|
+
data-testid="chat-tool-group"
|
|
58
|
+
>
|
|
59
|
+
<summary className="chat-toolgroup-sum">
|
|
60
|
+
<span className={`chat-tool-dot ${allDone ? 'chat-tool-dot--done' : 'chat-tool-dot--run'}`} />
|
|
61
|
+
<span>{summarizeGroup(parts)}</span>
|
|
62
|
+
{anyError ? <span className="del"> · error</span> : null}
|
|
63
|
+
</summary>
|
|
64
|
+
<div className="chat-toolgroup-body">
|
|
65
|
+
{parts.map((p, i) => (
|
|
66
|
+
<ToolCard
|
|
67
|
+
key={p.toolCallId || i}
|
|
68
|
+
toolName={p.toolName}
|
|
69
|
+
args={p.args}
|
|
70
|
+
result={p.result}
|
|
71
|
+
isError={p.isError}
|
|
72
|
+
verbose={verbose}
|
|
73
|
+
flat
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</details>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Pure helpers for the ACP session-capabilities channel (feature-acp-panel-
|
|
2
|
+
// dynamic-claude-code-capabilities). Every model/mode/effort list rendered by
|
|
3
|
+
// the panel is parsed from these — NEVER a hardcoded array. The session's
|
|
4
|
+
// `configOptions[]` is a generic, agent-defined menu (models/effort/fast-mode/
|
|
5
|
+
// agent persona/…, keyed by opaque ids); `modes` is the separate, protocol-
|
|
6
|
+
// typed permission-mode roster. See CapabilityBar.jsx for the render side.
|
|
7
|
+
|
|
8
|
+
const KNOWN_CONFIG_IDS = { model: 'model', effort: 'effort', fast: 'fast', mode: 'mode' };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `configOptions[]` → the three well-known controls (identified by `id` —
|
|
12
|
+
* `category` is only a UX hint and may be absent, e.g. the "Agent persona"
|
|
13
|
+
* option) plus every OTHER advertised select option, generic. The "mode"
|
|
14
|
+
* entry mirrors `modes`/`SessionModeState` (the adapter keeps both in sync)
|
|
15
|
+
* so it's excluded here — the dedicated mode picker renders from `parseModes`
|
|
16
|
+
* instead, never from this list, or it would show twice.
|
|
17
|
+
*/
|
|
18
|
+
export function parseConfigOptions(configOptions) {
|
|
19
|
+
const list = Array.isArray(configOptions) ? configOptions : [];
|
|
20
|
+
let model = null;
|
|
21
|
+
let effort = null;
|
|
22
|
+
let fast = null;
|
|
23
|
+
const others = [];
|
|
24
|
+
for (const opt of list) {
|
|
25
|
+
if (!opt || typeof opt !== 'object' || opt.id === KNOWN_CONFIG_IDS.mode) continue;
|
|
26
|
+
if (opt.id === KNOWN_CONFIG_IDS.model) model = opt;
|
|
27
|
+
else if (opt.id === KNOWN_CONFIG_IDS.effort) effort = opt;
|
|
28
|
+
else if (opt.id === KNOWN_CONFIG_IDS.fast) fast = opt;
|
|
29
|
+
else others.push(opt);
|
|
30
|
+
}
|
|
31
|
+
return { model, effort, fast, others };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `SessionModeState` → `{ current, available }` (empty when the agent doesn't
|
|
36
|
+
* advertise modes at all). `available` legitimately varies turn to turn — the
|
|
37
|
+
* adapter clamps the roster to what the CURRENT model supports (e.g. "Auto"
|
|
38
|
+
* only when `supportsAutoMode`) — never assume a fixed set.
|
|
39
|
+
*/
|
|
40
|
+
export function parseModes(modes) {
|
|
41
|
+
if (!modes || !Array.isArray(modes.availableModes)) return { current: null, available: [] };
|
|
42
|
+
return { current: modes.currentModeId ?? null, available: modes.availableModes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Flatten a `SessionConfigSelect.options` — a flat option array OR grouped
|
|
47
|
+
* (`SessionConfigSelectGroup[]`, each `{group,name,options}`) — into one list
|
|
48
|
+
* of selectable leaves (`{value,name,description}`).
|
|
49
|
+
*/
|
|
50
|
+
export function flattenSelectOptions(options) {
|
|
51
|
+
const list = Array.isArray(options) ? options : [];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const o of list) {
|
|
54
|
+
if (o && Array.isArray(o.options)) out.push(...o.options);
|
|
55
|
+
else if (o) out.push(o);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve which value a picker should show: the user's last explicit pick
|
|
62
|
+
* (localStorage, `savedValue`) if it's still offered among `availableValues`
|
|
63
|
+
* (a plain array of value/id strings — the caller flattens+maps first), else
|
|
64
|
+
* `defaultValue` (the session's own current value — a fresh session/model can
|
|
65
|
+
* default to something the last chat didn't use).
|
|
66
|
+
*/
|
|
67
|
+
export function resolvePersistedPick(availableValues, savedValue, defaultValue) {
|
|
68
|
+
const set = new Set(Array.isArray(availableValues) ? availableValues : []);
|
|
69
|
+
if (savedValue != null && set.has(savedValue)) return savedValue;
|
|
70
|
+
return defaultValue ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Modes where the agent CANNOT apply an edit and there's no interactive approve
|
|
74
|
+
// path to unblock it (DDR-184 #3). `plan` = "no actual tool execution"; `dontAsk`
|
|
75
|
+
// denies anything without a standing pre-approval (and Maude's default allow-list
|
|
76
|
+
// only covers edits + `maude`, so an off-list step is silently denied). The
|
|
77
|
+
// ChatPanel ModeBanner reads this to decide whether to show the loud alert. Keyed
|
|
78
|
+
// off the mode id, never a label, so it stays truthful as the roster evolves.
|
|
79
|
+
export const NO_EDIT_MODE_IDS = new Set(['plan', 'dontAsk']);
|
|
80
|
+
export function modeBlocksEdits(modes) {
|
|
81
|
+
return !!modes?.currentModeId && NO_EDIT_MODE_IDS.has(modes.currentModeId);
|
|
82
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Pure helpers for ACP form elicitation (feature-acp-ask-user-question) —
|
|
2
|
+
// parses a `requestedSchema` (ElicitationSchema, confirmed on disk against
|
|
3
|
+
// `@agentclientprotocol/sdk`'s schema/types.gen.d.ts) into renderable question
|
|
4
|
+
// descriptors, and folds the UI's answer state back into the `content` map a
|
|
5
|
+
// `CreateElicitationResponse` expects. No DOM — `ElicitationPrompt.jsx` is the
|
|
6
|
+
// only consumer that touches rendering.
|
|
7
|
+
//
|
|
8
|
+
// The exact field-naming convention this is written against — `question_<n>`
|
|
9
|
+
// paired with a sibling `question_<n>_custom` free-text field, enum options
|
|
10
|
+
// carrying `{const, title, description?}` — is confirmed by reading
|
|
11
|
+
// `@agentclientprotocol/claude-agent-acp/dist/elicitation.js`'s
|
|
12
|
+
// `askUserQuestionsToCreateRequest`/`applyAskElicitationResponse` directly (the
|
|
13
|
+
// installed source, not the docs — this whole feature rides an UNSTABLE
|
|
14
|
+
// protocol surface). The parser is written generically against the pairing
|
|
15
|
+
// PATTERN (`<key>` + `<key>_custom` both present in `properties`), not
|
|
16
|
+
// hardcoded to the `question_` prefix, so a well-formed non-AskUserQuestion MCP
|
|
17
|
+
// form that happens to follow the same convention renders correctly too.
|
|
18
|
+
|
|
19
|
+
// SECURITY (ethical-hacker finding) — Maude's bridge only ever declares the
|
|
20
|
+
// `form` elicitation capability, never `url` (see DDR-180's Open decisions +
|
|
21
|
+
// `acp/bridge.ts`'s structural rejection of any other mode). The MCP spec's
|
|
22
|
+
// OWN guidance is that url-mode exists specifically so a real credential
|
|
23
|
+
// request never has to pass through the LLM/client at all. By not supporting
|
|
24
|
+
// it, Maude makes the free-text box on THIS card the only channel any
|
|
25
|
+
// connected MCP server — benign or hostile — can ever use to ask for a
|
|
26
|
+
// secret. This heuristic doesn't change accept/decline routing or try to
|
|
27
|
+
// block the request; it only asks the free-text input to behave like a
|
|
28
|
+
// password field (masked, with a visible warning) when the question's own
|
|
29
|
+
// text reads as a credential ask, so a user who WOULD stop and think twice
|
|
30
|
+
// before typing a real secret into a password-styled field gets that same
|
|
31
|
+
// pause here. A false positive just means an unrelated question shows a
|
|
32
|
+
// masked box — harmless; a false negative leaves today's behavior unchanged.
|
|
33
|
+
const SECRET_SHAPED_PATTERN =
|
|
34
|
+
/\b(password|passphrase|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|auth(?:entication)?[\s_-]?token|credential|oauth[\s_-]?token)\b/i;
|
|
35
|
+
|
|
36
|
+
/** Does this question's own text read as a request for a credential/secret?
|
|
37
|
+
* Checked against title + description (or a top-level card `message`) — text
|
|
38
|
+
* the requester chose, never the user's own typed answer. */
|
|
39
|
+
export function looksLikeSecretRequest(...texts) {
|
|
40
|
+
const joined = texts.filter((t) => typeof t === 'string').join(' ');
|
|
41
|
+
return SECRET_SHAPED_PATTERN.test(joined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `_meta` key under which `askUserQuestionsToCreateRequest` bridges an
|
|
45
|
+
* AskUserQuestion option's `preview` (mockup/code-snippet/comparison) — ACP's
|
|
46
|
+
* `EnumOption` has no first-class slot for it (confirmed on disk against the
|
|
47
|
+
* installed `claude-agent-acp/dist/elicitation.js`). */
|
|
48
|
+
const OPTION_PREVIEW_META_KEY = '_claude/askUserQuestionOption';
|
|
49
|
+
|
|
50
|
+
/** Titled or bare enum options → a uniform `{value, label, description, preview}`
|
|
51
|
+
* list. `oneOf`/array-items `anyOf` carry `{const, title, description?, _meta?}`;
|
|
52
|
+
* a bare `enum` is just an array of strings with no separate label/preview. */
|
|
53
|
+
function normalizeOptions(schema) {
|
|
54
|
+
if (Array.isArray(schema?.oneOf)) {
|
|
55
|
+
return schema.oneOf.map((o) => ({
|
|
56
|
+
value: o?.const,
|
|
57
|
+
label: o?.title ?? String(o?.const ?? ''),
|
|
58
|
+
description: o?.description ?? null,
|
|
59
|
+
preview: o?._meta?.[OPTION_PREVIEW_META_KEY]?.preview ?? null,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(schema?.enum)) {
|
|
63
|
+
return schema.enum.map((v) => ({
|
|
64
|
+
value: v,
|
|
65
|
+
label: String(v),
|
|
66
|
+
description: null,
|
|
67
|
+
preview: null,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Classify one ElicitationPropertySchema into a renderable `kind` + its options. */
|
|
74
|
+
function classifyProperty(prop) {
|
|
75
|
+
if (!prop || typeof prop !== 'object') return null;
|
|
76
|
+
if (prop.type === 'string' && (Array.isArray(prop.oneOf) || Array.isArray(prop.enum))) {
|
|
77
|
+
return { kind: 'single', options: normalizeOptions(prop) };
|
|
78
|
+
}
|
|
79
|
+
if (prop.type === 'array') {
|
|
80
|
+
const items = prop.items;
|
|
81
|
+
const options = Array.isArray(items?.anyOf)
|
|
82
|
+
? normalizeOptions({ oneOf: items.anyOf })
|
|
83
|
+
: normalizeOptions(items);
|
|
84
|
+
if (options.length) return { kind: 'multi', options };
|
|
85
|
+
return { kind: 'text', options: [] }; // array without an item enum — no renderer for it; fall back to text
|
|
86
|
+
}
|
|
87
|
+
// string/number/integer/boolean with no enum, or anything unrecognized —
|
|
88
|
+
// render as a plain free-text field rather than dropping the question.
|
|
89
|
+
return { kind: 'text', options: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Walk `requestedSchema.properties` in object-key order into an ordered list
|
|
94
|
+
* of renderable question descriptors:
|
|
95
|
+
* { id, title, description, kind: 'single'|'multi'|'text', options, required, customFieldId }
|
|
96
|
+
* Tolerates a missing/malformed schema (returns `[]`) — never throws, since a
|
|
97
|
+
* malformed elicitation must still let the user Skip/Cancel rather than crash
|
|
98
|
+
* the panel.
|
|
99
|
+
*/
|
|
100
|
+
export function parseElicitationSchema(requestedSchema) {
|
|
101
|
+
const properties = requestedSchema?.properties;
|
|
102
|
+
if (!properties || typeof properties !== 'object') return [];
|
|
103
|
+
const keys = Object.keys(properties);
|
|
104
|
+
const required = new Set(Array.isArray(requestedSchema.required) ? requestedSchema.required : []);
|
|
105
|
+
// A key is a "custom" sibling (and must NOT render as its own top-level
|
|
106
|
+
// question) when `<base>_custom` names it AND `<base>` is also a property.
|
|
107
|
+
const customKeys = new Set();
|
|
108
|
+
for (const k of keys) {
|
|
109
|
+
if (!k.endsWith('_custom')) continue;
|
|
110
|
+
const base = k.slice(0, -'_custom'.length);
|
|
111
|
+
if (base && Object.hasOwn(properties, base)) customKeys.add(k);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const questions = [];
|
|
115
|
+
for (const id of keys) {
|
|
116
|
+
if (customKeys.has(id)) continue;
|
|
117
|
+
const prop = properties[id];
|
|
118
|
+
const classified = classifyProperty(prop);
|
|
119
|
+
if (!classified) continue;
|
|
120
|
+
const customFieldId = customKeys.has(`${id}_custom`) ? `${id}_custom` : null;
|
|
121
|
+
questions.push({
|
|
122
|
+
id,
|
|
123
|
+
title: prop.title ?? null,
|
|
124
|
+
description: prop.description ?? null,
|
|
125
|
+
kind: classified.kind,
|
|
126
|
+
options: classified.options,
|
|
127
|
+
required: required.has(id),
|
|
128
|
+
customFieldId,
|
|
129
|
+
secretShaped: looksLikeSecretRequest(prop.title, prop.description),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return questions;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fold the UI's answer state into the `content` map a `CreateElicitationResponse`
|
|
137
|
+
* `accept` action carries. `answers` is keyed by question id (string for
|
|
138
|
+
* single/text, string[] for multi) AND by `customFieldId` (string) where the
|
|
139
|
+
* user typed a free-text override.
|
|
140
|
+
*
|
|
141
|
+
* Single/text: a non-empty, trimmed custom answer WINS over a selection —
|
|
142
|
+
* mirrors `applyAskElicitationResponse`'s documented read order exactly (it
|
|
143
|
+
* checks the `_custom` field first and only falls back to the base field when
|
|
144
|
+
* empty), so a single-select AskUserQuestion field folds identically however
|
|
145
|
+
* the adapter reads it back. This is a real protocol constraint, not a UI
|
|
146
|
+
* choice — a single-select question only has room for one answer.
|
|
147
|
+
*
|
|
148
|
+
* Multi: a non-empty custom answer is APPENDED to the checked options instead
|
|
149
|
+
* (routed through the BASE field, `content[q.id]`, never `content[customFieldId]`)
|
|
150
|
+
* — dogfooding expected "type something extra" to ADD to a multi-select pick,
|
|
151
|
+
* not silently discard it the way single-select's override does. This is safe
|
|
152
|
+
* specifically because `applyAskElicitationResponse` only reads the custom key
|
|
153
|
+
* to decide whether to override; when it's absent, it falls through to
|
|
154
|
+
* `Array.isArray(value) ? value.join(", ") : ...` on the base field, which
|
|
155
|
+
* doesn't validate array entries against the declared enum — appending an
|
|
156
|
+
* arbitrary string is exactly what a real multi-select "and also…" answer
|
|
157
|
+
* should read as once joined. `content[customFieldId]` is deliberately never
|
|
158
|
+
* populated for a multi-select question, so the override path never fires.
|
|
159
|
+
*
|
|
160
|
+
* An unanswered, non-required question is simply omitted from `content`
|
|
161
|
+
* (Skip/leave-blank must never fabricate a value).
|
|
162
|
+
*/
|
|
163
|
+
export function buildElicitationContent(questions, answers) {
|
|
164
|
+
// `Object.create(null)` — a question/custom-field id of `__proto__` (an
|
|
165
|
+
// agent-supplied schema key, technically attacker-influenceable) would
|
|
166
|
+
// otherwise hit the Object.prototype accessor on a plain `{}` literal
|
|
167
|
+
// instead of creating an own key, silently dropping that answer
|
|
168
|
+
// (security-auditor finding; not exploitable across the wire since
|
|
169
|
+
// JSON.stringify never serializes it either way, but a null-prototype
|
|
170
|
+
// object closes it outright rather than relying on that side effect).
|
|
171
|
+
const content = Object.create(null);
|
|
172
|
+
const src = answers && typeof answers === 'object' ? answers : {};
|
|
173
|
+
for (const q of questions ?? []) {
|
|
174
|
+
const custom = q.customFieldId ? src[q.customFieldId] : undefined;
|
|
175
|
+
const trimmedCustom = typeof custom === 'string' ? custom.trim() : '';
|
|
176
|
+
|
|
177
|
+
if (q.kind === 'multi') {
|
|
178
|
+
const selected = Array.isArray(src[q.id]) ? src[q.id] : [];
|
|
179
|
+
const combined = trimmedCustom ? [...selected, trimmedCustom] : selected;
|
|
180
|
+
if (combined.length) content[q.id] = combined;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (trimmedCustom) {
|
|
185
|
+
content[q.customFieldId] = trimmedCustom;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const value = src[q.id];
|
|
189
|
+
if (typeof value === 'string' && value !== '') {
|
|
190
|
+
content[q.id] = value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return content;
|
|
194
|
+
}
|