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

View File

@@ -69,34 +69,46 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── 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> {
await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
/** Click the inline plus button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<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 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> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
@@ -117,9 +129,9 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
/** Assert the inline new-agent plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<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();
expect(count).toBeGreaterThanOrEqual(1);
}

View File

@@ -21,7 +21,7 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({
timeout: 30_000,
});
}

View File

@@ -2,7 +2,7 @@ import { test, expect } from "./fixtures";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertTerminalTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
pressNewTabShortcut,
clickNewChat,
@@ -91,7 +91,7 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
});

View File

@@ -181,7 +181,7 @@ test.describe("Workspace agent title handoff", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").click();
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff";

View File

@@ -1,6 +1,7 @@
// Vendored ACP provider SVG assets.
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":
'<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":

View File

@@ -1,4 +1,10 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_PROVIDER_ICON_NAMES,
KNOWN_PROVIDER_ICON_NAMES,
TERMINAL_PROFILE_ICON_NAMES,
} from "@getpaseo/protocol/provider-icon-names";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "./provider-icon-name";
describe("resolveProviderIconName", () => {
@@ -10,9 +16,34 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
});
it("falls back to the bot icon for unknown custom providers", () => {
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
});
});
describe("known provider icon names", () => {
it("includes every ACP catalog entry that ships an icon", () => {
const known = new Set(KNOWN_PROVIDER_ICON_NAMES);
for (const entry of ACP_PROVIDER_CATALOG) {
if (entry.iconSvg) {
expect(known).toContain(entry.id);
}
}
});
it("only lists ACP icon ids that have a catalog entry with an icon", () => {
const builtin = new Set(BUILTIN_PROVIDER_ICON_NAMES);
const terminalOnly = new Set(TERMINAL_PROFILE_ICON_NAMES);
const catalogIdsWithIcons = new Set(
ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg).map((entry) => entry.id),
);
for (const name of KNOWN_PROVIDER_ICON_NAMES) {
if (!builtin.has(name) && !terminalOnly.has(name)) {
expect(catalogIdsWithIcons).toContain(name);
}
}
});
});

View File

@@ -1,38 +1,21 @@
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
export type BuiltinProviderIconName =
| "claude"
| "codex"
| "copilot"
| "kiro"
| "omp"
| "opencode"
| "pi";
import {
BUILTIN_PROVIDER_ICON_NAMES,
KNOWN_PROVIDER_ICON_NAMES,
} from "@getpaseo/protocol/provider-icon-names";
export type ProviderIconName =
| { kind: "builtin"; id: BuiltinProviderIconName }
| { kind: "builtin"; id: string }
| { kind: "catalog"; id: string }
| { kind: "bot" };
const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
"claude",
"codex",
"copilot",
"kiro",
"omp",
"opencode",
"pi",
]);
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = new Set(
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
);
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
export function resolveProviderIconName(provider: string): ProviderIconName {
if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) {
return { kind: "builtin", id: provider as BuiltinProviderIconName };
if (BUILTIN_PROVIDER_IDS.has(provider)) {
return { kind: "builtin", id: provider };
}
if (CATALOG_ICON_PROVIDER_IDS.has(provider)) {
if (KNOWN_PROVIDER_IDS.has(provider)) {
return { kind: "catalog", id: provider };
}
return { kind: "bot" };

View File

@@ -8,10 +8,7 @@ import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import {
resolveProviderIconName,
type BuiltinProviderIconName,
} from "@/components/provider-icon-name";
import { resolveProviderIconName } from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
@@ -20,7 +17,7 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconComponent> = {
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
@@ -53,7 +50,10 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
if (cached) {
return cached;
}
const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? "";
const iconSvg = CATALOG_ICON_SVGS.get(provider);
if (!iconSvg) {
return Bot;
}
const icon = createCatalogIcon(provider, iconSvg);
catalogIconComponents.set(provider, icon);
return icon;

View File

@@ -56,6 +56,7 @@ import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -92,7 +93,7 @@ interface SplitContainerProps {
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: {

View File

@@ -901,8 +901,6 @@ const styles = StyleSheet.create((theme) => ({
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
separator: {
height: 1,

View File

@@ -416,7 +416,7 @@ export const ar: TranslationResources = {
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: {
newAgent: "جديد Agent",
newAgent: "وكيل جديد",
setup: "يثبت",
workspaceSetup: "إعداد Workspace",
terminal: "Terminal",
@@ -446,13 +446,15 @@ export const ar: TranslationResources = {
renameAgent: "إعادة تسمية الوكيل",
},
actions: {
newAgent: "علامة تبويب الوكيل الجديد",
newTerminal: "علامة تبويب طرفية جديدة",
newAgent: "وكيل جديد",
newTerminal: "محطة جديدة",
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
preparingTerminalTooltip: "جارٍ تحضير المحطة...",
newBrowser: "علامة تبويب متصفح جديدة",
newBrowser: "متصفح جديد",
splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "افتح المستكشف",
@@ -1271,7 +1273,7 @@ export const ar: TranslationResources = {
},
panels: {
draft: {
newAgent: "جديد Agent",
newAgent: "وكيل جديد",
creatingAgent: "وكيل الخلق",
},
file: {
@@ -1351,6 +1353,7 @@ export const ar: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1617,6 +1620,27 @@ export const ar: TranslationResources = {
workspaces: {
unavailable: "Connect to this host to manage workspaces",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "تحرير التسمية",

View File

@@ -415,7 +415,7 @@ export const en = {
loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.",
fallback: {
newAgent: "New Agent",
newAgent: "New agent",
setup: "Setup",
workspaceSetup: "Workspace setup",
terminal: "Terminal",
@@ -445,13 +445,15 @@ export const en = {
renameAgent: "Rename agent",
},
actions: {
newAgent: "New agent tab",
newTerminal: "New terminal tab",
newAgent: "New agent",
newTerminal: "New terminal",
preparingTerminal: "Preparing terminal tab",
preparingTerminalTooltip: "Preparing terminal...",
newBrowser: "New browser tab",
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Open explorer",
@@ -1357,6 +1359,7 @@ export const en = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Providers",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1624,6 +1627,27 @@ export const en = {
workspaces: {
unavailable: "Connect to this host to manage workspaces",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "Edit label",

View File

@@ -419,7 +419,7 @@ export const es: TranslationResources = {
loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.",
fallback: {
newAgent: "NuevoAgent",
newAgent: "Nuevo agente",
setup: "Configuración",
workspaceSetup: "Configuración deWorkspace",
terminal: "Terminal",
@@ -450,13 +450,15 @@ export const es: TranslationResources = {
renameAgent: "Cambiar nombre del agente",
},
actions: {
newAgent: "Nueva pestaña de agente",
newTerminal: "Nueva pestaña de terminal",
newAgent: "Nuevo agente",
newTerminal: "Nueva terminal",
preparingTerminal: "Preparando la pestaña del terminal",
preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Nueva pestaña del navegador",
newBrowser: "Nuevo navegador",
splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Explorador abierto",
@@ -1306,7 +1308,7 @@ export const es: TranslationResources = {
},
panels: {
draft: {
newAgent: "NuevoAgent",
newAgent: "Nuevo agente",
creatingAgent: "Agente creador",
},
file: {
@@ -1386,6 +1388,7 @@ export const es: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Proveedores",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1654,6 +1657,27 @@ export const es: TranslationResources = {
workspaces: {
unavailable: "Connect to this host to manage workspaces",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "Editar etiqueta",

View File

@@ -419,7 +419,7 @@ export const fr: TranslationResources = {
loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.",
fallback: {
newAgent: "NouveauAgent",
newAgent: "Nouvel agent",
setup: "Installation",
workspaceSetup: "ConfigurationWorkspace",
terminal: "Terminal",
@@ -450,13 +450,15 @@ export const fr: TranslationResources = {
renameAgent: "Renommer l'agent",
},
actions: {
newAgent: "Nouvel onglet agent",
newTerminal: "Nouvel onglet de terminal",
newAgent: "Nouvel agent",
newTerminal: "Nouveau terminal",
preparingTerminal: "Préparation de l'onglet du terminal",
preparingTerminalTooltip: "Préparation du terminal...",
newBrowser: "Nouvel onglet du navigateur",
newBrowser: "Nouveau navigateur",
splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Ouvrir l'explorateur",
@@ -1309,7 +1311,7 @@ export const fr: TranslationResources = {
},
panels: {
draft: {
newAgent: "NouveauAgent",
newAgent: "Nouvel agent",
creatingAgent: "Agent créateur",
},
file: {
@@ -1389,6 +1391,7 @@ export const fr: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Fournisseurs",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1660,6 +1663,27 @@ export const fr: TranslationResources = {
workspaces: {
unavailable: "Connect to this host to manage workspaces",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "Modifier l'étiquette",

View File

@@ -419,7 +419,7 @@ export const ru: TranslationResources = {
loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.",
fallback: {
newAgent: "Новый Agent",
newAgent: "Новый агент",
setup: "Настраивать",
workspaceSetup: "Настройка Workspace",
terminal: "Terminal",
@@ -449,13 +449,15 @@ export const ru: TranslationResources = {
renameAgent: "Переименовать агента",
},
actions: {
newAgent: "Новая вкладка агента",
newTerminal: "Новая вкладка терминала",
newAgent: "Новый агент",
newTerminal: "Новый терминал",
preparingTerminal: "Подготовка вкладки терминала",
preparingTerminalTooltip: "Подготовка терминала...",
newBrowser: "Новая вкладка браузера",
newBrowser: "Новый браузер",
splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Открыть проводник",
@@ -1298,7 +1300,7 @@ export const ru: TranslationResources = {
},
panels: {
draft: {
newAgent: "Новый Agent",
newAgent: "Новый агент",
creatingAgent: "Создание агента",
},
file: {
@@ -1378,6 +1380,7 @@ export const ru: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Провайдеры",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1648,6 +1651,27 @@ export const ru: TranslationResources = {
workspaces: {
unavailable: "Connect to this host to manage workspaces",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "Изменить ярлык",

View File

@@ -446,13 +446,15 @@ export const zhCN: TranslationResources = {
renameAgent: "重命名 Agent",
},
actions: {
newAgent: "新建 Agent 标签",
newTerminal: "新建 Terminal 标签",
newAgent: "新建 Agent",
newTerminal: "新建 Terminal",
preparingTerminal: "正在准备 Terminal 标签",
preparingTerminalTooltip: "正在准备 Terminal...",
newBrowser: "新建浏览器标签",
newBrowser: "新建浏览器",
splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "打开 explorer",
@@ -1334,6 +1336,7 @@ export const zhCN: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Providers",
terminals: "Terminals",
host: "Host",
},
general: {
@@ -1599,6 +1602,27 @@ export const zhCN: TranslationResources = {
workspaces: {
unavailable: "连接到这个 Host 以管理 Workspace",
},
terminalProfiles: {
unavailable: "Connect to this host to manage terminal profiles",
sectionTitle: "Terminal profiles",
editProfile: "Edit profile",
addProfileTitle: "Add terminal profile",
editProfileTitle: "Edit terminal profile",
namePlaceholder: "Claude Code",
commandPlaceholder: "claude",
argsPlaceholder: "--dangerously-skip-permissions",
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
argsHint: "Space-separated arguments passed to the command",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',
moveUp: "Move up",
moveDown: "Move down",
save: "Save",
emptyState: "No profiles yet. Add one to launch terminals with a specific command.",
},
daemon: {
rename: {
editLabel: "编辑标签",

View File

@@ -41,6 +41,7 @@ import {
Puzzle,
Plus,
FolderGit2,
SquareTerminal,
} from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
@@ -107,6 +108,7 @@ import {
HostSettingsPage,
HostProvidersPage,
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
@@ -173,6 +175,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
];
@@ -189,6 +192,8 @@ function renderHostSettingsContent(
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "terminals":
return <HostTerminalsPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
}

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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
@@ -33,6 +50,38 @@ import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons";
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
const ThemedProfilePencil = withUnistyles(Pencil);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedProfileSquareTerminal = withUnistyles(SquareTerminal);
const ThemedPlus = withUnistyles(Plus);
interface DynamicProviderIconProps {
iconKey: string;
size: number;
color?: string;
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <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 {
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) => ({
identityEditButton: {
padding: theme.spacing[1],

View File

@@ -16,10 +16,19 @@ import {
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
interface TerminalProfileInput {
name: string;
command: string;
args?: string[];
}
interface PendingTerminalCreateInput {
paneId?: string;
profile?: TerminalProfileInput;
}
export type { TerminalProfileInput };
interface UseWorkspaceTerminalsInput {
client: DaemonClient | null;
isConnected: boolean;
@@ -110,6 +119,10 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
if (!client || !workspaceDirectory) {
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);
},
onSuccess: (payload, createInput) => {

View File

@@ -1,4 +1,4 @@
import {
import React, {
useCallback,
useEffect,
useMemo,
@@ -20,6 +20,7 @@ import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
ChevronDown,
Columns2,
Copy,
Pencil,
@@ -33,6 +34,7 @@ import {
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useRouter, type Href } from "expo-router";
import { SortableInlineList } from "@/components/sortable-inline-list";
import type {
DraggableListDragHandleProps,
@@ -46,6 +48,14 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -67,6 +77,14 @@ import {
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
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 LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -82,14 +100,33 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
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 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) {
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));
}
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 {
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;
}
function WorkspaceInlineAddTabButton({
shortcutKeys,
onPress,
onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onCreateTerminalWithProfile,
onEditProfiles,
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
onLayout,
}: WorkspaceInlineAddTabButtonProps) {
const { t } = useTranslation();
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const tooltipText = t("workspace.tabs.actions.newAgent");
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab-inline"
onPress={onPress}
onPress={onCreateAgentTab}
accessibilityRole="button"
accessibilityLabel="New agent tab"
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={16} uniProps={mutedColorMapping} />
<ThemedPlus size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</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>
);
}
@@ -194,6 +354,40 @@ export interface WorkspaceDesktopTabRowItem {
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 {
paneId?: string;
isFocused?: boolean;
@@ -211,7 +405,7 @@ interface WorkspaceDesktopTabsRowProps {
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean;
@@ -539,8 +733,8 @@ export function WorkspaceDesktopTabsRow({
showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
@@ -641,19 +835,22 @@ export function WorkspaceDesktopTabsRow({
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(() => {
onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const newTerminalActionButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.newTabActionButton,
terminalDisabled && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
],
[terminalDisabled],
);
const renderTab = useCallback(
({
@@ -762,120 +959,32 @@ export function WorkspaceDesktopTabsRow({
/>
<WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys}
onPress={handleCreateAgentTab}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
onLayout={handleInlineAddButtonLayout}
/>
</ScrollView>
<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 ? (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.splitRight")}
style={newTabActionButtonStyle}
>
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<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>
<SplitActionButton
icon="split-right"
onPress={onSplitRight}
label={t("workspace.tabs.actions.splitRight")}
shortcutKeys={splitRightKeys}
/>
<SplitActionButton
icon="split-down"
onPress={onSplitDown}
label={t("workspace.tabs.actions.splitDown")}
shortcutKeys={splitDownKeys}
/>
</>
) : null}
</View>
@@ -1039,9 +1148,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
},
inlineAddButton: {
paddingHorizontal: theme.spacing[1],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[1],
},
tab: {
paddingHorizontal: theme.spacing[3],
@@ -1179,6 +1288,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
terminalProfileIconWrapper: {
width: 14,
height: 14,
},
}));
const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore];

View File

@@ -50,6 +50,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -172,9 +173,22 @@ import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
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 { 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 {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
@@ -241,6 +255,19 @@ const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
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 mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -927,6 +954,7 @@ function useCloseTabs(): UseCloseTabsResult {
}
interface WorkspaceHeaderMenuProps {
normalizedServerId: string;
normalizedWorkspaceId: string;
currentBranchName: string | null;
showWorkspaceSetup: boolean;
@@ -942,12 +970,55 @@ interface WorkspaceHeaderMenuProps {
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => 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({
hovered,
@@ -964,6 +1035,7 @@ function WorkspaceHeaderMenuTriggerIcon({
}
function WorkspaceHeaderMenu({
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
showWorkspaceSetup,
@@ -979,6 +1051,7 @@ function WorkspaceHeaderMenu({
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
@@ -986,6 +1059,17 @@ function WorkspaceHeaderMenu({
onOpenSetupTab,
}: WorkspaceHeaderMenuProps) {
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(
({ hovered, open }: { hovered: boolean; open: boolean }) => (
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} isMobile={isMobile} />
@@ -1011,6 +1095,7 @@ function WorkspaceHeaderMenu({
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={menuNewTerminalIcon}
@@ -1019,6 +1104,22 @@ function WorkspaceHeaderMenu({
>
{t("workspace.header.actions.newTerminal")}
</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 ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
@@ -1094,6 +1195,7 @@ interface WorkspaceHeaderTitleBarProps {
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
@@ -1128,6 +1230,7 @@ function WorkspaceHeaderTitleBar({
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
@@ -1165,6 +1268,7 @@ function WorkspaceHeaderTitleBar({
)}
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenu
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
@@ -1180,6 +1284,7 @@ function WorkspaceHeaderTitleBar({
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
onCreateBrowser={onCreateBrowser}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
@@ -2416,6 +2521,13 @@ function WorkspaceScreenContent({
const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => {
createTerminal({ profile });
},
[createTerminal],
);
const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => {
if (!persistenceKey || !getIsElectron()) {
@@ -3508,6 +3620,7 @@ function WorkspaceScreenContent({
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
@@ -3845,6 +3958,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
headerMenuProfileIconWrapper: {
width: 16,
height: 16,
},
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,

View File

@@ -403,6 +403,7 @@ export const HOST_SECTION_SLUGS = [
"agents",
"workspaces",
"providers",
"terminals",
"host",
] as const;

View File

@@ -116,6 +116,18 @@ const MutableMetadataGenerationConfigSchema = z
})
.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
.object({
mcp: z
@@ -127,6 +139,7 @@ export const MutableDaemonConfigSchema = z
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
autoArchiveAfterMerge: z.boolean().default(false),
appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
})
.passthrough();
@@ -139,6 +152,7 @@ export const MutableDaemonConfigPatchSchema = z
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
})
.partial()
.passthrough();

View File

@@ -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,
];

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -121,6 +121,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -235,6 +236,7 @@ export interface PaseoDaemonConfig {
mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean;
appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -310,6 +312,9 @@ export async function createPaseoDaemon(
},
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "",
...(config.terminalProfiles !== undefined
? { terminalProfiles: config.terminalProfiles }
: {}),
},
logger,
);

View File

@@ -327,6 +327,7 @@ function resolveStaticLoadConfigSettings(
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
terminalProfiles: persisted.daemon?.terminalProfiles,
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -352,6 +353,7 @@ export function loadConfig(
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
hostnames,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
@@ -385,6 +387,7 @@ export function loadConfig(
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),

View File

@@ -210,6 +210,9 @@ function mergeMutableConfigIntoPersistedConfig(params: {
},
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
appendSystemPrompt: mutable.appendSystemPrompt,
...(mutable.terminalProfiles !== undefined
? { terminalProfiles: mutable.terminalProfiles }
: {}),
},
agents: nextAgents,
} as PersistedConfig;

View File

@@ -9,6 +9,7 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.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 LogFormatSchema = z.enum(["pretty", "json"]);
@@ -223,6 +224,7 @@ export const PersistedConfigSchema = z
.optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).optional(),