@1agh/maude 0.55.0 → 0.57.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 +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -170,6 +170,11 @@ function ProviderCard({ provider, onChanged }) {
|
|
|
170
170
|
// default. Local whisper is free/offline/no-key; the cloud engines need a key
|
|
171
171
|
// (managed in the provider cards above).
|
|
172
172
|
const TRANSCRIPTION_ENGINES = [
|
|
173
|
+
{
|
|
174
|
+
id: 'auto',
|
|
175
|
+
label: 'Auto',
|
|
176
|
+
note: 'Use the best engine this machine is set up for — the card says which.',
|
|
177
|
+
},
|
|
173
178
|
{
|
|
174
179
|
id: 'whisper',
|
|
175
180
|
label: 'Local whisper.cpp',
|
|
@@ -183,10 +188,15 @@ const TRANSCRIPTION_ENGINES = [
|
|
|
183
188
|
{ id: 'groq', label: 'Groq Whisper', note: 'Cloud · fast · needs a Groq key' },
|
|
184
189
|
];
|
|
185
190
|
|
|
191
|
+
const ENGINE_LABELS = Object.fromEntries(TRANSCRIPTION_ENGINES.map((e) => [e.id, e.label]));
|
|
192
|
+
|
|
186
193
|
function TranscriptionEngineCard() {
|
|
187
194
|
const [engine, setEngine] = useState(null); // null = loading
|
|
188
195
|
const [busy, setBusy] = useState(false);
|
|
189
196
|
const [status, setStatus] = useState(null);
|
|
197
|
+
// What `auto` currently resolves to, from the server (key presence lives in
|
|
198
|
+
// the keychain). Shown verbatim so a delegated choice is still a visible one.
|
|
199
|
+
const [auto, setAuto] = useState(null);
|
|
190
200
|
|
|
191
201
|
useEffect(() => {
|
|
192
202
|
fetch('/_api/generate/prefs')
|
|
@@ -195,6 +205,15 @@ function TranscriptionEngineCard() {
|
|
|
195
205
|
.catch(() => setEngine('whisper'));
|
|
196
206
|
}, []);
|
|
197
207
|
|
|
208
|
+
// Re-read on every selection change: adding a key elsewhere in Settings moves
|
|
209
|
+
// what Auto resolves to, and the user should see that immediately.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
fetch('/_api/generate/whisper-model')
|
|
212
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
213
|
+
.then((d) => setAuto(d?.auto || null))
|
|
214
|
+
.catch(() => {});
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
198
217
|
async function choose(id) {
|
|
199
218
|
if (id === engine) return;
|
|
200
219
|
const prev = engine;
|
|
@@ -223,9 +242,16 @@ function TranscriptionEngineCard() {
|
|
|
223
242
|
<span className="st-provider-name">Subtitles — transcription engine</span>
|
|
224
243
|
</div>
|
|
225
244
|
<div className="st-provider-notes">
|
|
226
|
-
Which engine turns audio into subtitles.
|
|
227
|
-
|
|
245
|
+
Which engine turns audio into subtitles. Maude never switches engines behind your back — pin
|
|
246
|
+
one, or pick <strong>Auto</strong> and it uses the best you're set up for and says so.
|
|
228
247
|
</div>
|
|
248
|
+
{engine === 'auto' && auto && (
|
|
249
|
+
<div className="st-provider-notes">
|
|
250
|
+
Right now Auto uses <strong>{ENGINE_LABELS[auto.engine] || auto.engine}</strong> —{' '}
|
|
251
|
+
{auto.reason}
|
|
252
|
+
{auto.cloud && ' Audio is uploaded to that provider and billed to your account.'}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
229
255
|
<div className="st-engine-radios" role="radiogroup" aria-label="Transcription engine">
|
|
230
256
|
{TRANSCRIPTION_ENGINES.map((e) => (
|
|
231
257
|
<label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
|
|
@@ -314,6 +340,7 @@ function WhisperModelCard() {
|
|
|
314
340
|
|
|
315
341
|
const dl = state?.downloading;
|
|
316
342
|
const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
|
|
343
|
+
const modelsReady = (state?.models || []).some((m) => m.downloaded);
|
|
317
344
|
|
|
318
345
|
return (
|
|
319
346
|
<div className="st-provider-card">
|
|
@@ -321,10 +348,58 @@ function WhisperModelCard() {
|
|
|
321
348
|
<span className="st-provider-name">Local subtitle models (whisper.cpp)</span>
|
|
322
349
|
</div>
|
|
323
350
|
<div className="st-provider-notes">
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
only and never committed.
|
|
351
|
+
Local subtitles need two things: the whisper.cpp engine and one model. Models are stored on
|
|
352
|
+
this machine only and never committed.
|
|
327
353
|
</div>
|
|
354
|
+
{state && (
|
|
355
|
+
<div className="st-setup">
|
|
356
|
+
<div className={`st-step${state.setup?.installed ? ' is-done' : ''}`}>
|
|
357
|
+
<span className="st-step-n">{state.setup?.installed ? '✓' : '1'}</span>
|
|
358
|
+
<div className="st-step-body">
|
|
359
|
+
<span className="st-step-title">
|
|
360
|
+
{state.setup?.installed
|
|
361
|
+
? 'whisper.cpp engine is installed'
|
|
362
|
+
: 'Install the whisper.cpp engine'}
|
|
363
|
+
</span>
|
|
364
|
+
{/* Only routes that work on THIS machine — no `brew install`
|
|
365
|
+
without Homebrew. whisper.cpp ships no macOS prebuilt, so the
|
|
366
|
+
brew-less answer is a cloud engine or a source build. */}
|
|
367
|
+
{(state.setup?.options || []).slice(0, 1).map((opt) =>
|
|
368
|
+
opt.kind === 'command' ? (
|
|
369
|
+
<CopyCommand key={opt.id} command={opt.command} label={`Copy: ${opt.label}`} />
|
|
370
|
+
) : (
|
|
371
|
+
<span key={opt.id} className="st-provider-notes">
|
|
372
|
+
{opt.note}
|
|
373
|
+
</span>
|
|
374
|
+
)
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
<div className={`st-step${modelsReady ? ' is-done' : ''}`}>
|
|
379
|
+
<span className="st-step-n">{modelsReady ? '✓' : '2'}</span>
|
|
380
|
+
<div className="st-step-body">
|
|
381
|
+
<span className="st-step-title">
|
|
382
|
+
{modelsReady ? 'Model ready' : 'Download a model (below)'}
|
|
383
|
+
</span>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
{(state.setup?.options || []).length > 1 && (
|
|
387
|
+
<details className="st-setup-more">
|
|
388
|
+
<summary>Other ways to get the engine</summary>
|
|
389
|
+
{state.setup.options.slice(1).map((opt) => (
|
|
390
|
+
<div key={opt.id} className="st-setup-option">
|
|
391
|
+
<span className="st-setup-option-label">{opt.label}</span>
|
|
392
|
+
<CopyCommand
|
|
393
|
+
command={opt.kind === 'command' ? opt.command : opt.url}
|
|
394
|
+
label={`Copy: ${opt.label}`}
|
|
395
|
+
/>
|
|
396
|
+
{opt.note ? <span className="st-provider-notes">{opt.note}</span> : null}
|
|
397
|
+
</div>
|
|
398
|
+
))}
|
|
399
|
+
</details>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
328
403
|
{err && (
|
|
329
404
|
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
330
405
|
{err}
|
|
@@ -389,7 +464,7 @@ function WhisperModelCard() {
|
|
|
389
464
|
// sibling of the Subtitles section. Persists `keyframeEngine` to /_api/generate/prefs.
|
|
390
465
|
const KEYFRAME_ENGINES = [
|
|
391
466
|
{ id: 'auto', label: 'Auto', note: 'Use the best available: Gemma scout → ffmpeg → blind.' },
|
|
392
|
-
{ id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs
|
|
467
|
+
{ id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs Ollama (gemma3 vision) or mlx-vlm + a model.' },
|
|
393
468
|
{ id: 'ffmpeg', label: 'ffmpeg scene-detect', note: 'Scene cuts + endpoints. Needs ffmpeg. No model download.' },
|
|
394
469
|
{ id: 'blind', label: 'Blind (even-spaced)', note: 'Chromium fallback — works with neither ffmpeg nor Gemma.' },
|
|
395
470
|
];
|
|
@@ -468,11 +543,115 @@ function KeyframeEngineCard() {
|
|
|
468
543
|
);
|
|
469
544
|
}
|
|
470
545
|
|
|
546
|
+
// A copy/paste terminal command with a Copy button (same look as the ACP
|
|
547
|
+
// readiness rows — the rdy-* classes are global in the bundled stylesheet).
|
|
548
|
+
function CopyCommand({ command, label }) {
|
|
549
|
+
const [copied, setCopied] = useState(false);
|
|
550
|
+
const copy = () => {
|
|
551
|
+
navigator.clipboard?.writeText(command).then(
|
|
552
|
+
() => {
|
|
553
|
+
setCopied(true);
|
|
554
|
+
setTimeout(() => setCopied(false), 1500);
|
|
555
|
+
},
|
|
556
|
+
() => {}
|
|
557
|
+
);
|
|
558
|
+
};
|
|
559
|
+
return (
|
|
560
|
+
<span className="rdy-cmd">
|
|
561
|
+
<code className="rdy-cmd-tx">{command}</code>
|
|
562
|
+
<button type="button" className="rdy-copy" onClick={copy} aria-label={label || 'Copy command'}>
|
|
563
|
+
{copied ? 'Copied' : 'Copy'}
|
|
564
|
+
</button>
|
|
565
|
+
</span>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// The setup path, as STEPS rather than a wall of options. Only the shortest
|
|
570
|
+
// route is visible (install → pull); everything else — Homebrew, the download
|
|
571
|
+
// link, the whole mlx-vlm alternative — sits folded behind one disclosure, so a
|
|
572
|
+
// first-time reader sees two commands, not seven.
|
|
573
|
+
function SetupSteps({ state }) {
|
|
574
|
+
const ollama = state.ollama || {};
|
|
575
|
+
const setup = ollama.setup || [];
|
|
576
|
+
// The server orders routes best-first, so the head is the one step to take now
|
|
577
|
+
// ("install", or "start" when the binary is already there). Everything after
|
|
578
|
+
// it is an alternative way to reach the same place.
|
|
579
|
+
const [primary, ...alternatives] = setup;
|
|
580
|
+
const step1Done = Boolean(ollama.available);
|
|
581
|
+
const step2Done = Boolean(ollama.model);
|
|
582
|
+
// Once Ollama is running, its own route list collapses to the pull command —
|
|
583
|
+
// which IS step 2, so it must not also render as step 1.
|
|
584
|
+
const step1 = step1Done || primary?.id === 'pull' ? null : primary;
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<div className="st-setup">
|
|
588
|
+
<div className={`st-step${step1Done ? ' is-done' : ''}`}>
|
|
589
|
+
<span className="st-step-n">{step1Done ? '✓' : '1'}</span>
|
|
590
|
+
<div className="st-step-body">
|
|
591
|
+
<span className="st-step-title">
|
|
592
|
+
{step1Done
|
|
593
|
+
? 'Ollama is running'
|
|
594
|
+
: ollama.installed
|
|
595
|
+
? 'Start Ollama'
|
|
596
|
+
: 'Install Ollama'}
|
|
597
|
+
</span>
|
|
598
|
+
{step1?.kind === 'command' ? (
|
|
599
|
+
<CopyCommand command={step1.command} label="Copy the install command" />
|
|
600
|
+
) : null}
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
<div className={`st-step${step2Done ? ' is-done' : ''}`}>
|
|
604
|
+
<span className="st-step-n">{step2Done ? '✓' : '2'}</span>
|
|
605
|
+
<div className="st-step-body">
|
|
606
|
+
<span className="st-step-title">
|
|
607
|
+
{step2Done ? `Model ready (${ollama.model})` : 'Download the model'}
|
|
608
|
+
</span>
|
|
609
|
+
{step2Done ? null : (
|
|
610
|
+
<CopyCommand command={ollama.pullCommand} label="Copy the model pull command" />
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<details className="st-setup-more">
|
|
615
|
+
<summary>Other ways to install</summary>
|
|
616
|
+
{alternatives.map((opt) => (
|
|
617
|
+
<div key={opt.id} className="st-setup-option">
|
|
618
|
+
<span className="st-setup-option-label">{opt.label}</span>
|
|
619
|
+
{/* A link is rendered as a copyable URL, not a button: the desktop
|
|
620
|
+
shell has no general URL opener by design (DDR-054). */}
|
|
621
|
+
<CopyCommand
|
|
622
|
+
command={opt.kind === 'command' ? opt.command : opt.url}
|
|
623
|
+
label={`Copy: ${opt.label}`}
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
))}
|
|
627
|
+
<div className="st-setup-option">
|
|
628
|
+
<span className="st-setup-option-label">
|
|
629
|
+
mlx-vlm instead of Ollama — faster, Apple Silicon only
|
|
630
|
+
</span>
|
|
631
|
+
{state.mlx?.supported ? (
|
|
632
|
+
<>
|
|
633
|
+
<CopyCommand command={state.mlx.command} label="Copy the mlx-vlm install command" />
|
|
634
|
+
<span className="st-provider-notes">
|
|
635
|
+
Then download a model below. Models live in your HuggingFace cache.
|
|
636
|
+
</span>
|
|
637
|
+
</>
|
|
638
|
+
) : (
|
|
639
|
+
// Never show a command that would fail here — say why instead.
|
|
640
|
+
<span className="st-provider-notes">{state.mlx?.reason}</span>
|
|
641
|
+
)}
|
|
642
|
+
</div>
|
|
643
|
+
</details>
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
471
648
|
// The MODEL half of the Gemma scout — one click, mirroring WhisperModelCard. The
|
|
472
|
-
// RUNTIME
|
|
473
|
-
//
|
|
649
|
+
// RUNTIME is a manual install the app can't do for you (DDR-183), but the card
|
|
650
|
+
// hands you copy/paste commands for BOTH runtime paths — Ollama (simplest: one
|
|
651
|
+
// app, `ollama pull`, no Python) and mlx-vlm (fastest, the benchmarked path) —
|
|
652
|
+
// and re-probes on its own, so the card unlocks itself once you've run one.
|
|
474
653
|
function GemmaModelCard() {
|
|
475
|
-
const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable }
|
|
654
|
+
const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable, installCommand, ollama }
|
|
476
655
|
const [err, setErr] = useState(null);
|
|
477
656
|
|
|
478
657
|
const load = useCallback(() => {
|
|
@@ -492,6 +671,16 @@ function GemmaModelCard() {
|
|
|
492
671
|
return () => clearInterval(t);
|
|
493
672
|
}, [state?.downloading, load]);
|
|
494
673
|
|
|
674
|
+
// No scout runtime yet → the user is likely running one of the install
|
|
675
|
+
// commands in a terminal right now. Re-probe every 10 s (server-side the
|
|
676
|
+
// probes are TTL-cached) so the card unlocks itself without a reopen.
|
|
677
|
+
const noRuntime = state ? !state.mlxVlmAvailable && !state.ollama?.model : false;
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
if (!noRuntime) return undefined;
|
|
680
|
+
const t = setInterval(load, 10_000);
|
|
681
|
+
return () => clearInterval(t);
|
|
682
|
+
}, [noRuntime, load]);
|
|
683
|
+
|
|
495
684
|
async function download(id) {
|
|
496
685
|
setErr(null);
|
|
497
686
|
try {
|
|
@@ -510,8 +699,14 @@ function GemmaModelCard() {
|
|
|
510
699
|
const dl = state?.downloading;
|
|
511
700
|
const mlx = state?.mlxVlmAvailable;
|
|
512
701
|
const ff = state?.ffmpegAvailable;
|
|
702
|
+
const ollama = state?.ollama;
|
|
703
|
+
const scoutReady = Boolean(mlx || ollama?.model);
|
|
513
704
|
const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
|
|
514
|
-
const activeTier =
|
|
705
|
+
const activeTier = scoutReady
|
|
706
|
+
? `Gemma scout${mlx ? '' : ` (Ollama · ${ollama.model})`}`
|
|
707
|
+
: ff
|
|
708
|
+
? 'ffmpeg scene-detect'
|
|
709
|
+
: 'blind (even-spaced)';
|
|
515
710
|
|
|
516
711
|
return (
|
|
517
712
|
<div className="st-provider-card">
|
|
@@ -519,16 +714,14 @@ function GemmaModelCard() {
|
|
|
519
714
|
<span className="st-provider-name">Gemma scout model (optional)</span>
|
|
520
715
|
</div>
|
|
521
716
|
<div className="st-provider-notes">
|
|
522
|
-
|
|
523
|
-
|
|
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.
|
|
717
|
+
Adds semantic action-beat detection on top of scene cuts. Two short steps in a terminal —
|
|
718
|
+
Maude picks the result up on its own.
|
|
526
719
|
</div>
|
|
720
|
+
{state && !scoutReady && <SetupSteps state={state} />}
|
|
527
721
|
{state && (
|
|
528
722
|
<div className="st-provider-notes">
|
|
529
|
-
|
|
530
|
-
{
|
|
531
|
-
{!mlx && !ff && ' — install ffmpeg for scene-aware frames, or mlx-vlm for the Gemma scout.'}
|
|
723
|
+
Active tier on this machine: <strong>{activeTier}</strong>
|
|
724
|
+
{scoutReady && !mlx && ' — Ollama manages its own models, nothing to download below.'}
|
|
532
725
|
</div>
|
|
533
726
|
)}
|
|
534
727
|
{err && (
|
|
@@ -545,6 +738,13 @@ function GemmaModelCard() {
|
|
|
545
738
|
<div className="st-model-list">
|
|
546
739
|
{(state?.models || []).map((m) => {
|
|
547
740
|
const busy = dl && !dl.error && dl.id === m.id;
|
|
741
|
+
const runtimeReady = m.runtime === 'ollama' ? Boolean(ollama?.available) : Boolean(mlx);
|
|
742
|
+
const blockedNote =
|
|
743
|
+
m.runtime === 'ollama'
|
|
744
|
+
? ollama?.installed
|
|
745
|
+
? 'start Ollama first'
|
|
746
|
+
: 'needs Ollama'
|
|
747
|
+
: 'needs mlx-vlm';
|
|
548
748
|
return (
|
|
549
749
|
<div key={m.id} className="st-model-row">
|
|
550
750
|
<div className="st-model-info">
|
|
@@ -554,7 +754,10 @@ function GemmaModelCard() {
|
|
|
554
754
|
</span>
|
|
555
755
|
<span className="st-engine-radio-note">{m.note}</span>
|
|
556
756
|
{busy && (
|
|
557
|
-
<span className="st-model-progress">
|
|
757
|
+
<span className="st-model-progress">
|
|
758
|
+
{m.runtime === 'ollama' ? 'Pulling' : 'Downloading'}…{' '}
|
|
759
|
+
{pctOf(dl.received, dl.total)}%
|
|
760
|
+
</span>
|
|
558
761
|
)}
|
|
559
762
|
</div>
|
|
560
763
|
<div className="st-model-actions">
|
|
@@ -563,15 +766,24 @@ function GemmaModelCard() {
|
|
|
563
766
|
<Icon name="check" size={12} /> ready
|
|
564
767
|
</span>
|
|
565
768
|
) : (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
769
|
+
// Each row is enabled by ITS OWN runtime — an Ollama model is
|
|
770
|
+
// one click whenever Ollama is running (the server pulls it
|
|
771
|
+
// through /api/pull); an mlx snapshot needs mlx-vlm. The
|
|
772
|
+
// button keeps its label and just goes disabled, with the
|
|
773
|
+
// reason in small type underneath.
|
|
774
|
+
<span className="st-model-action">
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
className="st-btn"
|
|
778
|
+
disabled={!runtimeReady || !!(dl && !dl.error)}
|
|
779
|
+
onClick={() => download(m.id)}
|
|
780
|
+
>
|
|
781
|
+
{busy ? 'Downloading…' : 'Download'}
|
|
782
|
+
</button>
|
|
783
|
+
{runtimeReady ? null : (
|
|
784
|
+
<span className="st-model-action-note">{blockedNote}</span>
|
|
785
|
+
)}
|
|
786
|
+
</span>
|
|
575
787
|
)}
|
|
576
788
|
</div>
|
|
577
789
|
</div>
|
|
@@ -123,6 +123,20 @@ export function applyUpdate(parts, toolIndex, u) {
|
|
|
123
123
|
export function createAcpConnection() {
|
|
124
124
|
let ws = null;
|
|
125
125
|
let openPromise = null;
|
|
126
|
+
// ── The re-attach seam (Addendum Task 8) ──────────────────────────────────
|
|
127
|
+
// The chat this connection speaks for, and how far its history has been
|
|
128
|
+
// hydrated. Both are set by `bindChat()` BEFORE the socket opens, because the
|
|
129
|
+
// `attach` frame that carries them is sent from `onopen`.
|
|
130
|
+
//
|
|
131
|
+
// `hydratedSeq` is a transcript LINE NUMBER, not a message count — the server
|
|
132
|
+
// stamps every `update` with the line it occupies, so the two are directly
|
|
133
|
+
// comparable. Frames at or below it are things the panel already rendered
|
|
134
|
+
// from the HTTP hydration, and dropping them is what stops a reload from
|
|
135
|
+
// showing the last few seconds twice.
|
|
136
|
+
let attachedChatId = null;
|
|
137
|
+
let hydratedSeq = 0;
|
|
138
|
+
/** True when the server said we re-joined a turn that is STILL running. */
|
|
139
|
+
const attachedListeners = new Set();
|
|
126
140
|
let turnHandler = null; // the in-flight run()'s frame sink
|
|
127
141
|
const statusListeners = new Set();
|
|
128
142
|
const status = { available: null, reason: undefined, ready: false };
|
|
@@ -240,6 +254,26 @@ export function createAcpConnection() {
|
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
function onFrame(frame) {
|
|
257
|
+
// ── The re-attach seam (Addendum Task 8) ────────────────────────────────
|
|
258
|
+
// Drop anything the panel already rendered from its HTTP hydration. The
|
|
259
|
+
// server replays the tail on `attach` and the live stream continues past
|
|
260
|
+
// it, so without this filter a reload mid-turn would show the overlap
|
|
261
|
+
// twice. Frames with no `seq` (every non-`update` frame) are unaffected.
|
|
262
|
+
if (typeof frame.seq === 'number') {
|
|
263
|
+
if (frame.seq <= hydratedSeq) return;
|
|
264
|
+
// Advance the marker as we consume, so a SECOND reconnect on the same
|
|
265
|
+
// page (a dropped socket, not a page reload) re-attaches from where the
|
|
266
|
+
// live stream actually got to rather than from the original hydration.
|
|
267
|
+
hydratedSeq = frame.seq;
|
|
268
|
+
}
|
|
269
|
+
if (frame.t === 'attached') {
|
|
270
|
+
for (const fn of attachedListeners) fn({ chat: frame.chat, running: !!frame.running });
|
|
271
|
+
// Re-joined a turn that is STILL running: restore the busy state, or the
|
|
272
|
+
// panel would look idle while the agent keeps working (and the composer
|
|
273
|
+
// would happily start a second, concurrent turn).
|
|
274
|
+
if (frame.running) setBusy(true);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
243
277
|
if (frame.t === 'ready') {
|
|
244
278
|
status.ready = true;
|
|
245
279
|
status.available = frame.available;
|
|
@@ -274,7 +308,17 @@ export function createAcpConnection() {
|
|
|
274
308
|
if (frame.t === 'permission-request') {
|
|
275
309
|
pendingPermissions = [
|
|
276
310
|
...pendingPermissions,
|
|
277
|
-
{
|
|
311
|
+
{
|
|
312
|
+
id: frame.id,
|
|
313
|
+
toolCall: frame.toolCall,
|
|
314
|
+
options: frame.options ?? [],
|
|
315
|
+
// feature-acp-write-path-scope — present only when the server's
|
|
316
|
+
// write-path gate declined a write tool. `{ outOfProjectWrite,
|
|
317
|
+
// resolvedPaths, scopeRoot, reason }`; absent for every ordinary
|
|
318
|
+
// permission request, so PermissionPrompt's "outside the project"
|
|
319
|
+
// copy can't fire on one.
|
|
320
|
+
scope: frame.scope ?? null,
|
|
321
|
+
},
|
|
278
322
|
];
|
|
279
323
|
emitPermissions();
|
|
280
324
|
return;
|
|
@@ -329,7 +373,28 @@ export function createAcpConnection() {
|
|
|
329
373
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
330
374
|
const sock = new WebSocket(`${scheme}://${location.host}${WS_PATH}`);
|
|
331
375
|
ws = sock;
|
|
332
|
-
sock.onopen = () =>
|
|
376
|
+
sock.onopen = () => {
|
|
377
|
+
// Addendum Task 8 — bind to this chat's (possibly still running) bridge
|
|
378
|
+
// and close the re-attach seam in the same frame. `hydratedSeq` is the
|
|
379
|
+
// transcript line the panel hydrated over HTTP; the server replays
|
|
380
|
+
// exactly what came after it. A reload mid-turn therefore rejoins the
|
|
381
|
+
// live stream with no hole and no doubled text.
|
|
382
|
+
//
|
|
383
|
+
// Sent unconditionally, including for a brand-new chat (seq 0, nothing
|
|
384
|
+
// to replay) — the attach is what registers the socket as a sink, so
|
|
385
|
+
// skipping it for new chats would leave them detached until the first
|
|
386
|
+
// prompt.
|
|
387
|
+
if (attachedChatId) {
|
|
388
|
+
try {
|
|
389
|
+
sock.send(
|
|
390
|
+
JSON.stringify({ t: 'attach', chat: attachedChatId, seq: hydratedSeq })
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
/* the frame handlers below surface a dead socket */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
resolve();
|
|
397
|
+
};
|
|
333
398
|
sock.onerror = () => reject(new Error('Could not reach the Claude bridge.'));
|
|
334
399
|
sock.onclose = () => {
|
|
335
400
|
ws = null;
|
|
@@ -423,13 +488,37 @@ export function createAcpConnection() {
|
|
|
423
488
|
* "resolved" frame; the turn just continues). A response for an id that's
|
|
424
489
|
* no longer pending (already timed out, or a duplicate click) is a no-op.
|
|
425
490
|
*/
|
|
491
|
+
/**
|
|
492
|
+
* Bind this connection to a chat and tell it how far the panel already
|
|
493
|
+
* hydrated that chat's transcript over HTTP (`seq` — a transcript LINE
|
|
494
|
+
* number, from the `X-Maude-Chat-Seq` response header).
|
|
495
|
+
*
|
|
496
|
+
* Must be called BEFORE the socket opens: the `attach` frame is sent from
|
|
497
|
+
* `onopen`, and it is what registers this socket as a sink for the chat's
|
|
498
|
+
* (possibly still running) server-side bridge. Calling it later still
|
|
499
|
+
* works for a subsequent reconnect, it just misses the current one.
|
|
500
|
+
*/
|
|
501
|
+
bindChat(chatId, seq = 0) {
|
|
502
|
+
attachedChatId = chatId || null;
|
|
503
|
+
hydratedSeq = Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/** Subscribe to `{ chat, running }` — fires once per successful attach.
|
|
507
|
+
* `running: true` means this socket re-joined a LIVE turn. */
|
|
508
|
+
onAttached(fn) {
|
|
509
|
+
attachedListeners.add(fn);
|
|
510
|
+
return () => attachedListeners.delete(fn);
|
|
511
|
+
},
|
|
512
|
+
|
|
426
513
|
async respondPermission(id, decision) {
|
|
427
514
|
if (!pendingPermissions.some((p) => p.id === id)) return;
|
|
428
515
|
pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
|
|
429
516
|
emitPermissions();
|
|
430
517
|
try {
|
|
431
518
|
await ensureOpen();
|
|
432
|
-
|
|
519
|
+
// `chat` is echoed back from the request frame — a bridge is keyed by
|
|
520
|
+
// chat now, not by socket, so the response has to name which one.
|
|
521
|
+
ws?.send(JSON.stringify({ t: 'permission-response', id, chat: attachedChatId, decision }));
|
|
433
522
|
} catch {
|
|
434
523
|
/* socket unavailable — the bridge's own timeout will deny this request */
|
|
435
524
|
}
|
|
@@ -446,7 +535,9 @@ export function createAcpConnection() {
|
|
|
446
535
|
emitElicitations();
|
|
447
536
|
try {
|
|
448
537
|
await ensureOpen();
|
|
449
|
-
ws?.send(
|
|
538
|
+
ws?.send(
|
|
539
|
+
JSON.stringify({ t: 'elicitation-response', id, chat: attachedChatId, ...response })
|
|
540
|
+
);
|
|
450
541
|
} catch {
|
|
451
542
|
/* socket unavailable — the bridge's own timeout will decline this request */
|
|
452
543
|
}
|
|
@@ -532,7 +623,7 @@ export function createAcpConnection() {
|
|
|
532
623
|
};
|
|
533
624
|
const cancel = () => {
|
|
534
625
|
try {
|
|
535
|
-
ws?.send(JSON.stringify({ t: 'cancel' }));
|
|
626
|
+
ws?.send(JSON.stringify({ t: 'cancel', chat: attachedChatId || undefined }));
|
|
536
627
|
} catch {
|
|
537
628
|
/* socket already gone */
|
|
538
629
|
}
|
|
@@ -2240,10 +2240,19 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2240
2240
|
|
|
2241
2241
|
/* identity rail — sidebar footer */
|
|
2242
2242
|
.gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2243
|
-
|
|
2243
|
+
/* Truncation the code already assumed: CloudBar.jsx documents `title` as "the
|
|
2244
|
+
full sentence on hover (the rail truncates)" — and this rule did not exist,
|
|
2245
|
+
so a long hub-supplied project name wrapped and grew, pushing the rail into
|
|
2246
|
+
adjacent chrome. Its sibling .gi-rail-err has carried the same three
|
|
2247
|
+
properties all along. `anywhere` so a single unbreakable 60-char token
|
|
2248
|
+
cannot overflow horizontally either. */
|
|
2249
|
+
.gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: anywhere; }
|
|
2244
2250
|
.gi-rail-signin { width: 100%; justify-content: center; }
|
|
2245
2251
|
.gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
|
|
2246
|
-
|
|
2252
|
+
/* …but the deep-link confirm strip is a paragraph that MUST wrap — it explains
|
|
2253
|
+
both sides of a consent decision, and one ellipsised line would hide half of
|
|
2254
|
+
what the person is agreeing to. */
|
|
2255
|
+
.gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; white-space: normal; overflow: visible; }
|
|
2247
2256
|
.gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2248
2257
|
.gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
|
|
2249
2258
|
.gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
|
|
@@ -1725,6 +1725,65 @@
|
|
|
1725
1725
|
line-height: 1.5;
|
|
1726
1726
|
margin-top: 6px;
|
|
1727
1727
|
}
|
|
1728
|
+
/* Scout setup (feature-scene-aware-keyframes) — the shortest path as numbered
|
|
1729
|
+
steps; every alternative route folds behind one disclosure so the default
|
|
1730
|
+
view is two commands, not seven. */
|
|
1731
|
+
.st-setup {
|
|
1732
|
+
margin-top: 8px;
|
|
1733
|
+
}
|
|
1734
|
+
.st-step {
|
|
1735
|
+
display: flex;
|
|
1736
|
+
gap: 8px;
|
|
1737
|
+
align-items: flex-start;
|
|
1738
|
+
margin-top: 8px;
|
|
1739
|
+
}
|
|
1740
|
+
.st-step-n {
|
|
1741
|
+
flex-shrink: 0;
|
|
1742
|
+
width: 18px;
|
|
1743
|
+
height: 18px;
|
|
1744
|
+
border-radius: 50%;
|
|
1745
|
+
border: 1px solid var(--border-subtle);
|
|
1746
|
+
color: var(--fg-2);
|
|
1747
|
+
font-size: 10px;
|
|
1748
|
+
line-height: 16px;
|
|
1749
|
+
text-align: center;
|
|
1750
|
+
}
|
|
1751
|
+
.st-step.is-done .st-step-n {
|
|
1752
|
+
border-color: var(--accent);
|
|
1753
|
+
color: var(--accent);
|
|
1754
|
+
}
|
|
1755
|
+
.st-step-body {
|
|
1756
|
+
min-width: 0;
|
|
1757
|
+
flex: 1 1 auto;
|
|
1758
|
+
}
|
|
1759
|
+
.st-step-title {
|
|
1760
|
+
display: block;
|
|
1761
|
+
font-size: 11px;
|
|
1762
|
+
color: var(--fg-1);
|
|
1763
|
+
}
|
|
1764
|
+
.st-step.is-done .st-step-title {
|
|
1765
|
+
color: var(--fg-3);
|
|
1766
|
+
}
|
|
1767
|
+
.st-setup-more {
|
|
1768
|
+
margin-top: 10px;
|
|
1769
|
+
}
|
|
1770
|
+
.st-setup-more > summary {
|
|
1771
|
+
cursor: pointer;
|
|
1772
|
+
font-size: 11px;
|
|
1773
|
+
color: var(--fg-3);
|
|
1774
|
+
}
|
|
1775
|
+
.st-setup-more > summary:hover {
|
|
1776
|
+
color: var(--fg-1);
|
|
1777
|
+
}
|
|
1778
|
+
.st-setup-option {
|
|
1779
|
+
display: block;
|
|
1780
|
+
margin-top: 8px;
|
|
1781
|
+
}
|
|
1782
|
+
.st-setup-option-label {
|
|
1783
|
+
display: block;
|
|
1784
|
+
font-size: 11px;
|
|
1785
|
+
color: var(--fg-2);
|
|
1786
|
+
}
|
|
1728
1787
|
.st-provider-keylink {
|
|
1729
1788
|
display: inline-flex;
|
|
1730
1789
|
align-items: center;
|
|
@@ -1840,6 +1899,19 @@
|
|
|
1840
1899
|
gap: 8px;
|
|
1841
1900
|
flex-shrink: 0;
|
|
1842
1901
|
}
|
|
1902
|
+
/* A disabled action plus the reason it's disabled, in small type underneath —
|
|
1903
|
+
so the button label stays the action, not the explanation. */
|
|
1904
|
+
.st-model-action {
|
|
1905
|
+
display: flex;
|
|
1906
|
+
flex-direction: column;
|
|
1907
|
+
align-items: flex-end;
|
|
1908
|
+
gap: 3px;
|
|
1909
|
+
}
|
|
1910
|
+
.st-model-action-note {
|
|
1911
|
+
font-size: 10px;
|
|
1912
|
+
color: var(--fg-3);
|
|
1913
|
+
text-align: right;
|
|
1914
|
+
}
|
|
1843
1915
|
|
|
1844
1916
|
/* ── Generate dialog (feature-ai-media-generation, DDR-16x) ─────────────── */
|
|
1845
1917
|
.st-gen-prompt {
|