"use client"; import { useId, useMemo, useState } from "react"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import type { CLIFlag, PermissionSetting } from "@/lib/types/http"; /** * Editor-side representation of a single custom CLI flag. The persisted * `CLIFlag.flag` string is shell-tokenised by the backend, so a single * stored value like `--add-dir /shared` becomes two argv tokens. We * surface that split as separate Flag + Value inputs so the user doesn't * have to think about quoting, and re-concatenate on save. Description * is preserved through the round-trip but no longer shown in the UI. */ type CustomFlagRow = { flag: string; value: string; description: string; enabled: boolean; }; // Strip surrounding single or double quotes added by shellQuoteValue for display. function stripOuterQuotes(s: string): string { if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { return s.slice(1, -1); } return s; } // POSIX single-quote a value that contains whitespace so the backend's // shell tokeniser treats it as one argv token. function shellQuoteValue(v: string): string { if (!v || !/\s/.test(v)) return v; return `'${v.replace(/'/g, "'\\''")}'`; } function flagToRow(f: CLIFlag): CustomFlagRow { const trimmed = f.flag.trim(); const ws = trimmed.search(/\s/); if (ws === -1) { return { flag: trimmed, value: "", description: f.description, enabled: f.enabled }; } return { flag: trimmed.slice(0, ws), // Strip surrounding quotes added by shellQuoteValue so the input shows the raw value. value: stripOuterQuotes(trimmed.slice(ws + 1)), description: f.description, enabled: f.enabled, }; } function rowToFlag(r: CustomFlagRow): CLIFlag { const flagText = r.flag.trim(); // Do not trim value here — trailing spaces must survive inline editing. // shellQuoteValue wraps values containing whitespace so the backend shell // tokeniser produces a single argv token. const quotedValue = shellQuoteValue(r.value); return { flag: quotedValue.trim() ? `${flagText} ${quotedValue}` : flagText, description: r.description, enabled: r.enabled, }; } type CLIFlagsFieldProps = { flags: CLIFlag[]; onChange: (next: CLIFlag[]) => void; /** * The agent's curated PermissionSettings catalogue. Entries with * apply_method === "cli_flag" render as labelled predefined toggles * (one per setting) above the custom-flags list. The switch state is * read from `flags` by matching `setting.cli_flag` to `flag.flag`. */ permissionSettings?: Record; variant?: "default" | "compact"; /** * When true, the custom-flag list + Add form is hidden. Used by the * onboarding flow, where users should only see the agent's curated * predefined toggles and defer advanced edits to the profile page. */ hideCustomFlags?: boolean; }; type CuratedSetting = { key: string; label: string; description: string; flag: string; default: boolean; }; /** * CLIFlagsField renders two distinct controls backed by the same * `profile.cli_flags` list: * * 1. Predefined toggles — one Switch per entry in the agent's curated * PermissionSettings catalogue. Styled like the CLI Passthrough * toggle. State is read/written into cli_flags by flag-text match. * 2. Custom flags — a list of user-authored entries (anything in * cli_flags whose flag text does not match a curated setting), * with an Add form for new entries. * * Only entries with `enabled: true` reach the agent subprocess argv at * launch. */ export function CLIFlagsField({ flags, onChange, permissionSettings, variant = "default", hideCustomFlags = false, }: CLIFlagsFieldProps) { const isCompact = variant === "compact"; const curated = useMemo(() => extractCuratedSettings(permissionSettings), [permissionSettings]); const curatedFlagTexts = useMemo(() => new Set(curated.map((s) => s.flag)), [curated]); const customFlags = useMemo( () => flags .map((f, i) => ({ flag: f, index: i })) .filter((e) => !curatedFlagTexts.has(e.flag.flag)), [flags, curatedFlagTexts], ); const toggleCurated = (setting: CuratedSetting, enabled: boolean) => { const existingIdx = flags.findIndex((f) => f.flag === setting.flag); if (existingIdx >= 0) { onChange(flags.map((f, i) => (i === existingIdx ? { ...f, enabled } : f))); return; } // No existing entry: append an explicit record. The displayed switch // state derives from setting.default when absent, so we must persist // the user's choice (even when turning off) to actually flip it. onChange([...flags, { flag: setting.flag, description: setting.description, enabled }]); }; const updateRowAt = (index: number, next: CLIFlag) => { onChange(flags.map((f, i) => (i === index ? next : f))); }; const removeAt = (index: number) => onChange(flags.filter((_, i) => i !== index)); const appendCustom = (next: CLIFlag) => onChange([...flags, next]); return (
{curated.length > 0 && ( )} {!hideCustomFlags && ( )}
); } function permissionSettingFlagText(s: PermissionSetting): string { const flag = s.cli_flag?.trim() ?? ""; const value = s.cli_flag_value?.trim() ?? ""; if (!flag) return ""; return value ? `${flag} ${value}` : flag; } function extractCuratedSettings( permissionSettings?: Record, ): CuratedSetting[] { if (!permissionSettings) return []; const out: CuratedSetting[] = []; for (const [key, s] of Object.entries(permissionSettings)) { if (!s.supported || s.apply_method !== "cli_flag") continue; const flag = permissionSettingFlagText(s); if (!flag) continue; out.push({ key, label: s.label, description: s.description, flag, default: s.default, }); } // Stable order by flag text so the UI doesn't reshuffle across renders. out.sort((a, b) => a.flag.localeCompare(b.flag)); return out; } function CuratedFlagsSection({ curated, flags, onToggle, compact, }: { curated: CuratedSetting[]; flags: CLIFlag[]; onToggle: (setting: CuratedSetting, enabled: boolean) => void; compact: boolean; }) { const labelCls = compact ? "text-xs" : undefined; const switchSize = compact ? ("sm" as const) : ("default" as const); return (
{curated.map((setting) => { const entry = flags.find((f) => f.flag === setting.flag); const enabled = entry ? entry.enabled : setting.default; return (

{setting.description}

{setting.flag}
onToggle(setting, checked)} data-testid={`cli-flag-curated-enabled-${setting.key}`} aria-label={`${enabled ? "Disable" : "Enable"} ${setting.label}`} />
); })}
); } function CustomFlagsSection({ customFlags, onUpdateRow, onRemove, onAdd, }: { customFlags: Array<{ flag: CLIFlag; index: number }>; onUpdateRow: (index: number, next: CLIFlag) => void; onRemove: (index: number) => void; onAdd: (next: CLIFlag) => void; }) { return (
{customFlags.length === 0 ? (

No CLI flags configured. Add one below.

) : (
    {customFlags.map(({ flag, index }) => ( ))}
)}
); } function CLIFlagRow({ flag, index, onUpdateRow, onRemove, }: { flag: CLIFlag; index: number; onUpdateRow: (index: number, next: CLIFlag) => void; onRemove: (index: number) => void; }) { const row = flagToRow(flag); const update = (patch: Partial) => { onUpdateRow(index, rowToFlag({ ...row, ...patch })); }; return (
  • update({ flag: e.target.value })} placeholder="--my-flag" className="flex-[2] font-mono text-xs" data-testid={`cli-flag-flag-${index}`} /> update({ value: e.target.value })} placeholder="value (optional)" className="flex-[3] font-mono text-xs" data-testid={`cli-flag-value-${index}`} /> update({ enabled: checked })} data-testid={`cli-flag-enabled-${index}`} aria-label={`${row.enabled ? "Disable" : "Enable"} ${row.flag}`} />
  • ); } function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) { const uid = useId(); const flagId = `${uid}-flag`; const valueId = `${uid}-value`; const [newFlag, setNewFlag] = useState(""); const [newValue, setNewValue] = useState(""); const commit = () => { const trimmed = newFlag.trim(); if (trimmed === "") return; onAdd(rowToFlag({ flag: trimmed, value: newValue.trim(), description: "", enabled: true })); setNewFlag(""); setNewValue(""); }; const onEnter = (e: React.KeyboardEvent) => { if (e.key === "Enter" && newFlag.trim() !== "") { e.preventDefault(); commit(); } }; return (
    setNewFlag(e.target.value)} placeholder="--my-flag" className="font-mono text-xs" data-testid="cli-flag-new-flag-input" onKeyDown={onEnter} />
    setNewValue(e.target.value)} placeholder="value" className="font-mono text-xs" data-testid="cli-flag-new-value-input" onKeyDown={onEnter} />
    ); } type CustomCLIFlagsCardProps = { flags: CLIFlag[]; onChange: (next: CLIFlag[]) => void; permissionSettings?: Record; }; /** * Standalone card surfacing only the user-authored (custom) CLI flags, * with their values exposed as a dedicated input. Curated flags * (Switch toggles derived from PermissionSettings) stay inline within * ProfileFormFields so they sit alongside the related profile fields. */ export function CustomCLIFlagsCard({ flags, onChange, permissionSettings, }: CustomCLIFlagsCardProps) { const curatedFlagTexts = useMemo( () => new Set(extractCuratedSettings(permissionSettings).map((s) => s.flag)), [permissionSettings], ); const customFlags = useMemo( () => flags .map((f, i) => ({ flag: f, index: i })) .filter((e) => !curatedFlagTexts.has(e.flag.flag)), [flags, curatedFlagTexts], ); const enabledCount = customFlags.filter((e) => e.flag.enabled).length; const onUpdateRow = (index: number, next: CLIFlag) => { onChange(flags.map((f, i) => (i === index ? next : f))); }; const onRemove = (index: number) => onChange(flags.filter((_, i) => i !== index)); const onAdd = (next: CLIFlag) => onChange([...flags, next]); return (
    Agent CLI flags Flags passed to the agent CLI on launch. Only enabled entries are applied.
    {customFlags.length > 0 && ( {enabledCount} of {customFlags.length} enabled )}
    ); }