Add configurable terminal profiles

This commit is contained in:
Mohamed Boudra
2026-06-12 11:47:00 +07:00
parent 75aa626df5
commit fa800ae78e
30 changed files with 1412 additions and 211 deletions
+25 -13
View File
@@ -69,34 +69,46 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ─────────────────────────────────────────────────── // ─── 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<void> { export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect( await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(), page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
).toBeVisible(); ).toBeVisible();
} }
/** Assert the new terminal button is visible in the tab bar. */ /** Assert the new-tab dropdown trigger is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> { export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect( await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(), page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
).toBeVisible(); ).toBeVisible();
} }
// ─── Tab creation actions ───────────────────────────────────────────────── // ─── 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<void> { export async function clickNewChat(page: Page): Promise<void> {
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 expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); 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<void> { export async function clickNewTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); const trigger = page
await expect(button).toBeVisible({ timeout: 10_000 }); .getByTestId("workspace-new-tab-menu-trigger")
await button.click(); .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 ────────────────────────────────────────────────── // ─── Tab title assertions ──────────────────────────────────────────────────
@@ -117,9 +129,9 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout }); ).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<void> { export async function assertSingleNewTabButton(page: Page): Promise<void> {
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(); const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
} }
+1 -1
View File
@@ -21,7 +21,7 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000, 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, timeout: 30_000,
}); });
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import { test, expect } from "./fixtures";
import { import {
gotoWorkspace, gotoWorkspace,
assertNewChatTileVisible, assertNewChatTileVisible,
assertTerminalTileVisible, assertNewTabMenuTriggerVisible,
assertSingleNewTabButton, assertSingleNewTabButton,
pressNewTabShortcut, pressNewTabShortcut,
clickNewChat, clickNewChat,
@@ -91,7 +91,7 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page); await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page); await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page); await assertNewTabMenuTriggerVisible(page);
}); });
}); });
@@ -181,7 +181,7 @@ test.describe("Workspace agent title handoff", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").click(); await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page); await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff"; const promptTitle = "Investigate optimistic tab title handoff";
@@ -1,6 +1,7 @@
// Vendored ACP provider SVG assets. // Vendored ACP provider SVG assets.
export const ACP_PROVIDER_ICON_SVGS = { export const ACP_PROVIDER_ICON_SVGS = {
agy: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">\n <path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z"/>\n</svg>\n',
"agoragentic-acp": "agoragentic-acp":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>\n <path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n <line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>\n</svg>\n', '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>\n <path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n <line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>\n</svg>\n',
"amp-acp": "amp-acp":
@@ -1,4 +1,10 @@
import { describe, expect, it } from "vitest"; 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"; import { resolveProviderIconName } from "./provider-icon-name";
describe("resolveProviderIconName", () => { describe("resolveProviderIconName", () => {
@@ -10,9 +16,34 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { 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("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", () => { it("falls back to the bot icon for unknown custom providers", () => {
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); 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);
}
}
});
});
@@ -1,38 +1,21 @@
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; import {
BUILTIN_PROVIDER_ICON_NAMES,
export type BuiltinProviderIconName = KNOWN_PROVIDER_ICON_NAMES,
| "claude" } from "@getpaseo/protocol/provider-icon-names";
| "codex"
| "copilot"
| "kiro"
| "omp"
| "opencode"
| "pi";
export type ProviderIconName = export type ProviderIconName =
| { kind: "builtin"; id: BuiltinProviderIconName } | { kind: "builtin"; id: string }
| { kind: "catalog"; id: string } | { kind: "catalog"; id: string }
| { kind: "bot" }; | { kind: "bot" };
const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([ const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
"claude", const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
"codex",
"copilot",
"kiro",
"omp",
"opencode",
"pi",
]);
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = new Set(
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
);
export function resolveProviderIconName(provider: string): ProviderIconName { export function resolveProviderIconName(provider: string): ProviderIconName {
if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) { if (BUILTIN_PROVIDER_IDS.has(provider)) {
return { kind: "builtin", id: provider as BuiltinProviderIconName }; 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: "catalog", id: provider };
} }
return { kind: "bot" }; return { kind: "bot" };
@@ -8,10 +8,7 @@ import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon"; import { OmpIcon } from "@/components/icons/omp-icon";
import { PiIcon } from "@/components/icons/pi-icon"; import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { import { resolveProviderIconName } from "@/components/provider-icon-name";
resolveProviderIconName,
type BuiltinProviderIconName,
} from "@/components/provider-icon-name";
export interface ProviderIconProps { export interface ProviderIconProps {
size: number; size: number;
@@ -20,7 +17,7 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>; export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconComponent> = { const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
claude: ClaudeIcon as unknown as ProviderIconComponent, claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent,
@@ -53,7 +50,10 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
if (cached) { if (cached) {
return 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); const icon = createCatalogIcon(provider, iconSvg);
catalogIconComponents.set(provider, icon); catalogIconComponents.set(provider, icon);
return icon; return icon;
@@ -56,6 +56,7 @@ import {
WorkspaceDesktopTabsRow, WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem, type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row"; } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
import { import {
WorkspaceTabPresentationResolver, WorkspaceTabPresentationResolver,
WorkspaceTabIcon, WorkspaceTabIcon,
@@ -92,7 +93,7 @@ interface SplitContainerProps {
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean; showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
@@ -901,8 +901,6 @@ const styles = StyleSheet.create((theme) => ({
labelText: { labelText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
}, },
separator: { separator: {
height: 1, height: 1,
+29 -5
View File
@@ -416,7 +416,7 @@ export const ar: TranslationResources = {
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل", loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.", emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: { fallback: {
newAgent: "جديد Agent", newAgent: "وكيل جديد",
setup: "يثبت", setup: "يثبت",
workspaceSetup: "إعداد Workspace", workspaceSetup: "إعداد Workspace",
terminal: "Terminal", terminal: "Terminal",
@@ -446,13 +446,15 @@ export const ar: TranslationResources = {
renameAgent: "إعادة تسمية الوكيل", renameAgent: "إعادة تسمية الوكيل",
}, },
actions: { actions: {
newAgent: "علامة تبويب الوكيل الجديد", newAgent: "وكيل جديد",
newTerminal: "علامة تبويب طرفية جديدة", newTerminal: "محطة جديدة",
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
preparingTerminalTooltip: "جارٍ تحضير المحطة...", preparingTerminalTooltip: "جارٍ تحضير المحطة...",
newBrowser: "علامة تبويب متصفح جديدة", newBrowser: "متصفح جديد",
splitRight: "تقسيم الجزء الأيمن", splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل", splitDown: "تقسيم الجزء لأسفل",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "افتح المستكشف", open: "افتح المستكشف",
@@ -1271,7 +1273,7 @@ export const ar: TranslationResources = {
}, },
panels: { panels: {
draft: { draft: {
newAgent: "جديد Agent", newAgent: "وكيل جديد",
creatingAgent: "وكيل الخلق", creatingAgent: "وكيل الخلق",
}, },
file: { file: {
@@ -1351,6 +1353,7 @@ export const ar: TranslationResources = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "مقدمي الخدمات", providers: "مقدمي الخدمات",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1617,6 +1620,27 @@ export const ar: TranslationResources = {
workspaces: { workspaces: {
unavailable: "Connect to this host to manage 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: { daemon: {
rename: { rename: {
editLabel: "تحرير التسمية", editLabel: "تحرير التسمية",
+28 -4
View File
@@ -415,7 +415,7 @@ export const en = {
loadingAgentTitle: "Loading agent title", loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.", emptyPane: "No tabs in this pane.",
fallback: { fallback: {
newAgent: "New Agent", newAgent: "New agent",
setup: "Setup", setup: "Setup",
workspaceSetup: "Workspace setup", workspaceSetup: "Workspace setup",
terminal: "Terminal", terminal: "Terminal",
@@ -445,13 +445,15 @@ export const en = {
renameAgent: "Rename agent", renameAgent: "Rename agent",
}, },
actions: { actions: {
newAgent: "New agent tab", newAgent: "New agent",
newTerminal: "New terminal tab", newTerminal: "New terminal",
preparingTerminal: "Preparing terminal tab", preparingTerminal: "Preparing terminal tab",
preparingTerminalTooltip: "Preparing terminal...", preparingTerminalTooltip: "Preparing terminal...",
newBrowser: "New browser tab", newBrowser: "New browser",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", splitDown: "Split pane down",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "Open explorer", open: "Open explorer",
@@ -1357,6 +1359,7 @@ export const en = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "Providers", providers: "Providers",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1624,6 +1627,27 @@ export const en = {
workspaces: { workspaces: {
unavailable: "Connect to this host to manage 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: { daemon: {
rename: { rename: {
editLabel: "Edit label", editLabel: "Edit label",
+29 -5
View File
@@ -419,7 +419,7 @@ export const es: TranslationResources = {
loadingAgentTitle: "Título del agente de carga", loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.", emptyPane: "No hay pestañas en este panel.",
fallback: { fallback: {
newAgent: "NuevoAgent", newAgent: "Nuevo agente",
setup: "Configuración", setup: "Configuración",
workspaceSetup: "Configuración deWorkspace", workspaceSetup: "Configuración deWorkspace",
terminal: "Terminal", terminal: "Terminal",
@@ -450,13 +450,15 @@ export const es: TranslationResources = {
renameAgent: "Cambiar nombre del agente", renameAgent: "Cambiar nombre del agente",
}, },
actions: { actions: {
newAgent: "Nueva pestaña de agente", newAgent: "Nuevo agente",
newTerminal: "Nueva pestaña de terminal", newTerminal: "Nueva terminal",
preparingTerminal: "Preparando la pestaña del terminal", preparingTerminal: "Preparando la pestaña del terminal",
preparingTerminalTooltip: "Preparando terminal...", preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Nueva pestaña del navegador", newBrowser: "Nuevo navegador",
splitRight: "Panel dividido a la derecha", splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo", splitDown: "Dividir panel hacia abajo",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "Explorador abierto", open: "Explorador abierto",
@@ -1306,7 +1308,7 @@ export const es: TranslationResources = {
}, },
panels: { panels: {
draft: { draft: {
newAgent: "NuevoAgent", newAgent: "Nuevo agente",
creatingAgent: "Agente creador", creatingAgent: "Agente creador",
}, },
file: { file: {
@@ -1386,6 +1388,7 @@ export const es: TranslationResources = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "Proveedores", providers: "Proveedores",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1654,6 +1657,27 @@ export const es: TranslationResources = {
workspaces: { workspaces: {
unavailable: "Connect to this host to manage 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: { daemon: {
rename: { rename: {
editLabel: "Editar etiqueta", editLabel: "Editar etiqueta",
+29 -5
View File
@@ -419,7 +419,7 @@ export const fr: TranslationResources = {
loadingAgentTitle: "Titre d'agent de chargement", loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.", emptyPane: "Aucun onglet dans ce volet.",
fallback: { fallback: {
newAgent: "NouveauAgent", newAgent: "Nouvel agent",
setup: "Installation", setup: "Installation",
workspaceSetup: "ConfigurationWorkspace", workspaceSetup: "ConfigurationWorkspace",
terminal: "Terminal", terminal: "Terminal",
@@ -450,13 +450,15 @@ export const fr: TranslationResources = {
renameAgent: "Renommer l'agent", renameAgent: "Renommer l'agent",
}, },
actions: { actions: {
newAgent: "Nouvel onglet agent", newAgent: "Nouvel agent",
newTerminal: "Nouvel onglet de terminal", newTerminal: "Nouveau terminal",
preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminal: "Préparation de l'onglet du terminal",
preparingTerminalTooltip: "Préparation du terminal...", preparingTerminalTooltip: "Préparation du terminal...",
newBrowser: "Nouvel onglet du navigateur", newBrowser: "Nouveau navigateur",
splitRight: "Volet divisé à droite", splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas", splitDown: "Diviser le volet vers le bas",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "Ouvrir l'explorateur", open: "Ouvrir l'explorateur",
@@ -1309,7 +1311,7 @@ export const fr: TranslationResources = {
}, },
panels: { panels: {
draft: { draft: {
newAgent: "NouveauAgent", newAgent: "Nouvel agent",
creatingAgent: "Agent créateur", creatingAgent: "Agent créateur",
}, },
file: { file: {
@@ -1389,6 +1391,7 @@ export const fr: TranslationResources = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "Fournisseurs", providers: "Fournisseurs",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1660,6 +1663,27 @@ export const fr: TranslationResources = {
workspaces: { workspaces: {
unavailable: "Connect to this host to manage 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: { daemon: {
rename: { rename: {
editLabel: "Modifier l'étiquette", editLabel: "Modifier l'étiquette",
+29 -5
View File
@@ -419,7 +419,7 @@ export const ru: TranslationResources = {
loadingAgentTitle: "Название агента загрузки", loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.", emptyPane: "На этой панели нет вкладок.",
fallback: { fallback: {
newAgent: "Новый Agent", newAgent: "Новый агент",
setup: "Настраивать", setup: "Настраивать",
workspaceSetup: "Настройка Workspace", workspaceSetup: "Настройка Workspace",
terminal: "Terminal", terminal: "Terminal",
@@ -449,13 +449,15 @@ export const ru: TranslationResources = {
renameAgent: "Переименовать агента", renameAgent: "Переименовать агента",
}, },
actions: { actions: {
newAgent: "Новая вкладка агента", newAgent: "Новый агент",
newTerminal: "Новая вкладка терминала", newTerminal: "Новый терминал",
preparingTerminal: "Подготовка вкладки терминала", preparingTerminal: "Подготовка вкладки терминала",
preparingTerminalTooltip: "Подготовка терминала...", preparingTerminalTooltip: "Подготовка терминала...",
newBrowser: "Новая вкладка браузера", newBrowser: "Новый браузер",
splitRight: "Разделить панель справа", splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз", splitDown: "Разделить панель вниз",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "Открыть проводник", open: "Открыть проводник",
@@ -1298,7 +1300,7 @@ export const ru: TranslationResources = {
}, },
panels: { panels: {
draft: { draft: {
newAgent: "Новый Agent", newAgent: "Новый агент",
creatingAgent: "Создание агента", creatingAgent: "Создание агента",
}, },
file: { file: {
@@ -1378,6 +1380,7 @@ export const ru: TranslationResources = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "Провайдеры", providers: "Провайдеры",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1648,6 +1651,27 @@ export const ru: TranslationResources = {
workspaces: { workspaces: {
unavailable: "Connect to this host to manage 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: { daemon: {
rename: { rename: {
editLabel: "Изменить ярлык", editLabel: "Изменить ярлык",
+27 -3
View File
@@ -446,13 +446,15 @@ export const zhCN: TranslationResources = {
renameAgent: "重命名 Agent", renameAgent: "重命名 Agent",
}, },
actions: { actions: {
newAgent: "新建 Agent 标签", newAgent: "新建 Agent",
newTerminal: "新建 Terminal 标签", newTerminal: "新建 Terminal",
preparingTerminal: "正在准备 Terminal 标签", preparingTerminal: "正在准备 Terminal 标签",
preparingTerminalTooltip: "正在准备 Terminal...", preparingTerminalTooltip: "正在准备 Terminal...",
newBrowser: "新建浏览器标签", newBrowser: "新建浏览器",
splitRight: "向右拆分窗格", splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格", splitDown: "向下拆分窗格",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
}, },
explorer: { explorer: {
open: "打开 explorer", open: "打开 explorer",
@@ -1334,6 +1336,7 @@ export const zhCN: TranslationResources = {
agents: "Agents", agents: "Agents",
workspaces: "Workspaces", workspaces: "Workspaces",
providers: "Providers", providers: "Providers",
terminals: "Terminals",
host: "Host", host: "Host",
}, },
general: { general: {
@@ -1599,6 +1602,27 @@ export const zhCN: TranslationResources = {
workspaces: { workspaces: {
unavailable: "连接到这个 Host 以管理 Workspace", 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: { daemon: {
rename: { rename: {
editLabel: "编辑标签", editLabel: "编辑标签",
@@ -41,6 +41,7 @@ import {
Puzzle, Puzzle,
Plus, Plus,
FolderGit2, FolderGit2,
SquareTerminal,
} from "lucide-react-native"; } from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
@@ -107,6 +108,7 @@ import {
HostSettingsPage, HostSettingsPage,
HostProvidersPage, HostProvidersPage,
HostWorkspacesPage, HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page"; } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen"; import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-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: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "host", labelKey: "settings.hostSections.host", icon: Server }, { id: "host", labelKey: "settings.hostSections.host", icon: Server },
]; ];
@@ -189,6 +192,8 @@ function renderHostSettingsContent(
return <HostWorkspacesPage serverId={view.serverId} />; return <HostWorkspacesPage serverId={view.serverId} />;
case "providers": case "providers":
return <HostProvidersPage serverId={view.serverId} />; return <HostProvidersPage serverId={view.serverId} />;
case "terminals":
return <HostTerminalsPage serverId={view.serverId} />;
case "host": case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />; return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
} }
+572 -2
View File
@@ -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 type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native"; 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 { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal"; import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea"; 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 { confirmDialog } from "@/utils/confirm-dialog";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency"; 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 <Icon size={size} color={color} />;
}
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const editProfileIcon = <ThemedProfilePencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const removeProfileIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiveColorMapping} />;
const addProfileIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string { function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
if (connection.type === "relay") { 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<SheetHeader>(() => ({ 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 (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={onClose}
testID="terminal-profile-edit-modal"
desktopMaxWidth={480}
>
<View style={profileModalStyles.body}>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.nameLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.name}
resetKey={visible ? "open" : "closed"}
onChangeText={setName}
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-name-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.commandLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.command}
resetKey={visible ? "open" : "closed"}
onChangeText={setCommand}
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-command-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.argsLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.args}
resetKey={visible ? "open" : "closed"}
onChangeText={setArgs}
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-args-input"
/>
<Text style={profileModalStyles.fieldHint}>
{t("settings.host.terminalProfiles.argsHint")}
</Text>
</View>
<View style={profileModalStyles.actions}>
<Button
variant="secondary"
size="sm"
style={profileModalStyles.actionButton}
onPress={onClose}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={profileModalStyles.actionButton}
onPress={handleSave}
disabled={!canSave}
testID="terminal-profile-save-button"
>
{t("settings.host.terminalProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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 (
<View style={rowStyle} testID={`terminal-profile-row-${profile.id}`}>
<View style={terminalProfileStyles.iconWrapper}>
{icon ? (
<ThemedDynamicProviderIcon
iconKey={icon}
size={ICON_SIZE.md}
uniProps={mutedColorMapping}
/>
) : (
<ThemedProfileSquareTerminal size={ICON_SIZE.md} uniProps={mutedColorMapping} />
)}
</View>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{commandText}
</Text>
</View>
<View style={terminalProfileStyles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
accessibilityLabel={t("settings.host.terminalProfiles.moveUp")}
testID={`terminal-profile-move-up-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
accessibilityLabel={t("settings.host.terminalProfiles.moveDown")}
testID={`terminal-profile-move-down-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={editProfileIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.terminalProfiles.editProfile")}
testID={`terminal-profile-edit-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeProfileIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.terminalProfiles.remove")}
testID={`terminal-profile-remove-${profile.id}`}
/>
</View>
</View>
);
}
function TerminalProfilesSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const [editingProfile, setEditingProfile] = useState<{
id: string;
draft: ProfileDraft;
} | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const profiles = useMemo(
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
[config],
);
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);
}
},
[patchConfig, t],
);
const handleAddOpen = useCallback(() => setIsAdding(true), []);
const handleAddClose = useCallback(() => setIsAdding(false), []);
const handleAddSave = useCallback(
(draft: ProfileDraft) => {
const current = profiles ? [...profiles] : [];
const next: TerminalProfile[] = [
...current,
{
id: generateProfileId(),
name: draft.name.trim(),
command: draft.command.trim(),
args: parseArgsString(draft.args),
},
];
setIsAdding(false);
void saveProfiles(next);
},
[profiles, saveProfiles],
);
const handleEditOpen = useCallback(
(id: string) => {
const profile = profiles?.find((p) => p.id === id);
if (!profile) return;
setEditingProfile({
id,
draft: {
name: profile.name,
command: profile.command,
args: profile.args ? profile.args.join(" ") : "",
},
});
},
[profiles],
);
const handleEditClose = useCallback(() => setEditingProfile(null), []);
const handleEditSave = useCallback(
(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(),
args: parseArgsString(draft.args),
}
: p,
);
setEditingProfile(null);
void saveProfiles(next);
},
[editingProfile, profiles, saveProfiles],
);
const handleRemove = useCallback(
(id: string) => {
const profile = profiles?.find((p) => p.id === id);
if (!profile) return;
void confirmDialog({
title: t("settings.host.terminalProfiles.removeConfirmTitle"),
message: t("settings.host.terminalProfiles.removeConfirmMessage", {
name: profile.name,
}),
confirmLabel: t("settings.host.terminalProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}).then((confirmed) => {
if (!confirmed || !profiles) return;
void saveProfiles(profiles.filter((p) => p.id !== id));
return;
});
},
[profiles, saveProfiles, t],
);
const handleMoveUp = useCallback(
(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);
},
[profiles, saveProfiles],
);
const handleMoveDown = useCallback(
(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);
},
[profiles, saveProfiles],
);
const addButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addProfileIcon}
onPress={handleAddOpen}
disabled={isSaving || !isConnected || !profiles}
testID="terminal-profiles-add-button"
/>
),
[handleAddOpen, isSaving, isConnected, profiles],
);
if (!isConnected) {
return (
<View style={settingsStyles.card} testID="terminal-profiles-unavailable">
<View style={terminalProfileStyles.emptyCard}>
<Text style={terminalProfileStyles.emptyText}>
{t("settings.host.terminalProfiles.unavailable")}
</Text>
</View>
</View>
);
}
return (
<>
<SettingsSection
title={t("settings.host.terminalProfiles.sectionTitle")}
trailing={addButton}
testID="terminal-profiles-section"
>
<View style={settingsStyles.card} testID="terminal-profiles-card">
{profiles && profiles.length > 0 ? (
profiles.map((profile, index) => (
<TerminalProfileRow
key={profile.id}
profile={profile}
isFirst={index === 0}
isLast={index === profiles.length - 1}
onEdit={handleEditOpen}
onRemove={handleRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))
) : (
<View style={terminalProfileStyles.emptyCard}>
<Text style={terminalProfileStyles.emptyText}>
{t("settings.host.terminalProfiles.emptyState")}
</Text>
</View>
)}
</View>
</SettingsSection>
<TerminalProfileEditModal
visible={isAdding}
title={t("settings.host.terminalProfiles.addProfileTitle")}
initialDraft={EMPTY_PROFILE_DRAFT}
onClose={handleAddClose}
onSave={handleAddSave}
/>
{editingProfile ? (
<TerminalProfileEditModal
visible
title={t("settings.host.terminalProfiles.editProfileTitle")}
initialDraft={editingProfile.draft}
onClose={handleEditClose}
onSave={handleEditSave}
/>
) : null}
</>
);
}
export function HostTerminalsPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<TerminalProfilesSection serverId={serverId} />
</View>
);
}
const terminalProfileStyles = StyleSheet.create((theme) => ({
row: {
gap: theme.spacing[2],
minHeight: 56,
},
iconWrapper: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowActions: {
flexDirection: "row",
alignItems: "center",
gap: 0,
},
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
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) => ({ const styles = StyleSheet.create((theme) => ({
identityEditButton: { identityEditButton: {
padding: theme.spacing[1], padding: theme.spacing[1],
@@ -16,10 +16,19 @@ import {
upsertCreatedTerminalPayload, upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state"; } from "@/screens/workspace/terminals/state";
interface TerminalProfileInput {
name: string;
command: string;
args?: string[];
}
interface PendingTerminalCreateInput { interface PendingTerminalCreateInput {
paneId?: string; paneId?: string;
profile?: TerminalProfileInput;
} }
export type { TerminalProfileInput };
interface UseWorkspaceTerminalsInput { interface UseWorkspaceTerminalsInput {
client: DaemonClient | null; client: DaemonClient | null;
isConnected: boolean; isConnected: boolean;
@@ -110,6 +119,10 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
if (_input?.profile) {
const { name, command, args } = _input.profile;
return await client.createTerminal(workspaceDirectory, name, undefined, { command, args });
}
return await client.createTerminal(workspaceDirectory); return await client.createTerminal(workspaceDirectory);
}, },
onSuccess: (payload, createInput) => { onSuccess: (payload, createInput) => {
@@ -1,4 +1,4 @@
import { import React, {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -20,6 +20,7 @@ import {
CopyX, CopyX,
ArrowLeftToLine, ArrowLeftToLine,
ArrowRightToLine, ArrowRightToLine,
ChevronDown,
Columns2, Columns2,
Copy, Copy,
Pencil, Pencil,
@@ -33,6 +34,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter, type Href } from "expo-router";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
import type { import type {
DraggableListDragHandleProps, DraggableListDragHandleProps,
@@ -46,6 +48,14 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -67,6 +77,14 @@ import {
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { getProviderIcon } from "@/components/provider-icons";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -82,14 +100,33 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil); const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2); const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2); const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus); const ThemedPlus = withUnistyles(Plus);
const ThemedProfileSquareTerminal = withUnistyles(SquareTerminal);
interface DynamicProviderIconProps {
iconKey: string;
size: number;
color?: string;
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
} }
@@ -103,38 +140,161 @@ function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event:
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
} }
interface DropdownMenuProfileItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled?: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
}
function DropdownMenuProfileItem({
profile,
disabled,
onCreateTerminalWithProfile,
}: DropdownMenuProfileItemProps) {
const handleSelect = useCallback(() => {
onCreateTerminalWithProfile({
name: profile.name,
command: profile.command,
args: profile.args,
});
}, [onCreateTerminalWithProfile, profile]);
const icon = getTerminalProfileIcon(profile);
const leading = useMemo(() => {
if (!icon) {
return (
<View style={styles.terminalProfileIconWrapper}>
<ThemedProfileSquareTerminal size={14} uniProps={mutedColorMapping} />
</View>
);
}
return (
<View style={styles.terminalProfileIconWrapper}>
<ThemedDynamicProviderIcon iconKey={icon} size={14} uniProps={mutedColorMapping} />
</View>
);
}, [icon]);
return (
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
{profile.name}
</DropdownMenuItem>
);
}
interface WorkspaceInlineAddTabButtonProps { interface WorkspaceInlineAddTabButtonProps {
shortcutKeys: ShortcutKey[][] | null; shortcutKeys: ShortcutKey[][] | null;
onPress: () => void; onCreateAgentTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onEditProfiles: () => void;
normalizedServerId: string;
showCreateBrowserTab: boolean;
terminalDisabled: boolean;
onLayout: (event: LayoutChangeEvent) => void; onLayout: (event: LayoutChangeEvent) => void;
} }
function WorkspaceInlineAddTabButton({ function WorkspaceInlineAddTabButton({
shortcutKeys, shortcutKeys,
onPress, onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onCreateTerminalWithProfile,
onEditProfiles,
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
onLayout, onLayout,
}: WorkspaceInlineAddTabButtonProps) { }: WorkspaceInlineAddTabButtonProps) {
const { t } = useTranslation();
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const tooltipText = t("workspace.tabs.actions.newAgent");
return ( return (
<View style={styles.inlineAddButton} onLayout={onLayout}> <View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
testID="workspace-new-agent-tab-inline" testID="workspace-new-agent-tab-inline"
onPress={onPress} onPress={onCreateAgentTab}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="New agent tab" accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle} style={inlineAddActionButtonStyle}
> >
<ThemedPlus size={16} uniProps={mutedColorMapping} /> <ThemedPlus size={14} uniProps={mutedColorMapping} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text> <Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? ( {shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
testID="workspace-new-tab-menu-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.moreActions")}
style={inlineAddActionButtonStyle}
>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.moreActions")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}>
<DropdownMenuItem
testID="workspace-new-tab-menu-agent"
leading={AGENT_ICON}
onSelect={onCreateAgentTab}
>
{t("workspace.tabs.actions.newAgent")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-new-tab-menu-terminal"
leading={TERMINAL_ICON}
disabled={terminalDisabled}
onSelect={terminalDisabled ? undefined : onCreateTerminal}
>
{t("workspace.tabs.actions.newTerminal")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-new-tab-menu-browser"
leading={BROWSER_ICON}
onSelect={onCreateBrowser}
>
{t("workspace.tabs.actions.newBrowser")}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
{profiles.map((profile) => (
<DropdownMenuProfileItem
key={profile.id}
profile={profile}
disabled={terminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem testID="workspace-new-tab-menu-edit-profiles" onSelect={onEditProfiles}>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View> </View>
); );
} }
@@ -194,6 +354,40 @@ export interface WorkspaceDesktopTabRowItem {
isClosingTab: boolean; isClosingTab: boolean;
} }
interface SplitActionButtonProps {
onPress: () => void;
label: string;
shortcutKeys: ShortcutKey[][] | null;
icon: "split-right" | "split-down";
}
function SplitActionButton({ onPress, label, shortcutKeys, icon }: SplitActionButtonProps) {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
style={newTabActionButtonStyle}
>
{icon === "split-right" ? (
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
) : (
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{label}</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
);
}
interface WorkspaceDesktopTabsRowProps { interface WorkspaceDesktopTabsRowProps {
paneId?: string; paneId?: string;
isFocused?: boolean; isFocused?: boolean;
@@ -211,7 +405,7 @@ interface WorkspaceDesktopTabsRowProps {
onCloseTabsToRight: (tabId: string) => Promise<void> | void; onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean; showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean; disableCreateTerminal?: boolean;
@@ -539,8 +733,8 @@ export function WorkspaceDesktopTabsRow({
showPaneSplitActions = true, showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) { }: WorkspaceDesktopTabsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
@@ -641,19 +835,22 @@ export function WorkspaceDesktopTabsRow({
onCreateTerminalTab({ paneId }); onCreateTerminalTab({ paneId });
}, [onCreateTerminalTab, paneId]); }, [onCreateTerminalTab, paneId]);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => {
onCreateTerminalTab({ paneId, profile });
},
[onCreateTerminalTab, paneId],
);
const handleEditProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href);
}, [normalizedServerId, router]);
const handleCreateBrowser = useCallback(() => { const handleCreateBrowser = useCallback(() => {
onCreateBrowserTab({ paneId }); onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]); }, [onCreateBrowserTab, paneId]);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const newTerminalActionButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.newTabActionButton,
terminalDisabled && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
],
[terminalDisabled],
);
const renderTab = useCallback( const renderTab = useCallback(
({ ({
@@ -762,120 +959,32 @@ export function WorkspaceDesktopTabsRow({
/> />
<WorkspaceInlineAddTabButton <WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys} shortcutKeys={newTabKeys}
onPress={handleCreateAgentTab} onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
onLayout={handleInlineAddButtonLayout} onLayout={handleInlineAddButtonLayout}
/> />
</ScrollView> </ScrollView>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}> <View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={handleCreateAgentTab}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.newAgent")}
style={newTabActionButtonStyle}
>
<ThemedSquarePen size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.newAgent")}</Text>
{newTabKeys ? (
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal"
onPress={handleCreateTerminal}
disabled={terminalDisabled}
accessibilityRole="button"
accessibilityLabel={
isWaitingOnTerminalReadiness
? t("workspace.tabs.actions.preparingTerminal")
: t("workspace.tabs.actions.newTerminal")
}
style={newTerminalActionButtonStyle}
>
<ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{isWaitingOnTerminalReadiness
? t("workspace.tabs.actions.preparingTerminalTooltip")
: t("workspace.tabs.actions.newTerminal")}
</Text>
{newTerminalKeys ? (
<Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
{showCreateBrowserTab ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-browser"
onPress={handleCreateBrowser}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.newBrowser")}
style={newTabActionButtonStyle}
>
<ThemedGlobe size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.newBrowser")}
</Text>
</View>
</TooltipContent>
</Tooltip>
) : null}
{showPaneSplitActions ? ( {showPaneSplitActions ? (
<> <>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <SplitActionButton
<TooltipTrigger icon="split-right"
onPress={onSplitRight} onPress={onSplitRight}
accessibilityRole="button" label={t("workspace.tabs.actions.splitRight")}
accessibilityLabel={t("workspace.tabs.actions.splitRight")} shortcutKeys={splitRightKeys}
style={newTabActionButtonStyle} />
> <SplitActionButton
<ThemedColumns2 size={14} uniProps={mutedColorMapping} /> icon="split-down"
</TooltipTrigger> onPress={onSplitDown}
<TooltipContent side="bottom" align="center" offset={8}> label={t("workspace.tabs.actions.splitDown")}
<View style={styles.newTabTooltipRow}> shortcutKeys={splitDownKeys}
<Text style={styles.newTabTooltipText}> />
{t("workspace.tabs.actions.splitRight")}
</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.splitDown")}
style={newTabActionButtonStyle}
>
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.splitDown")}
</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</> </>
) : null} ) : null}
</View> </View>
@@ -1039,9 +1148,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
inlineAddButton: { inlineAddButton: {
paddingHorizontal: theme.spacing[1], flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", paddingHorizontal: theme.spacing[1],
}, },
tab: { tab: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -1179,6 +1288,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
terminalProfileIconWrapper: {
width: 14,
height: 14,
},
})); }));
const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore]; const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore];
@@ -50,6 +50,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
@@ -172,9 +173,22 @@ import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import {
buildHostRootRoute,
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
} from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import {
useWorkspaceTerminals,
type TerminalProfileInput,
} from "@/screens/workspace/terminals/use-workspace-terminals";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { getProviderIcon } from "@/components/provider-icons";
import { import {
createWorkspaceFileTabTarget, createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation, normalizeWorkspaceFileLocation,
@@ -241,6 +255,19 @@ const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight); const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon); const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
interface DynamicProviderIconProps {
iconKey: string;
size: number;
color?: string;
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -927,6 +954,7 @@ function useCloseTabs(): UseCloseTabsResult {
} }
interface WorkspaceHeaderMenuProps { interface WorkspaceHeaderMenuProps {
normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
currentBranchName: string | null; currentBranchName: string | null;
showWorkspaceSetup: boolean; showWorkspaceSetup: boolean;
@@ -942,12 +970,55 @@ interface WorkspaceHeaderMenuProps {
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
onOpenImportSheet: () => void; onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
onCopyBranchName: () => void; onCopyBranchName: () => void;
onOpenSetupTab: () => void; onOpenSetupTab: () => void;
} }
interface HeaderMenuProfileItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
}
function HeaderMenuProfileItem({
profile,
disabled,
onCreateTerminalWithProfile,
}: HeaderMenuProfileItemProps) {
const handleSelect = useCallback(() => {
onCreateTerminalWithProfile({
name: profile.name,
command: profile.command,
args: profile.args,
});
}, [onCreateTerminalWithProfile, profile]);
const icon = getTerminalProfileIcon(profile);
const leading = useMemo(() => {
if (!icon) {
return (
<View style={styles.headerMenuProfileIconWrapper}>
<ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />
</View>
);
}
return (
<View style={styles.headerMenuProfileIconWrapper}>
<ThemedDynamicProviderIcon iconKey={icon} size={16} uniProps={mutedColorMapping} />
</View>
);
}, [icon]);
return (
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
{profile.name}
</DropdownMenuItem>
);
}
function WorkspaceHeaderMenuTriggerIcon({ function WorkspaceHeaderMenuTriggerIcon({
hovered, hovered,
@@ -964,6 +1035,7 @@ function WorkspaceHeaderMenuTriggerIcon({
} }
function WorkspaceHeaderMenu({ function WorkspaceHeaderMenu({
normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
currentBranchName, currentBranchName,
showWorkspaceSetup, showWorkspaceSetup,
@@ -979,6 +1051,7 @@ function WorkspaceHeaderMenu({
menuSettingsIcon, menuSettingsIcon,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminal, onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser, onCreateBrowser,
onOpenImportSheet, onOpenImportSheet,
onCopyWorkspacePath, onCopyWorkspacePath,
@@ -986,6 +1059,17 @@ function WorkspaceHeaderMenu({
onOpenSetupTab, onOpenSetupTab,
}: WorkspaceHeaderMenuProps) { }: WorkspaceHeaderMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const handleEditProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href);
}, [normalizedServerId, router]);
const renderTriggerIcon = useCallback( const renderTriggerIcon = useCallback(
({ hovered, open }: { hovered: boolean; open: boolean }) => ( ({ hovered, open }: { hovered: boolean; open: boolean }) => (
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} /> <WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} />
@@ -1011,6 +1095,7 @@ function WorkspaceHeaderMenu({
> >
{t("workspace.header.actions.newAgent")} {t("workspace.header.actions.newAgent")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
testID="workspace-header-new-terminal" testID="workspace-header-new-terminal"
leading={menuNewTerminalIcon} leading={menuNewTerminalIcon}
@@ -1019,6 +1104,22 @@ function WorkspaceHeaderMenu({
> >
{t("workspace.header.actions.newTerminal")} {t("workspace.header.actions.newTerminal")}
</DropdownMenuItem> </DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-edit-terminal-profiles"
onSelect={handleEditProfiles}
>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{showCreateBrowserTab ? ( {showCreateBrowserTab ? (
<DropdownMenuItem <DropdownMenuItem
testID="workspace-header-new-browser" testID="workspace-header-new-browser"
@@ -1094,6 +1195,7 @@ interface WorkspaceHeaderTitleBarProps {
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
onOpenImportSheet: () => void; onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
@@ -1128,6 +1230,7 @@ function WorkspaceHeaderTitleBar({
menuSettingsIcon, menuSettingsIcon,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminal, onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser, onCreateBrowser,
onOpenImportSheet, onOpenImportSheet,
onCopyWorkspacePath, onCopyWorkspacePath,
@@ -1165,6 +1268,7 @@ function WorkspaceHeaderTitleBar({
)} )}
<View style={styles.compactHeaderMenuCluster}> <View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenu <WorkspaceHeaderMenu
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName} currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup} showWorkspaceSetup={showWorkspaceSetup}
@@ -1180,6 +1284,7 @@ function WorkspaceHeaderTitleBar({
menuSettingsIcon={menuSettingsIcon} menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal} onCreateTerminal={onCreateTerminal}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
onCreateBrowser={onCreateBrowser} onCreateBrowser={onCreateBrowser}
onOpenImportSheet={onOpenImportSheet} onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath} onCopyWorkspacePath={onCopyWorkspacePath}
@@ -2416,6 +2521,13 @@ function WorkspaceScreenContent({
const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => {
createTerminal({ profile });
},
[createTerminal],
);
const handleCreateBrowserTab = useCallback( const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => { (input?: { paneId?: string }) => {
if (!persistenceKey || !getIsElectron()) { if (!persistenceKey || !getIsElectron()) {
@@ -3508,6 +3620,7 @@ function WorkspaceScreenContent({
menuSettingsIcon={menuSettingsIcon} menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal} onCreateTerminal={handleCreateTerminal}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab} onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet} onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath} onCopyWorkspacePath={handleCopyWorkspacePath}
@@ -3845,6 +3958,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
headerMenuProfileIconWrapper: {
width: 16,
height: 16,
},
tabsContainer: { tabsContainer: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
+1
View File
@@ -403,6 +403,7 @@ export const HOST_SECTION_SLUGS = [
"agents", "agents",
"workspaces", "workspaces",
"providers", "providers",
"terminals",
"host", "host",
] as const; ] as const;
+14
View File
@@ -116,6 +116,18 @@ const MutableMetadataGenerationConfigSchema = z
}) })
.passthrough(); .passthrough();
export const TerminalProfileSchema = z
.object({
id: z.string(),
name: z.string(),
command: z.string(),
args: z.array(z.string()).optional(),
icon: z.string().optional(),
})
.passthrough();
export type TerminalProfile = z.infer<typeof TerminalProfileSchema>;
export const MutableDaemonConfigSchema = z export const MutableDaemonConfigSchema = z
.object({ .object({
mcp: z mcp: z
@@ -127,6 +139,7 @@ export const MutableDaemonConfigSchema = z
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
autoArchiveAfterMerge: z.boolean().default(false), autoArchiveAfterMerge: z.boolean().default(false),
appendSystemPrompt: z.string().default(""), appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
}) })
.passthrough(); .passthrough();
@@ -139,6 +152,7 @@ export const MutableDaemonConfigPatchSchema = z
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
autoArchiveAfterMerge: z.boolean().optional(), autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(), appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
}) })
.partial() .partial()
.passthrough(); .passthrough();
@@ -0,0 +1,52 @@
export const BUILTIN_PROVIDER_ICON_NAMES = [
"claude",
"codex",
"copilot",
"kiro",
"omp",
"opencode",
"pi",
];
export const ACP_PROVIDER_ICON_NAMES = [
"agoragentic-acp",
"amp-acp",
"auggie",
"autohand",
"cline",
"codebuddy-code",
"codewhale",
"cortex-code",
"corust-agent",
"crow-cli",
"cursor",
"deepagents",
"dimcode",
"dirac",
"factory-droid",
"fast-agent",
"gemini",
"glm-acp-agent",
"goose",
"grok",
"junie",
"kilo",
"kimi",
"minion-code",
"mistral-vibe",
"nova",
"poolside",
"qoder",
"qwen-code",
"sigit",
"stakpak",
"vtcode",
];
export const TERMINAL_PROFILE_ICON_NAMES = ["agy"];
export const KNOWN_PROVIDER_ICON_NAMES = [
...BUILTIN_PROVIDER_ICON_NAMES,
...ACP_PROVIDER_ICON_NAMES,
...TERMINAL_PROFILE_ICON_NAMES,
];
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TERMINAL_PROFILES,
getTerminalProfileIcon,
guessTerminalProfileIcon,
resolveTerminalProfiles,
} from "./terminal-profiles.js";
describe("resolveTerminalProfiles", () => {
it("returns defaults when undefined", () => {
const result = resolveTerminalProfiles(undefined);
expect(result).toBe(DEFAULT_TERMINAL_PROFILES);
});
it("returns an empty array as-is when defined as empty", () => {
const result = resolveTerminalProfiles([]);
expect(result).toEqual([]);
});
it("returns a custom array as-is", () => {
const custom = [{ id: "zsh", name: "Zsh", command: "zsh" }];
const result = resolveTerminalProfiles(custom);
expect(result).toBe(custom);
});
it("default profiles include claude, codex, opencode", () => {
const ids = DEFAULT_TERMINAL_PROFILES.map((p) => p.id);
expect(ids).toContain("claude");
expect(ids).toContain("codex");
expect(ids).toContain("opencode");
});
it("claude profile has the correct icon", () => {
const claude = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "claude");
expect(claude?.icon).toBe("claude");
});
it("codex profile has the correct icon", () => {
const codex = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "codex");
expect(codex?.icon).toBe("codex");
});
it("opencode profile has the correct icon", () => {
const opencode = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "opencode");
expect(opencode?.icon).toBe("opencode");
});
});
describe("guessTerminalProfileIcon", () => {
it.each([
["claude", "claude"],
["codex", "codex"],
["opencode", "opencode"],
["copilot", "copilot"],
["kiro", "kiro"],
["omp", "omp"],
["pi", "pi"],
["gemini", "gemini"],
["cursor", "cursor"],
["goose", "goose"],
["grok", "grok"],
["kimi", "kimi"],
["Claude", "claude"],
["CODEX", "codex"],
["/usr/local/bin/claude", "claude"],
["C:\\\\Program Files\\\\Codex\\\\codex.exe", "codex"],
["/usr/local/bin/gemini", "gemini"],
["agy", "agy"],
["/usr/local/bin/agy", "agy"],
])("guesses %s -> %s", (command, expected) => {
expect(guessTerminalProfileIcon(command)).toBe(expected);
});
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-agent"]])(
"returns undefined for unknown command %s",
(command) => {
expect(guessTerminalProfileIcon(command)).toBeUndefined();
},
);
});
describe("getTerminalProfileIcon", () => {
it("returns the explicit icon when set", () => {
const profile = { id: "1", name: "Foo", command: "zsh", icon: "claude" };
expect(getTerminalProfileIcon(profile)).toBe("claude");
});
it("guesses the icon when none is set", () => {
const profile = { id: "1", name: "Foo", command: "claude" };
expect(getTerminalProfileIcon(profile)).toBe("claude");
});
it("returns undefined when no icon is set and command is unknown", () => {
const profile = { id: "1", name: "Foo", command: "zsh" };
expect(getTerminalProfileIcon(profile)).toBeUndefined();
});
});
@@ -0,0 +1,36 @@
import type { TerminalProfile } from "./messages.js";
import { KNOWN_PROVIDER_ICON_NAMES } from "./provider-icon-names.js";
export const DEFAULT_TERMINAL_PROFILES: readonly TerminalProfile[] = [
{ id: "claude", name: "Claude Code", command: "claude", icon: "claude" },
{ id: "codex", name: "Codex", command: "codex", icon: "codex" },
{ id: "opencode", name: "OpenCode", command: "opencode", icon: "opencode" },
];
const WELL_KNOWN_COMMAND_ICONS = new Map(KNOWN_PROVIDER_ICON_NAMES.map((name) => [name, name]));
function getCommandBaseName(command: string): string {
const lastSlash = command.lastIndexOf("/");
const lastBackslash = command.lastIndexOf("\\");
const start = Math.max(lastSlash, lastBackslash) + 1;
const base = command.slice(start).toLowerCase();
const dotIndex = base.indexOf(".");
return dotIndex > 0 ? base.slice(0, dotIndex) : base;
}
export function guessTerminalProfileIcon(command: string): string | undefined {
return WELL_KNOWN_COMMAND_ICONS.get(getCommandBaseName(command));
}
export function getTerminalProfileIcon(profile: TerminalProfile): string | undefined {
return profile.icon ?? guessTerminalProfileIcon(profile.command);
}
export function resolveTerminalProfiles(
terminalProfiles: TerminalProfile[] | undefined,
): readonly TerminalProfile[] {
if (terminalProfiles === undefined) {
return DEFAULT_TERMINAL_PROFILES;
}
return terminalProfiles;
}
+5
View File
@@ -121,6 +121,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js"; import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js"; import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type { import type {
AgentProviderRuntimeSettingsMap, AgentProviderRuntimeSettingsMap,
ProviderOverride, ProviderOverride,
@@ -235,6 +236,7 @@ export interface PaseoDaemonConfig {
mcpInjectIntoAgents?: boolean; mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean; autoArchiveAfterMerge?: boolean;
appendSystemPrompt?: string; appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
staticDir: string; staticDir: string;
mcpDebug: boolean; mcpDebug: boolean;
isDev?: boolean; isDev?: boolean;
@@ -310,6 +312,9 @@ export async function createPaseoDaemon(
}, },
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "", appendSystemPrompt: config.appendSystemPrompt ?? "",
...(config.terminalProfiles !== undefined
? { terminalProfiles: config.terminalProfiles }
: {}),
}, },
logger, logger,
); );
+3
View File
@@ -327,6 +327,7 @@ function resolveStaticLoadConfigSettings(
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted), appendSystemPrompt: resolveAppendSystemPrompt(persisted),
terminalProfiles: persisted.daemon?.terminalProfiles,
hostnames: mergeHostnames([ hostnames: mergeHostnames([
persisted.daemon?.hostnames, persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -352,6 +353,7 @@ export function loadConfig(
mcpInjectIntoAgents, mcpInjectIntoAgents,
autoArchiveAfterMerge, autoArchiveAfterMerge,
appendSystemPrompt, appendSystemPrompt,
terminalProfiles,
hostnames, hostnames,
appBaseUrl, appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted); } = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
@@ -385,6 +387,7 @@ export function loadConfig(
mcpInjectIntoAgents, mcpInjectIntoAgents,
autoArchiveAfterMerge, autoArchiveAfterMerge,
appendSystemPrompt, appendSystemPrompt,
terminalProfiles,
mcpDebug: env.MCP_DEBUG === "1", mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development", isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
@@ -210,6 +210,9 @@ function mergeMutableConfigIntoPersistedConfig(params: {
}, },
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
appendSystemPrompt: mutable.appendSystemPrompt, appendSystemPrompt: mutable.appendSystemPrompt,
...(mutable.terminalProfiles !== undefined
? { terminalProfiles: mutable.terminalProfiles }
: {}),
}, },
agents: nextAgents, agents: nextAgents,
} as PersistedConfig; } as PersistedConfig;
@@ -9,6 +9,7 @@ import {
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
export const LogFormatSchema = z.enum(["pretty", "json"]); export const LogFormatSchema = z.enum(["pretty", "json"]);
@@ -223,6 +224,7 @@ export const PersistedConfigSchema = z
.optional(), .optional(),
autoArchiveAfterMerge: z.boolean().optional(), autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(), appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
cors: z cors: z
.object({ .object({
allowedOrigins: z.array(z.string()).optional(), allowedOrigins: z.array(z.string()).optional(),