[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const root = new URL('../..', import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), 'utf8');
}
test('DebouncedTextarea keeps draft locally and commits on debounce/blur', () => {
const source = readProjectFile('components/settings/DebouncedTextarea.tsx');
assert.match(source, /useState\(value\)/);
assert.match(source, /onDraftChangeRef\.current\?\.\(next\)/);
assert.match(source, /setTimeout\(\(\) =>/);
assert.match(source, /onBlur/);
assert.match(source, /onCommitRef\.current\(draft\)/);
assert.match(source, /draftRef\.current !== committedRef\.current/);
});
test('settings appearance uses debounced custom CSS textarea with live preview', () => {
const source = readProjectFile('components/settings/tabs/SettingsAppearanceTab.tsx');
assert.match(source, /DebouncedTextarea/);
assert.match(source, /applyCustomCssToDocument/);
assert.match(source, /onDraftChange=\{applyCustomCssToDocument\}/);
});

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
interface DebouncedTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
value: string;
onCommit: (value: string) => void;
/** Fires on every keystroke before the debounced commit (e.g. live CSS preview). */
onDraftChange?: (value: string) => void;
debounceMs?: number;
}
/**
* Keeps typing responsive by holding draft text locally and committing upstream
* after a short pause — avoids re-rendering the full settings tree per keystroke.
*/
export const DebouncedTextarea: React.FC<DebouncedTextareaProps> = ({
value,
onCommit,
onDraftChange,
debounceMs = 300,
className,
...props
}) => {
const [draft, setDraft] = useState(value);
const draftRef = useRef(value);
const committedRef = useRef(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCommitRef = useRef(onCommit);
const onDraftChangeRef = useRef(onDraftChange);
onCommitRef.current = onCommit;
onDraftChangeRef.current = onDraftChange;
draftRef.current = draft;
committedRef.current = value;
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
return () => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
if (draftRef.current !== committedRef.current) {
onCommitRef.current(draftRef.current);
}
};
}, []);
const scheduleCommit = (next: string) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
commitTimerRef.current = null;
onCommitRef.current(next);
}, debounceMs);
};
return (
<textarea
{...props}
value={draft}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
onDraftChangeRef.current?.(next);
scheduleCommit(next);
}}
onBlur={() => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
if (draft !== value) {
onCommitRef.current(draft);
}
}}
className={cn(className)}
/>
);
};

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import test from 'node:test';
test('font picker uses the searchable combobox and preserves font previews', () => {
const source = readFileSync(new URL('./FontSelect.tsx', import.meta.url), 'utf8');
assert.match(source, /<Combobox/);
assert.match(source, /placeholder=\{t\('common\.searchPlaceholder'\)\}/);
assert.match(source, /emptyText=\{t\('common\.noResultsFound'\)\}/);
assert.match(source, /labelStyle: \{ fontFamily: font\.family \}/);
assert.match(source, /inputStyle=\{\{ fontFamily: selectedFont\?\.family \}\}/);
assert.match(source, /clearable=\{false\}/);
assert.match(source, /selectValueOnFocus/);
assert.match(source, /ariaLabel=\{ariaLabel\}/);
});
test('terminal font picker reuses the shared searchable font picker', () => {
const source = readFileSync(new URL('./TerminalFontSelect.tsx', import.meta.url), 'utf8');
assert.match(source, /<FontSelect/);
assert.match(source, /fonts=\{visibleFonts\}/);
assert.doesNotMatch(source, /<Combobox/);
});

View File

@@ -0,0 +1,53 @@
import React, { useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Combobox, type ComboboxOption } from '../ui/combobox';
interface SelectableFont {
id: string;
name: string;
family: string;
}
interface FontSelectProps {
value: string;
fonts: SelectableFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
ariaLabel: string;
}
export const FontSelect: React.FC<FontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
ariaLabel,
}) => {
const { t } = useI18n();
const selectedFont = fonts.find((font) => font.id === value);
const options = useMemo<ComboboxOption[]>(() => fonts.map((font) => ({
value: font.id,
label: font.name,
labelStyle: { fontFamily: font.family },
})), [fonts]);
return (
<Combobox
options={options}
value={value}
onValueChange={onChange}
placeholder={t('common.searchPlaceholder')}
emptyText={t('common.noResultsFound')}
triggerClassName={className}
inputStyle={{ fontFamily: selectedFont?.family }}
disabled={disabled}
clearable={false}
selectValueOnFocus
ariaLabel={ariaLabel}
/>
);
};
export default FontSelect;

View File

@@ -0,0 +1,65 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { SettingsFocusTarget } from "./settingsFocus";
export type SettingsFocusRequest = SettingsFocusTarget & {
nonce: number;
};
type SettingsFocusContextValue = {
request: SettingsFocusRequest | null;
requestFocus: (target: SettingsFocusTarget) => void;
clearFocus: () => void;
openSearch: () => void;
registerOpenSearch: (opener: (() => void) | null) => void;
};
const SettingsFocusContext = createContext<SettingsFocusContextValue | null>(null);
export function SettingsFocusProvider({ children }: { children: React.ReactNode }) {
const [request, setRequest] = useState<SettingsFocusRequest | null>(null);
const openSearchRef = useRef<(() => void) | null>(null);
const nonceRef = useRef(0);
const requestFocus = useCallback((target: SettingsFocusTarget) => {
nonceRef.current += 1;
setRequest({
...target,
nonce: nonceRef.current,
});
}, []);
const clearFocus = useCallback(() => {
setRequest(null);
}, []);
const registerOpenSearch = useCallback((opener: (() => void) | null) => {
openSearchRef.current = opener;
}, []);
const openSearch = useCallback(() => {
openSearchRef.current?.();
}, []);
const value = useMemo(
() => ({ request, requestFocus, clearFocus, openSearch, registerOpenSearch }),
[request, requestFocus, clearFocus, openSearch, registerOpenSearch],
);
return (
<SettingsFocusContext.Provider value={value}>
{children}
</SettingsFocusContext.Provider>
);
}
export function useSettingsFocus(): SettingsFocusContextValue {
const ctx = useContext(SettingsFocusContext);
if (!ctx) {
throw new Error("useSettingsFocus must be used within SettingsFocusProvider");
}
return ctx;
}
export function useOptionalSettingsFocus(): SettingsFocusContextValue | null {
return useContext(SettingsFocusContext);
}

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { SettingsFocusProvider } from "./SettingsFocusContext.tsx";
import { SettingsSearchControl } from "./SettingsSearchControl.tsx";
test("SettingsSearchControl collapsed state shows search open button", () => {
const html = renderToStaticMarkup(
<I18nProvider locale="en">
<SettingsFocusProvider>
<SettingsSearchControl />
</SettingsFocusProvider>
</I18nProvider>,
);
assert.match(html, /id="settings-search-open"/);
assert.match(html, /Search settings/);
});

View File

@@ -0,0 +1,220 @@
import { Search, X } from "lucide-react";
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { filterSettingsSearchCatalog, type SettingsSearchHit } from "../../domain/settingsSearch";
import { cn } from "../../lib/utils";
import { Input } from "../ui/input";
import { useSettingsFocus } from "./SettingsFocusContext";
type SettingsSearchControlProps = {
includePlugins?: boolean;
className?: string;
};
export function SettingsSearchControl({
includePlugins = true,
className,
}: SettingsSearchControlProps) {
const { t } = useI18n();
const { requestFocus, registerOpenSearch } = useSettingsFocus();
const listId = useId();
const optionIdPrefix = useId();
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [expanded, setExpanded] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [restoreOpenFocus, setRestoreOpenFocus] = useState(false);
const hits = useMemo(
() => filterSettingsSearchCatalog(query, t, { includePlugins, limit: 12 }),
[query, t, includePlugins],
);
const collapseSearch = useCallback((restoreFocus = false) => {
setExpanded(false);
setQuery("");
if (restoreFocus) setRestoreOpenFocus(true);
}, []);
useEffect(() => {
setActiveIndex(0);
}, [query, hits.length]);
useEffect(() => {
const open = () => {
setExpanded(true);
window.requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
};
registerOpenSearch(open);
return () => registerOpenSearch(null);
}, [registerOpenSearch]);
useEffect(() => {
if (!expanded) return;
const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
collapseSearch(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) return;
if (event.key === "Escape") {
event.preventDefault();
collapseSearch(true);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [expanded, collapseSearch]);
useEffect(() => {
if (expanded) {
inputRef.current?.focus();
return;
}
if (restoreOpenFocus) {
openButtonRef.current?.focus();
setRestoreOpenFocus(false);
}
}, [expanded, restoreOpenFocus]);
const selectHit = (hit: SettingsSearchHit) => {
requestFocus({
tab: hit.entry.tab,
aiSubTab: hit.entry.aiSubTab,
syncSubTab: hit.entry.syncSubTab,
anchorId: hit.entry.id,
});
collapseSearch(false);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Avoid stealing IME candidate navigation / composition confirm (CJK).
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown") {
event.preventDefault();
if (hits.length === 0) return;
setActiveIndex((index) => (index + 1) % hits.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (hits.length === 0) return;
setActiveIndex((index) => (index - 1 + hits.length) % hits.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const hit = hits[activeIndex];
if (hit) selectHit(hit);
}
};
const activeOptionId = hits[activeIndex]
? `${optionIdPrefix}-${hits[activeIndex].entry.id}`
: undefined;
if (!expanded) {
return (
<div className={cn("px-0 pb-2", className)} ref={rootRef}>
<button
id="settings-search-open"
ref={openButtonRef}
type="button"
onClick={() => setExpanded(true)}
className={cn(
"app-no-drag flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm",
"text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground",
)}
aria-label={t("settings.search.open")}
title={t("settings.search.open")}
>
<Search size={14} className="shrink-0" />
<span className="min-w-0 truncate">{t("settings.search.open")}</span>
</button>
</div>
);
}
return (
<div className={cn("relative px-0 pb-2", className)} ref={rootRef}>
<div className="app-no-drag relative">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
placeholder={t("settings.search.placeholder")}
aria-label={t("settings.search.placeholder")}
aria-controls={listId}
aria-expanded={true}
aria-autocomplete="list"
aria-haspopup="listbox"
aria-activedescendant={activeOptionId}
role="combobox"
className="h-9 pl-8 pr-8 text-sm"
/>
<button
type="button"
onClick={() => collapseSearch(true)}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("common.close")}
>
<X size={14} />
</button>
</div>
<div
id={listId}
role="listbox"
className={cn(
"absolute left-0 right-0 top-[calc(100%+4px)] z-40 max-h-72 overflow-y-auto",
"rounded-md border border-border bg-popover text-popover-foreground shadow-md",
)}
>
{hits.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground">
{t("settings.search.noResults")}
</div>
) : (
hits.map((hit, index) => {
const path = [hit.tabLabel, hit.section].filter(Boolean).join(" · ");
return (
<button
key={hit.entry.id}
id={`${optionIdPrefix}-${hit.entry.id}`}
type="button"
role="option"
aria-selected={index === activeIndex}
className={cn(
"flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left transition-colors",
index === activeIndex ? "bg-primary/15" : "hover:bg-muted/50",
)}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectHit(hit)}
>
<span className="text-sm font-medium text-foreground">{hit.label}</span>
{path ? (
<span className="text-[11px] text-muted-foreground">{path}</span>
) : null}
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import * as terminalBehaviorSettings from "./tabs/TerminalBehaviorSettings.tsx";
const source = readFileSync(new URL("./tabs/TerminalBehaviorSettings.tsx", import.meta.url), "utf8");
const middleClickBehaviorOptions = (
terminalBehaviorSettings as {
MIDDLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).MIDDLE_CLICK_BEHAVIOR_OPTIONS;
const dynamicTabTitleModeOptions = (
terminalBehaviorSettings as {
DYNAMIC_TAB_TITLE_MODE_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).DYNAMIC_TAB_TITLE_MODE_OPTIONS;
test("middle-click settings expose only supported behaviors", () => {
assert.ok(Array.isArray(middleClickBehaviorOptions));
assert.deepEqual(
middleClickBehaviorOptions.map((option) => option.value),
["context-menu", "paste", "disabled"],
);
});
test("dynamic tab title settings expose off, agent-only, and all modes", () => {
assert.ok(Array.isArray(dynamicTabTitleModeOptions));
assert.deepEqual(
dynamicTabTitleModeOptions.map((option) => option.value),
["off", "agent", "all"],
);
});
test("terminal behavior settings expose word separator editing", () => {
assert.match(source, /settings\.terminal\.behavior\.wordSeparators/);
assert.match(source, /terminalSettings\.wordSeparators/);
assert.match(source, /updateTerminalSetting\("wordSeparators", e\.target\.value\)/);
});
test("terminal behavior settings expose Shift+Enter text controls", () => {
assert.match(source, /settings\.terminal\.behavior\.shiftEnterNewline/);
assert.match(source, /terminalSettings\.shiftEnterNewlineEnabled/);
assert.match(source, /updateTerminalSetting\("shiftEnterNewlineEnabled", v\)/);
assert.match(source, /terminalSettings\.shiftEnterNewlineText/);
assert.match(source, /updateTerminalSetting\("shiftEnterNewlineText", e\.target\.value\)/);
});

View File

@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import test from 'node:test';
const source = readFileSync(
new URL('./TerminalCjkFontSelect.tsx', import.meta.url),
'utf8',
);
test('font warnings follow the value currently shown in the preview', () => {
assert.match(
source,
/getTerminalCjkFontSelectionStatus\(\s*previewSelection,/,
);
assert.match(
source,
/previewSelection && isFontInstalled\(previewSelection\)/,
);
});

View File

@@ -0,0 +1,168 @@
import React, { useEffect, useMemo, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
refreshFonts,
useFontsLoading,
useInstalledFontFamilies,
} from '../../application/state/fontStore';
import { isFontInstalled } from '../../lib/fontAvailability';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import {
buildTerminalCjkFontOptions,
getTerminalCjkFontSelectionStatus,
RECOMMENDED_CJK_FONT_FAMILIES,
type TerminalCjkFontOptionKind,
} from '../../domain/terminalCjkFonts';
const previewFontFamily = (family: string): string | undefined => {
const trimmed = family.trim();
if (!trimmed) return undefined;
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}", monospace`;
};
interface Props {
value: string;
onChange: (next: string) => void;
className?: string;
disabled?: boolean;
label?: string;
description?: string;
}
export const TerminalCjkFontSelect: React.FC<Props> = ({
value,
onChange,
className,
disabled,
label,
description,
}) => {
const { t } = useI18n();
const installedFamilies = useInstalledFontFamilies();
const isLoading = useFontsLoading();
const [previewValue, setPreviewValue] = useState(value);
useEffect(() => {
setPreviewValue(value);
}, [value]);
const availableRecommendedFamilies = RECOMMENDED_CJK_FONT_FAMILIES.filter(
(family) => isFontInstalled(family),
);
const options = useMemo<ComboboxOption[]>(() => {
const built = buildTerminalCjkFontOptions({
installedFamilies,
selectedValue: value,
availableRecommendedFamilies,
});
const kindLabels: Record<TerminalCjkFontOptionKind, string> = {
auto: '',
recommended: t('settings.terminal.font.cjk.option.recommended'),
installed: t('settings.terminal.font.cjk.option.installed'),
unverified: t('settings.terminal.font.cjk.option.unverified'),
unavailable: t('settings.terminal.font.cjk.option.unavailable'),
};
return built.map((option) => {
const label = option.kind === 'auto'
? t('settings.terminal.font.cjk.option.auto')
: option.value.trim();
return {
value: option.value,
label,
sublabel: kindLabels[option.kind] || undefined,
labelStyle: option.value
? { fontFamily: previewFontFamily(option.value) }
: undefined,
};
});
}, [availableRecommendedFamilies, installedFamilies, t, value]);
const previewSelection = previewValue.trim();
const status = getTerminalCjkFontSelectionStatus(
previewSelection,
installedFamilies,
availableRecommendedFamilies,
Boolean(previewSelection && isFontInstalled(previewSelection)),
);
const selectedFontFamily = previewFontFamily(value);
const previewFamily = previewFontFamily(previewValue);
const controls = (
<div className="flex items-center gap-2">
<Combobox
options={options}
value={value}
onValueChange={onChange}
placeholder={t('settings.terminal.font.cjk.searchPlaceholder')}
emptyText={t('settings.terminal.font.cjk.empty')}
allowCreate
createText={t('settings.terminal.font.cjk.useCustom')}
triggerClassName="h-9"
inputStyle={{ fontFamily: selectedFontFamily }}
onInputValueChange={setPreviewValue}
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
aria-label={t('settings.terminal.font.cjk.refresh')}
title={t('settings.terminal.font.cjk.refresh')}
disabled={disabled || isLoading}
onClick={() => void refreshFonts()}
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
</Button>
</div>
);
const preview = previewValue.trim() && (
<>
<pre
className="m-0 py-2 text-center text-base leading-7 text-foreground"
style={{ fontFamily: previewFamily }}
>
{'你好 │ ABC │ 123\n123 │ 测试 │ ABC'}
</pre>
{status === 'alignment-risk' && (
<p className="m-0 text-center text-xs text-amber-600 dark:text-amber-400">
{t('settings.terminal.font.cjk.alignmentWarning')}
</p>
)}
{status === 'unavailable' && (
<p className="m-0 text-center text-xs text-muted-foreground">
{t('settings.terminal.font.cjk.unavailableWarning')}
</p>
)}
</>
);
if (label) {
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-2 py-3', className)}>
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && <div className="mt-0.5 text-xs text-muted-foreground">{description}</div>}
</div>
<div className="w-72 shrink-0">{controls}</div>
{preview && <div className="col-span-2 justify-self-center">{preview}</div>}
</div>
);
}
return (
<div className={cn('space-y-2', className)}>
{controls}
{preview}
</div>
);
};
export default TerminalCjkFontSelect;

View File

@@ -0,0 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("terminal font dropdown always preserves an explicit default option", () => {
const source = readFileSync(new URL("./TerminalFontSelect.tsx", import.meta.url), "utf8");
assert.match(source, /font\.id === "" \|\| font\.id === value/);
});

View File

@@ -0,0 +1,72 @@
import React, { useMemo, useSyncExternalStore } from 'react';
import {
extractPrimaryFamily,
getFontAvailabilityVersion,
hasAuthoritativeData,
isFontInstalled,
subscribeFontAvailability,
} from '../../lib/fontAvailability';
import type { TerminalFont } from '../../infrastructure/config/fonts';
import { FontSelect } from './FontSelect';
interface TerminalFontSelectProps {
value: string;
fonts: TerminalFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
ariaLabel: string;
}
export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
ariaLabel,
}) => {
// Subscribe to font availability so the filter re-evaluates after the
// Local Font Access API populates the authoritative install set
// asynchronously, even if the `fonts` prop ref hasn't changed.
const availabilityVersion = useSyncExternalStore(
subscribeFontAvailability,
getFontAvailabilityVersion,
getFontAvailabilityVersion,
);
// Hide fonts that aren't actually rendered on this machine so users
// don't pick a font and then see no visible change. The currently
// selected font is always shown so the user can read their setting.
//
// When the Local Font Access API has populated authoritative data,
// trust it: an empty or near-empty result means the user really has
// few monospace fonts (Layer 3 still gives at least one option via
// bundled Sarasa Mono SC). When canvas-only fallback is in play,
// we keep a safety net at length>=1 to avoid an empty dropdown if
// detection misfires.
const visibleFonts = useMemo(() => {
// Referenced so eslint-react-hooks sees the dep used; the real
// purpose is to invalidate this memo when setSystemFamilies bumps
// the version (isFontInstalled reads module state).
void availabilityVersion;
const filtered = fonts.filter(
(font) => font.id === "" || font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
);
if (hasAuthoritativeData()) return filtered;
return filtered.length >= 1 ? filtered : fonts;
}, [fonts, value, availabilityVersion]);
return (
<FontSelect
fonts={visibleFonts}
value={value}
onChange={onChange}
className={className}
disabled={disabled}
ariaLabel={ariaLabel}
/>
);
};
export default TerminalFontSelect;

View File

@@ -0,0 +1,114 @@
/**
* Theme Select Modal
* A modal dialog for selecting terminal themes in settings
*/
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { ThemeList } from '../ThemeList';
interface ThemeSelectModalProps {
open: boolean;
onClose: () => void;
selectedThemeId: string;
onSelect: (themeId: string) => void;
filterType?: 'dark' | 'light';
showAutoOption?: boolean;
}
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
open,
onClose,
selectedThemeId,
onSelect,
filterType,
showAutoOption,
}) => {
const { t } = useI18n();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
onClose();
}, [onSelect, onClose]);
// Handle ESC key
React.useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
}, [onClose]);
if (!open) return null;
const modalTitleId = 'theme-select-modal-title';
const modalContent = (
<div
className="fixed inset-0 flex items-center justify-center bg-black/60"
style={{ zIndex: 99999 }}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby={modalTitleId}
>
<div
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
<Palette size={16} className="text-primary" />
</div>
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t('common.close')}
>
<X size={16} />
</button>
</div>
{/* Theme List */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
filterType={filterType}
showAutoOption={showAutoOption}
/>
</div>
{/* Footer */}
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
<Button
variant="ghost"
onClick={onClose}
>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
);
// Use Portal to render at document root
return createPortal(modalContent, document.body);
};
export default ThemeSelectModal;

View File

@@ -0,0 +1,267 @@
import React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
import { cn } from "../../lib/utils";
import { TabsContent } from "../ui/tabs";
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
ariaLabel?: string;
}
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input",
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
);
interface SelectProps {
value: string;
options: { value: string; label: string; icon?: React.ReactNode }[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
placeholder?: string;
}
export const Select: React.FC<SelectProps> = ({
value,
options,
onChange,
className,
disabled,
placeholder,
}) => {
const selectedOption = options.find((opt) => opt.value === value);
const fitSelectedText = typeof className !== "string" || !className.includes("w-full");
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
"flex h-9 max-w-full items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&>span]:whitespace-nowrap",
fitSelectedText && "min-w-max",
className,
)}
>
<SelectPrimitive.Value placeholder={placeholder}>
<span className="flex min-w-0 items-center gap-2 truncate whitespace-nowrap">
{selectedOption?.icon}
<span className="truncate whitespace-nowrap">{selectedOption?.label}</span>
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1">
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
{opt.icon}
{opt.label}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export const SectionHeader: React.FC<{
title: string;
className?: string;
anchorId?: string;
}> = ({ title, className, anchorId }) => (
<h3
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
data-settings-anchor={anchorId}
className={cn("text-sm font-semibold text-foreground mb-3 rounded-md", className)}
>
{title}
</h3>
);
/** Section title row → content gap (shared across settings pages). */
export const settingsSectionGapClassName = "gap-2";
/** Groups a section title (optional icon/actions) with its content at a uniform gap. */
export const SettingsSection: React.FC<{
title?: string;
leading?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
anchorId?: string;
}> = ({ title, leading, actions, children, className, anchorId }) => (
<section
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
data-settings-anchor={anchorId}
className={cn("flex flex-col rounded-md", settingsSectionGapClassName, className)}
>
{(title || leading || actions) && (
<div
className={cn(
"flex min-h-8 items-center gap-2",
actions && "justify-between gap-4",
)}
>
<div className="flex min-w-0 items-center gap-2">
{leading}
{title ? <h3 className="text-sm font-semibold text-foreground">{title}</h3> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
/** Footer note under a SettingCard. Keep a gap so it does not kiss the card. */
export const settingHintClassName = "mt-3 text-xs text-muted-foreground";
export const SettingHint: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => (
<p className={cn(settingHintClassName, className)}>{children}</p>
);
export const settingCardClassName = "rounded-lg border bg-card";
interface SettingCardProps {
children: React.ReactNode;
className?: string;
/** Row list with dividers; vertical spacing comes from SettingRow. */
divided?: boolean;
/** Free-form content; apply even padding on all sides. */
padded?: boolean;
}
export const SettingCard: React.FC<SettingCardProps> = ({
children,
className,
divided = false,
padded = false,
}) => (
<div
className={cn(
settingCardClassName,
padded ? "p-4" : "px-4",
divided && "space-y-0 divide-y divide-border",
className,
)}
>
{children}
</div>
);
interface SettingRowProps {
label?: string;
description?: string;
children: React.ReactNode;
align?: "center" | "start";
/** Stable catalog id for settings search jump targets. */
anchorId?: string;
}
export const SettingRow: React.FC<SettingRowProps> = ({
label,
description,
children,
align = "center",
anchorId,
}) => (
<div
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
data-settings-anchor={anchorId}
className={cn(
// Keep square corners: rounded rows bend divide-y separators at both ends.
"flex justify-between py-3 gap-4",
align === "start" ? "items-start" : "items-center",
)}
>
<div className="flex-1 min-w-0">
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
<div className={cn("text-xs text-muted-foreground", label && "mt-0.5")}>{description}</div>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
);
/** Lightweight wrapper for non-SettingRow search targets. */
export const SettingsAnchor: React.FC<{
anchorId: string;
className?: string;
children: React.ReactNode;
}> = ({ anchorId, className, children }) => (
<div
id={settingsAnchorDomId(anchorId)}
data-settings-anchor={anchorId}
// No default rounded-md: inside divide-y cards, child border-radius bends separators.
className={className}
>
{children}
</div>
);
export const SettingsTabContent: React.FC<{
value: string;
children: React.ReactNode;
}> = ({ value, children }) => (
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
{/* data-settings-scroll-pane: search jump scrolls only this pane, not the window */}
<div
data-settings-scroll-pane
className="h-full overflow-y-auto overflow-x-hidden"
>
<div className="p-6 space-y-6">{children}</div>
</div>
</TabsContent>
);

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("system setting hints sit below their cards with a shared top gap", () => {
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
const systemSource = readFileSync(new URL("./tabs/SettingsSystemTab.tsx", import.meta.url), "utf8");
assert.match(uiSource, /export const settingHintClassName = "mt-3 text-xs text-muted-foreground"/);
assert.match(uiSource, /export const SettingHint/);
assert.match(systemSource, /SettingHint/);
assert.match(systemSource, /settings\.system\.crashLogs\.hint/);
assert.doesNotMatch(
systemSource,
/<p className="text-xs text-muted-foreground">\s*\{t\("settings\.system\.crashLogs\.hint"\)\}/,
);
});
test("settings search focus scrolls the content pane, not the window viewport", () => {
const source = readFileSync(new URL("./settingsFocus.ts", import.meta.url), "utf8");
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
assert.match(source, /scrollSettingsAnchorIntoView/);
assert.match(source, /findSettingsScrollContainer/);
assert.match(source, /data-settings-scroll-pane/);
assert.match(source, /preventScroll:\s*true/);
// Viewport centering lifts the whole settings window under macOS traffic lights.
assert.doesNotMatch(source, /block:\s*["']center["']/);
assert.match(uiSource, /data-settings-scroll-pane/);
});
test("scrollSettingsAnchorIntoView prefers marked pane over document scroll", async () => {
const { scrollSettingsAnchorIntoView } = await import("./settingsFocus.ts");
const calls: Array<{ top: number; behavior?: ScrollBehavior }> = [];
const scroller = {
scrollHeight: 2000,
clientHeight: 400,
scrollTop: 0,
getBoundingClientRect: () => ({
top: 100,
left: 0,
bottom: 500,
right: 400,
width: 400,
height: 400,
x: 0,
y: 100,
toJSON() {
return this;
},
}),
scrollTo(opts: { top: number; behavior?: ScrollBehavior }) {
calls.push(opts);
this.scrollTop = opts.top;
},
} as unknown as HTMLElement;
const anchor = {
getBoundingClientRect: () => ({
top: 700,
left: 0,
bottom: 760,
right: 200,
width: 200,
height: 60,
x: 0,
y: 700,
toJSON() {
return this;
},
}),
closest(selector: string) {
if (selector === "[data-settings-scroll-pane]") return scroller;
return null;
},
parentElement: scroller,
} as unknown as HTMLElement;
scrollSettingsAnchorIntoView(anchor, "auto");
assert.equal(calls.length, 1);
// elTopInScroller = 700 - 100 + 0 = 600; minus 24 padding → 576
assert.equal(calls[0].top, 576);
assert.equal(calls[0].behavior, "auto");
});

View File

@@ -0,0 +1,166 @@
import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
const HIGHLIGHT_CLASS = "settings-search-highlight";
const HIGHLIGHT_MS = 1600;
/** Keep focused anchors slightly below the top of the settings content pane. */
const SCROLL_TOP_PADDING_PX = 24;
export type SettingsFocusTarget = {
tab: string;
aiSubTab?: string;
syncSubTab?: string;
anchorId: string;
};
let focusGeneration = 0;
let highlightClearTimer: number | null = null;
/** Cancel any in-flight focusSettingsAnchor retries / scroll. */
export function cancelSettingsFocus(): void {
focusGeneration += 1;
}
function isAnchorVisible(el: HTMLElement): boolean {
if (el.closest("[hidden]")) return false;
if (typeof el.checkVisibility === "function") {
return el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
}
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
return el.getClientRects().length > 0;
}
function focusAnchorElement(el: HTMLElement): void {
const focusable = el.matches("button, a, input, select, textarea, [tabindex]")
? el
: el.querySelector<HTMLElement>(
"button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
);
const target = focusable ?? el;
if (!target.hasAttribute("tabindex") && target === el) {
target.tabIndex = -1;
}
try {
// preventScroll: we own scrolling so the settings titlebar / traffic-light
// chrome never rides along with a viewport-level scrollIntoView.
target.focus({ preventScroll: true });
} catch {
// ignore focus failures in non-interactive hosts
}
}
function isVerticallyScrollable(el: HTMLElement): boolean {
const { overflowY } = window.getComputedStyle(el);
if (overflowY !== "auto" && overflowY !== "scroll" && overflowY !== "overlay") {
return false;
}
return el.scrollHeight > el.clientHeight + 1;
}
/**
* Prefer the dedicated settings content scroller; never fall back to
* document/body scrolling (that lifts the whole window under macOS traffic lights).
*/
export function findSettingsScrollContainer(el: HTMLElement): HTMLElement | null {
// Trust the marked pane even when content is shorter than the viewport —
// search jumps must still scroll this host, not the window.
const marked = el.closest<HTMLElement>("[data-settings-scroll-pane]");
if (marked) return marked;
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.documentElement && node !== document.body) {
if (isVerticallyScrollable(node)) return node;
node = node.parentElement;
}
return null;
}
/**
* Scroll only within the settings content pane so the window chrome stays put.
*/
export function scrollSettingsAnchorIntoView(
el: HTMLElement,
behavior: ScrollBehavior = "smooth",
): void {
const scroller = findSettingsScrollContainer(el);
if (!scroller) {
// Nearest-only, never "center" — avoids yanking the whole settings window.
el.scrollIntoView({ behavior, block: "nearest", inline: "nearest" });
return;
}
const scrollerRect = scroller.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elTopInScroller = elRect.top - scrollerRect.top + scroller.scrollTop;
const targetTop = Math.max(0, elTopInScroller - SCROLL_TOP_PADDING_PX);
const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
scroller.scrollTo({
top: Math.min(targetTop, maxScroll),
behavior,
});
}
/**
* Scroll the settings content pane to a catalog anchor and briefly highlight it.
* Retries to cover lazy tab mounts / nested sub-tab switches.
*/
export function focusSettingsAnchor(
anchorId: string,
options?: { attempts?: number; delayMs?: number },
): Promise<boolean> {
const attempts = options?.attempts ?? 40;
const delayMs = options?.delayMs ?? 50;
const generation = ++focusGeneration;
let remaining = attempts;
return new Promise((resolve) => {
const finish = (ok: boolean) => {
if (generation !== focusGeneration) return;
resolve(ok);
};
const tryFocus = () => {
if (generation !== focusGeneration) {
resolve(false);
return;
}
const domId = settingsAnchorDomId(anchorId);
const candidates = [
document.getElementById(domId),
...Array.from(
document.querySelectorAll<HTMLElement>(
`[data-settings-anchor="${CSS.escape(anchorId)}"]`,
),
),
].filter((node): node is HTMLElement => Boolean(node));
const el = candidates.find((node) => isAnchorVisible(node));
if (!el) {
remaining -= 1;
if (remaining > 0) {
window.setTimeout(tryFocus, delayMs);
return;
}
finish(false);
return;
}
scrollSettingsAnchorIntoView(el, "smooth");
el.classList.remove(HIGHLIGHT_CLASS);
void el.offsetWidth;
el.classList.add(HIGHLIGHT_CLASS);
if (highlightClearTimer !== null) {
window.clearTimeout(highlightClearTimer);
}
highlightClearTimer = window.setTimeout(() => {
el.classList.remove(HIGHLIGHT_CLASS);
highlightClearTimer = null;
}, HIGHLIGHT_MS);
focusAnchorElement(el);
finish(true);
};
requestAnimationFrame(tryFocus);
});
}

View File

@@ -0,0 +1,451 @@
import React, { useCallback, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { AppLockSystemUnlockStatus } from '../../../application/state/useAppLockState';
import {
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
type AppLockSettings,
type AppLockSettingsChangeError,
type AppLockTimeoutMinutes,
} from '../../../domain/appLock';
import { Button } from '../../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../ui/dialog';
import { Input } from '../../ui/input';
import { Label } from '../../ui/label';
import { Select, SettingCard, SettingHint, SettingRow, SectionHeader, Toggle } from '../settings-ui';
type AppLockDialogMode = 'setup' | 'change' | 'disable' | null;
interface AppLockSettingsSectionProps {
appLockSettings: AppLockSettings;
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
requestAppLockDisable: (
currentPassword: string,
) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
requestAppLockPasswordChange: (input: {
currentPassword?: string;
nextPassword: string;
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
setAppLockSystemUnlockEnabled?: (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => Promise<
AppLockSettings
| { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }
>;
}
export const AppLockSettingsSection: React.FC<AppLockSettingsSectionProps> = ({
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockDisable,
requestAppLockPasswordChange,
appLockSystemUnlockStatus,
setAppLockSystemUnlockEnabled,
}) => {
const { t } = useI18n();
const [dialogMode, setDialogMode] = useState<AppLockDialogMode>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [disablePassword, setDisablePassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isDisabling, setIsDisabling] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingSystemUnlock, setIsSavingSystemUnlock] = useState(false);
const hasPassword = Boolean(appLockSettings.passwordVerifier);
const isDialogBusy = isDisabling || isSavingPassword;
const showSystemUnlock = Boolean(
hasPassword
&& appLockSystemUnlockStatus?.supported
&& appLockSystemUnlockStatus.label
&& (appLockSystemUnlockStatus.available || appLockSettings.systemUnlockEnabled),
);
const timeoutOptions = APP_LOCK_TIMEOUT_OPTIONS_MINUTES.map((minutes) => ({
value: String(minutes),
label: t(`settings.appLock.timeout.${minutes}`),
}));
const mapChangeError = useCallback((
changeError: AppLockSettingsChangeError | 'locked' | 'unsupported' | 'unavailable' | 'failed',
): string => {
switch (changeError) {
case 'empty-current':
return t('settings.appLock.validation.currentRequired');
case 'empty-next':
return t('settings.appLock.validation.newRequired');
case 'incorrect':
return t('settings.appLock.validation.incorrect');
case 'locked':
return t('settings.appLock.systemUnlock.locked');
case 'unsupported':
case 'unavailable':
case 'failed':
return t('settings.appLock.systemUnlock.unavailable');
}
}, [t]);
const resetDialog = useCallback(() => {
setDialogMode(null);
setCurrentPassword('');
setDisablePassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
}, []);
const openDialog = useCallback((mode: Exclude<AppLockDialogMode, null>) => {
setError(null);
setDialogMode(mode);
}, []);
const handleTimeoutChange = useCallback((value: string) => {
const timeoutMinutes = Number(value) as AppLockTimeoutMinutes;
if (!APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(timeoutMinutes)) return;
setAppLockTimeoutMinutes(timeoutMinutes);
}, [setAppLockTimeoutMinutes]);
const handleDisable = useCallback(async () => {
setError(null);
setIsDisabling(true);
try {
const result = await requestAppLockDisable(disablePassword);
if ('ok' in result && result.ok === false) {
setError(mapChangeError(result.error));
return;
}
resetDialog();
} finally {
setIsDisabling(false);
}
}, [disablePassword, mapChangeError, requestAppLockDisable, resetDialog]);
const handleSavePassword = useCallback(async () => {
setError(null);
if (newPassword.length === 0) {
setError(t('settings.appLock.validation.newRequired'));
return;
}
if (confirmPassword.length === 0) {
setError(t('settings.appLock.validation.confirmRequired'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('settings.appLock.validation.mismatch'));
return;
}
setIsSavingPassword(true);
try {
const result = await requestAppLockPasswordChange({
currentPassword: hasPassword ? currentPassword : undefined,
nextPassword: newPassword,
});
if ('ok' in result && result.ok === false) {
setError(mapChangeError(result.error));
return;
}
resetDialog();
} finally {
setIsSavingPassword(false);
}
}, [
confirmPassword,
currentPassword,
hasPassword,
mapChangeError,
newPassword,
requestAppLockPasswordChange,
resetDialog,
t,
]);
const handleSystemUnlockChange = useCallback(async (enabled: boolean) => {
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
setError(null);
setIsSavingSystemUnlock(true);
try {
const result = await setAppLockSystemUnlockEnabled({
enabled,
autoPromptEnabled: enabled && appLockSettings.systemUnlockEnabled
? appLockSettings.systemUnlockAutoPromptEnabled
: false,
});
if ('ok' in result && result.ok === false) {
if (result.error === 'cancelled') return;
setError(mapChangeError(result.error));
}
} finally {
setIsSavingSystemUnlock(false);
}
}, [
appLockSettings.systemUnlockAutoPromptEnabled,
appLockSettings.systemUnlockEnabled,
appLockSystemUnlockStatus?.label,
mapChangeError,
setAppLockSystemUnlockEnabled,
]);
const handleAutoPromptChange = useCallback(async (autoPromptEnabled: boolean) => {
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
if (!appLockSettings.systemUnlockEnabled) return;
setError(null);
setIsSavingSystemUnlock(true);
try {
const result = await setAppLockSystemUnlockEnabled({ enabled: true, autoPromptEnabled });
if ('ok' in result && result.ok === false) {
if (result.error === 'cancelled') return;
setError(mapChangeError(result.error));
}
} finally {
setIsSavingSystemUnlock(false);
}
}, [
appLockSettings.systemUnlockEnabled,
appLockSystemUnlockStatus?.label,
mapChangeError,
setAppLockSystemUnlockEnabled,
]);
const dialogTitle = dialogMode === 'disable'
? t('settings.appLock.disableTitle')
: dialogMode === 'change'
? t('settings.appLock.changePasswordTitle')
: t('settings.appLock.setupPasswordTitle');
const dialogDescription = dialogMode === 'disable'
? t('settings.appLock.disableDescription')
: dialogMode === 'change'
? t('settings.appLock.changePasswordDescription')
: t('settings.appLock.setupPasswordDescription');
return (
<>
<SectionHeader title={t('settings.appLock.title')} anchorId="system-app-lock" />
<SettingCard divided>
{!hasPassword ? (
<SettingRow
label={t('settings.appLock.setupTitle')}
description={t('settings.appLock.setupDescription')}
>
<Button type="button" size="sm" onClick={() => openDialog('setup')}>
{t('settings.appLock.savePassword')}
</Button>
</SettingRow>
) : (
<>
<SettingRow
label={t('settings.appLock.manageTitle')}
description={appLockSettings.enabled
? t('settings.appLock.enabledStatus')
: t('settings.appLock.disabledStatus')}
>
<span className={appLockSettings.enabled
? 'text-xs font-medium text-emerald-600 dark:text-emerald-400'
: 'text-xs font-medium text-muted-foreground'}
>
{t(appLockSettings.enabled ? 'common.enabled' : 'common.disabled')}
</span>
</SettingRow>
<SettingRow
label={t('settings.appLock.timeout')}
description={t('settings.appLock.timeoutDesc')}
>
<Select
value={String(appLockSettings.timeoutMinutes)}
options={timeoutOptions}
onChange={handleTimeoutChange}
className="w-36"
/>
</SettingRow>
{showSystemUnlock && appLockSystemUnlockStatus?.label && (
<>
<SettingRow
label={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
description={appLockSystemUnlockStatus.available
? t('settings.appLock.systemUnlock.desc').replace('{label}', appLockSystemUnlockStatus.label)
: t('settings.appLock.systemUnlock.unavailableDesc').replace('{label}', appLockSystemUnlockStatus.label)}
>
<Toggle
checked={appLockSettings.systemUnlockEnabled}
disabled={
isSavingSystemUnlock
|| (!appLockSystemUnlockStatus.available && !appLockSettings.systemUnlockEnabled)
}
ariaLabel={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
onChange={(enabled) => void handleSystemUnlockChange(enabled)}
/>
</SettingRow>
<SettingRow
label={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
description={t('settings.appLock.systemUnlock.autoPrompt.desc').replace('{label}', appLockSystemUnlockStatus.label)}
>
<Toggle
checked={appLockSettings.systemUnlockAutoPromptEnabled}
disabled={
isSavingSystemUnlock
|| !appLockSettings.systemUnlockEnabled
|| !appLockSystemUnlockStatus.available
}
ariaLabel={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
onChange={(enabled) => void handleAutoPromptChange(enabled)}
/>
</SettingRow>
</>
)}
<SettingRow
label={t('settings.appLock.changePasswordTitle')}
description={t('settings.appLock.changePasswordDescription')}
>
<Button type="button" size="sm" variant="outline" onClick={() => openDialog('change')}>
{t('settings.appLock.replacePassword')}
</Button>
</SettingRow>
{appLockSettings.enabled && (
<SettingRow
label={t('settings.appLock.disableTitle')}
description={t('settings.appLock.disableDescription')}
>
<Button type="button" size="sm" variant="destructive" onClick={() => openDialog('disable')}>
{t('settings.appLock.disable')}
</Button>
</SettingRow>
)}
</>
)}
</SettingCard>
<SettingHint>{t('settings.appLock.localOnlyHint')}</SettingHint>
{error && dialogMode === null && (
<p className="mt-2 text-xs text-destructive">{error}</p>
)}
<Dialog
open={dialogMode !== null}
onOpenChange={(open) => {
if (!open && !isDialogBusy) resetDialog();
}}
>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (dialogMode === 'disable') {
void handleDisable();
} else {
void handleSavePassword();
}
}}
>
<div className="space-y-3">
{dialogMode === 'disable' && (
<div className="space-y-2">
<Label htmlFor="app-lock-disable-password">{t('settings.appLock.currentPassword')}</Label>
<Input
id="app-lock-disable-password"
type="password"
value={disablePassword}
autoComplete="current-password"
placeholder={t('settings.appLock.currentPasswordForDisablePlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setDisablePassword(event.target.value);
setError(null);
}}
/>
</div>
)}
{dialogMode === 'change' && (
<div className="space-y-2">
<Label htmlFor="app-lock-current-password">{t('settings.appLock.currentPassword')}</Label>
<Input
id="app-lock-current-password"
type="password"
value={currentPassword}
autoComplete="current-password"
placeholder={t('settings.appLock.currentPasswordForChangePlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setCurrentPassword(event.target.value);
setError(null);
}}
/>
</div>
)}
{dialogMode !== 'disable' && (
<>
<div className="space-y-2">
<Label htmlFor="app-lock-new-password">{t('settings.appLock.newPassword')}</Label>
<Input
id="app-lock-new-password"
type="password"
value={newPassword}
autoComplete="new-password"
placeholder={t('settings.appLock.newPasswordPlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setNewPassword(event.target.value);
setError(null);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="app-lock-confirm-password">{t('settings.appLock.confirmPassword')}</Label>
<Input
id="app-lock-confirm-password"
type="password"
value={confirmPassword}
autoComplete="new-password"
placeholder={t('settings.appLock.confirmPasswordPlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setConfirmPassword(event.target.value);
setError(null);
}}
/>
</div>
</>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={isDialogBusy} onClick={resetDialog}>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant={dialogMode === 'disable' ? 'destructive' : 'default'}
disabled={isDialogBusy}
>
{dialogMode === 'disable'
? (isDisabling ? t('settings.appLock.disabling') : t('settings.appLock.disable'))
: (isSavingPassword ? t('settings.appLock.savingPassword') : t('settings.appLock.savePassword'))}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import {
createPluginStructuredDefaultValue,
PluginStructuredSettingEditor,
} from './PluginStructuredSettingEditor';
const labels = {
add: 'Add item',
remove: 'Remove item',
moveUp: (index: number) => `Move ${index} up`,
moveDown: (index: number) => `Move ${index} down`,
};
test('structured setting defaults preserve nested const, object, and array semantics', () => {
assert.deepEqual(createPluginStructuredDefaultValue({
type: 'object',
properties: {
kind: { type: 'string', const: 'ssh' },
enabled: { type: 'boolean' },
ports: { type: 'array', items: { type: 'integer', minimum: 1 } },
},
required: ['kind', 'ports'],
}), { kind: 'ssh', ports: [] });
assert.deepEqual(createPluginStructuredDefaultValue({
type: 'array',
minItems: 2,
items: { type: 'string', minLength: 3 },
}), ['xxx', 'xxx']);
assert.equal(createPluginStructuredDefaultValue({ type: 'integer', maximum: -2 }), -2);
});
test('structured table settings render native nested controls instead of a JSON textarea', () => {
const setting = {
id: 'com.example.routes',
label: 'Routes',
control: 'table',
scope: 'application',
scopeId: 'application',
visible: true,
sortable: true,
valueSchema: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1 },
targets: { type: 'array', items: { type: 'string' }, maxItems: 3 },
note: { type: 'string' },
},
required: ['name', 'targets'],
},
},
} as NetcattyPluginSettingContribution;
const html = renderToStaticMarkup(
<PluginStructuredSettingEditor
setting={setting}
value={[{ name: 'Production', targets: ['host-1'] }]}
disabled={false}
onChange={() => {}}
onCommit={() => {}}
labels={labels}
/>,
);
assert.match(html, /aria-label="Routes name 1"/u);
assert.match(html, /aria-label="Routes targets 1 1"/u);
assert.match(html, /aria-label="Add item: note"/u);
assert.match(html, />Add item</u);
assert.doesNotMatch(html, /<textarea/u);
});
test('structured enum controls select deserialized object values by canonical JSON equality', () => {
const setting = {
id: 'com.example.targets',
label: 'Targets',
control: 'list',
scope: 'application',
scopeId: 'application',
visible: true,
valueSchema: {
type: 'array',
items: {
type: 'object',
enum: [{ kind: 'host', id: 'one' }, { id: 'two', kind: 'host' }],
properties: { id: { type: 'string' }, kind: { type: 'string' } },
required: ['id', 'kind'],
additionalProperties: false,
},
},
} as NetcattyPluginSettingContribution;
const html = renderToStaticMarkup(
<PluginStructuredSettingEditor
setting={setting}
value={[{ kind: 'host', id: 'two' }]}
disabled={false}
onChange={() => {}}
onCommit={() => {}}
labels={labels}
/>,
);
assert.match(html, /<option value="1" selected="">/u);
assert.match(html, /\{&quot;id&quot;:&quot;two&quot;,&quot;kind&quot;:&quot;host&quot;\}/u);
});

View File

@@ -0,0 +1,362 @@
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
import React from 'react';
import { canonicalJsonString, jsonValuesEqual } from '../../../domain/convergentSync/json';
import type { JsonValue } from '../../../domain/convergentSync/types';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
type RestrictedSchema = {
type?: string;
const?: unknown;
enum?: unknown[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
minItems?: number;
maxItems?: number;
items?: RestrictedSchema;
properties?: Record<string, RestrictedSchema>;
required?: string[];
};
function asSchema(value: unknown): RestrictedSchema {
return value && typeof value === 'object' && !Array.isArray(value) ? value as RestrictedSchema : {};
}
export function createPluginStructuredDefaultValue(schema: RestrictedSchema): unknown {
if (schema.const !== undefined) return structuredClone(schema.const);
if (schema.enum?.length) return structuredClone(schema.enum[0]);
if (schema.type === 'boolean') return false;
if (schema.type === 'number' || schema.type === 'integer') {
if (schema.type === 'integer') {
return schema.minimum !== undefined
? Math.ceil(schema.minimum)
: Math.min(0, Math.floor(schema.maximum ?? 0));
}
return schema.minimum ?? Math.min(0, schema.maximum ?? 0);
}
if (schema.type === 'array') {
return Array.from(
{ length: schema.minItems ?? 0 },
() => createPluginStructuredDefaultValue(asSchema(schema.items)),
);
}
if (schema.type === 'null') return null;
if (schema.type === 'object') {
const required = new Set(schema.required ?? []);
return Object.fromEntries(Object.entries(schema.properties ?? {})
.filter(([key]) => required.has(key))
.map(([key, child]) => [key, createPluginStructuredDefaultValue(child)]));
}
return ''.padEnd(schema.minLength ?? 0, 'x');
}
function StructuredValueInput({
schema,
value,
disabled,
label,
onChange,
onCommit,
sortable = false,
labels,
}: {
schema: RestrictedSchema;
value: unknown;
disabled: boolean;
label: string;
onChange(value: unknown): void;
onCommit(value: unknown): void;
sortable?: boolean;
labels: { add: string; remove: string; moveUp(index: number): string; moveDown(index: number): string };
}) {
if (schema.const !== undefined) {
return <code className="block rounded bg-muted/40 px-2 py-1.5 text-xs">{JSON.stringify(schema.const)}</code>;
}
if (schema.enum?.length) {
const selectedIndex = schema.enum.findIndex((candidate) => (
jsonValuesEqual(candidate as JsonValue, value as JsonValue)
));
return (
<select
aria-label={label}
value={String(Math.max(0, selectedIndex))}
disabled={disabled}
onChange={(event) => {
const selected = schema.enum?.[Number(event.target.value)];
onChange(selected);
onCommit(selected);
}}
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
>
{schema.enum.map((candidate, index) => (
<option key={`${index}:${JSON.stringify(candidate)}`} value={String(index)}>
{typeof candidate === 'string' ? candidate : canonicalJsonString(candidate as JsonValue)}
</option>
))}
</select>
);
}
if (schema.type === 'null') {
return <code className="block rounded bg-muted/40 px-2 py-1.5 text-xs">null</code>;
}
if (schema.type === 'boolean') {
return (
<input
aria-label={label}
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(event) => {
onChange(event.target.checked);
onCommit(event.target.checked);
}}
className="h-4 w-4 accent-primary"
/>
);
}
if (schema.type === 'object') {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const required = new Set(schema.required ?? []);
return (
<div className="space-y-2 rounded-md border border-border/60 p-2">
{Object.entries(schema.properties ?? {}).map(([key, child]) => {
const present = Object.hasOwn(record, key);
if (!required.has(key) && !present) {
return (
<div key={key} className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-muted-foreground">{key}</span>
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => {
const next = { ...record, [key]: createPluginStructuredDefaultValue(child) };
onChange(next);
onCommit(next);
}} aria-label={`${labels.add}: ${key}`}><Plus size={13} className="mr-2" />{labels.add}</Button>
</div>
);
}
return (
<div key={key} className="block space-y-1 text-xs">
<span className="font-medium text-muted-foreground">{key}</span>
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1">
<StructuredValueInput
schema={child}
value={record[key]}
disabled={disabled}
label={`${label} ${key}`}
labels={labels}
onChange={(next) => onChange({ ...record, [key]: next })}
onCommit={(next) => onCommit({ ...record, [key]: next })}
/>
</div>
{!required.has(key) && <Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" disabled={disabled} onClick={() => {
const next = { ...record };
delete next[key];
onChange(next);
onCommit(next);
}} aria-label={`${labels.remove}: ${key}`}><Trash2 size={13} /></Button>}
</div>
</div>
);
})}
</div>
);
}
if (schema.type === 'array') {
const items = Array.isArray(value) ? value : [];
const itemSchema = asSchema(schema.items);
const commitItems = (next: unknown[]) => {
onChange(next);
onCommit(next);
};
return (
<div className="space-y-2 rounded-md border border-border/60 p-2">
{items.map((item, index) => (
<div key={index} className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<StructuredValueInput
schema={itemSchema}
value={item}
disabled={disabled}
label={`${label} ${index + 1}`}
labels={labels}
onChange={(nextItem) => {
const next = [...items];
next[index] = nextItem;
onChange(next);
}}
onCommit={(nextItem) => {
const next = [...items];
next[index] = nextItem;
onCommit(next);
}}
/>
</div>
<div className="flex shrink-0 items-center gap-1">
{sortable && <Button type="button" variant="ghost" size="icon" className="h-7 w-7" disabled={disabled || index === 0} onClick={() => {
const next = [...items];
const [moved] = next.splice(index, 1);
next.splice(index - 1, 0, moved);
commitItems(next);
}} aria-label={labels.moveUp(index)}><ArrowUp size={13} /></Button>}
{sortable && <Button type="button" variant="ghost" size="icon" className="h-7 w-7" disabled={disabled || index === items.length - 1} onClick={() => {
const next = [...items];
const [moved] = next.splice(index, 1);
next.splice(index + 1, 0, moved);
commitItems(next);
}} aria-label={labels.moveDown(index)}><ArrowDown size={13} /></Button>}
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" disabled={disabled || items.length <= (schema.minItems ?? 0)} onClick={() => commitItems(items.filter((_item, itemIndex) => itemIndex !== index))} aria-label={labels.remove}><Trash2 size={13} /></Button>
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" disabled={disabled || items.length >= (schema.maxItems ?? Number.POSITIVE_INFINITY)} onClick={() => commitItems([...items, createPluginStructuredDefaultValue(itemSchema)])}>
<Plus size={13} className="mr-2" />{labels.add}
</Button>
</div>
);
}
const numeric = schema.type === 'number' || schema.type === 'integer';
return (
<Input
aria-label={label}
type={numeric ? 'number' : 'text'}
value={numeric ? Number(value ?? 0) : String(value ?? '')}
min={schema.minimum}
max={schema.maximum}
minLength={schema.minLength}
maxLength={schema.maxLength}
step={schema.type === 'integer' ? 1 : undefined}
disabled={disabled}
onChange={(event) => onChange(numeric ? Number(event.target.value) : event.target.value)}
onBlur={(event) => onCommit(numeric ? Number(event.currentTarget.value) : event.currentTarget.value)}
className="min-w-28"
/>
);
}
export function PluginStructuredSettingEditor({
setting,
value,
disabled,
onChange,
onCommit,
labels,
}: {
setting: NetcattyPluginSettingContribution;
value: unknown;
disabled: boolean;
onChange(value: unknown[]): void;
onCommit(value: unknown[]): void;
labels: { add: string; remove: string; moveUp(index: number): string; moveDown(index: number): string };
}) {
const root = asSchema(setting.valueSchema);
const itemSchema = asSchema(root.items);
const items = Array.isArray(value) ? value : [];
const properties = itemSchema.type === 'object' && !itemSchema.enum?.length && itemSchema.const === undefined
? Object.entries(itemSchema.properties ?? {})
: [];
const updateItem = (index: number, nextItem: unknown, commit: boolean) => {
const next = [...items];
next[index] = nextItem;
onChange(next);
if (commit) onCommit(next);
};
const move = (from: number, to: number) => {
if (to < 0 || to >= items.length) return;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
onCommit(next);
};
const remove = (index: number) => {
const next = items.filter((_item, itemIndex) => itemIndex !== index);
onChange(next);
onCommit(next);
};
const add = () => {
const next = [...items, createPluginStructuredDefaultValue(itemSchema)];
onChange(next);
onCommit(next);
};
return (
<div className="max-w-2xl space-y-2">
{properties.length > 0 && (
<div className="grid gap-2 px-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
style={{ gridTemplateColumns: `repeat(${properties.length}, minmax(7rem, 1fr)) auto` }}>
{properties.map(([key]) => <span key={key}>{key}</span>)}
<span className="sr-only">Actions</span>
</div>
)}
{items.map((item, index) => {
const objectItem = item && typeof item === 'object' && !Array.isArray(item) ? item as Record<string, unknown> : {};
const required = new Set(itemSchema.required ?? []);
return (
<div
key={`${setting.id}:${index}`}
className="grid items-center gap-2 rounded-md border border-border/70 bg-muted/10 p-2"
style={{ gridTemplateColumns: properties.length ? `repeat(${properties.length}, minmax(7rem, 1fr)) auto` : 'minmax(0, 1fr) auto' }}
>
{properties.length ? properties.map(([key, schema]) => {
const present = Object.hasOwn(objectItem, key);
if (!required.has(key) && !present) {
return <Button key={key} type="button" variant="outline" size="sm" disabled={disabled} onClick={() => updateItem(index, {
...objectItem,
[key]: createPluginStructuredDefaultValue(schema),
}, true)} aria-label={`${labels.add}: ${key}`}><Plus size={13} className="mr-2" />{labels.add}</Button>;
}
return (
<div key={key} className="flex min-w-0 items-start gap-1">
<div className="min-w-0 flex-1">
<StructuredValueInput
schema={schema}
value={objectItem[key]}
disabled={disabled}
label={`${setting.label} ${key} ${index + 1}`}
labels={labels}
onChange={(next) => updateItem(index, { ...objectItem, [key]: next }, false)}
onCommit={(next) => updateItem(index, { ...objectItem, [key]: next }, true)}
/>
</div>
{!required.has(key) && <Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" disabled={disabled} onClick={() => {
const next = { ...objectItem };
delete next[key];
updateItem(index, next, true);
}} aria-label={`${labels.remove}: ${key}`}><Trash2 size={13} /></Button>}
</div>
);
}) : (
<StructuredValueInput
schema={itemSchema}
value={item}
disabled={disabled}
label={`${setting.label} ${index + 1}`}
labels={labels}
sortable={setting.sortable}
onChange={(next) => updateItem(index, next, false)}
onCommit={(next) => updateItem(index, next, true)}
/>
)}
<div className="flex items-center justify-end gap-1">
{setting.sortable && (
<>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" disabled={disabled || index === 0} onClick={() => move(index, index - 1)} aria-label={labels.moveUp(index)}><ArrowUp size={13} /></Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" disabled={disabled || index === items.length - 1} onClick={() => move(index, index + 1)} aria-label={labels.moveDown(index)}><ArrowDown size={13} /></Button>
</>
)}
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" disabled={disabled || items.length <= (root.minItems ?? 0)} onClick={() => remove(index)} aria-label={labels.remove}><Trash2 size={13} /></Button>
</div>
</div>
);
})}
<Button type="button" variant="outline" size="sm" disabled={disabled || items.length >= (root.maxItems ?? Number.POSITIVE_INFINITY)} onClick={add}>
<Plus size={13} className="mr-2" />{labels.add}
</Button>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React, { useCallback, useEffect, useRef, useState } from "react";
import TestRenderer, { act } from "react-test-renderer";
import ts from "typescript";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Exercise the production lifecycle and asynchronous resolver together, without
// loading the unrelated provider forms or making real CLI/authentication calls.
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
...setters.map(name => `setIsResolving${name}`)];
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
const { ${bindings.join(",")} } = ctx;
${declarations}
return resolveAgentPath;
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
function deferred() {
let resolve!: (value: unknown) => void;
const promise = new Promise<unknown>(done => { resolve = done; });
return { promise, resolve };
}
async function mountResolver() {
const calls: ReturnType<typeof deferred>[] = [];
const applied: unknown[] = [];
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
function Harness() {
const [busy, setBusy] = useState(false);
state.busy = busy;
state.resolve = runHooks({
useRef, useEffect, useCallback,
getBridge: () => bridge,
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
cursorApiKeyEncrypted: "",
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
});
return null;
}
let root!: TestRenderer.ReactTestRenderer;
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
return { calls, applied, state, root };
}
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
const h = await mountResolver();
try {
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
assert.equal(h.state.busy, true);
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
await act(async () => { h.calls[0].resolve(result); await request; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
test("AI detection still ignores completion after real unmount", async () => {
const h = await mountResolver();
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
await act(async () => h.root.unmount());
h.calls[0].resolve({ installed: true });
await request;
assert.deepEqual(h.applied, []);
});
test("an older AI detection result cannot replace or finish the newer request", async () => {
const h = await mountResolver();
try {
let old!: Promise<unknown>;
let current!: Promise<unknown>;
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
assert.deepEqual(h.applied, []);
assert.equal(h.state.busy, true);
const result = { installed: true };
await act(async () => { h.calls[1].resolve(result); await current; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,612 @@
import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { useStoredString } from "../../../application/state/useStoredString";
import { useStoredNumber } from "../../../application/state/useStoredNumber";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
import { useAvailableFonts } from "../../../application/state/fontStore";
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { APP_ICON_ELF_SVG, APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY, APP_ICON_VARIANT_TILE } from "../../../infrastructure/config/appIconVariants";
import {
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
} from "../../../infrastructure/config/storageKeys";
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../ui/dialog";
import { LazyMessageResponse } from "../../ai-elements/LazyMessageResponse";
const CUSTOM_CSS_HELP_PROSE_CLASS =
"text-xs text-foreground/90 leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0";
function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
resolvedTheme: "dark" | "light";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
setLightUiThemeId: (themeId: string) => void;
darkUiThemeId: string;
setDarkUiThemeId: (themeId: string) => void;
accentMode: "theme" | "custom";
setAccentMode: (mode: "theme" | "custom") => void;
customAccent: string;
setCustomAccent: (color: string) => void;
uiFontFamilyId: string;
setUiFontFamilyId: (fontId: string) => void;
uiLanguage: string;
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
showRecentHosts: boolean;
setShowRecentHosts: (enabled: boolean) => void;
hostClickBehavior: "connect" | "select";
setHostClickBehavior: (behavior: "connect" | "select") => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
showHostTreeSidebar: boolean;
setShowHostTreeSidebar: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
appIconVariant: AppIconVariant;
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
const availableMonoFonts = useAvailableFonts();
const noteFontOptions = useMemo(() => [
{ id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const },
...availableMonoFonts,
], [availableMonoFonts, t]);
const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false);
const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean(
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
);
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
"",
);
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
14,
{ min: 10, max: 32 },
);
const handleSetNoteFontSize = useCallback((size: number) => {
setNoteFontSize(size);
persistNoteFontSize(size);
}, [persistNoteFontSize, setNoteFontSize]);
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
13,
{ min: 10, max: 32 },
);
const handleSetNoteCodeFontSize = useCallback((size: number) => {
setNoteCodeFontSize(size);
persistNoteCodeFontSize(size);
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
const {
theme,
resolvedTheme,
setTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,
setDarkUiThemeId,
accentMode,
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
uiLanguage,
setUiLanguage,
customCSS,
setCustomCSS,
showRecentHosts,
setShowRecentHosts,
hostClickBehavior,
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
windowOpacity,
setWindowOpacity,
appIconVariant,
setAppIconVariant,
} = props;
const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant);
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}, []);
const ACCENT_COLORS = [
{ name: "Sky", value: "199 89% 48%" },
{ name: "Blue", value: "221.2 83.2% 53.3%" },
{ name: "Indigo", value: "234 89% 62%" },
{ name: "Violet", value: "262.1 83.3% 57.8%" },
{ name: "Purple", value: "271 81% 56%" },
{ name: "Fuchsia", value: "292 84% 61%" },
{ name: "Pink", value: "330 81% 60%" },
{ name: "Rose", value: "346.8 77.2% 49.8%" },
{ name: "Red", value: "0 84.2% 60.2%" },
{ name: "Orange", value: "24.6 95% 53.1%" },
{ name: "Amber", value: "38 92% 50%" },
{ name: "Yellow", value: "48 96% 53%" },
{ name: "Lime", value: "84 81% 44%" },
{ name: "Green", value: "142.1 76.2% 36.3%" },
{ name: "Emerald", value: "160 84% 39%" },
{ name: "Teal", value: "173 80% 40%" },
{ name: "Cyan", value: "189 94% 43%" },
{ name: "Slate", value: "215 16% 47%" },
];
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
];
const renderThemeSwatches = (
options: { id: string; name: string; tokens: { background: string } }[],
value: string,
onChange: (next: string) => void,
) => (
<div className="flex min-w-0 flex-1 flex-wrap justify-end gap-2">
{options.map((preset) => (
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{preset.name}</TooltipContent>
</Tooltip>
))}
</div>
);
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
return (
<SettingsTabContent value="appearance">
<SectionHeader title={t("settings.appearance.language")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-language"
label={t("settings.appearance.language")}
description={t("settings.appearance.language.desc")}
>
<Select
value={uiLanguage}
options={SUPPORTED_UI_LOCALES.map((l) => ({ value: l.id, label: l.label }))}
onChange={(v) => setUiLanguage(v)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="appearance-ui-font"
label={t("settings.appearance.uiFont")}
description={t("settings.appearance.uiFont.desc")}
>
<FontSelect
value={uiFontFamilyId}
fonts={availableUIFonts}
onChange={(v) => setUiFontFamilyId(v)}
className="w-48"
ariaLabel={t("settings.appearance.uiFont")}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.windowOpacity")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-window-opacity"
label={t("settings.appearance.windowOpacity")}
description={t("settings.appearance.windowOpacity.desc")}
>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={1}
value={Math.round(windowOpacity * 100)}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{Math.round(windowOpacity * 100)}%
</span>
</div>
<div className="flex items-center gap-1.5">
{WINDOW_OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
windowOpacity === preset.value
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:text-foreground",
)}
>
{preset.label}
</button>
))}
</div>
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow anchorId="appearance-theme" label={t("settings.appearance.theme")}>
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
theme === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</SettingRow>
<SettingsAnchor anchorId="appearance-theme-color">
<div className="flex items-start justify-between gap-4 py-3">
<div className="shrink-0 pt-0.5 text-sm font-medium">
{resolvedTheme === "dark"
? t("settings.appearance.themeColor.dark")
: t("settings.appearance.themeColor.light")}
</div>
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
</div>
</SettingsAnchor>
<SettingRow
anchorId="appearance-accent-mode"
label={t("settings.appearance.accentColor.mode")}
description={t("settings.appearance.accentColor.mode.desc")}
>
<div className="flex items-center gap-2">
<Toggle
checked={accentMode === "custom"}
onChange={(checked) => setAccentMode(checked ? "custom" : "theme")}
/>
</div>
</SettingRow>
{accentMode === "custom" && (
<div className="py-3 space-y-2">
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((c) => (
<Tooltip key={c.name}>
<TooltipTrigger asChild>
<button
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{c.name}</TooltipContent>
</Tooltip>
))}
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
</TooltipTrigger>
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
</Tooltip>
</div>
</div>
)}
</div>
<SectionHeader title={t("settings.appearance.appIcon")} />
<SettingsAnchor anchorId="appearance-app-icon" className="rounded-lg border bg-card px-4 py-3 space-y-4">
<p className="text-xs text-muted-foreground">
{t("settings.appearance.appIcon.desc")}
</p>
<div className="space-y-3">
{APP_ICON_VARIANT_GROUPS.map((group) => (
<div key={group.id} className="space-y-1.5">
<span className="text-[11px] text-muted-foreground">{t(group.labelKey)}</span>
<div className="flex flex-wrap gap-2">
{group.variants.map((variant) => (
<Tooltip key={variant}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setAppIconVariant(variant)}
className={cn(
"relative w-11 h-11 rounded-xl overflow-hidden transition-transform flex items-center justify-center",
resolvedAppIconVariant === variant
? "scale-105"
: "hover:scale-105 opacity-90 hover:opacity-100",
)}
style={{
background: APP_ICON_VARIANT_TILE[variant].background,
border: APP_ICON_VARIANT_TILE[variant].border,
}}
aria-label={t(APP_ICON_VARIANT_I18N_KEY[variant])}
>
<img
src={APP_ICON_ELF_SVG}
alt=""
className="w-8 h-8 object-contain drop-shadow-sm"
draggable={false}
/>
{resolvedAppIconVariant === variant && (
<span className="absolute inset-0 flex items-center justify-center bg-black/20">
<Check className="text-white drop-shadow-md" size={14} />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>{t(APP_ICON_VARIANT_I18N_KEY[variant])}</TooltipContent>
</Tooltip>
))}
</div>
</div>
))}
</div>
</SettingsAnchor>
<SectionHeader title={t("settings.vault.title")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="appearance-vault-show-recent"
label={t('settings.vault.showRecentHosts')}
description={t('settings.vault.showRecentHostsDesc')}
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-select-before-connect"
label={t('settings.vault.selectBeforeConnect')}
description={t('settings.vault.selectBeforeConnectDesc')}
>
<Toggle
checked={hostClickBehavior === 'select'}
onChange={(enabled) => setHostClickBehavior(enabled ? 'select' : 'connect')}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-ungrouped-root"
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
checked={showOnlyUngroupedHostsInRoot}
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-show-sftp-tab"
label={t('settings.vault.showSftpTab')}
description={t('settings.vault.showSftpTabDesc')}
>
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-host-tree"
label={t('settings.vault.showHostTreeSidebar')}
description={t('settings.vault.showHostTreeSidebarDesc')}
>
<Toggle checked={showHostTreeSidebar} onChange={setShowHostTreeSidebar} />
</SettingRow>
<SettingRow
anchorId="appearance-vault-auto-import-known-hosts"
label={t('settings.vault.autoImportSystemKnownHosts')}
description={t('settings.vault.autoImportSystemKnownHostsDesc')}
>
<Toggle
checked={autoImportSystemKnownHosts}
onChange={setAutoImportSystemKnownHosts}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font"
label={t('settings.vault.notesFont')}
description={t('settings.vault.notesFontDesc')}
>
<TerminalFontSelect
value={resolveNoteFontSelectionId(noteFontOptions, noteFontFamily)}
fonts={noteFontOptions}
onChange={(v) => setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))}
className="w-48"
ariaLabel={t('settings.vault.notesFont')}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font-size"
label={t('settings.vault.notesFontSize')}
description={t('settings.vault.notesFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={12}
max={22}
step={1}
value={noteFontSize}
onChange={(e) => handleSetNoteFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteFontSize}px
</span>
</div>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-code-font-size"
label={t('settings.vault.notesCodeFontSize')}
description={t('settings.vault.notesCodeFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={10}
max={22}
step={1}
value={noteCodeFontSize}
onChange={(e) => handleSetNoteCodeFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteCodeFontSize}px
</span>
</div>
</SettingRow>
</div>
<SettingsAnchor anchorId="appearance-custom-css">
<div className="mb-3 flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{t("settings.appearance.customCss")}
</h3>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label={t("settings.appearance.customCss.help.ariaLabel")}
onClick={() => setCustomCssHelpOpen(true)}
>
<HelpCircle size={13} />
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("settings.appearance.customCss.desc")}
</p>
<DebouncedTextarea
value={customCSS}
onCommit={setCustomCSS}
onDraftChange={applyCustomCssToDocument}
placeholder={t("settings.appearance.customCss.placeholder")}
className="w-full h-32 px-3 py-2 text-xs font-mono bg-muted/50 border border-border rounded-lg resize-y focus:outline-none focus:ring-2 focus:ring-primary/50"
spellCheck={false}
/>
</div>
</SettingsAnchor>
<Dialog open={customCssHelpOpen} onOpenChange={setCustomCssHelpOpen}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("settings.appearance.customCss.help.title")}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{customCssHelpOpen ? (
<LazyMessageResponse className={CUSTOM_CSS_HELP_PROSE_CLASS}>
{t("settings.appearance.customCss.help.body")}
</LazyMessageResponse>
) : null}
</div>
</DialogContent>
</Dialog>
</SettingsTabContent>
);
}
export default memo(SettingsAppearanceTab);

View File

@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./SettingsFileAssociationsTab.tsx", import.meta.url), "utf8");
test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /<Toggle\b/);
assert.match(source, /Toggle,\s*\n\s*Select,/);
});

View File

@@ -0,0 +1,377 @@
/**
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
*/
import { FileType, Pencil, Trash2 } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
SectionHeader,
SettingCard,
SettingsTabContent,
SettingRow,
Toggle,
Select,
} from "../settings-ui";
const getOpenerLabel = (
openerType: FileOpenerType,
systemApp: SystemAppInfo | undefined,
t: (key: string) => string
): string => {
if (openerType === 'builtin-editor') {
return t('sftp.opener.builtInEditor');
} else if (openerType === 'system-app' && systemApp) {
return systemApp.name;
}
return openerType;
};
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
const {
sftpDoubleClickBehavior, setSftpDoubleClickBehavior,
sftpAutoSync, setSftpAutoSync,
sftpShowHiddenFiles, setSftpShowHiddenFiles,
sftpUseCompressedUpload, setSftpUseCompressedUpload,
sftpSkipUnchanged, setSftpSkipUnchanged,
sftpAutoOpenSidebar, setSftpAutoOpenSidebar,
sftpFollowTerminalCwd, setSftpFollowTerminalCwd,
sftpDefaultViewMode, setSftpDefaultViewMode,
sftpTransferConcurrency, setSftpTransferConcurrency,
sshTransportIdleTtlMs, setSshTransportIdleTtlMs,
} = useSettingsState();
const associations = getAllAssociations();
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const defaultOpenerValue = useMemo(() => {
if (!defaultOpener) return 'ask';
if (defaultOpener.openerType === 'builtin-editor') return 'builtin-editor';
return 'system-app';
}, [defaultOpener]);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return;
const result = await bridge.selectApplication();
if (result) {
setDefaultOpener('system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingDefaultApp(false);
}
}, [setDefaultOpener]);
const handleDefaultOpenerChange = useCallback((value: string) => {
if (value === 'ask') {
removeDefaultOpener();
return;
}
if (value === 'builtin-editor') {
setDefaultOpener('builtin-editor');
return;
}
void handleSelectDefaultSystemApp();
}, [handleSelectDefaultSystemApp, removeDefaultOpener, setDefaultOpener]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return;
}
const result = await bridge.selectApplication();
if (result) {
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setEditingExtension(null);
}
}, [setOpenerForExtension]);
return (
<SettingsTabContent value="file-associations">
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
<SettingCard>
<SettingRow
anchorId="sftp-double-click"
description={t('settings.sftp.doubleClickBehavior.desc')}
>
<Select
value={sftpDoubleClickBehavior}
options={[
{ value: 'open', label: t('settings.sftp.doubleClickBehavior.open') },
{ value: 'transfer', label: t('settings.sftp.doubleClickBehavior.transfer') },
]}
onChange={(value) => setSftpDoubleClickBehavior(value as 'open' | 'transfer')}
className="w-48"
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
<SettingCard>
<SettingRow
anchorId="sftp-default-view-mode"
description={t('settings.sftp.defaultViewMode.desc')}
>
<Select
value={sftpDefaultViewMode}
options={[
{ value: 'list', label: t('settings.sftp.defaultViewMode.list') },
{ value: 'tree', label: t('settings.sftp.defaultViewMode.tree') },
]}
onChange={(value) => setSftpDefaultViewMode(value as 'list' | 'tree')}
className="w-48"
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<SettingCard>
<SettingRow
anchorId="sftp-show-hidden-files"
label={t('settings.sftp.showHiddenFiles.enable')}
description={t('settings.sftp.showHiddenFiles.enableDesc')}
>
<Toggle checked={sftpShowHiddenFiles} onChange={setSftpShowHiddenFiles} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.autoSync')} />
<SettingCard>
<SettingRow
anchorId="sftp-auto-sync"
label={t('settings.sftp.autoSync.enable')}
description={t('settings.sftp.autoSync.enableDesc')}
>
<Toggle checked={sftpAutoSync} onChange={setSftpAutoSync} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.compressedUpload.enable')}
description={t('settings.sftp.compressedUpload.enableDesc')}
>
<Toggle checked={sftpUseCompressedUpload} onChange={setSftpUseCompressedUpload} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.followTerminalCwd')} />
<SettingCard>
<SettingRow
anchorId="sftp-follow-terminal-cwd"
label={t('settings.sftp.followTerminalCwd.enable')}
description={t('settings.sftp.followTerminalCwd.enableDesc')}
>
<Toggle checked={sftpFollowTerminalCwd} onChange={setSftpFollowTerminalCwd} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<SettingCard>
<SettingRow
anchorId="sftp-auto-open-sidebar"
label={t('settings.sftp.autoOpenSidebar.enable')}
description={t('settings.sftp.autoOpenSidebar.enableDesc')}
>
<Toggle checked={sftpAutoOpenSidebar} onChange={setSftpAutoOpenSidebar} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
<SettingCard>
<SettingRow
anchorId="sftp-transfer-concurrency"
description={t('settings.sftp.transferConcurrency.desc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={16}
step={1}
value={sftpTransferConcurrency}
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
className="w-40 accent-primary"
/>
<span className="text-sm text-muted-foreground w-6 text-center tabular-nums">
{sftpTransferConcurrency}
</span>
</div>
</SettingRow>
<SettingRow
label={t('settings.sftp.skipUnchanged.enable')}
description={t('settings.sftp.skipUnchanged.enableDesc')}
>
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
</SettingRow>
<SettingRow
label={t('settings.ssh.transportIdleTtl')}
description={t('settings.ssh.transportIdleTtl.desc')}
>
<Select
value={String(sshTransportIdleTtlMs)}
onChange={(value) => setSshTransportIdleTtlMs(Number(value))}
options={[
{ value: '60000', label: t('settings.ssh.transportIdleTtl.1m') },
{ value: '300000', label: t('settings.ssh.transportIdleTtl.5m') },
{ value: '900000', label: t('settings.ssh.transportIdleTtl.15m') },
{ value: '1800000', label: t('settings.ssh.transportIdleTtl.30m') },
{ value: '0', label: t('settings.ssh.transportIdleTtl.never') },
]}
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.defaultOpener')} />
<SettingCard>
<SettingRow
anchorId="sftp-default-opener"
description={t('settings.sftp.defaultOpener.desc')}
>
<div className="flex flex-col items-end gap-2">
<Select
value={defaultOpenerValue}
options={[
{ value: 'ask', label: t('settings.sftp.defaultOpener.ask') },
{ value: 'builtin-editor', label: t('sftp.opener.builtInEditor') },
{
value: 'system-app',
label:
defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
? defaultOpener.systemApp.name
: t('settings.sftp.defaultOpener.systemApp'),
},
]}
onChange={handleDefaultOpenerChange}
className="w-56"
disabled={isSelectingDefaultApp}
/>
{defaultOpener?.openerType === 'system-app' && (
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectDefaultSystemApp()}
disabled={isSelectingDefaultApp}
>
{t('settings.sftp.defaultOpener.systemApp')}
</Button>
)}
</div>
</SettingRow>
</SettingCard>
<SectionHeader
title={t('settings.sftpFileAssociations.title')}
anchorId="sftp-file-associations-list"
/>
<p className="text-xs text-muted-foreground -mt-3 mb-1">
{t('settings.sftpFileAssociations.desc')}
</p>
{associations.length === 0 ? (
<SettingCard className="py-12">
<div className="flex flex-col items-center justify-center text-muted-foreground">
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
</div>
</SettingCard>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.extension')}
</th>
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.application')}
</th>
<th className="text-right px-4 py-2 font-medium w-28">
{/* Actions */}
</th>
</tr>
</thead>
<tbody>
{associations.map(({ extension, openerType, systemApp }) => (
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-3">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginSettingField } from './SettingsPluginsTab';
test('plugin setting cards retain localized descriptions alongside structured controls', () => {
const setting = {
id: 'com.example.timeout',
label: 'Connection timeout',
description: 'Maximum time to wait before cancelling the connection.',
control: 'number',
scope: 'application',
scopeId: 'application',
visible: true,
value: 30,
} as NetcattyPluginSettingContribution;
const html = renderToStaticMarkup(
<I18nProvider locale="en">
<PluginSettingField
pluginId="com.example"
setting={setting}
updateSetting={async () => ({ restartRequired: false })}
resetSetting={async () => ({ restartRequired: false })}
selectSettingPath={async () => null}
availableFonts={[]}
/>
</I18nProvider>,
);
assert.match(html, /Maximum time to wait before cancelling the connection\./u);
assert.match(html, /aria-label="Connection timeout"/u);
});

View File

@@ -0,0 +1,374 @@
import { FolderOpen } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
import { requestOpenPluginView } from '../../plugins/PluginContributionHost';
import { parsePluginStructuredSettingValue } from './pluginSettingValues';
import { PluginStructuredSettingEditor } from './PluginStructuredSettingEditor';
import { useAvailableFonts } from '../../../application/state/fontStore';
import { TerminalFontSelect } from '../TerminalFontSelect';
import {
resolvePluginSettingScopeSelection,
usePluginSettingScopeCatalog,
} from '../../../application/state/usePluginSettingScopeCatalog';
import { PluginContributionIcon } from '../../plugins/PluginContributionIcon';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return (
<input
aria-label={setting.label}
type="checkbox"
checked={Boolean(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.checked); void save(event.target.checked); }}
className="h-4 w-4 accent-primary"
/>
);
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
return (
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<label className="text-sm font-medium">{setting.label}</label>
{setting.description && (
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>
)}
<p className="mt-1 font-mono text-[10px] text-muted-foreground">{setting.id} · {setting.scope}</p>
</div>
{setting.restartRequired && <span className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
{control}
<div className="flex items-center gap-2">
{!setting.required && setting.scopeId != null && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => {
setSaving(true);
setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}
>
{t('common.reset')}
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>
</div>
);
}
export default function SettingsPluginsTab() {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds((current) => resolvePluginSettingScopeSelection(scopeCatalog, current));
}, [scopeCatalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
context: { 'netcatty.surface': 'settings' },
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins]);
const hasVisibleContributions = contributions.snapshot.plugins.some((plugin) => (
plugin.settings.some((setting) => setting.visible)
|| plugin.views.some((view) => view.visible && view.location === 'settings')
));
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="mx-auto w-full max-w-3xl space-y-6 px-8 py-8">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
<label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select
value={scopeIds[kind] ?? ''}
onChange={(event) => setScopeIds((current) => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal"
>
{!scopeCatalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{scopeCatalog[kind].map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>
))}
</section>
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
</div>
{settings.map((setting) => (
<PluginSettingField
key={setting.id}
pluginId={plugin.id}
setting={setting}
updateSetting={contributions.updateSetting}
resetSetting={contributions.resetSetting}
selectSettingPath={contributions.selectSettingPath}
availableFonts={availableFonts}
/>
))}
{views.map((view) => (
<div key={view.id} className="flex items-center justify-between rounded-lg border border-border/70 bg-background p-4">
<div className="flex min-w-0 items-center gap-3">
<PluginContributionIcon pluginId={plugin.id} icon={view.icon} size={18} className="shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium">{view.title}</div>
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
})}>{t('common.open')}</Button>
</div>
))}
</section>
);
})}
{!contributions.loading && contributions.available && !hasVisibleContributions && (
<p className="text-sm text-muted-foreground">{t('settings.plugins.empty')}</p>
)}
</SettingsAnchor>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,310 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Ban, RotateCcw } from "lucide-react";
import type { HotkeyScheme, KeyBinding } from "../../../domain/models";
import { keyEventToString } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { SectionHeader, Select, SettingsAnchor, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
export default function SettingsShortcutsTab(props: {
hotkeyScheme: HotkeyScheme;
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
showTabNumberBadges: boolean;
setShowTabNumberBadges: (enabled: boolean) => void;
disableTerminalFontZoom: boolean;
setDisableTerminalFontZoom: (enabled: boolean) => void;
keyBindings: KeyBinding[];
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
resetAllKeyBindings: () => void;
setIsHotkeyRecording?: (isRecording: boolean) => void;
}) {
const {
hotkeyScheme,
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
keyBindings,
updateKeyBinding,
resetKeyBinding,
resetAllKeyBindings,
setIsHotkeyRecording,
} = props;
const { t } = useI18n();
const [recordingBindingId, setRecordingBindingId] = useState<string | null>(null);
const [recordingScheme, setRecordingScheme] = useState<"mac" | "pc" | null>(null);
const cancelRecording = useCallback(() => {
setRecordingBindingId(null);
setRecordingScheme(null);
}, []);
const getSpecialSuffix = useCallback(
(bindingId: string): string | null => {
const binding = keyBindings.find((b) => b.id === bindingId);
if (!binding) return null;
const currentKey = hotkeyScheme === "mac" ? binding.mac : binding.pc;
if (currentKey.includes("[1...9]")) return "[1...9]";
if (currentKey.includes("arrows")) return "arrows";
return null;
},
[keyBindings, hotkeyScheme],
);
useEffect(() => {
if (!recordingBindingId || !recordingScheme) return;
const specialSuffix = getSpecialSuffix(recordingBindingId);
const handleKeyDown = (e: KeyboardEvent) => {
// Skip while app lock overlay is up so password keys never bind (Codex P2).
if (isAppLockOverlayActive()) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
cancelRecording();
return;
}
if (specialSuffix) {
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const parts: string[] = [];
if (recordingScheme === "mac") {
if (e.metaKey) parts.push("⌘");
if (e.ctrlKey) parts.push("⌃");
if (e.altKey) parts.push("⌥");
if (e.shiftKey) parts.push("Shift");
} else {
if (e.ctrlKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
if (e.metaKey) parts.push("Win");
}
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
const fullKeyString = modifierString + specialSuffix;
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
cancelRecording();
return;
}
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const keyString = keyEventToString(e, recordingScheme === "mac");
updateKeyBinding?.(recordingBindingId, recordingScheme, keyString);
cancelRecording();
};
const handleClick = () => {
cancelRecording();
};
const timer = setTimeout(() => {
window.addEventListener("click", handleClick, true);
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("click", handleClick, true);
};
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
useEffect(() => {
const isRecording = Boolean(recordingBindingId && recordingScheme);
setIsHotkeyRecording?.(isRecording);
return () => {
setIsHotkeyRecording?.(false);
};
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
return (
<SettingsTabContent value="shortcuts">
<SectionHeader title={t("settings.shortcuts.section.scheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="shortcuts-scheme"
label={t("settings.shortcuts.scheme.label")}
description={t("settings.shortcuts.scheme.desc")}
>
<Select
value={hotkeyScheme}
options={[
{ value: "disabled", label: t("settings.shortcuts.scheme.disabled") },
{ value: "mac", label: t("settings.shortcuts.scheme.mac") },
{ value: "pc", label: t("settings.shortcuts.scheme.pc") },
]}
onChange={(v) => setHotkeyScheme(v as HotkeyScheme)}
className="w-32"
/>
</SettingRow>
<SettingRow
anchorId="shortcuts-disable-terminal-font-zoom"
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
>
<Toggle
checked={disableTerminalFontZoom}
onChange={setDisableTerminalFontZoom}
/>
</SettingRow>
<SettingRow
anchorId="shortcuts-shell-only-tab-numbers"
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
>
<Toggle
checked={shellOnlyTabNumberShortcuts}
onChange={setShellOnlyTabNumberShortcuts}
/>
</SettingRow>
<SettingRow
anchorId="shortcuts-show-tab-number-badges"
label={t("settings.shortcuts.showTabNumberBadges.label")}
description={t("settings.shortcuts.showTabNumberBadges.desc")}
>
<Toggle
checked={showTabNumberBadges}
onChange={setShowTabNumberBadges}
/>
</SettingRow>
</div>
{hotkeyScheme !== "disabled" && (
<>
<div className="flex items-center justify-between">
<SectionHeader
title={t("settings.shortcuts.section.custom")}
className="mb-0"
anchorId="shortcuts-section-custom"
/>
<Button
variant="ghost"
size="sm"
onClick={resetAllKeyBindings}
className="text-xs gap-1"
>
<RotateCcw size={12} /> {t("settings.shortcuts.resetAll")}
</Button>
</div>
{categories.map((category) => {
const categoryBindings = keyBindings.filter((kb) => kb.category === category);
if (categoryBindings.length === 0) return null;
return (
<div key={category}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
{t(`settings.shortcuts.category.${category}`)}
</h4>
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card">
{categoryBindings.map((binding) => {
const currentKey = hotkeyScheme === "mac" ? binding.mac : binding.pc;
const specialSuffix = currentKey.includes("[1...9]")
? "[1...9]"
: currentKey.includes("arrows")
? "arrows"
: null;
const isSpecialBinding = !!specialSuffix;
const modifierPrefix = isSpecialBinding
? currentKey.replace(specialSuffix!, "").trim().replace(/\+\s*$/, "").trim()
: null;
const isRecordingThis = recordingBindingId === binding.id;
const scheme = hotkeyScheme === "mac" ? "mac" : "pc";
return (
<div key={binding.id} className="flex items-center justify-between px-4 py-2">
<span className="text-sm">{t(`settings.shortcuts.binding.${binding.id}`) !== `settings.shortcuts.binding.${binding.id}` ? t(`settings.shortcuts.binding.${binding.id}`) : binding.label}</span>
<div className="flex items-center gap-2">
{isSpecialBinding ? (
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setRecordingBindingId(binding.id);
setRecordingScheme(scheme);
}}
className={cn(
"px-2 py-1 text-xs font-mono rounded border transition-colors min-w-[60px] text-center",
isRecordingThis
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingThis
? t("settings.shortcuts.recording")
: modifierPrefix || t("settings.shortcuts.none")}
</button>
<span className="text-xs text-muted-foreground">+</span>
<span className="px-2 py-1 text-xs font-mono rounded border border-border bg-muted/30 text-muted-foreground">
{specialSuffix}
</span>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setRecordingBindingId(binding.id);
setRecordingScheme(scheme);
}}
className={cn(
"px-2 py-1 text-xs font-mono rounded border transition-colors min-w-[80px] text-center",
isRecordingThis
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingThis
? t("settings.shortcuts.recording")
: currentKey === "Disabled"
? t("settings.shortcuts.scheme.disabled")
: currentKey || t("settings.shortcuts.scheme.disabled")}
</button>
)}
{!isSpecialBinding && (
<button
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
className="p-1 hover:bg-muted rounded"
aria-label={t("settings.shortcuts.setDisabled")}
>
<Ban size={12} />
</button>
)}
<button
onClick={() => resetKeyBinding?.(binding.id, scheme)}
className="p-1 hover:bg-muted rounded"
aria-label={t("settings.shortcuts.resetToDefault")}
>
<RotateCcw size={12} />
</button>
</div>
</div>
);
})}
</div>
</div>
);
})}
</>
)}
{hotkeyScheme === "disabled" && (
<SettingsAnchor anchorId="shortcuts-section-custom" />
)}
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,123 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import {
buildCloudSyncPayload,
applySyncPayload,
getEffectivePortForwardingRulesForSync,
prepareLocalVaultPayloadApply,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void | Promise<void>;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;
}) {
const {
vault,
portForwardingRules,
importDataFromString,
importPortForwardingRules,
clearVaultData,
onSettingsApplied,
} = props;
const { t } = useI18n();
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
return getEffectivePortForwardingRulesForSync(portForwardingRules) ?? [];
}, [portForwardingRules]);
const onBuildPayload = useCallback((): Promise<SyncPayload> => {
return buildCloudSyncPayload(vault, getEffectivePortForwardingRules());
}, [vault, getEffectivePortForwardingRules]);
const onBuildLocalPayload = useCallback(async (): Promise<SyncPayload> => {
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
const { buildLocalVaultPayloadAsync } = await import('../../../application/syncPayload');
return buildLocalVaultPayloadAsync(
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
getEffectivePortForwardingRules(),
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyMigrationPayload = useCallback(
(payload: SyncPayload) =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}, { currentHosts: vault.hosts }),
[importDataFromString, importPortForwardingRules, onSettingsApplied, vault.hosts],
);
const onApplyPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () => onApplyMigrationPayload(payload),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
);
const onApplyConvergentPayload = useCallback(
(
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: async () => {
await onApplyMigrationPayload(payload);
await commitReplica();
},
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
);
const onApplyLocalPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
prepareApply: () =>
prepareLocalVaultPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {
clearVaultData();
importPortForwardingRules([]);
}, [clearVaultData, importPortForwardingRules]);
return (
<SettingsTabContent value="sync">
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onBuildLocalPayload={onBuildLocalPayload}
onApplyMigrationPayload={onApplyMigrationPayload}
onApplyPayload={onApplyPayload}
onApplyConvergentPayload={onApplyConvergentPayload}
onApplyLocalPayload={onApplyLocalPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const readAppLockSectionSource = () => (
readFileSync(new URL("./AppLockSettingsSection.tsx", import.meta.url), "utf8")
);
test("disabling app lock does not trigger a second renderer-side unlock request", () => {
const source = readAppLockSectionSource();
assert.doesNotMatch(source, /unlockApp\?\.\(/);
assert.doesNotMatch(source, /unlockApp\?:/);
});
test("app lock setup starts with password setup instead of a misleading enable toggle", () => {
const source = readAppLockSectionSource();
assert.match(source, /!hasPassword \? \(/);
assert.match(source, /settings\.appLock\.setupTitle/);
assert.match(source, /settings\.appLock\.setupDescription/);
assert.match(source, /settings\.appLock\.manageTitle/);
assert.doesNotMatch(
source,
/hasAppLockPassword \? t\("settings\.appLock\.enableDesc"\) : t\("settings\.appLock\.enableAfterPassword"\)/,
);
});
test("app lock section exposes settings-search anchors", () => {
const source = readAppLockSectionSource();
assert.match(source, /anchorId="system-app-lock"/);
});
test("app lock disable handler uses its modal current password field", () => {
const source = readAppLockSectionSource();
const handlerStart = source.indexOf("const handleDisable = useCallback");
const handlerEnd = source.indexOf("const handleSavePassword", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
assert.match(handlerSource, /requestAppLockDisable\(disablePassword\)/);
assert.match(handlerSource, /disablePassword,/);
assert.doesNotMatch(source, /handleAppLockEnabledChange/);
});
test("app lock page uses settings rows and moves password forms into a dialog", () => {
const source = readAppLockSectionSource();
const dialogStart = source.indexOf('<Dialog');
const firstPasswordInput = source.indexOf('<Input');
assert.match(source, /<SettingCard divided>/);
assert.match(source, /<SettingRow/);
assert.match(source, /<DialogContent/);
assert.ok(firstPasswordInput > dialogStart, "password inputs should only render inside the dialog");
assert.doesNotMatch(source.slice(0, dialogStart), /<Input/);
assert.doesNotMatch(source, /settings\.appLock\.enableDesc/);
assert.match(source, /settings\.appLock\.disableTitle/);
assert.match(source, /settings\.appLock\.disableDescription/);
assert.match(source, /settings\.appLock\.disable/);
assert.match(source, /settings\.appLock\.changePasswordTitle/);
assert.match(source, /settings\.appLock\.currentPasswordForDisablePlaceholder/);
assert.match(source, /settings\.appLock\.currentPasswordForChangePlaceholder/);
});
test("app lock password setup keeps whitespace as password content", () => {
const source = readAppLockSectionSource();
assert.ok(source.includes("newPassword.length === 0"));
assert.ok(source.includes("confirmPassword.length === 0"));
assert.ok(!source.includes("newPassword.trim()"));
assert.ok(!source.includes("confirmPassword.trim()"));
});
test("app lock system unlock setting uses bridge-provided platform label", () => {
const source = readAppLockSectionSource();
assert.match(source, /showSystemUnlock/);
assert.match(source, /appLockSystemUnlockStatus\.label/);
assert.match(source, /settings\.appLock\.systemUnlock\.label/);
assert.doesNotMatch(source, /navigator\.platform/);
});
test("app lock system unlock enablement does not require current password in settings", () => {
const source = readAppLockSectionSource();
const handlerStart = source.indexOf("const handleSystemUnlockChange = useCallback");
const handlerEnd = source.indexOf("const handleAutoPromptChange", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
assert.match(handlerSource, /setAppLockSystemUnlockEnabled\(\{/);
assert.doesNotMatch(handlerSource, /appLockSystemUnlockPassword/);
assert.doesNotMatch(handlerSource, /currentPassword:/);
const systemUnlockStart = source.indexOf("{showSystemUnlock");
const systemUnlockEnd = source.indexOf("settings.appLock.changePasswordTitle", systemUnlockStart);
const systemUnlockSection = source.slice(systemUnlockStart, systemUnlockEnd);
assert.doesNotMatch(systemUnlockSection, /settings\.appLock\.currentPassword/);
});
test("app lock system unlock setting hides when unavailable unless already enabled", () => {
const source = readAppLockSectionSource();
assert.match(source, /appLockSystemUnlockStatus\.available \|\| appLockSettings\.systemUnlockEnabled/);
// Already-enabled toggle stays clickable when unavailable so the user can disable.
assert.match(
source,
/disabled=\{\s*isSavingSystemUnlock\s*\|\|\s*\(!appLockSystemUnlockStatus\.available && !appLockSettings\.systemUnlockEnabled\)\s*\}/,
);
});
test("app lock system unlock exposes auto prompt as a child option", () => {
const source = readAppLockSectionSource();
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.label/);
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.desc/);
assert.match(source, /appLockSettings\.systemUnlockAutoPromptEnabled/);
assert.match(source, /appLockSettings\.systemUnlockEnabled/);
});
test("app lock system unlock handles native verification failures", () => {
const source = readAppLockSectionSource();
assert.match(source, /'cancelled' \| 'failed'/);
assert.match(source, /result\.error === 'cancelled'/);
assert.match(source, /case 'failed':\s*return t\('settings\.appLock\.systemUnlock\.unavailable'\)/);
});
test("app lock disable explains that turning it off removes the saved password", () => {
const englishLocale = readFileSync(new URL("../../../application/i18n/locales/en/core.ts", import.meta.url), "utf8");
assert.match(englishLocale, /saved password will be removed/);
assert.match(englishLocale, /requires creating a new one/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./SettingsTerminalTab.tsx", import.meta.url), "utf8");
test("terminal settings expose cursor line highlight toggle", () => {
assert.match(source, /settings\.terminal\.cursor\.highlightLine/);
assert.match(source, /checked=\{terminalSettings\.highlightCursorLine\}/);
assert.match(source, /updateTerminalSetting\("highlightCursorLine", v\)/);
});
test("terminal settings hide terminal theme pickers while following app theme", () => {
assert.match(source, /\{!followAppTerminalTheme && \(/);
assert.doesNotMatch(source, /settings\.terminal\.theme\.followingTheme/);
});
test("terminal settings only update the legacy global theme for the active mode", () => {
assert.match(source, /if \(themeModalSlot === resolvedTheme\) \{\s*setTerminalThemeId\(id\);/);
});
test("terminal settings expose host key verification toggle", () => {
assert.match(source, /settings\.terminal\.connection\.verifyHostKeys/);
assert.match(source, /checked=\{terminalSettings\.verifyHostKeys\}/);
assert.match(source, /updateTerminalSetting\("verifyHostKeys", v\)/);
});
test("terminal settings expose SSH auto reconnect toggle", () => {
assert.match(source, /settings\.terminal\.connection\.sshAutoReconnectEnabled/);
assert.match(source, /checked=\{terminalSettings\.sshAutoReconnectEnabled\}/);
assert.match(source, /updateTerminalSetting\("sshAutoReconnectEnabled", v\)/);
});
test("terminal settings expose the host information bar toggle", () => {
assert.match(source, /checked=\{terminalSettings\.showHostInfoBar\}/);
assert.match(source, /updateTerminalSetting\("showHostInfoBar", v\)/);
});
test("terminal settings expose host information title mode when the bar is shown", () => {
assert.match(source, /terminalSettings\.showHostInfoBar && \(/);
assert.match(source, /updateTerminalSetting\("hostInfoBarTitleMode"/);
assert.match(source, /settings\.terminal\.hostInfoBar\.titleMode/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../../application/i18n/I18nProvider.tsx";
import type { KeywordHighlightRule } from "../../../domain/models/terminal.ts";
import {
KeywordHighlightRulesEditor,
toggleKeywordHighlightRuleEnabled,
} from "./SettingsTerminalTabControls.tsx";
const sampleRules: KeywordHighlightRule[] = [
{
id: "error",
label: "Error",
patterns: ["\\berror\\b"],
color: "#F87171",
enabled: true,
},
{
id: "warning",
label: "Warning",
patterns: ["\\bwarn(?:ing)?\\b"],
color: "#FBBF24",
enabled: false,
},
];
test("toggleKeywordHighlightRuleEnabled flips only the selected rule", () => {
const next = toggleKeywordHighlightRuleEnabled(sampleRules, "error");
assert.equal(next[0]?.enabled, false);
assert.equal(next[1]?.enabled, false);
assert.equal(sampleRules[0]?.enabled, true);
});
test("toggleKeywordHighlightRuleEnabled re-enables a disabled rule", () => {
const next = toggleKeywordHighlightRuleEnabled(sampleRules, "warning");
assert.equal(next[0]?.enabled, true);
assert.equal(next[1]?.enabled, true);
});
test("keyword highlight rules editor exposes a per-rule enable switch", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(KeywordHighlightRulesEditor, {
rules: sampleRules,
onChange: () => {},
}),
),
);
assert.match(markup, /role="switch" aria-checked="true" aria-label="Error, Enabled"/);
assert.match(markup, /role="switch" aria-checked="false" aria-label="Warning, Disabled"/);
assert.match(markup, /line-through/);
});

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from "react";
import { ChevronRight, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { cn } from "../../../lib/utils";
import { Toggle } from "../settings-ui";
import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { Textarea } from "../../ui/textarea";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
/** Temporarily disable/enable one rule without deleting it. */
export function toggleKeywordHighlightRuleEnabled(
rules: KeywordHighlightRule[],
ruleId: string,
): KeywordHighlightRule[] {
return rules.map((rule) => (
rule.id === ruleId ? { ...rule, enabled: !rule.enabled } : rule
));
}
export const AddCustomRuleDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
editRule?: KeywordHighlightRule | null;
isBuiltIn?: boolean;
onAdd: (rule: KeywordHighlightRule) => void;
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
const { t } = useI18n();
const [label, setLabel] = useState('');
// Multi-line text: one regex pattern per line. Built-in rules typically
// ship multiple patterns (e.g. several spellings of "error"), and the user
// is allowed to add as many as they like.
const [patternsText, setPatternsText] = useState('');
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
// Populate form when editing
useEffect(() => {
if (open && editRule) {
setLabel(editRule.label);
setPatternsText(editRule.patterns.join('\n'));
setColor(editRule.color);
setPatternError(null);
} else if (!open) {
reset();
}
}, [open, editRule]);
const handleSubmit = () => {
if (!label.trim()) return;
const patterns = patternsText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (patterns.length === 0) return;
for (const p of patterns) {
try { new RegExp(p, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
}
}
onAdd({
id: editRule?.id ?? crypto.randomUUID(),
label: label.trim(),
patterns,
color,
enabled: editRule?.enabled ?? true,
// Editing a built-in rule flips it into "user-customized" mode so the
// normalizer keeps the user's patterns across restarts.
customized: isBuiltIn ? true : editRule?.customized,
});
reset();
onOpenChange(false);
};
const dialogTitleKey = editRule
? (isBuiltIn
? 'settings.terminal.keywordHighlight.editBuiltIn'
: 'settings.terminal.keywordHighlight.editCustom')
: 'settings.terminal.keywordHighlight.addCustom';
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
value={label}
onChange={(e) => setLabel(e.target.value)}
className="flex-1"
/>
<label className="relative flex-shrink-0">
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
</label>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
<Textarea
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
value={patternsText}
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
className={cn("font-mono text-xs", patternError && "border-destructive")}
/>
<p className="text-[11px] text-muted-foreground">
{t('settings.terminal.keywordHighlight.patternHint')}
</p>
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
</div>
{label.trim() && patternsText.trim() && !patternError && (
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
<span className="text-sm font-medium" style={{ color }}>{label}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const KeywordHighlightRulesEditor: React.FC<{
rules: KeywordHighlightRule[];
onChange: (rules: KeywordHighlightRule[]) => void;
}> = ({ rules, onChange }) => {
const { t } = useI18n();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
return (
<div className="space-y-2.5">
{rules.map((rule) => {
const builtIn = isBuiltIn(rule.id);
const customized = builtIn && rule.customized;
return (
<div key={rule.id} className="flex items-center gap-2 group">
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
{rule.label}
</span>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
{!builtIn && (
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
)}
{customized && (
<RotateCcw
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
onClick={() => {
// Drop the user's customizations and restore the shipped
// defaults for label/patterns. Color stays whatever the
// user picked (color is the only built-in property they
// can edit without flipping `customized`).
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
if (!def) return;
onChange(rules.map((r) => r.id === rule.id
? { ...def, color: r.color, enabled: r.enabled, customized: false }
: r));
}}
/>
)}
</div>
<label className="relative flex-shrink-0">
<input
type="color"
value={rule.color}
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
className="sr-only"
/>
<span
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
style={{ backgroundColor: rule.color }}
/>
</label>
<Toggle
checked={rule.enabled}
onChange={() => onChange(toggleKeywordHighlightRuleEnabled(rules, rule.id))}
ariaLabel={`${rule.label}, ${rule.enabled ? t('common.enabled') : t('common.disabled')}`}
/>
</div>
);
})}
<div className="flex pt-2 mt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => setAddDialogOpen(true)}
>
<Plus size={14} className="mr-1.5" />
{t('settings.terminal.keywordHighlight.addCustom')}
</Button>
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => {
// Restore every built-in rule back to shipped defaults
// (label/patterns/color), drop customizations, and keep the user's
// custom rules untouched.
onChange(rules.map((rule) => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
if (!def) return rule;
return { ...def, enabled: rule.enabled, customized: false };
}));
}}
>
<RotateCcw size={14} className="mr-1.5" />
{t("settings.terminal.keywordHighlight.resetDefaults")}
</Button>
</div>
<AddCustomRuleDialog
open={addDialogOpen}
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
editRule={editingRule}
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
onAdd={(rule) => {
if (editingRule) {
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
} else {
onChange([...rules, rule]);
}
setEditingRule(null);
}}
/>
</div>
);
};
// Theme preview button component
export const ThemePreviewButton: React.FC<{
theme: (typeof TERMINAL_THEMES)[0];
onClick?: () => void;
buttonLabel: string;
disabled?: boolean;
}> = ({ theme, onClick, buttonLabel, disabled = false }) => {
const c = theme.colors;
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card transition-all text-left",
disabled ? "cursor-default" : "hover:bg-accent/50",
)}
>
{/* Theme preview swatch */}
<div
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
style={{ backgroundColor: c.background }}
>
<div className="flex gap-1 items-center">
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
</div>
<div className="flex gap-0.5">
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
</div>
<div className="flex gap-1 items-center">
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
</div>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{theme.name}</div>
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
</div>
{/* Action button area */}
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs">{buttonLabel}</span>
{!disabled && <ChevronRight size={16} />}
</div>
</button>
);
};

View File

@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { normalizeTerminalSettings } from "../../../domain/models/terminal.ts";
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings.tsx";
const renderSettings = (
autoCloseOnExit: boolean,
disconnectedNoticeMode: "terminal" | "dialog" = "terminal",
) => renderToStaticMarkup(
React.createElement(TerminalBehaviorSettings, {
t: (key: string) => key,
terminalSettings: normalizeTerminalSettings({ autoCloseOnExit, disconnectedNoticeMode }),
updateTerminalSetting: () => {},
}),
);
test("terminal behavior settings expose enabled auto-close by default", () => {
const markup = renderSettings(true);
assert.match(
markup,
/settings\.terminal\.behavior\.autoCloseOnExit[\s\S]*?role="switch" aria-checked="true"/,
);
});
test("terminal behavior settings expose disabled auto-close", () => {
const markup = renderSettings(false);
assert.match(
markup,
/settings\.terminal\.behavior\.autoCloseOnExit[\s\S]*?role="switch" aria-checked="false"/,
);
});
test("terminal behavior settings expose OSC desktop notification mode", () => {
const markup = renderSettings(true);
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications/);
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications\.always/);
});
test("terminal behavior settings expose disconnected notice mode", () => {
const terminalMarkup = renderSettings(true, "terminal");
const dialogMarkup = renderSettings(true, "dialog");
assert.match(terminalMarkup, /settings-anchor-terminal-disconnected-notice/);
assert.match(terminalMarkup, /settings\.terminal\.behavior\.disconnectedNotice/);
assert.match(terminalMarkup, /settings\.terminal\.behavior\.disconnectedNotice\.terminal/);
assert.match(dialogMarkup, /settings\.terminal\.behavior\.disconnectedNotice\.dialog/);
});

View File

@@ -0,0 +1,357 @@
import React from "react";
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
type Translate = (key: string) => string;
interface TerminalBehaviorSettingsProps {
t: Translate;
terminalSettings: TerminalSettings;
updateTerminalSetting: <K extends keyof TerminalSettings>(key: K, value: TerminalSettings[K]) => void;
}
export const MIDDLE_CLICK_BEHAVIOR_OPTIONS: Array<{
value: MiddleClickBehavior;
labelKey: string;
}> = [
{ value: "context-menu", labelKey: "settings.terminal.behavior.middleClick.menu" },
{ value: "paste", labelKey: "settings.terminal.behavior.middleClick.paste" },
{ value: "disabled", labelKey: "settings.terminal.behavior.middleClick.disabled" },
];
export const DYNAMIC_TAB_TITLE_MODE_OPTIONS: Array<{
value: DynamicTabTitleMode;
labelKey: string;
}> = [
{ value: "off", labelKey: "settings.terminal.behavior.dynamicTabTitle.off" },
{ value: "agent", labelKey: "settings.terminal.behavior.dynamicTabTitle.agent" },
{ value: "all", labelKey: "settings.terminal.behavior.dynamicTabTitle.all" },
];
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
t,
terminalSettings,
updateTerminalSetting,
}) => (
<>
<SectionHeader title={t("settings.terminal.section.behavior")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="terminal-auto-close-on-exit"
label={t("settings.terminal.behavior.autoCloseOnExit")}
description={t("settings.terminal.behavior.autoCloseOnExit.desc")}
>
<Toggle
checked={terminalSettings.autoCloseOnExit}
onChange={(v) => updateTerminalSetting("autoCloseOnExit", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-disconnected-notice"
label={t("settings.terminal.behavior.disconnectedNotice")}
description={t("settings.terminal.behavior.disconnectedNotice.desc")}
>
<Select
value={terminalSettings.disconnectedNoticeMode}
options={[
{ value: "terminal", label: t("settings.terminal.behavior.disconnectedNotice.terminal") },
{ value: "dialog", label: t("settings.terminal.behavior.disconnectedNotice.dialog") },
]}
onChange={(v) => updateTerminalSetting("disconnectedNoticeMode", v as DisconnectedNoticeMode)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="terminal-right-click"
label={t("settings.terminal.behavior.rightClick")}
description={t("settings.terminal.behavior.rightClick.desc")}
>
<Select
value={terminalSettings.rightClickBehavior}
options={[
{ value: "context-menu", label: t("settings.terminal.behavior.rightClick.menu") },
{ value: "paste", label: t("settings.terminal.behavior.rightClick.paste") },
{ value: "select-word", label: t("settings.terminal.behavior.rightClick.selectWord") },
]}
onChange={(v) => updateTerminalSetting("rightClickBehavior", v as RightClickBehavior)}
className="w-36"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.rightClick.fullscreenMenu")}
description={t("settings.terminal.behavior.rightClick.fullscreenMenu.desc")}
>
<Toggle
checked={terminalSettings.showContextMenuOverFullscreenApps}
onChange={(v) => updateTerminalSetting("showContextMenuOverFullscreenApps", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-copy-on-select"
label={t("settings.terminal.behavior.copyOnSelect")}
description={t("settings.terminal.behavior.copyOnSelect.desc")}
>
<Toggle checked={terminalSettings.copyOnSelect} onChange={(v) => updateTerminalSetting("copyOnSelect", v)} />
</SettingRow>
<SettingRow
anchorId="terminal-normalize-text-on-copy"
label={t("settings.terminal.behavior.normalizeTextOnCopy")}
description={t("settings.terminal.behavior.normalizeTextOnCopy.desc")}
>
<Toggle
checked={terminalSettings.normalizeTextOnCopy ?? true}
onChange={(v) => updateTerminalSetting("normalizeTextOnCopy", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-middle-click"
label={t("settings.terminal.behavior.middleClick")}
description={t("settings.terminal.behavior.middleClick.desc")}
>
<Select
value={terminalSettings.middleClickBehavior}
options={MIDDLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("middleClickBehavior", v as MiddleClickBehavior)}
className="w-36"
/>
</SettingRow>
<SettingRow
anchorId="terminal-word-separators"
label={t("settings.terminal.behavior.wordSeparators")}
description={t("settings.terminal.behavior.wordSeparators.desc")}
>
<Input
value={terminalSettings.wordSeparators}
onChange={(e) => updateTerminalSetting("wordSeparators", e.target.value)}
placeholder={`${DEFAULT_TERMINAL_WORD_SEPARATORS}=,:`}
className="w-56 font-mono"
spellCheck={false}
/>
</SettingRow>
<SettingRow
anchorId="terminal-bracketed-paste"
label={t("settings.terminal.behavior.bracketedPaste")}
description={t("settings.terminal.behavior.bracketedPaste.desc")}
>
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
anchorId="terminal-auto-upload-clipboard-image"
label={t("settings.terminal.behavior.autoUploadClipboardImage")}
description={t("settings.terminal.behavior.autoUploadClipboardImage.desc")}
>
<Toggle
checked={terminalSettings.autoUploadClipboardImageOnPaste ?? false}
onChange={(v) => updateTerminalSetting("autoUploadClipboardImageOnPaste", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-shift-enter-newline"
label={t("settings.terminal.behavior.shiftEnterNewline")}
description={t("settings.terminal.behavior.shiftEnterNewline.desc")}
>
<Toggle checked={terminalSettings.shiftEnterNewlineEnabled ?? true} onChange={(v) => updateTerminalSetting("shiftEnterNewlineEnabled", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.shiftEnterNewlineText")}
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
>
<Input
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
placeholder="\\n"
className="w-56 font-mono"
spellCheck={false}
/>
</SettingRow>
<SettingRow
anchorId="terminal-clear-wipes-scrollback"
label={t("settings.terminal.behavior.clearWipesScrollback")}
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
>
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
>
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.forcePromptNewLine")}
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
>
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
anchorId="terminal-dynamic-tab-title"
label={t("settings.terminal.behavior.dynamicTabTitle")}
description={t("settings.terminal.behavior.dynamicTabTitle.desc")}
>
<Select
value={terminalSettings.dynamicTabTitleMode ?? "agent"}
options={DYNAMIC_TAB_TITLE_MODE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("dynamicTabTitleMode", v as DynamicTabTitleMode)}
className="w-44"
/>
</SettingRow>
<SettingRow
anchorId="terminal-osc-notifications"
label={t("settings.terminal.behavior.oscNotifications")}
description={t("settings.terminal.behavior.oscNotifications.desc")}
>
<Select
value={terminalSettings.oscNotifications ?? "always"}
options={[
{ value: "always", label: t("settings.terminal.behavior.oscNotifications.always") },
{ value: "unfocused", label: t("settings.terminal.behavior.oscNotifications.unfocused") },
{ value: "off", label: t("settings.terminal.behavior.oscNotifications.off") },
]}
onChange={(v) => updateTerminalSetting("oscNotifications", v as OscNotificationMode)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="terminal-osc52-clipboard"
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
>
<Select
value={terminalSettings.osc52Clipboard ?? 'write-only'}
options={[
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
]}
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
>
<Toggle checked={terminalSettings.scrollOnInput} onChange={(v) => updateTerminalSetting("scrollOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnOutput")}
description={t("settings.terminal.behavior.scrollOnOutput.desc")}
>
<Toggle checked={terminalSettings.scrollOnOutput} onChange={(v) => updateTerminalSetting("scrollOnOutput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnKeyPress")}
description={t("settings.terminal.behavior.scrollOnKeyPress.desc")}
>
<Toggle checked={terminalSettings.scrollOnKeyPress} onChange={(v) => updateTerminalSetting("scrollOnKeyPress", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnPaste")}
description={t("settings.terminal.behavior.scrollOnPaste.desc")}
>
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}
>
<Select
value={terminalSettings.linkModifier}
options={[
{ value: "none", label: t("settings.terminal.behavior.linkModifier.none") },
{ value: "ctrl", label: t("settings.terminal.behavior.linkModifier.ctrl") },
{ value: "alt", label: t("settings.terminal.behavior.linkModifier.alt") },
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
]}
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
className="w-48"
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.scrollback")} />
<SettingsAnchor anchorId="terminal-scrollback-rows" className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground mb-3">
{t("settings.terminal.scrollback.desc")}
</p>
<div className="space-y-1">
<Label className="text-xs">{t("settings.terminal.scrollback.rows")}</Label>
<Input
type="number"
min={0}
max={100000}
value={terminalSettings.scrollback}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 100000) {
updateTerminalSetting("scrollback", val);
}
}}
className="w-full"
/>
</div>
</SettingsAnchor>
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
<SettingsAnchor anchorId="terminal-startup-command-delay" className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground mb-3">
{t("settings.terminal.startupCommandDelay.desc")}
</p>
<div className="space-y-1">
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
<Input
type="number"
min={0}
max={10000}
value={terminalSettings.startupCommandDelayMs}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 10000) {
updateTerminalSetting("startupCommandDelayMs", val);
}
}}
className="w-full"
/>
</div>
</SettingsAnchor>
</>
);

View File

@@ -0,0 +1,58 @@
import React, { useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const ADD_PROVIDER_MENU_CLASS =
"absolute top-full right-0 mt-1 z-[101] min-w-[220px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover shadow-md py-1";
export const AddProviderDropdown: React.FC<{
onAdd: (providerId: AIProviderId) => void;
}> = ({ onAdd }) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
return (
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="gap-1.5"
>
<Plus size={14} />
{t('ai.providers.add')}
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
</Button>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
{/* Menu */}
<div className={ADD_PROVIDER_MENU_CLASS}>
{providerIds.map((pid) => (
<button
key={pid}
onClick={() => {
onAdd(pid);
setIsOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors text-left"
>
<ProviderIconBadge providerId={pid} size="sm" />
{PROVIDER_PRESETS[pid].name}
</button>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
settingsPath: string;
onSettingsPathChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
configDir,
onConfigDirChange,
settingsPath,
onSettingsPathChange,
envText,
onEnvTextChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(configDir.trim() || settingsPath.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.claude.detecting')
: found
? t('ai.claude.detected')
: t('ai.claude.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.claude.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.claude.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.claude.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.claude.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.claude.resetPath')}
</Button>
</div>
</div>
)}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.claude.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
<input
id="claude-config-dir"
type="text"
value={configDir}
onChange={(e) => onConfigDirChange(e.target.value)}
placeholder={t('ai.claude.configDir.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-settings" className="text-xs text-muted-foreground">{t('ai.claude.settings')}</label>
<input
id="claude-settings"
type="text"
value={settingsPath}
onChange={(e) => onSettingsPathChange(e.target.value)}
placeholder={t('ai.claude.settings.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.settings.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea
id="claude-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.claude.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,310 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.internetEnv.default" },
{ value: "internal", labelKey: "ai.codebuddy.internetEnv.internal" },
{ value: "ioa", labelKey: "ai.codebuddy.internetEnv.ioa" },
] as const;
const EFFORT_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.effort.default" },
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
] as const;
export const CodebuddyCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
internetEnv: string;
onInternetEnvChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
advancedOptions?: CodebuddyAdvancedOptions;
onAdvancedOptionsChange?: (options: CodebuddyAdvancedOptions | undefined) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
internetEnv,
onInternetEnvChange,
envText,
onEnvTextChange,
advancedOptions,
onAdvancedOptionsChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(internetEnv.trim() || envText.trim()),
);
const [advancedOpen, setAdvancedOpen] = useState(
() => Boolean(advancedOptions && Object.keys(advancedOptions).length > 0),
);
const updateAdvanced = (patch: Partial<CodebuddyAdvancedOptions>) => {
if (!onAdvancedOptionsChange) return;
const next = { ...(advancedOptions || {}), ...patch };
// Remove undefined/empty values to keep storage clean.
const cleaned = Object.fromEntries(
Object.entries(next).filter(([, v]) => v != null && v !== "" && v !== 0),
) as CodebuddyAdvancedOptions;
onAdvancedOptionsChange(Object.keys(cleaned).length > 0 ? cleaned : undefined);
};
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.codebuddy.detecting')
: found
? t('ai.codebuddy.detected')
: t('ai.codebuddy.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codebuddy.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.codebuddy.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.codebuddy.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.codebuddy.resetPath')}
</Button>
</div>
</div>
)}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.codebuddy.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="codebuddy-internet-env" className="text-xs text-muted-foreground">{t('ai.codebuddy.internetEnv')}</label>
<select
id="codebuddy-internet-env"
value={internetEnv}
onChange={(e) => onInternetEnvChange(e.target.value)}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{INTERNET_ENV_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.internetEnv.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="codebuddy-env-vars" className="text-xs text-muted-foreground">{t('ai.codebuddy.envVars')}</label>
<textarea
id="codebuddy-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.codebuddy.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.envVars.hint')}</p>
</div>
</div>
)}
</div>
{/* Advanced SDK options (SDK 0.3.230) */}
{onAdvancedOptionsChange && (
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.codebuddy.advancedSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
/>
</button>
{advancedOpen && (
<div className="space-y-3 mt-3">
{/* Effort */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-effort" className="text-xs text-muted-foreground">{t('ai.codebuddy.effort')}</label>
<select
id="codebuddy-effort"
value={advancedOptions?.effort || ""}
onChange={(e) => updateAdvanced({ effort: (e.target.value || undefined) as CodebuddyAdvancedOptions['effort'] })}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{EFFORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.effort.hint')}</p>
</div>
{/* Max Turns */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-max-turns" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxTurns')}</label>
<input
id="codebuddy-max-turns"
type="number"
min={1}
max={200}
value={advancedOptions?.maxTurns ?? ""}
onChange={(e) => updateAdvanced({ maxTurns: e.target.value ? Number(e.target.value) : undefined })}
placeholder="20"
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxTurns.hint')}</p>
</div>
{/* Max Budget USD */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-max-budget" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxBudget')}</label>
<input
id="codebuddy-max-budget"
type="number"
min={0.01}
step={0.01}
value={advancedOptions?.maxBudgetUsd ?? ""}
onChange={(e) => updateAdvanced({ maxBudgetUsd: e.target.value ? Number(e.target.value) : undefined })}
placeholder="0.50"
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxBudget.hint')}</p>
</div>
{/* Sandbox */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.sandbox')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.sandbox.hint')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(advancedOptions?.sandbox?.enabled)}
onClick={() => updateAdvanced({ sandbox: advancedOptions?.sandbox?.enabled ? undefined : { enabled: true } })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
advancedOptions?.sandbox?.enabled ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
advancedOptions?.sandbox?.enabled ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
{/* File Checkpointing */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.fileCheckpointing')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.fileCheckpointing.hint')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(advancedOptions?.enableFileCheckpointing)}
onClick={() => updateAdvanced({ enableFileCheckpointing: advancedOptions?.enableFileCheckpointing ? undefined : true })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
advancedOptions?.enableFileCheckpointing ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
advancedOptions?.enableFileCheckpointing ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import { CodexConnectionCard } from './CodexConnectionCard';
test('CodexConnectionCard surfaces the experimental App Server runtime', () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: 'en' },
React.createElement(CodexConnectionCard, {
pathInfo: { path: '/usr/bin/codex', version: '0.144.3', available: true },
isResolvingPath: false,
customPath: '',
onCustomPathChange: () => {},
onRecheckPath: () => {},
onResetPath: () => {},
integration: null,
loginSession: null,
isLoading: false,
error: null,
onRefresh: () => {},
onConnect: () => {},
onCancel: () => {},
onOpenUrl: () => {},
onLogout: () => {},
appServerRuntime: 'app-server',
appServerStatus: { available: true },
onAppServerRuntimeChange: () => {},
}),
),
);
assert.match(markup, /Use Codex App Server/);
assert.match(markup, /Experimental/);
assert.match(markup, /App Server is available/);
assert.match(markup, /role="switch"/);
assert.match(markup, /aria-checked="true"/);
});

View File

@@ -0,0 +1,249 @@
import React from "react";
import { ExternalLink, LogIn, LogOut, RefreshCw, RotateCcw, X } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Switch } from "../../../ui/switch";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo, CodexAppServerStatus, CodexIntegrationStatus, CodexLoginSession } from "./types";
export const CodexConnectionCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasPendingCustomPath?: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
onCancel: () => void;
onOpenUrl: () => void;
onLogout: () => void;
appServerRuntime: 'sdk' | 'app-server';
appServerStatus: CodexAppServerStatus | null;
onAppServerRuntimeChange: (runtime: 'sdk' | 'app-server') => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
integration,
loginSession,
isLoading,
hasPendingCustomPath = false,
error,
onRefresh,
onConnect,
onCancel,
onOpenUrl,
onLogout,
appServerRuntime,
appServerStatus,
onAppServerRuntimeChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const customConfigIncomplete = Boolean(
integration?.state === "connected_custom_config"
&& integration.customConfig
&& integration.customConfig.envKey
&& !integration.customConfig.envKeyPresent
&& !integration.customConfig.hasHardcodedApiKey,
);
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
? t('ai.codex.notFound')
: loginSession?.state === "running"
? t('ai.codex.awaitingLogin')
: integration?.state === "connected_chatgpt"
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "connected_custom_config"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: !found
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: customConfigIncomplete
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
: loginSession?.output?.trim()
? loginSession.output.trim()
: integration?.rawOutput?.trim()
? integration.rawOutput.trim()
: "";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codex.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{status}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.codex.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.codex.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.codex.resetPath')}
</Button>
</div>
</div>
)}
{found && (
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t('ai.codex.appServer.title')}</span>
<span className="rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-500">
{t('ai.codex.appServer.experimental')}
</span>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t('ai.codex.appServer.description')}
</p>
{appServerStatus?.checking ? (
<p className="text-xs text-muted-foreground">{t('ai.codex.appServer.checking')}</p>
) : appServerStatus?.available ? (
<p className="text-xs text-emerald-500">{t('ai.codex.appServer.available')}</p>
) : appServerStatus?.error ? (
<p className="text-xs text-amber-500">{appServerStatus.error}</p>
) : null}
</div>
<Switch
checked={appServerRuntime === 'app-server'}
disabled={Boolean(appServerStatus?.checking) || (appServerRuntime === 'sdk' && appServerStatus?.available !== true)}
aria-label={t('ai.codex.appServer.title')}
onCheckedChange={(checked) => onAppServerRuntimeChange(checked ? 'app-server' : 'sdk')}
/>
</div>
)}
{/* Connection & login UI -- only when codex is detected */}
{found && (
<>
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
{loginSession?.state === "running" ? (
<>
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
<ExternalLink size={14} className="mr-1.5" />
{t('ai.codex.openLogin')}
</Button>
<Button variant="outline" size="sm" onClick={onCancel}>
<X size={14} className="mr-1.5" />
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout} disabled={hasPendingCustomPath}>
<LogOut size={14} className="mr-1.5" />
{t('ai.codex.logout')}
</Button>
) : (
<Button variant="default" size="sm" onClick={onConnect} disabled={hasPendingCustomPath}>
<LogIn size={14} className="mr-1.5" />
{t('ai.codex.connectChatGPT')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading || hasPendingCustomPath}>
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
{t('ai.codex.refreshStatus')}
</Button>
</div>
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
</>
)}
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
{found && outputText && (
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
{outputText}
</pre>
)}
</div>
);
};

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CopilotCliCard } from "./CopilotCliCard";
function firstButton(markup: string): string {
const match = markup.match(/<button\b[^>]*>/);
return match?.[0] ?? "";
}
test("Cursor check button stays enabled without a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.cursor"
allowEmptyCheck
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), false);
});
test("Copilot check button still requires a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
});
test("Grok card surfaces ACP runtime toggle when detected", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
onGrokRuntimeChange={() => {}}
/>,
);
assert.match(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP/);
assert.match(markup, /role="switch"/);
});
test("Grok card hides ACP toggle without runtime change handler", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
/>,
);
assert.doesNotMatch(markup, /role="switch"/);
assert.doesNotMatch(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP \(agent stdio\)/);
});

View File

@@ -0,0 +1,136 @@
import React from "react";
import { RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Switch } from "../../../ui/switch";
import { cn } from "../../../../lib/utils";
import type { GrokRuntime } from "../../../../infrastructure/ai/types";
import type { AgentPathInfo } from "./types";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath?: () => void;
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
allowEmptyCheck?: boolean;
showCustomPathInput?: boolean;
/** Grok only: ACP (default) vs headless streaming-json. */
grokRuntime?: GrokRuntime;
onGrokRuntimeChange?: (runtime: GrokRuntime) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
i18nPrefix = "ai.copilot",
allowEmptyCheck = false,
showCustomPathInput = true,
grokRuntime = "acp",
onGrokRuntimeChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const showGrokRuntime = i18nPrefix === "ai.grok" && typeof onGrokRuntimeChange === "function";
const statusText = isResolvingPath
? t(`${i18nPrefix}.detecting`)
: found
? t(`${i18nPrefix}.detected`)
: t(`${i18nPrefix}.notFound`);
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t(`${i18nPrefix}.description`)}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t(`${i18nPrefix}.notFoundHint`)}
</p>
)}
<div className={cn("flex items-center gap-2", showCustomPathInput ? "" : "justify-end")}>
{showCustomPathInput && (
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t(`${i18nPrefix}.check`)}
</Button>
{showCustomPathInput && onResetPath && (
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t(`${i18nPrefix}.resetPath`)}
</Button>
)}
</div>
</div>
)}
{showGrokRuntime && found && (
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("ai.grok.runtime.acp.title")}</span>
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{t("ai.grok.runtime.acp.default")}
</span>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.acp.description")}
</p>
{grokRuntime === "streaming-json" && (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.streamingJson.hint")}
</p>
)}
</div>
<Switch
checked={grokRuntime === "acp"}
aria-label={t("ai.grok.runtime.acp.title")}
onCheckedChange={(checked) =>
onGrokRuntimeChange?.(checked ? "acp" : "streaming-json")
}
/>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useState } from "react";
import { Check, Eye, EyeOff, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import type { CursorAuthMode } from "../../../../infrastructure/ai/types";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { isCursorRuntimeInstalled, type AgentPathInfo } from "./types";
export const CursorSdkCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
encryptedApiKey?: string;
authMode: CursorAuthMode;
onAuthModeChange: (mode: CursorAuthMode) => void;
onSaveApiKey: (apiKey: string) => Promise<void>;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
encryptedApiKey,
authMode,
onAuthModeChange,
onSaveApiKey,
onRecheckPath,
}) => {
const { t } = useI18n();
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
let cancelled = false;
setSaved(false);
if (!encryptedApiKey) {
setApiKeyDraft("");
return;
}
setIsDecrypting(true);
decryptField(encryptedApiKey)
.then((value) => {
if (!cancelled) setApiKeyDraft(value ?? "");
})
.catch(() => {
if (!cancelled) setApiKeyDraft("");
})
.finally(() => {
if (!cancelled) setIsDecrypting(false);
});
return () => {
cancelled = true;
};
}, [encryptedApiKey]);
const installed = isCursorRuntimeInstalled(pathInfo);
const hasStoredApiKey = Boolean(encryptedApiKey);
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY" || (
pathInfo?.apiKeyOk && !hasStoredApiKey && pathInfo?.authSource !== "settings"
);
// CLI login is only proven by the dedicated probe — never generic `authenticated`
// (which is also true for env/settings API keys).
const hasCliLogin = Boolean(
pathInfo?.cliLoginOk || pathInfo?.authSource === "cli-login",
);
const hasAnyApiKey = hasStoredApiKey || Boolean(pathInfo?.apiKeyOk) || usesEnvApiKey
|| pathInfo?.authSource === "settings"
|| pathInfo?.authSource === "CURSOR_API_KEY";
const isApiKeyMode = authMode === "api-key";
const isCliMode = authMode === "cli-login";
const available = isCliMode
? hasCliLogin
: (hasAnyApiKey && Boolean(pathInfo?.sdkInstalled ?? true));
const canSave = isApiKeyMode && !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
const installStatus = isResolvingPath
? t("ai.cursor.detecting")
: installed
? t("ai.cursor.installed")
: t("ai.cursor.notInstalled");
const authStatus = isCliMode
? hasCliLogin
? (pathInfo?.cliEmail
? t("ai.cursor.cliLoginAs", { email: pathInfo.cliEmail })
: t("ai.cursor.cliLoginOk"))
: t("ai.cursor.cliLoginMissing")
: hasAnyApiKey
? usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyFromEnv")
: t("ai.cursor.apiKeyConfigured")
: t("ai.cursor.apiKeyMissing");
const installStatusClassName = isResolvingPath
? "text-muted-foreground"
: installed
? "text-emerald-500"
: "text-amber-500";
const authStatusClassName = isCliMode
? (hasCliLogin ? "text-emerald-500" : "text-amber-500")
: (hasAnyApiKey ? "text-emerald-500" : "text-amber-500");
const handleSave = async () => {
if (!isApiKeyMode) return;
setIsSaving(true);
setSaved(false);
try {
await onSaveApiKey(apiKeyDraft.trim());
setSaved(true);
} finally {
setIsSaving(false);
}
};
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex gap-1 rounded-md border border-border/60 p-0.5 bg-muted/30">
<button
type="button"
onClick={() => onAuthModeChange("cli-login")}
className={cn(
"flex-1 h-7 rounded text-xs font-medium transition-colors",
isCliMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{t("ai.cursor.modeCli")}
</button>
<button
type="button"
onClick={() => onAuthModeChange("api-key")}
className={cn(
"flex-1 h-7 rounded text-xs font-medium transition-colors",
isApiKeyMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{t("ai.cursor.modeApiKey")}
</button>
</div>
<p className="text-[11px] text-muted-foreground leading-4">
{isCliMode ? t("ai.cursor.modeCliHint") : t("ai.cursor.modeApiKeyHint")}
</p>
<div className="grid gap-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("ai.cursor.installStatus")}</span>
<span className={cn("font-medium", installStatusClassName)}>{installStatus}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{isCliMode ? t("ai.cursor.cliLoginStatus") : t("ai.cursor.apiKeyStatus")}
</span>
<span className={cn("font-medium truncate max-w-[60%] text-right", authStatusClassName)}>
{authStatus}
</span>
</div>
</div>
{!available && (
<p className="text-xs text-amber-500">
{isCliMode
? t("ai.cursor.cliLoginHint")
: (Boolean(pathInfo?.sdkInstalled) || installed)
? t("ai.cursor.notFoundHint")
: t("ai.cursor.notInstalledHint")}
</p>
)}
{isApiKeyMode ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t("ai.cursor.apiKey")}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyDraft}
onChange={(event) => {
setSaved(false);
setApiKeyDraft(event.target.value);
}}
placeholder={
isDecrypting
? t("ai.providers.apiKey.decrypting")
: usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyPlaceholder.env")
: t("ai.cursor.apiKeyPlaceholder")
}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowApiKey((value) => !value)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showApiKey ? t("ai.cursor.hideApiKey") : t("ai.cursor.showApiKey")}
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
{saved ? <Check size={14} className="mr-1.5" /> : null}
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
</Button>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
<RefreshCw size={14} className="mr-1.5" />
{t("ai.cursor.check")}
</Button>
</div>
{usesEnvApiKey && !hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyEnvHint")}
</p>
) : null}
{usesEnvApiKey && hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyOverrideHint")}
</p>
) : null}
</div>
) : (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
<RefreshCw size={14} className="mr-1.5" />
{t("ai.cursor.check")}
</Button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,928 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
state?: string;
host?: string;
port?: number | null;
discoveryPath?: string | null;
launcherPath?: string | null;
exposedSessionCount?: number;
mode?: ExternalMcpMode;
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
};
type ClientSetupStatus = {
ok: boolean;
state?: string;
launcherPath?: string | null;
command?: string;
existingCommand?: string | null;
error?: string | null;
};
type StatusView = {
labelKey: string;
className: string;
};
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
if (!enabled) {
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
if (!status || !status.ok) {
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
}
if (status.state === "running") {
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
}
if (status.state === "starting") {
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
}
if (status.state === "error") {
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
}
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
/** Map bridge permissionMode to a Safety i18n key for display. */
function getPermissionModeLabelKey(mode: string | null | undefined): string {
switch (mode) {
case "observer":
return "ai.safety.permissionMode.observer";
case "auto":
return "ai.safety.permissionMode.auto";
case "confirm":
return "ai.safety.permissionMode.confirm";
default:
return "ai.externalMcp.permissionMode.unknown";
}
}
function getPermissionModeToneClass(mode: string | null | undefined): string {
switch (mode) {
case "auto":
return "text-emerald-500";
case "observer":
return "text-amber-500";
case "confirm":
return "text-foreground";
default:
return "text-muted-foreground";
}
}
function getCodexStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "codex_not_found":
return { labelKey: "ai.externalMcp.status.codexNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getClaudeStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "claude_not_found":
return { labelKey: "ai.externalMcp.status.claudeNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getGrokStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "grok_not_found":
return { labelKey: "ai.externalMcp.status.grokNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function escapeTomlBasicString(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function quoteShellArg(value: string) {
if (!value) return '""';
if (!/[\s"'\\]/.test(value)) return value;
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
}
export const EXTERNAL_MCP_DISCOVERY_ENV_VAR = "NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE";
export function formatCodexAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` --env ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `codex mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatClaudeAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `claude mcp add -s user netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatGrokAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `grok mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
function buildTomlEnvBlock(discoveryPath?: string | null) {
if (!discoveryPath) return "";
return `\nenv = { ${EXTERNAL_MCP_DISCOVERY_ENV_VAR} = "${escapeTomlBasicString(discoveryPath)}" }`;
}
export function buildCodexTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
export function buildGrokTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
function buildJsonServerEntry(launcherPath: string, discoveryPath?: string | null) {
const entry: {
command: string;
args: string[];
env?: Record<string, string>;
} = {
command: launcherPath,
args: [],
};
if (discoveryPath) {
entry.env = { [EXTERNAL_MCP_DISCOVERY_ENV_VAR]: discoveryPath };
}
return entry;
}
export function buildClaudeSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export function buildCursorSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const { enabled, setEnabled } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
const [grokStatus, setGrokStatus] = useState<ClientSetupStatus | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingCodex, setIsAddingCodex] = useState(false);
const [isAddingClaude, setIsAddingClaude] = useState(false);
const [isAddingGrok, setIsAddingGrok] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
const pushConfig = useCallback((nextMode: ExternalMcpMode, nextIdle: number, nextSessionIdle: number) => {
void getBridge()?.externalMcpSetConfig?.({
mode: nextMode,
idleTimeoutMinutes: nextIdle,
sessionIdleTimeoutMinutes: nextSessionIdle,
});
}, []);
const setMode = useCallback((nextMode: ExternalMcpMode) => {
const normalized = writeExternalMcpMode(nextMode);
setModeRaw(normalized);
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
setIdleTimeoutRaw(normalized);
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeSessionIdleTimeoutMinutes(minutes);
setSessionIdleTimeoutRaw(normalized);
pushConfig(mode, idleTimeoutMinutes, normalized);
}, [idleTimeoutMinutes, mode, pushConfig]);
const setFocusOnHostOpen = useCallback((nextFocusOnHostOpen: boolean) => {
setFocusOnHostOpenRaw(nextFocusOnHostOpen);
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
if (
!bridge?.externalMcpGetStatus
|| !bridge?.externalMcpCodexGetStatus
|| !bridge?.externalMcpClaudeGetStatus
|| !bridge?.externalMcpGrokGetStatus
) {
setStatus({
ok: false,
enabled,
state: "unavailable",
discoveryPath: null,
launcherPath: null,
exposedSessionCount: 0,
// Bridge default when IPC is missing; keeps the permission row from
// looking blank while Safety settings remain the source of truth.
permissionMode: "confirm",
error: bridgeUnavailableMessage,
});
const unavailableClientStatus: ClientSetupStatus = {
ok: true,
state: "error",
launcherPath: null,
command: "",
existingCommand: null,
error: bridgeUnavailableMessage,
};
setCodexStatus(unavailableClientStatus);
setClaudeStatus(unavailableClientStatus);
setGrokStatus(unavailableClientStatus);
return;
}
if (!options?.quiet) setIsRefreshing(true);
try {
if (includeClients) {
const [nextStatus, nextCodexStatus, nextClaudeStatus, nextGrokStatus] = await Promise.all([
bridge.externalMcpGetStatus(),
bridge.externalMcpCodexGetStatus(),
bridge.externalMcpClaudeGetStatus(),
bridge.externalMcpGrokGetStatus(),
]);
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
setCodexStatus(nextCodexStatus as ClientSetupStatus);
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
setGrokStatus(nextGrokStatus as ClientSetupStatus);
} else {
const nextStatus = await bridge.externalMcpGetStatus();
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
} finally {
if (!options?.quiet) setIsRefreshing(false);
}
}, [bridgeUnavailableMessage, enabled, setEnabled]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
useEffect(() => {
if (!enabled) return;
// Quiet polling only refreshes bridge runtime status. Spawning Codex/Claude/Grok
// CLIs every few seconds is too expensive for a settings page keep-alive.
const intervalId = window.setInterval(() => {
void refreshStatus({ quiet: true, clients: false });
}, 3000);
return () => window.clearInterval(intervalId);
}, [enabled, refreshStatus]);
useEffect(() => {
pushConfig(mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- sync stored config once on mount
const bridgeStatusView = useMemo(() => getBridgeStatusView(status, enabled), [enabled, status]);
const exposedSessionCount = enabled ? status?.exposedSessionCount ?? 0 : 0;
const codexStatusView = useMemo(() => getCodexStatusView(codexStatus), [codexStatus]);
const claudeStatusView = useMemo(() => getClaudeStatusView(claudeStatus), [claudeStatus]);
const grokStatusView = useMemo(() => getGrokStatusView(grokStatus), [grokStatus]);
const launcherPath = status?.launcherPath
|| codexStatus?.launcherPath
|| claudeStatus?.launcherPath
|| grokStatus?.launcherPath
|| null;
const discoveryPath = status?.discoveryPath || null;
// Prefer backend status.command so desktop-resolved absolute CLI paths
// (outside PATH) survive into the copyable setup command.
const codexCommand = (codexStatus?.command || "").trim()
|| (launcherPath ? formatCodexAddCommand(launcherPath, discoveryPath) : "");
const claudeCommand = (claudeStatus?.command || "").trim()
|| (launcherPath ? formatClaudeAddCommand(launcherPath, discoveryPath) : "");
const grokCommand = (grokStatus?.command || "").trim()
|| (launcherPath ? formatGrokAddCommand(launcherPath, discoveryPath) : "");
const codexTomlSnippet = launcherPath ? buildCodexTomlSnippet(launcherPath, discoveryPath) : "";
const grokTomlSnippet = launcherPath ? buildGrokTomlSnippet(launcherPath, discoveryPath) : "";
const claudeSnippet = launcherPath ? buildClaudeSnippet(launcherPath, discoveryPath) : "";
const cursorSnippet = launcherPath ? buildCursorSnippet(launcherPath, discoveryPath) : "";
const canAddToCodex = codexStatus?.state === "not_configured";
const canAddToClaude = claudeStatus?.state === "not_configured";
const canAddToGrok = grokStatus?.state === "not_configured";
const copyText = useCallback(async (key: string, text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(key);
window.setTimeout(() => {
setCopied((current) => (current === key ? null : current));
}, 1200);
} catch {
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
}
}, [t]);
const handleAddToCodex = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpCodexAdd) return;
setActionMessage(null);
setIsAddingCodex(true);
try {
const result = await bridge.externalMcpCodexAdd() as ClientSetupStatus;
setCodexStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.codexAdded") });
} else if (result.state === "codex_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installCodex") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingCodex(false);
}
}, [refreshStatus, t]);
const handleAddToClaude = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpClaudeAdd) return;
setActionMessage(null);
setIsAddingClaude(true);
try {
const result = await bridge.externalMcpClaudeAdd() as ClientSetupStatus;
setClaudeStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.claudeAdded") });
} else if (result.state === "claude_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installClaude") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingClaude(false);
}
}, [refreshStatus, t]);
const handleAddToGrok = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpGrokAdd) return;
setActionMessage(null);
setIsAddingGrok(true);
try {
const result = await bridge.externalMcpGrokAdd() as ClientSetupStatus;
setGrokStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.grokAdded") });
} else if (result.state === "grok_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installGrok") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingGrok(false);
}
}, [refreshStatus, t]);
const selectedClientMeta = useMemo(() => {
if (selectedClient === "cursor") {
return {
kind: "snippet" as const,
statusView: null as StatusView | null,
command: "",
snippet: cursorSnippet,
canAdd: false,
isAdding: false,
addLabelKey: "",
onAdd: null as (() => void) | null,
};
}
if (selectedClient === "claude") {
return {
kind: "installable" as const,
statusView: claudeStatusView,
command: claudeCommand,
snippet: claudeSnippet,
canAdd: canAddToClaude,
isAdding: isAddingClaude,
addLabelKey: "ai.externalMcp.addToClaude",
onAdd: () => { void handleAddToClaude(); },
};
}
if (selectedClient === "grok") {
return {
kind: "installable" as const,
statusView: grokStatusView,
command: grokCommand,
snippet: grokTomlSnippet,
canAdd: canAddToGrok,
isAdding: isAddingGrok,
addLabelKey: "ai.externalMcp.addToGrok",
onAdd: () => { void handleAddToGrok(); },
};
}
return {
kind: "installable" as const,
statusView: codexStatusView,
command: codexCommand,
snippet: codexTomlSnippet,
canAdd: canAddToCodex,
isAdding: isAddingCodex,
addLabelKey: "ai.externalMcp.addToCodex",
onAdd: () => { void handleAddToCodex(); },
};
}, [
canAddToClaude,
canAddToCodex,
canAddToGrok,
claudeCommand,
claudeSnippet,
claudeStatusView,
codexCommand,
codexStatusView,
codexTomlSnippet,
cursorSnippet,
grokCommand,
grokStatusView,
grokTomlSnippet,
handleAddToClaude,
handleAddToCodex,
handleAddToGrok,
isAddingClaude,
isAddingCodex,
isAddingGrok,
selectedClient,
]);
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex items-start gap-1.5">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.description")}
</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="relative -top-px mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label={t("ai.externalMcp.help.ariaLabel")}
>
<HelpCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-[320px] space-y-1.5 bg-popover text-popover-foreground border border-border px-3 py-2.5 text-left text-xs leading-relaxed shadow-md"
>
<div className="font-medium text-foreground">{t("ai.externalMcp.usage.title")}</div>
<p>{t("ai.externalMcp.usage.keepRunning")}</p>
<p>{t("ai.externalMcp.usage.localhost")}</p>
<p>{t("ai.externalMcp.usage.permissions")}</p>
<p>{t("ai.externalMcp.usage.capabilities")}</p>
<div className="pt-1 font-medium text-foreground">{t("ai.externalMcp.security")}</div>
<p>{t("ai.externalMcp.security.description")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
{t(bridgeStatusView.labelKey)}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
<div className="text-xs text-muted-foreground">
{t("ai.externalMcp.sessionsExposed", { count: String(exposedSessionCount) })}
</div>
</div>
<Toggle
checked={enabled}
onChange={(nextEnabled) => {
setActionMessage(null);
setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
}}
/>
</div>
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
users see why write tools may still prompt (confirm) or run freely (auto). */}
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
<div className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.permissionMode.hint")}
</div>
</div>
<div
className={cn(
"shrink-0 text-xs font-medium text-right max-w-[12rem]",
getPermissionModeToneClass(status?.permissionMode),
)}
data-testid="external-mcp-permission-mode"
>
{t(getPermissionModeLabelKey(status?.permissionMode))}
</div>
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")}
>
<Select
value={mode}
options={[
{ value: "temporary", label: t("ai.externalMcp.mode.temporary") },
{ value: "persistent", label: t("ai.externalMcp.mode.persistent") },
]}
onChange={(value) => setMode(value === "persistent" ? "persistent" : "temporary")}
className="w-36"
/>
</SettingRow>
{mode === "temporary" ? (
<SettingRow
label={t("ai.externalMcp.idleTimeout")}
description={t("ai.externalMcp.idleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.idleTimeout")}
min={1}
max={24 * 60}
value={idleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
) : null}
<SettingRow
label={t("ai.externalMcp.focusOnHostOpen")}
description={t("ai.externalMcp.focusOnHostOpen.description")}
>
<Toggle
checked={focusOnHostOpen}
onChange={setFocusOnHostOpen}
ariaLabel={t("ai.externalMcp.focusOnHostOpen")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.silentSessions")}
description={t("ai.externalMcp.silentSessions.description")}
>
<Toggle
checked={silentSessions}
onChange={setSilentSessions}
ariaLabel={t("ai.externalMcp.silentSessions")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.sessionIdleTimeout")}
description={t("ai.externalMcp.sessionIdleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.sessionIdleTimeout")}
min={1}
max={24 * 60}
value={sessionIdleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setSessionIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
</SettingCard>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">
{t("ai.externalMcp.clientConfiguration")}
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.clientConfiguration.description")}
</p>
</div>
<div
role="tablist"
aria-label={t("ai.externalMcp.clientConfiguration")}
className="grid grid-cols-4 gap-1 rounded-md bg-muted p-1"
>
{CLIENT_TABS.map((client) => {
const active = selectedClient === client;
return (
<button
key={client}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setSelectedClient(client);
setActionMessage(null);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`ai.externalMcp.client.${client}`)}
</button>
);
})}
</div>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
<div className="flex items-center justify-between gap-3">
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
{t(selectedClientMeta.statusView.labelKey)}
</div>
<Button
size="sm"
disabled={
!selectedClientMeta.canAdd
|| selectedClientMeta.isAdding
|| !enabled
|| !launcherPath
}
onClick={() => selectedClientMeta.onAdd?.()}
>
{t(selectedClientMeta.addLabelKey)}
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.cursor.description")}
</p>
)}
{selectedClientMeta.kind === "installable" ? (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.cliCommand")}
value={selectedClientMeta.command}
copyKey="command"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="snippet"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</>
) : (
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="cursor"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
)}
</div>
</div>
{actionMessage ? (
<div
className={cn(
"text-xs",
actionMessage.tone === "success" && "text-emerald-500",
actionMessage.tone === "warning" && "text-amber-500",
actionMessage.tone === "error" && "text-destructive",
)}
>
{actionMessage.text}
</div>
) : null}
{status?.error ? (
<div className="text-xs text-destructive">{status.error}</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,310 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
import { parseFetchedModels } from "./modelMetadata";
export function buildModelSuggestions({
presetModels,
fetchedModels,
hasFetched,
value,
}: {
presetModels?: readonly string[];
fetchedModels: FetchedModel[];
hasFetched: boolean;
value: string;
}): FetchedModel[] {
const byId = new Map<string, FetchedModel>();
for (const modelId of presetModels ?? []) {
const id = modelId.trim();
if (id) byId.set(id, { id });
}
if (hasFetched) {
for (const model of fetchedModels) {
byId.set(model.id, model);
}
}
const allSuggestions = Array.from(byId.values());
if (!value.trim()) return allSuggestions;
const q = value.toLowerCase();
return allSuggestions.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}
export function getModelSuggestionsPresentation({
suggestionsLength,
isLoading,
error,
hasFetched,
hasPresetModels,
}: {
suggestionsLength: number;
isLoading: boolean;
error: string | null;
hasFetched: boolean;
hasPresetModels: boolean;
}): {
showSuggestions: boolean;
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
footerState: "loading" | "error" | null;
} {
if (suggestionsLength > 0) {
return {
showSuggestions: true,
emptyState: null,
footerState: isLoading ? "loading" : error ? "error" : null,
};
}
if (isLoading) {
return { showSuggestions: false, emptyState: "loading", footerState: null };
}
if (error) {
return { showSuggestions: false, emptyState: "error", footerState: null };
}
return {
showSuggestions: false,
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
footerState: null,
};
}
export function getModelSuggestionClassName(isSelected: boolean): string {
return cn(
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between gap-2",
isSelected && "bg-accent text-accent-foreground",
);
}
export const ModelSelector: React.FC<{
value: string;
onChange: (value: string) => void;
baseURL: string;
modelsEndpoint?: string;
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Resolve the wire-protocol family: prefer an explicit style override (set in
// the form), then fall back to the providerId-derived default.
const resolvedStyle: ProviderStyle = style
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
// Anthropic providerId switched to OpenAI style) still hits the right path.
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
// Ollama runs locally without auth; all other providers need an API key to list models
const needsApiKey = providerId !== "ollama";
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
const hasPresetModels = (presetModels?.length ?? 0) > 0;
const canSuggest = canFetch || hasPresetModels;
const discoveryKey = JSON.stringify({
baseURL,
effectiveModelsEndpoint,
apiKey,
resolvedStyle,
skipTLSVerify,
});
const discoveryKeyRef = useRef(discoveryKey);
useEffect(() => {
discoveryKeyRef.current = discoveryKey;
setModels([]);
setHasFetched(false);
setError(null);
setIsLoading(false);
}, [discoveryKey]);
const fetchModels = useCallback(async () => {
if (!effectiveModelsEndpoint) return;
const bridge = getFetchBridge();
if (!bridge?.aiFetch) return;
const requestKey = discoveryKey;
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
}
const parsed = JSON.parse(result.data);
const list = parseFetchedModels(parsed);
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
if (discoveryKeyRef.current !== requestKey) return;
setModels(list);
setHasFetched(true);
} catch (err) {
if (discoveryKeyRef.current !== requestKey) return;
setError(err instanceof Error ? err.message : "Failed to parse response");
} finally {
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
return buildModelSuggestions({
presetModels,
fetchedModels: models,
hasFetched,
value,
});
}, [models, presetModels, value, hasFetched]);
const showSuggestions = isOpen && canSuggest;
const presentation = getModelSuggestionsPresentation({
suggestionsLength: suggestions.length,
isLoading,
error,
hasFetched,
hasPresetModels,
});
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
if (canSuggest && !isOpen) setIsOpen(true);
}}
onFocus={() => { if (canSuggest) setIsOpen(true); }}
onBlur={() => { setIsOpen(false); }}
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
canSuggest && "pr-8",
)}
/>
{canSuggest && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
</button>
)}
</div>
{canFetch && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
</Tooltip>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
<div className="max-h-60 overflow-y-auto">
{!presentation.showSuggestions ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
{presentation.emptyState === "loading" ? (
<>
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</>
) : presentation.emptyState === "error" ? (
<span className="text-destructive">{error}</span>
) : presentation.emptyState === "noMatches" ? (
t('ai.providers.noMatchingModels')
) : (
t('ai.providers.clickToLoadModels')
)}
</div>
) : (
suggestions.slice(0, 100).map((m) => (
<button
key={m.id}
onMouseDown={(e) => {
e.preventDefault();
onChange(m.id);
onModelMetadata?.(m);
setIsOpen(false);
}}
className={getModelSuggestionClassName(m.id === value)}
>
<span className="font-mono truncate">{m.id}</span>
{m.id === value && <Check size={12} className="text-accent-foreground shrink-0" />}
</button>
))
)}
{presentation.footerState && (
<div className={cn(
"px-3 py-2 text-center text-[10px] border-t border-border/40",
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
)}>
{presentation.footerState === "loading" ? (
<>
<RefreshCw size={12} className="animate-spin inline mr-1" />
{t('ai.providers.loadingModels')}
</>
) : (
error
)}
</div>
)}
{suggestions.length > 100 && (
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,255 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Download, Plus, Trash2, Upload } from 'lucide-react';
import { useI18n } from '../../../../application/i18n/I18nProvider';
import { Button } from '../../../ui/button';
import { SettingCard, SettingsSection } from '../../settings-ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/tooltip';
import type { PermissionGrantRule } from '../../../../infrastructure/ai/harness/permissionGrants';
import {
capabilitySupportsCommandPatternGrant,
createPermissionGrantId,
listGrantableCapabilityIds,
} from '../../../../infrastructure/ai/harness/permissionGrants';
const cellInputClass =
'w-full min-w-0 max-w-full h-7 rounded border border-input bg-background px-2 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring overflow-x-auto whitespace-nowrap scrollbar-thin';
const cellSelectClass =
`${cellInputClass} font-sans truncate pr-6`;
const GrantCellInput: React.FC<{
value: string;
placeholder?: string;
mono?: boolean;
onChange: (value: string) => void;
}> = ({ value, placeholder, mono = true, onChange }) => (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={mono ? cellInputClass : `${cellInputClass} font-sans whitespace-normal`}
title={value}
/>
);
const GrantCapabilitySelect: React.FC<{
value: string;
options: readonly string[];
onChange: (value: string) => void;
}> = ({ value, options, onChange }) => {
const selectOptions = useMemo(() => {
if (options.includes(value)) return options;
return [value, ...options];
}, [options, value]);
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cellSelectClass}
title={value}
>
{selectOptions.map((capabilityId) => (
<option key={capabilityId} value={capabilityId}>
{capabilityId}
</option>
))}
</select>
);
};
export const PermissionGrantsSettings: React.FC<{
grants: PermissionGrantRule[];
addGrant: (rule: PermissionGrantRule) => void;
updateGrant: (id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>) => void;
removeGrant: (id: string) => void;
importGrants: (raw: unknown, mode?: 'merge' | 'replace') => void;
exportGrants: () => PermissionGrantRule[];
}> = ({
grants,
addGrant,
updateGrant,
removeGrant,
importGrants,
exportGrants,
}) => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const grantableCapabilityIds = useMemo(() => listGrantableCapabilityIds(), []);
const handleAdd = useCallback(() => {
addGrant({
id: createPermissionGrantId(),
capabilityId: grantableCapabilityIds[0] ?? 'terminal.execute',
sessionPattern: '*',
createdAt: Date.now(),
});
}, [addGrant, grantableCapabilityIds]);
const handleExport = useCallback(() => {
const payload = exportGrants();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'netcatty-permission-grants.json';
anchor.click();
URL.revokeObjectURL(url);
}, [exportGrants]);
const handleImportFile = useCallback(async (file: File) => {
setImportError(null);
try {
const text = await file.text();
const parsed = JSON.parse(text) as unknown;
importGrants(parsed, 'replace');
} catch (error) {
setImportError(error instanceof Error ? error.message : String(error));
}
}, [importGrants]);
return (
<SettingsSection title={t('ai.safety.grants.title')} anchorId="ai-safety-grants">
<SettingCard padded className="space-y-3 min-w-0 max-w-full overflow-hidden">
<div className="space-y-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t('ai.safety.grants.heading')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('ai.safety.grants.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAdd}>
<Plus size={14} className="mr-1" />
{t('ai.safety.grants.add')}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleExport}>
<Download size={14} className="mr-1" />
{t('ai.safety.grants.export')}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => fileInputRef.current?.click()}
>
<Upload size={14} className="mr-1" />
{t('ai.safety.grants.import')}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportFile(file);
}}
/>
</div>
</div>
{importError && (
<p className="text-[11px] text-destructive">{importError}</p>
)}
{grants.length === 0 ? (
<p className="text-xs text-muted-foreground py-6 text-center border border-dashed border-border/50 rounded-lg">
{t('ai.safety.grants.empty')}
</p>
) : (
<div className="w-full max-w-full min-w-0 overflow-x-auto overscroll-x-contain rounded-lg border border-border/40 bg-card">
<table className="w-full max-w-full table-fixed text-sm border-collapse">
<colgroup>
<col className="w-[28%]" />
<col className="w-[42%]" />
<col className="w-[24%]" />
<col className="w-[6%]" />
</colgroup>
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.capability')}
</th>
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.commandPattern')}
</th>
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.note')}
</th>
<th className="px-1 py-2" aria-hidden />
</tr>
</thead>
<tbody>
{grants.map((grant) => {
const supportsCommandPattern = capabilitySupportsCommandPatternGrant(grant.capabilityId);
return (
<tr
key={grant.id}
className="border-b border-border/60 last:border-b-0 hover:bg-muted/20"
>
<td className="px-2 py-2 align-middle max-w-0">
<GrantCapabilitySelect
value={grant.capabilityId}
options={grantableCapabilityIds}
onChange={(capabilityId) => {
const updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>> = {
capabilityId,
};
if (!capabilitySupportsCommandPatternGrant(capabilityId)) {
updates.commandPattern = undefined;
}
updateGrant(grant.id, updates);
}}
/>
</td>
<td className="px-2 py-2 align-middle max-w-0">
{supportsCommandPattern ? (
<GrantCellInput
value={grant.commandPattern ?? ''}
placeholder="lscpu *"
onChange={(commandPattern) => updateGrant(grant.id, {
commandPattern: commandPattern.trim() || undefined,
})}
/>
) : (
<span className="text-xs text-muted-foreground px-1"></span>
)}
</td>
<td className="px-2 py-2 align-middle max-w-0">
<GrantCellInput
value={grant.note ?? ''}
mono={false}
onChange={(note) => updateGrant(grant.id, {
note: note.trim() || undefined,
})}
/>
</td>
<td className="px-1 py-2 align-middle text-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => removeGrant(grant.id)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.grants.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,104 @@
import React from "react";
import { Pencil, Trash2 } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Toggle } from "../../settings-ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { ProviderConfigForm } from "./ProviderConfigForm";
export const ProviderCard: React.FC<{
provider: ProviderConfig;
isActive: boolean;
onToggleEnabled: (enabled: boolean) => void;
onEdit: () => void;
onRemove: () => void;
onUpdate: (updates: Partial<ProviderConfig>) => void;
isEditing: boolean;
onCancelEdit: () => void;
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
const { t } = useI18n();
const hasApiKey = !!provider.apiKey;
return (
<div
className={cn(
"rounded-lg border p-4 transition-colors",
isActive ? "border-primary/50 bg-primary/5" : "border-border bg-card",
)}
>
<div className="flex items-center gap-3">
{/* Provider icon */}
<ProviderIconBadge provider={provider} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{provider.name}</span>
{isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
{t('ai.providers.active')}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className={cn(
"text-xs",
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
)}
>
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
</span>
{provider.defaultModel && (
<>
<span className="text-muted-foreground text-xs">|</span>
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onEdit}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Pencil size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
</Tooltip>
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
</div>
</div>
{/* Expandable config form */}
{isEditing && (
<ProviderConfigForm
provider={provider}
onSave={(updates) => {
onUpdate(updates);
onCancelEdit();
}}
onCancel={onCancelEdit}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,749 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import {
probeProviderConnection,
validateProviderProbeInputs,
type ProviderProbeHealth,
} from "../../../../infrastructure/ai/providerConnectionProbe";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { BuiltinProviderIcon } from "./types";
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
import type { ProviderFormState } from "./types";
import { ModelSelector } from "./ModelSelector";
import { mergeModelContextWindow } from "./modelMetadata";
import { ProviderIconBadge } from "./ProviderIconBadge";
const ICON_PIXEL_SIZE = 64;
const ICON_WEBP_QUALITY = 0.85;
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
async function compressIconFileToDataUrl(file: File): Promise<string> {
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error("Image too large; please use an image under 5 MB.");
}
const sourceUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("Failed to decode image"));
el.src = sourceUrl;
});
const canvas = document.createElement("canvas");
canvas.width = ICON_PIXEL_SIZE;
canvas.height = ICON_PIXEL_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE);
const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h);
return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY);
}
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
export const ProviderConfigForm: React.FC<{
provider: ProviderConfig;
onSave: (updates: Partial<ProviderConfig>) => void;
onCancel: () => void;
}> = ({ provider, onSave, onCancel }) => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [form, setForm] = useState<ProviderFormState>({
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "",
modelContextWindows: provider.modelContextWindows ?? {},
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
style: provider.style ?? "",
openaiApi: resolveOpenAIApi(provider),
iconId: provider.iconId ?? "",
iconDataUrl: provider.iconDataUrl ?? "",
});
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
const [iconError, setIconError] = useState<string | null>(null);
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
const [isTesting, setIsTesting] = useState(false);
const [probeResult, setProbeResult] = useState<{
health: ProviderProbeHealth;
message: string;
} | null>(null);
const probeRequestIdRef = useRef(0);
const preset = PROVIDER_PRESETS[provider.providerId];
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
const resolvedBaseURL = provider.providerId === "ollama"
? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "")
: (form.baseURL || preset?.defaultBaseURL || "");
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
providerId: provider.providerId,
baseURL: form.baseURL || preset?.defaultBaseURL || "",
modelsEndpoint: preset?.modelsEndpoint ?? "",
apiKeySourceVersion,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
}), [
provider.providerId,
form.baseURL,
apiKeySourceVersion,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const probeFingerprint = useMemo(() => JSON.stringify({
baseURL: form.baseURL || preset?.defaultBaseURL || "",
apiKey: form.apiKey,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
modelsEndpoint: preset?.modelsEndpoint ?? "",
}), [
form.apiKey,
form.baseURL,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const modelMetadataSourceKeyRef = useRef<string | null>(null);
const probeFingerprintRef = useRef<string | null>(null);
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
providerId: provider.providerId,
name: form.name,
iconId: form.iconId || undefined,
iconDataUrl: form.iconDataUrl || undefined,
};
// Decrypt and load existing API key on mount
useEffect(() => {
if (provider.apiKey) {
setIsDecrypting(true);
decryptField(provider.apiKey)
.then((decrypted) => {
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
})
.catch(() => {
// If decryption fails, show raw value
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
})
.finally(() => setIsDecrypting(false));
}
}, [provider.apiKey]);
useEffect(() => {
if (modelMetadataSourceKeyRef.current == null) {
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
return;
}
if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return;
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
setForm((prev) => Object.keys(prev.modelContextWindows).length > 0
? { ...prev, modelContextWindows: {} }
: prev);
}, [modelMetadataSourceKey]);
useEffect(() => {
if (probeFingerprintRef.current == null) {
probeFingerprintRef.current = probeFingerprint;
return;
}
if (probeFingerprintRef.current === probeFingerprint) return;
probeFingerprintRef.current = probeFingerprint;
probeRequestIdRef.current += 1;
setProbeResult(null);
setIsTesting(false);
}, [probeFingerprint]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw.trim() === "" || raw.trim() === "-") {
delete next[key];
} else {
const num = Number(raw);
if (!Number.isNaN(num)) {
next[key] = num;
}
}
return { ...prev, advancedParams: next };
});
}, []);
const handleIconFileSelect = useCallback(async (file: File | null) => {
setIconError(null);
if (!file) return;
if (!/^image\//.test(file.type)) {
setIconError(t("ai.providers.icon.errorType"));
return;
}
try {
const dataUrl = await compressIconFileToDataUrl(file);
setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" }));
} catch (err) {
setIconError(err instanceof Error ? err.message : String(err));
}
}, [t]);
const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => {
setIconError(null);
setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name }));
}, []);
const handleResetIcon = useCallback(() => {
setIconError(null);
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
}, []);
const handleApiKeyChange = useCallback((value: string) => {
setApiKeySourceVersion((version) => version + 1);
setForm((prev) => ({ ...prev, apiKey: value }));
}, []);
const handleTestConnection = useCallback(async () => {
const baseURL = resolvedBaseURL;
const inputCheck = validateProviderProbeInputs({
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
});
if (!inputCheck.ok) {
probeRequestIdRef.current += 1;
setIsTesting(false);
setProbeResult({
health: "error",
message: t(
inputCheck.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: "ai.providers.test.missingApiKey",
),
});
return;
}
const requestId = ++probeRequestIdRef.current;
setIsTesting(true);
setProbeResult(null);
try {
const run = await probeProviderConnection({
bridge: getFetchBridge(),
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
style: resolvedStyle,
presetModelsEndpoint: preset?.modelsEndpoint,
skipTLSVerify: form.skipTLSVerify,
});
if (probeRequestIdRef.current !== requestId) return;
if (!run.ok) {
setProbeResult({
health: "error",
message: t(
run.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: run.reason === "missing_api_key"
? "ai.providers.test.missingApiKey"
: "ai.providers.test.unavailable",
),
});
return;
}
const classified = run.classification;
const latency = String(classified.latencyMs);
if (classified.health === "ok") {
setProbeResult({
health: "ok",
message: t("ai.providers.test.ok", { latency }),
});
} else if (classified.health === "warn") {
const warnKey = classified.modelCount === 0 || classified.error
? "ai.providers.test.warn"
: "ai.providers.test.warnSlow";
setProbeResult({
health: "warn",
message: t(warnKey, { latency }),
});
} else {
const detail = classified.error
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
setProbeResult({
health: "error",
message: t("ai.providers.test.error", { detail }),
});
}
} catch (err) {
if (probeRequestIdRef.current !== requestId) return;
setProbeResult({
health: "error",
message: t("ai.providers.test.error", {
detail: err instanceof Error ? err.message : String(err),
}),
});
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
const trimmedName = form.name.trim();
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
const rawContextWindow = form.contextWindow.trim();
const rawContextWindowNumber = Number(rawContextWindow);
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined;
if (rawContextWindow && manualContextWindow == null) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
setContextWindowError(null);
const updates: Partial<ProviderConfig> = {
name: trimmedName || defaultName,
baseURL: provider.providerId === "ollama"
? resolvedBaseURL
: (form.baseURL || undefined),
defaultModel: form.defaultModel || undefined,
contextWindow: manualContextWindow,
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
style: form.style || undefined,
openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined,
iconId: form.iconId || undefined,
iconDataUrl: form.iconDataUrl || undefined,
};
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
onSave(updates);
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
{/* Display: icon + name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowIconPicker((v) => !v)}
className="group relative shrink-0 rounded-md transition-all hover:brightness-110 hover:ring-2 hover:ring-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-label={t('ai.providers.icon.change')}
title={t('ai.providers.icon.change')}
>
<ProviderIconBadge provider={previewProvider} />
<span
aria-hidden="true"
className="pointer-events-none absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
<Pencil size={9} strokeWidth={2.5} />
</span>
</button>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('ai.providers.name.placeholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{showIconPicker && (
<div className="rounded-md border border-border/50 bg-muted/20 p-2 space-y-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1.5">
{BUILTIN_PROVIDER_ICONS.map((icon) => {
const isSelected = form.iconId === icon.id && !form.iconDataUrl;
return (
<button
key={icon.id}
type="button"
onClick={() => (isSelected ? handleResetIcon() : handlePickBuiltin(icon))}
title={icon.label}
aria-label={icon.label}
aria-pressed={isSelected}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md border text-left transition-colors min-w-0",
isSelected
? "border-primary/70 bg-primary/15"
: "border-transparent hover:border-border hover:bg-muted/40",
)}
>
<ProviderIconBadge
provider={{ providerId: provider.providerId, name: icon.label, iconId: icon.id }}
size="md"
/>
<span className="text-xs text-foreground/85 truncate">{icon.label}</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => void handleIconFileSelect(e.target.files?.[0] ?? null)}
/>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={12} className="mr-1.5" />
{t('ai.providers.icon.upload')}
</Button>
<Button variant="ghost" size="sm" onClick={handleResetIcon}>
<RotateCcw size={12} className="mr-1.5" />
{t('ai.providers.icon.reset')}
</Button>
{form.iconDataUrl && (
<span className="text-[10px] text-muted-foreground">{t('ai.providers.icon.uploadedNote')}</span>
)}
<div className="ml-auto" />
<Button
variant="ghost"
size="sm"
onClick={() => setShowIconPicker(false)}
aria-label={t('ai.providers.icon.close')}
title={t('ai.providers.icon.close')}
>
<X size={12} className="mr-1.5" />
{t('ai.providers.icon.close')}
</Button>
</div>
{iconError && <p className="text-[11px] text-destructive">{iconError}</p>}
</div>
)}
</div>
{/* Provider style */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.style')}</label>
<div className="flex items-center gap-1.5">
{STYLE_OPTIONS.map((style) => {
const isSelected = resolvedStyle === style;
const isInherited = !form.style && isSelected;
return (
<button
key={style}
type="button"
onClick={() => setForm((prev) => ({ ...prev, style: prev.style === style ? "" : style }))}
className={cn(
"h-7 px-2.5 rounded-md text-xs border transition-colors",
isSelected
? "border-primary/70 bg-primary/15 text-foreground"
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
aria-pressed={isSelected}
>
{t(`ai.providers.style.${style}`)}
{isInherited && (
<span className="ml-1 text-[9px] text-muted-foreground/70">({t('ai.providers.style.inherited')})</span>
)}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
</div>
{resolvedStyle === "openai" && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.openaiApi')}</label>
<div className="flex items-center gap-1.5">
{OPENAI_API_OPTIONS.map((format) => {
const isSelected = form.openaiApi === format;
return (
<button
key={format}
type="button"
onClick={() => setForm((prev) => ({ ...prev, openaiApi: format }))}
className={cn(
"h-7 px-2.5 rounded-md text-xs border transition-colors",
isSelected
? "border-primary/70 bg-primary/15 text-foreground"
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
aria-pressed={isSelected}
>
{t(`ai.providers.openaiApi.${format}`)}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.openaiApi.help')}</p>
</div>
)}
{/* API Key */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : form.apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
<input
type="text"
value={form.baseURL}
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
placeholder={preset?.defaultBaseURL || "https://"}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{resolvedStyle === "anthropic" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
) : null}
{provider.providerId === "ollama" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.ollamaHelp')}</p>
) : null}
</div>
{/* Default Model */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
<ModelSelector
value={form.defaultModel}
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
onModelMetadata={(model) => {
setForm((prev) => ({
...prev,
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
}));
}}
baseURL={resolvedBaseURL}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
providerId={provider.providerId}
style={resolvedStyle}
skipTLSVerify={form.skipTLSVerify}
/>
</div>
{/* Context window */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.contextWindow')}</label>
<input
type="number"
min={1}
step={1}
value={form.contextWindow}
onChange={(e) => {
setContextWindowError(null);
setForm((prev) => ({ ...prev, contextWindow: e.target.value }));
}}
placeholder={
form.defaultModel && form.modelContextWindows[form.defaultModel]
? String(form.modelContextWindows[form.defaultModel])
: t('ai.providers.contextWindow.placeholder')
}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
contextWindowError && "border-destructive focus-visible:ring-destructive",
)}
/>
{contextWindowError && <p className="text-[11px] text-destructive">{contextWindowError}</p>}
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.contextWindow.help')}</p>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.skipTLSVerify}
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Advanced Parameters */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('ai.providers.advancedParams')}
</button>
{showAdvanced && (
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
{/* max_tokens */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">max_tokens</label>
<input
type="number"
min={1}
step={1}
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* temperature */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(02)</span></label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* top_p */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(01)</span></label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* frequency_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* presence_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
onClick={() => void handleSave()}
>
<Check size={14} className="size-3.5 shrink-0" />
{t('common.save')}
</Button>
<Button
variant="outline"
size="sm"
className={PROVIDER_ACTION_CLASS}
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
>
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
</Button>
<Button variant="ghost" size="sm" className={PROVIDER_ACTION_CLASS} onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
{(isTesting || probeResult) && (
<p
className={cn(
"text-[11px]",
isTesting && "text-muted-foreground",
probeResult?.health === "ok" && "text-emerald-500",
probeResult?.health === "warn" && "text-amber-500",
probeResult?.health === "error" && "text-destructive",
)}
role="status"
aria-live="polite"
>
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { cn } from "../../../../lib/utils";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import type { SettingsIconId } from "./types";
import {
BUILTIN_PROVIDER_ICON_BY_ID,
SETTINGS_ICON_PATHS,
SETTINGS_ICON_COLORS,
} from "./types";
/**
* Optional ProviderConfig-like shape for per-provider customization. Only the
* fields used by the badge are listed so non-provider call sites (Claude/Copilot
* agent cards) can still pass a bare `providerId`.
*/
type ProviderLike = Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl">;
interface BaseProps {
size?: "xs" | "sm" | "md";
}
type Props =
| (BaseProps & { providerId: SettingsIconId; provider?: undefined })
| (BaseProps & { provider: ProviderLike; providerId?: undefined });
const BADGE_DIMENSIONS = {
xs: "w-4 h-4",
sm: "w-5 h-5",
md: "w-8 h-8",
} as const;
const IMG_DIMENSIONS = {
xs: "w-2.5 h-2.5",
sm: "w-3 h-3",
md: "w-4 h-4",
} as const;
const UPLOAD_IMG_DIMENSIONS = {
xs: "w-4 h-4",
sm: "w-5 h-5",
md: "w-8 h-8",
} as const;
export const ProviderIconBadge: React.FC<Props> = (props) => {
const size = props.size ?? "md";
const dim = BADGE_DIMENSIONS[size];
// Branch 1: user-uploaded data URL — render verbatim, no filter, neutral bg.
if (props.provider?.iconDataUrl) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-900/40", dim)}>
<img
src={props.provider.iconDataUrl}
alt=""
aria-hidden="true"
draggable={false}
className={cn("object-contain", UPLOAD_IMG_DIMENSIONS[size])}
/>
</div>
);
}
// Branch 2: built-in iconId (lobe-icons subset).
const iconId = props.provider?.iconId;
if (iconId) {
const builtin = BUILTIN_PROVIDER_ICON_BY_ID[iconId];
if (builtin) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, builtin.bgColor)}>
<img
src={builtin.path}
alt=""
aria-hidden="true"
draggable={false}
className={cn("object-contain brightness-0 invert", IMG_DIMENSIONS[size])}
/>
</div>
);
}
}
// Branch 3: providerId → existing built-in fallback table.
const fallbackId: SettingsIconId | undefined =
props.providerId ?? (props.provider ? (props.provider.providerId as SettingsIconId) : undefined);
if (fallbackId && fallbackId in SETTINGS_ICON_PATHS) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, SETTINGS_ICON_COLORS[fallbackId])}>
<img
src={SETTINGS_ICON_PATHS[fallbackId]}
alt=""
aria-hidden="true"
draggable={false}
className={cn(
"object-contain",
fallbackId === "copilot" ? "brightness-0" : "brightness-0 invert",
IMG_DIMENSIONS[size],
)}
/>
</div>
);
}
// Branch 4: letter avatar from the provider name.
const letter = (props.provider?.name?.trim().charAt(0) ?? "?").toUpperCase();
return (
<div
className={cn(
"rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-600 text-white font-medium",
dim,
size === "md" ? "text-sm" : size === "sm" ? "text-[10px]" : "text-[9px]",
)}
aria-hidden="true"
>
{letter}
</div>
);
};

View File

@@ -0,0 +1,305 @@
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
import {
createQuickMessageId,
isValidQuickMessageSlug,
normalizeQuickMessageSlug,
QUICK_MESSAGE_LIMITS,
slugFromQuickMessageName,
} from "../../../../infrastructure/ai/quickMessages";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { SettingCard, SettingsSection } from "../../settings-ui";
interface QuickMessagesSettingsProps {
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
reservedUserSkillSlugs?: string[];
}
type DraftQuickMessage = {
name: string;
slug: string;
content: string;
description: string;
};
const emptyDraft = (): DraftQuickMessage => ({
name: "",
slug: "",
content: "",
description: "",
});
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
quickMessages,
setQuickMessages,
reservedUserSkillSlugs = [],
}) => {
const { t } = useI18n();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
const [slugTouched, setSlugTouched] = useState(false);
const [error, setError] = useState<string | null>(null);
const sortedMessages = useMemo(
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
[quickMessages],
);
const resetEditor = useCallback(() => {
setEditingId(null);
setIsCreating(false);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginCreate = useCallback(() => {
setEditingId(null);
setIsCreating(true);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginEdit = useCallback((message: AIQuickMessage) => {
setIsCreating(false);
setEditingId(message.id);
setDraft({
name: message.name,
slug: message.slug,
content: message.content,
description: message.description ?? "",
});
setSlugTouched(true);
setError(null);
}, []);
const handleNameChange = useCallback((name: string) => {
setDraft((prev) => ({
...prev,
name,
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
}));
}, [slugTouched]);
const handleSlugChange = useCallback((slug: string) => {
setSlugTouched(true);
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
}, []);
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
const name = nextDraft.name.trim();
const slug = normalizeQuickMessageSlug(nextDraft.slug);
const content = nextDraft.content.trim();
if (!name) return t("ai.quickMessages.error.nameRequired");
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
if (!content) return t("ai.quickMessages.error.contentRequired");
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
}
const slugTaken = quickMessages.some(
(message) => message.slug === slug && message.id !== excludeId,
);
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
if (skillConflict) {
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
}
return null;
}, [quickMessages, reservedUserSkillSlugs, t]);
const handleSave = useCallback(() => {
const validationError = validateDraft(draft, editingId);
if (validationError) {
setError(validationError);
return;
}
const payload: AIQuickMessage = {
id: editingId ?? createQuickMessageId(),
name: draft.name.trim(),
slug: normalizeQuickMessageSlug(draft.slug),
content: draft.content.trim(),
description: draft.description.trim() || undefined,
};
if (editingId) {
setQuickMessages((prev) => prev.map((message) => (
message.id === editingId ? payload : message
)));
} else {
setQuickMessages((prev) => [...prev, payload]);
}
resetEditor();
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
const handleDelete = useCallback((message: AIQuickMessage) => {
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
if (!ok) return;
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
if (editingId === message.id) {
resetEditor();
}
}, [editingId, resetEditor, setQuickMessages, t]);
const showEditor = isCreating || editingId != null;
return (
<SettingsSection
anchorId="ai-quick-messages"
title={t("ai.quickMessages.title")}
actions={(
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
<Plus size={14} className="mr-2" />
{t("ai.quickMessages.add")}
</Button>
)}
>
<SettingCard padded className="space-y-3">
<p className="text-xs text-muted-foreground/80 leading-5">
{t("ai.quickMessages.description")}
</p>
{showEditor ? (
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
</div>
<button
type="button"
onClick={resetEditor}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
aria-label={t("common.cancel")}
>
<X size={14} />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
<input
value={draft.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("ai.quickMessages.name.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.name}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground/70">/</span>
<input
value={draft.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder={t("ai.quickMessages.slug.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.slug}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
/>
</div>
</label>
</div>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
<input
value={draft.description}
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.description}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
<textarea
value={draft.content}
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
placeholder={t("ai.quickMessages.content.placeholder")}
rows={5}
maxLength={QUICK_MESSAGE_LIMITS.content}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
/>
</label>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={resetEditor}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("common.save")}
</Button>
</div>
</div>
) : null}
{sortedMessages.length > 0 ? (
<div className="border-t border-border/60 divide-y divide-border/60">
{sortedMessages.map((message) => (
<div
key={message.id}
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{message.name}</span>
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
</div>
{message.description ? (
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
) : null}
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
{message.content}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => beginEdit(message)}
aria-label={t("ai.quickMessages.editTitle")}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(message)}
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showEditor ? (
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
</div>
) : null}
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,235 @@
import React, { useCallback, useState } from "react";
import { Plus, X } from "lucide-react";
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
import {
DEFAULT_COMMAND_BLOCKLIST,
MAX_COMMAND_TIMEOUT_SECONDS,
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
} from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Select, SettingCard, SettingRow, SettingsAnchor, SettingsSection } from "../../settings-ui";
export const SafetySettings: React.FC<{
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
commandBlocklist: string[];
setCommandBlocklist: (value: string[]) => void;
commandTimeout: number;
setCommandTimeout: (value: number) => void;
responseIdleTimeout: number;
setResponseIdleTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
}> = ({
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
}) => {
const { t } = useI18n();
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
if (!pattern) {
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
}
try {
new RegExp(pattern);
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
} catch (e) {
setRegexErrors((prev) => ({
...prev,
[idx]: e instanceof Error ? e.message : String(e),
}));
return false;
}
}, []);
const handlePatternChange = useCallback((value: string, idx: number) => {
const next = [...commandBlocklist];
next[idx] = value;
validatePattern(value, idx);
setCommandBlocklist(next);
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
const permissionModeOptions = [
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
{ value: "auto", label: t('ai.safety.permissionMode.auto') },
];
return (
<SettingsSection title={t('ai.safety.title')}>
<div className="flex flex-col gap-4">
<SettingCard divided>
<SettingRow
anchorId="ai-safety-permission-mode"
label={t('ai.safety.permissionMode')}
description={t('ai.safety.permissionMode.description')}
>
<Select
value={globalPermissionMode}
options={permissionModeOptions}
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
className="w-64"
/>
</SettingRow>
<SettingRow
anchorId="ai-safety-response-idle-timeout"
label={t('ai.safety.responseIdleTimeout')}
description={t('ai.safety.responseIdleTimeout.description')}
>
<div className="flex items-center gap-2">
<input
type="number"
aria-label={t('ai.safety.responseIdleTimeout')}
value={responseIdleTimeout}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) setResponseIdleTimeout(val);
}}
min={1}
max={MAX_RESPONSE_IDLE_TIMEOUT_SECONDS}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">{t('ai.safety.responseIdleTimeout.unit')}</span>
</div>
</SettingRow>
<SettingRow
anchorId="ai-safety-command-timeout"
label={t('ai.safety.commandTimeout')}
description={t('ai.safety.commandTimeout.description')}
>
<div className="flex items-center gap-2">
<input
type="number"
value={commandTimeout}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) setCommandTimeout(val);
}}
min={1}
max={MAX_COMMAND_TIMEOUT_SECONDS}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
</div>
</SettingRow>
<SettingRow
label={t('ai.safety.maxIterations')}
description={t('ai.safety.maxIterations.description')}
>
<input
type="number"
value={maxIterations}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val > 0) setMaxIterations(val);
}}
min={1}
max={100}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</SettingCard>
{/* Command Blocklist */}
<SettingsAnchor anchorId="ai-safety-blocklist">
<SettingCard padded className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
<p className="text-xs text-muted-foreground">
{t('ai.safety.blocklist.description')}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
>
{t('ai.safety.blocklist.reset')}
</Button>
</div>
<div className="space-y-1.5">
{commandBlocklist.map((pattern, idx) => (
<div key={idx} className="space-y-0.5">
<div className="flex items-center gap-2">
<input
type="text"
value={pattern}
onChange={(e) => handlePatternChange(e.target.value, idx)}
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
regexErrors[idx]
? 'border-destructive focus-visible:ring-destructive'
: 'border-input focus-visible:ring-ring'
}`}
placeholder={t('ai.safety.blocklist.placeholder')}
/>
<button
onClick={() => {
const next = commandBlocklist.filter((_, i) => i !== idx);
setCommandBlocklist(next);
setRegexErrors((prev) => {
const updated: Record<number, string> = {};
for (const [k, v] of Object.entries(prev)) {
const ki = Number(k);
if (ki < idx) updated[ki] = v as string;
else if (ki > idx) updated[ki - 1] = v as string;
}
return updated;
});
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X size={14} />
</button>
</div>
{regexErrors[idx] && (
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
)}
</div>
))}
</div>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
>
<Plus size={14} className="mr-1" />
{t('ai.safety.blocklist.add')}
</Button>
</SettingCard>
</SettingsAnchor>
<p className="text-xs text-muted-foreground">
{t('ai.safety.note')}
</p>
</div>
</SettingsSection>
);
};

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMcpOnboardingPrompt } from "./ToolAccessGuidance";
test("buildMcpOnboardingPrompt includes launcher and discovery env", () => {
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", "/tmp/discovery.json");
assert.match(prompt, /netcatty-external/);
assert.match(prompt, /\/opt\/netcatty\/launcher/);
assert.match(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=\/tmp\/discovery\.json/);
assert.match(prompt, /get_environment/);
});
test("buildMcpOnboardingPrompt omits env line without discovery path", () => {
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", null);
assert.match(prompt, /\/opt\/netcatty\/launcher/);
assert.doesNotMatch(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/);
});
test("buildMcpOnboardingPrompt falls back to enable-External-MCP guidance", () => {
const prompt = buildMcpOnboardingPrompt(null, null);
assert.match(prompt, /External MCP/);
assert.doesNotMatch(prompt, /Command: /);
});

View File

@@ -0,0 +1,134 @@
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
import { cn } from "../../../../lib/utils";
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
export function buildMcpOnboardingPrompt(
launcherPath: string | null | undefined,
discoveryPath: string | null | undefined,
): string {
if (!launcherPath) {
return [
"Please connect Netcatty to this session via MCP.",
"In the Netcatty desktop app, open Settings → AI → Tool Access, turn on External MCP,",
"then copy the generated prompt from the Tool Access section and run it here.",
"After that, list the netcatty-external MCP tools and call get_environment to verify the connection.",
].join(" ");
}
const lines = [
"Please register Netcatty's MCP server in your MCP client configuration:",
`- Server name: netcatty-external`,
`- Transport: local stdio`,
`- Command: ${launcherPath}`,
];
if (discoveryPath) {
lines.push(`- Environment: ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${discoveryPath}`);
}
lines.push(
"After registering, list the server's tools and call get_environment to verify the connection.",
"Keep the Netcatty desktop app running while you use these tools.",
);
return lines.join("\n");
}
type CopyRowProps = {
value: string;
label: string;
copyLabel: string;
copiedLabel: string;
testId?: string;
};
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
const [copied, setCopied] = useState(false);
const canCopy = Boolean(value);
const handleCopy = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard may be unavailable; the text stays selectable in the block.
}
};
return (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
data-testid={testId}
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{value}
</pre>
<button
type="button"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-40"
onClick={() => void handleCopy()}
aria-label={copied ? copiedLabel : copyLabel}
title={copied ? copiedLabel : copyLabel}
>
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</button>
</div>
</div>
);
};
export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({ mode }) => {
const { t } = useI18n();
const { skillPath, commandPrefix, mcpLauncherPath, mcpDiscoveryPath } =
useToolAccessGuidanceState(mode);
if (mode === "skills") {
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.skills.description")}
</p>
<CopyRow
value={skillPath || ""}
label={t("ai.toolAccess.skills.file")}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
testId="tool-access-skill-path"
/>
{!skillPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.skills.unavailable")}</p>
) : null}
{commandPrefix ? (
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
) : null}
</div>
);
}
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.mcpPrompt.description")}
</p>
<CopyRow
value={buildMcpOnboardingPrompt(mcpLauncherPath, mcpDiscoveryPath)}
label={t("ai.toolAccess.mcpPrompt.title")}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
testId="tool-access-mcp-prompt"
/>
{!mcpLauncherPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
) : null}
</div>
);
};

View File

@@ -0,0 +1,211 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
exa: "/ai/search/exa.png",
bocha: "/ai/search/bocha.webp",
zhipu: "/ai/search/zhipu.png",
searxng: "/ai/search/searxng.svg",
};
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
<img
src={SEARCH_ICON_PATHS[providerId]}
alt=""
className="w-4 h-4 shrink-0"
/>
);
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
WEB_SEARCH_PROVIDER_PRESETS,
).map(([id, preset]) => ({
value: id as WebSearchProviderId,
label: preset.name,
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
}));
export const WebSearchSettings: React.FC<{
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}> = ({ webSearchConfig, setWebSearchConfig }) => {
const { t } = useI18n();
const [apiKeyInput, setApiKeyInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const config = useMemo(() => webSearchConfig ?? {
providerId: "tavily" as WebSearchProviderId,
enabled: false,
maxResults: 5,
}, [webSearchConfig]);
// Ref to always read the latest config in async callbacks (avoids stale closure)
const configRef = useRef(config);
configRef.current = config;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
// Decrypt API key on mount or when provider changes (with cancellation guard)
const decryptSeqRef = useRef(0);
useEffect(() => {
if (config.apiKey) {
const seq = ++decryptSeqRef.current;
setIsDecrypting(true);
decryptField(config.apiKey)
.then((decrypted) => {
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
})
.catch(() => {
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
})
.finally(() => {
if (decryptSeqRef.current === seq) setIsDecrypting(false);
});
} else {
decryptSeqRef.current++;
setApiKeyInput("");
setIsDecrypting(false);
}
}, [config.apiKey, config.providerId]);
const updateConfig = useCallback(
(updates: Partial<WebSearchConfig>) => {
setWebSearchConfig({ ...configRef.current, ...updates });
},
[setWebSearchConfig],
);
const handleProviderChange = useCallback(
(val: string) => {
const providerId = val as WebSearchProviderId;
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
setWebSearchConfig({
...configRef.current,
providerId,
apiKey: undefined,
apiHost: newPreset.defaultApiHost || undefined,
});
setApiKeyInput("");
},
[setWebSearchConfig],
);
// Sequence counter for blur saves — prevents out-of-order encryption results
const blurSeqRef = useRef(0);
const handleApiKeyBlur = useCallback(async () => {
if (!apiKeyInput.trim()) {
blurSeqRef.current++;
updateConfig({ apiKey: undefined });
return;
}
const seq = ++blurSeqRef.current;
const providerAtBlur = configRef.current.providerId;
const encrypted = await encryptField(apiKeyInput.trim());
// Only apply if this is still the latest blur and provider hasn't changed
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
updateConfig({ apiKey: encrypted });
}
}, [apiKeyInput, updateConfig]);
return (
<SettingsSection title={t("ai.webSearch.title")}>
<SettingCard divided>
<SettingRow
anchorId="ai-web-search-enable"
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<Toggle
checked={config.enabled}
onChange={(enabled) => updateConfig({ enabled })}
/>
</SettingRow>
{/* Provider */}
<SettingRow
anchorId="ai-web-search-provider"
label={t("ai.webSearch.provider")}
description={t("ai.webSearch.provider.description")}
>
<Select
value={config.providerId}
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
className="w-48"
/>
</SettingRow>
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
<div className="flex items-center gap-1.5">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyInput}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
onChange={(e) => setApiKeyInput(e.target.value)}
onBlur={() => void handleApiKeyBlur()}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isDecrypting}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</SettingRow>
)}
{/* API Host */}
<SettingRow
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
? t("ai.webSearch.apiHost.searxngDescription")
: t("ai.webSearch.apiHost.description")
}
>
<input
type="text"
value={config.apiHost ?? preset.defaultApiHost}
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
placeholder={preset.defaultApiHost || "https://..."}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
{/* Max Results */}
<SettingRow
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
<input
type="number"
value={config.maxResults ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= 20) {
updateConfig({ maxResults: val });
}
}}
min={1}
max={20}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,75 @@
/**
* Pure helpers for the Claude Code card's "config directory + environment
* variables" editor. The managed Claude agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
* is preserved across edits but never shown in the env editor.
*/
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
// netcatty marker carrying the claude SDK `settings` option (a settings.json
// path or inline JSON). Extracted in the main process and passed to the SDK as
// `options.settings`; never sent to the agent as a real env var. Additive to —
// and independent of — CLAUDE_CONFIG_DIR.
const SETTINGS_KEY = "NETCATTY_CLAUDE_SETTINGS";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY, SETTINGS_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitClaudeEnv(
env: Record<string, string> | undefined,
): { configDir: string; settingsPath: string; envText: string } {
if (!env) return { configDir: "", settingsPath: "", envText: "" };
const configDir = env[CONFIG_DIR_KEY] ?? "";
const settingsPath = env[SETTINGS_KEY] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { configDir, settingsPath, envText: serializeEnvLines(rest) };
}
export function buildClaudeEnv(
prevEnv: Record<string, string> | undefined,
configDir: string,
settingsPath: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
// Preserve discovery-owned key if present.
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
const trimmedDir = String(configDir || "").trim();
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
const trimmedSettings = String(settingsPath || "").trim();
if (trimmedSettings) next[SETTINGS_KEY] = trimmedSettings;
// Drop managed keys if a user typed them into the free-text editor — the
// dedicated fields and path discovery own these keys.
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,73 @@
/**
* Pure helpers for the CodeBuddy card's environment variables editor.
* The managed CodeBuddy agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them.
*
* CODEBUDDY_CODE_PATH is owned by path discovery, so it is preserved across
* edits but never shown in the env editor.
*
* The SDK supports CODEBUDDY_API_KEY (via options.env), but the CLI itself
* does not. CODEBUDDY_INTERNET_ENVIRONMENT is managed as a first-class field.
* Users who need CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN should set them
* in the free-text environment editor or in their shell profile.
*/
const INTERNET_ENV_VAR = "CODEBUDDY_INTERNET_ENVIRONMENT";
const CODE_PATH_KEY = "CODEBUDDY_CODE_PATH";
const MANAGED_KEYS = new Set([INTERNET_ENV_VAR, CODE_PATH_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitCodebuddyEnv(
env: Record<string, string> | undefined,
): { internetEnv: string; envText: string } {
if (!env) return { internetEnv: "", envText: "" };
const internetEnv = env[INTERNET_ENV_VAR] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { internetEnv, envText: serializeEnvLines(rest) };
}
export function buildCodebuddyEnv(
prevEnv: Record<string, string> | undefined,
internetEnv: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
const trimmedInternetEnv = String(internetEnv || "").trim();
if (trimmedInternetEnv) next[INTERNET_ENV_VAR] = trimmedInternetEnv;
// Preserve auto-injected CODEBUDDY_CODE_PATH across edits.
const codePath = prevEnv?.[CODE_PATH_KEY];
if (codePath) next[CODE_PATH_KEY] = codePath;
// Drop managed keys if a user typed them into the free-text editor
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { ADD_PROVIDER_MENU_CLASS } from "./AddProviderDropdown.tsx";
import { getModelSuggestionClassName, getModelSuggestionsPresentation } from "./ModelSelector.tsx";
const modelSelectorSource = readFileSync(new URL("./ModelSelector.tsx", import.meta.url), "utf8");
test("add provider menu opens toward the left edge of the button and stays width-bounded", () => {
assert.match(ADD_PROVIDER_MENU_CLASS, /right-0/);
assert.doesNotMatch(ADD_PROVIDER_MENU_CLASS, /left-0/);
assert.match(ADD_PROVIDER_MENU_CLASS, /max-w-\[calc\(100vw-2rem\)\]/);
});
test("preset model suggestions stay visible while remote models are loading", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: true,
error: null,
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "loading" },
);
});
test("preset model suggestions stay visible when remote model discovery fails", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: false,
error: "Failed to fetch models",
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "error" },
);
});
test("selected model suggestions use the matching accent foreground", () => {
assert.match(getModelSuggestionClassName(true), /bg-accent/);
assert.match(getModelSuggestionClassName(true), /text-accent-foreground/);
assert.match(getModelSuggestionClassName(false), /hover:text-accent-foreground/);
assert.match(modelSelectorSource, /<Check size=\{12\} className="text-accent-foreground shrink-0" \/>/);
});

View File

@@ -0,0 +1,239 @@
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
import {
type ManagedAgentKey,
isPathLikeCommand,
} from "../../../../infrastructure/ai/managedAgents";
import type { AgentPathInfo } from "./types";
import { AGENT_DEFAULTS, isCursorAvailableForMode } from "./types";
import { buildCodebuddyEnv } from "./codebuddyConfigEnv";
function getAutoManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
if (managed?.commandSource === "auto") return null;
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
}
export function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
export function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
commandSource: "manual" | "auto" = "auto",
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
if (!pathInfo?.available || !pathInfo.path) {
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
if (agentKey === "cursor" && (existingManaged?.apiKey || existingManaged?.cursorAuthMode === "cli-login")) {
const defaults = AGENT_DEFAULTS[agentKey];
const {
acpCommand: _legacyCommand,
acpArgs: _legacyArgs,
...existingManagedWithoutLegacy
} = existingManaged;
return {
agents: [
...otherAgents,
{
...existingManagedWithoutLegacy,
...defaults,
id: managedId,
command: pathInfo?.path || existingManaged.command || "cursor",
// Preserve enable preference when probe is temporarily unavailable
// (e.g. wrong apiKeyPresent gating). Send requires available too.
enabled: existingManaged.enabled ?? true,
available: false,
// Preserve stored API key across mode / temporary unavailability.
...(existingManaged.apiKey ? { apiKey: existingManaged.apiKey } : {}),
cursorAuthMode: existingManaged.cursorAuthMode === "cli-login" ? "cli-login" : "api-key",
},
],
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
};
}
if (agentKey === "codebuddy") {
const hasSavedCodebuddyConfig = Boolean(
(existingManaged?.env && Object.keys(existingManaged.env).length > 0) ||
(
existingManaged?.codebuddyOptions &&
Object.keys(existingManaged.codebuddyOptions).length > 0
),
);
if (hasSavedCodebuddyConfig) {
return {
agents: [
...otherAgents,
{
...existingManaged,
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: existingManaged.command || "codebuddy",
enabled: false,
available: false,
},
],
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
};
}
}
return {
agents: otherAgents,
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const {
acpCommand: _legacyCommand,
acpArgs: _legacyArgs,
...existingManagedWithoutLegacy
} = existingManaged ?? {};
const defaults = AGENT_DEFAULTS[agentKey];
const managedEnv =
agentKey === "claude"
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
: agentKey === "codebuddy"
? { ...(existingManaged?.env ?? {}), CODEBUDDY_CODE_PATH: pathInfo.path }
: agentKey === "opencode"
? { ...(existingManaged?.env ?? {}), OPENCODE_BIN: pathInfo.path }
: existingManaged?.env;
const cursorAuthMode = agentKey === "cursor"
? (existingManaged?.cursorAuthMode
?? (pathInfo.authSource === "cli-login" || pathInfo.cliLoginOk ? "cli-login" : "api-key"))
: undefined;
const cursorModeAvailable = agentKey === "cursor"
? isCursorAvailableForMode(pathInfo, cursorAuthMode === "cli-login" ? "cli-login" : "api-key", {
hasStoredApiKey: Boolean(existingManaged?.apiKey),
})
: true;
const nextManagedAgent: ExternalAgentConfig = {
...existingManagedWithoutLegacy,
...defaults,
id: managedId,
command: agentKey === "cursor" && cursorAuthMode === "cli-login"
? (pathInfo.cliBinPath || pathInfo.path)
: pathInfo.path,
commandSource,
// Persist probed --version so the chat model picker can gate GPT-5.6+
// even when this custom path is not the PATH discovery binary.
...(pathInfo.version ? { cliVersion: pathInfo.version } : {}),
...(managedEnv ? { env: managedEnv } : {}),
available: cursorModeAvailable,
// Do not force-disable when only the current auth mode is temporarily
// unavailable (user may switch modes). Send paths already require available.
enabled: managedAgents.length === 0
|| (agentKey === "codebuddy" && existingManaged && !isPathLikeCommand(existingManaged.command))
? true
: managedAgents.some((agent) => agent.enabled) || managedAgents.every((agent) => agent.available === false),
...(agentKey === "cursor" ? {
cursorAuthMode,
// Keep stored API key in both modes; CLI turns omit it via env wiring.
...(existingManaged?.apiKey ? { apiKey: existingManaged.apiKey } : {}),
} : {}),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
export function updateCodebuddyManagedEnv(
prevAgents: ExternalAgentConfig[],
internetEnv: string,
envText: string,
): ExternalAgentConfig[] {
const managedId = "discovered_codebuddy";
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
const nextEnv = buildCodebuddyEnv(existingManaged?.env, internetEnv, envText);
if (existingManaged) {
if (!nextEnv && !isPathLikeCommand(existingManaged.command)) {
return prevAgents.filter((agent) => agent.id !== managedId);
}
return prevAgents.map((agent) =>
agent.id === managedId
? { ...agent, ...(nextEnv ? { env: nextEnv } : { env: undefined }) }
: agent,
);
}
if (!nextEnv) return prevAgents;
return [
...prevAgents,
{
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: "codebuddy",
enabled: false,
env: nextEnv,
},
];
}
export function updateCodebuddyManagedOptions(
prevAgents: ExternalAgentConfig[],
options: ExternalAgentConfig['codebuddyOptions'],
): ExternalAgentConfig[] {
const managedId = "discovered_codebuddy";
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
if (existingManaged) {
if (
!options &&
(!existingManaged.env || Object.keys(existingManaged.env).length === 0) &&
!isPathLikeCommand(existingManaged.command)
) {
return prevAgents.filter((agent) => agent.id !== managedId);
}
return prevAgents.map((agent) =>
agent.id === managedId
? { ...agent, codebuddyOptions: options }
: agent,
);
}
if (!options) return prevAgents;
return [
...prevAgents,
{
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: "codebuddy",
enabled: false,
codebuddyOptions: options,
},
];
}
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
return {
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
cursor: getAutoManagedAgentStoredPath(agents, "cursor") ?? "",
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
};
}

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
mergeModelContextWindow,
parseFetchedModels,
} from "./modelMetadata.ts";
import { buildModelSuggestions } from "./ModelSelector.tsx";
test("parseFetchedModels reads common context window fields from model list responses", () => {
assert.deepEqual(
parseFetchedModels({
data: [
{ id: "openrouter/model", name: "OpenRouter Model", context_length: 131072 },
{ id: "vercel/model", context_window: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
}),
[
{ id: "openrouter/model", name: "OpenRouter Model", contextWindow: 131072 },
{ id: "vercel/model", contextWindow: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
);
});
test("mergeModelContextWindow stores valid discovered model windows only", () => {
assert.deepEqual(
mergeModelContextWindow(undefined, "qwen", 262144),
{ qwen: 262144 },
);
assert.deepEqual(
mergeModelContextWindow({ old: 8192 }, "qwen", undefined),
{ old: 8192 },
);
});
test("buildModelSuggestions uses provider presets before fetched model discovery", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["qwen3.6-plus", "qwen3.6-flash"],
fetchedModels: [],
hasFetched: false,
value: "plus",
}),
[{ id: "qwen3.6-plus" }],
);
});
test("buildModelSuggestions merges fetched and preset models without duplicates", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["kimi-k2.6", "moonshot-v1-128k"],
fetchedModels: [
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "moonshot-v1-8k", name: "Moonshot 8K" },
],
hasFetched: true,
value: "",
}).map((model) => model.id),
["kimi-k2.6", "moonshot-v1-128k", "moonshot-v1-8k"],
);
});

View File

@@ -0,0 +1,44 @@
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import type { FetchedModel } from "./types";
export function parseFetchedModels(parsed: unknown): FetchedModel[] {
const record = parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
const rawModels = Array.isArray(record.data)
? record.data
: Array.isArray(record.models)
? record.models
: [];
return rawModels
.map((raw): FetchedModel | null => {
if (!raw || typeof raw !== "object") return null;
const model = raw as Record<string, unknown>;
if (typeof model.id !== "string" || !model.id) return null;
return {
id: model.id,
...(typeof model.name === "string" ? { name: model.name } : {}),
...(resolveModelContextWindow(model) != null ? { contextWindow: resolveModelContextWindow(model) } : {}),
};
})
.filter((model): model is FetchedModel => model != null);
}
export function mergeModelContextWindow(
current: Record<string, number> | undefined,
modelId: string,
contextWindow: number | undefined,
): Record<string, number> | undefined {
const sanitized = sanitizeContextWindow(contextWindow);
if (!modelId || sanitized == null) return current;
return { ...(current ?? {}), [modelId]: sanitized };
}
function resolveModelContextWindow(model: Record<string, unknown>): number | undefined {
return sanitizeContextWindow(
model.context_length
?? model.context_window
?? model.contextWindow
?? model.context
?? model.max_context_tokens,
);
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isCursorAvailableForMode, isCursorRuntimeInstalled } from "./types";
test("isCursorRuntimeInstalled ignores bundled SDK flags", () => {
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: true,
sdkInstalled: true,
}), false);
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
sdkInstalled: true,
cliBinPath: null,
cliLoginOk: false,
}), false);
});
test("isCursorRuntimeInstalled is true for Agent CLI path or CLI login", () => {
assert.equal(isCursorRuntimeInstalled({
path: "/usr/local/bin/cursor-agent",
version: "Cursor Agent CLI",
available: false,
sdkInstalled: true,
cliBinPath: "/usr/local/bin/cursor-agent",
cliLoginOk: false,
}), true);
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor Agent CLI",
available: true,
sdkInstalled: true,
cliLoginOk: true,
}), true);
});
test("isCursorAvailableForMode still allows API-key mode from bundled SDK", () => {
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
sdkInstalled: true,
apiKeyOk: true,
}, "api-key"), true);
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
apiKeyOk: true,
}, "api-key"), true);
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
sdkInstalled: true,
cliLoginOk: false,
}, "cli-login"), false);
});

View File

@@ -0,0 +1,339 @@
/**
* Shared types for AI settings sub-components
*/
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
OpenAIApiFormat,
ProviderStyle,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "connected_custom_config"
| "not_logged_in"
| "unknown";
export interface CodexCustomProviderConfig {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export interface CodexAppServerStatus {
available: boolean;
checking?: boolean;
error?: string;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
export interface CodexLoginSession {
sessionId: string;
state: CodexLoginState;
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
codexPath?: string | null;
}
export interface AgentPathInfo {
path: string | null;
binPath?: string | null;
version: string | null;
available: boolean;
/** True when the user's Cursor Agent CLI is on PATH or logged in. */
installed?: boolean;
authenticated?: boolean;
authSource?: string | null;
cliEmail?: string | null;
cliBinPath?: string | null;
/** True when local Cursor Agent CLI is logged in (subscription session). */
cliLoginOk?: boolean;
/** True when settings or env API key is present. */
apiKeyOk?: boolean;
/** True when @cursor/sdk platform package is importable. */
sdkInstalled?: boolean;
}
/** User-environment Cursor Agent CLI, not Netcatty's bundled @cursor/sdk. */
export function isCursorRuntimeInstalled(pathInfo: AgentPathInfo | null | undefined): boolean {
return Boolean(pathInfo?.cliBinPath || pathInfo?.cliLoginOk);
}
/** Mode-aware Cursor availability for Settings enablement. */
export function isCursorAvailableForMode(
pathInfo: AgentPathInfo | null | undefined,
mode: "api-key" | "cli-login",
options?: { hasStoredApiKey?: boolean },
): boolean {
if (!pathInfo) return false;
if (mode === "cli-login") {
return Boolean(pathInfo.cliLoginOk || pathInfo.authSource === "cli-login");
}
const hasKey = Boolean(
options?.hasStoredApiKey
|| pathInfo.apiKeyOk
|| pathInfo.authSource === "settings"
|| pathInfo.authSource === "CURSOR_API_KEY",
);
// Missing sdkInstalled means the probe has not filled it yet. API-key mode
// uses Netcatty's bundled SDK and must not wait for Cursor.app.
const sdkOk = pathInfo.sdkInstalled !== undefined
? Boolean(pathInfo.sdkInstalled)
: true;
return hasKey && sdkOk;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
baseURL: string;
defaultModel: string;
contextWindow: string;
modelContextWindows: Record<string, number>;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
style: ProviderStyle | ""; // "" means inherit-from-providerId
openaiApi: OpenAIApiFormat;
iconId: string; // "" means no built-in pick (fall back to providerId)
iconDataUrl: string; // "" means no upload override
}
export interface FetchedModel {
id: string;
name?: string;
contextWindow?: number;
}
export interface FetchBridge {
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
}
export interface NetcattyAiBridge {
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
aiSkillsCliGetInvocation?: () => Promise<{
ok: boolean;
skillPath?: string | null;
commandPrefix?: string;
launcherPath?: string | null;
usesLauncher?: boolean;
error?: string;
}>;
openExternal?: (url: string) => Promise<void>;
externalMcpGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpSetEnabled?: (enabled: boolean) => Promise<Record<string, unknown>>;
externalMcpSetConfig?: (config: {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
}) => Promise<Record<string, unknown>>;
externalMcpCodexGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpCodexAdd?: () => Promise<Record<string, unknown>>;
externalMcpClaudeGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpClaudeAdd?: () => Promise<Record<string, unknown>>;
externalMcpGrokGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpGrokAdd?: () => Promise<Record<string, unknown>>;
}
// Agent default configs for registration in externalAgents
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
codex: {
name: "Codex CLI",
args: ["exec", "--full-auto", "--json", "{prompt}"],
icon: "openai",
sdkBackend: "codex",
},
claude: {
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
sdkBackend: "claude",
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
sdkBackend: "copilot",
},
cursor: {
name: "Cursor",
args: ["{prompt}"],
icon: "cursor",
sdkBackend: "cursor",
},
codebuddy: {
name: "CodeBuddy Code",
args: [],
icon: "codebuddy",
sdkBackend: "codebuddy",
},
opencode: {
name: "OpenCode",
args: [],
icon: "opencode",
sdkBackend: "opencode",
},
grok: {
name: "Grok Build",
args: [],
icon: "grok",
sdkBackend: "grok",
},
};
// ---------------------------------------------------------------------------
// Bridge helpers
// ---------------------------------------------------------------------------
export function getBridge(): NetcattyAiBridge | undefined {
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
}
export function getFetchBridge(): FetchBridge | undefined {
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
}
export function normalizeCodexBridgeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
}
return message;
}
// ---------------------------------------------------------------------------
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude" | "copilot" | "codebuddy" | "opencode";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
codebuddy: "/ai/agents/codebuddy.svg",
opencode: "/ai/agents/opencode.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
qwen: "/ai/providers/qwen.svg",
deepseek: "/ai/providers/deepseek.svg",
kimi: "/ai/providers/kimi.svg",
zhipu: "/ai/providers/zhipu.svg",
doubao: "/ai/providers/doubao.svg",
mimo: "/ai/providers/xiaomi.svg",
custom: "/ai/providers/custom.svg",
};
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
codebuddy: "bg-indigo-600",
opencode: "bg-teal-600",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",
qwen: "bg-[#615CED]",
deepseek: "bg-[#4D6BFE]",
kimi: "bg-zinc-800",
zhipu: "bg-[#3859FF]",
doubao: "bg-[#0066FF]",
mimo: "bg-[#FF6900]",
custom: "bg-zinc-600",
};
// ---------------------------------------------------------------------------
// Extra brand icons (lobe-icons subset, MIT) for ProviderConfig.iconId
// See public/ai/providers/NOTICE.md for attribution.
// ---------------------------------------------------------------------------
export interface BuiltinProviderIcon {
/** Identifier stored as ProviderConfig.iconId. */
id: string;
/** Display label shown in the icon picker. */
label: string;
/** Suggested display name when picking this preset (auto-fills ProviderConfig.name). */
name: string;
/** Absolute URL of the SVG asset. */
path: string;
/** Background tint applied behind the monochrome glyph. */
bgColor: string;
}
export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
{ id: "anthropic", label: "Anthropic", name: "Anthropic", path: "/ai/providers/anthropic.svg", bgColor: "bg-orange-600" },
{ id: "openai", label: "OpenAI", name: "OpenAI", path: "/ai/providers/openai.svg", bgColor: "bg-emerald-600" },
{ id: "google", label: "Google", name: "Google", path: "/ai/providers/google.svg", bgColor: "bg-blue-600" },
{ id: "ollama", label: "Ollama", name: "Ollama", path: "/ai/providers/ollama.svg", bgColor: "bg-purple-600" },
{ id: "openrouter", label: "OpenRouter", name: "OpenRouter", path: "/ai/providers/openrouter.svg", bgColor: "bg-pink-600" },
{ id: "deepseek", label: "DeepSeek", name: "DeepSeek", path: "/ai/providers/deepseek.svg", bgColor: "bg-[#4D6BFE]" },
{ id: "moonshot", label: "Moonshot", name: "Moonshot", path: "/ai/providers/moonshot.svg", bgColor: "bg-zinc-800" },
{ id: "kimi", label: "Kimi", name: "Kimi", path: "/ai/providers/kimi.svg", bgColor: "bg-zinc-800" },
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
{ id: "xiaomi", label: "Xiaomi / 小米", name: "Xiaomi MiMo", path: "/ai/providers/xiaomi.svg", bgColor: "bg-[#FF6900]" },
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },
{ id: "perplexity", label: "Perplexity", name: "Perplexity", path: "/ai/providers/perplexity.svg", bgColor: "bg-[#1F8A8C]" },
{ id: "groq", label: "Groq", name: "Groq", path: "/ai/providers/groq.svg", bgColor: "bg-[#F55036]" },
{ id: "huggingface", label: "Hugging Face", name: "Hugging Face", path: "/ai/providers/huggingface.svg", bgColor: "bg-[#FF9D00]" },
];
export const BUILTIN_PROVIDER_ICON_BY_ID: Record<string, BuiltinProviderIcon> =
Object.fromEntries(BUILTIN_PROVIDER_ICONS.map((icon) => [icon.id, icon]));

View File

@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parsePluginStructuredSettingValue } from './pluginSettingValues';
test('structured plugin settings preserve an unchanged array value on blur', () => {
const value = [{ name: 'first' }, { name: 'second' }];
assert.equal(parsePluginStructuredSettingValue(value), value);
});
test('structured plugin settings parse edited JSON arrays', () => {
assert.deepEqual(parsePluginStructuredSettingValue('[{"name":"edited"}]'), [{ name: 'edited' }]);
});
test('structured plugin settings reject non-array JSON values', () => {
assert.throws(() => parsePluginStructuredSettingValue('{"name":"invalid"}'), /must be a JSON array/u);
});

View File

@@ -0,0 +1,7 @@
export function parsePluginStructuredSettingValue(value: unknown): unknown[] {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) {
throw new TypeError('Plugin structured setting value must be a JSON array');
}
return parsed;
}