diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx
index 9ad4af730..6bd72e2ad 100644
--- a/packages/app/src/components/add-host-modal.tsx
+++ b/packages/app/src/components/add-host-modal.tsx
@@ -319,8 +319,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}, []);
const connectIcon = useMemo(
- () => ,
- [theme.colors.palette.white],
+ () => ,
+ [theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx
index ff7ecbf46..57169ee09 100644
--- a/packages/app/src/components/pair-link-modal.tsx
+++ b/packages/app/src/components/pair-link-modal.tsx
@@ -77,8 +77,8 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
}, []);
const pairIcon = useMemo(
- () => ,
- [theme.colors.palette.white],
+ () => ,
+ [theme.colors.accentForeground],
);
const handleClose = useCallback(() => {
diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx
index b705ffbce..e6bb7b349 100644
--- a/packages/app/src/components/ui/button.tsx
+++ b/packages/app/src/components/ui/button.tsx
@@ -94,10 +94,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
textDefault: {
- color: theme.colors.palette.white,
+ color: theme.colors.accentForeground,
},
textDestructive: {
- color: theme.colors.palette.white,
+ color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,
diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx
new file mode 100644
index 000000000..a353807ea
--- /dev/null
+++ b/packages/app/src/components/ui/form-field.tsx
@@ -0,0 +1,82 @@
+import { forwardRef, useMemo, type ReactNode } from "react";
+import { Text, View, type TextInput } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
+
+interface FormFieldProps {
+ label: string;
+ children: ReactNode;
+ hint?: string;
+ error?: string | null;
+ testID?: string;
+}
+
+export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
+ const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
+ const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
+ const hintOrError = useMemo(() => {
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+ if (hint) {
+ return (
+
+ {hint}
+
+ );
+ }
+ return null;
+ }, [error, hint, errorTestID, hintTestID]);
+
+ return (
+
+ {label}
+ {children}
+ {hintOrError}
+
+ );
+}
+
+export const FormTextInput = forwardRef(function FormTextInput(
+ { style, ...props },
+ ref,
+) {
+ const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
+ return ;
+});
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ gap: theme.spacing[2],
+ },
+ label: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ },
+ hintText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ },
+ errorText: {
+ color: theme.colors.palette.red[300],
+ fontSize: theme.fontSize.xs,
+ },
+}));
+
+const formInputStyles = StyleSheet.create((theme) => ({
+ input: {
+ backgroundColor: theme.colors.surface2,
+ borderRadius: theme.borderRadius.lg,
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ color: theme.colors.foreground,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ fontSize: theme.fontSize.base,
+ },
+}));
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index b05a89fbf..d9eeea9fd 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1642,7 +1642,10 @@ export const ar: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index a0cb235c6..5725fa6ff 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1648,7 +1648,10 @@ export const en = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index d1ba3d9df..ceb2a6ec9 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1680,7 +1680,10 @@ export const es: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index b7c013bcb..30d784e57 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -1685,7 +1685,10 @@ export const fr: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 693d36995..3e19f1532 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1672,7 +1672,10 @@ export const ru: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index db02a1e92..c36537f2c 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1623,7 +1623,10 @@ export const zhCN: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
+ nameRequired: "Name is required",
+ commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
+ saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx
index 2ac6d6a36..919eb41da 100644
--- a/packages/app/src/screens/settings/host-page.tsx
+++ b/packages/app/src/screens/settings/host-page.tsx
@@ -20,12 +20,15 @@ import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
-import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
+import {
+ ProfileDraft,
+ TerminalProfileEditModal,
+} from "@/screens/settings/terminal-profile-edit-modal";
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
@@ -1109,127 +1112,8 @@ function parseArgsString(raw: string): string[] | undefined {
.filter(Boolean);
}
-interface ProfileDraft {
- name: string;
- command: string;
- args: string;
-}
-
const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" };
-interface TerminalProfileEditModalProps {
- visible: boolean;
- title: string;
- initialDraft: ProfileDraft;
- onClose: () => void;
- onSave: (draft: ProfileDraft) => void;
-}
-
-function TerminalProfileEditModal({
- visible,
- title,
- initialDraft,
- onClose,
- onSave,
-}: TerminalProfileEditModalProps) {
- const { t } = useTranslation();
- const [name, setName] = useState(initialDraft.name);
- const [command, setCommand] = useState(initialDraft.command);
- const [args, setArgs] = useState(initialDraft.args);
- const sheetHeader = useMemo(() => ({ title }), [title]);
-
- useEffect(() => {
- if (!visible) return;
- setName(initialDraft.name);
- setCommand(initialDraft.command);
- setArgs(initialDraft.args);
- }, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
-
- const handleSave = useCallback(() => {
- onSave({ name, command, args });
- }, [onSave, name, command, args]);
-
- const canSave = name.trim().length > 0 && command.trim().length > 0;
-
- return (
-
-
-
-
- {t("settings.host.terminalProfiles.nameLabel")}
-
-
-
-
-
- {t("settings.host.terminalProfiles.commandLabel")}
-
-
-
-
-
- {t("settings.host.terminalProfiles.argsLabel")}
-
-
-
- {t("settings.host.terminalProfiles.argsHint")}
-
-
-
-
-
-
-
-
- );
-}
-
interface TerminalProfileRowProps {
profile: TerminalProfile;
isFirst: boolean;
@@ -1338,7 +1222,6 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
draft: ProfileDraft;
} | null>(null);
const [isAdding, setIsAdding] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
const profiles = useMemo(
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
@@ -1347,38 +1230,28 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
const saveProfiles = useCallback(
async (next: TerminalProfile[]) => {
- setIsSaving(true);
- try {
- await patchConfig({ terminalProfiles: next });
- } catch (error) {
- Alert.alert(
- t("common.errors.unableToSave"),
- error instanceof Error ? error.message : String(error),
- );
- } finally {
- setIsSaving(false);
- }
+ await patchConfig({ terminalProfiles: next });
},
- [patchConfig, t],
+ [patchConfig],
);
const handleAddOpen = useCallback(() => setIsAdding(true), []);
const handleAddClose = useCallback(() => setIsAdding(false), []);
const handleAddSave = useCallback(
- (draft: ProfileDraft) => {
+ async (draft: ProfileDraft) => {
const current = profiles ? [...profiles] : [];
const next: TerminalProfile[] = [
...current,
{
id: generateProfileId(),
- name: draft.name.trim(),
- command: draft.command.trim(),
+ name: draft.name,
+ command: draft.command,
args: parseArgsString(draft.args),
},
];
+ await saveProfiles(next);
setIsAdding(false);
- void saveProfiles(next);
},
[profiles, saveProfiles],
);
@@ -1402,20 +1275,20 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
const handleEditClose = useCallback(() => setEditingProfile(null), []);
const handleEditSave = useCallback(
- (draft: ProfileDraft) => {
+ async (draft: ProfileDraft) => {
if (!editingProfile || !profiles) return;
const next: TerminalProfile[] = profiles.map((p) =>
p.id === editingProfile.id
? {
...p,
- name: draft.name.trim(),
- command: draft.command.trim(),
+ name: draft.name,
+ command: draft.command,
args: parseArgsString(draft.args),
}
: p,
);
+ await saveProfiles(next);
setEditingProfile(null);
- void saveProfiles(next);
},
[editingProfile, profiles, saveProfiles],
);
@@ -1432,9 +1305,16 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
confirmLabel: t("settings.host.terminalProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
- }).then((confirmed) => {
+ }).then(async (confirmed) => {
if (!confirmed || !profiles) return;
- void saveProfiles(profiles.filter((p) => p.id !== id));
+ try {
+ await saveProfiles(profiles.filter((p) => p.id !== id));
+ } catch (error) {
+ Alert.alert(
+ t("common.errors.unableToSave"),
+ error instanceof Error ? error.message : String(error),
+ );
+ }
return;
});
},
@@ -1442,29 +1322,43 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
);
const handleMoveUp = useCallback(
- (id: string) => {
+ async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index <= 0) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index - 1, 0, item);
- void saveProfiles(next);
+ try {
+ await saveProfiles(next);
+ } catch (error) {
+ Alert.alert(
+ t("common.errors.unableToSave"),
+ error instanceof Error ? error.message : String(error),
+ );
+ }
},
- [profiles, saveProfiles],
+ [profiles, saveProfiles, t],
);
const handleMoveDown = useCallback(
- (id: string) => {
+ async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index < 0 || index >= profiles.length - 1) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index + 1, 0, item);
- void saveProfiles(next);
+ try {
+ await saveProfiles(next);
+ } catch (error) {
+ Alert.alert(
+ t("common.errors.unableToSave"),
+ error instanceof Error ? error.message : String(error),
+ );
+ }
},
- [profiles, saveProfiles],
+ [profiles, saveProfiles, t],
);
const addButton = useMemo(
@@ -1474,11 +1368,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
size="sm"
leftIcon={addProfileIcon}
onPress={handleAddOpen}
- disabled={isSaving || !isConnected || !profiles}
+ disabled={!isConnected || !profiles}
testID="terminal-profiles-add-button"
/>
),
- [handleAddOpen, isSaving, isConnected, profiles],
+ [handleAddOpen, isConnected, profiles],
);
if (!isConnected) {
@@ -1530,6 +1424,7 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
initialDraft={EMPTY_PROFILE_DRAFT}
onClose={handleAddClose}
onSave={handleAddSave}
+ testID="terminal-profile-edit-modal"
/>
{editingProfile ? (
@@ -1585,34 +1480,6 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
},
}));
-const profileModalStyles = StyleSheet.create((theme) => ({
- body: {
- gap: theme.spacing[4],
- paddingBottom: theme.spacing[2],
- },
- fieldGroup: {
- gap: theme.spacing[1.5],
- },
- fieldLabel: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.medium,
- },
- fieldHint: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- },
- actions: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- marginTop: theme.spacing[2],
- },
- actionButton: {
- flex: 1,
- },
-}));
-
const styles = StyleSheet.create((theme) => ({
identityEditButton: {
padding: theme.spacing[1],
diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx
new file mode 100644
index 000000000..540615112
--- /dev/null
+++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx
@@ -0,0 +1,376 @@
+import { JSDOM } from "jsdom";
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TerminalProfileEditModal, type ProfileDraft } from "./terminal-profile-edit-modal";
+
+const { theme } = vi.hoisted(() => ({
+ theme: {
+ spacing: { 2: 8, 3: 12, 4: 16 },
+ fontSize: { sm: 13, base: 15, xs: 11 },
+ fontWeight: { medium: 500 },
+ borderRadius: { lg: 8 },
+ colors: {
+ surface2: "#222",
+ foreground: "#fff",
+ foregroundMuted: "#aaa",
+ border: "#555",
+ palette: { red: { 300: "#f87171" } },
+ },
+ },
+}));
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
+ },
+ useUnistyles: () => ({ theme }),
+}));
+
+vi.mock("@/constants/platform", () => ({
+ isWeb: true,
+ isNative: false,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const inputPropsByTestID = vi.hoisted(() => ({
+ map: new Map void; onSubmitEditing?: () => void }>(),
+}));
+
+vi.mock("@/components/adaptive-modal-sheet", async () => {
+ const ReactModule = await import("react");
+ const AdaptiveModalSheet = ({
+ visible,
+ title,
+ children,
+ onClose,
+ testID,
+ }: {
+ visible: boolean;
+ title: string;
+ children: React.ReactNode;
+ onClose: () => void;
+ testID?: string;
+ }) => {
+ if (!visible) return null;
+ return ReactModule.createElement(
+ "div",
+ { "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
+ ReactModule.createElement(
+ "button",
+ {
+ type: "button",
+ "data-testid": "adaptive-modal-sheet-close",
+ onClick: onClose,
+ },
+ "Close",
+ ),
+ children,
+ );
+ };
+ const AdaptiveTextInput = ReactModule.forwardRef>(
+ (props, ref) => {
+ const p = props as {
+ initialValue?: string;
+ defaultValue?: string;
+ editable?: boolean;
+ testID?: string;
+ onChangeText?: (next: string) => void;
+ onSubmitEditing?: () => void;
+ };
+ if (p.testID) {
+ inputPropsByTestID.map.set(p.testID, {
+ onChangeText: p.onChangeText,
+ onSubmitEditing: p.onSubmitEditing,
+ });
+ }
+ return ReactModule.createElement("input", {
+ ref,
+ defaultValue: p.initialValue ?? p.defaultValue ?? "",
+ disabled: p.editable === false,
+ "data-testid": p.testID,
+ onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
+ onKeyDown: (e: { key: string; preventDefault: () => void }) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ p.onSubmitEditing?.();
+ }
+ },
+ });
+ },
+ );
+ return { AdaptiveModalSheet, AdaptiveTextInput };
+});
+
+vi.mock("@/components/ui/button", async () => {
+ const ReactModule = await import("react");
+ return {
+ Button: ({
+ children,
+ onPress,
+ disabled,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ onPress?: () => void;
+ disabled?: boolean;
+ testID?: string;
+ }) =>
+ ReactModule.createElement(
+ "button",
+ {
+ type: "button",
+ "data-testid": testID,
+ disabled: disabled || undefined,
+ onClick: () => !disabled && onPress?.(),
+ },
+ children,
+ ),
+ };
+});
+
+let root: Root | null = null;
+let container: HTMLElement | null = null;
+
+beforeEach(() => {
+ const dom = new JSDOM("");
+ vi.stubGlobal("React", React);
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("window", dom.window);
+ vi.stubGlobal("document", dom.window.document);
+ vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
+ vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
+ vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
+ vi.stubGlobal("Node", dom.window.Node);
+ vi.stubGlobal("navigator", dom.window.navigator);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ }
+ root = null;
+ container = null;
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+interface RenderOptions {
+ visible?: boolean;
+ title?: string;
+ initialDraft?: ProfileDraft;
+ onClose?: () => void;
+ onSave?: (draft: ProfileDraft) => Promise;
+}
+
+function renderModal(options: RenderOptions = {}): void {
+ const {
+ visible = true,
+ title = "Add terminal profile",
+ initialDraft = { name: "", command: "", args: "" },
+ onClose = vi.fn(),
+ onSave = vi.fn().mockResolvedValue(undefined),
+ } = options;
+ act(() => {
+ root?.render(
+ ,
+ );
+ });
+}
+
+function queryInput(testID: string): HTMLInputElement | null {
+ return document.querySelector(`[data-testid="${testID}"]`);
+}
+
+function querySubmit(): HTMLButtonElement | null {
+ return document.querySelector('[data-testid="terminal-profile-save-button"]');
+}
+
+function queryCancel(): HTMLButtonElement | null {
+ return document.querySelector(
+ '[data-testid="terminal-profile-cancel-button"]',
+ );
+}
+
+function queryError(testID: string): HTMLElement | null {
+ return document.querySelector(`[data-testid="${testID}"]`);
+}
+
+function click(element: Element | null): void {
+ if (!element) throw new Error("Cannot click null element");
+ act(() => {
+ element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
+ });
+}
+
+function typeInto(testID: string, value: string): void {
+ act(() => {
+ const input = queryInput(testID);
+ if (!input) throw new Error(`Input ${testID} not found`);
+ input.value = value;
+ inputPropsByTestID.map.get(testID)?.onChangeText?.(value);
+ });
+}
+
+function pressEnter(testID: string): void {
+ act(() => {
+ inputPropsByTestID.map.get(testID)?.onSubmitEditing?.();
+ });
+}
+
+async function flush(): Promise {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+describe("TerminalProfileEditModal", () => {
+ it("renders with the initial draft pre-filled", () => {
+ renderModal({
+ initialDraft: { name: "Claude Code", command: "claude", args: "--skip" },
+ });
+
+ expect(queryInput("terminal-profile-name-input")?.value).toBe("Claude Code");
+ expect(queryInput("terminal-profile-command-input")?.value).toBe("claude");
+ expect(queryInput("terminal-profile-args-input")?.value).toBe("--skip");
+ });
+
+ it("calls onSave with trimmed values and closes on success", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ renderModal({ onSave, onClose });
+
+ typeInto("terminal-profile-name-input", " Codex ");
+ typeInto("terminal-profile-command-input", " codex ");
+ typeInto("terminal-profile-args-input", " --flag ");
+ click(querySubmit());
+ await flush();
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(onSave).toHaveBeenCalledWith({
+ name: "Codex",
+ command: "codex",
+ args: " --flag ",
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows inline validation errors for empty required fields", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ renderModal({ onSave });
+
+ click(querySubmit());
+ await flush();
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(queryError("terminal-profile-name-field-error")?.textContent).toContain(
+ "settings.host.terminalProfiles.nameRequired",
+ );
+ expect(queryError("terminal-profile-command-field-error")?.textContent).toContain(
+ "settings.host.terminalProfiles.commandRequired",
+ );
+ });
+
+ it("clears validation errors when the user types", async () => {
+ renderModal();
+
+ click(querySubmit());
+ await flush();
+ expect(queryError("terminal-profile-name-field-error")).not.toBeNull();
+
+ typeInto("terminal-profile-name-input", "Codex");
+ await flush();
+ expect(queryError("terminal-profile-name-field-error")).toBeNull();
+ });
+
+ it("disables save and cancel while onSave is pending", async () => {
+ let resolve: () => void = () => {};
+ const onSave = vi.fn(
+ () =>
+ new Promise((r) => {
+ resolve = r;
+ }),
+ );
+ renderModal({ onSave });
+
+ typeInto("terminal-profile-name-input", "Codex");
+ typeInto("terminal-profile-command-input", "codex");
+ click(querySubmit());
+ await flush();
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(querySubmit()?.disabled).toBe(true);
+ expect(queryCancel()?.disabled).toBe(true);
+
+ await act(async () => {
+ resolve();
+ await Promise.resolve();
+ });
+ });
+
+ it("keeps the modal open with an error when onSave rejects", async () => {
+ const onSave = vi.fn().mockRejectedValue(new Error("Server said no"));
+ const onClose = vi.fn();
+ renderModal({ onSave, onClose });
+
+ typeInto("terminal-profile-name-input", "Codex");
+ typeInto("terminal-profile-command-input", "codex");
+ click(querySubmit());
+ await flush();
+
+ expect(onClose).not.toHaveBeenCalled();
+ expect(
+ document.querySelector('[data-testid="terminal-profile-submit-error"]')
+ ?.textContent,
+ ).toContain("Server said no");
+ expect(querySubmit()?.disabled).toBe(false);
+ });
+
+ it("calls onClose when the cancel button is clicked", () => {
+ const onClose = vi.fn();
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ renderModal({ onClose, onSave });
+
+ click(queryCancel());
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("submits the form when Enter is pressed in the args field", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ renderModal({ onSave });
+
+ typeInto("terminal-profile-name-input", "Codex");
+ typeInto("terminal-profile-command-input", "codex");
+ pressEnter("terminal-profile-args-input");
+ await flush();
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(onSave).toHaveBeenCalledWith({
+ name: "Codex",
+ command: "codex",
+ args: "",
+ });
+ });
+});
diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx
new file mode 100644
index 000000000..e3939fc62
--- /dev/null
+++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx
@@ -0,0 +1,259 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Text, TextInput, View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet } from "react-native-unistyles";
+import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
+import { Button } from "@/components/ui/button";
+import { FormField, FormTextInput } from "@/components/ui/form-field";
+
+export interface ProfileDraft {
+ name: string;
+ command: string;
+ args: string;
+}
+
+interface FieldErrors {
+ name?: string;
+ command?: string;
+}
+
+interface TerminalProfileEditModalProps {
+ visible: boolean;
+ title: string;
+ initialDraft: ProfileDraft;
+ onClose: () => void;
+ onSave: (draft: ProfileDraft) => Promise;
+ testID?: string;
+}
+
+export function TerminalProfileEditModal({
+ visible,
+ title,
+ initialDraft,
+ onClose,
+ onSave,
+ testID,
+}: TerminalProfileEditModalProps) {
+ const { t } = useTranslation();
+ const [name, setName] = useState(initialDraft.name);
+ const [command, setCommand] = useState(initialDraft.command);
+ const [args, setArgs] = useState(initialDraft.args);
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitError, setSubmitError] = useState(null);
+ const [isPending, setIsPending] = useState(false);
+ const nameInputRef = useRef(null);
+ const commandInputRef = useRef(null);
+ const argsInputRef = useRef(null);
+
+ const handleNameChange = useCallback((value: string) => {
+ setName(value);
+ setFieldErrors((current) => ({ ...current, name: undefined }));
+ }, []);
+
+ const handleCommandChange = useCallback((value: string) => {
+ setCommand(value);
+ setFieldErrors((current) => ({ ...current, command: undefined }));
+ }, []);
+
+ const handleArgsChange = useCallback((value: string) => {
+ setArgs(value);
+ }, []);
+
+ const sheetHeader = useMemo(() => ({ title }), [title]);
+
+ useEffect(() => {
+ if (!visible) {
+ setIsPending(false);
+ return;
+ }
+ setName(initialDraft.name);
+ setCommand(initialDraft.command);
+ setArgs(initialDraft.args);
+ setFieldErrors({});
+ setSubmitError(null);
+ setIsPending(false);
+
+ const timeout = setTimeout(() => {
+ nameInputRef.current?.focus();
+ }, 50);
+ return () => clearTimeout(timeout);
+ }, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
+
+ const validate = useCallback((): boolean => {
+ const errors: FieldErrors = {};
+ if (name.trim().length === 0) {
+ errors.name = t("settings.host.terminalProfiles.nameRequired");
+ }
+ if (command.trim().length === 0) {
+ errors.command = t("settings.host.terminalProfiles.commandRequired");
+ }
+ setFieldErrors(errors);
+ return Object.keys(errors).length === 0;
+ }, [command, name, t]);
+
+ const handleSave = useCallback(async () => {
+ if (isPending) return;
+ setSubmitError(null);
+
+ if (!validate()) {
+ return;
+ }
+
+ setIsPending(true);
+ try {
+ await onSave({ name: name.trim(), command: command.trim(), args });
+ onClose();
+ } catch (error) {
+ setSubmitError(error instanceof Error ? error.message : t("common.errors.unableToSave"));
+ } finally {
+ setIsPending(false);
+ }
+ }, [args, command, isPending, name, onClose, onSave, t, validate]);
+
+ const handleCancel = useCallback(() => {
+ if (isPending) return;
+ onClose();
+ }, [isPending, onClose]);
+
+ const handleNameSubmit = useCallback(() => {
+ commandInputRef.current?.focus();
+ }, []);
+
+ const handleCommandSubmit = useCallback(() => {
+ argsInputRef.current?.focus();
+ }, []);
+
+ const handleArgsSubmit = useCallback(() => {
+ void handleSave();
+ }, [handleSave]);
+
+ const nameError = fieldErrors.name;
+ const commandError = fieldErrors.command;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {submitError ? (
+
+ {submitError}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ body: {
+ gap: theme.spacing[4],
+ paddingBottom: theme.spacing[2],
+ },
+ actions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ marginTop: theme.spacing[2],
+ },
+ actionButton: {
+ flex: 1,
+ },
+ submitError: {
+ color: theme.colors.palette.red[300],
+ fontSize: theme.fontSize.sm,
+ },
+}));