diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index c0678edcd..df0756eff 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -10,7 +10,6 @@ import { Play, Pause, RotateCw, - Terminal, Copy, FileText, Smartphone, @@ -24,7 +23,6 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, - getCliSymlinkInstructions, getDesktopDaemonLogs, getDesktopDaemonPairing, getDesktopDaemonStatus, @@ -32,7 +30,6 @@ import { shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, - type CliSymlinkInstructions, type DesktopDaemonLogs, type DesktopDaemonStatus, type DesktopPairingOffer, @@ -52,17 +49,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [statusError, setStatusError] = useState(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); - const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [cliStatusMessage, setCliStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); - const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false); const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [pairingOffer, setPairingOffer] = useState(null); - const [cliSymlinkInstructions, setCliSymlinkInstructions] = - useState(null); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); @@ -223,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD updateSettings, ]); - const handleOpenCliSymlinkInstructions = useCallback(() => { - if (!showSection || isLoadingCliSymlinkInstructions) { - return; - } - setIsLoadingCliSymlinkInstructions(true); - setCliStatusMessage(null); - void getCliSymlinkInstructions() - .then((instructions) => { - setCliSymlinkInstructions(instructions); - setIsCliSymlinkModalOpen(true); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`); - }) - .finally(() => { - setIsLoadingCliSymlinkInstructions(false); - }); - }, [isLoadingCliSymlinkInstructions, showSection]); - - const handleCopyCliSymlinkCommands = useCallback(() => { - if (!cliSymlinkInstructions?.commands) { - return; - } - void Clipboard.setStringAsync(cliSymlinkInstructions.commands) - .then(() => { - Alert.alert("Copied", "CLI symlink commands copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy CLI symlink commands", error); - Alert.alert("Error", "Unable to copy CLI symlink commands."); - }); - }, [cliSymlinkInstructions?.commands]); - const handleCopyLogPath = useCallback(() => { const logPath = daemonLogs?.logPath; if (!logPath) { @@ -353,14 +311,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD return ( - - Built-in daemon + + Built-in daemon - - - Status - Only the built-in desktop daemon is shown here. + + + Status + Only the built-in desktop daemon is shown here. {daemonStatusStateText} @@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {showLifecycleControls ? ( <> - - - Daemon management - + + + Daemon management + {isDaemonManagementPaused ? "Paused. The built-in daemon stays stopped until you start it again." : "Enabled. Paseo can manage the built-in daemon from the desktop app."} @@ -411,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD : "Pause"} - - - {daemonActionLabel} - {daemonActionMessage} + + + {daemonActionLabel} + {daemonActionMessage} {statusMessage ? {statusMessage} : null} - - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} + + + Log file + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( @@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. + + + Pair device + Connect your phone to this computer. - - - - - setIsPairingModalOpen(false)} @@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD snapPoints={["70%", "92%"]} > - {daemonLogs?.logPath ?? "Log path unavailable."} + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} @@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: { return ( - Loading pairing offer… + Loading pairing offer… ); } @@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: { if (statusMessage) { return ( - {statusMessage} + {statusMessage} ); } @@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: { if (!pairingOffer?.url) { return ( - Pairing offer unavailable. + Pairing offer unavailable. ); } return ( - + Scan this QR code in Paseo, or copy the pairing link below. {qrDataUrl ? ( ) : qrError ? ( - QR unavailable: {qrError} + QR unavailable: {qrError} ) : ( )} @@ -703,37 +618,6 @@ function PairingOfferDialogContent(input: { } const styles = StyleSheet.create((theme) => ({ - sectionHeader: { - alignItems: "center", - flexDirection: "row", - justifyContent: "space-between", - marginBottom: theme.spacing[3], - marginLeft: theme.spacing[1], - }, - sectionLink: { - alignItems: "center", - flexDirection: "row", - gap: theme.spacing[1], - }, - sectionLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - row: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - rowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - rowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, actionGroup: { flexDirection: "row", gap: theme.spacing[2], @@ -744,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "flex-end", gap: 2, }, - rowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, @@ -757,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, - hintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, statusText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx new file mode 100644 index 000000000..cfc1b8510 --- /dev/null +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -0,0 +1,189 @@ +import { useCallback, useState } from "react"; +import { Text, View } from "react-native"; +import { useFocusEffect } from "@react-navigation/native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { + shouldUseDesktopDaemon, + getCliInstallStatus, + installCli, + getSkillsInstallStatus, + installSkills, + type InstallStatus, +} from "@/desktop/daemon/desktop-daemon"; + +const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; +const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; + +export function IntegrationsSection() { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + + const [cliStatus, setCliStatus] = useState(null); + const [skillsStatus, setSkillsStatus] = useState(null); + const [isInstallingCli, setIsInstallingCli] = useState(false); + const [isInstallingSkills, setIsInstallingSkills] = useState(false); + + const loadStatus = useCallback(() => { + if (!showSection) return; + void getCliInstallStatus() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to load CLI status", error); + }); + void getSkillsInstallStatus() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to load skills status", error); + }); + }, [showSection]); + + useFocusEffect( + useCallback(() => { + if (!showSection) return undefined; + loadStatus(); + return undefined; + }, [loadStatus, showSection]), + ); + + const handleInstallCli = useCallback(() => { + if (isInstallingCli) return; + setIsInstallingCli(true); + void installCli() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to install CLI", error); + }) + .finally(() => { + setIsInstallingCli(false); + }); + }, [isInstallingCli]); + + const handleInstallSkills = useCallback(() => { + if (isInstallingSkills) return; + setIsInstallingSkills(true); + void installSkills() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to install skills", error); + }) + .finally(() => { + setIsInstallingSkills(false); + }); + }, [isInstallingSkills]); + + if (!showSection) { + return null; + } + + return ( + + + Integrations + + + + + + + + + + + Command line + + + Control and script agents from your terminal. + + + {cliStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + + + Orchestration skills + + + Teach your agents to orchestrate through the CLI. + + + {skillsStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerLinks: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[0], + }, + rowTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + installedLabel: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 5fcc61a59..9204dc7b3 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -26,12 +26,6 @@ export type DesktopPairingOffer = { qr: string | null; }; -export type CliSymlinkInstructions = { - title: string; - detail: string; - commands: string; -}; - export type LocalTransportTarget = { transportType: "socket" | "pipe"; transportPath: string; @@ -115,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer { }; } -function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null { - if (!isRecord(raw)) { - return null; - } - return { - title: toStringOrNull(raw.title) ?? "", - detail: toStringOrNull(raw.detail) ?? "", - commands: toStringOrNull(raw.commands) ?? "", - }; -} - export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -154,18 +137,6 @@ export async function getDesktopDaemonPairing(): Promise { return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); } -export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { - const instructions = parseCliSymlinkInstructionsInternal(raw); - if (!instructions) { - throw new Error("Unexpected CLI symlink instructions response."); - } - return instructions; -} - -export async function getCliSymlinkInstructions(): Promise { - return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions")); -} - export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { @@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: { export async function closeLocalTransportSession(sessionId: string): Promise { await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); } + +// --------------------------------------------------------------------------- +// Integrations +// --------------------------------------------------------------------------- + +export interface InstallStatus { + installed: boolean; +} + +function parseInstallStatus(raw: unknown): InstallStatus { + if (!isRecord(raw)) { + throw new Error("Unexpected install status response."); + } + return { installed: raw.installed === true }; +} + +export async function getCliInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status")); +} + +export async function installCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_cli")); +} + +export async function getSkillsInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status")); +} + +export async function installSkills(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_skills")); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5dc28a0b6..39e3611fd 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -20,6 +20,7 @@ import { Stethoscope, Info, Shield, + Puzzle, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -50,6 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; +import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; @@ -69,6 +71,7 @@ type SettingsSectionId = | "hosts" | "appearance" | "shortcuts" + | "integrations" | "diagnostics" | "about" | "permissions" @@ -85,12 +88,13 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio { id: "hosts", label: "Hosts", icon: Server }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( + { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "daemon", label: "Daemon", icon: Settings }, - { id: "permissions", label: "Permissions", icon: Shield }, ); } @@ -523,6 +527,8 @@ function SettingsSectionContent({ return ; case "about": return ; + case "integrations": + return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "daemon": @@ -573,31 +579,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout {sections.map((section) => { const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; + const showSeparator = + section.id === "integrations" || section.id === "diagnostics"; return ( - setSelectedSectionId(section.id)} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - > - - + {showSeparator ? : null} + setSelectedSectionId(section.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} > - {section.label} - - + + + {section.label} + + + ); })} @@ -1896,6 +1906,12 @@ const desktopStyles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, }, + sidebarSeparator: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + marginVertical: theme.spacing[2], + marginHorizontal: theme.spacing[3], + }, contentPane: { flex: 1, }, diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 7f64c7e55..09b5b07ac 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() { return ( - - Shortcuts + + Shortcuts {hasOverrides && (