mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add configurable terminal profiles
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "تحرير التسمية",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Изменить ярлык",
|
||||
|
||||
@@ -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: "编辑标签",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -403,6 +403,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"agents",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"terminals",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
52
packages/protocol/src/provider-icon-names.ts
Normal file
52
packages/protocol/src/provider-icon-names.ts
Normal 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,
|
||||
];
|
||||
97
packages/protocol/src/terminal-profiles.test.ts
Normal file
97
packages/protocol/src/terminal-profiles.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
packages/protocol/src/terminal-profiles.ts
Normal file
36
packages/protocol/src/terminal-profiles.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -210,6 +210,9 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
},
|
||||
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
||||
appendSystemPrompt: mutable.appendSystemPrompt,
|
||||
...(mutable.terminalProfiles !== undefined
|
||||
? { terminalProfiles: mutable.terminalProfiles }
|
||||
: {}),
|
||||
},
|
||||
agents: nextAgents,
|
||||
} as PersistedConfig;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user