Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3911761
Agent pane: permission card at Claude desktop's level, keycaps Esc an…
teamleaderleo Nov 5, 2026
f77da28
Permission card: answer labels sit level with their keycaps
teamleaderleo Nov 5, 2026
c7103af
Permission card and needs-input line: match Leo's Claude desktop scre…
teamleaderleo Nov 5, 2026
24449f6
Permission card: the allows wrap as a pair at the trailing edge
teamleaderleo Nov 5, 2026
41f85e8
Needs-input test: fake timers for the dot's fallback; Deny keeps the …
teamleaderleo Nov 5, 2026
30de747
Permission card and needs-input line: on the type and token scales
teamleaderleo Nov 5, 2026
8887174
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/approv…
teamleaderleo Nov 5, 2026
0be3019
Needs-input pill: only the Thinking line draws it
teamleaderleo Nov 5, 2026
3d5ceee
Merge feat-cmux-next: plan approvals keep their plan block and labels…
teamleaderleo Nov 5, 2026
c2d81b5
Merge feat-cmux-next: the answered dot and the rolling live line shar…
teamleaderleo Nov 5, 2026
911cc3b
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/approv…
teamleaderleo Nov 6, 2026
a917fc7
Agent pane: the Needs your input pill is a small-radius rect, not a c…
teamleaderleo Nov 6, 2026
e7b0718
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/approv…
teamleaderleo Nov 6, 2026
ca57320
webviews: format the permission card gallery note and shortcuts test …
teamleaderleo Nov 6, 2026
604f5bb
Agent pane: the feedback field's Cancel keycap reuses Deny's Esc key …
teamleaderleo Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion webviews/scripts/design-tokens-baseline.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@
{"file":"agent-session/acpmux/styles.css","kind":"duration","property":"animation","values":{"acpmux-chief-mascot-float 2.8s cubic-bezier(.22,.7,.28,1) infinite":1}}
{"file":"agent-session/acpmux/styles.css","kind":"easing","property":"animation","values":{"acpmux-chief-card-in var(--cmux-motion-quick) cubic-bezier(.22,.7,.28,1) both":1}}
{"file":"agent-session/acpmux/styles.css","kind":"easing","property":"transition","values":{"background-color var(--acpmux-motion-hover) ease-out,color var(--acpmux-motion-hover) ease-out,box-shadow var(--acpmux-motion-hover) ease-out":1,"opacity var(--acpmux-motion-hover) ease-out":1}}
{"file":"agent-session/acpmux/styles.css","kind":"font-weight","property":"font-weight","values":{"500":4,"650":2,"700":1}}
{"file":"agent-session/acpmux/styles.css","kind":"font-weight","property":"font-weight","values":{"500":3,"650":2,"700":1}}
{"file":"agent-session/acpmux/styles.css","kind":"height","property":"height","values":{"16px":4,"7px":1,".5px":1,"48px":1,"18px":1,"22px":6,"40px":3,"1px":1,"26px":5,"34px":1,"30px":1,"12px":1,"8px":2,"36px":1,"180px":2,"360px":1,"6px":2,"164px":1,"42px":1,"68px":1,"56px":3,"max(var(--ui-row-height,28px),calc(96px * var(--cmux-interface-scale,1)))":1,"44px":1,"17px":1,"21px":1,"5px":1}}
{"file":"agent-session/acpmux/styles.css","kind":"height","property":"min-height","values":{"34px":2,"38px":1,"calc(var(--ds-control-row) + var(--ds-space-3) + 4 * var(--cmux-hairline,.5px))":1,"30px":1,"16px":1,"128px":1,"36px":1,"58px":1,"48px":1,"46px":1,"56px":1,"44px":1}}
{"file":"agent-session/acpmux/styles.css","kind":"radius","property":"border-radius","values":{"22px":1,"14px":1,"3px":4,"5px":1,"9px":1,"48px":2,"24px":2,"20px":1}}
Expand Down
8 changes: 5 additions & 3 deletions webviews/src/agent-session/acpmux/PermissionCard.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
// button, like this, it shouldn't be encompassing so much ... this other, like, thing shouldn't be
// the same thing." Then, on equal-width buttons stretched across the card: "but there's literally
// no point having it be the same size? just make it good? incl. the symbol hints". The buttons are
// sized to their labels with a light key hint; the scope is one muted line. scopeStyle and rowStyle
// draw gallery-only alternatives over the shipped card; "under" and "light" are the shipped ones.
// sized to their labels; the scope is one muted line. Leo 2026-11-05, on Claude desktop's card: a
// bold title over a muted reason, a quiet details band, then Deny (esc) and the filled Allow once
// (⌘↩) at the trailing edge with their keycaps. scopeStyle and rowStyle draw gallery-only
// alternatives over the shipped card.
import type { ComponentProps } from "react";
import { componentEntry } from "../../gallery/format";
import type { Play } from "../../gallery/play";
Expand Down Expand Up @@ -232,7 +234,7 @@ export default componentEntry<Props>({
props: { permission: shortRule, harness: "claude" },
},
"long-rule": {
note: "Leo 2026-10-25: a long Claude session rule. Compact buttons sized to their labels, grouped at the leading edge, each with a light ⌥⌘ hint; the rule is one muted line under them, cut, whole on hover.",
note: 'Leo 2026-11-05, on Claude desktop\'s "Allow Claude to use …?" card: "The whole, like, allow whatever to use, allow for this session, or deny. And then it shows the hotkeys." A bold title and a muted reason, a quiet details band with the command and the long rule on one muted line (cut, whole on hover), then the answers at the trailing edge: Deny with its esc keycap, Always allow with ⌥⌘2, and the filled Allow once with ⌘↩.',
props: { permission: longRule, harness: "claude" },
play: scopeOnce,
},
Expand Down
206 changes: 128 additions & 78 deletions webviews/src/agent-session/acpmux/PermissionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "re
import { Markdown } from "./conversation/Markdown";
import { toolIcon } from "./conversation/ToolRow";
import { useT } from "./i18n";
import { Keycap } from "./Keycap";
import type { AcpmuxPermission } from "./model";
import { ProposedEdits } from "./permissions/ProposedEdits";
import {
Expand Down Expand Up @@ -71,36 +72,62 @@ function KeyHint({ shortcut }: { shortcut: string }) {
);
}

/// A key the card itself answers (Esc, ⌘↩), drawn as a keycap in its button: `label` as shown,
/// `aria` in `aria-keyshortcuts` form.
export type CardKey = { keys: string[]; aria: string };

/** Shared action button so grouped and legacy cards present the same localized label and key hint.
* Only the short label and the key: what an answer covers is the card's one scope line, never the
* button's (Leo 2026-10-25: "the allow button ... it shouldn't be encompassing so much"). */
* button's (Leo 2026-10-25: "the allow button ... it shouldn't be encompassing so much"). With
* `cardKey` the button shows that key as a keycap, as Claude desktop's "Deny Esc" and "Allow ⌘↩",
* and still answers `shortcut`, which it lists after the card key. */
export function PermissionActionButton({
label,
shortcut,
cardKey,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string; shortcut?: string }) {
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
shortcut?: string;
cardKey?: CardKey;
}) {
const { className, ...buttonProps } = props;
return (
<button
{...buttonProps}
className={className}
type="button"
aria-label={label}
aria-keyshortcuts={shortcut || undefined}
aria-keyshortcuts={[cardKey?.aria, shortcut].filter(Boolean).join(" ") || undefined}
>
<span className="acpmux-permission-action-label">{label}</span>
{shortcut && <KeyHint shortcut={shortcut} />}
{cardKey ? (
<span className="acpmux-permission-keys">
{cardKey.keys.map((key) => (
<Keycap key={key}>{key}</Keycap>
))}
</span>
) : (
shortcut && <KeyHint shortcut={shortcut} />
)}
</button>
);
}

/// A permission ask as Claude Code desktop draws it: a glass card with the tool's glyph and what the
/// call does as its title, the command clamped to its first line ("Show full command" opens all of
/// it), and Allow once, Always allow and Deny, each shown only when the agent offered that kind
/// (permissions/prompt.ts), with any other option the agent offered after them: short buttons sized
/// to their labels, grouped at the card's leading edge. What they cover (the folder or app, the Always allow rule) is one muted
/// line under them, cut with an ellipsis and whole on hover. Deny opens a field for what the agent should do instead, sent with the
/// deny through `onDenyWithFeedback`. Keys 1, 2 and 3, Return (Allow once) and Esc (Deny, or close
// One keycap per key, as Claude desktop draws "Esc" and "⌘ ↩".
const DENY_KEY: CardKey = { keys: ["Esc"], aria: "Escape" };
const ALLOW_KEY: CardKey = { keys: ["⌘", "↩"], aria: "Meta+Enter" };

/// A permission ask as Claude desktop draws its "Allow Claude to use …?" card (Leo, 2026-11-05: "The
/// whole, like, allow whatever to use, allow for this session, or deny. And then it shows the
/// hotkeys."): a glass card whose head, a shade lighter than the rest, holds what the call does, with
/// the tool's glyph, and a muted reason under it; then the details, the command clamped to its first line ("Show full
/// command" opens all of it) and what an answer covers (the folder or app, the Always allow rule) as
/// one muted line, cut with an ellipsis and whole on hover; then the answers, short buttons sized to
/// their labels: any other option the agent offered and Deny with its Esc keycap at the leading
/// edge, Always allow and the filled Allow once with its ⌘ ↩ keycaps at the trailing edge, each shown only when the
/// agent offered that kind (permissions/prompt.ts). Deny opens a field for what the agent should do instead, sent with the
/// deny through `onDenyWithFeedback`. Keys 1, 2 and 3, Return and ⌘↩ (Allow once) and Esc (Deny, or close
/// the field) answer while this card is the prompt and nothing else has the keyboard. In a chat
/// running its highest mode (`fullAccessMode`), an ask still here is one the mode does not answer
/// (acpmux hub/permissions.rs `full_access_allow`: an ask Claude Code marks as needing the person,
Expand Down Expand Up @@ -394,6 +421,38 @@ export function PermissionCard({
if (onCard) field.current?.focus();
};

const commandBlock = command && (
<div className="acpmux-permission-command" data-expanded={showFull || undefined}>
{showFull ? (
<pre id={fullId} className="acpmux-permission-command-full">
<code>{command}</code>
</pre>
) : (
<div className="acpmux-permission-command-preview">
<code ref={previewRef}>{preview.line}</code>
</div>
)}
{(preview.more || clamped || showFull) && (
<button
type="button"
className="acpmux-permission-command-toggle"
aria-expanded={showFull}
aria-controls={showFull ? fullId : undefined}
onClick={() => setExpanded(showFull ? undefined : permission.permissionId)}
>
{showFull ? t("permission.hideFullCommand") : t("permission.showFullCommand")}
</button>
)}
</div>
);
const scopeBlock = !writing && scopeLine && (
<p className="acpmux-permission-scope" data-scope-kind={permission.scope?.kind} title={scopeLine}>
{scopeText && <span id={targetId}>{scopeText}</span>}
{scopeText && alwaysScope && " · "}
{alwaysScope && <span id={ruleId}>{alwaysScope}</span>}
</p>
);

return (
<PermissionCardFrame ref={group} className="acpmux-permission-ask ui-glass" ariaLabel={title}>
<div className="acpmux-permission-request" data-harness={harness || undefined}>
Expand All @@ -418,63 +477,21 @@ export function PermissionCard({
<Markdown plainCode>{plan}</Markdown>
</div>
)}
{command && (
<div className="acpmux-permission-command" data-expanded={showFull || undefined}>
{showFull ? (
<pre id={fullId} className="acpmux-permission-command-full">
<code>{command}</code>
</pre>
) : (
<div className="acpmux-permission-command-preview">
<code ref={previewRef}>{preview.line}</code>
</div>
)}
{(preview.more || clamped || showFull) && (
<button
type="button"
className="acpmux-permission-command-toggle"
aria-expanded={showFull}
aria-controls={showFull ? fullId : undefined}
onClick={() => setExpanded(showFull ? undefined : permission.permissionId)}
>
{showFull ? t("permission.hideFullCommand") : t("permission.showFullCommand")}
</button>
)}
</div>
)}
</div>
{(commandBlock || scopeBlock) && (
<div className="acpmux-permission-details">
{commandBlock}
{scopeBlock}
</div>
)}
<ProposedEdits permissionId={permission.permissionId} diffs={permission.diffs} />
{!writing && refused === permission.permissionId && (
<p className="acpmux-permission-refused" role="alert">
{t("permission.error.refused")}
</p>
)}
{!writing && (
<div className="acpmux-permission-buttons">
{allowOnce && (
<PermissionActionButton
label={plan ? t("permission.plan.approve") : t("permission.allowOnce")}
aria-describedby={scopeText ? targetId : undefined}
shortcut={shortcuts ? allowOnceShortcut : undefined}
className="acpmux-permission-primary"
onClick={() => answer(allowOnce)}
/>
)}
{allowAlways && allowAlwaysLabel && (
<PermissionActionButton
label={allowAlwaysLabel}
shortcut={shortcuts && !alwaysAtSource ? allowChatShortcut : undefined}
title={alwaysAtSource ? t("permission.alwaysAtSource") : alwaysScope}
aria-describedby={[scopeText && targetId, alwaysScope && ruleId].filter(Boolean).join(" ") || undefined}
disabled={alwaysAtSource}
onClick={() => answer(allowAlways)}
/>
)}
{denyOption && (
<PermissionActionButton
label={plan ? t("permission.plan.keepPlanning") : t("permission.deny")}
shortcut={shortcuts ? denyShortcut : undefined}
aria-describedby={scopeText ? targetId : undefined}
className="acpmux-permission-deny"
data-feedback-opener=""
onClick={() => deny()}
/>
)}
{others.map((option, index) => {
const label = otherLabels[index]!;
const full = [option.name.trim(), option.description?.trim()].filter(Boolean).join(" · ");
Expand All @@ -497,20 +514,46 @@ export function PermissionCard({
</React.Fragment>
);
})}
{denyOption && (
<PermissionActionButton
label={plan ? t("permission.plan.keepPlanning") : t("permission.deny")}
shortcut={shortcuts ? denyShortcut : undefined}
cardKey={shortcuts ? DENY_KEY : undefined}
aria-describedby={scopeText ? targetId : undefined}
className="acpmux-permission-deny"
data-feedback-opener=""
onClick={() => deny()}
/>
)}
{(allowAlways || allowOnce) && (
// The allows keep the trailing edge together, so a narrow card wraps them as a pair.
<span className="acpmux-permission-allows">
{allowAlways && allowAlwaysLabel && (
<PermissionActionButton
label={allowAlwaysLabel}
shortcut={shortcuts && !alwaysAtSource ? allowChatShortcut : undefined}
title={alwaysAtSource ? t("permission.alwaysAtSource") : alwaysScope}
aria-describedby={
[scopeText && targetId, alwaysScope && ruleId].filter(Boolean).join(" ") || undefined
}
disabled={alwaysAtSource}
onClick={() => answer(allowAlways)}
/>
)}
{allowOnce && (
<PermissionActionButton
label={plan ? t("permission.plan.approve") : t("permission.allowOnce")}
aria-describedby={scopeText ? targetId : undefined}
shortcut={shortcuts ? allowOnceShortcut : undefined}
cardKey={shortcuts ? ALLOW_KEY : undefined}
className="acpmux-permission-primary"
onClick={() => answer(allowOnce)}
/>
)}
</span>
)}
</div>
)}
{!writing && refused === permission.permissionId && (
<p className="acpmux-permission-refused" role="alert">
{t("permission.error.refused")}
</p>
)}
{!writing && scopeLine && (
<p className="acpmux-permission-scope" data-scope-kind={permission.scope?.kind} title={scopeLine}>
{scopeText && <span id={targetId}>{scopeText}</span>}
{scopeText && alwaysScope && " · "}
{alwaysScope && <span id={ruleId}>{alwaysScope}</span>}
</p>
)}
{writing && (
<form
className="acpmux-permission-feedback"
Expand Down Expand Up @@ -538,11 +581,18 @@ export function PermissionCard({
}}
/>
<div className="acpmux-permission-feedback-actions">
<button type="button" disabled={writing.sending} onClick={closeFeedback}>
<button type="button" aria-keyshortcuts="Escape" disabled={writing.sending} onClick={closeFeedback}>
{t("permission.feedbackCancel")}
<Keycap>{DENY_KEY.keys[0]}</Keycap>
</button>
<button type="submit" className="acpmux-permission-feedback-send" disabled={writing.sending}>
<button
type="submit"
className="acpmux-permission-feedback-send"
aria-keyshortcuts="Enter"
disabled={writing.sending}
>
{t("permission.feedbackSend")}
<Keycap>↩</Keycap>
</button>
</div>
</form>
Expand Down
Loading
Loading