[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
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:
29
components/settings/DebouncedTextarea.test.tsx
Normal file
29
components/settings/DebouncedTextarea.test.tsx
Normal 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\}/);
|
||||
});
|
||||
83
components/settings/DebouncedTextarea.tsx
Normal file
83
components/settings/DebouncedTextarea.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
24
components/settings/FontSelect.test.tsx
Normal file
24
components/settings/FontSelect.test.tsx
Normal 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/);
|
||||
});
|
||||
53
components/settings/FontSelect.tsx
Normal file
53
components/settings/FontSelect.tsx
Normal 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;
|
||||
65
components/settings/SettingsFocusContext.tsx
Normal file
65
components/settings/SettingsFocusContext.tsx
Normal 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);
|
||||
}
|
||||
20
components/settings/SettingsSearchControl.test.tsx
Normal file
20
components/settings/SettingsSearchControl.test.tsx
Normal 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/);
|
||||
});
|
||||
220
components/settings/SettingsSearchControl.tsx
Normal file
220
components/settings/SettingsSearchControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
components/settings/TerminalBehaviorSettings.test.tsx
Normal file
49
components/settings/TerminalBehaviorSettings.test.tsx
Normal 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\)/);
|
||||
});
|
||||
19
components/settings/TerminalCjkFontSelect.test.ts
Normal file
19
components/settings/TerminalCjkFontSelect.test.ts
Normal 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\)/,
|
||||
);
|
||||
});
|
||||
168
components/settings/TerminalCjkFontSelect.tsx
Normal file
168
components/settings/TerminalCjkFontSelect.tsx
Normal 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;
|
||||
8
components/settings/TerminalFontSelect.test.ts
Normal file
8
components/settings/TerminalFontSelect.test.ts
Normal 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/);
|
||||
});
|
||||
72
components/settings/TerminalFontSelect.tsx
Normal file
72
components/settings/TerminalFontSelect.tsx
Normal 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;
|
||||
114
components/settings/ThemeSelectModal.tsx
Normal file
114
components/settings/ThemeSelectModal.tsx
Normal 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;
|
||||
267
components/settings/settings-ui.tsx
Normal file
267
components/settings/settings-ui.tsx
Normal 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>
|
||||
);
|
||||
86
components/settings/settingsFocus.test.ts
Normal file
86
components/settings/settingsFocus.test.ts
Normal 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");
|
||||
});
|
||||
166
components/settings/settingsFocus.ts
Normal file
166
components/settings/settingsFocus.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
451
components/settings/tabs/AppLockSettingsSection.tsx
Normal file
451
components/settings/tabs/AppLockSettingsSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
109
components/settings/tabs/PluginStructuredSettingEditor.test.tsx
Normal file
109
components/settings/tabs/PluginStructuredSettingEditor.test.tsx
Normal 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, /\{"id":"two","kind":"host"\}/u);
|
||||
});
|
||||
362
components/settings/tabs/PluginStructuredSettingEditor.tsx
Normal file
362
components/settings/tabs/PluginStructuredSettingEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
components/settings/tabs/SettingsAITab.lifecycle.test.ts
Normal file
96
components/settings/tabs/SettingsAITab.lifecycle.test.ts
Normal 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()); }
|
||||
});
|
||||
1416
components/settings/tabs/SettingsAITab.tsx
Normal file
1416
components/settings/tabs/SettingsAITab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
612
components/settings/tabs/SettingsAppearanceTab.tsx
Normal file
612
components/settings/tabs/SettingsAppearanceTab.tsx
Normal 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);
|
||||
10
components/settings/tabs/SettingsFileAssociationsTab.test.ts
Normal file
10
components/settings/tabs/SettingsFileAssociationsTab.test.ts
Normal 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,/);
|
||||
});
|
||||
377
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
377
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
components/settings/tabs/SettingsPluginsTab.test.tsx
Normal file
35
components/settings/tabs/SettingsPluginsTab.test.tsx
Normal 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);
|
||||
});
|
||||
374
components/settings/tabs/SettingsPluginsTab.tsx
Normal file
374
components/settings/tabs/SettingsPluginsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
components/settings/tabs/SettingsShortcutsTab.tsx
Normal file
310
components/settings/tabs/SettingsShortcutsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
components/settings/tabs/SettingsSyncTab.tsx
Normal file
123
components/settings/tabs/SettingsSyncTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
components/settings/tabs/SettingsSystemTab.test.ts
Normal file
132
components/settings/tabs/SettingsSystemTab.test.ts
Normal 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/);
|
||||
});
|
||||
1325
components/settings/tabs/SettingsSystemTab.tsx
Normal file
1325
components/settings/tabs/SettingsSystemTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
43
components/settings/tabs/SettingsTerminalTab.test.ts
Normal file
43
components/settings/tabs/SettingsTerminalTab.test.ts
Normal 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/);
|
||||
});
|
||||
1504
components/settings/tabs/SettingsTerminalTab.tsx
Normal file
1504
components/settings/tabs/SettingsTerminalTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
60
components/settings/tabs/SettingsTerminalTabControls.test.ts
Normal file
60
components/settings/tabs/SettingsTerminalTabControls.test.ts
Normal 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/);
|
||||
});
|
||||
314
components/settings/tabs/SettingsTerminalTabControls.tsx
Normal file
314
components/settings/tabs/SettingsTerminalTabControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
components/settings/tabs/TerminalBehaviorSettings.test.ts
Normal file
53
components/settings/tabs/TerminalBehaviorSettings.test.ts
Normal 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/);
|
||||
});
|
||||
357
components/settings/tabs/TerminalBehaviorSettings.tsx
Normal file
357
components/settings/tabs/TerminalBehaviorSettings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
58
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
58
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
179
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
179
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
310
components/settings/tabs/ai/CodebuddyCard.tsx
Normal file
310
components/settings/tabs/ai/CodebuddyCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
components/settings/tabs/ai/CodexConnectionCard.test.tsx
Normal file
40
components/settings/tabs/ai/CodexConnectionCard.test.tsx
Normal 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"/);
|
||||
});
|
||||
249
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
249
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal file
75
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal 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\)/);
|
||||
});
|
||||
136
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
136
components/settings/tabs/ai/CopilotCliCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
231
components/settings/tabs/ai/CursorSdkCard.tsx
Normal file
231
components/settings/tabs/ai/CursorSdkCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
928
components/settings/tabs/ai/ExternalMcpCard.tsx
Normal file
928
components/settings/tabs/ai/ExternalMcpCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
310
components/settings/tabs/ai/ModelSelector.tsx
Normal file
310
components/settings/tabs/ai/ModelSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
255
components/settings/tabs/ai/PermissionGrantsSettings.tsx
Normal file
255
components/settings/tabs/ai/PermissionGrantsSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
components/settings/tabs/ai/ProviderCard.tsx
Normal file
104
components/settings/tabs/ai/ProviderCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
749
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
749
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal 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">(0–2)</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">(0–1)</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">(-2–2)</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">(-2–2)</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>
|
||||
);
|
||||
};
|
||||
117
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
117
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
305
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
235
components/settings/tabs/ai/SafetySettings.tsx
Normal file
235
components/settings/tabs/ai/SafetySettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
components/settings/tabs/ai/ToolAccessGuidance.test.ts
Normal file
24
components/settings/tabs/ai/ToolAccessGuidance.test.ts
Normal 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: /);
|
||||
});
|
||||
134
components/settings/tabs/ai/ToolAccessGuidance.tsx
Normal file
134
components/settings/tabs/ai/ToolAccessGuidance.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
75
components/settings/tabs/ai/claudeConfigEnv.ts
Normal 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;
|
||||
}
|
||||
73
components/settings/tabs/ai/codebuddyConfigEnv.ts
Normal file
73
components/settings/tabs/ai/codebuddyConfigEnv.ts
Normal 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;
|
||||
}
|
||||
47
components/settings/tabs/ai/dropdownLayout.test.ts
Normal file
47
components/settings/tabs/ai/dropdownLayout.test.ts
Normal 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" \/>/);
|
||||
});
|
||||
239
components/settings/tabs/ai/managedAgentState.ts
Normal file
239
components/settings/tabs/ai/managedAgentState.ts
Normal 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") ?? "",
|
||||
};
|
||||
}
|
||||
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal file
63
components/settings/tabs/ai/modelMetadata.test.ts
Normal 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"],
|
||||
);
|
||||
});
|
||||
44
components/settings/tabs/ai/modelMetadata.ts
Normal file
44
components/settings/tabs/ai/modelMetadata.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
67
components/settings/tabs/ai/types.test.ts
Normal file
67
components/settings/tabs/ai/types.test.ts
Normal 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);
|
||||
});
|
||||
339
components/settings/tabs/ai/types.ts
Normal file
339
components/settings/tabs/ai/types.ts
Normal 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]));
|
||||
17
components/settings/tabs/pluginSettingValues.test.ts
Normal file
17
components/settings/tabs/pluginSettingValues.test.ts
Normal 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);
|
||||
});
|
||||
7
components/settings/tabs/pluginSettingValues.ts
Normal file
7
components/settings/tabs/pluginSettingValues.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user