Files
kandev/apps/web/components/settings/cli-flags-field.tsx
T
2026-04-27 16:42:24 +01:00

335 lines
11 KiB
TypeScript

"use client";
import { useId, useMemo, useState } from "react";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
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";
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<string, PermissionSetting>;
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 toggleAt = (index: number, enabled: boolean) => {
onChange(flags.map((f, i) => (i === index ? { ...f, enabled } : f)));
};
const removeAt = (index: number) => onChange(flags.filter((_, i) => i !== index));
const appendCustom = (flag: string, description: string) =>
onChange([...flags, { flag, description, enabled: true }]);
return (
<div className={isCompact ? "space-y-3" : "space-y-4"} data-testid="cli-flags-field">
{curated.length > 0 && (
<CuratedFlagsSection
curated={curated}
flags={flags}
onToggle={toggleCurated}
compact={isCompact}
/>
)}
{!hideCustomFlags && (
<CustomFlagsSection
customFlags={customFlags}
onToggle={toggleAt}
onRemove={removeAt}
onAdd={appendCustom}
compact={isCompact}
/>
)}
</div>
);
}
function extractCuratedSettings(
permissionSettings?: Record<string, PermissionSetting>,
): CuratedSetting[] {
if (!permissionSettings) return [];
const out: CuratedSetting[] = [];
for (const [key, s] of Object.entries(permissionSettings)) {
if (!s.supported || s.apply_method !== "cli_flag" || !s.cli_flag) continue;
out.push({
key,
label: s.label,
description: s.description,
flag: s.cli_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 (
<div className="space-y-2" data-testid="cli-flags-curated">
{curated.map((setting) => {
const entry = flags.find((f) => f.flag === setting.flag);
const enabled = entry ? entry.enabled : setting.default;
return (
<div
key={setting.key}
className="flex items-center justify-between gap-3 rounded-md border p-3"
data-testid={`cli-flag-curated-${setting.key}`}
>
<div className="flex-1 min-w-0 space-y-0.5">
<Label className={labelCls}>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
<code className="text-[10px] text-muted-foreground/80">{setting.flag}</code>
</div>
<Switch
size={switchSize}
checked={enabled}
onCheckedChange={(checked) => onToggle(setting, checked)}
data-testid={`cli-flag-curated-enabled-${setting.key}`}
aria-label={`${enabled ? "Disable" : "Enable"} ${setting.label}`}
/>
</div>
);
})}
</div>
);
}
function CustomFlagsSection({
customFlags,
onToggle,
onRemove,
onAdd,
compact,
}: {
customFlags: Array<{ flag: CLIFlag; index: number }>;
onToggle: (index: number, enabled: boolean) => void;
onRemove: (index: number) => void;
onAdd: (flag: string, description: string) => void;
compact: boolean;
}) {
const labelCls = compact ? "text-xs" : undefined;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className={labelCls}>Agent CLI flags</Label>
{customFlags.length > 0 && (
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
{customFlags.filter((e) => e.flag.enabled).length} of {customFlags.length} enabled
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Flags passed to the agent CLI on launch. Only enabled entries are applied. Use quotes for
values with spaces, e.g.{" "}
<code className="bg-muted px-1 rounded">{`--msg "hello world"`}</code>.
</p>
{customFlags.length === 0 ? (
<p className="text-xs italic text-muted-foreground" data-testid="cli-flags-empty">
No CLI flags configured. Add one below.
</p>
) : (
<ul className="space-y-2" data-testid="cli-flags-list">
{customFlags.map(({ flag, index }) => (
<CLIFlagRow
key={`${flag.flag}-${index}`}
flag={flag}
index={index}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
)}
<CLIFlagsAddForm onAdd={onAdd} />
</div>
);
}
function CLIFlagRow({
flag,
index,
onToggle,
onRemove,
}: {
flag: CLIFlag;
index: number;
onToggle: (i: number, enabled: boolean) => void;
onRemove: (i: number) => void;
}) {
return (
<li
className="flex items-start justify-between gap-3 rounded-md border p-3"
data-testid={`cli-flag-row-${index}`}
>
<div className="flex-1 min-w-0 space-y-1">
<code className="text-sm font-semibold break-all" data-testid={`cli-flag-text-${index}`}>
{flag.flag}
</code>
{flag.description && <p className="text-xs text-muted-foreground">{flag.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={flag.enabled}
onCheckedChange={(checked) => onToggle(index, checked)}
data-testid={`cli-flag-enabled-${index}`}
aria-label={`${flag.enabled ? "Disable" : "Enable"} ${flag.flag}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="cursor-pointer"
data-testid={`cli-flag-remove-${index}`}
aria-label={`Remove ${flag.flag}`}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
</li>
);
}
function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string) => void }) {
const uid = useId();
const flagId = `${uid}-flag`;
const descId = `${uid}-desc`;
const [newFlag, setNewFlag] = useState("");
const [newDesc, setNewDesc] = useState("");
const commit = () => {
const trimmed = newFlag.trim();
if (trimmed === "") return;
onAdd(trimmed, newDesc.trim());
setNewFlag("");
setNewDesc("");
};
const onEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && newFlag.trim() !== "") {
e.preventDefault();
commit();
}
};
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1">
<Label className="text-xs" htmlFor={flagId}>
Flag
</Label>
<Input
id={flagId}
value={newFlag}
onChange={(e) => setNewFlag(e.target.value)}
placeholder="--my-flag or --key=value"
data-testid="cli-flag-new-flag-input"
onKeyDown={onEnter}
/>
</div>
<div className="flex-1 space-y-1">
<Label className="text-xs" htmlFor={descId}>
Description (optional)
</Label>
<Input
id={descId}
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="What this flag does"
data-testid="cli-flag-new-desc-input"
onKeyDown={onEnter}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={commit}
disabled={newFlag.trim() === ""}
className="cursor-pointer"
data-testid="cli-flag-add-button"
>
<IconPlus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
);
}