Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-03 19:34:57 +07:00
43 changed files with 2610 additions and 955 deletions
+50 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
@@ -64,6 +65,21 @@ export interface DesktopBridgeConfig {
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
}
interface DesktopEditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface ConfirmDialogCall {
@@ -74,6 +90,7 @@ export interface ConfirmDialogCall {
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
@@ -87,6 +104,15 @@ declare global {
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
if (config.editorRecordPath) {
await page.exposeFunction(
"__recordDesktopEditorOpen",
async (input: DesktopEditorOpenRecord) => {
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
},
);
}
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
@@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
@@ -202,6 +240,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
if (cfg.editorTargets) {
desktopBridge.editor = {
listTargets: async () => cfg.editorTargets ?? [],
openTarget: async (input: DesktopEditorOpenRecord) => {
await window.__recordDesktopEditorOpen?.(input);
},
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, type Page } from "@playwright/test";
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
}
export async function expectCurrentQuestion(
page: Page,
input: { index: number; total: number; question: string },
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
}
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: option })
.click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
}
export async function fillQuestionAnswer(
page: Page,
input: { question: string; answer: string },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: input.question })
.fill(input.answer);
}
export async function submitQuestionAnswers(page: Page): Promise<void> {
await page.getByTestId("question-form-primary-action").click();
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
}
@@ -0,0 +1,73 @@
import { test } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
chooseQuestionOption,
expectCurrentQuestion,
expectQuestionHidden,
expectQuestionOptionSelected,
fillQuestionAnswer,
openQuestion,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "./helpers/questions";
const TOTAL_QUESTIONS = 3;
const SURFACE_QUESTION = "Which surface should this apply to?";
const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
test.describe("Question prompt pagination", () => {
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-pagination-",
title: "Question pagination e2e",
initialPrompt: "Emit synthetic questions.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionHidden(page, ROLLOUT_QUESTION);
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
question: ROLLOUT_QUESTION,
});
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await chooseQuestionOption(page, "Behind feature flag");
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
question: SUCCESS_QUESTION,
});
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Only one prompt is visible at a time.",
});
await submitQuestionAnswers(page);
} finally {
await session.cleanup();
}
});
});
@@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
interface EditorOpenRecord {
command: string;
args: string[];
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
function requireE2EEnv(name: string): string {
@@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void>
async function expectEditorOpened(input: {
recordPath: string;
command: string;
workspacePath: string;
editorId: string;
path: string;
afterCount: number;
}): Promise<void> {
await expect
@@ -53,10 +55,7 @@ async function expectEditorOpened(input: {
const records = await readEditorOpenRecords(input.recordPath);
return records
.slice(input.afterCount)
.some(
(record) =>
record.command === input.command && record.args.includes(input.workspacePath),
);
.some((record) => record.editorId === input.editorId && record.path === input.path);
},
{ timeout: 30_000 },
)
@@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => {
const serverId = requireE2EEnv("E2E_SERVER_ID");
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
await rm(recordPath, { force: true });
await injectDesktopBridge(page, { serverId });
await injectDesktopBridge(page, {
serverId,
editorTargets: [
{ id: "cursor", label: "Cursor", kind: "editor" },
{ id: "vscode", label: "VS Code", kind: "editor" },
],
editorRecordPath: recordPath,
});
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
await workspace.navigateTo();
@@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => {
await chooseEditorTarget(page, "vscode");
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: 0,
});
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
@@ -94,8 +100,8 @@ test.describe("Workspace open in editor", () => {
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterSelection,
});
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
@@ -105,8 +111,8 @@ test.describe("Workspace open in editor", () => {
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterReturnOpen,
});
});
@@ -1,11 +1,7 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@getpaseo/protocol/messages";
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
interface EditorAppIconProps {
editorId: EditorTargetId;
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
if (!hasBundledEditorAppIcon(editorId)) {
const source = EDITOR_APP_IMAGES[editorId];
if (!source) {
return <SquareTerminal size={size} color={color} />;
}
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}
@@ -9,7 +9,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import { Check, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
@@ -82,9 +82,18 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
@@ -102,8 +111,73 @@ function QuestionOptionRow({
);
}
interface QuestionNavButtonProps {
index: number;
total: number;
isActive: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
function QuestionNavButton({
index,
total,
isActive,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const handlePress = useCallback(() => {
onSelect(index);
}, [index, onSelect]);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
return [
styles.questionNavButton,
{
backgroundColor:
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
},
pressed && styles.optionItemPressed,
];
},
[
isActive,
theme.colors.border,
theme.colors.foregroundMuted,
theme.colors.surface1,
theme.colors.surface2,
],
);
const textStyle = useMemo(
() => [
styles.questionNavText,
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
testID={`question-form-question-nav-${index + 1}`}
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
>
<Text style={textStyle}>{index + 1}</Text>
</Pressable>
);
}
interface QuestionOtherInputProps {
qIndex: number;
accessibilityLabel: string;
value: string;
placeholder: string;
isResponding: boolean;
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
accessibilityLabel,
value,
placeholder,
isResponding,
@@ -149,6 +224,7 @@ function QuestionOtherInput({
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -163,15 +239,19 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestionFormQuestions(permission.request.input);
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
[permission.request.input],
);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
const current = selections[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
} else {
next.add(optIndex);
}
} else if (next.has(optIndex)) {
next.clear();
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.clear();
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}, []);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
? Math.min(activeQuestionIndex, questions.length - 1)
: 0;
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
}, []);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const submitDisabled = !allAnswered || isResponding;
const submitButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
opacity: submitDisabled ? 0.5 : 1,
},
pressed && !submitDisabled ? styles.optionItemPressed : null,
],
[
submitDisabled,
theme.colors.surface2,
theme.colors.surface1,
theme.colors.border,
theme.colors.borderAccent,
],
[submitDisabled, theme.colors.accent],
);
const containerStyle = useMemo(
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
[isMobile],
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const submitActionTextColor = allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted;
const submitActionTextColor = theme.colors.accentForeground;
const submitActionTextStyle = useMemo(
() => [styles.actionText, { color: submitActionTextColor }],
[submitActionTextColor],
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
const dismissLabel = resolveDismissLabel(questions);
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
return (
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
const showTextInput = questionShowsTextInput(q);
return (
<View key={q.question} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
{q.options.length > 0 ? (
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
placeholder={getQuestionInputPlaceholder(q)}
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
onSelect={handleSelectQuestion}
/>
) : null}
</View>
);
})}
);
})}
</View>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={resolvedActiveQuestionIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={activeQuestion.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
) : null}
</View>
) : null}
<View style={actionsContainerStyle}>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
<Pressable
style={dismissButtonStyle}
onPress={handleDeny}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
)}
</Pressable>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
<Pressable
style={submitButtonStyle}
onPress={handleSubmit}
disabled={submitDisabled}
accessibilityRole="button"
accessibilityLabel="Submit"
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
flex: 1,
},
questionText: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
lineHeight: 22,
},
optionsWrap: {
gap: theme.spacing[1],
},
questionNav: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
},
questionNavButton: {
minWidth: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.xs,
fontWeight: "700",
},
optionItem: {
flexDirection: "row",
alignItems: "center",
@@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
@@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
@@ -75,15 +56,8 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
}, [isSkillsWorking, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
+19
View File
@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface DesktopEditorBridge {
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
}
export interface DesktopWebUtilsBridge {
getPathForFile?: (file: File) => string;
}
@@ -111,6 +129,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
editor?: DesktopEditorBridge;
webUtils?: DesktopWebUtilsBridge;
menu?: DesktopMenuBridge;
browser?: DesktopBrowserBridge;
+80 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
@@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => {
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});
+47
View File
@@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: {
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}
@@ -20,6 +20,12 @@ describe("resolvePreferredEditorId", () => {
);
});
it("keeps custom script target ids as plain strings", () => {
expect(resolvePreferredEditorId(["script:open-in-nvim", "cursor"], "script:open-in-nvim")).toBe(
"script:open-in-nvim",
);
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@getpaseo/protocol/messages";
import type { EditorTargetId } from "@/workspace/editor-targets";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
@@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise<EditorTargetId | null> {
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
return stored.trim() || null;
}
export function resolvePreferredEditorId(
@@ -6,10 +6,9 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
@@ -21,18 +20,20 @@ import {
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner";
import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
activeFile?: WorkspaceFileLocation | null;
hideLabels?: boolean;
}
@@ -40,7 +41,6 @@ interface OpenTarget {
id: string;
label: string;
icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void;
}
@@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
activeFile,
hideLabels,
}: WorkspaceOpenInEditorButtonProps) {
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } =
useDesktopOpenTargets({
isLocalExecution: isLocalDaemon,
});
const shouldQueryWorkspace =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadEditorTargets,
staleTime: 60_000,
retry: false,
queryFn: async () => {
if (!client) {
return [];
}
try {
const payload = await client.listAvailableEditors();
return payload.error ? [] : payload.editors;
} catch {
return [];
}
},
});
const availableEditors = useMemo(
() => availableEditorsQuery.data ?? [],
[availableEditorsQuery.data],
const resolvedFile = useMemo(
() =>
activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null,
[activeFile, cwd],
);
const activeFileName = useMemo(
() => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null,
[resolvedFile],
);
const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldQueryCheckout = canResolveWorkspace && isConnected;
const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId,
cwd: shouldQueryWorkspace ? cwd : "",
cwd: shouldQueryCheckout ? cwd : "",
});
const editorTargets = useMemo<OpenTarget[]>(
const targets = useMemo<OpenTarget[]>(
() =>
availableEditors.map((editor) => ({
id: editor.id,
label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.openInEditor(cwd, editor.id);
if (payload.error) {
throw new Error(payload.error);
}
},
})),
[availableEditors, client, cwd],
);
const githubTarget = useMemo<OpenTarget | null>(() => {
if (!checkoutStatus?.isGit) {
return null;
}
const url = buildGitHubBranchTreeUrl({
remoteUrl: checkoutStatus.remoteUrl,
branch: checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
id: "github",
label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url),
};
}, [checkoutStatus]);
const targets = useMemo(
() =>
filterTargetsForDaemonLocation(
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
planWorkspaceOpenTargets({
workspaceDirectory: cwd,
activeFile,
resolvedActiveFile: resolvedFile,
desktopTargets: desktopOpenTargets,
canUseDesktopBridge: isDesktopOpenAvailable,
isLocalExecution: isLocalDaemon,
checkoutStatus,
}).map((target) => {
if (target.source === "github") {
return {
id: target.id,
label: target.label,
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
onOpen: () => openExternalUrl(target.url),
};
}
return {
id: target.id,
label: target.label,
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
onOpen: () => openDesktopTarget(target.openInput),
};
}),
[
activeFile,
checkoutStatus,
cwd,
desktopOpenTargets,
isDesktopOpenAvailable,
isLocalDaemon,
resolvedFile,
],
);
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({
}
}, [primaryOption, handleOpenTarget]);
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
if (!canResolveWorkspace || !primaryOption || targets.length === 0) {
return null;
}
@@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({
onPress={handlePrimaryPress}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
accessibilityLabel={
activeFileName
? `Open ${activeFileName} in ${primaryOption.label}`
: `Open workspace in ${primaryOption.label}`
}
>
{openMutation.isPending ? (
<ThemedActivityIndicator
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});
@@ -1,13 +0,0 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}
@@ -193,6 +193,31 @@ function getWorkspaceScripts(
return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS;
}
interface WorkspaceFileLocationFields {
path: string | null;
lineStart?: number;
lineEnd?: number;
}
function getWorkspaceFileLocationFields(
tab: WorkspaceTabDescriptor | null,
): WorkspaceFileLocationFields {
const target = tab?.target;
if (target?.kind !== "file") {
return { path: null };
}
return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd };
}
function buildWorkspaceFileLocation(
fields: WorkspaceFileLocationFields,
): WorkspaceFileLocation | null {
if (fields.path === null) {
return null;
}
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
@@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
const activeFileLineStart = activeFileFields.lineStart;
const activeFileLineEnd = activeFileFields.lineEnd;
const activeFileLocation = useMemo<WorkspaceFileLocation | null>(
() =>
buildWorkspaceFileLocation({
path: activeFilePath,
lineStart: activeFileLineStart,
lineEnd: activeFileLineEnd,
}),
[activeFileLineEnd, activeFileLineStart, activeFilePath],
);
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = useMemo(
() => !isMobile && !canRenderDesktopPaneSplits,
@@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({
hideLabels={showCompactButtonLabels}
/>
) : null}
{!isMobile ? (
{!isMobile && workspaceDirectory ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
cwd={workspaceDirectory}
activeFile={activeFileLocation}
hideLabels={showCompactButtonLabels}
/>
) : null}
@@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({
workspaceDescriptor,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
handleViewScriptTerminal,
@@ -0,0 +1,71 @@
import { useQuery } from "@tanstack/react-query";
import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host";
export type DesktopOpenTargetKind = "editor" | "file-manager";
export type DesktopOpenMode = "open" | "reveal";
export interface DesktopOpenTarget {
id: string;
label: string;
kind: DesktopOpenTargetKind;
}
export interface OpenDesktopTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: DesktopOpenMode;
}
interface AvailableDesktopEditorBridge {
listTargets: NonNullable<DesktopEditorBridge["listTargets"]>;
openTarget: NonNullable<DesktopEditorBridge["openTarget"]>;
}
function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null {
const bridge = getDesktopHost()?.editor;
if (!bridge?.listTargets || !bridge.openTarget) {
return null;
}
return {
listTargets: bridge.listTargets,
openTarget: bridge.openTarget,
};
}
export function hasDesktopOpenTargetsBridge(): boolean {
return getDesktopEditorBridge() !== null;
}
export async function listDesktopOpenTargets(): Promise<DesktopOpenTarget[]> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
return [];
}
return await bridge.listTargets();
}
export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<void> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
throw new Error("Desktop editor bridge is unavailable");
}
await bridge.openTarget(input);
}
export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) {
const hasBridge = hasDesktopOpenTargetsBridge();
const canListTargets = hasBridge && input.isLocalExecution;
const query = useQuery({
queryKey: ["desktop-open-targets"],
enabled: canListTargets,
staleTime: 60_000,
retry: false,
queryFn: listDesktopOpenTargets,
});
return {
targets: query.data ?? [],
isAvailable: canListTargets,
};
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { isKnownEditorTargetId } from "./editor-targets";
describe("editor target ids", () => {
it("recognizes built-ins without typing custom ids out of the system", () => {
const customEditorId: string = "script:open-in-nvim";
expect(isKnownEditorTargetId("vscode")).toBe(true);
expect(isKnownEditorTargetId(customEditorId)).toBe(false);
});
});
@@ -0,0 +1,15 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
workspaceFileLocationsEqual,
} from ".";
@@ -61,3 +62,124 @@ describe("workspace file tab targets", () => {
).toBe(false);
});
});
describe("resolveWorkspaceFilePaths", () => {
it("joins workspace-relative paths against the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("derives the relative path for an absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/src/app.ts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("keeps the absolute path but drops the relative path when outside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/etc/hosts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/etc/hosts",
relativePath: null,
});
});
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
expect(
resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/app.ts",
relativePath: "app.ts",
});
});
it("rejects workspace-relative paths that escape the workspace", () => {
expect(
resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
expect(
resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
it("derives the relative path for a Windows absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("returns null for home-relative paths that cannot be anchored", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
expect(resolveWorkspaceFilePaths({ path: "~", workspaceRoot: "/Users/me/repo" })).toBeNull();
});
it("treats filenames that merely start with ~ as workspace-relative", () => {
expect(resolveWorkspaceFilePaths({ path: "~env.ts", workspaceRoot: "/Users/me/repo" })).toEqual(
{
absolutePath: "/Users/me/repo/~env.ts",
relativePath: "~env.ts",
},
);
});
it("matches Windows workspace paths case-insensitively", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\Me\\Repo\\src\\app.ts",
workspaceRoot: "c:\\users\\me\\repo",
}),
).toEqual({
absolutePath: "C:/Users/Me/Repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("treats a case-only difference from the Windows root as the root itself", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\Me\\Repo",
workspaceRoot: "c:\\users\\me\\repo",
}),
).toBeNull();
});
it("returns null when the workspace root is not absolute", () => {
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull();
});
it("returns null when the resolved file equals the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
});
@@ -1,3 +1,5 @@
import { isAbsolutePath } from "@/utils/path";
export type OpenFileDisposition = "main" | "side";
export interface WorkspaceFileLocation {
@@ -57,3 +59,120 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin
? Math.floor(value)
: undefined;
}
function trimTrailingSlashes(value: string): string {
if (value === "/" || /^\/+$/.test(value)) {
return "/";
}
if (/^[A-Za-z]:\/+$/.test(value)) {
return `${value.slice(0, 2)}/`;
}
return value.replace(/\/+$/, "");
}
function normalizePathSegments(value: string, rejectEscape: boolean): string | null {
const segments: string[] = [];
for (const segment of value.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
if (rejectEscape) {
return null;
}
continue;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/");
}
function normalizeAbsolutePath(value: string): string | null {
const normalizedInput = trimTrailingSlashes(value);
if (!isAbsolutePath(normalizedInput)) {
return null;
}
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
if (drivePath) {
const normalizedBody = normalizePathSegments(drivePath[2], false);
return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`);
}
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false);
return trimTrailingSlashes(`${prefix}${normalizedBody}`);
}
function normalizeRelativePath(value: string): string | null {
const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true);
return normalized || null;
}
function isWindowsPath(value: string): boolean {
return /^[A-Za-z]:\//.test(value);
}
function pathsEqual(left: string, right: string): boolean {
return isWindowsPath(left) || isWindowsPath(right)
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function startsWithPath(value: string, prefix: string): boolean {
return isWindowsPath(value) || isWindowsPath(prefix)
? value.toLowerCase().startsWith(prefix.toLowerCase())
: value.startsWith(prefix);
}
export interface ResolvedWorkspaceFilePaths {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
/** Path relative to the workspace root, or null when the file lives outside it. */
relativePath: string | null;
}
/**
* Resolves a file tab's path (which may be workspace-relative) against the workspace
* root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative
* path or a relative path with no workspace root to anchor it.
*/
export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
if (isAbsolutePath(filePath)) {
const normalizedFile = normalizeAbsolutePath(filePath);
if (!normalizedFile) {
return null;
}
if (pathsEqual(normalizedFile, workspaceRoot)) {
return null;
}
const prefix = `${workspaceRoot}/`;
const relativePath = startsWithPath(normalizedFile, prefix)
? normalizedFile.slice(prefix.length)
: null;
return { absolutePath: normalizedFile, relativePath };
}
if (filePath === "~" || filePath.startsWith("~/")) {
return null;
}
const relativePath = normalizeRelativePath(filePath);
if (!relativePath) {
return null;
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { planWorkspaceOpenTargets } from "./open-target-planner";
const desktopTargets = [
{ id: "vscode", label: "VS Code", kind: "editor" as const },
{ id: "finder", label: "Finder", kind: "file-manager" as const },
];
const checkoutStatus = {
isGit: true,
remoteUrl: "git@github.com:getpaseo/paseo.git",
currentBranch: "main",
};
describe("planWorkspaceOpenTargets", () => {
it("plans editor targets with active-file absolute path and cwd", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
});
});
it("plans file-manager targets with active-file absolute path and reveal mode", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
});
});
it("plans no active file as opening the workspace folder", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo" },
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo" },
});
});
it("passes custom target ids through as strings", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets).toEqual([
{
source: "desktop",
id: "script:open-in-nvim",
label: "Open in Neovim",
editorId: "script:open-in-nvim",
openInput: {
editorId: "script:open-in-nvim",
path: "/repo/src/app.ts",
cwd: "/repo",
},
},
]);
});
it("keeps GitHub target independent and uses blob and tree URLs", () => {
const blobTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
const treeTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
expect(blobTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/blob/main/src/app.ts#L3-L5",
},
]);
expect(treeTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/tree/main",
},
]);
});
it("suppresses desktop targets when Electron bridge is unavailable", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: false,
isLocalExecution: true,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
it("suppresses desktop targets for remote execution paths", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: false,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
});
@@ -0,0 +1,146 @@
import { buildGitHubBlobUrl, buildGitHubBranchTreeUrl } from "@/git/github-url";
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
import {
type ResolvedWorkspaceFilePaths,
resolveWorkspaceFilePaths,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
currentBranch?: string | null;
}
export interface PlannedDesktopOpenTarget {
source: "desktop";
id: string;
label: string;
editorId: string;
openInput: OpenDesktopTargetInput;
}
export interface PlannedGitHubOpenTarget {
source: "github";
id: "github";
label: "GitHub";
url: string;
}
export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedGitHubOpenTarget;
export interface PlanWorkspaceOpenTargetsInput {
workspaceDirectory: string;
activeFile?: WorkspaceFileLocation | null;
resolvedActiveFile?: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}
function resolveActiveFileForOpenTargets(
input: Pick<
PlanWorkspaceOpenTargetsInput,
"activeFile" | "resolvedActiveFile" | "workspaceDirectory"
>,
): ResolvedWorkspaceFilePaths | null {
if (input.resolvedActiveFile !== undefined) {
return input.resolvedActiveFile;
}
return input.activeFile
? resolveWorkspaceFilePaths({
path: input.activeFile.path,
workspaceRoot: input.workspaceDirectory,
})
: null;
}
function planDesktopOpenTargets(input: {
workspaceDirectory: string;
resolvedFile: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
}): PlannedDesktopOpenTarget[] {
if (!input.canUseDesktopBridge || !input.isLocalExecution) {
return [];
}
return input.desktopTargets.map((target) => {
if (!input.resolvedFile) {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: { editorId: target.id, path: input.workspaceDirectory },
};
}
if (target.kind === "editor") {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
cwd: input.workspaceDirectory,
},
};
}
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
mode: "reveal",
},
};
});
}
function planGitHubOpenTarget(input: {
activeFile?: WorkspaceFileLocation | null;
resolvedFile: ResolvedWorkspaceFilePaths | null;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}): PlannedGitHubOpenTarget | null {
if (!input.checkoutStatus?.isGit) {
return null;
}
const url = input.resolvedFile?.relativePath
? buildGitHubBlobUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
path: input.resolvedFile.relativePath,
lineStart: input.activeFile?.lineStart,
lineEnd: input.activeFile?.lineEnd,
})
: buildGitHubBranchTreeUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
source: "github",
id: "github",
label: "GitHub",
url,
};
}
export function planWorkspaceOpenTargets(
input: PlanWorkspaceOpenTargetsInput,
): PlannedWorkspaceOpenTarget[] {
const resolvedFile = resolveActiveFileForOpenTargets(input);
const desktopTargets = planDesktopOpenTargets({ ...input, resolvedFile });
const githubTarget = planGitHubOpenTarget({ ...input, resolvedFile });
return githubTarget ? [...desktopTargets, githubTarget] : desktopTargets;
}
-34
View File
@@ -50,8 +50,6 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListAvailableEditorsResponseMessage,
OpenInEditorResponseMessage,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
@@ -77,7 +75,6 @@ import type {
SessionInboundMessage,
SessionOutboundMessage,
SendAgentMessageRequest,
EditorTargetId,
PaseoConfigRaw,
PaseoConfigRevision,
} from "@getpaseo/protocol/messages";
@@ -641,12 +638,9 @@ export interface RenameTerminalInput {
title: string;
requestId?: string;
}
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
export interface FetchAgentResult {
agent: AgentSnapshotPayload;
@@ -1778,34 +1772,6 @@ export class DaemonClient {
});
}
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "list_available_editors_request",
},
responseType: "list_available_editors_response",
timeout: 10000,
});
}
async openInEditor(
path: string,
editorId: EditorTargetId,
requestId?: string,
): Promise<OpenInEditorPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "open_in_editor_request",
path,
editorId,
},
responseType: "open_in_editor_response",
timeout: 10000,
});
}
async archiveWorkspace(
workspaceId: string,
requestId?: string,
@@ -36,6 +36,24 @@ describe("desktop startup", () => {
expect(calls).toEqual(["cli", "env", "gui"]);
});
it("starts skills auto-update after GUI startup", async () => {
const calls: string[] = [];
await runDesktopStartup({
hasPendingOpenProjectPath: false,
runCliPassthroughIfRequested: vi.fn(async () => {
calls.push("cli");
return false;
}),
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")),
});
expect(calls).toEqual(["cli", "env", "gui", "skills"]);
});
it("does not route open-project launches through CLI passthrough", async () => {
const runCliPassthroughIfRequested = vi.fn(async () => true);
const calls: string[] = [];
+2
View File
@@ -3,6 +3,7 @@ export interface DesktopStartupDependencies {
runCliPassthroughIfRequested: () => Promise<boolean>;
inheritLoginShellEnv: () => void;
bootstrapGui: () => Promise<void>;
autoUpdateInstalledSkills?: () => void;
}
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
@@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
deps.inheritLoginShellEnv();
await deps.bootstrapGui();
deps.autoUpdateInstalledSkills?.();
}
@@ -0,0 +1,459 @@
import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
listAvailableEditorTargets,
openEditorTarget,
registerEditorTargetHandlers,
} from "./editor-targets";
interface SpawnCall {
command: string;
args: string[];
options: SpawnOptions;
listenedForError: boolean;
listenedForSpawn: boolean;
unrefed: boolean;
}
function createExistsSync(paths: readonly string[]): (path: string) => boolean {
const availablePaths = new Set(paths);
return (path) => availablePaths.has(path);
}
function createSpawnRecorder() {
const calls: SpawnCall[] = [];
const spawn = (command: string, args: string[], options: SpawnOptions) => {
const call: SpawnCall = {
command,
args,
options,
listenedForError: false,
listenedForSpawn: false,
unrefed: false,
};
calls.push(call);
const child = {
once: (event: "error" | "spawn", handler: (error?: Error) => void) => {
if (event === "error") {
call.listenedForError = true;
}
if (event === "spawn") {
call.listenedForSpawn = true;
queueMicrotask(() => handler());
}
return child;
},
unref: () => {
call.unrefed = true;
},
};
return child;
};
return { calls, spawn };
}
describe("desktop editor targets", () => {
it("lists all known editor targets for the platform in deterministic order", () => {
const targets = listAvailableEditorTargets({
platform: "win32",
env: { PATH: "C:/bin" },
existsSync: createExistsSync([
"C:/bin/cursor.exe",
"C:/bin/code.exe",
"C:/bin/webstorm.exe",
"C:/bin/zed.exe",
"C:/bin/explorer.exe",
]),
});
expect(targets).toEqual([
{ id: "cursor", label: "Cursor", kind: "editor" },
{ id: "vscode", label: "VS Code", kind: "editor" },
{ id: "webstorm", label: "WebStorm", kind: "editor" },
{ id: "zed", label: "Zed", kind: "editor" },
{ id: "explorer", label: "Explorer", kind: "file-manager" },
]);
});
it("lists Finder on macOS", () => {
const targets = listAvailableEditorTargets({
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/usr/bin/open"]),
});
expect(targets).toEqual([{ id: "finder", label: "Finder", kind: "file-manager" }]);
});
it("lists the generic file manager on Linux", () => {
const targets = listAvailableEditorTargets({
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/usr/bin/xdg-open"]),
});
expect(targets).toEqual([{ id: "file-manager", label: "File Manager", kind: "file-manager" }]);
});
it("can list future custom script targets without changing bridge types", () => {
const targets = listAvailableEditorTargets({
platform: "linux",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync(["/usr/local/bin/open-in-nvim"]),
targetDefinitions: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
command: "open-in-nvim",
},
],
});
expect(targets).toEqual([
{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" },
]);
});
it("launches editors as detached external processes", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/local/bin", ELECTRON_RUN_AS_NODE: "1" },
existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls).toEqual([
{
command: "/usr/local/bin/code",
args: ["/tmp/repo"],
options: {
detached: true,
env: { PATH: "/usr/local/bin" },
shell: false,
stdio: "ignore",
},
listenedForError: true,
listenedForSpawn: true,
unrefed: true,
},
]);
});
it("reveals files in Finder on macOS", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "finder", path: "/tmp/repo/src/index.ts", mode: "reveal" },
{
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/tmp/repo/src/index.ts", "/usr/bin/open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.command).toBe("/usr/bin/open");
expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]);
});
it("reveals files in Explorer on Windows", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/repo/src/index.ts", mode: "reveal" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["C:/repo/src/index.ts", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]);
expect(recorder.calls[0]?.options.shell).toBe(false);
});
it("keeps Windows shell metacharacters literal for direct executables", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/repo/src/file & calculator.ts", mode: "reveal" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync([
"C:/repo/src/file & calculator.ts",
"C:/Windows/explorer.exe",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: "C:/Windows/explorer.exe",
args: ["/select,", "C:/repo/src/file & calculator.ts"],
options: { shell: false },
});
});
it("quotes Windows command-script paths without corrupting metacharacters", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo/src/file & calculator.ts",
cwd: "C:/repo & workspace",
},
{
platform: "win32",
env: { PATH: "C:/Program Files/Editors & Tools/bin" },
existsSync: createExistsSync([
"C:/repo/src/file & calculator.ts",
"C:/repo & workspace",
"C:/Program Files/Editors & Tools/bin/code.cmd",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: '"C:/Program Files/Editors & Tools/bin/code.cmd"',
args: ['"C:/repo & workspace"', '"C:/repo/src/file & calculator.ts"'],
options: { shell: true },
});
});
it("quotes Windows command-script values that contain shell metacharacters", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo/src/file&calculator.ts",
},
{
platform: "win32",
env: { PATH: "C:/Editors&Tools/bin" },
existsSync: createExistsSync([
"C:/repo/src/file&calculator.ts",
"C:/Editors&Tools/bin/code.cmd",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: '"C:/Editors&Tools/bin/code.cmd"',
args: ['"C:/repo/src/file&calculator.ts"'],
options: { shell: true },
});
});
it("reveals Linux files by opening the containing folder", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "file-manager", path: "/home/user/repo/src/index.ts", mode: "reveal" },
{
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/xdg-open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src"]);
});
it("ignores reveal mode for editor targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/home/user/repo/src/index.ts", mode: "reveal" },
{
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/code"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src/index.ts"]);
});
it("opens the workspace folder alongside the file for editor targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo/src/index.ts",
"/tmp/repo",
"/usr/local/bin/code",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo", "/tmp/repo/src/index.ts"]);
});
it("can launch future custom script targets by string id", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "script:open-in-nvim", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" },
{
platform: "linux",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo/src/index.ts",
"/tmp/repo",
"/usr/local/bin/open-in-nvim",
]),
spawn: recorder.spawn,
targetDefinitions: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
command: "open-in-nvim",
},
],
},
);
expect(recorder.calls[0]).toMatchObject({
command: "/usr/local/bin/open-in-nvim",
args: ["/tmp/repo", "/tmp/repo/src/index.ts"],
options: { shell: false },
});
});
it("does not prepend invalid, equal, relative, or missing cwd values", async () => {
const inputs = [
{ editorId: "vscode", path: "/tmp/repo", cwd: "/tmp/repo" },
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "repo" },
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/missing" },
];
for (const input of inputs) {
const recorder = createSpawnRecorder();
await openEditorTarget(input, {
platform: "darwin",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo",
"/tmp/repo/src/index.ts",
"/usr/local/bin/code",
]),
spawn: recorder.spawn,
});
expect(recorder.calls[0]?.args).toEqual([input.path]);
}
});
it("does not prepend cwd for file-manager targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "finder", path: "/tmp/repo/src", cwd: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/tmp/repo/src", "/tmp/repo", "/usr/bin/open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo/src"]);
});
it("rejects relative and missing paths", async () => {
await expect(
openEditorTarget(
{ editorId: "cursor", path: "repo" },
{ existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target path must be an absolute local path");
await expect(
openEditorTarget(
{ editorId: "cursor", path: "/tmp/repo" },
{ existsSync: () => false, env: { PATH: "/bin" } },
),
).rejects.toThrow("Path does not exist: /tmp/repo");
});
it("rejects unsupported, unknown, and missing executable targets", async () => {
await expect(
openEditorTarget(
{ editorId: "finder", path: "/tmp/repo" },
{ platform: "linux", existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target unavailable: Finder");
await expect(
openEditorTarget(
{ editorId: "unknown-editor", path: "/tmp/repo" },
{ existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Unknown editor target: unknown-editor");
await expect(
openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo" },
{ existsSync: (path) => path === "/tmp/repo", env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target unavailable: VS Code");
});
it("runs list and open behavior through registered IPC handlers", async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const ipc = {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, listener);
},
};
const recorder = createSpawnRecorder();
registerEditorTargetHandlers({
ipc,
dependencies: {
platform: "darwin",
env: { PATH: "/usr/local/bin:/usr/bin" },
existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code", "/usr/bin/open"]),
spawn: recorder.spawn,
},
});
const listHandler = handlers.get("paseo:editor:listTargets");
const openHandler = handlers.get("paseo:editor:openTarget");
if (!listHandler || !openHandler) {
throw new Error("editor IPC handlers were not registered");
}
expect(listHandler({})).toEqual([
{ id: "vscode", label: "VS Code", kind: "editor" },
{ id: "finder", label: "Finder", kind: "file-manager" },
]);
await openHandler({}, { editorId: "vscode", path: "/tmp/repo" });
await expect(openHandler({}, { editorId: "vscode", path: "repo" })).rejects.toThrow(
"Editor target path must be an absolute local path",
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo"]);
});
});
@@ -0,0 +1,353 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { existsSync as nodeExistsSync } from "node:fs";
import { posix, win32 } from "node:path";
import { ipcMain } from "electron";
import { z } from "zod";
type EditorTargetKind = "editor" | "file-manager";
type OpenEditorMode = "open" | "reveal";
interface EditorTargetDefinition {
id: string;
label: string;
kind: EditorTargetKind;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
}
export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: EditorTargetKind;
}
export interface OpenEditorTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: OpenEditorMode;
}
interface ListEditorTargetsDependencies {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
existsSync?: (path: string) => boolean;
targetDefinitions?: readonly EditorTargetDefinition[];
}
interface SpawnedProcess {
once(event: "error", handler: (error: Error) => void): SpawnedProcess;
once(event: "spawn", handler: () => void): SpawnedProcess;
unref(): void;
}
interface OpenEditorTargetDependencies extends ListEditorTargetsDependencies {
spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess;
}
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
interface Launch {
command: string;
args: string[];
}
interface SpawnLaunch extends Launch {
shell: boolean;
}
const RUNTIME_CONTROL_ENV_KEYS = [
"PASEO_NODE_ENV",
"PASEO_DESKTOP_MANAGED",
"PASEO_SUPERVISED",
"ELECTRON_RUN_AS_NODE",
"ELECTRON_NO_ATTACH_CONSOLE",
] as const;
const BUILT_IN_EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", kind: "editor", command: "cursor" },
{ id: "vscode", label: "VS Code", kind: "editor", command: "code" },
{ id: "webstorm", label: "WebStorm", kind: "editor", command: "webstorm" },
{ id: "zed", label: "Zed", kind: "editor", command: "zed" },
{
id: "finder",
label: "Finder",
kind: "file-manager",
command: "open",
platforms: ["darwin"],
},
{
id: "explorer",
label: "Explorer",
kind: "file-manager",
command: "explorer",
platforms: ["win32"],
},
{
id: "file-manager",
label: "File Manager",
kind: "file-manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
const OpenEditorTargetInputSchema = z.object({
editorId: z.string().trim().min(1),
path: z.string().trim().min(1),
cwd: z.string().trim().min(1).optional(),
mode: z.enum(["open", "reveal"]).optional(),
});
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function createExternalProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
for (const key of RUNTIME_CONTROL_ENV_KEYS) {
delete env[key];
}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete env[key];
}
}
return env;
}
function resolveExecutable(
command: string,
input: {
env: NodeJS.ProcessEnv;
existsSync: (path: string) => boolean;
platform: NodeJS.Platform;
},
): string | null {
if (isAbsolutePath(command, input.platform) && input.existsSync(command)) {
return command;
}
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
for (const directory of pathValue.split(pathDelimiter)) {
if (!directory) {
continue;
}
const candidate = `${directory}/${command}`;
if (input.existsSync(candidate)) {
return candidate;
}
if (input.platform === "win32") {
for (const extension of [".exe", ".cmd"]) {
const windowsCandidate = `${candidate}${extension}`;
if (input.existsSync(windowsCandidate)) {
return windowsCandidate;
}
}
}
}
return null;
}
function resolveTargetDefinitions(
dependencies: ListEditorTargetsDependencies,
): readonly EditorTargetDefinition[] {
return dependencies.targetDefinitions ?? BUILT_IN_EDITOR_TARGETS;
}
function findTarget(
targetId: string,
targetDefinitions: readonly EditorTargetDefinition[],
): EditorTargetDefinition {
const target = targetDefinitions.find((entry) => entry.id === targetId);
if (!target) {
throw new Error(`Unknown editor target: ${targetId}`);
}
return target;
}
function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function dirnameForPlatform(value: string, platform: NodeJS.Platform): string {
return platform === "win32" ? win32.dirname(value) : posix.dirname(value);
}
function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean {
if (platform !== "win32") {
return false;
}
const extension = win32.extname(executable).toLowerCase();
return extension === ".cmd" || extension === ".bat";
}
function escapeWindowsCmdValue(value: string): string {
const isQuoted = value.startsWith('"') && value.endsWith('"');
const unquoted = isQuoted ? value.slice(1, -1) : value;
if (isQuoted || /[\s"&|^<>()!]/u.test(unquoted)) {
const quoted = unquoted
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
return `"${quoted}"`;
}
return unquoted;
}
function createSpawnLaunch(launch: Launch, platform: NodeJS.Platform): SpawnLaunch {
if (!isWindowsCommandScript(launch.command, platform)) {
return { ...launch, shell: false };
}
return {
command: escapeWindowsCmdValue(launch.command),
args: launch.args.map(escapeWindowsCmdValue),
shell: true,
};
}
function buildLaunch(input: {
target: EditorTargetDefinition;
path: string;
cwd?: string;
mode: OpenEditorMode;
platform: NodeJS.Platform;
executable: string;
}): Launch {
if (input.mode === "reveal") {
if (input.target.id === "finder" && input.platform === "darwin") {
return { command: input.executable, args: ["-R", input.path] };
}
if (input.target.id === "explorer" && input.platform === "win32") {
return { command: input.executable, args: ["/select,", input.path] };
}
if (input.target.id === "file-manager") {
return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] };
}
}
if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) {
return { command: input.executable, args: [input.cwd, input.path] };
}
return { command: input.executable, args: [input.path] };
}
function spawnDetachedProcess(
command: string,
args: string[],
options: SpawnOptions,
): SpawnedProcess {
return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess;
}
export function listAvailableEditorTargets(
dependencies: ListEditorTargetsDependencies = {},
): DesktopEditorTargetDescriptor[] {
const platform = dependencies.platform ?? process.platform;
const existsSync = dependencies.existsSync ?? nodeExistsSync;
const env = dependencies.env ?? process.env;
const targetDefinitions = resolveTargetDefinitions(dependencies);
return targetDefinitions
.filter((target) => isTargetSupportedOnPlatform(target, platform))
.filter((target) => resolveExecutable(target.command, { platform, env, existsSync }))
.map((target) => ({ id: target.id, label: target.label, kind: target.kind }));
}
export async function openEditorTarget(
input: OpenEditorTargetInput,
dependencies: OpenEditorTargetDependencies = {},
): Promise<void> {
const parsedInput = OpenEditorTargetInputSchema.parse(input);
const platform = dependencies.platform ?? process.platform;
const existsSync = dependencies.existsSync ?? nodeExistsSync;
const env = dependencies.env ?? process.env;
const spawn = dependencies.spawn ?? spawnDetachedProcess;
const pathToOpen = parsedInput.path;
if (!isAbsolutePath(pathToOpen, platform)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSync(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const target = findTarget(parsedInput.editorId, resolveTargetDefinitions(dependencies));
if (!isTargetSupportedOnPlatform(target, platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = resolveExecutable(target.command, { platform, env, existsSync });
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const workspaceCwd =
parsedInput.cwd &&
isAbsolutePath(parsedInput.cwd, platform) &&
parsedInput.cwd !== pathToOpen &&
existsSync(parsedInput.cwd)
? parsedInput.cwd
: undefined;
const launch = buildLaunch({
target,
path: pathToOpen,
cwd: workspaceCwd,
mode: parsedInput.mode ?? "open",
platform,
executable,
});
const spawnLaunch = createSpawnLaunch(launch, platform);
await new Promise<void>((resolve, reject) => {
let child: SpawnedProcess;
try {
child = spawn(spawnLaunch.command, spawnLaunch.args, {
detached: true,
env: createExternalProcessEnv(env),
shell: spawnLaunch.shell,
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
export function registerEditorTargetHandlers(
options: {
ipc?: IpcHandlerRegistry;
dependencies?: OpenEditorTargetDependencies;
} = {},
): void {
const ipc = options.ipc ?? ipcMain;
const dependencies = options.dependencies ?? {};
ipc.handle("paseo:editor:listTargets", () => listAvailableEditorTargets(dependencies));
ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => {
const parsedInput = OpenEditorTargetInputSchema.parse(payload);
await openEditorTarget(parsedInput, dependencies);
});
}
@@ -1,4 +1,5 @@
export {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
uninstallSkills,
@@ -11,6 +11,7 @@ vi.mock("electron", () => ({
}));
import {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
@@ -60,6 +61,18 @@ async function writeOnDiskSkill(
await writeFiles(path.join(agentsDir, name), files);
}
async function writeOnDiskSkillToAllTargets(
targets: SkillTargets,
name: string,
files: Record<string, string>,
): Promise<void> {
await Promise.all([
writeOnDiskSkill(targets.agentsDir, name, files),
writeOnDiskSkill(targets.claudeDir, name, files),
writeOnDiskSkill(targets.codexDir, name, files),
]);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
@@ -112,8 +125,22 @@ describe("getSkillsStatus", () => {
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("ignores user-added files inside current managed skill dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
"SKILL.md": "paseo-v1",
"my-context.md": "user context",
});
const status = await getSkillsStatus(sandbox.targets);
@@ -123,7 +150,24 @@ describe("getSkillsStatus", () => {
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift when a secondary agent target is stale", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
@@ -133,7 +177,7 @@ describe("getSkillsStatus", () => {
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
@@ -143,8 +187,8 @@ describe("getSkillsStatus", () => {
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
@@ -156,6 +200,8 @@ describe("getSkillsStatus", () => {
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
@@ -226,6 +272,51 @@ describe("installSkills / updateSkills", () => {
}
});
it("repairs secondary agent targets even when agents skills are current", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
});
it("auto-updates drifted installed skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
const status = await autoUpdateInstalledSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
});
it("does not auto-install skills on a clean machine", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await autoUpdateInstalledSkills(sandbox.targets);
expect(status).toEqual({
state: "not-installed",
ops: [
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
],
});
expect(await pathExists(path.join(sandbox.targets.agentsDir, "paseo"))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, "paseo"))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, "paseo"))).toBe(false);
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
@@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [
] as const;
type SkillFiles = Map<string, string>;
type TargetSkills = Map<string, SkillFiles>;
function resolveSkillTargets(): SkillTargets {
return {
@@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
const targetFiles = disks.map((disk) => disk.get(name));
const installedTargets = targetFiles.filter(
(files): files is SkillFiles => files !== undefined,
);
if (b) {
const missingTargets = installedTargets.length < disks.length;
const changedTargets = installedTargets.some((files) => !bundleFilesMatch(b, files));
if (missingTargets) ops.push({ kind: "add", name });
else if (changedTargets) ops.push({ kind: "update", name });
} else if (installedTargets.length > 0) {
ops.push({ kind: "delete", name });
}
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
return disks.some((disk) => disk.size > 0);
}
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
for (const [rel, sha] of bundle) {
if (disk.get(rel) !== sha) return false;
}
return true;
}
@@ -107,16 +119,25 @@ function compareStrings(a: string, b: string): number {
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
hashSkills(t.sourceDir),
hashSkills(t.agentsDir),
hashSkills(t.claudeDir),
hashSkills(t.codexDir),
]);
const disks = [agentsDisk, claudeDisk, codexDisk];
const ops = diff(bundle, disks);
if (disk.size === 0) return { state: "not-installed", ops };
if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
async function applySkills(
targets: SkillTargets,
initialStatus?: SkillsStatus,
): Promise<SkillsStatus> {
const status = initialStatus ?? (await getSkillsStatus(targets));
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
@@ -151,6 +172,13 @@ export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus
return applySkills(targets ?? resolveSkillTargets());
}
export async function autoUpdateInstalledSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const status = await getSkillsStatus(t);
if (status.state !== "drift") return status;
return applySkills(t, status);
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
@@ -137,12 +137,23 @@ describe("syncSkills", () => {
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
});
it("does not delete files on disk that are no longer in the bundle", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
it("removes stale files previously written to managed skill dirs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "old",
"references/stale.md": "stale",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await fs.rm(path.join(sandbox.sourceDir, "paseo"), { recursive: true, force: true });
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
await syncSkills({
sourceDir: sandbox.sourceDir,
@@ -153,8 +164,32 @@ describe("syncSkills", () => {
});
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe(
"stale",
await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow();
await expect(fs.access(path.join(onDiskSkill, "references"))).rejects.toThrow();
});
it("preserves user-added files in managed skill dirs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
await fs.writeFile(path.join(onDiskSkill, "my-context.md"), "user context");
await fs.writeFile(path.join(onDiskSkill, "references", "notes.md"), "user notes");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
expect(await fs.readFile(path.join(onDiskSkill, "my-context.md"), "utf-8")).toBe(
"user context",
);
expect(await fs.readFile(path.join(onDiskSkill, "references", "notes.md"), "utf-8")).toBe(
"user notes",
);
});
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
@@ -15,6 +16,13 @@ export interface SkillSyncResult {
processedSkills: number;
}
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
interface ManagedFilesManifest {
version: 1;
files: Record<string, string>;
}
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
const src = await fs.readFile(srcPath);
const dst = await fs.readFile(dstPath).catch(() => null);
@@ -30,10 +38,12 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(rootDir, full);
if (rel === MANAGED_FILES_MANIFEST) continue;
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile()) {
out.push(path.relative(rootDir, full));
out.push(rel);
}
}
}
@@ -41,14 +51,89 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
return out;
}
async function readManagedFilesManifest(dstDir: string): Promise<ManagedFilesManifest | null> {
const raw = await fs
.readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8")
.catch(() => null);
if (!raw) return null;
const parsed = safeParseJson(raw) as Partial<ManagedFilesManifest> | null;
if (!parsed) return null;
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null)
return null;
return { version: 1, files: parsed.files as Record<string, string> };
}
function safeParseJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function hashFile(filePath: string): Promise<string> {
const buf = await fs.readFile(filePath);
return createHash("sha256").update(buf).digest("hex");
}
async function writeManifestIfChanged(
dstDir: string,
files: Record<string, string>,
): Promise<boolean> {
const manifest: ManagedFilesManifest = { version: 1, files };
const next = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST);
const current = await fs.readFile(manifestPath, "utf-8").catch(() => null);
if (current === next) return false;
await fs.mkdir(dstDir, { recursive: true });
await fs.writeFile(manifestPath, next);
return true;
}
async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise<void> {
const dirs = new Set<string>();
for (const rel of rels) {
let dir = path.dirname(rel);
while (dir !== ".") {
dirs.add(dir);
dir = path.dirname(dir);
}
}
const deepestFirst = [...dirs].sort(
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
);
for (const rel of deepestFirst) {
await fs.rmdir(path.join(rootDir, rel)).catch(() => {});
}
}
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir);
const srcFileSet = new Set(files);
const srcHashes: Record<string, string> = {};
for (const rel of files) {
srcHashes[rel] = await hashFile(path.join(srcDir, rel));
}
const previousManifest = await readManagedFilesManifest(dstDir);
let changed = 0;
for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++;
}
}
const deletedRels: string[] = [];
for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) {
if (srcFileSet.has(rel)) continue;
const dstPath = path.join(dstDir, rel);
const currentHash = await hashFile(dstPath).catch(() => null);
if (currentHash !== previousHash) continue;
await fs.rm(dstPath, { force: true });
deletedRels.push(rel);
changed++;
}
await pruneEmptyParentDirs(dstDir, deletedRels);
if (await writeManifestIfChanged(dstDir, srcHashes)) changed++;
return changed;
}
+8
View File
@@ -43,6 +43,7 @@ import {
ensureNotificationCenterRegistration,
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
getPaseoBrowserIdForWebContents,
@@ -63,6 +64,7 @@ import {
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js";
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
@@ -697,6 +699,7 @@ async function bootstrap(): Promise<void> {
registerDialogHandlers();
registerNotificationHandlers();
registerOpenerHandlers();
registerEditorTargetHandlers();
await createMainWindow();
@@ -712,6 +715,11 @@ void runDesktopStartup({
runCliPassthroughIfRequested,
inheritLoginShellEnv,
bootstrapGui: bootstrap,
autoUpdateInstalledSkills: () => {
void autoUpdateInstalledSkills().catch((error) => {
log.error("[skills] auto-update failed", error);
});
},
}).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
+9
View File
@@ -55,6 +55,15 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
opener: {
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
},
editor: {
listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"),
openTarget: (input: {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}) => ipcRenderer.invoke("paseo:editor:openTarget", input),
},
webUtils: {
getPathForFile: (file: File) => webUtils.getPathForFile(file),
},
+28 -52
View File
@@ -145,7 +145,6 @@ export const MutableDaemonConfigPatchSchema = z
export type MutableDaemonConfig = z.infer<typeof MutableDaemonConfigSchema>;
export type MutableDaemonConfigPatch = z.infer<typeof MutableDaemonConfigPatchSchema>;
import type { LiteralUnion } from "./literal-union.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
@@ -1569,47 +1568,18 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
requestId: z.string(),
});
// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids.
export const LEGACY_EDITOR_TARGET_IDS = [
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
] as const;
export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const;
export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS);
export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS);
export const EditorTargetIdSchema = z.string().trim().min(1);
const KNOWN_EDITOR_TARGET_ID_SET = new Set<string>(KNOWN_EDITOR_TARGET_IDS);
const LEGACY_EDITOR_TARGET_ID_SET = new Set<string>(LEGACY_EDITOR_TARGET_IDS);
export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId {
return KNOWN_EDITOR_TARGET_ID_SET.has(value);
}
export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId {
return LEGACY_EDITOR_TARGET_ID_SET.has(value);
}
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
label: z.string(),
});
export const ListAvailableEditorsRequestSchema = z.object({
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
export const LegacyListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
requestId: z.string(),
});
export const OpenInEditorRequestSchema = z.object({
export const LegacyOpenInEditorRequestSchema = z.object({
type: z.literal("open_in_editor_request"),
path: z.string(),
editorId: EditorTargetIdSchema,
editorId: z.string().trim().min(1),
mode: z.enum(["open", "reveal"]).optional(),
cwd: z.string().optional(),
requestId: z.string(),
});
@@ -1924,8 +1894,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
ListAvailableEditorsRequestSchema,
OpenInEditorRequestSchema,
LegacyListAvailableEditorsRequestSchema,
LegacyOpenInEditorRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -2596,16 +2566,22 @@ export const StartWorkspaceScriptResponseMessageSchema = z.object({
}),
});
export const ListAvailableEditorsResponseMessageSchema = z.object({
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer parse daemon editor RPC responses.
export const LegacyListAvailableEditorsResponseMessageSchema = z.object({
type: z.literal("list_available_editors_response"),
payload: z.object({
requestId: z.string(),
editors: z.array(EditorTargetDescriptorPayloadSchema),
editors: z.array(
z.object({
id: z.string().trim().min(1),
label: z.string(),
}),
),
error: z.string().nullable(),
}),
});
export const OpenInEditorResponseMessageSchema = z.object({
export const LegacyOpenInEditorResponseMessageSchema = z.object({
type: z.literal("open_in_editor_response"),
payload: z.object({
requestId: z.string(),
@@ -3684,8 +3660,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceScriptResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema,
LegacyListAvailableEditorsResponseMessageSchema,
LegacyOpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
@@ -3811,10 +3787,6 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
export type KnownEditorTargetId = z.infer<typeof KnownEditorTargetIdSchema>;
export type LegacyEditorTargetId = z.infer<typeof LegacyEditorTargetIdSchema>;
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchAgentHistoryResponseMessage = z.infer<
typeof FetchAgentHistoryResponseMessageSchema
@@ -3828,10 +3800,12 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
>;
export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema
export type LegacyListAvailableEditorsResponseMessage = z.infer<
typeof LegacyListAvailableEditorsResponseMessageSchema
>;
export type LegacyOpenInEditorResponseMessage = z.infer<
typeof LegacyOpenInEditorResponseMessageSchema
>;
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer<
@@ -4030,8 +4004,10 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type LegacyListAvailableEditorsRequest = z.infer<
typeof LegacyListAvailableEditorsRequestSchema
>;
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -241,62 +241,42 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
test("parses legacy editor RPC messages for compatibility", () => {
const listRequest = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_request with flexible editor ids", () => {
const knownEditor = SessionInboundMessageSchema.parse({
const openRequest = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-webstorm",
editorId: "webstorm",
path: "/tmp/repo",
});
const unknownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-custom",
requestId: "req-open-editor",
editorId: "unknown-editor",
path: "/tmp/repo",
mode: "reveal",
cwd: "/tmp",
});
expect(knownEditor.type).toBe("open_in_editor_request");
expect(unknownEditor.type).toBe("open_in_editor_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("parses list_available_editors_response with unknown editor ids", () => {
const parsed = SessionOutboundMessageSchema.parse({
const listResponse = SessionOutboundMessageSchema.parse({
type: "list_available_editors_response",
payload: {
requestId: "req-editors",
editors: [
{ id: "cursor", label: "Cursor" },
{ id: "unknown-editor", label: "Unknown Editor" },
],
editors: [{ id: "unknown-editor", label: "Unknown Editor" }],
error: null,
},
});
const openResponse = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: "Editor opening moved to the desktop app",
},
});
expect(parsed.type).toBe("list_available_editors_response");
expect(listRequest.type).toBe("list_available_editors_request");
expect(openRequest.type).toBe("open_in_editor_request");
expect(listResponse.type).toBe("list_available_editors_response");
expect(openResponse.type).toBe("open_in_editor_response");
});
test("rejects empty editor ids", () => {
test("rejects empty legacy editor ids", () => {
const result = SessionInboundMessageSchema.safeParse({
type: "open_in_editor_request",
requestId: "req-open-empty",
@@ -128,6 +128,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean {
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
}
function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean {
return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt));
}
function resolveModelProfile(modelId: string | null | undefined): {
modelId: string;
durationMs: number;
@@ -524,6 +528,8 @@ export class MockLoadTestAgentSession implements AgentSession {
const stress = parseAgentStreamStressPrompt(prompt);
if (shouldEmitPlanApprovalPrompt(prompt)) {
this.schedulePlanApprovalTurn(turn);
} else if (shouldEmitQuestionPrompt(prompt)) {
this.scheduleQuestionPromptTurn(turn);
} else if (largePayload) {
this.scheduleLargePayloadTurn(turn, largePayload);
} else if (stress) {
@@ -576,9 +582,11 @@ export class MockLoadTestAgentSession implements AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
if (!this.pendingPermissions.delete(requestId)) {
const request = this.pendingPermissions.get(requestId);
if (!request) {
return undefined;
}
this.pendingPermissions.delete(requestId);
const turn = this.activeTurn;
this.emit({
@@ -590,7 +598,12 @@ export class MockLoadTestAgentSession implements AgentSession {
});
if (turn) {
this.finishTurnWithText(turn, "Synthetic plan approval resolved");
this.finishTurnWithText(
turn,
request.kind === "question"
? "Synthetic questions resolved"
: "Synthetic plan approval resolved",
);
}
return undefined;
}
@@ -689,6 +702,13 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.();
}
private scheduleQuestionPromptTurn(turn: ActiveTurn): void {
turn.timer = setTimeout(() => {
this.emitQuestionPromptTurn(turn);
}, 0);
turn.timer.unref?.();
}
private emitPlanApprovalTurn(turn: ActiveTurn): void {
if (this.activeTurn !== turn) {
return;
@@ -741,6 +761,61 @@ export class MockLoadTestAgentSession implements AgentSession {
});
}
private emitQuestionPromptTurn(turn: ActiveTurn): void {
if (this.activeTurn !== turn) {
return;
}
this.clearTurnTimer(turn);
this.emit({
type: "turn_started",
provider: this.provider,
turnId: turn.turnId,
});
const request: AgentPermissionRequest = {
id: `mock-questions-${turn.turnId}`,
provider: this.provider,
name: "MockQuestions",
kind: "question",
title: "Questions",
input: {
questions: [
{
question: "Which surface should this apply to?",
header: "surface",
options: [{ label: "App" }, { label: "Desktop" }],
multiSelect: false,
},
{
question: "Which rollout should we use?",
header: "rollout",
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
multiSelect: false,
},
{
question: "What success criteria should we use?",
header: "success",
options: [],
multiSelect: false,
placeholder: "Describe success...",
},
],
},
metadata: {
source: "mock_questions",
},
};
this.pendingPermissions.set(request.id, request);
this.emit({
type: "permission_requested",
provider: this.provider,
request,
turnId: turn.turnId,
});
}
private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void {
if (this.activeTurn !== turn) {
return;
@@ -1,118 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", async () => {
const available = new Set(["code", "cursor", "explorer", "webstorm"]);
const editors = await listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "webstorm", label: "WebStorm" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", async () => {
const editors = await listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", async () => {
const editors = await listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
});
it("launches editors as detached processes", async () => {
const unref = vi.fn();
const once = vi.fn((event: string, handler: () => void) => {
if (event === "spawn") {
queueMicrotask(handler);
}
return child;
});
const child = { once, unref };
const spawn = vi.fn(() => child as unknown as ChildProcess);
await openInEditorTarget(
{
editorId: "vscode",
path: "/tmp/repo",
},
{
platform: "darwin",
existsSync: () => true,
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
spawn,
},
);
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
detached: true,
env: expect.any(Object),
shell: false,
stdio: "ignore",
});
expect(unref).toHaveBeenCalled();
});
it("rejects relative paths", async () => {
await expect(
openInEditorTarget(
{
editorId: "cursor",
path: "repo",
},
{
existsSync: () => true,
findExecutable: () => "/usr/local/bin/cursor",
},
),
).rejects.toThrow("Editor target path must be an absolute local path");
});
it("rejects platform-specific targets that are unavailable on this OS", async () => {
await expect(
openInEditorTarget(
{
editorId: "finder",
path: "/tmp/repo",
},
{
platform: "linux",
existsSync: () => true,
findExecutable: () => "/usr/bin/open",
},
),
).rejects.toThrow("Editor target unavailable: Finder");
});
it("rejects unknown editor ids", async () => {
await expect(
openInEditorTarget(
{
editorId: "unknown-editor",
path: "/tmp/repo",
},
{
existsSync: () => true,
findExecutable: () => null,
},
),
).rejects.toThrow("Unknown editor target: unknown-editor");
});
});
@@ -1,168 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "@getpaseo/protocol/messages";
import { createExternalProcessEnv } from "./paseo-env.js";
import { findExecutable } from "../utils/executable.js";
import { spawnProcess } from "../utils/spawn.js";
interface EditorTargetDefinition {
id: KnownEditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
}
interface ListAvailableEditorTargetsDependencies {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null | Promise<string | null>;
}
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawnProcess;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "webstorm", label: "WebStorm", command: "webstorm" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
{
id: "file-manager",
label: "File Manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
function isAbsolutePath(value: string): boolean {
return posix.isAbsolute(value) || win32.isAbsolute(value);
}
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
if (!target) {
throw new Error(`Unknown editor target: ${editorId}`);
}
return target;
}
export async function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): Promise<EditorTargetDescriptorPayload[]> {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const supportedTargets = EDITOR_TARGETS.filter((target) =>
isTargetSupportedOnPlatform(target, platform),
);
const executables = await Promise.all(
supportedTargets.map((target) => findExecutableFn(target.command)),
);
const results: EditorTargetDescriptorPayload[] = [];
for (let i = 0; i < supportedTargets.length; i += 1) {
if (!executables[i]) continue;
const target = supportedTargets[i];
results.push({
id: target.id,
label: target.label,
});
}
return results;
}
interface Launch {
command: string;
args: string[];
}
async function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: (command: string) => string | null | Promise<string | null>;
}): Promise<Launch> {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = await input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
return {
command: executable,
args: [input.path],
};
}
export async function openInEditorTarget(
input: {
editorId: EditorTargetId;
path: string;
},
dependencies: OpenInEditorTargetDependencies = {},
): Promise<void> {
const platform = dependencies.platform ?? process.platform;
const pathToOpen = input.path.trim();
const existsSyncFn = dependencies.existsSync ?? existsSync;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const spawnFn = dependencies.spawn ?? spawnProcess;
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSyncFn(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = await resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(launch.command, launch.args, {
detached: true,
env: createExternalProcessEnv(process.env),
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+21 -107
View File
@@ -1,7 +1,5 @@
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { basename, resolve, sep } from "path";
@@ -10,7 +8,6 @@ import { z } from "zod";
import type { ToolSet } from "ai";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type AgentAttachment,
@@ -26,8 +23,6 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
@@ -47,7 +42,6 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import { getPidLockInfo } from "./pid-lock.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import {
@@ -337,7 +331,6 @@ const LEGACY_MODE_ICONS = new Set<string>([
"ShieldQuestionMark",
]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message;
@@ -418,10 +411,6 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void;
};
@@ -529,9 +518,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
);
const AgentIdSchema = z.string().uuid();
const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000;
const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available";
interface VoiceModeBaseConfig {
systemPrompt?: string;
}
@@ -817,21 +803,6 @@ export class Session {
private readonly terminalController: TerminalSessionController;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly availableEditorTargetsCache = new TTLCache<
string,
EditorTargetDescriptorPayload[]
>({
ttl: AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS,
max: 1,
checkAgeOnGet: true,
});
private readonly getMemoizedAvailableEditorTargets = pMemoize(
async () => this.resolveAvailableEditorTargets(),
{
cache: this.availableEditorTargetsCache,
cacheKey: () => AVAILABLE_EDITOR_TARGETS_CACHE_KEY,
},
);
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
@@ -1446,15 +1417,6 @@ export class Session {
return LEGACY_PROVIDER_IDS.has(provider);
}
private filterEditorsForClient(
editors: EditorTargetDescriptorPayload[],
): EditorTargetDescriptorPayload[] {
if (clientSupportsFlexibleEditorIds(this.appVersion)) {
return editors;
}
return editors.filter((editor) => isLegacyEditorTargetId(editor.id));
}
private agentThinkingOptionMatchesFilter(
agent: AgentSnapshotPayload,
filter: AgentUpdatesFilter,
@@ -2128,10 +2090,11 @@ export class Session {
return this.handleCreatePaseoWorktreeRequest(msg);
case "workspace_setup_status_request":
return this.handleWorkspaceSetupStatusRequest(msg);
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
case "list_available_editors_request":
return this.handleListAvailableEditorsRequest(msg);
return this.handleLegacyListAvailableEditorsRequest(msg);
case "open_in_editor_request":
return this.handleOpenInEditorRequest(msg);
return this.handleLegacyOpenInEditorRequest(msg);
case "open_project_request":
return this.handleOpenProjectRequest(msg);
case "archive_workspace_request":
@@ -7166,18 +7129,6 @@ export class Session {
});
}
async resolveAvailableEditorTargets(): Promise<EditorTargetDescriptorPayload[]> {
return listAvailableEditorTargets();
}
async getAvailableEditorTargets() {
return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceScriptRequest(
request: StartWorkspaceScriptRequest,
): Promise<void> {
@@ -7244,67 +7195,30 @@ export class Session {
}
}
private async handleListAvailableEditorsRequest(
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
private async handleLegacyListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
try {
const editors = await this.getAvailableEditorTargets();
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list available editors";
this.sessionLogger.error(
{ err: error, requestType: request.type },
"Failed to list available editors",
);
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: message,
},
});
}
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
},
});
}
private async handleOpenInEditorRequest(
private async handleLegacyOpenInEditorRequest(
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
try {
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open in editor";
this.sessionLogger.error(
{
err: error,
editorId: request.editorId,
path: request.path,
requestType: request.type,
},
"Failed to open in editor",
);
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: message,
},
});
}
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
},
});
}
private async handleCreatePaseoWorktreeRequest(
@@ -5,11 +5,7 @@ import path from "node:path";
import { expect, test, vi } from "vitest";
import { z } from "zod";
import { Session } from "./session.js";
import type {
AgentSnapshotPayload,
EditorTargetDescriptorPayload,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import type {
@@ -123,10 +119,6 @@ interface SessionTestAccess {
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
emit(message: unknown): void;
onMessage(message: unknown): void;
getAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
filterEditorsForClient(...args: unknown[]): unknown;
openEditorTarget(input: { editorId: string; path: string }): Promise<unknown>;
resolveAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
paseoHome: string;
terminalManager: {
killTerminal(id: string): unknown;
@@ -3492,137 +3484,16 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
test("legacy editor RPC requests return daemon unsupported errors", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" });
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
});
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find((message) => message.type === "list_available_editors_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
});
test("list_available_editors_request coalesces concurrent discovery", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
createSessionForWorkspaceTests({
appVersion: "0.1.50",
onMessage: (message) => emitted.push(message),
}),
);
let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {};
let discoveryCalls = 0;
vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => {
discoveryCalls += 1;
return await new Promise<EditorTargetDescriptorPayload[]>((resolve) => {
resolveDiscovery = resolve;
});
});
const first = session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-1",
});
const second = session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-2",
});
await Promise.resolve();
expect(discoveryCalls).toBe(1);
resolveDiscovery([{ id: "cursor", label: "Cursor" }]);
await Promise.all([first, second]);
const responses = emitted.filter(
(
message,
): message is Extract<SessionOutboundMessage, { type: "list_available_editors_response" }> =>
message.type === "list_available_editors_response",
);
expect(responses).toHaveLength(2);
expect(responses.map((response) => response.payload.requestId)).toEqual([
"req-editors-1",
"req-editors-2",
]);
expect(responses.map((response) => response.payload.editors)).toEqual([
[{ id: "cursor", label: "Cursor" }],
[{ id: "cursor", label: "Cursor" }],
]);
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-3",
});
expect(discoveryCalls).toBe(1);
});
test("list_available_editors_request filters unsupported ids for legacy clients", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" });
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "unknown-editor", label: "Unknown Editor" },
{ id: "finder", label: "Finder" },
]);
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-legacy",
});
const response = emitted.find((message) => message.type === "list_available_editors_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
]);
});
test("open_in_editor_request launches the selected target", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const calls: Array<{ editorId: string; path: string }> = [];
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.openEditorTarget = async (input: { editorId: string; path: string }) => {
calls.push(input);
};
await session.handleMessage({
type: "open_in_editor_request",
requestId: "req-open-editor",
@@ -3630,11 +3501,15 @@ test("open_in_editor_request launches the selected target", async () => {
path: REPO_CWD,
});
expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]);
const response = emitted.find((message) => message.type === "open_in_editor_response") as
| { payload: Record<string, unknown> }
| undefined;
expect(response?.payload.error).toBeNull();
const listResponse = findByType(emitted, "list_available_editors_response");
const openResponse = findByType(emitted, "open_in_editor_response");
expect(listResponse?.payload.editors).toEqual([]);
expect(listResponse?.payload.error).toBe(
"Editor opening moved to the desktop app and is no longer supported by the daemon",
);
expect(openResponse?.payload.error).toBe(
"Editor opening moved to the desktop app and is no longer supported by the daemon",
);
});
test("archive_workspace_request hides non-destructive workspace records", async () => {
+2
View File
@@ -16,6 +16,8 @@ Two ways to install:
- **Desktop app:** Settings → Integrations → Install
- **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent.
When the desktop app finds installed Paseo skills, it keeps the bundled skills up to date on startup. If automatic update fails, use Settings → Integrations → Update or the manual command above.
## `/paseo`, Paseo Reference
The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees.