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
/>
)}