Files
kandev/apps/web/components/settings/cli-flags-field.tsx
T
2026-09-10 18:14:05 +01:00

484 lines
16 KiB
TypeScript

"use client";
import { useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } 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";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsFieldLabel } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
/**
* 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.
*/
// An example flag name — code the user types verbatim, never translated.
const FLAG_PLACEHOLDER = "--my-flag";
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<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 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 (
<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}
onUpdateRow={updateRowAt}
onRemove={removeAt}
onAdd={appendCustom}
/>
)}
</div>
);
}
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<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") 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 { t } = useTranslation();
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
? t("agents:disableSetting", { name: setting.label })
: t("agents:enableSetting", { name: setting.label })
}
/>
</div>
);
})}
</div>
);
}
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;
}) {
const { t } = useTranslation();
return (
<div className="space-y-3">
{customFlags.length === 0 ? (
<p className="text-xs italic text-muted-foreground" data-testid="cli-flags-empty">
{t("agents:noCliFlagsConfigured")}
</p>
) : (
<ul className="space-y-2" data-testid="cli-flags-list">
{customFlags.map(({ flag, index }) => (
<CLIFlagRow
key={index}
flag={flag}
index={index}
onUpdateRow={onUpdateRow}
onRemove={onRemove}
/>
))}
</ul>
)}
<CLIFlagsAddForm onAdd={onAdd} />
</div>
);
}
function CLIFlagRow({
flag,
index,
onUpdateRow,
onRemove,
}: {
flag: CLIFlag;
index: number;
onUpdateRow: (index: number, next: CLIFlag) => void;
onRemove: (index: number) => void;
}) {
const { t } = useTranslation();
const row = flagToRow(flag);
const update = (patch: Partial<CustomFlagRow>) => {
onUpdateRow(index, rowToFlag({ ...row, ...patch }));
};
return (
<li className="flex items-center gap-2" data-testid={`cli-flag-row-${index}`}>
<Input
value={row.flag}
onChange={(e) => update({ flag: e.target.value })}
placeholder={FLAG_PLACEHOLDER}
className="flex-[2] font-mono text-xs"
data-testid={`cli-flag-flag-${index}`}
/>
<Input
value={row.value}
onChange={(e) => update({ value: e.target.value })}
placeholder={t("agents:valueOptional")}
className="flex-[3] font-mono text-xs"
data-testid={`cli-flag-value-${index}`}
/>
<Switch
checked={row.enabled}
onCheckedChange={(checked) => update({ enabled: checked })}
data-testid={`cli-flag-enabled-${index}`}
aria-label={
row.enabled
? t("agents:disableSetting", { name: row.flag })
: t("agents:enableSetting", { name: row.flag })
}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="shrink-0 cursor-pointer"
data-testid={`cli-flag-remove-${index}`}
aria-label={t("agents:removeCliFlag", { name: row.flag || t("agents:flag") })}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</li>
);
}
function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
const { t } = useTranslation();
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<HTMLInputElement>) => {
if (e.key === "Enter" && newFlag.trim() !== "") {
e.preventDefault();
commit();
}
};
return (
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-[2] space-y-1">
<SettingsFieldLabel htmlFor={flagId}>{t("agents:flag")}</SettingsFieldLabel>
<Input
id={flagId}
value={newFlag}
onChange={(e) => setNewFlag(e.target.value)}
placeholder={FLAG_PLACEHOLDER}
className="font-mono text-xs"
data-testid="cli-flag-new-flag-input"
onKeyDown={onEnter}
/>
</div>
<div className="flex-[3] space-y-1">
<SettingsFieldLabel htmlFor={valueId}>{t("agents:valueOptionalLabel")}</SettingsFieldLabel>
<Input
id={valueId}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder={t("agents:valuePlaceholder")}
className="font-mono text-xs"
data-testid="cli-flag-new-value-input"
onKeyDown={onEnter}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={commit}
disabled={newFlag.trim() === ""}
className={settingsActionClassName("cursor-pointer")}
data-testid="cli-flag-add-button"
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
{t("agents:add")}
</Button>
</div>
);
}
type CustomCLIFlagsCardProps = {
flags: CLIFlag[];
baselineFlags?: CLIFlag[];
onChange: (next: CLIFlag[]) => void;
permissionSettings?: Record<string, PermissionSetting>;
discoveryTargetId?: string;
};
/**
* 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,
baselineFlags,
onChange,
permissionSettings,
discoveryTargetId,
}: CustomCLIFlagsCardProps) {
const { t } = useTranslation();
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 isDirty =
baselineFlags !== undefined && JSON.stringify(flags) !== JSON.stringify(baselineFlags);
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 (
<SettingsCard
isDirty={isDirty}
discoveryTargetId={discoveryTargetId}
data-testid="custom-cli-flags-card"
>
<SettingsCardHeader
title={t("agents:agentCliFlags")}
description={t("agents:agentCliFlagsDescription")}
actions={
customFlags.length > 0 ? (
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
{t("agents:cliFlagsEnabledCount", {
count: customFlags.length,
enabled: enabledCount,
})}
</span>
) : undefined
}
/>
<CardContent data-settings-dirty={isDirty} data-settings-dirty-level="container">
<CustomFlagsSection
customFlags={customFlags}
onUpdateRow={onUpdateRow}
onRemove={onRemove}
onAdd={onAdd}
/>
</CardContent>
</SettingsCard>
);
}