feat(desktop): add Integrations settings to install CLI and orchestration skills
Add a new Integrations section in desktop settings that lets users install the Paseo CLI and orchestration skills directly from the app. CLI install symlinks the bundled shim to ~/.local/bin and updates the user's shell rc file if needed. Skills install copies SKILL.md files to ~/.agents/skills/, symlinks into ~/.claude/skills/ for Claude Code, and copies to ~/.codex/skills/ for Codex. Both use a single status check code path for install verification and on-load detection. Also adds a /docs/skills page documenting all six orchestration skills, extracts shared settings styles (section headers, rows) used across the daemon, integrations, and shortcuts sections, and reorders the settings sidebar to group integrations and daemon together.
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(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<DesktopPairingOffer | null>(null);
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(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 (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
@@ -368,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{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"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
@@ -433,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenCliSymlinkInstructions}
|
||||
disabled={isLoadingCliSymlinkInstructions}
|
||||
>
|
||||
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
@@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair device</Text>
|
||||
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -490,10 +432,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Full status</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
@@ -518,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliSymlinkModalOpen}
|
||||
onClose={() => setIsCliSymlinkModalOpen(false)}
|
||||
title="Add paseo to your shell"
|
||||
testID="managed-daemon-cli-symlink-dialog"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Paseo does not add the command for you. Run the command below in your terminal.
|
||||
</Text>
|
||||
{cliSymlinkInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeBlock} selectable>
|
||||
{cliSymlinkInstructions?.commands ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
|
||||
Copy commands
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
@@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
@@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: {
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: {
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<InstallStatus | null>(null);
|
||||
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(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 (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
|
||||
<View style={styles.headerLinks}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
>
|
||||
CLI docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
>
|
||||
Skills docs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Control and script agents from your terminal.
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI.
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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<DesktopPairingOffer> {
|
||||
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<CliSymlinkInstructions> {
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
|
||||
}
|
||||
|
||||
export async function getCliDaemonStatus(): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
@@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
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<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
@@ -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 <DiagnosticsSection {...diagnosticsProps} />;
|
||||
case "about":
|
||||
return <AboutSection {...aboutProps} />;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : 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 (
|
||||
<Pressable
|
||||
key={section.id}
|
||||
style={[
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
<View key={section.id}>
|
||||
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||
<Pressable
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
subsectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sectionHeaderLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
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],
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
rowHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user