@1agh/maude 0.56.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.
Files changed (48) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/build.ts +28 -1
  7. package/apps/studio/client/app.jsx +47 -30
  8. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  9. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  10. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  11. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  12. package/apps/studio/client/panels/acp-runtime.js +96 -5
  13. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  14. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  15. package/apps/studio/dist/client.bundle.js +1278 -1278
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/http.ts +51 -2
  18. package/apps/studio/server.ts +11 -0
  19. package/apps/studio/sync/connection-state.ts +116 -6
  20. package/apps/studio/sync/index.ts +50 -1
  21. package/apps/studio/sync/presentation.ts +273 -0
  22. package/apps/studio/sync/remote-docs.ts +191 -0
  23. package/apps/studio/sync/status.ts +4 -1
  24. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  25. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  26. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  27. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  28. package/apps/studio/test/acp-commands.test.ts +5 -0
  29. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  30. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  31. package/apps/studio/test/acp-permission.test.ts +18 -2
  32. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  33. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  35. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  36. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  37. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  38. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  40. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  41. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  42. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  43. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  44. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  45. package/apps/studio/test/sync-presentation.test.ts +185 -0
  46. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  47. package/apps/studio/whats-new.json +18 -0
  48. package/package.json +8 -8
@@ -97,6 +97,30 @@ async function postJson(url, body, opts = {}) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Copy for the "a chat is mid-turn" confirm (Addendum Task 9). Pure + exported
102
+ * so the WORDING is unit-testable without rendering the switcher (which needs a
103
+ * native shim and three fetches on mount) — same rationale as
104
+ * PermissionPrompt.jsx's `pickDefaultAllow`/`describeOutOfProject`: on a safety
105
+ * prompt the affordance IS the control, so the words are worth a test.
106
+ *
107
+ * The wording is deliberately "is editing files on this branch", NOT "this will
108
+ * stop it". Before Task 8 the reload genuinely killed the turn and "will stop
109
+ * it" was true; now the bridge outlives its socket, so the turn KEEPS GOING —
110
+ * into a worktree that has moved under it. Saying "will stop it" would now be
111
+ * false in the reassuring direction, which is the worst way for a safety prompt
112
+ * to be wrong.
113
+ */
114
+ export function chatGuardCopy({ count, branch, verb }) {
115
+ return {
116
+ title: count > 1 ? `${count} chats are working right now` : 'A chat is working right now',
117
+ body: `Claude is editing files on ${branch}. If you ${verb} now, those edits can land on the wrong branch — and Maude's History can't undo a cross-branch mix-up.`,
118
+ meta: 'Safest is to wait for the chat to finish, or stop it yourself first.',
119
+ confirm: 'Do it anyway',
120
+ cancel: 'Wait',
121
+ };
122
+ }
123
+
100
124
  export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, onGetLatest }) {
101
125
  const native = isNativeApp();
102
126
  const [status, setStatus] = useState(null); // { repo, branch }
@@ -115,6 +139,9 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
115
139
  const [pulling, setPulling] = useState(false);
116
140
  // "Continue on <draft>?" nudge — dismissed for this session once the user opts out.
117
141
  const [resumeDismissed, setResumeDismissed] = useState(false);
142
+ // Task 9 — a pending branch change held back because a chat is mid-turn:
143
+ // `{ count, verb, action }`. Null when nothing is being confirmed.
144
+ const [chatGuard, setChatGuard] = useState(null);
118
145
 
119
146
  // Open the PR in the OS browser. A WKWebView anchor can't reach the default browser,
120
147
  // so native goes through the Tauri opener (github.com host-locked in Rust); an older
@@ -241,9 +268,52 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
241
268
  catch { /* not a repo / offline — keep the prior list, never blank it */ }
242
269
  }
243
270
 
244
- async function switchDraft(name, where) {
245
- setOpen(false);
246
- if (name === branch) return;
271
+ // ── Task 9: warn before a branch change moves the ground under a live turn ──
272
+ //
273
+ // THREE call sites reload, not one — `switchDraft`, `createDraft` and the
274
+ // local-merge fold in `foldDraft`. All three need the gate; missing one is the
275
+ // easy mistake here.
276
+ //
277
+ // Task 8 means the reload itself no longer KILLS the chat (the bridge outlives
278
+ // its socket now). That makes this warning MORE necessary, not less: the turn
279
+ // survives into a worktree that is no longer the one it was reasoning about.
280
+ // The agent read `foo.tsx` on this draft and will write it back after the
281
+ // checkout — a silent cross-branch clobber. And the "it's reversible" story
282
+ // this whole feature leans on degrades exactly here, because the `_history/`
283
+ // snapshot stack is per-canvas-slug with NO branch awareness.
284
+ //
285
+ // Asked of the SERVER, not of local chat state: a bridge can be running
286
+ // detached (post-reload, pre-re-attach) with no client that knows about it —
287
+ // precisely the case this warning is for.
288
+ async function chatsRunning() {
289
+ try {
290
+ const r = await fetch('/_api/acp/running');
291
+ if (!r.ok) return 0;
292
+ const j = await r.json();
293
+ return Number(j?.running) || 0;
294
+ } catch {
295
+ // Never block a branch switch because the probe failed — a switch the
296
+ // user asked for must not be hostage to an unrelated fetch error.
297
+ return 0;
298
+ }
299
+ }
300
+
301
+ /** Run `action` after an explicit confirm if a turn is in flight. Resolves
302
+ * the pending action into state so the sheet below can render it; the sheet
303
+ * calls back in on confirm. */
304
+ async function guardedByChat(action, verb) {
305
+ const n = await chatsRunning();
306
+ if (n === 0) {
307
+ action();
308
+ return;
309
+ }
310
+ setChatGuard({ count: n, verb, action });
311
+ }
312
+
313
+ // The raw action. Every caller goes through the guarded wrapper below — the
314
+ // warning has to happen BEFORE the checkout, since warning after the worktree
315
+ // already moved would be theatre.
316
+ async function doSwitchDraft(name, where) {
247
317
  setSwitching(name);
248
318
  setDownloading(where === 'remote'); // a remote-only branch is downloaded on switch
249
319
  setErr('');
@@ -252,6 +322,12 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
252
322
  else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); setDownloading(false); }
253
323
  }
254
324
 
325
+ async function switchDraft(name, where) {
326
+ setOpen(false);
327
+ if (name === branch) return;
328
+ await guardedByChat(() => doSwitchDraft(name, where), `switch to “${name}”`);
329
+ }
330
+
255
331
  // "Fetch remote branches" — fetch all remote heads so a teammate's new branch
256
332
  // surfaces, then re-read the list. Explicit gesture (never auto-run); token is
257
333
  // server-held. A failure surfaces as an in-popup notice (DDR-133) — it NEVER
@@ -271,15 +347,21 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
271
347
  setRefreshing(false);
272
348
  }
273
349
 
274
- async function createDraft() {
275
- const name = slugify(draftName);
276
- if (!name) return;
350
+ async function doCreateDraft(name) {
277
351
  setBusy(true); setErr('');
278
352
  const r = await postJson('/_api/git/branch', { name });
279
353
  if (r.ok && r.json?.ok) window.location.reload();
280
354
  else { setErr(r.json?.error || 'Could not create the draft.'); setBusy(false); }
281
355
  }
282
356
 
357
+ // Call site 2 of 3 — creating a branch checks it out, so it moves the
358
+ // worktree exactly like a switch does.
359
+ async function createDraft() {
360
+ const name = slugify(draftName);
361
+ if (!name) return;
362
+ await guardedByChat(() => doCreateDraft(name), `create “${name}”`);
363
+ }
364
+
283
365
  // "Get latest" from the dock nudge — pull the shared version's new commits. Reuses
284
366
  // the app's pull handler (onGetLatest = gitGetLatest): on success it re-probes so the
285
367
  // nudge clears; a content conflict opens the visual resolver (handled in app.jsx).
@@ -297,7 +379,15 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
297
379
  // opens a pull request (the merge happens on GitHub, post-review) — surface the PR
298
380
  // link and keep the sheet open. A local project merges directly (reload). A draft that
299
381
  // pushed but couldn't open a PR (no sign-in / non-github) shows a heads-up.
382
+ // Call site 3 of 3 — the LOCAL-merge path reloads (the GitHub-remote path
383
+ // opens a PR and stays put). Guarded as a whole rather than only around the
384
+ // reload: a merge landing under a running agent is the same hazard whether or
385
+ // not the page happens to reload afterward.
300
386
  async function foldDraft() {
387
+ await guardedByChat(doFoldDraft, `merge “${branch}” into ${sharedName}`);
388
+ }
389
+
390
+ async function doFoldDraft() {
301
391
  setFolding(branch);
302
392
  setErr('');
303
393
  const r = await postJson('/_api/git/fold', { name: branch }, { timeoutMs: 45000 });
@@ -339,6 +429,10 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
339
429
  }
340
430
 
341
431
  const slug = slugify(draftName);
432
+ // Task 9 — the confirm's words, from the pure helper above so they stay testable.
433
+ const guardCopy = chatGuard
434
+ ? chatGuardCopy({ count: chatGuard.count, branch, verb: chatGuard.verb })
435
+ : null;
342
436
  const currentDraft = onShared ? null : branches.find((b) => b.current);
343
437
  // On the Shared version with a draft around? Offer to jump back to the most
344
438
  // recently-worked local draft. Maude shows whatever branch HEAD is, so after a
@@ -544,6 +638,51 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
544
638
  {err && !open && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
545
639
  </div>
546
640
 
641
+ {/* Task 9 — a chat is mid-turn and the user asked for something that moves
642
+ the worktree. Silently reloading is the ONE outcome the Addendum calls
643
+ unacceptable, so this is an explicit confirm, not a toast. */}
644
+ {chatGuard && guardCopy && (
645
+ <div className="rb-scrim" role="presentation" onClick={() => setChatGuard(null)}>
646
+ <div
647
+ className="rb-sheet"
648
+ role="dialog"
649
+ aria-modal="true"
650
+ aria-labelledby="rb-chatguard-title"
651
+ aria-describedby="rb-chatguard-body"
652
+ data-testid="switcher-chat-guard"
653
+ onClick={(e) => e.stopPropagation()}
654
+ onKeyDown={(e) => { if (e.key === 'Escape') setChatGuard(null); }}
655
+ >
656
+ <span className="rb-sheet-icon"><Icon name="draft" size={20} /></span>
657
+ <h2 className="rb-sheet-title" id="rb-chatguard-title">{guardCopy.title}</h2>
658
+ <p className="rb-sheet-body" id="rb-chatguard-body">{guardCopy.body}</p>
659
+ <p className="rb-sheet-meta">{guardCopy.meta}</p>
660
+ <div className="rb-sheet-actions">
661
+ <button
662
+ type="button"
663
+ className="btn btn--ghost"
664
+ data-testid="switcher-chat-guard-cancel"
665
+ onClick={() => setChatGuard(null)}
666
+ >
667
+ {guardCopy.cancel}
668
+ </button>
669
+ <button
670
+ type="button"
671
+ className="btn btn--danger"
672
+ data-testid="switcher-chat-guard-confirm"
673
+ onClick={() => {
674
+ const { action } = chatGuard;
675
+ setChatGuard(null);
676
+ action();
677
+ }}
678
+ >
679
+ {guardCopy.confirm}
680
+ </button>
681
+ </div>
682
+ </div>
683
+ </div>
684
+ )}
685
+
547
686
  {/* Add-to-default confirm — the one modal in this surface. No 3-way merge UI.
548
687
  After a GitHub-remote fold it flips to a pull-request result view (DDR-162). */}
549
688
  {foldConfirm && (
@@ -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
- { id: frame.id, toolCall: frame.toolCall, options: frame.options ?? [] },
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 = () => resolve();
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
- ws?.send(JSON.stringify({ t: 'permission-response', id, decision }));
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(JSON.stringify({ t: 'elicitation-response', id, ...response }));
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
- .gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); }
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
- .gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; }
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; }
@@ -880,6 +880,57 @@
880
880
  gap: var(--space-2);
881
881
  }
882
882
 
883
+ /* feature-acp-write-path-scope Task 4 — the out-of-project WRITE variant.
884
+ Distinguished from an ordinary permission card because the two ask
885
+ materially different questions: the ordinary one is "do you want this
886
+ tool to run", this one is "do you want a file written somewhere your
887
+ project's history stack cannot roll back". The danger tint is the whole
888
+ point — a card that looks identical to the routine one gets clicked
889
+ through at the same rate. */
890
+ .chat-perm--outside {
891
+ border-color: var(--danger, #d9534f);
892
+ }
893
+ .chat-perm--outside .chat-perm-hd b {
894
+ color: var(--danger, #d9534f);
895
+ }
896
+ .chat-perm-outside {
897
+ display: flex;
898
+ flex-direction: column;
899
+ gap: var(--space-1);
900
+ }
901
+ /* The resolved absolute paths. Monospaced + wrapping at any character: these
902
+ are the one string on the card the user actually has to READ, and a path
903
+ that overflows or ellipsizes is a path whose dangerous tail is hidden —
904
+ the same failure the .chat-perm-hd b clamp above exists to prevent, but
905
+ with the opposite fix, because here truncation is what would be unsafe. */
906
+ .chat-perm-paths {
907
+ margin: 0;
908
+ padding: 0;
909
+ list-style: none;
910
+ display: flex;
911
+ flex-direction: column;
912
+ gap: var(--space-1);
913
+ }
914
+ .chat-perm-path {
915
+ padding: var(--space-1) var(--space-2);
916
+ border-radius: var(--radius-xs);
917
+ background: var(--bg-1);
918
+ color: var(--fg-0);
919
+ font-family: var(--font-mono);
920
+ font-size: var(--type-xs);
921
+ overflow-wrap: anywhere;
922
+ }
923
+ .chat-perm-scope,
924
+ .chat-perm-note {
925
+ margin: 0;
926
+ font-size: var(--type-xs);
927
+ color: var(--fg-3);
928
+ }
929
+ .chat-perm-root {
930
+ font-family: var(--font-mono);
931
+ overflow-wrap: anywhere;
932
+ }
933
+
883
934
  /* Form-elicitation card (feature-acp-ask-user-question — AskUserQuestion +
884
935
  generic MCP form input). Same slot + shape as .chat-perm, mutually
885
936
  exclusive with it — see the mount site in ChatPanel.jsx. */