From fa800ae78edd5c971852418f5f95e3b97dde0133 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 11:47:00 +0700 Subject: [PATCH] Add configurable terminal profiles --- packages/app/e2e/helpers/launcher.ts | 38 +- packages/app/e2e/helpers/workspace-tabs.ts | 2 +- packages/app/e2e/launcher-tab.spec.ts | 4 +- .../e2e/workspace-agent-title-handoff.spec.ts | 2 +- packages/app/src/assets/acp-provider-icons.ts | 1 + .../src/components/provider-icon-name.test.ts | 31 + .../app/src/components/provider-icon-name.ts | 37 +- packages/app/src/components/provider-icons.ts | 12 +- .../app/src/components/split-container.tsx | 3 +- .../app/src/components/ui/dropdown-menu.tsx | 2 - packages/app/src/i18n/resources/ar.ts | 34 +- packages/app/src/i18n/resources/en.ts | 32 +- packages/app/src/i18n/resources/es.ts | 34 +- packages/app/src/i18n/resources/fr.ts | 34 +- packages/app/src/i18n/resources/ru.ts | 34 +- packages/app/src/i18n/resources/zh-CN.ts | 30 +- packages/app/src/screens/settings-screen.tsx | 5 + .../app/src/screens/settings/host-page.tsx | 574 +++++++++++++++++- .../terminals/use-workspace-terminals.ts | 13 + .../workspace/workspace-desktop-tabs-row.tsx | 367 +++++++---- .../screens/workspace/workspace-screen.tsx | 121 +++- packages/app/src/utils/host-routes.ts | 1 + packages/protocol/src/messages.ts | 14 + packages/protocol/src/provider-icon-names.ts | 52 ++ .../protocol/src/terminal-profiles.test.ts | 97 +++ packages/protocol/src/terminal-profiles.ts | 36 ++ packages/server/src/server/bootstrap.ts | 5 + packages/server/src/server/config.ts | 3 + .../server/src/server/daemon-config-store.ts | 3 + .../server/src/server/persisted-config.ts | 2 + 30 files changed, 1412 insertions(+), 211 deletions(-) create mode 100644 packages/protocol/src/provider-icon-names.ts create mode 100644 packages/protocol/src/terminal-profiles.test.ts create mode 100644 packages/protocol/src/terminal-profiles.ts diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 65f195a29..38af80480 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -69,34 +69,46 @@ export async function pressNewTabShortcut(page: Page): Promise { // ─── Tab bar assertions ─────────────────────────────────────────────────── -/** Assert the new agent tab button is visible in the tab bar. */ +/** Assert the inline new-agent plus button is visible in the tab bar. */ export async function assertNewChatTileVisible(page: Page): Promise { await expect( - page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(), + page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(), ).toBeVisible(); } -/** Assert the new terminal button is visible in the tab bar. */ -export async function assertTerminalTileVisible(page: Page): Promise { +/** Assert the new-tab dropdown trigger is visible in the tab bar. */ +export async function assertNewTabMenuTriggerVisible(page: Page): Promise { await expect( - page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(), + page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), ).toBeVisible(); } // ─── Tab creation actions ───────────────────────────────────────────────── -/** Click the new agent tab button to create a draft/chat tab. */ +/** Click the inline plus button to create a draft/chat tab. */ export async function clickNewChat(page: Page): Promise { - const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(); + const button = page + .getByTestId("workspace-new-agent-tab-inline") + .filter({ visible: true }) + .first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); } -/** Click the new terminal button to create a terminal tab. */ +/** Open the new-tab menu and click "New terminal". */ export async function clickNewTerminal(page: Page): Promise { - const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); - await expect(button).toBeVisible({ timeout: 10_000 }); - await button.click(); + const trigger = page + .getByTestId("workspace-new-tab-menu-trigger") + .filter({ visible: true }) + .first(); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); + const item = page + .getByTestId("workspace-new-tab-menu-terminal") + .filter({ visible: true }) + .first(); + await expect(item).toBeVisible({ timeout: 10_000 }); + await item.click(); } // ─── Tab title assertions ────────────────────────────────────────────────── @@ -117,9 +129,9 @@ export async function waitForTabWithTitle( ).toBeVisible({ timeout }); } -/** Assert the new agent tab button is visible in the tab bar. */ +/** Assert the inline new-agent plus button is visible in the tab bar. */ export async function assertSingleNewTabButton(page: Page): Promise { - const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }); + const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }); const count = await buttons.count(); expect(count).toBeGreaterThanOrEqual(1); } diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 62b5fc1fd..f28b07a62 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -21,7 +21,7 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise { await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ timeout: 30_000, }); - await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({ + await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({ timeout: 30_000, }); } diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 9d4e4d5a4..68fd56110 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures"; import { gotoWorkspace, assertNewChatTileVisible, - assertTerminalTileVisible, + assertNewTabMenuTriggerVisible, assertSingleNewTabButton, pressNewTabShortcut, clickNewChat, @@ -91,7 +91,7 @@ test.describe("Tab creation", () => { await gotoWorkspace(page, workspace.workspaceId); await assertSingleNewTabButton(page); await assertNewChatTileVisible(page); - await assertTerminalTileVisible(page); + await assertNewTabMenuTriggerVisible(page); }); }); diff --git a/packages/app/e2e/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/workspace-agent-title-handoff.spec.ts index b24ef5003..d75acc582 100644 --- a/packages/app/e2e/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/workspace-agent-title-handoff.spec.ts @@ -181,7 +181,7 @@ test.describe("Workspace agent title handoff", () => { await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab").click(); + await page.getByTestId("workspace-new-agent-tab-inline").click(); await expectComposerVisible(page); const promptTitle = "Investigate optimistic tab title handoff"; diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 2f4e25333..296721622 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -1,6 +1,7 @@ // Vendored ACP provider SVG assets. export const ACP_PROVIDER_ICON_SVGS = { + agy: '\n \n\n', "agoragentic-acp": '\n \n \n \n\n', "amp-acp": diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 414a288e4..80727b59c 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it } from "vitest"; +import { + BUILTIN_PROVIDER_ICON_NAMES, + KNOWN_PROVIDER_ICON_NAMES, + TERMINAL_PROFILE_ICON_NAMES, +} from "@getpaseo/protocol/provider-icon-names"; +import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; import { resolveProviderIconName } from "./provider-icon-name"; describe("resolveProviderIconName", () => { @@ -10,9 +16,34 @@ describe("resolveProviderIconName", () => { it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); + expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" }); }); it("falls back to the bot icon for unknown custom providers", () => { expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); }); }); + +describe("known provider icon names", () => { + it("includes every ACP catalog entry that ships an icon", () => { + const known = new Set(KNOWN_PROVIDER_ICON_NAMES); + for (const entry of ACP_PROVIDER_CATALOG) { + if (entry.iconSvg) { + expect(known).toContain(entry.id); + } + } + }); + + it("only lists ACP icon ids that have a catalog entry with an icon", () => { + const builtin = new Set(BUILTIN_PROVIDER_ICON_NAMES); + const terminalOnly = new Set(TERMINAL_PROFILE_ICON_NAMES); + const catalogIdsWithIcons = new Set( + ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg).map((entry) => entry.id), + ); + for (const name of KNOWN_PROVIDER_ICON_NAMES) { + if (!builtin.has(name) && !terminalOnly.has(name)) { + expect(catalogIdsWithIcons).toContain(name); + } + } + }); +}); diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 6e25c5589..02cd2ec87 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -1,38 +1,21 @@ -import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; - -export type BuiltinProviderIconName = - | "claude" - | "codex" - | "copilot" - | "kiro" - | "omp" - | "opencode" - | "pi"; +import { + BUILTIN_PROVIDER_ICON_NAMES, + KNOWN_PROVIDER_ICON_NAMES, +} from "@getpaseo/protocol/provider-icon-names"; export type ProviderIconName = - | { kind: "builtin"; id: BuiltinProviderIconName } + | { kind: "builtin"; id: string } | { kind: "catalog"; id: string } | { kind: "bot" }; -const BUILTIN_PROVIDER_IDS: ReadonlySet = new Set([ - "claude", - "codex", - "copilot", - "kiro", - "omp", - "opencode", - "pi", -]); - -const CATALOG_ICON_PROVIDER_IDS: ReadonlySet = new Set( - ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])), -); +const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES); +const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES); export function resolveProviderIconName(provider: string): ProviderIconName { - if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) { - return { kind: "builtin", id: provider as BuiltinProviderIconName }; + if (BUILTIN_PROVIDER_IDS.has(provider)) { + return { kind: "builtin", id: provider }; } - if (CATALOG_ICON_PROVIDER_IDS.has(provider)) { + if (KNOWN_PROVIDER_IDS.has(provider)) { return { kind: "catalog", id: provider }; } return { kind: "bot" }; diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index d552537e5..443abb50e 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -8,10 +8,7 @@ import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; import { PiIcon } from "@/components/icons/pi-icon"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; -import { - resolveProviderIconName, - type BuiltinProviderIconName, -} from "@/components/provider-icon-name"; +import { resolveProviderIconName } from "@/components/provider-icon-name"; export interface ProviderIconProps { size: number; @@ -20,7 +17,7 @@ export interface ProviderIconProps { export type ProviderIconComponent = ComponentType; -const BUILTIN_PROVIDER_ICONS: Record = { +const BUILTIN_PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, @@ -53,7 +50,10 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent { if (cached) { return cached; } - const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? ""; + const iconSvg = CATALOG_ICON_SVGS.get(provider); + if (!iconSvg) { + return Bot; + } const icon = createCatalogIcon(provider, iconSvg); catalogIconComponents.set(provider, icon); return icon; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 2b9cac093..f3c52fa8c 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -56,6 +56,7 @@ import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; +import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -92,7 +93,7 @@ interface SplitContainerProps { onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCreateDraftTab: (input: { paneId?: string }) => void; - onCreateTerminalTab: (input: { paneId?: string }) => void; + onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void; showCreateBrowserTab?: boolean; buildPaneContentModel: (input: { diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index d77fc591a..959f5fe82 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -901,8 +901,6 @@ const styles = StyleSheet.create((theme) => ({ labelText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.6, }, separator: { height: 1, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0480b043f..33cfd8687 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -416,7 +416,7 @@ export const ar: TranslationResources = { loadingAgentTitle: "جارٍ تحميل عنوان الوكيل", emptyPane: "لا توجد علامات تبويب في هذا الجزء.", fallback: { - newAgent: "جديد Agent", + newAgent: "وكيل جديد", setup: "يثبت", workspaceSetup: "إعداد Workspace", terminal: "Terminal", @@ -446,13 +446,15 @@ export const ar: TranslationResources = { renameAgent: "إعادة تسمية الوكيل", }, actions: { - newAgent: "علامة تبويب الوكيل الجديد", - newTerminal: "علامة تبويب طرفية جديدة", + newAgent: "وكيل جديد", + newTerminal: "محطة جديدة", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminalTooltip: "جارٍ تحضير المحطة...", - newBrowser: "علامة تبويب متصفح جديدة", + newBrowser: "متصفح جديد", splitRight: "تقسيم الجزء الأيمن", splitDown: "تقسيم الجزء لأسفل", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "افتح المستكشف", @@ -1271,7 +1273,7 @@ export const ar: TranslationResources = { }, panels: { draft: { - newAgent: "جديد Agent", + newAgent: "وكيل جديد", creatingAgent: "وكيل الخلق", }, file: { @@ -1351,6 +1353,7 @@ export const ar: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "مقدمي الخدمات", + terminals: "Terminals", host: "Host", }, general: { @@ -1617,6 +1620,27 @@ export const ar: TranslationResources = { workspaces: { unavailable: "Connect to this host to manage workspaces", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "تحرير التسمية", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bcbf724a0..07a86c3f1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -415,7 +415,7 @@ export const en = { loadingAgentTitle: "Loading agent title", emptyPane: "No tabs in this pane.", fallback: { - newAgent: "New Agent", + newAgent: "New agent", setup: "Setup", workspaceSetup: "Workspace setup", terminal: "Terminal", @@ -445,13 +445,15 @@ export const en = { renameAgent: "Rename agent", }, actions: { - newAgent: "New agent tab", - newTerminal: "New terminal tab", + newAgent: "New agent", + newTerminal: "New terminal", preparingTerminal: "Preparing terminal tab", preparingTerminalTooltip: "Preparing terminal...", - newBrowser: "New browser tab", + newBrowser: "New browser", splitRight: "Split pane right", splitDown: "Split pane down", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "Open explorer", @@ -1357,6 +1359,7 @@ export const en = { agents: "Agents", workspaces: "Workspaces", providers: "Providers", + terminals: "Terminals", host: "Host", }, general: { @@ -1624,6 +1627,27 @@ export const en = { workspaces: { unavailable: "Connect to this host to manage workspaces", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "Edit label", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6f7b09aa2..94aa4da45 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -419,7 +419,7 @@ export const es: TranslationResources = { loadingAgentTitle: "Título del agente de carga", emptyPane: "No hay pestañas en este panel.", fallback: { - newAgent: "NuevoAgent", + newAgent: "Nuevo agente", setup: "Configuración", workspaceSetup: "Configuración deWorkspace", terminal: "Terminal", @@ -450,13 +450,15 @@ export const es: TranslationResources = { renameAgent: "Cambiar nombre del agente", }, actions: { - newAgent: "Nueva pestaña de agente", - newTerminal: "Nueva pestaña de terminal", + newAgent: "Nuevo agente", + newTerminal: "Nueva terminal", preparingTerminal: "Preparando la pestaña del terminal", preparingTerminalTooltip: "Preparando terminal...", - newBrowser: "Nueva pestaña del navegador", + newBrowser: "Nuevo navegador", splitRight: "Panel dividido a la derecha", splitDown: "Dividir panel hacia abajo", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "Explorador abierto", @@ -1306,7 +1308,7 @@ export const es: TranslationResources = { }, panels: { draft: { - newAgent: "NuevoAgent", + newAgent: "Nuevo agente", creatingAgent: "Agente creador", }, file: { @@ -1386,6 +1388,7 @@ export const es: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Proveedores", + terminals: "Terminals", host: "Host", }, general: { @@ -1654,6 +1657,27 @@ export const es: TranslationResources = { workspaces: { unavailable: "Connect to this host to manage workspaces", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "Editar etiqueta", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index de23eaae3..6c735310e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -419,7 +419,7 @@ export const fr: TranslationResources = { loadingAgentTitle: "Titre d'agent de chargement", emptyPane: "Aucun onglet dans ce volet.", fallback: { - newAgent: "NouveauAgent", + newAgent: "Nouvel agent", setup: "Installation", workspaceSetup: "ConfigurationWorkspace", terminal: "Terminal", @@ -450,13 +450,15 @@ export const fr: TranslationResources = { renameAgent: "Renommer l'agent", }, actions: { - newAgent: "Nouvel onglet agent", - newTerminal: "Nouvel onglet de terminal", + newAgent: "Nouvel agent", + newTerminal: "Nouveau terminal", preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminalTooltip: "Préparation du terminal...", - newBrowser: "Nouvel onglet du navigateur", + newBrowser: "Nouveau navigateur", splitRight: "Volet divisé à droite", splitDown: "Diviser le volet vers le bas", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "Ouvrir l'explorateur", @@ -1309,7 +1311,7 @@ export const fr: TranslationResources = { }, panels: { draft: { - newAgent: "NouveauAgent", + newAgent: "Nouvel agent", creatingAgent: "Agent créateur", }, file: { @@ -1389,6 +1391,7 @@ export const fr: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Fournisseurs", + terminals: "Terminals", host: "Host", }, general: { @@ -1660,6 +1663,27 @@ export const fr: TranslationResources = { workspaces: { unavailable: "Connect to this host to manage workspaces", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "Modifier l'étiquette", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 61487037e..1ad8ad5b8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -419,7 +419,7 @@ export const ru: TranslationResources = { loadingAgentTitle: "Название агента загрузки", emptyPane: "На этой панели нет вкладок.", fallback: { - newAgent: "Новый Agent", + newAgent: "Новый агент", setup: "Настраивать", workspaceSetup: "Настройка Workspace", terminal: "Terminal", @@ -449,13 +449,15 @@ export const ru: TranslationResources = { renameAgent: "Переименовать агента", }, actions: { - newAgent: "Новая вкладка агента", - newTerminal: "Новая вкладка терминала", + newAgent: "Новый агент", + newTerminal: "Новый терминал", preparingTerminal: "Подготовка вкладки терминала", preparingTerminalTooltip: "Подготовка терминала...", - newBrowser: "Новая вкладка браузера", + newBrowser: "Новый браузер", splitRight: "Разделить панель справа", splitDown: "Разделить панель вниз", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "Открыть проводник", @@ -1298,7 +1300,7 @@ export const ru: TranslationResources = { }, panels: { draft: { - newAgent: "Новый Agent", + newAgent: "Новый агент", creatingAgent: "Создание агента", }, file: { @@ -1378,6 +1380,7 @@ export const ru: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Провайдеры", + terminals: "Terminals", host: "Host", }, general: { @@ -1648,6 +1651,27 @@ export const ru: TranslationResources = { workspaces: { unavailable: "Connect to this host to manage workspaces", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "Изменить ярлык", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f1c131e5c..88dd4997a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -446,13 +446,15 @@ export const zhCN: TranslationResources = { renameAgent: "重命名 Agent", }, actions: { - newAgent: "新建 Agent 标签", - newTerminal: "新建 Terminal 标签", + newAgent: "新建 Agent", + newTerminal: "新建 Terminal", preparingTerminal: "正在准备 Terminal 标签", preparingTerminalTooltip: "正在准备 Terminal...", - newBrowser: "新建浏览器标签", + newBrowser: "新建浏览器", splitRight: "向右拆分窗格", splitDown: "向下拆分窗格", + terminalProfilesMenu: "Terminal profiles", + editTerminalProfiles: "Edit profiles…", }, explorer: { open: "打开 explorer", @@ -1334,6 +1336,7 @@ export const zhCN: TranslationResources = { agents: "Agents", workspaces: "Workspaces", providers: "Providers", + terminals: "Terminals", host: "Host", }, general: { @@ -1599,6 +1602,27 @@ export const zhCN: TranslationResources = { workspaces: { unavailable: "连接到这个 Host 以管理 Workspace", }, + terminalProfiles: { + unavailable: "Connect to this host to manage terminal profiles", + sectionTitle: "Terminal profiles", + editProfile: "Edit profile", + addProfileTitle: "Add terminal profile", + editProfileTitle: "Edit terminal profile", + namePlaceholder: "Claude Code", + commandPlaceholder: "claude", + argsPlaceholder: "--dangerously-skip-permissions", + nameLabel: "Name", + commandLabel: "Command", + argsLabel: "Arguments", + argsHint: "Space-separated arguments passed to the command", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + save: "Save", + emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + }, daemon: { rename: { editLabel: "编辑标签", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 08aa23619..adf8e3228 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -41,6 +41,7 @@ import { Puzzle, Plus, FolderGit2, + SquareTerminal, } from "lucide-react-native"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; @@ -107,6 +108,7 @@ import { HostSettingsPage, HostProvidersPage, HostWorkspacesPage, + HostTerminalsPage, } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; @@ -173,6 +175,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, + { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, { id: "host", labelKey: "settings.hostSections.host", icon: Server }, ]; @@ -189,6 +192,8 @@ function renderHostSettingsContent( return ; case "providers": return ; + case "terminals": + return ; case "host": return ; } diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index e9ed45337..2ac6d6a36 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,9 +1,26 @@ -import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; +import { + ArrowDown, + ArrowUp, + ChevronRight, + Globe, + Monitor, + Pencil, + Plus, + RotateCw, + SquareTerminal, + Trash2, +} from "lucide-react-native"; import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +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"; @@ -33,6 +50,38 @@ import type { HostConnection, HostProfile } from "@/types/host-connection"; import { confirmDialog } from "@/utils/confirm-dialog"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatLatency } from "@/utils/latency"; +import { ICON_SIZE } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; +import { getProviderIcon } from "@/components/provider-icons"; + +const ThemedArrowUp = withUnistyles(ArrowUp); +const ThemedArrowDown = withUnistyles(ArrowDown); +const ThemedProfilePencil = withUnistyles(Pencil); +const ThemedTrash2 = withUnistyles(Trash2); +const ThemedProfileSquareTerminal = withUnistyles(SquareTerminal); +const ThemedPlus = withUnistyles(Plus); + +interface DynamicProviderIconProps { + iconKey: string; + size: number; + color?: string; +} + +function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { + const Icon = getProviderIcon(iconKey); + return ; +} + +const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); + +const moveUpIcon = ; +const moveDownIcon = ; +const editProfileIcon = ; +const removeProfileIcon = ; +const addProfileIcon = ; function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string { if (connection.type === "relay") { @@ -1043,6 +1092,527 @@ function RemoveHostSection({ ); } +// --------------------------------------------------------------------------- +// Terminal Profiles +// --------------------------------------------------------------------------- + +function generateProfileId(): string { + return `profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; +} + +function parseArgsString(raw: string): string[] | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return trimmed + .split(/\s+/) + .map((s) => s.trim()) + .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; + isLast: boolean; + onEdit: (id: string) => void; + onRemove: (id: string) => void; + onMoveUp: (id: string) => void; + onMoveDown: (id: string) => void; +} + +function TerminalProfileRow({ + profile, + isFirst, + isLast, + onEdit, + onRemove, + onMoveUp, + onMoveDown, +}: TerminalProfileRowProps) { + const { t } = useTranslation(); + + const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]); + const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]); + const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]); + const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]); + + const commandText = + profile.args && profile.args.length > 0 + ? `${profile.command} ${profile.args.join(" ")}` + : profile.command; + + const rowStyle = useMemo( + () => [settingsStyles.row, !isFirst && settingsStyles.rowBorder, terminalProfileStyles.row], + [isFirst], + ); + + const icon = getTerminalProfileIcon(profile); + + return ( + + + {icon ? ( + + ) : ( + + )} + + + + {profile.name} + + + {commandText} + + + +