feat(coder): unified Plan/Ask/Bypass permission picker

Replace the raw per-agent mode dropdown in the BooCoder composer with a
curated three-option permission ladder mapped generically onto each
provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended
-> Bypass (claude bypassPermissions, qwen yolo, opencode full-access).
modeId stays the single wire field; the active unified mode is derived
from it (no contracts change).

Native BooCode gains its own mode set: Ask stages to the pending-changes
queue (today's behavior), Bypass auto-applies the queue to disk after the
turn (interactive messages path + task dispatcher path), Plan falls back
to Ask. The shared apps/server inference engine is left untouched.

Also preserve isUnattended on live-probed ACP modes so opencode's bypass
mode stays detectable from the wire.

Coder 373 tests green; coder + web typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:14:21 +00:00
parent da36344d0b
commit e04d0fdaa8
9 changed files with 180 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -14,8 +14,22 @@ import {
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
import { formatModelLabel } from '@/lib/model-label';
import {
availablePermissionModes,
permissionForModeId,
nativeModeForPermission,
type PermissionMode,
} from '@/lib/permission-mode';
import { cn } from '@/lib/utils';
// Permission picker icon — varies with the active mode so the (icon-only) control
// is glanceable: Eye = Plan (read-only), Shield = Ask, ShieldAlert = Bypass.
function permissionIcon(mode: PermissionMode): React.ReactNode {
if (mode === 'plan') return <Eye className="size-3 shrink-0" />;
if (mode === 'bypass') return <ShieldAlert className="size-3 shrink-0 text-amber-500" />;
return <Shield className="size-3 shrink-0" />;
}
const PREFS_KEY = 'boocode.coder.agent-prefs';
@@ -350,7 +364,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
}
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
// Unified permission ladder (Plan / Ask / Bypass) mapped onto this provider's
// native modes. `value.modeId` stays the wire field; the active unified mode is
// derived from it.
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
@@ -380,15 +398,25 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
</>
}
/>
{/* Mode (shield) only when the provider actually exposes modes. Native
BooCoder has none, so it's hidden rather than shown disabled. */}
{modeOptions.length > 0 && (
{/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes
modes. Picks the unified mode; we resolve it to the provider's native
modeId. Icon varies with the active mode (Bypass is amber). */}
{permissionModes.length > 0 && (
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
label="Permission"
value={currentPermission}
options={permissionModes}
onPick={(perm) =>
persist({
...value,
modeId: nativeModeForPermission(
perm as PermissionMode,
currentEntry?.modes ?? [],
currentEntry?.defaultModeId ?? null,
),
})
}
icon={permissionIcon(currentPermission)}
iconOnly
/>
)}

View File

@@ -0,0 +1,55 @@
// Unified permission ladder shown in the composer's permission picker. Maps a
// curated three-option control (Plan / Ask Permission / Bypass) onto each
// provider's native mode vocabulary, derived purely from the snapshot's mode
// metadata (`plan` id, the default mode, and the `isUnattended` bypass mode).
// `modeId` stays the single wire field sent to the dispatcher — there is no
// separate persisted permission field; the active unified mode is derived from
// the current `modeId`.
import type { ProviderMode } from '@/api/types';
export type PermissionMode = 'plan' | 'ask' | 'bypass';
export const PERMISSION_LABELS: Record<PermissionMode, string> = {
plan: 'Plan',
ask: 'Ask Permission',
bypass: 'Bypass',
};
/** The native modeId for a unified permission, or null when the provider has no
* modes (e.g. goose). `plan` → the `plan`-id mode; `bypass` → the `isUnattended`
* mode; `ask` → the non-unattended default. Falls back to defaultModeId. */
export function nativeModeForPermission(
mode: PermissionMode,
modes: ProviderMode[],
defaultModeId: string | null,
): string | null {
if (modes.length === 0) return null;
if (mode === 'plan') return modes.find((m) => m.id === 'plan')?.id ?? defaultModeId;
if (mode === 'bypass') return modes.find((m) => m.isUnattended)?.id ?? defaultModeId;
return (
modes.find((m) => m.id === defaultModeId && !m.isUnattended)?.id ??
modes.find((m) => !m.isUnattended && m.id !== 'plan')?.id ??
defaultModeId
);
}
/** Which unified permission a native modeId corresponds to (for picker state). */
export function permissionForModeId(modeId: string | null, modes: ProviderMode[]): PermissionMode {
if (!modeId) return 'ask';
if (modeId === 'plan') return 'plan';
if (modes.find((m) => m.id === modeId)?.isUnattended) return 'bypass';
return 'ask';
}
/** The unified permission options a provider supports, in fixed Plan→Ask→Bypass
* order. Empty when the provider exposes no modes (no picker shown). */
export function availablePermissionModes(
modes: ProviderMode[],
): Array<{ id: PermissionMode; label: string }> {
if (modes.length === 0) return [];
const out: Array<{ id: PermissionMode; label: string }> = [];
if (modes.some((m) => m.id === 'plan')) out.push({ id: 'plan', label: PERMISSION_LABELS.plan });
out.push({ id: 'ask', label: PERMISSION_LABELS.ask });
if (modes.some((m) => m.isUnattended)) out.push({ id: 'bypass', label: PERMISSION_LABELS.bypass });
return out;
}