mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -43,6 +43,22 @@ In any worktree-style or portless setup, never assume default ports.
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
source that drives frame production — stuck on a stale display. The compositor
|
||||
then stops producing frames and the window looks frozen: unresponsive to clicks
|
||||
and keys even though the renderer and every process stay alive. It self-recovers
|
||||
after a few minutes, which is too long for a foreground app.
|
||||
|
||||
`setupDarwinCompositorWatchdog`
|
||||
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
|
||||
this. It polls the renderer for frame production every couple of seconds and,
|
||||
after a sustained stall while the window is visible and unlocked, restarts the
|
||||
GPU process so Chromium rebuilds the display link. The probe is skipped while
|
||||
the screen is locked or the window is hidden or minimized, since a window
|
||||
legitimately stops producing frames then.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`
|
||||
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||
- Direction: `request` or `response`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-sRHRfPuhsoOx+wtEb+VYfs29wz3BdKu6vDYhaHr7LQE=
|
||||
sha256-t6vqegESlQGlbkOfjpjOHbf6ufiDeBl/ApFtQHvOeLc=
|
||||
|
||||
2459
package-lock.json
generated
2459
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
packages/app/e2e/helpers/rename.ts
Normal file
40
packages/app/e2e/helpers/rename.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
export function renameModalSubmit(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-submit`);
|
||||
}
|
||||
|
||||
export function renameModalError(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-error`);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||
|
||||
138
packages/app/e2e/sidebar-workspace-rename.spec.ts
Normal file
138
packages/app/e2e/sidebar-workspace-rename.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function workspaceRenameModalTestId(workspaceId: string, suffix: string): string {
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
expect(workspace.name).toBe("main");
|
||||
|
||||
const renameRequests = captureWsSessionFrames(
|
||||
page,
|
||||
"checkout.rename_branch.request",
|
||||
(inner) => ({
|
||||
branch: String(inner.branch ?? ""),
|
||||
cwd: String(inner.cwd ?? ""),
|
||||
}),
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
await expect(input).toHaveValue("main");
|
||||
await input.fill("Feature Rename 2");
|
||||
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
|
||||
"feature-rename-2",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
expect(renameRequests.length).toBeGreaterThan(0);
|
||||
expect(renameRequests.at(-1)).toEqual({
|
||||
branch: "feature-rename-2",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
const currentBranchOnDisk = execSync("git branch --show-current", {
|
||||
cwd: repo.path,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await input.fill("taken");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
|
||||
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
|
||||
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
||||
await expect(errorNode).toContainText(/already exists|branch/i);
|
||||
await expect(input).toBeVisible();
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
88
packages/app/e2e/workspace-agent-tab-rename.spec.ts
Normal file
88
packages/app/e2e/workspace-agent-tab-rename.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabVisible,
|
||||
} from "./helpers/archive-tab";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab rename", () => {
|
||||
test("right-click rename sends update_agent_request and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-agent-rename-");
|
||||
|
||||
try {
|
||||
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
|
||||
agentId: String(inner.agentId ?? ""),
|
||||
name: String(inner.name ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await openAgentInWorkspace(page, agent);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await expect(tab).toContainText(initialTitle, { timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "My Renamed Agent";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText(renamed, { timeout: 15_000 });
|
||||
|
||||
expect(updateFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = updateFrames.at(-1)!;
|
||||
expect(lastFrame.agentId).toBe(agent.id);
|
||||
expect(lastFrame.name).toBe(renamed);
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
67
packages/app/e2e/workspace-terminal-tab-rename.spec.ts
Normal file
67
packages/app/e2e/workspace-terminal-tab-rename.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-terminal-rename-");
|
||||
|
||||
try {
|
||||
const seeded = await client.openProject(repo.path);
|
||||
if (!seeded.workspace) {
|
||||
throw new Error(seeded.error ?? "Failed to seed workspace");
|
||||
}
|
||||
const workspaceId = seeded.workspace.id;
|
||||
|
||||
const created = await client.createTerminal(repo.path);
|
||||
if (!created.terminal) {
|
||||
throw new Error(created.error ?? "Failed to create terminal");
|
||||
}
|
||||
const terminalId = created.terminal.id;
|
||||
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await input.fill("My Renamed Terminal");
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
|
||||
expect(renameFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = renameFrames.at(-1)!;
|
||||
expect(lastFrame.terminalId).toBe(terminalId);
|
||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantInlinePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
@@ -9,5 +8,9 @@ export {
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkResolver } from "./use-resolver";
|
||||
export { useAssistantFileLinkActions } from "./use-file-link";
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
@@ -16,128 +9,66 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
interface AssistantInlinePathLinkProps {
|
||||
content: string;
|
||||
parsed: InlinePathTarget;
|
||||
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
style: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export function AssistantInlinePathLink({
|
||||
content,
|
||||
parsed,
|
||||
onPress,
|
||||
workspaceRoot,
|
||||
style,
|
||||
}: AssistantInlinePathLinkProps) {
|
||||
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
|
||||
const handleAnchorClickCapture = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onPress(parsed, "side");
|
||||
},
|
||||
[onPress, parsed],
|
||||
);
|
||||
|
||||
if (!isNative) {
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
|
||||
<a
|
||||
href={parsed.path}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
||||
{content}
|
||||
</Text>
|
||||
</a>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const href = source.href;
|
||||
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
|
||||
const handleAnchorClickCapture = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onPress(source, "side");
|
||||
},
|
||||
[onPress, source],
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onAuxPress();
|
||||
});
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
handlePrefetch();
|
||||
}, [handlePrefetch]);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
onHoverIn();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const tooltipFilePath = useMemo(
|
||||
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
|
||||
[source.href, workspaceRoot],
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
href={href}
|
||||
href={source.href}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onFocus={handlePrefetch}
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
|
||||
</a>
|
||||
);
|
||||
|
||||
if (tooltipFilePath) {
|
||||
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
return anchor;
|
||||
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
|
||||
interface AssistantMarkdownCodeLinkProps {
|
||||
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink
|
||||
source={source}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
<AssistantMarkdownLink source={source} style={style}>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function AssistantInlineCodePathLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
}: AssistantInlineCodePathLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantMarkdownCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkTooltipFilePath(
|
||||
href: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const classification = classifyAssistantFileLink(href, { workspaceRoot });
|
||||
if (classification?.kind !== "directFile") {
|
||||
return null;
|
||||
}
|
||||
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: InlinePathTarget,
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
}
|
||||
|
||||
export function AssistantInlineCodePathLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
}: AssistantInlineCodePathLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantMarkdownCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||
// the tooltip wrapper from breaking inline link flow.
|
||||
@@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
|
||||
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
||||
|
||||
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
|
||||
function FileLinkHoverTooltip({
|
||||
filePath,
|
||||
children,
|
||||
}: {
|
||||
filePath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!isWeb) {
|
||||
return children;
|
||||
}
|
||||
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
|
||||
<TooltipTrigger asChild>
|
||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
{filePath ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
87
packages/app/src/assistant-file-links/provider.tsx
Normal file
87
packages/app/src/assistant-file-links/provider.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
export interface AssistantFileLinkDaemonClient {
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverConfig {
|
||||
client?: AssistantFileLinkDaemonClient | null;
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
toast?: ToastApi | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverContextValue {
|
||||
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
const AssistantFileLinkResolverContext =
|
||||
createContext<AssistantFileLinkResolverContextValue | null>(null);
|
||||
|
||||
export function AssistantFileLinkResolverProvider({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
children,
|
||||
}: AssistantFileLinkResolverProviderProps) {
|
||||
const configRef = useRef<AssistantFileLinkResolverConfig>({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
});
|
||||
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
|
||||
|
||||
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
|
||||
const activeClient = configRef.current.client;
|
||||
if (!activeClient) {
|
||||
return { entries: [], error: null };
|
||||
}
|
||||
|
||||
const result = await activeClient.getDirectorySuggestions(input);
|
||||
return { entries: result.entries, error: result.error };
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AssistantFileLinkResolverContextValue>(
|
||||
() => ({ configRef, getDirectorySuggestions }),
|
||||
[getDirectorySuggestions],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantFileLinkResolverContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantFileLinkResolverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
|
||||
const context = useContext(AssistantFileLinkResolverContext);
|
||||
if (!context) {
|
||||
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type { AssistantFileLinkContext };
|
||||
@@ -1,16 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
type DirectorySuggestionResult,
|
||||
} from "./resolver";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
@@ -20,554 +19,201 @@ function resolvedSuggestions(
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
|
||||
getDirectorySuggestions: ReturnType<typeof vi.fn>;
|
||||
searches: Array<{
|
||||
query: string;
|
||||
cwd: string;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}>;
|
||||
} {
|
||||
const searches: Array<{
|
||||
query: string;
|
||||
cwd: string;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}> = [];
|
||||
const getDirectorySuggestions = vi.fn(
|
||||
async (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
},
|
||||
);
|
||||
return { getDirectorySuggestions, searches };
|
||||
}
|
||||
|
||||
interface DirectorySearch {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}
|
||||
describe("classifyForResolution", () => {
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
class FakeWorkspaceFiles {
|
||||
readonly searches: DirectorySearch[] = [];
|
||||
readonly openedFiles: OpenedFile[] = [];
|
||||
readonly unresolvedTokens: string[] = [];
|
||||
|
||||
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
|
||||
|
||||
createResolver() {
|
||||
return createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: this.getDirectorySuggestions,
|
||||
openWorkspaceFile: this.openWorkspaceFile,
|
||||
openExternalUrl: async () => {},
|
||||
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
private getDirectorySuggestions = async (
|
||||
search: DirectorySearch,
|
||||
): Promise<DirectorySuggestionResult> => {
|
||||
this.searches.push(search);
|
||||
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
|
||||
};
|
||||
it("preserves line ranges on direct workspace files", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
|
||||
|
||||
private openWorkspaceFile = (
|
||||
target: InlinePathTarget,
|
||||
disposition: OpenFileDisposition,
|
||||
): void => {
|
||||
this.openedFiles.push({ target, disposition });
|
||||
};
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "src/components/message.tsx:33-40",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
private onUnresolvedFileCandidate = (token: string): void => {
|
||||
this.unresolvedTokens.push(token);
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant file link resolver", () => {
|
||||
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
|
||||
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
|
||||
CONTEXT,
|
||||
);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await Promise.all([
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
]);
|
||||
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "dumm.md",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/src/dumm.md",
|
||||
lineStart: undefined,
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", () => {
|
||||
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "external", url: "http://dumm.md" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
CONTEXT,
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(true);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "external", url: "http://google.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("click consumes an in-flight hover resolution", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const suggestions = vi.fn(() => deferred.promise);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
||||
it("returns ignored for non-file-looking content", () => {
|
||||
const result = classifyForResolution({ href: "" }, CONTEXT);
|
||||
|
||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await prefetch;
|
||||
const result = await opened;
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
||||
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(openResult.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cache unresolved candidates", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
expect(second.opened).toBe(true);
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keys cache entries by server, workspace, and token", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
await resolver.open({
|
||||
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
||||
source,
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/other/two/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply stale async results after the active context changes", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
let isCurrent = true;
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(() => deferred.promise),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
isCurrentContext: () => isCurrent,
|
||||
});
|
||||
|
||||
const opened = resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
isCurrent = false;
|
||||
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
||||
const result = await opened;
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(false);
|
||||
expect(result.kind).toBe("file");
|
||||
});
|
||||
|
||||
it("opens direct workspace file links without querying suggestions", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves direct workspace file line ranges", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx:33-40" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx:33-40",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: 40,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens basename line refs when the daemon returns that exact filename", async () => {
|
||||
const workspaceFiles = new FakeWorkspaceFiles({
|
||||
describe("fetchDaemonResolution", () => {
|
||||
it("resolves daemon suggestions into workspace file targets", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
||||
});
|
||||
const resolver = workspaceFiles.createResolver();
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "file.ts:12",
|
||||
text: "file.ts:12",
|
||||
sourceType: "inline-code",
|
||||
const result = await fetchDaemonResolution({
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(workspaceFiles.searches).toEqual([
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "file.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(workspaceFiles.openedFiles).toEqual([
|
||||
{
|
||||
expect(result).toEqual({
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/packages/app/src/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon finds no match", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "src/file.ts",
|
||||
token: "src/file.ts",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/packages/app/src/file.ts",
|
||||
lineStart: 12,
|
||||
raw: "src/file.ts",
|
||||
path: "/Users/test/project/src/file.ts",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
},
|
||||
]);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
|
||||
const workspaceFiles = new FakeWorkspaceFiles({});
|
||||
const resolver = workspaceFiles.createResolver();
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "src/file.ts",
|
||||
text: "src/file.ts",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(workspaceFiles.searches).toEqual([
|
||||
{
|
||||
query: "src/file.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(workspaceFiles.openedFiles).toEqual([]);
|
||||
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "src/file.ts",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes side open disposition to workspace file links", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "side",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"side",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://dumm.md",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://google.com",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domain paths external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://openai.com/path",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open unresolved linkified markdown filenames in the browser", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const prefetchResult = await resolver.prefetch({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
});
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps failed ambiguous resolution out of the browser", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon throws", async () => {
|
||||
const getDirectorySuggestions = vi.fn(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "dumm.md",
|
||||
token: "dumm.md",
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
|
||||
@@ -4,12 +4,6 @@ import {
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
|
||||
sourceType?: "inline-code";
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionEntry {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverDependencies {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
openExternalUrl: (url: string) => void | Promise<void>;
|
||||
onUnresolvedFileCandidate?: (token: string) => void;
|
||||
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolver {
|
||||
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
|
||||
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkPrefetchInput {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
export type GetDirectorySuggestions = (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| { kind: "external"; url: string }
|
||||
| { kind: "file"; target: InlinePathTarget }
|
||||
| { kind: "ignored" };
|
||||
|
||||
export type AssistantFileLinkResolution =
|
||||
| { kind: "resolved"; value: ResolvedAssistantFileLink }
|
||||
| {
|
||||
kind: "external";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
target: InlinePathTarget;
|
||||
}
|
||||
| {
|
||||
kind: "unresolvedFileCandidate";
|
||||
kind: "needsLookup";
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
kind: "ignored";
|
||||
target: InlinePathTarget;
|
||||
};
|
||||
|
||||
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
||||
|
||||
interface ParsedAssistantFileLinkInteraction {
|
||||
export interface FetchDaemonResolutionInput {
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
classification: AssistantFileLinkClassification;
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
export function createAssistantFileLinkResolver(
|
||||
dependencies: AssistantFileLinkResolverDependencies,
|
||||
): AssistantFileLinkResolver {
|
||||
const cache = new Map<string, CachedAssistantFileLink>();
|
||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): Promise<ResolvedAssistantFileLink> {
|
||||
const parsed = parseInteraction(input);
|
||||
if (!parsed) {
|
||||
return { kind: "ignored" };
|
||||
}
|
||||
export async function fetchDaemonResolution({
|
||||
ambiguousQuery,
|
||||
token,
|
||||
target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
|
||||
const trimmedRoot = workspaceRoot?.trim();
|
||||
if (!trimmedRoot) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "external") {
|
||||
return { kind: "external", url: parsed.classification.raw };
|
||||
}
|
||||
let suggestions: DirectorySuggestionResult;
|
||||
try {
|
||||
suggestions = await getDirectorySuggestions({
|
||||
query: ambiguousQuery,
|
||||
cwd: trimmedRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
} catch {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.classification.kind === "directFile" &&
|
||||
!shouldResolveDirectFileThroughSuggestions({
|
||||
context: input.context,
|
||||
source: input.source,
|
||||
token: parsed.token,
|
||||
target: parsed.classification.target,
|
||||
})
|
||||
) {
|
||||
return { kind: "file", target: parsed.classification.target };
|
||||
}
|
||||
|
||||
const key = getResolutionKey(input.context, parsed.token);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const active = inFlight.get(key);
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
const request = resolveAmbiguousCandidate({
|
||||
context: input.context,
|
||||
token: parsed.token,
|
||||
target: parsed.classification.target,
|
||||
getDirectorySuggestions: dependencies.getDirectorySuggestions,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.kind === "file") {
|
||||
cache.set(key, result);
|
||||
}
|
||||
inFlight.delete(key);
|
||||
return result;
|
||||
})
|
||||
.catch((): CachedAssistantFileLink => {
|
||||
inFlight.delete(key);
|
||||
return { kind: "unresolvedFileCandidate", token: parsed.token };
|
||||
});
|
||||
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
return {
|
||||
prefetch(input) {
|
||||
return resolve(input);
|
||||
},
|
||||
async open(input) {
|
||||
const resolved = await resolve(input);
|
||||
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
||||
return { ...resolved, opened: false };
|
||||
}
|
||||
...target,
|
||||
path: joinWorkspacePath(trimmedRoot, match.path),
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
dependencies.openWorkspaceFile(resolved.target, input.disposition);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
await dependencies.openExternalUrl(resolved.url);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
if (classification.kind === "external") {
|
||||
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
|
||||
}
|
||||
if (
|
||||
classification.kind === "directFile" &&
|
||||
!shouldResolveDirectFileThroughSuggestions({
|
||||
context,
|
||||
source,
|
||||
token,
|
||||
target: classification.target,
|
||||
})
|
||||
) {
|
||||
return { kind: "resolved", value: { kind: "file", target: classification.target } };
|
||||
}
|
||||
|
||||
if (resolved.kind === "unresolvedFileCandidate") {
|
||||
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
||||
}
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
return { ...resolved, opened: false };
|
||||
},
|
||||
return {
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
|
||||
token,
|
||||
target: classification.target,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
|
||||
return source.href;
|
||||
}
|
||||
|
||||
function parseInteraction(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): ParsedAssistantFileLinkInteraction | null {
|
||||
const token = getAssistantFileLinkToken(input.source).trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: input.context.workspaceRoot,
|
||||
});
|
||||
if (!classification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { token, classification };
|
||||
}
|
||||
|
||||
async function resolveAmbiguousCandidate(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
|
||||
}): Promise<CachedAssistantFileLink> {
|
||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
|
||||
const suggestions = await input.getDirectorySuggestions({
|
||||
query,
|
||||
cwd: workspaceRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "file",
|
||||
target: {
|
||||
...input.target,
|
||||
path: joinWorkspacePath(workspaceRoot, match.path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot: string,
|
||||
): string {
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
token: string;
|
||||
@@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
||||
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||
}
|
||||
|
||||
function canApplyResult(
|
||||
context: AssistantFileLinkContext,
|
||||
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
|
||||
): boolean {
|
||||
return isCurrentContext ? isCurrentContext(context) : true;
|
||||
}
|
||||
|
||||
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return root ? `${root}/${child}` : child;
|
||||
}
|
||||
|
||||
export type { AssistantFileLinkClassification };
|
||||
|
||||
328
packages/app/src/assistant-file-links/use-file-link.test.tsx
Normal file
328
packages/app/src/assistant-file-links/use-file-link.test.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||
import type { DirectorySuggestionResult } from "./resolver";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const SOURCE = {
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "linkify",
|
||||
};
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
interface TestClient {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
}
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper(input: {
|
||||
client: TestClient;
|
||||
openedFiles: OpenedFile[];
|
||||
toast?: {
|
||||
show: ReturnType<typeof vi.fn>;
|
||||
copied: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
const queryClient = createQueryClient();
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
const openWorkspaceFile = useCallback(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
input.openedFiles.push({ target, disposition });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={input.client}
|
||||
serverId="server-1"
|
||||
workspaceRoot="/Users/test/project"
|
||||
onOpenWorkspaceFile={openWorkspaceFile}
|
||||
toast={input.toast}
|
||||
>
|
||||
{children}
|
||||
</AssistantFileLinkResolverProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFileLink", () => {
|
||||
it("returns the same object across no-op parent rerenders", () => {
|
||||
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const queryClient = createQueryClient();
|
||||
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
|
||||
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
|
||||
children?: ReactNode;
|
||||
}
|
||||
>;
|
||||
|
||||
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(
|
||||
Provider,
|
||||
{
|
||||
client: { getDirectorySuggestions },
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
onOpenWorkspaceFile: () => {},
|
||||
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
|
||||
wrapper: ChurningProviderWrapper,
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
expect(result.current.onHoverIn).toBe(first.onHoverIn);
|
||||
expect(result.current.onPress).toBe(first.onPress);
|
||||
expect(result.current.onAuxPress).toBe(first.onAuxPress);
|
||||
expect(result.current.open).toBe(first.open);
|
||||
});
|
||||
|
||||
it("does not cache unresolved lookups forever", async () => {
|
||||
const getDirectorySuggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
toast,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toEqual([
|
||||
{
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("click retries after hover prefetch fails", async () => {
|
||||
const getDirectorySuggestions = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toHaveLength(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("dedupes two links pointing at the same source", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
first: useFileLink(SOURCE),
|
||||
second: useFileLink(SOURCE),
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.first.onHoverIn();
|
||||
result.current.second.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
await waitFor(() => {
|
||||
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
});
|
||||
});
|
||||
|
||||
it("hover then click uses the prefetched result", async () => {
|
||||
const getDirectorySuggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||
);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toHaveLength(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not open a stale result after the workspace changes", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
|
||||
const client = useMemo(() => ({ getDirectorySuggestions }), []);
|
||||
const openWorkspaceFile = useCallback(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
openedFiles.push({ target, disposition });
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenWorkspaceFile={openWorkspaceFile}
|
||||
>
|
||||
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
|
||||
{children}
|
||||
</WorkspaceSwitchContext.Provider>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
link: useFileLink(SOURCE),
|
||||
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.link.onPress();
|
||||
});
|
||||
act(() => {
|
||||
result.current.setWorkspaceRoot("/Users/test/other");
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(openedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});
|
||||
347
packages/app/src/assistant-file-links/use-file-link.ts
Normal file
347
packages/app/src/assistant-file-links/use-file-link.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import {
|
||||
useAssistantFileLinkResolverContext,
|
||||
type AssistantFileLinkResolverContextValue,
|
||||
} from "./provider";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkResolution,
|
||||
type AssistantFileLinkSource,
|
||||
} from "./resolver";
|
||||
|
||||
export interface UseFileLinkResult {
|
||||
target: InlinePathTarget | null;
|
||||
onHoverIn: () => void;
|
||||
onPress: () => void;
|
||||
onAuxPress: () => void;
|
||||
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkActions {
|
||||
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
|
||||
canOpen(source: AssistantFileLinkSource): boolean;
|
||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||
}
|
||||
|
||||
type AssistantFileLinkQueryKey = readonly [
|
||||
"assistantFileLink",
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
];
|
||||
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const queryClient = useQueryClient();
|
||||
const stableSource = useStableSource(source);
|
||||
const activeConfig = context.configRef.current;
|
||||
const workspaceRoot = activeConfig.workspaceRoot;
|
||||
const serverId = activeConfig.serverId;
|
||||
const resolution = useMemo(
|
||||
() =>
|
||||
classifyForResolution(stableSource, {
|
||||
workspaceRoot,
|
||||
}),
|
||||
[stableSource, workspaceRoot],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
resolution.kind === "needsLookup"
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
if (resolution.kind !== "needsLookup") {
|
||||
throw new Error("Assistant file link lookup requested for a sync link.");
|
||||
}
|
||||
return fetchDaemonResolution({
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
target: resolution.target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||
});
|
||||
},
|
||||
enabled: false,
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const open = useStableEvent(
|
||||
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
openAssistantFileLink({
|
||||
source: nextSource,
|
||||
disposition,
|
||||
context,
|
||||
queryClient,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const onHoverIn = useStableEvent(() => {
|
||||
if (resolution.kind !== "needsLookup") {
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
target: resolution.target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||
}),
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
});
|
||||
|
||||
const onPress = useStableEvent(() => {
|
||||
open(stableSource, "main");
|
||||
});
|
||||
const onAuxPress = useStableEvent(() => {
|
||||
open(stableSource, "side");
|
||||
});
|
||||
|
||||
const target = useMemo(() => {
|
||||
if (resolution.kind === "resolved") {
|
||||
return resolution.value.kind === "file" ? resolution.value.target : null;
|
||||
}
|
||||
return query.data ?? null;
|
||||
}, [query.data, resolution]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
|
||||
[target, onHoverIn, onPress, onAuxPress, open],
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const actionLink = useFileLink(ACTION_LINK_SOURCE);
|
||||
|
||||
const open = useStableEvent(
|
||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
actionLink.open(source, disposition);
|
||||
},
|
||||
);
|
||||
const canOpen = useCallback(
|
||||
(source: AssistantFileLinkSource) =>
|
||||
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
|
||||
[context.configRef],
|
||||
);
|
||||
const canResolveFile = useCallback(
|
||||
(source: AssistantFileLinkSource) =>
|
||||
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
|
||||
[context.configRef],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
|
||||
}
|
||||
|
||||
function openAssistantFileLink(input: {
|
||||
source: AssistantFileLinkSource;
|
||||
disposition: OpenFileDisposition;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
}): void {
|
||||
const capturedConfig = input.context.configRef.current;
|
||||
const capturedResolution = classifyForResolution(input.source, {
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
});
|
||||
|
||||
if (capturedResolution.kind === "resolved") {
|
||||
void dispatchResolvedLink({
|
||||
resolution: capturedResolution,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const target = await input.queryClient.fetchQuery({
|
||||
queryKey: capturedQueryKey,
|
||||
queryFn: () =>
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
target: capturedResolution.target,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
getDirectorySuggestions: input.context.getDirectorySuggestions,
|
||||
}),
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
await dispatchFileTarget({
|
||||
target,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatchUnresolvedError({
|
||||
error,
|
||||
fallbackToken: capturedResolution.token,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
}
|
||||
|
||||
function canOpenAssistantFileLink(
|
||||
source: AssistantFileLinkSource,
|
||||
workspaceRoot: string | undefined,
|
||||
): boolean {
|
||||
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
|
||||
}
|
||||
|
||||
function canResolveAssistantFileLinkToFile(
|
||||
source: AssistantFileLinkSource,
|
||||
workspaceRoot: string | undefined,
|
||||
): boolean {
|
||||
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
|
||||
}
|
||||
|
||||
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
|
||||
const { href, text, markup, sourceInfo, sourceType } = source;
|
||||
return useMemo(
|
||||
() => ({ href, text, markup, sourceInfo, sourceType }),
|
||||
[href, text, markup, sourceInfo, sourceType],
|
||||
);
|
||||
}
|
||||
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
|
||||
disposition: OpenFileDisposition;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const { value } = input.resolution;
|
||||
if (value.kind === "file") {
|
||||
await dispatchFileTarget({
|
||||
target: value.target,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: input.capturedServerId,
|
||||
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value.kind === "external") {
|
||||
await dispatchExternalUrl({
|
||||
url: value.url,
|
||||
capturedServerId: input.capturedServerId,
|
||||
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFileTarget(input: {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
current.onOpenWorkspaceFile?.(input.target, input.disposition);
|
||||
}
|
||||
|
||||
async function dispatchExternalUrl(input: {
|
||||
url: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await openExternalUrl(input.url);
|
||||
}
|
||||
|
||||
async function dispatchUnresolvedError(input: {
|
||||
error: unknown;
|
||||
fallbackToken: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||
current.toast?.show(`No file found for ${token}`, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
}
|
||||
|
||||
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
|
||||
href: "",
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
type AssistantFileLinkContext,
|
||||
type AssistantFileLinkOpenInput,
|
||||
type AssistantFileLinkPrefetchInput,
|
||||
} from "./resolver";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
export interface UseAssistantFileLinkResolverOptions {
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
toast?: ToastApi | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkActions {
|
||||
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
|
||||
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
|
||||
const context: AssistantFileLinkContext = useMemo(
|
||||
() => ({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
}),
|
||||
[serverId, workspaceRoot],
|
||||
);
|
||||
const latestContextRef = useRef(context);
|
||||
latestContextRef.current = context;
|
||||
|
||||
const resolver = useMemo(
|
||||
() =>
|
||||
createAssistantFileLinkResolver({
|
||||
async getDirectorySuggestions(input) {
|
||||
if (!client) {
|
||||
return { entries: [], error: null };
|
||||
}
|
||||
|
||||
const result = await client.getDirectorySuggestions(input);
|
||||
return {
|
||||
entries: result.entries,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
openWorkspaceFile(target, disposition) {
|
||||
onOpenWorkspaceFile?.(target, disposition);
|
||||
},
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate(token) {
|
||||
toast?.show(`No file found for ${token}`, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
},
|
||||
isCurrentContext(candidate) {
|
||||
const current = latestContextRef.current;
|
||||
return (
|
||||
current.serverId === candidate.serverId &&
|
||||
current.workspaceRoot === candidate.workspaceRoot
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, onOpenWorkspaceFile, toast],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
prefetch(input) {
|
||||
void resolver.prefetch({ ...input, context });
|
||||
},
|
||||
open(input) {
|
||||
void resolver.open({ ...input, context });
|
||||
},
|
||||
}),
|
||||
[context, resolver],
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
adaptiveInputPlaceholder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
@@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & {
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
|
||||
const {
|
||||
value: _value,
|
||||
initialValue,
|
||||
resetKey,
|
||||
defaultValue,
|
||||
style,
|
||||
placeholderTextColor,
|
||||
...inputProps
|
||||
} = props;
|
||||
// Recolor the browser's :focus-visible outline (defined in public/index.html)
|
||||
// so it matches the active theme's accent instead of its hard-coded fallback.
|
||||
// Consumer style wins if it sets outlineColor explicitly.
|
||||
const textInputProps = {
|
||||
...inputProps,
|
||||
defaultValue: initialValue ?? defaultValue,
|
||||
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
|
||||
style: [styles.adaptiveInputOutline, style],
|
||||
};
|
||||
|
||||
if (isMobile && isNative) {
|
||||
|
||||
@@ -74,7 +74,10 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
@@ -83,6 +86,7 @@ import {
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const handleInlinePathPress = useCallback(
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
checkout,
|
||||
});
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
agent.projectPlacement?.checkout?.isGit,
|
||||
isMobile,
|
||||
openFileExplorerForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
setExplorerTabForCheckout,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
},
|
||||
[handleInlinePathPress],
|
||||
);
|
||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
belowItem,
|
||||
});
|
||||
return (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={resolvedServerId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenWorkspaceFile={handleInlinePathPress}
|
||||
toast={toast}
|
||||
spacing={spacing}
|
||||
/>
|
||||
>
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={spacing}
|
||||
/>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
|
||||
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
|
||||
@@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import {
|
||||
AssistantInlineCodePathLink,
|
||||
classifyAssistantFileLink,
|
||||
type AssistantFileLinkSource,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkResolver,
|
||||
useAssistantFileLinkActions,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
toast?: ToastApi | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
}
|
||||
|
||||
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
client,
|
||||
toast,
|
||||
spacing = "default",
|
||||
}: AssistantMessageProps) {
|
||||
const markdownParser = useMemo(() => {
|
||||
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return parser;
|
||||
}, []);
|
||||
|
||||
const fileLinkResolver = useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile: onInlinePathPress,
|
||||
toast,
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
fileLinkActions.open({ href: url }, "main");
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleLinkPress = useCallback(
|
||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
fileLinkResolver.open({ source, disposition });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleLinkPrefetch = useCallback(
|
||||
(source: AssistantFileLinkSource) => {
|
||||
fileLinkResolver.prefetch({ source });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleMarkdownLinkPress = useCallback(
|
||||
(url: string) => {
|
||||
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
|
||||
const markdownRules = useMemo<RenderRules>(() => {
|
||||
return {
|
||||
text: (
|
||||
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
||||
const inlineCodeSource: AssistantFileLinkSource = {
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
};
|
||||
const shouldResolveInlinePath =
|
||||
onInlinePathPress &&
|
||||
!isLinkedInlineCode &&
|
||||
inlineCodeFileLink &&
|
||||
inlineCodeFileLink.kind !== "external";
|
||||
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
|
||||
|
||||
if (shouldResolveInlinePath) {
|
||||
return (
|
||||
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
key={node.key}
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
handleLinkPrefetch,
|
||||
handleLinkPress,
|
||||
markdownParser,
|
||||
onInlinePathPress,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
@@ -9,6 +9,7 @@ const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
kiro: PackagePlus,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
368
packages/app/src/components/rename-modal.test.tsx
Normal file
368
packages/app/src/components/rename-modal.test.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdaptiveRenameModal } from "./rename-modal";
|
||||
|
||||
const { theme, adaptiveInputState } = vi.hoisted(() => ({
|
||||
adaptiveInputState: {
|
||||
latestProps: null as {
|
||||
onChangeText?: (next: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
} | null,
|
||||
},
|
||||
theme: {
|
||||
spacing: { 2: 8, 3: 12 },
|
||||
fontSize: { sm: 13, base: 15 },
|
||||
borderRadius: { md: 6 },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
palette: { red: { 300: "#f87171" } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", async () => {
|
||||
const ReactModule = await import("react");
|
||||
const AdaptiveModalSheet = ({
|
||||
visible,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
return ReactModule.createElement(
|
||||
"div",
|
||||
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
|
||||
ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": "adaptive-modal-sheet-close",
|
||||
onClick: onClose,
|
||||
},
|
||||
"Close",
|
||||
),
|
||||
children,
|
||||
);
|
||||
};
|
||||
// Mirrors production AdaptiveTextInput: native-owned input seeded by
|
||||
// initialValue, remounted (via key) when resetKey changes so the new
|
||||
// initialValue takes effect.
|
||||
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
|
||||
(props, ref) => {
|
||||
const p = props as {
|
||||
initialValue?: string;
|
||||
defaultValue?: string;
|
||||
editable?: boolean;
|
||||
maxLength?: number;
|
||||
testID?: string;
|
||||
onChangeText?: (next: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
};
|
||||
adaptiveInputState.latestProps = {
|
||||
onChangeText: p.onChangeText,
|
||||
onSubmitEditing: p.onSubmitEditing,
|
||||
};
|
||||
return ReactModule.createElement("input", {
|
||||
ref,
|
||||
defaultValue: p.initialValue ?? p.defaultValue ?? "",
|
||||
disabled: p.editable === false,
|
||||
maxLength: p.maxLength,
|
||||
"data-testid": p.testID,
|
||||
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
|
||||
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
p.onSubmitEditing?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
return { AdaptiveModalSheet, AdaptiveTextInput };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const ReactModule = await import("react");
|
||||
return {
|
||||
Button: ({
|
||||
children,
|
||||
onPress,
|
||||
disabled,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) =>
|
||||
ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
disabled: disabled || undefined,
|
||||
onClick: () => !disabled && onPress?.(),
|
||||
},
|
||||
children,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
adaptiveInputState.latestProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
interface RenderOptions {
|
||||
visible?: boolean;
|
||||
initialValue?: string;
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
onClose?: () => void;
|
||||
onSubmit?: (value: string) => Promise<void> | void;
|
||||
validate?: (value: string) => string | null;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
function renderModal(options: RenderOptions = {}): void {
|
||||
const {
|
||||
visible = true,
|
||||
initialValue = "",
|
||||
title = "Rename",
|
||||
placeholder,
|
||||
submitLabel,
|
||||
onClose = vi.fn(),
|
||||
onSubmit = vi.fn(),
|
||||
validate,
|
||||
maxLength,
|
||||
} = options;
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={title}
|
||||
initialValue={initialValue}
|
||||
placeholder={placeholder}
|
||||
submitLabel={submitLabel}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
maxLength={maxLength}
|
||||
testID="rename-modal"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function queryInput(): HTMLInputElement | null {
|
||||
return document.querySelector<HTMLInputElement>('[data-testid="rename-modal-input"]');
|
||||
}
|
||||
|
||||
function querySubmit(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-submit"]');
|
||||
}
|
||||
|
||||
function queryCancel(): HTMLButtonElement | null {
|
||||
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-cancel"]');
|
||||
}
|
||||
|
||||
function queryError(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[data-testid="rename-modal-error"]');
|
||||
}
|
||||
|
||||
function click(element: Element | null): void {
|
||||
if (!element) throw new Error("Cannot click null element");
|
||||
act(() => {
|
||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function typeInto(value: string): void {
|
||||
act(() => {
|
||||
adaptiveInputState.latestProps?.onChangeText?.(value);
|
||||
});
|
||||
}
|
||||
|
||||
function pressEnter(): void {
|
||||
act(() => {
|
||||
adaptiveInputState.latestProps?.onSubmitEditing?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("RenameModal", () => {
|
||||
it("renders with the initial value pre-filled and selects it after open", async () => {
|
||||
vi.useFakeTimers();
|
||||
renderModal({ initialValue: "main" });
|
||||
const input = queryInput();
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("main");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
const focused = document.activeElement as HTMLInputElement | null;
|
||||
expect(focused).toBe(input);
|
||||
expect(focused?.selectionStart).toBe(0);
|
||||
expect(focused?.selectionEnd).toBe("main".length);
|
||||
});
|
||||
|
||||
it("submits on Enter keypress in the input when the value has changed", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ initialValue: "feature", onSubmit, onClose });
|
||||
|
||||
typeInto("feature-2");
|
||||
pressEnter();
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith("feature-2");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ initialValue: "main", onClose, onSubmit });
|
||||
|
||||
click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables submit when draft equals initialValue and re-enables after a change", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ initialValue: "main", onSubmit });
|
||||
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
|
||||
pressEnter();
|
||||
await flush();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
typeInto("main-v2");
|
||||
await flush();
|
||||
expect(querySubmit()?.disabled).toBe(false);
|
||||
|
||||
typeInto("main");
|
||||
await flush();
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces validate errors inline and blocks submission", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null));
|
||||
renderModal({ initialValue: "ok", validate, onSubmit });
|
||||
|
||||
typeInto("bad");
|
||||
const submit = querySubmit()!;
|
||||
expect(submit.disabled).toBe(true);
|
||||
|
||||
pressEnter();
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
const errorNode = queryError();
|
||||
expect(errorNode?.textContent).toContain("Invalid name");
|
||||
});
|
||||
|
||||
it("disables the submit button while onSubmit is pending", async () => {
|
||||
let resolve: () => void = () => {};
|
||||
const onSubmit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
renderModal({ initialValue: "main", onSubmit });
|
||||
|
||||
typeInto("main-renamed");
|
||||
click(querySubmit());
|
||||
await flush();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(querySubmit()?.disabled).toBe(true);
|
||||
expect(queryCancel()?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the modal open with an error when onSubmit rejects", async () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ initialValue: "main", onSubmit, onClose });
|
||||
|
||||
typeInto("main-renamed");
|
||||
click(querySubmit());
|
||||
await flush();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(queryError()?.textContent).toContain("Server said no");
|
||||
expect(querySubmit()?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
195
packages/app/src/components/rename-modal.tsx
Normal file
195
packages/app/src/components/rename-modal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface AdaptiveRenameModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
initialValue: string;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (value: string) => Promise<void> | void;
|
||||
validate?: (value: string) => string | null;
|
||||
maxLength?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function AdaptiveRenameModal({
|
||||
visible,
|
||||
title,
|
||||
initialValue,
|
||||
placeholder,
|
||||
submitLabel = "Rename",
|
||||
onClose,
|
||||
onSubmit,
|
||||
validate,
|
||||
maxLength,
|
||||
testID,
|
||||
}: AdaptiveRenameModalProps) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setDraft(initialValue);
|
||||
setError(null);
|
||||
setIsPending(false);
|
||||
}, [visible, initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const length = initialValue.length;
|
||||
const timeout = setTimeout(() => {
|
||||
const node = inputRef.current;
|
||||
if (!node) return;
|
||||
node.focus();
|
||||
if (isWeb && node instanceof HTMLInputElement) {
|
||||
node.setSelectionRange(0, length);
|
||||
} else if (!isWeb && length > 0) {
|
||||
node.setNativeProps({ selection: { start: 0, end: length } });
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, initialValue]);
|
||||
|
||||
const computeError = useCallback(
|
||||
(value: string): string | null => {
|
||||
if (!value.trim()) return "Name is required";
|
||||
return validate ? validate(value) : null;
|
||||
},
|
||||
[validate],
|
||||
);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isPending) return;
|
||||
const value = draft;
|
||||
if (value === initialValue) return;
|
||||
const validationError = computeError(value);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsPending(true);
|
||||
await onSubmit(value);
|
||||
setIsPending(false);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setIsPending(false);
|
||||
const message = err instanceof Error && err.message ? err.message : "Unable to save";
|
||||
setError(message);
|
||||
}
|
||||
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isPending) return;
|
||||
onClose();
|
||||
}, [isPending, onClose]);
|
||||
|
||||
const handleSubmitVoid = useCallback(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null;
|
||||
const inputTestID = testID ? `${testID}-input` : undefined;
|
||||
const errorTestID = testID ? `${testID}-error` : undefined;
|
||||
const submitTestID = testID ? `${testID}-submit` : undefined;
|
||||
const cancelTestID = testID ? `${testID}-cancel` : undefined;
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={handleCancel}
|
||||
header={sheetHeader}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialValue}
|
||||
onChangeText={handleChange}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isPending}
|
||||
maxLength={maxLength}
|
||||
onSubmitEditing={handleSubmitVoid}
|
||||
style={styles.input}
|
||||
testID={inputTestID}
|
||||
/>
|
||||
{error ? (
|
||||
<Text style={styles.errorText} testID={errorTestID}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleCancel}
|
||||
disabled={isPending}
|
||||
testID={cancelTestID}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleSubmitVoid}
|
||||
disabled={submitDisabled}
|
||||
testID={submitTestID}
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
SquareTerminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
@@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps {
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
@@ -565,6 +571,7 @@ const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
@@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isHovered: boolean;
|
||||
@@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
return (
|
||||
@@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -701,6 +711,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -710,6 +721,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -750,6 +762,15 @@ function WorkspaceKebabMenu({
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</View>
|
||||
{prHint ? (
|
||||
@@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (branch: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||
if (!payload.success || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||
}
|
||||
return { targetCwd };
|
||||
},
|
||||
onSuccess: async ({ targetCwd }) => {
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: workspace.serverId,
|
||||
cwd: targetCwd,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseRename = useCallback(() => {
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(slugify(value));
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||
const result = validateBranchSlug(slugify(value));
|
||||
if (result.valid) return null;
|
||||
return result.error ?? "Invalid branch name";
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.name}
|
||||
placeholder="branch-name"
|
||||
submitLabel="Rename"
|
||||
validate={validateRenameSlug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ interface SplitContainerProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
@@ -362,6 +363,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -577,6 +579,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -716,6 +719,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -768,6 +772,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -813,6 +818,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -864,6 +870,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -1004,6 +1011,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
|
||||
@@ -240,6 +240,15 @@ const CATALOG_DATA = [
|
||||
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
|
||||
command: ["kilo", "acp"],
|
||||
},
|
||||
{
|
||||
id: "kiro",
|
||||
title: "Kiro CLI",
|
||||
description: "Amazon's AI coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
installLink: "https://kiro.dev/docs/cli/acp/",
|
||||
command: ["kiro-cli", "acp"],
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
title: "Kimi CLI",
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ACP provider catalog", () => {
|
||||
});
|
||||
|
||||
it("bundles SVG icons for catalog entries that declare an icon", () => {
|
||||
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes");
|
||||
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg !== null);
|
||||
|
||||
expect(entriesWithIcons.length).toBeGreaterThan(0);
|
||||
for (const entry of entriesWithIcons) {
|
||||
@@ -39,6 +39,7 @@ describe("ACP provider catalog", () => {
|
||||
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHostRuntimeSnapshot,
|
||||
useHostMutations,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
|
||||
const RESTART_CONFIRMATION_MESSAGE =
|
||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||
@@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" };
|
||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||
|
||||
@@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { renameHost } = useHostMutations();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftLabel, setDraftLabel] = useState(host.label ?? "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftLabel(host.label ?? "");
|
||||
}, [host.serverId, host.label]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
const timeout = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
return undefined;
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const nextLabel = draftLabel.trim();
|
||||
if (!nextLabel) {
|
||||
Alert.alert("Label required", "Enter a label for this host.");
|
||||
return;
|
||||
}
|
||||
if (isSaving) return;
|
||||
if (nextLabel === host.label.trim()) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const nextLabel = value.trim();
|
||||
if (nextLabel === host.label.trim()) return;
|
||||
await renameHost(host.serverId, nextLabel);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("[HostPage] Failed to rename host", error);
|
||||
Alert.alert("Error", "Unable to save host");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftLabel, host.label, host.serverId, isSaving, renameHost]);
|
||||
},
|
||||
[host.label, host.serverId, renameHost],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsEditing(false);
|
||||
}, [host.label, isSaving]);
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsEditing(true);
|
||||
}, [host.label]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
const openEditor = useCallback(() => setIsEditing(true), []);
|
||||
const closeEditor = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleStartEdit}
|
||||
onPress={openEditor}
|
||||
hitSlop={8}
|
||||
style={styles.identityEditButton}
|
||||
accessibilityRole="button"
|
||||
@@ -245,48 +204,16 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
<AdaptiveRenameModal
|
||||
visible={isEditing}
|
||||
onClose={handleCancel}
|
||||
header={RENAME_HOST_HEADER}
|
||||
title="Rename host"
|
||||
initialValue={host.label}
|
||||
placeholder="My Host"
|
||||
submitLabel="Save"
|
||||
onClose={closeEditor}
|
||||
onSubmit={handleSubmit}
|
||||
testID="host-page-rename-modal"
|
||||
>
|
||||
<View style={styles.renameBody}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={draftLabel}
|
||||
onChangeText={setDraftLabel}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
onSubmitEditing={handleSavePress}
|
||||
style={styles.renameInput}
|
||||
testID="host-page-label-input"
|
||||
/>
|
||||
<View style={styles.renameActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={handleSavePress}
|
||||
disabled={isSaving}
|
||||
testID="host-page-label-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
renameBody: {
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
renameInput: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
renameActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
|
||||
125
packages/app/src/screens/workspace/use-workspace-tab-rename.tsx
Normal file
125
packages/app/src/screens/workspace/use-workspace-tab-rename.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { type QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
interface RenamingTabState {
|
||||
kind: "terminal" | "agent";
|
||||
id: string;
|
||||
currentTitle: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceTabRenameInput {
|
||||
client: DaemonClient | null;
|
||||
normalizedServerId: string;
|
||||
queryClient: QueryClient;
|
||||
terminalsData: ListTerminalsResponse["payload"] | undefined;
|
||||
terminalsQueryKey: readonly unknown[];
|
||||
}
|
||||
|
||||
interface UseWorkspaceTabRenameResult {
|
||||
renamingTab: RenamingTabState | null;
|
||||
handleRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
handleRenameModalSubmit: (nextTitle: string) => Promise<void>;
|
||||
handleRenameModalClose: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceTabRename(
|
||||
input: UseWorkspaceTabRenameInput,
|
||||
): UseWorkspaceTabRenameResult {
|
||||
const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input;
|
||||
const [renamingTab, setRenamingTab] = useState<RenamingTabState | null>(null);
|
||||
|
||||
const handleRenameTab = useCallback(
|
||||
(tab: WorkspaceTabDescriptor) => {
|
||||
if (tab.target.kind === "terminal") {
|
||||
const { terminalId } = tab.target;
|
||||
const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null;
|
||||
const currentTitle = terminal?.title ?? terminal?.name ?? "";
|
||||
setRenamingTab({ kind: "terminal", id: terminalId, currentTitle });
|
||||
return;
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
const agent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
const currentTitle = agent?.title ?? "";
|
||||
setRenamingTab({ kind: "agent", id: agentId, currentTitle });
|
||||
}
|
||||
},
|
||||
[normalizedServerId, terminalsData],
|
||||
);
|
||||
|
||||
const handleRenameModalSubmit = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
if (!renamingTab) return;
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const trimmed = nextTitle.trim();
|
||||
if (renamingTab.kind === "terminal") {
|
||||
const result = await client.renameTerminal({
|
||||
terminalId: renamingTab.id,
|
||||
title: trimmed,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to rename terminal");
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
return;
|
||||
}
|
||||
await client.updateAgent(renamingTab.id, { name: trimmed });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", normalizedServerId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", normalizedServerId],
|
||||
});
|
||||
},
|
||||
[client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey],
|
||||
);
|
||||
|
||||
const handleRenameModalClose = useCallback(() => {
|
||||
setRenamingTab(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
renamingTab,
|
||||
handleRenameTab,
|
||||
handleRenameModalSubmit,
|
||||
handleRenameModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceTabRenameModalProps {
|
||||
renamingTab: RenamingTabState | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (nextTitle: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function WorkspaceTabRenameModal({
|
||||
renamingTab,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: WorkspaceTabRenameModalProps) {
|
||||
const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent";
|
||||
const initialValue = renamingTab?.currentTitle ?? "";
|
||||
const testID = renamingTab
|
||||
? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}`
|
||||
: undefined;
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
visible={renamingTab !== null}
|
||||
title={title}
|
||||
initialValue={initialValue}
|
||||
submitLabel="Rename"
|
||||
maxLength={200}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ArrowRightToLine,
|
||||
Columns2,
|
||||
Copy,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
Globe,
|
||||
@@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
@@ -101,6 +103,8 @@ function TabContextMenuItem({
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
@@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand,
|
||||
onNavigateTab,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
setHoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
tabDropPreviewIndex,
|
||||
@@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
@@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyAgentId,
|
||||
onCopyResumeCommand,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
tabCount,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Settings,
|
||||
SquarePen,
|
||||
@@ -109,6 +110,10 @@ import {
|
||||
WorkspaceTabOptionRow,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import {
|
||||
useWorkspaceTabRename,
|
||||
WorkspaceTabRenameModal,
|
||||
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
@@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
@@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
@@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
@@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCloseTabsAbove={onCloseTabsAbove}
|
||||
onCloseTabsBelow={onCloseTabsBelow}
|
||||
@@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
createMutation: createTerminalMutation,
|
||||
createTerminal,
|
||||
@@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds,
|
||||
pendingCreateInput: pendingTerminalCreateInput,
|
||||
query: terminalsQuery,
|
||||
queryKey: terminalsQueryKey,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
@@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({
|
||||
|
||||
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
||||
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
|
||||
useWorkspaceTabRename({
|
||||
client,
|
||||
normalizedServerId,
|
||||
queryClient,
|
||||
terminalsData: terminalsQuery.data,
|
||||
terminalsQueryKey,
|
||||
});
|
||||
|
||||
const tabByKey = useMemo(() => {
|
||||
const map = new Map<string, WorkspaceTabDescriptor>();
|
||||
@@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
@@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({
|
||||
handleCopyResumeCommand,
|
||||
handleCopyAgentId,
|
||||
handleReloadAgent,
|
||||
handleRenameTab,
|
||||
handleCloseTabsToLeftInPane,
|
||||
handleCloseTabsToRightInPane,
|
||||
handleCloseOtherTabsInPane,
|
||||
@@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
@@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
@@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
<WorkspaceTabRenameModal
|
||||
renamingTab={renamingTab}
|
||||
onSubmit={handleRenameModalSubmit}
|
||||
onClose={handleRenameModalClose}
|
||||
/>
|
||||
</View>
|
||||
</WorkspaceFocusProvider>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
const onCopyResumeCommand = vi.fn();
|
||||
const onCopyAgentId = vi.fn();
|
||||
const onReloadAgent = vi.fn();
|
||||
const onRenameTab = vi.fn();
|
||||
const onCloseTab = vi.fn();
|
||||
const onCloseTabsBefore = vi.fn();
|
||||
const onCloseTabsAfter = vi.fn();
|
||||
@@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
@@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Rename",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
@@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
@@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Rename",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
@@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits agent copy actions for non-agent tabs", () => {
|
||||
it("omits agent copy actions and rename for draft tabs", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: {
|
||||
@@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
@@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false);
|
||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
@@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onRenameTab when the rename entry is selected for agent tabs", () => {
|
||||
const onRenameTab = vi.fn();
|
||||
const tab = createAgentTab();
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab,
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase: "workspace-tab-context-agent_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab,
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
|
||||
if (!renameEntry || renameEntry.kind !== "item") {
|
||||
throw new Error("Rename entry missing");
|
||||
}
|
||||
renameEntry.onSelect();
|
||||
|
||||
expect(onRenameTab).toHaveBeenCalledWith(tab);
|
||||
});
|
||||
|
||||
it("includes rename as the first entry for terminal tabs", () => {
|
||||
const onRenameTab = vi.fn();
|
||||
const terminalTab: WorkspaceTabDescriptor = {
|
||||
key: "terminal_abc",
|
||||
tabId: "terminal_abc",
|
||||
kind: "terminal",
|
||||
target: { kind: "terminal", terminalId: "terminal-abc" },
|
||||
};
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: terminalTab,
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase: "workspace-tab-context-terminal_abc",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab,
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label);
|
||||
expect(labels[0]).toBe("Rename");
|
||||
expect(labels).not.toContain("Copy resume command");
|
||||
expect(labels).not.toContain("Copy agent id");
|
||||
expect(labels).not.toContain("Reload agent");
|
||||
|
||||
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
|
||||
if (!renameEntry || renameEntry.kind !== "item") {
|
||||
throw new Error("Rename entry missing");
|
||||
}
|
||||
renameEntry.onSelect();
|
||||
expect(onRenameTab).toHaveBeenCalledWith(terminalTab);
|
||||
});
|
||||
|
||||
it("uses the same rename entry shape for agent and terminal tabs", () => {
|
||||
const terminalTab: WorkspaceTabDescriptor = {
|
||||
key: "terminal_abc",
|
||||
tabId: "terminal_abc",
|
||||
kind: "terminal",
|
||||
target: { kind: "terminal", terminalId: "terminal-abc" },
|
||||
};
|
||||
const menuTestIDBase = "workspace-tab-context";
|
||||
const sharedInput = {
|
||||
surface: "desktop" as const,
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
};
|
||||
|
||||
const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() });
|
||||
const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab });
|
||||
|
||||
const agentRename = agentEntries.find(
|
||||
(entry) => entry.kind === "item" && entry.key === "rename",
|
||||
);
|
||||
const terminalRename = terminalEntries.find(
|
||||
(entry) => entry.kind === "item" && entry.key === "rename",
|
||||
);
|
||||
if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing");
|
||||
if (!terminalRename || terminalRename.kind !== "item")
|
||||
throw new Error("Terminal rename missing");
|
||||
|
||||
expect({
|
||||
key: agentRename.key,
|
||||
label: agentRename.label,
|
||||
icon: agentRename.icon,
|
||||
testID: agentRename.testID,
|
||||
}).toEqual({
|
||||
key: terminalRename.key,
|
||||
label: terminalRename.label,
|
||||
icon: terminalRename.icon,
|
||||
testID: terminalRename.testID,
|
||||
});
|
||||
|
||||
const agentSeparator = agentEntries
|
||||
.slice(agentEntries.indexOf(agentRename) + 1)
|
||||
.find((entry) => entry.kind === "separator");
|
||||
const terminalSeparator = terminalEntries
|
||||
.slice(terminalEntries.indexOf(terminalRename) + 1)
|
||||
.find((entry) => entry.kind === "separator");
|
||||
expect(agentSeparator?.key).toBe("rename-separator");
|
||||
expect(terminalSeparator?.key).toBe("rename-separator");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry =
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
|
||||
icon?:
|
||||
| "copy"
|
||||
| "rotate-cw"
|
||||
| "arrow-left-to-line"
|
||||
| "arrow-right-to-line"
|
||||
| "copy-x"
|
||||
| "pencil"
|
||||
| "x";
|
||||
hint?: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
@@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||
@@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
@@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
@@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "rename",
|
||||
label: "Rename",
|
||||
icon: "pencil",
|
||||
testID: `${menuTestIDBase}-rename`,
|
||||
onSelect: () => {
|
||||
onRenameTab(tab);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
key: "rename-separator",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions(
|
||||
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||
onCopyAgentId: input.onCopyAgentId,
|
||||
onReloadAgent: input.onReloadAgent,
|
||||
onRenameTab: input.onRenameTab,
|
||||
onCloseTab: input.onCloseTab,
|
||||
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@server/shared/terminal-input-mode";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
@@ -84,6 +85,14 @@ const isMac =
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
||||
|
||||
const isAppleHandheld =
|
||||
typeof navigator !== "undefined" &&
|
||||
isAppleHandheldPlatform({
|
||||
userAgent: navigator.userAgent,
|
||||
platform: (navigator as Navigator & { platform?: string }).platform,
|
||||
maxTouchPoints: navigator.maxTouchPoints,
|
||||
});
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
@@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime {
|
||||
metaKey: event.metaKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||
isAppleHandheld,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mapTerminalDataToKey,
|
||||
mergeTerminalModifiers,
|
||||
@@ -10,6 +11,13 @@ import {
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "./terminal-keys";
|
||||
|
||||
const IPAD_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15";
|
||||
const MAC_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
const IPHONE_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
|
||||
|
||||
describe("terminal key helpers", () => {
|
||||
it("normalizes supported DOM keys", () => {
|
||||
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
||||
@@ -157,6 +165,140 @@ describe("terminal key helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => {
|
||||
// See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts.
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
// Uppercase variant in case Caps Lock is on.
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "C",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => {
|
||||
for (const key of ["b", "d", "z", "a", "r", "l"]) {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: true,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => {
|
||||
// Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm.
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: true,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
isAppleHandheld: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects iPad masquerading as macOS via maxTouchPoints", () => {
|
||||
expect(
|
||||
isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects iPhone/iPod by UA", () => {
|
||||
expect(
|
||||
isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag real macOS desktop as a handheld", () => {
|
||||
expect(
|
||||
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => {
|
||||
expect(
|
||||
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates null/undefined navigator-style inputs", () => {
|
||||
expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isAppleHandheldPlatform({
|
||||
userAgent: undefined,
|
||||
platform: undefined,
|
||||
maxTouchPoints: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects pending modifier state", () => {
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
||||
|
||||
@@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
|
||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
||||
}
|
||||
|
||||
interface AppleHandheldDetectionInput {
|
||||
userAgent: string | null | undefined;
|
||||
platform: string | null | undefined;
|
||||
maxTouchPoints: number | null | undefined;
|
||||
}
|
||||
|
||||
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
|
||||
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
|
||||
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {
|
||||
const userAgent = input.userAgent ?? "";
|
||||
const platform = input.platform ?? "";
|
||||
const touchPoints = input.maxTouchPoints ?? 0;
|
||||
if (/iPad|iPhone|iPod/.test(userAgent)) {
|
||||
return true;
|
||||
}
|
||||
if (/Mac/i.test(platform) && touchPoints > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldInterceptDomTerminalKey(args: {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
@@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
metaKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
enhancedInputActive?: boolean;
|
||||
isAppleHandheld?: boolean;
|
||||
}): boolean {
|
||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
return true;
|
||||
@@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||
return Boolean(args.enhancedInputActive);
|
||||
}
|
||||
// COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so
|
||||
// xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0.
|
||||
// Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it.
|
||||
if (
|
||||
args.isAppleHandheld &&
|
||||
args.ctrlKey &&
|
||||
!args.metaKey &&
|
||||
!args.altKey &&
|
||||
!args.shiftKey &&
|
||||
(args.key === "c" || args.key === "C")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...envWithHome(options.home),
|
||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
|
||||
PASEO_LISTEN: undefined,
|
||||
PASEO_HOSTNAMES: undefined,
|
||||
PASEO_ALLOWED_HOSTS: undefined,
|
||||
PASEO_RELAY_ENABLED: undefined,
|
||||
PASEO_RELAY_ENDPOINT: undefined,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
|
||||
PASEO_RELAY_USE_TLS: undefined,
|
||||
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
|
||||
};
|
||||
const home = resolvePaseoHome(env);
|
||||
const config = loadConfig(home, { env });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
|
||||
interface PairOptions {
|
||||
@@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome();
|
||||
const state = resolveLocalDaemonState({ home: paseoHome });
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
|
||||
// Try to get the pairing offer from the running daemon first.
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const offer = await client.getDaemonPairingOffer();
|
||||
await client.close().catch(() => {});
|
||||
outputPairingResult(
|
||||
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
|
||||
options,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
}
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local pairing offer generation.
|
||||
const config = loadConfig(paseoHome);
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
@@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
includeQr: true,
|
||||
});
|
||||
|
||||
outputPairingResult(pairing, options);
|
||||
}
|
||||
|
||||
function outputPairingResult(
|
||||
pairing: { relayEnabled: boolean; url: string | null; qr: string | null },
|
||||
options: PairOptions,
|
||||
): void {
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
|
||||
console.error(chalk.yellow("Enable relay and run this command again."));
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ProviderBinaryStatus {
|
||||
label: string;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
source?: "daemon" | "local";
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
return "red";
|
||||
}
|
||||
if (item.key.startsWith(" ")) {
|
||||
if (item.value === "not found") return "red";
|
||||
if (item.value === "not found" || item.value === "not found (daemon)") return "red";
|
||||
if (item.value.endsWith("(--version failed)")) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
@@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
rows.push({ key: "", value: "" });
|
||||
rows.push({ key: "Providers", value: "" });
|
||||
for (const provider of status.providers) {
|
||||
if (!provider.path) {
|
||||
if (provider.source === "daemon") {
|
||||
if (!provider.path) {
|
||||
rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" });
|
||||
} else {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` });
|
||||
}
|
||||
} else if (!provider.path) {
|
||||
rows.push({ key: ` ${provider.label}`, value: "not found" });
|
||||
} else if (!provider.version) {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
||||
@@ -210,6 +217,7 @@ interface DaemonProbeResult {
|
||||
runningAgents?: number;
|
||||
idleAgents?: number;
|
||||
daemonNodeOverride?: string;
|
||||
daemonProviders?: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: {
|
||||
}
|
||||
|
||||
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
const runningAgents = agents.filter((a) => a.status === "running").length;
|
||||
const idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||
|
||||
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const statusPayload = await client.getDaemonStatus();
|
||||
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
|
||||
daemonProviders = statusPayload.providers.map((p) => ({
|
||||
label: labelMap.get(p.provider) ?? p.provider,
|
||||
path: p.available ? "available" : null,
|
||||
version: p.available ? null : (p.error ?? null),
|
||||
source: "daemon" as const,
|
||||
}));
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side provider resolution while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; status should come from daemon.get_status.
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.running) {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
@@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: {
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonNodeOverride: "unknown (API reachable, PID unresolved)",
|
||||
daemonProviders,
|
||||
note: state.pidInfo
|
||||
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
||||
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
||||
@@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: {
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -278,6 +308,7 @@ interface ProbeMergeState {
|
||||
daemonVersion: string | null;
|
||||
runningAgents: number | null;
|
||||
idleAgents: number | null;
|
||||
daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
note: string | undefined;
|
||||
}
|
||||
|
||||
@@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
|
||||
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
||||
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
|
||||
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
|
||||
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
|
||||
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
||||
};
|
||||
}
|
||||
@@ -338,6 +370,7 @@ export async function runStatusCommand(
|
||||
let runningAgents: number | null = null;
|
||||
let idleAgents: number | null = null;
|
||||
let daemonVersion: string | null = null;
|
||||
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
@@ -347,17 +380,26 @@ export async function runStatusCommand(
|
||||
|
||||
if (host) {
|
||||
const probe = await probeDaemonOverWebsocket({ host, state });
|
||||
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } =
|
||||
applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
note,
|
||||
}));
|
||||
({
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
note,
|
||||
} = applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
note,
|
||||
}));
|
||||
} else {
|
||||
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
|
||||
}
|
||||
@@ -370,7 +412,7 @@ export async function runStatusCommand(
|
||||
note = appendNote(note, serverIdResult.error);
|
||||
}
|
||||
|
||||
const providers = await checkProviderBinaries();
|
||||
const providers = daemonProviders ?? (await checkProviderBinaries());
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
serverId,
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
getMainWindowChromeOptions,
|
||||
getWindowBackgroundColor,
|
||||
resolveSystemWindowTheme,
|
||||
setupDarwinPaintRefresh,
|
||||
setupWindowResizeEvents,
|
||||
setupDefaultContextMenu,
|
||||
setupDragDropPrevention,
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
registerNotificationHandlers,
|
||||
@@ -398,7 +398,7 @@ async function createMainWindow(): Promise<void> {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
|
||||
setupDarwinPaintRefresh(mainWindow);
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldRecoverFromFrameStall } from ".";
|
||||
|
||||
describe("compositor-watchdog", () => {
|
||||
describe("shouldRecoverFromFrameStall", () => {
|
||||
const recoverable = {
|
||||
stalledChecks: 3,
|
||||
recovering: false,
|
||||
msSinceLastRecovery: 120_000,
|
||||
consecutiveRecoveries: 0,
|
||||
};
|
||||
|
||||
it("recovers once the stall threshold is reached", () => {
|
||||
expect(shouldRecoverFromFrameStall(recoverable)).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the stall threshold is reached", () => {
|
||||
expect(shouldRecoverFromFrameStall({ ...recoverable, stalledChecks: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not recover while a recovery is already in progress", () => {
|
||||
expect(shouldRecoverFromFrameStall({ ...recoverable, recovering: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects the cooldown between recoveries", () => {
|
||||
expect(shouldRecoverFromFrameStall({ ...recoverable, msSinceLastRecovery: 30_000 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops recovering after the consecutive-recovery cap", () => {
|
||||
expect(shouldRecoverFromFrameStall({ ...recoverable, consecutiveRecoveries: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
packages/desktop/src/window/compositor-watchdog/index.ts
Normal file
158
packages/desktop/src/window/compositor-watchdog/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { app, type BrowserWindow, powerMonitor } from "electron";
|
||||
|
||||
// COMPAT(darwinCompositorWatchdog): added in v0.1.78, target removal after
|
||||
// 2026-11-19. Workaround for Electron/Chromium macOS display-sleep compositor
|
||||
// stalls; re-test when Electron/Chromium is upgraded.
|
||||
|
||||
// How often the main process probes the renderer for frame production.
|
||||
const FRAME_PROBE_INTERVAL_MS = 2000;
|
||||
// A probed frame must arrive within this window or the probe counts as stalled.
|
||||
const FRAME_PROBE_DEADLINE_MS = 300;
|
||||
// Consecutive stalled probes before the watchdog restarts the GPU process (~6 s).
|
||||
const FRAME_STALL_CHECKS_TO_RECOVER = 3;
|
||||
// Minimum gap between GPU-process restarts.
|
||||
const COMPOSITOR_RECOVERY_COOLDOWN_MS = 60_000;
|
||||
// Grace period for Chromium to relaunch the GPU process before probing resumes.
|
||||
const GPU_RELAUNCH_GRACE_MS = 5_000;
|
||||
// Stop restarting the GPU process after this many tries without frames returning.
|
||||
const MAX_CONSECUTIVE_RECOVERIES = 3;
|
||||
|
||||
// Resolves { producedFrame, visibilityState } for the renderer. The frame is
|
||||
// requested with requestAnimationFrame; setTimeout (not vsync-driven) bounds the
|
||||
// wait so the probe always resolves even when frame production has stopped.
|
||||
const FRAME_PROBE_SOURCE = `new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (producedFrame) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ producedFrame, visibilityState: document.visibilityState });
|
||||
};
|
||||
requestAnimationFrame(() => finish(true));
|
||||
setTimeout(() => finish(false), ${FRAME_PROBE_DEADLINE_MS});
|
||||
})`;
|
||||
|
||||
interface FrameStallState {
|
||||
stalledChecks: number;
|
||||
recovering: boolean;
|
||||
msSinceLastRecovery: number;
|
||||
consecutiveRecoveries: number;
|
||||
}
|
||||
|
||||
export function shouldRecoverFromFrameStall(state: FrameStallState): boolean {
|
||||
return (
|
||||
state.stalledChecks >= FRAME_STALL_CHECKS_TO_RECOVER &&
|
||||
!state.recovering &&
|
||||
state.msSinceLastRecovery >= COMPOSITOR_RECOVERY_COOLDOWN_MS &&
|
||||
state.consecutiveRecoveries < MAX_CONSECUTIVE_RECOVERIES
|
||||
);
|
||||
}
|
||||
|
||||
function findGpuProcessPid(): number | null {
|
||||
for (const metric of app.getAppMetrics()) {
|
||||
if (metric.type === "GPU") {
|
||||
return metric.pid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS display sleep can leave Chromium's GPU-process display link (the vsync
|
||||
// source that drives frame production) stuck on a stale display. The compositor
|
||||
// then stops producing frames and the window looks frozen: unresponsive to
|
||||
// clicks and keys even though the renderer and every process stay alive. This
|
||||
// watchdog polls the renderer for frame production and, on a sustained stall,
|
||||
// restarts the GPU process so Chromium rebuilds the display link.
|
||||
export function setupDarwinCompositorWatchdog(win: BrowserWindow): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep producing frames while occluded so the probe is not fooled by throttling.
|
||||
win.webContents.setBackgroundThrottling(false);
|
||||
|
||||
let stalledChecks = 0;
|
||||
let recovering = false;
|
||||
let lastRecoveryAt = 0;
|
||||
let consecutiveRecoveries = 0;
|
||||
let screenLocked = false;
|
||||
|
||||
const recoverCompositor = async () => {
|
||||
recovering = true;
|
||||
lastRecoveryAt = Date.now();
|
||||
consecutiveRecoveries += 1;
|
||||
stalledChecks = 0;
|
||||
const gpuPid = findGpuProcessPid();
|
||||
console.warn(
|
||||
`[compositor-watchdog] Desktop window stopped producing frames; restarting GPU process ` +
|
||||
`(pid=${gpuPid ?? "unknown"}, attempt ${consecutiveRecoveries}) to recover`,
|
||||
);
|
||||
if (gpuPid !== null) {
|
||||
try {
|
||||
process.kill(gpuPid, "SIGKILL");
|
||||
} catch (error) {
|
||||
console.warn("[compositor-watchdog] Could not restart GPU process", error);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, GPU_RELAUNCH_GRACE_MS));
|
||||
recovering = false;
|
||||
};
|
||||
|
||||
const probeFrameProduction = async () => {
|
||||
if (win.isDestroyed() || recovering) {
|
||||
return;
|
||||
}
|
||||
// A freeze is only meaningful, and only distinguishable from a normal idle
|
||||
// window, while the window is actually on screen. A locked screen, a
|
||||
// minimized window, or a hidden one legitimately stops producing frames.
|
||||
if (screenLocked || !win.isVisible() || win.isMinimized()) {
|
||||
stalledChecks = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let result: { producedFrame?: unknown; visibilityState?: unknown } | null;
|
||||
try {
|
||||
result = await win.webContents.executeJavaScript(FRAME_PROBE_SOURCE);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!result || result.visibilityState !== "visible") {
|
||||
stalledChecks = 0;
|
||||
return;
|
||||
}
|
||||
if (result.producedFrame === true) {
|
||||
stalledChecks = 0;
|
||||
consecutiveRecoveries = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
stalledChecks += 1;
|
||||
if (
|
||||
shouldRecoverFromFrameStall({
|
||||
stalledChecks,
|
||||
recovering,
|
||||
msSinceLastRecovery: Date.now() - lastRecoveryAt,
|
||||
consecutiveRecoveries,
|
||||
})
|
||||
) {
|
||||
void recoverCompositor();
|
||||
}
|
||||
};
|
||||
|
||||
const probeTimer = setInterval(() => void probeFrameProduction(), FRAME_PROBE_INTERVAL_MS);
|
||||
const handleScreenLocked = () => {
|
||||
screenLocked = true;
|
||||
stalledChecks = 0;
|
||||
};
|
||||
const handleScreenUnlocked = () => {
|
||||
screenLocked = false;
|
||||
stalledChecks = 0;
|
||||
};
|
||||
powerMonitor.on("lock-screen", handleScreenLocked);
|
||||
powerMonitor.on("unlock-screen", handleScreenUnlocked);
|
||||
|
||||
win.once("closed", () => {
|
||||
clearInterval(probeTimer);
|
||||
powerMonitor.off("lock-screen", handleScreenLocked);
|
||||
powerMonitor.off("unlock-screen", handleScreenUnlocked);
|
||||
});
|
||||
}
|
||||
@@ -229,59 +229,6 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
function refreshChromiumSurface(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.invalidate();
|
||||
if (win.isMaximized() || win.isFullScreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = win.getSize();
|
||||
win.setSize(width + 1, height);
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.setSize(width, height);
|
||||
}
|
||||
}, 32);
|
||||
}
|
||||
|
||||
export function setupDarwinPaintRefresh(win: BrowserWindow): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.setBackgroundThrottling(false);
|
||||
|
||||
const requestSurfaceRefresh = () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.invalidate();
|
||||
}
|
||||
};
|
||||
const handleChildProcessGone = (
|
||||
_event: Electron.Event,
|
||||
details: { type?: string; reason?: string },
|
||||
) => {
|
||||
if (details.type !== "GPU") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("[window] GPU process gone:", details.reason);
|
||||
refreshChromiumSurface(win);
|
||||
};
|
||||
|
||||
win.on("restore", requestSurfaceRefresh);
|
||||
win.on("show", requestSurfaceRefresh);
|
||||
app.on("child-process-gone", handleChildProcessGone);
|
||||
win.once("closed", () => {
|
||||
win.off("restore", requestSurfaceRefresh);
|
||||
win.off("show", requestSurfaceRefresh);
|
||||
app.off("child-process-gone", handleChildProcessGone);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildStandardContextMenuItems(
|
||||
contents: WebContents,
|
||||
params: Electron.ContextMenuParams,
|
||||
|
||||
@@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] {
|
||||
return [transportA, transportB];
|
||||
}
|
||||
|
||||
async function waitForAsyncDelivery(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
describe("EncryptedChannel", () => {
|
||||
it("establishes encrypted channel between daemon and client", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
@@ -96,7 +100,7 @@ describe("EncryptedChannel", () => {
|
||||
await clientChannel.send("Second message from client");
|
||||
|
||||
// Wait for async delivery
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncDelivery();
|
||||
|
||||
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||
@@ -194,4 +198,75 @@ describe("EncryptedChannel", () => {
|
||||
|
||||
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
||||
});
|
||||
|
||||
it("accepts duplicate hello from the same client without re-keying", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = generateKeyPair();
|
||||
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
|
||||
const daemonMessages: (string | ArrayBuffer)[] = [];
|
||||
|
||||
let clientOpenedResolve: (() => void) | null = null;
|
||||
const clientOpened = new Promise<void>((resolve) => {
|
||||
clientOpenedResolve = resolve;
|
||||
});
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
|
||||
onmessage: (data) => daemonMessages.push(data),
|
||||
});
|
||||
|
||||
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
|
||||
onopen: () => clientOpenedResolve?.(),
|
||||
});
|
||||
|
||||
await daemonChannelPromise;
|
||||
await clientOpened;
|
||||
|
||||
const firstHello = (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
([data]) => typeof data === "string" && data.includes('"type":"e2ee_hello"'),
|
||||
)?.[0];
|
||||
expect(typeof firstHello).toBe("string");
|
||||
|
||||
daemonTransport.onmessage?.(firstHello as string);
|
||||
await waitForAsyncDelivery();
|
||||
|
||||
expect(daemonTransport.close).not.toHaveBeenCalled();
|
||||
|
||||
await clientChannel.send("still encrypted with original key");
|
||||
await waitForAsyncDelivery();
|
||||
|
||||
expect(daemonMessages).toEqual(["still encrypted with original key"]);
|
||||
});
|
||||
|
||||
it("closes an open daemon channel when a different client key sends hello", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = generateKeyPair();
|
||||
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
let clientOpenedResolve: (() => void) | null = null;
|
||||
const clientOpened = new Promise<void>((resolve) => {
|
||||
clientOpenedResolve = resolve;
|
||||
});
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
|
||||
await createClientChannel(clientTransport, daemonPubKeyB64, {
|
||||
onopen: () => clientOpenedResolve?.(),
|
||||
});
|
||||
|
||||
await daemonChannelPromise;
|
||||
await clientOpened;
|
||||
|
||||
const attackerKeyPair = generateKeyPair();
|
||||
const attackerHello = JSON.stringify({
|
||||
type: "e2ee_hello",
|
||||
key: exportPublicKey(attackerKeyPair.publicKey),
|
||||
});
|
||||
|
||||
daemonTransport.onmessage?.(attackerHello);
|
||||
await waitForAsyncDelivery();
|
||||
|
||||
expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,8 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
|
||||
|
||||
const HANDSHAKE_RETRY_MS = 1000;
|
||||
const MAX_PENDING_SENDS = 200;
|
||||
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
||||
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
|
||||
|
||||
interface TimeoutWithUnref {
|
||||
unref(): void;
|
||||
@@ -420,16 +422,14 @@ export class EncryptedChannel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Different key implies a new client connection (common with relays
|
||||
// where the daemon's socket stays open while the client reconnects).
|
||||
// Re-key and re-send "ready". Drop any queued sends to avoid leaking
|
||||
// messages between logical client sessions.
|
||||
this.state = "handshaking";
|
||||
this.sharedKey = nextSharedKey;
|
||||
this.pendingSends = [];
|
||||
this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage));
|
||||
this.state = "open";
|
||||
await this.flushPendingSends();
|
||||
// A different key on an already-open encrypted channel is not an
|
||||
// authenticated reconnect. Close and require a fresh transport instead of
|
||||
// allowing the relay to switch this channel to an attacker-chosen key.
|
||||
this.state = "closed";
|
||||
this.transport.close(
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE,
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON,
|
||||
);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
@@ -452,8 +452,9 @@ export class EncryptedChannel {
|
||||
|
||||
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
let difference = 0;
|
||||
for (let i = 0; i < a.byteLength; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return true;
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"types": "./dist/server/utils/tool-call-parsers.d.ts",
|
||||
"source": "./src/utils/tool-call-parsers.ts",
|
||||
"default": "./dist/server/utils/tool-call-parsers.js"
|
||||
},
|
||||
"./utils/branch-slug": {
|
||||
"types": "./dist/server/utils/branch-slug.d.ts",
|
||||
"source": "./src/utils/branch-slug.ts",
|
||||
"default": "./dist/server/utils/branch-slug.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -58,12 +63,12 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@earendil-works/pi-agent-core": "^0.75.3",
|
||||
"@earendil-works/pi-ai": "^0.75.3",
|
||||
"@earendil-works/pi-coding-agent": "^0.75.3",
|
||||
"@getpaseo/highlight": "0.1.78",
|
||||
"@getpaseo/relay": "0.1.78",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a branch via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.renameBranch({
|
||||
cwd: "/tmp/project",
|
||||
branch: "feature/new-name",
|
||||
requestId: "req-rename-branch",
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout.rename_branch.request";
|
||||
cwd: string;
|
||||
branch: string;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout.rename_branch.request");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.branch).toBe("feature/new-name");
|
||||
expect(request.message.requestId).toBe("req-rename-branch");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
requestId: "req-rename-branch",
|
||||
success: true,
|
||||
cwd: "/tmp/project",
|
||||
currentBranch: "feature/new-name",
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: "req-rename-branch",
|
||||
success: true,
|
||||
cwd: "/tmp/project",
|
||||
currentBranch: "feature/new-name",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns renameBranch business failures", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.renameBranch({
|
||||
cwd: "/tmp/project",
|
||||
branch: "already-exists",
|
||||
requestId: "req-rename-branch-fail",
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
requestId: "req-rename-branch-fail",
|
||||
success: false,
|
||||
cwd: "/tmp/project",
|
||||
currentBranch: null,
|
||||
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: "req-rename-branch-fail",
|
||||
success: false,
|
||||
cwd: "/tmp/project",
|
||||
currentBranch: null,
|
||||
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
||||
});
|
||||
});
|
||||
|
||||
test("resubscribes checkout diff streams after reconnect", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
AgentRefreshedStatusPayloadSchema,
|
||||
AgentResumedStatusPayloadSchema,
|
||||
CheckoutRenameBranchResponseSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
RenameTerminalResponseSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
@@ -60,6 +62,8 @@ import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
@@ -285,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||
export type RenameBranchResult = z.infer<typeof CheckoutRenameBranchResponseSchema>["payload"];
|
||||
type StashSavePayload = StashSaveResponse["payload"];
|
||||
type StashPopPayload = StashPopResponse["payload"];
|
||||
type StashListPayload = StashListResponse["payload"];
|
||||
@@ -317,6 +322,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||
type ReadProjectConfigPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "read_project_config_response" }
|
||||
@@ -351,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract<
|
||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type CloseItemsPayload = CloseItemsResponse["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
@@ -614,6 +622,16 @@ export interface UpdateScheduleOptions {
|
||||
expiresAt?: string | null;
|
||||
requestId?: string;
|
||||
}
|
||||
export interface RenameBranchInput {
|
||||
cwd: string;
|
||||
branch: string;
|
||||
requestId?: string;
|
||||
}
|
||||
export interface RenameTerminalInput {
|
||||
terminalId: string;
|
||||
title: string;
|
||||
requestId?: string;
|
||||
}
|
||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
@@ -2929,6 +2947,19 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async renameBranch(input: RenameBranchInput): Promise<RenameBranchResult> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: input.cwd,
|
||||
branch: input.branch,
|
||||
},
|
||||
responseType: "checkout.rename_branch.response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async stashSave(
|
||||
cwd: string,
|
||||
options?: { branch?: string },
|
||||
@@ -3298,6 +3329,28 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getDaemonStatus(requestId?: string): Promise<DaemonStatusPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "daemon.get_status.request",
|
||||
},
|
||||
responseType: "daemon.get_status.response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getDaemonPairingOffer(requestId?: string): Promise<DaemonPairingOfferPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "daemon.get_pairing_offer.request",
|
||||
},
|
||||
responseType: "daemon.get_pairing_offer.response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async patchDaemonConfig(
|
||||
config: MutableDaemonConfigPatch,
|
||||
requestId?: string,
|
||||
@@ -3635,6 +3688,19 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async renameTerminal(input: RenameTerminalInput): Promise<RenameTerminalResult> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "terminal.rename.request",
|
||||
terminalId: input.terminalId,
|
||||
title: input.title,
|
||||
},
|
||||
responseType: "terminal.rename.response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeTerminal(
|
||||
terminalId: string,
|
||||
requestId?: string,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
@@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a
|
||||
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset preserves an existing user title", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
await manager.setTitle(snapshot.id, "User title");
|
||||
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
|
||||
|
||||
const after = await storage.get(snapshot.id);
|
||||
expect(after?.title).toBe("User title");
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000129",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
|
||||
|
||||
const after = await storage.get(snapshot.id);
|
||||
expect(after?.title).toBe("Generated title");
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
const before = await storage.get(snapshot.id);
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
const stateEvents: ManagedAgent[] = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state") {
|
||||
stateEvents.push(event.agent);
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
await manager.setGeneratedTitleIfUnset(snapshot.id, " ");
|
||||
|
||||
const after = await storage.get(snapshot.id);
|
||||
expect(after?.title).toBeNull();
|
||||
expect(after?.updatedAt).toBe(before?.updatedAt);
|
||||
expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt);
|
||||
expect(stateEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"),
|
||||
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
|
||||
});
|
||||
|
||||
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -1228,6 +1228,23 @@ export class AgentManager {
|
||||
this.emitState(agent, { persist: false });
|
||||
}
|
||||
|
||||
async setGeneratedTitleIfUnset(agentId: string, title: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle);
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
agent.updatedAt = new Date(persisted.updatedAt);
|
||||
this.emitState(agent, { persist: false });
|
||||
}
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata(
|
||||
if (needs.needsTitle && typeof result.title === "string") {
|
||||
const normalizedTitle = normalizeAutoTitle(result.title);
|
||||
if (normalizedTitle) {
|
||||
await options.agentManager.setTitle(options.agentId, normalizedTitle);
|
||||
await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ function createDeps(
|
||||
|
||||
describe("agent metadata generator auto-title", () => {
|
||||
it("caps generated auto titles at 40 characters before persisting", async () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setTitle } as unknown as AgentManager;
|
||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
|
||||
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
@@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => {
|
||||
deps: createDeps(generateStructured),
|
||||
});
|
||||
|
||||
expect(setTitle).toHaveBeenCalledTimes(1);
|
||||
expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS));
|
||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1);
|
||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not generate an auto title when an explicit title is provided", async () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setTitle } as unknown as AgentManager;
|
||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
>;
|
||||
@@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => {
|
||||
});
|
||||
|
||||
expect(generateStructured).not.toHaveBeenCalled();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates titles independently from workspace branch naming", async () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setTitle } as unknown as AgentManager;
|
||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||
const generateStructured = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||
@@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => {
|
||||
persistSession: false,
|
||||
}),
|
||||
);
|
||||
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
|
||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
||||
"agent-suppressed-branch",
|
||||
"Generated title",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt:
|
||||
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
||||
}
|
||||
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setTitle } as unknown as AgentManager;
|
||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
>;
|
||||
|
||||
@@ -321,6 +321,42 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => {
|
||||
const agentId = "agent-generated-title-race";
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||
await storage.setTitle(agentId, "User title");
|
||||
|
||||
const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
|
||||
|
||||
expect(result).toBeNull();
|
||||
const record = await storage.get(agentId);
|
||||
expect(record?.title).toBe("User title");
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => {
|
||||
const agentId = "agent-generated-title-concurrent";
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||
|
||||
await Promise.all([
|
||||
storage.setGeneratedTitleIfUnset(agentId, "Title A"),
|
||||
storage.setGeneratedTitleIfUnset(agentId, "Title B"),
|
||||
]);
|
||||
|
||||
const record = await storage.get(agentId);
|
||||
expect(["Title A", "Title B"]).toContain(record?.title);
|
||||
});
|
||||
|
||||
test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => {
|
||||
const agentId = "agent-generated-title-empty";
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||
|
||||
const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
|
||||
|
||||
expect(written?.title).toBe("Generated title");
|
||||
const record = await storage.get(agentId);
|
||||
expect(record?.title).toBe("Generated title");
|
||||
});
|
||||
|
||||
test("applySnapshot accepts explicit title overrides", async () => {
|
||||
const agentId = "agent-override";
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
||||
|
||||
@@ -115,44 +115,51 @@ export class AgentStorage {
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
await this.load();
|
||||
await this.queueRecordWrite(record);
|
||||
}
|
||||
|
||||
private queueRecordWrite(record: StoredAgentRecord): Promise<void> {
|
||||
const agentId = record.id;
|
||||
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
|
||||
const next = prev.then(async () => {
|
||||
if (this.deleting.has(agentId)) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextPath = this.buildRecordPath(record);
|
||||
const previousPath = this.pathById.get(agentId);
|
||||
|
||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||
this.addIndexedPath(agentId, nextPath);
|
||||
|
||||
if (previousPath && previousPath !== nextPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
this.removeIndexedPath(agentId, previousPath);
|
||||
}
|
||||
|
||||
this.cache.set(agentId, record);
|
||||
this.pathById.set(agentId, nextPath);
|
||||
return;
|
||||
await this.writeRecord(record);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
this.pendingWrites.set(
|
||||
agentId,
|
||||
next.finally(() => {
|
||||
if (this.pendingWrites.get(agentId) === next) {
|
||||
this.pendingWrites.delete(agentId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const tracked = next.finally(() => {
|
||||
if (this.pendingWrites.get(agentId) === tracked) {
|
||||
this.pendingWrites.delete(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
await next;
|
||||
this.pendingWrites.set(agentId, tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private async writeRecord(record: StoredAgentRecord): Promise<void> {
|
||||
const agentId = record.id;
|
||||
const nextPath = this.buildRecordPath(record);
|
||||
const previousPath = this.pathById.get(agentId);
|
||||
|
||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||
this.addIndexedPath(agentId, nextPath);
|
||||
|
||||
if (previousPath && previousPath !== nextPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
this.removeIndexedPath(agentId, previousPath);
|
||||
}
|
||||
|
||||
this.cache.set(agentId, record);
|
||||
this.pathById.set(agentId, nextPath);
|
||||
}
|
||||
|
||||
beginDelete(agentId: string): void {
|
||||
@@ -225,6 +232,40 @@ export class AgentStorage {
|
||||
await this.upsert({ ...record, title });
|
||||
}
|
||||
|
||||
async setGeneratedTitleIfUnset(
|
||||
agentId: string,
|
||||
title: string,
|
||||
): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
await this.waitForPendingWrite(agentId);
|
||||
const record = this.cache.get(agentId) ?? null;
|
||||
if (!record) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
if (record.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-drain pending writes: a concurrent setTitle may have queued between the
|
||||
// first drain and here. After waiting, re-read the cache before writing.
|
||||
await this.waitForPendingWrite(agentId);
|
||||
const latestRecord = this.cache.get(agentId) ?? null;
|
||||
if (!latestRecord) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
if (latestRecord.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextRecord = {
|
||||
...latestRecord,
|
||||
title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.queueRecordWrite(nextRecord);
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.load().catch(() => undefined);
|
||||
const writes = Array.from(this.pendingWrites.values());
|
||||
|
||||
@@ -154,7 +154,7 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
|
||||
title: "Parity agent",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
mode: "bypassPermissions",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
background: true,
|
||||
...args,
|
||||
});
|
||||
@@ -240,7 +240,7 @@ beforeAll(async () => {
|
||||
title: "MCP parity parent",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
mode: "bypassPermissions",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
background: true,
|
||||
});
|
||||
parentAgentId = str(parentPayload.agentId);
|
||||
@@ -339,7 +339,7 @@ describe("Suite A: Core Fixes", () => {
|
||||
test("create_agent accepts provider features over MCP", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ features: { test_feature: true } });
|
||||
agentId = await createTopLevelAgent({ settings: { features: { test_feature: true } } });
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("Suite A: Core Fixes", () => {
|
||||
try {
|
||||
agentId = await createChildAgent({
|
||||
provider: "claude/claude-test-model",
|
||||
features: { test_feature: true },
|
||||
settings: { features: { test_feature: true } },
|
||||
});
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||
@@ -369,14 +369,13 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("set_agent_feature updates provider features over MCP", async () => {
|
||||
test("update_agent updates provider features over MCP", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ features: { test_feature: false } });
|
||||
const updated = await callToolStructured(topLevelClient, "set_agent_feature", {
|
||||
agentId = await createTopLevelAgent({ settings: { features: { test_feature: false } } });
|
||||
const updated = await callToolStructured(topLevelClient, "update_agent", {
|
||||
agentId,
|
||||
featureId: "test_feature",
|
||||
value: true,
|
||||
settings: { features: { test_feature: true } },
|
||||
});
|
||||
expect(updated.success).toBe(true);
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
@@ -390,15 +389,18 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("list_provider_features returns draft provider features over MCP", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "list_provider_features", {
|
||||
test("inspect_provider returns draft provider features over MCP", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "inspect_provider", {
|
||||
provider: "claude",
|
||||
cwd: parentAgentCwd,
|
||||
model: "claude-test-model",
|
||||
featureValues: { test_feature: true },
|
||||
settings: {
|
||||
model: "claude-test-model",
|
||||
features: { test_feature: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.provider).toBe("claude");
|
||||
expect(payload.selectedModel).toBe("claude-test-model");
|
||||
expect(recordArr(payload.features)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -117,7 +117,9 @@ function buildAgentManagerSpies() {
|
||||
createAgent: vi.fn(),
|
||||
waitForAgentEvent: vi.fn(),
|
||||
recordUserMessage: vi.fn(),
|
||||
setAgentMode: vi.fn(),
|
||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -446,7 +448,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const missingTitle = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "test",
|
||||
});
|
||||
@@ -455,7 +457,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const tooLong = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "x".repeat(61),
|
||||
initialPrompt: "test",
|
||||
@@ -465,7 +467,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const ok = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Short title",
|
||||
initialPrompt: "test",
|
||||
@@ -479,7 +481,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Short title",
|
||||
});
|
||||
@@ -510,7 +512,7 @@ describe("create_agent MCP tool", () => {
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
features: { fast_mode: true },
|
||||
settings: { features: { fast_mode: true } },
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
@@ -536,7 +538,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const missingProvider = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
title: "Short title",
|
||||
initialPrompt: "test",
|
||||
});
|
||||
@@ -549,7 +551,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
title: "Short title",
|
||||
provider: "codex",
|
||||
initialPrompt: "test",
|
||||
@@ -558,7 +560,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
title: "Short title",
|
||||
provider: "codex/",
|
||||
initialPrompt: "test",
|
||||
@@ -567,7 +569,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
title: "Short title",
|
||||
provider: "/gpt-5.4",
|
||||
initialPrompt: "test",
|
||||
@@ -577,7 +579,7 @@ describe("create_agent MCP tool", () => {
|
||||
await expect(
|
||||
tool.handler({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
title: "Short title",
|
||||
provider: "codex/gpt-5.4",
|
||||
model: "gpt-5.4",
|
||||
@@ -722,10 +724,9 @@ describe("create_agent MCP tool", () => {
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
mode: "auto",
|
||||
initialPrompt: "Do work",
|
||||
provider: "codex/gpt-5.4",
|
||||
thinking: "think-hard",
|
||||
settings: { modeId: "auto", thinkingOptionId: "think-hard" },
|
||||
labels: { source: "mcp" },
|
||||
});
|
||||
|
||||
@@ -1269,7 +1270,7 @@ describe("create_agent MCP tool", () => {
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
title: "Custom provider agent",
|
||||
mode: "default",
|
||||
settings: { modeId: "default" },
|
||||
provider: "zai/custom-model",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
@@ -1360,7 +1361,7 @@ describe("create_agent MCP tool", () => {
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
features: { fast_mode: true },
|
||||
settings: { features: { fast_mode: true } },
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
@@ -1403,7 +1404,7 @@ describe("create_agent MCP tool", () => {
|
||||
await tool.handler({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
mode: "auto",
|
||||
settings: { modeId: "auto" },
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
@@ -1428,7 +1429,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: existingCwd,
|
||||
title: "Bad mode",
|
||||
provider: "opencode/gpt-5.4",
|
||||
mode: "bypassPermissions",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
initialPrompt: "Do work",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
@@ -1567,7 +1568,7 @@ describe("create_agent MCP tool", () => {
|
||||
await tool.handler({
|
||||
title: "Child",
|
||||
provider: "opencode/gpt-5.4",
|
||||
mode: "build",
|
||||
settings: { modeId: "build" },
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
@@ -1579,17 +1580,31 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("set_agent_feature MCP tool", () => {
|
||||
describe("update_agent MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("sets a provider feature on an existing agent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
it("does not register the replaced feature-specific MCP tool", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "set_agent_feature");
|
||||
|
||||
expect(lookupTool(server, "set_agent_feature")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates runtime settings before metadata", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" }));
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "update_agent");
|
||||
const input = {
|
||||
agentId: "agent-1",
|
||||
featureId: "fast_mode",
|
||||
value: true,
|
||||
name: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
settings: {
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
features: { fast_mode: true },
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
@@ -1597,9 +1612,39 @@ describe("set_agent_feature MCP tool", () => {
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(spies.agentStorage.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "agent-1",
|
||||
title: "Updated agent",
|
||||
}),
|
||||
);
|
||||
expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" });
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "update_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
agentId: "agent-1",
|
||||
name: "Should not persist",
|
||||
labels: { role: "worker" },
|
||||
settings: { features: { fast_mode: true } },
|
||||
}),
|
||||
).rejects.toThrow("unsupported feature");
|
||||
|
||||
expect(spies.agentStorage.get).not.toHaveBeenCalled();
|
||||
expect(spies.agentStorage.upsert).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setLabels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_schedule MCP tool", () => {
|
||||
@@ -2033,10 +2078,17 @@ describe("provider listing MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("model listing MCP tool", () => {
|
||||
describe("provider MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("lists provider features for a draft agent configuration", async () => {
|
||||
it("does not register the replaced feature-specific provider discovery MCP tool", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
|
||||
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("inspects provider features for a draft agent configuration", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||
{
|
||||
@@ -2046,19 +2098,29 @@ describe("model listing MCP tool", () => {
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
const providerRegistry = {
|
||||
codex: createProviderDefinition({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||
}),
|
||||
};
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_provider_features");
|
||||
const tool = registeredTool(server, "inspect_provider");
|
||||
const input = {
|
||||
provider: "codex",
|
||||
provider: "codex/gpt-5.4",
|
||||
cwd: "~/repo",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
settings: {
|
||||
modeId: "full-access",
|
||||
thinkingOptionId: "high",
|
||||
features: { fast_mode: true },
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
@@ -2076,6 +2138,12 @@ describe("model listing MCP tool", () => {
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: true,
|
||||
status: "available",
|
||||
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||
selectedModel: "gpt-5.4",
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
@@ -2118,6 +2186,38 @@ describe("model listing MCP tool", () => {
|
||||
);
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inspect_provider rejects disabled providers without fetching models", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const fetchModels = vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
},
|
||||
]);
|
||||
const providerRegistry = {
|
||||
codex: createProviderDefinition({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
enabled: false,
|
||||
fetchModels,
|
||||
}),
|
||||
};
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "inspect_provider");
|
||||
|
||||
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("speak MCP tool", () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
AgentModelSchema,
|
||||
AgentProviderEnum,
|
||||
AgentStatusEnum,
|
||||
ProviderModeSchema,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveRequiredProviderModel,
|
||||
@@ -587,6 +588,55 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
|
||||
);
|
||||
const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim()
|
||||
.min(1, "provider is required")
|
||||
.refine(
|
||||
(value) => {
|
||||
if (!value.includes("/")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
resolveRequiredProviderModel(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "provider must be provider or provider/model, for example codex/gpt-5.4" },
|
||||
);
|
||||
const CreateAgentSettingsInputSchema = z
|
||||
.object({
|
||||
modeId: z.string().optional().describe("Session mode to configure before the first run."),
|
||||
thinkingOptionId: z.string().optional().describe("Thinking option ID."),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
})
|
||||
.strict();
|
||||
const UpdateAgentSettingsInputSchema = z
|
||||
.object({
|
||||
modeId: z.string().optional().describe("Session mode ID."),
|
||||
model: z.string().nullable().optional().describe("Model ID. Pass null to clear."),
|
||||
thinkingOptionId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Thinking option ID. Pass null to clear."),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
})
|
||||
.strict();
|
||||
const InspectProviderSettingsInputSchema = z
|
||||
.object({
|
||||
modeId: z.string().optional().describe("Draft session mode ID."),
|
||||
model: z.string().optional().describe("Draft model ID."),
|
||||
thinkingOptionId: z.string().optional().describe("Draft thinking option ID."),
|
||||
features: z.record(z.unknown()).optional().describe("Draft provider feature values."),
|
||||
})
|
||||
.strict();
|
||||
const agentToAgentInputSchema = {
|
||||
cwd: z
|
||||
.string()
|
||||
@@ -601,23 +651,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
provider: ProviderModelInputSchema.describe(
|
||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||
),
|
||||
thinking: z.string().optional().describe("Thinking option ID"),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
settings: CreateAgentSettingsInputSchema.optional().describe(
|
||||
"Initial runtime settings for the new agent.",
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.",
|
||||
),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -647,21 +689,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
provider: ProviderModelInputSchema.describe(
|
||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||
),
|
||||
thinking: z.string().optional().describe("Thinking option ID"),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
settings: CreateAgentSettingsInputSchema.optional().describe(
|
||||
"Initial runtime settings for the new agent.",
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional session mode to configure before the first run."),
|
||||
worktreeName: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -700,13 +736,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
|
||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
|
||||
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
|
||||
const listProviderFeaturesInputSchema = {
|
||||
provider: AgentProviderEnum,
|
||||
cwd: z.string().describe("Working directory used to resolve provider feature availability."),
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
featureValues: z.record(z.unknown()).optional(),
|
||||
const inspectProviderInputSchema = {
|
||||
provider: ProviderOrProviderModelInputSchema.describe(
|
||||
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Working directory used to resolve provider feature availability."),
|
||||
settings: InspectProviderSettingsInputSchema.optional().describe(
|
||||
"Draft provider settings used to compute available features.",
|
||||
),
|
||||
};
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
@@ -758,7 +798,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
background: boolean;
|
||||
normalizedTitle: string | null;
|
||||
model: string | undefined;
|
||||
thinking: string | undefined;
|
||||
thinkingOptionId: string | undefined;
|
||||
features: Record<string, unknown> | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
notifyOnFinish: boolean;
|
||||
@@ -805,6 +845,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw new Error(`Parent agent ${parentAgentId} not found`);
|
||||
}
|
||||
const provider = resolvedProviderModel.provider;
|
||||
const settings = callerArgs.settings;
|
||||
const resolvedCwd = resolveChildAgentCwd({
|
||||
parentCwd: parentAgent.cwd,
|
||||
requestedCwd: callerArgs.cwd,
|
||||
@@ -812,7 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||
});
|
||||
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||
requestedMode: callerArgs.mode,
|
||||
requestedMode: settings?.modeId,
|
||||
targetProvider: provider,
|
||||
parent: {
|
||||
provider: parentAgent.provider,
|
||||
@@ -828,8 +869,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
background: callerArgs.background ?? false,
|
||||
normalizedTitle: callerArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: callerArgs.thinking,
|
||||
features: callerArgs.features,
|
||||
thinkingOptionId: settings?.thinkingOptionId,
|
||||
features: settings?.features,
|
||||
labels: callerArgs.labels,
|
||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
@@ -843,9 +884,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
): Promise<ResolvedCreateAgentArgs> => {
|
||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
||||
const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } =
|
||||
topLevelArgs;
|
||||
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||
requestedMode: mode,
|
||||
requestedMode: settings?.modeId,
|
||||
targetProvider: resolvedProviderModel.provider,
|
||||
parent: null,
|
||||
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
||||
@@ -902,8 +944,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
background: topLevelArgs.background ?? false,
|
||||
normalizedTitle: topLevelArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: topLevelArgs.thinking,
|
||||
features: topLevelArgs.features,
|
||||
thinkingOptionId: settings?.thinkingOptionId,
|
||||
features: settings?.features,
|
||||
labels: topLevelArgs.labels,
|
||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
@@ -946,7 +988,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
background,
|
||||
normalizedTitle,
|
||||
model,
|
||||
thinking,
|
||||
thinkingOptionId,
|
||||
features,
|
||||
labels,
|
||||
notifyOnFinish,
|
||||
@@ -968,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
modeId: resolvedMode,
|
||||
title: normalizedTitle ?? undefined,
|
||||
model,
|
||||
thinkingOptionId: thinking,
|
||||
thinkingOptionId,
|
||||
featureValues: features,
|
||||
},
|
||||
undefined,
|
||||
@@ -1057,29 +1099,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_agent_feature",
|
||||
{
|
||||
title: "Set agent feature",
|
||||
description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
featureId: z.string().trim().min(1),
|
||||
value: z.unknown(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, featureId, value }) => {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"wait_for_agent",
|
||||
{
|
||||
@@ -1440,17 +1459,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"update_agent",
|
||||
{
|
||||
title: "Update agent",
|
||||
description: "Update an agent name and/or labels.",
|
||||
description: "Update an agent name, labels, and/or runtime settings.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
settings: UpdateAgentSettingsInputSchema.optional().describe(
|
||||
"Runtime settings to apply to the agent.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, name, labels }) => {
|
||||
async ({ agentId, name, labels, settings }) => {
|
||||
if (settings?.modeId !== undefined) {
|
||||
await agentManager.setAgentMode(agentId, settings.modeId);
|
||||
}
|
||||
if (settings?.model !== undefined) {
|
||||
await agentManager.setAgentModel(agentId, settings.model);
|
||||
}
|
||||
if (settings?.thinkingOptionId !== undefined) {
|
||||
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||
}
|
||||
if (settings?.features) {
|
||||
for (const [featureId, value] of Object.entries(settings.features)) {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedName = name?.trim();
|
||||
if (trimmedName) {
|
||||
const record = await agentStorage.get(agentId);
|
||||
@@ -2022,30 +2059,63 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_provider_features",
|
||||
"inspect_provider",
|
||||
{
|
||||
title: "List provider features",
|
||||
title: "Inspect provider",
|
||||
description:
|
||||
"List provider-specific features available for a draft agent configuration, such as Codex fast_mode.",
|
||||
inputSchema: listProviderFeaturesInputSchema,
|
||||
"Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.",
|
||||
inputSchema: inspectProviderInputSchema,
|
||||
outputSchema: {
|
||||
provider: AgentProviderEnum,
|
||||
label: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
status: z.string(),
|
||||
modes: z.array(ProviderModeSchema).nullish(),
|
||||
selectedModel: z.string().nullable(),
|
||||
features: z.array(AgentFeatureSchema),
|
||||
},
|
||||
},
|
||||
async ({ provider, cwd, modeId, model, thinkingOptionId, featureValues }) => {
|
||||
const features = await agentManager.listDraftFeatures({
|
||||
async ({ provider, cwd, settings }) => {
|
||||
const resolvedProviderModel = resolveScheduleProviderAndModel({
|
||||
provider,
|
||||
cwd: expandUserPath(cwd),
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
defaultProvider: provider,
|
||||
});
|
||||
const providerId = resolvedProviderModel.provider;
|
||||
if (!providerRegistry) {
|
||||
throw new Error("Provider registry is not configured");
|
||||
}
|
||||
const definition = providerRegistry[providerId];
|
||||
if (!definition) {
|
||||
throw new Error(`Provider ${providerId} is not configured`);
|
||||
}
|
||||
const summary = await resolveProviderSummary(definition, childLogger);
|
||||
if (!definition.enabled) {
|
||||
throw new Error(`Provider '${providerId}' is disabled`);
|
||||
}
|
||||
if (summary.status !== "available") {
|
||||
throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`);
|
||||
}
|
||||
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||
const features = await agentManager.listDraftFeatures({
|
||||
provider: providerId,
|
||||
cwd: resolvedCwd,
|
||||
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||
...(settings?.features ? { featureValues: settings.features } : {}),
|
||||
});
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
provider,
|
||||
provider: providerId,
|
||||
label: summary.label,
|
||||
description: summary.description,
|
||||
enabled: summary.enabled,
|
||||
status: summary.status,
|
||||
modes: summary.modes,
|
||||
selectedModel: selectedModel ?? null,
|
||||
features,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1438,6 +1438,26 @@ describe("ACPAgentSession slash commands", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
|
||||
const logger = createTestLogger();
|
||||
const trace = vi.spyOn(logger, "trace");
|
||||
const session = createSessionWithConfig({ provider: "kiro" }, logger);
|
||||
|
||||
await expect(
|
||||
session.extNotification("_kiro.dev/session/initialized", {
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(trace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "kiro",
|
||||
method: "_kiro.dev/session/initialized",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
"provider.acp.extension_notification",
|
||||
);
|
||||
});
|
||||
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
|
||||
@@ -1667,6 +1667,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: this.provider,
|
||||
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
|
||||
method,
|
||||
rawEvent: params,
|
||||
},
|
||||
"provider.acp.extension_notification",
|
||||
);
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
|
||||
const raw = await fs.readFile(params.path, "utf8");
|
||||
if (!params.line && !params.limit) {
|
||||
|
||||
@@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
expect(timelineEvents).toEqual([]);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
});
|
||||
|
||||
test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => {
|
||||
const queryFactory = createQueryFactoryForTurns([
|
||||
[
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "session-third-party",
|
||||
permissionMode: "default",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "assistant-third-party-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Here is the answer." }],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
session_id: "session-third-party",
|
||||
uuid: "assistant-third-party-event-1",
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Here is the answer.",
|
||||
is_error: false,
|
||||
duration_ms: 100,
|
||||
duration_api_ms: 80,
|
||||
num_turns: 1,
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0.01,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
permission_denials: [],
|
||||
uuid: "result-third-party-1",
|
||||
session_id: "session-third-party",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const result = await session.run("turn");
|
||||
await session.close();
|
||||
|
||||
expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private pendingInterruptAbort = false;
|
||||
private lastForegroundPromptText: string | null = null;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
private lastContextWindowUsedTokens: number | undefined;
|
||||
private lastContextWindowMaxTokens: number | undefined;
|
||||
private lastStreamRequestInputTokens: number | undefined;
|
||||
@@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const turnId = this.createTurnId("foreground");
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
|
||||
@@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
}
|
||||
|
||||
@@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
} else if (this.autonomousTurn) {
|
||||
this.autonomousTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("autonomous turn terminal");
|
||||
}
|
||||
}
|
||||
@@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.autonomousTurn = {
|
||||
id: this.createTurnId("autonomous"),
|
||||
};
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||
this.syncTurnState("autonomous turn started");
|
||||
}
|
||||
@@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
||||
this.autonomousTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("autonomous turn completed");
|
||||
}
|
||||
|
||||
@@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pendingInterruptAbort &&
|
||||
message.type === "result" &&
|
||||
@@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.logger.debug("Suppressing stale Claude interrupt terminal result");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
events.some((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
) {
|
||||
this.activeTurnHasAssistantText = true;
|
||||
}
|
||||
if (
|
||||
this.activeForegroundTurnId &&
|
||||
events.some(
|
||||
@@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (message.subtype === "success") {
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
// run client-side in the Claude CLI with no model turn — output_tokens
|
||||
// is 0 and the user-visible text is carried in `result`. Surface it as
|
||||
// an assistant message so the turn doesn't end silently. Normal turns
|
||||
// have output_tokens > 0 and their text is already in the stream.
|
||||
// is 0 and the user-visible text is carried in `result`. Surface it only
|
||||
// when the turn has not already emitted assistant text so zero-token
|
||||
// accounting from provider gateways does not duplicate streamed output.
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const outputTokens = message.usage?.output_tokens;
|
||||
if (resultText.length > 0 && outputTokens === 0) {
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
|
||||
@@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("does not synthesize a parent sub-agent failure from child error state alone", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
id: "call-sub-agent-transient-child-error",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
prompt: "Validate the child agent result.",
|
||||
receiverThreadIds: ["child-thread-1"],
|
||||
agentsStates: {
|
||||
"child-thread-1": { status: "error", message: "Sub-agent failed" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.at(-1)?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-transient-child-error",
|
||||
name: "Sub-agent",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Validate the child agent result.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("loads Codex persisted history from the app-server thread", async () => {
|
||||
const session = createSession();
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js";
|
||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
@@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults(
|
||||
};
|
||||
}
|
||||
|
||||
function codexMicrosoftStorePackageRoot(): string | null {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
return null;
|
||||
}
|
||||
return path.join(localAppData, "Packages");
|
||||
}
|
||||
|
||||
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageRoot = codexMicrosoftStorePackageRoot();
|
||||
if (!packageRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(packageRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codexPackages = entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const packageName of codexPackages) {
|
||||
const candidate = path.join(
|
||||
packageRoot,
|
||||
packageName,
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
"codex.exe",
|
||||
);
|
||||
if (await probeExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findDefaultCodexBinary(): Promise<string | null> {
|
||||
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
|
||||
}
|
||||
|
||||
async function resolveCodexBinary(): Promise<string> {
|
||||
const found = await findExecutable("codex");
|
||||
const found = await findDefaultCodexBinary();
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
@@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
if (command?.mode === "replace") {
|
||||
return await isCommandAvailable(command.argv[0]);
|
||||
}
|
||||
return await isCommandAvailable("codex");
|
||||
return (await findDefaultCodexBinary()) !== null;
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const available = await this.isAvailable();
|
||||
const resolvedBinary = await findExecutable("codex");
|
||||
const resolvedBinary = await findDefaultCodexBinary();
|
||||
const entries: Array<{ label: string; value: string }> = [
|
||||
{
|
||||
label: "Binary",
|
||||
@@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = {
|
||||
CodexAppServerClient,
|
||||
codexModelSupportsFastMode,
|
||||
CodexAppServerAgentSession,
|
||||
findCodexMicrosoftStoreBinary,
|
||||
findDefaultCodexBinary,
|
||||
formatCodexQuestionPrompts,
|
||||
mapCodexQuestionRequestToToolCall,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
|
||||
@@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fail a collabAgentToolCall from child error state alone", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
id: "call-sub-agent-transient-child-error",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
prompt: "Inspect the Codex stream path.",
|
||||
receiverThreadIds: ["child-thread-1"],
|
||||
agentsStates: {
|
||||
"child-thread-1": { status: "error", message: "Sub-agent failed" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-transient-child-error",
|
||||
name: "Sub-agent",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Inspect the Codex stream path.",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("still fails a collabAgentToolCall from an explicitly failed child state", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
id: "call-sub-agent-child-failed",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
prompt: "Inspect the Codex stream path.",
|
||||
receiverThreadIds: ["child-thread-1"],
|
||||
agentsStates: {
|
||||
"child-thread-1": { status: "failed", message: "Child failed" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-child-failed",
|
||||
name: "Sub-agent",
|
||||
status: "failed",
|
||||
error: { message: "Sub-agent failed" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Inspect the Codex stream path.",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps mcp read_file completion with detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
|
||||
@@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined {
|
||||
return typeof value.status === "string" ? value.status : undefined;
|
||||
}
|
||||
|
||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (normalized === "error" || normalized === "errored") {
|
||||
return "running";
|
||||
}
|
||||
return normalizeToolCallStatus(status, null, null);
|
||||
}
|
||||
|
||||
function resolveCollabAgentStatus(
|
||||
item: z.infer<typeof CodexCollabAgentToolCallItemSchema>,
|
||||
): ToolCallTimelineItem["status"] {
|
||||
@@ -551,10 +559,15 @@ function resolveCollabAgentStatus(
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const parentStatus = normalizeToolCallStatus(item.status, null, null);
|
||||
if (parentStatus === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const childStatuses = Object.values(item.agentsStates ?? {})
|
||||
.map(readStatus)
|
||||
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
|
||||
.map((status) => normalizeToolCallStatus(status, null, null));
|
||||
.map(normalizeCollabAgentChildStatus);
|
||||
|
||||
if (childStatuses.some((status) => status === "failed")) {
|
||||
return "failed";
|
||||
@@ -566,7 +579,7 @@ function resolveCollabAgentStatus(
|
||||
return childStatuses.every((status) => status === "completed") ? "completed" : "running";
|
||||
}
|
||||
|
||||
return normalizeToolCallStatus(item.status, item.error ?? null, null);
|
||||
return parentStatus;
|
||||
}
|
||||
|
||||
function buildMcpToolName(server: string | undefined, tool: string): string {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
type ResolvedCommand,
|
||||
type Skill,
|
||||
type WriteToolInput,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface PiSessionRecoveryResult {
|
||||
}
|
||||
|
||||
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
|
||||
// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow.
|
||||
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
|
||||
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
|
||||
|
||||
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
@@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
|
||||
import { ClaudeAgentClient } from "./claude/agent.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
|
||||
const originalEnv = {
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
||||
PATH: process.env.PATH,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
};
|
||||
@@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isolateCodexDefaultDiscoveryTo(dir: string): void {
|
||||
isolatePathTo(dir);
|
||||
if (process.platform === "win32") {
|
||||
process.env.LOCALAPPDATA = dir;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.LOCALAPPDATA === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA;
|
||||
}
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@@ -42,12 +55,51 @@ afterEach(() => {
|
||||
describe("default provider availability", () => {
|
||||
test("Codex reports unavailable when the default command cannot be resolved", async () => {
|
||||
const binDir = makeTempDir("provider-availability-codex-");
|
||||
isolatePathTo(binDir);
|
||||
isolateCodexDefaultDiscoveryTo(binDir);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
const root = makeTempDir("provider-availability-codex-store-");
|
||||
const emptyPathDir = join(root, "empty-path");
|
||||
const codexBinDir = join(
|
||||
root,
|
||||
"Packages",
|
||||
"OpenAI.Codex_abc123",
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
);
|
||||
const codexExe = join(codexBinDir, "codex.exe");
|
||||
mkdirSync(emptyPathDir, { recursive: true });
|
||||
mkdirSync(codexBinDir, { recursive: true });
|
||||
copyFileSync(process.execPath, codexExe);
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
process.env.LOCALAPPDATA = root;
|
||||
isolatePathTo(emptyPathDir);
|
||||
process.env.PATHEXT = ".EXE";
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe);
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
if (originalLocalAppData === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude reports unavailable when the default command cannot be resolved", async () => {
|
||||
const binDir = makeTempDir("provider-availability-claude-");
|
||||
isolatePathTo(binDir);
|
||||
@@ -66,7 +118,7 @@ describe("default provider availability", () => {
|
||||
|
||||
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||
isolatePathTo(binDir);
|
||||
isolateCodexDefaultDiscoveryTo(binDir);
|
||||
const workdir = makeTempDir("provider-availability-manager-work-");
|
||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||
const manager = new AgentManager({
|
||||
@@ -88,7 +140,7 @@ describe("default provider availability", () => {
|
||||
|
||||
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
|
||||
const binDir = makeTempDir("provider-availability-resume-bin-");
|
||||
isolatePathTo(binDir);
|
||||
isolateCodexDefaultDiscoveryTo(binDir);
|
||||
const workdir = makeTempDir("provider-availability-resume-work-");
|
||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||
const manager = new AgentManager({
|
||||
|
||||
@@ -936,6 +936,16 @@ export async function createPaseoDaemon(
|
||||
workspaceGitService,
|
||||
github,
|
||||
config.pushNotificationSender,
|
||||
{
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
|
||||
@@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({
|
||||
mergeToBase: vi.fn(),
|
||||
pullCurrentBranch: vi.fn(),
|
||||
pushCurrentBranch: vi.fn(),
|
||||
renameCurrentBranch: vi.fn(),
|
||||
resolveBranchCheckout: vi.fn(),
|
||||
warmCheckoutShortstatInBackground: vi.fn(),
|
||||
}));
|
||||
@@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => {
|
||||
mergeToBase: checkoutGitMocks.mergeToBase,
|
||||
pullCurrentBranch: checkoutGitMocks.pullCurrentBranch,
|
||||
pushCurrentBranch: checkoutGitMocks.pushCurrentBranch,
|
||||
renameCurrentBranch: checkoutGitMocks.renameCurrentBranch,
|
||||
resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout,
|
||||
warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground,
|
||||
};
|
||||
@@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType<typeof vi.fn> }): {
|
||||
setTerminalTitle: ReturnType<typeof vi.fn>;
|
||||
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
setTerminalTitle: options?.setTerminalTitle ?? vi.fn(),
|
||||
subscribeTerminalsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session checkout rename branch handling", () => {
|
||||
test("rejects invalid branch slugs without renaming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "Feature Name",
|
||||
requestId: "request-rename-invalid",
|
||||
});
|
||||
|
||||
expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: false,
|
||||
currentBranch: null,
|
||||
error: {
|
||||
code: "UNKNOWN",
|
||||
message:
|
||||
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||
},
|
||||
requestId: "request-rename-invalid",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reports null current branch when branch rename fails", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists"));
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "feature/new-name",
|
||||
requestId: "request-rename-failure",
|
||||
});
|
||||
|
||||
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
"feature/new-name",
|
||||
);
|
||||
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: false,
|
||||
currentBranch: null,
|
||||
error: {
|
||||
code: "UNKNOWN",
|
||||
message: "branch already exists",
|
||||
},
|
||||
requestId: "request-rename-failure",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("forces workspace git refresh after renaming the current branch", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const github = { invalidate: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(
|
||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "feature/new-name",
|
||||
isDirty: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: { currentBranch: "feature/old-name" },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||
checkoutGitMocks.renameCurrentBranch.mockResolvedValue({
|
||||
previousBranch: "feature/old-name",
|
||||
currentBranch: "feature/new-name",
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "feature/new-name",
|
||||
requestId: "request-rename-success",
|
||||
});
|
||||
|
||||
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
"feature/new-name",
|
||||
);
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
||||
force: true,
|
||||
reason: "rename-branch",
|
||||
});
|
||||
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
currentBranch: "feature/new-name",
|
||||
error: null,
|
||||
requestId: "request-rename-success",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session terminal rename handling", () => {
|
||||
test("rejects an empty terminal title without calling the terminal manager", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const terminalManager = createTerminalManagerStub();
|
||||
const session = createSessionForTest({ terminalManager, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "terminal.rename.request",
|
||||
terminalId: "terminal-1",
|
||||
title: " ",
|
||||
requestId: "request-empty-title",
|
||||
});
|
||||
|
||||
expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "terminal.rename.response",
|
||||
payload: {
|
||||
requestId: "request-empty-title",
|
||||
success: false,
|
||||
error: "Title is required",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reports when the terminal manager cannot find the terminal", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
setTerminalTitle: vi.fn(() => false),
|
||||
});
|
||||
const session = createSessionForTest({ terminalManager, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "terminal.rename.request",
|
||||
terminalId: "missing-terminal",
|
||||
title: "Renamed terminal",
|
||||
requestId: "request-missing-terminal",
|
||||
});
|
||||
|
||||
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith(
|
||||
"missing-terminal",
|
||||
"Renamed terminal",
|
||||
);
|
||||
expect(messages).toContainEqual({
|
||||
type: "terminal.rename.response",
|
||||
payload: {
|
||||
requestId: "request-missing-terminal",
|
||||
success: false,
|
||||
error: "Terminal not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("trims and sets a valid terminal title", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const terminalManager = createTerminalManagerStub({
|
||||
setTerminalTitle: vi.fn(() => true),
|
||||
});
|
||||
const session = createSessionForTest({ terminalManager, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "terminal.rename.request",
|
||||
terminalId: "terminal-1",
|
||||
title: " Renamed terminal ",
|
||||
requestId: "request-title-success",
|
||||
});
|
||||
|
||||
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal");
|
||||
expect(messages).toContainEqual({
|
||||
type: "terminal.rename.response",
|
||||
payload: {
|
||||
requestId: "request-title-success",
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session branch suggestions handling", () => {
|
||||
test("lists branch suggestions through the workspace git service", async () => {
|
||||
const messages: unknown[] = [];
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type FileExplorerRequest,
|
||||
type FileDownloadTokenRequest,
|
||||
type GitSetupOptions,
|
||||
type CheckoutRenameBranchRequest,
|
||||
type StartWorkspaceScriptRequest,
|
||||
type CloseItemsRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
@@ -47,6 +48,8 @@ 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 {
|
||||
DictationStreamManager,
|
||||
type DictationStreamOutboundMessage,
|
||||
@@ -192,7 +195,9 @@ import {
|
||||
pullCurrentBranch,
|
||||
pushCurrentBranch,
|
||||
createPullRequest,
|
||||
renameCurrentBranch,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { validateBranchSlug } from "../utils/branch-slug.js";
|
||||
import { getProjectIcon } from "../utils/project-icon.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||
@@ -306,8 +311,8 @@ type GitMutationRefreshReason =
|
||||
| "disable-pr-auto-merge"
|
||||
| "create-pr"
|
||||
| "switch-branch"
|
||||
| "create-branch"
|
||||
| "rename-branch"
|
||||
| "create-branch"
|
||||
| "stash-push"
|
||||
| "stash-pop"
|
||||
| "create-worktree";
|
||||
@@ -429,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing";
|
||||
|
||||
interface WorkspaceGitWatchTarget {
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
watchers: FSWatcher[];
|
||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
refreshPromise: Promise<void> | null;
|
||||
@@ -595,6 +601,18 @@ export interface SessionOptions {
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
isDev?: boolean;
|
||||
serverId?: string;
|
||||
daemonVersion?: string;
|
||||
daemonRuntimeConfig?: {
|
||||
listen: string | null;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionLifecycleIntent =
|
||||
@@ -805,6 +823,9 @@ export class Session {
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly isDev: boolean;
|
||||
private readonly serverId: string | undefined;
|
||||
private readonly daemonVersion: string | undefined;
|
||||
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
|
||||
private voiceModeAgentId: string | null = null;
|
||||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
||||
|
||||
@@ -850,6 +871,9 @@ export class Session {
|
||||
agentProviderRuntimeSettings,
|
||||
providerOverrides,
|
||||
isDev,
|
||||
serverId,
|
||||
daemonVersion,
|
||||
daemonRuntimeConfig,
|
||||
} = options;
|
||||
this.clientId = clientId;
|
||||
this.appVersion = appVersion ?? null;
|
||||
@@ -901,6 +925,9 @@ export class Session {
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.providerOverrides = providerOverrides;
|
||||
this.isDev = isDev === true;
|
||||
this.serverId = serverId;
|
||||
this.daemonVersion = daemonVersion;
|
||||
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
||||
this.abortController = new AbortController();
|
||||
this.workspaceDirectory = new WorkspaceDirectory({
|
||||
logger: this.sessionLogger,
|
||||
@@ -1864,6 +1891,10 @@ export class Session {
|
||||
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
|
||||
});
|
||||
return undefined;
|
||||
case "daemon.get_status.request":
|
||||
return this.handleDaemonGetStatusRequest(msg);
|
||||
case "daemon.get_pairing_offer.request":
|
||||
return this.handleDaemonGetPairingOfferRequest(msg);
|
||||
case "set_daemon_config_request":
|
||||
this.emit({
|
||||
type: "set_daemon_config_response",
|
||||
@@ -2019,12 +2050,8 @@ export class Session {
|
||||
return undefined;
|
||||
case "checkout_switch_branch_request":
|
||||
return this.handleCheckoutSwitchBranchRequest(msg);
|
||||
case "stash_save_request":
|
||||
return this.handleStashSaveRequest(msg);
|
||||
case "stash_pop_request":
|
||||
return this.handleStashPopRequest(msg);
|
||||
case "stash_list_request":
|
||||
return this.handleStashListRequest(msg);
|
||||
case "checkout.rename_branch.request":
|
||||
return this.handleCheckoutRenameBranchRequest(msg);
|
||||
case "checkout_commit_request":
|
||||
return this.handleCheckoutCommitRequest(msg);
|
||||
case "checkout_merge_request":
|
||||
@@ -2047,6 +2074,12 @@ export class Session {
|
||||
return this.handlePullRequestTimelineRequest(msg);
|
||||
case "github_search_request":
|
||||
return this.handleGitHubSearchRequest(msg);
|
||||
case "stash_save_request":
|
||||
return this.handleStashSaveRequest(msg);
|
||||
case "stash_pop_request":
|
||||
return this.handleStashPopRequest(msg);
|
||||
case "stash_list_request":
|
||||
return this.handleStashListRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -3813,6 +3846,86 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDaemonGetStatusRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const pidInfo = await getPidLockInfo(this.paseoHome);
|
||||
const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({
|
||||
provider: p.provider,
|
||||
available: p.available,
|
||||
error: p.error ?? null,
|
||||
}));
|
||||
this.emit({
|
||||
type: "daemon.get_status.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
serverId: this.serverId ?? "",
|
||||
version: this.daemonVersion ?? null,
|
||||
pid: process.pid,
|
||||
nodePath: process.execPath,
|
||||
startedAt: pidInfo?.startedAt ?? null,
|
||||
listen: this.daemonRuntimeConfig?.listen ?? null,
|
||||
relay: this.daemonRuntimeConfig?.relay ?? null,
|
||||
providers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to handle daemon status request");
|
||||
this.emit({
|
||||
type: "daemon.get_status.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
serverId: this.serverId ?? "",
|
||||
version: this.daemonVersion ?? null,
|
||||
pid: process.pid,
|
||||
nodePath: process.execPath,
|
||||
startedAt: null,
|
||||
listen: null,
|
||||
relay: null,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDaemonGetPairingOfferRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const relay = this.daemonRuntimeConfig?.relay;
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome: this.paseoHome,
|
||||
relayEnabled: relay?.enabled ?? true,
|
||||
relayEndpoint: relay?.endpoint,
|
||||
relayPublicEndpoint: relay?.publicEndpoint,
|
||||
relayUseTls: relay?.useTls,
|
||||
relayPublicUseTls: relay?.publicUseTls,
|
||||
includeQr: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.emit({
|
||||
type: "daemon.get_pairing_offer.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
url: pairing.url ?? "",
|
||||
qr: pairing.qr ?? null,
|
||||
relayEnabled: pairing.relayEnabled,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request");
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: "daemon.get_pairing_offer.request",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListAvailableProvidersRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -4826,16 +4939,35 @@ export class Session {
|
||||
target.lastBranchName = workspace?.name ?? null;
|
||||
}
|
||||
|
||||
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBranchName = target.lastBranchName;
|
||||
if (branchName === previousBranchName) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.lastBranchName = branchName;
|
||||
this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
|
||||
}
|
||||
|
||||
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||
for (const workspace of workspaces) {
|
||||
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
|
||||
isGit: workspace.projectKind === "git",
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
|
||||
private syncWorkspaceGitObserver(
|
||||
cwd: string,
|
||||
options: { isGit: boolean; workspaceId: string },
|
||||
): void {
|
||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||
if (!options.isGit) {
|
||||
this.removeWorkspaceGitSubscription(normalizedCwd);
|
||||
@@ -4846,9 +4978,22 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceGitWatchTarget = {
|
||||
cwd: normalizedCwd,
|
||||
workspaceId: options.workspaceId,
|
||||
watchers: [],
|
||||
debounceTimer: null,
|
||||
refreshPromise: null,
|
||||
refreshQueued: false,
|
||||
latestDescriptorStateKey: null,
|
||||
lastBranchName: null,
|
||||
};
|
||||
this.workspaceGitWatchTargets.set(normalizedCwd, target);
|
||||
|
||||
const subscription = this.workspaceGitService.registerWorkspace(
|
||||
{ cwd: normalizedCwd },
|
||||
(snapshot) => {
|
||||
this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
||||
},
|
||||
@@ -4955,6 +5100,58 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise<void> {
|
||||
const { cwd, branch, requestId } = msg;
|
||||
const validation = validateBranchSlug(branch);
|
||||
|
||||
if (!validation.valid) {
|
||||
this.emit({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
currentBranch: null,
|
||||
error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await renameCurrentBranch(cwd, branch);
|
||||
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||
|
||||
// Push a workspace_update immediately so the sidebar/header reflect
|
||||
// the new branch name without waiting for the background git watcher.
|
||||
await this.emitWorkspaceUpdateForCwd(cwd);
|
||||
|
||||
this.emit({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: true,
|
||||
currentBranch: result.currentBranch,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
currentBranch: null,
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stash handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -6672,7 +6869,10 @@ export class Session {
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
|
||||
options?: {
|
||||
skipReconcile?: boolean;
|
||||
dedupeGitState?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type {
|
||||
WorkspaceGitListener,
|
||||
WorkspaceGitRuntimeSnapshot,
|
||||
@@ -24,7 +27,7 @@ interface SessionInternals {
|
||||
lastEmittedByWorkspaceId: Map<string, unknown>;
|
||||
};
|
||||
buildWorkspaceDescriptorMap: () => Promise<Map<string, unknown>>;
|
||||
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void;
|
||||
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void;
|
||||
listAgentPayloads: () => Promise<unknown[]>;
|
||||
}
|
||||
|
||||
@@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionForWorkspaceGitWatchTests(): {
|
||||
function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
oldBranch: string | null,
|
||||
newBranch: string | null,
|
||||
) => void;
|
||||
scriptRouteStore?: ScriptRouteStore;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
}): {
|
||||
session: Session;
|
||||
emitted: Array<{ type: string; payload: unknown }>;
|
||||
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
|
||||
@@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
scriptRouteStore: options?.scriptRouteStore,
|
||||
scriptRuntimeStore: options?.scriptRuntimeStore,
|
||||
onBranchChanged: options?.onBranchChanged,
|
||||
getDaemonTcpPort: () => 6767,
|
||||
});
|
||||
|
||||
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
|
||||
@@ -305,7 +320,7 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||
|
||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||
{ cwd: REPO_CWD },
|
||||
@@ -364,7 +379,7 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
@@ -407,6 +422,74 @@ describe("workspace git watch targets", () => {
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("updates running service script URLs when the git branch changes", async () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
routeStore.registerRoute({
|
||||
hostname: "app.old-branch.paseo.localhost",
|
||||
port: 4321,
|
||||
workspaceId: "ws-10",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "app",
|
||||
});
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
runtimeStore.set({
|
||||
workspaceId: "ws-10",
|
||||
scriptName: "app",
|
||||
type: "service",
|
||||
lifecycle: "running",
|
||||
terminalId: "term-app",
|
||||
exitCode: null,
|
||||
});
|
||||
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
onRoutesChanged: vi.fn(),
|
||||
});
|
||||
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
|
||||
{
|
||||
scriptRouteStore: routeStore,
|
||||
scriptRuntimeStore: runtimeStore,
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: "proj-1",
|
||||
workspaceId: "ws-10",
|
||||
cwd: "/tmp/repo",
|
||||
name: "old-branch",
|
||||
});
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" });
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "new-branch",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
|
||||
expect.objectContaining({
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app.new-branch.paseo.localhost",
|
||||
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
||||
const { session, emitted, projects, workspaces, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
@@ -427,7 +510,7 @@ describe("workspace git watch targets", () => {
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||
emitted.length = 0;
|
||||
|
||||
subscriptions[0]?.listener(
|
||||
|
||||
@@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly daemonRuntimeConfig:
|
||||
| {
|
||||
listen: string | null;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
@@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceGitService?: WorkspaceGitService,
|
||||
github?: GitHubService,
|
||||
pushNotificationSender?: PushNotificationSender,
|
||||
daemonRuntimeConfig?: {
|
||||
listen: string | null;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
},
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
throw new MissingDaemonVersionError();
|
||||
}
|
||||
this.daemonVersion = daemonVersion.trim();
|
||||
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
@@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
isDev: this.isDev,
|
||||
serverId: this.serverId,
|
||||
daemonVersion: this.daemonVersion,
|
||||
daemonRuntimeConfig: this.daemonRuntimeConfig,
|
||||
});
|
||||
|
||||
connection = {
|
||||
@@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
providersSnapshot: true,
|
||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
140
packages/server/src/shared/messages.rename-entities.test.ts
Normal file
140
packages/server/src/shared/messages.rename-entities.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">;
|
||||
|
||||
function schemaWithoutMessageTypes(
|
||||
schema: { options: SessionMessageOption[] },
|
||||
excludedTypes: string[],
|
||||
) {
|
||||
const excluded = new Set(excludedTypes);
|
||||
const options = schema.options.filter((option) => !excluded.has(option.shape.type.value));
|
||||
|
||||
return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]);
|
||||
}
|
||||
|
||||
describe("rename entity message schemas", () => {
|
||||
test("new client schema still parses old daemon checkout and terminal responses", () => {
|
||||
const checkoutResponse = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
branch: "main",
|
||||
source: "local",
|
||||
error: null,
|
||||
requestId: "request-switch",
|
||||
},
|
||||
});
|
||||
const terminalResponse = SessionOutboundMessageSchema.parse({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkoutResponse).toEqual({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
branch: "main",
|
||||
source: "local",
|
||||
error: null,
|
||||
requestId: "request-switch",
|
||||
},
|
||||
});
|
||||
expect(terminalResponse).toEqual({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("old unions without rename variants reject rename messages and still parse existing messages", () => {
|
||||
const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [
|
||||
"terminal.rename.request",
|
||||
"checkout.rename_branch.request",
|
||||
]);
|
||||
const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [
|
||||
"terminal.rename.response",
|
||||
"checkout.rename_branch.response",
|
||||
]);
|
||||
|
||||
expect(
|
||||
legacyInboundSchema.safeParse({
|
||||
type: "terminal.rename.request",
|
||||
terminalId: "terminal-1",
|
||||
title: "Server logs",
|
||||
requestId: "request-terminal-rename",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyInboundSchema.safeParse({
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "feature/new-name",
|
||||
requestId: "request-branch-rename",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyOutboundSchema.safeParse({
|
||||
type: "terminal.rename.response",
|
||||
payload: {
|
||||
requestId: "request-terminal-rename",
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyOutboundSchema.safeParse({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
requestId: "request-branch-rename",
|
||||
success: true,
|
||||
cwd: "/tmp/repo",
|
||||
currentBranch: "feature/new-name",
|
||||
error: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
legacyInboundSchema.parse({
|
||||
type: "checkout_switch_branch_request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "main",
|
||||
requestId: "request-switch",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout_switch_branch_request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "main",
|
||||
requestId: "request-switch",
|
||||
});
|
||||
expect(
|
||||
legacyOutboundSchema.parse({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const DaemonGetStatusRequestSchema = z.object({
|
||||
type: z.literal("daemon.get_status.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DaemonGetPairingOfferRequestSchema = z.object({
|
||||
type: z.literal("daemon.get_pairing_offer.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const GetDaemonConfigRequestMessageSchema = z.object({
|
||||
type: z.literal("get_daemon_config_request"),
|
||||
requestId: z.string(),
|
||||
@@ -1379,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutRenameBranchRequestSchema = z.object({
|
||||
type: z.literal("checkout.rename_branch.request"),
|
||||
cwd: z.string(),
|
||||
branch: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StashSaveRequestSchema = z.object({
|
||||
type: z.literal("stash_save_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1696,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const RenameTerminalRequestSchema = z.object({
|
||||
type: z.literal("terminal.rename.request"),
|
||||
terminalId: z.string(),
|
||||
title: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const StartWorkspaceScriptRequestSchema = z.object({
|
||||
type: z.literal("start_workspace_script_request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -1764,6 +1788,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
DaemonGetStatusRequestSchema,
|
||||
DaemonGetPairingOfferRequestSchema,
|
||||
GetDaemonConfigRequestMessageSchema,
|
||||
SetDaemonConfigRequestMessageSchema,
|
||||
ReadProjectConfigRequestMessageSchema,
|
||||
@@ -1806,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrStatusRequestSchema,
|
||||
PullRequestTimelineRequestSchema,
|
||||
CheckoutSwitchBranchRequestSchema,
|
||||
CheckoutRenameBranchRequestSchema,
|
||||
StashSaveRequestSchema,
|
||||
StashPopRequestSchema,
|
||||
StashListRequestSchema,
|
||||
@@ -1833,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SubscribeTerminalsRequestSchema,
|
||||
UnsubscribeTerminalsRequestSchema,
|
||||
CreateTerminalRequestSchema,
|
||||
RenameTerminalRequestSchema,
|
||||
StartWorkspaceScriptRequestSchema,
|
||||
SubscribeTerminalRequestSchema,
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
@@ -2026,6 +2054,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2586,6 +2616,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
export const DaemonGetStatusResponseSchema = z.object({
|
||||
type: z.literal("daemon.get_status.response"),
|
||||
payload: z
|
||||
.object({
|
||||
requestId: z.string(),
|
||||
serverId: z.string(),
|
||||
version: z.string().nullable().optional(),
|
||||
pid: z.number(),
|
||||
nodePath: z.string(),
|
||||
startedAt: z.string().nullable().optional(),
|
||||
listen: z.string().nullable(),
|
||||
relay: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
endpoint: z.string(),
|
||||
publicEndpoint: z.string(),
|
||||
useTls: z.boolean(),
|
||||
publicUseTls: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
available: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
export const DaemonGetPairingOfferResponseSchema = z.object({
|
||||
type: z.literal("daemon.get_pairing_offer.response"),
|
||||
payload: z
|
||||
.object({
|
||||
requestId: z.string(),
|
||||
url: z.string(),
|
||||
qr: z.string().nullable().optional(),
|
||||
relayEnabled: z.boolean(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
export const SetDaemonConfigResponseMessageSchema = z.object({
|
||||
type: z.literal("set_daemon_config_response"),
|
||||
payload: z
|
||||
@@ -3047,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutRenameBranchResponseSchema = z.object({
|
||||
type: z.literal("checkout.rename_branch.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
cwd: z.string(),
|
||||
currentBranch: z.string().nullable(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const StashEntrySchema = z.object({
|
||||
index: z.number().int().min(0),
|
||||
message: z.string(),
|
||||
@@ -3401,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const RenameTerminalResponseSchema = z.object({
|
||||
type: z.literal("terminal.rename.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SubscribeTerminalResponseSchema = z.object({
|
||||
type: z.literal("subscribe_terminal_response"),
|
||||
payload: z.union([
|
||||
@@ -3481,6 +3575,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
SendAgentMessageResponseMessageSchema,
|
||||
SetVoiceModeResponseMessageSchema,
|
||||
DaemonGetStatusResponseSchema,
|
||||
DaemonGetPairingOfferResponseSchema,
|
||||
GetDaemonConfigResponseMessageSchema,
|
||||
SetDaemonConfigResponseMessageSchema,
|
||||
ReadProjectConfigResponseMessageSchema,
|
||||
@@ -3512,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrStatusResponseSchema,
|
||||
PullRequestTimelineResponseSchema,
|
||||
CheckoutSwitchBranchResponseSchema,
|
||||
CheckoutRenameBranchResponseSchema,
|
||||
StashSaveResponseSchema,
|
||||
StashPopResponseSchema,
|
||||
StashListResponseSchema,
|
||||
@@ -3537,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
RenameTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
KillTerminalResponseSchema,
|
||||
CaptureTerminalResponseSchema,
|
||||
@@ -3643,6 +3741,8 @@ export type ListProviderFeaturesResponseMessage = z.infer<
|
||||
typeof ListProviderFeaturesResponseMessageSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
|
||||
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
|
||||
export type GetProvidersSnapshotResponseMessage = z.infer<
|
||||
typeof GetProvidersSnapshotResponseMessageSchema
|
||||
>;
|
||||
@@ -3782,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer<typeof PullRequestTimelineItemSche
|
||||
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
|
||||
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
||||
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
||||
export type CheckoutRenameBranchRequest = z.infer<typeof CheckoutRenameBranchRequestSchema>;
|
||||
export type CheckoutRenameBranchResponse = z.infer<typeof CheckoutRenameBranchResponseSchema>;
|
||||
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
||||
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
||||
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
||||
@@ -3835,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
|
||||
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
|
||||
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
||||
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
||||
export type RenameTerminalRequest = z.infer<typeof RenameTerminalRequestSchema>;
|
||||
export type RenameTerminalResponse = z.infer<typeof RenameTerminalResponseSchema>;
|
||||
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
|
||||
export type StartWorkspaceScriptResponse = z.infer<
|
||||
typeof StartWorkspaceScriptResponseMessageSchema
|
||||
|
||||
@@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => {
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({
|
||||
cwd: realpathSync(tmpdir()),
|
||||
title: "Existing title",
|
||||
});
|
||||
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(
|
||||
input.terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
|
||||
expect(session.getTitle()).toBe("Existing title");
|
||||
expect(session.getState().title).toBe("Existing title");
|
||||
expect(snapshots).toEqual([]);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||
|
||||
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
|
||||
expect(session.getTitle()).toBe("x");
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface TerminalManager {
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
|
||||
setTerminalTitle(id: string, title: string): boolean;
|
||||
killTerminal(id: string): void;
|
||||
killTerminalAndWait(
|
||||
id: string,
|
||||
@@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager {
|
||||
return terminalsById.get(id)?.getStateSnapshot() ?? null;
|
||||
},
|
||||
|
||||
setTerminalTitle(id: string, title: string): boolean {
|
||||
const session = terminalsById.get(id);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.setTitle(title);
|
||||
return true;
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
removeSessionById(id, { kill: true });
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreateTerminalRequest,
|
||||
KillTerminalRequest,
|
||||
ListTerminalsRequest,
|
||||
RenameTerminalRequest,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
SubscribeTerminalRequest,
|
||||
@@ -64,7 +65,8 @@ type TerminalDispatchableMessage =
|
||||
| UnsubscribeTerminalRequest
|
||||
| TerminalInput
|
||||
| KillTerminalRequest
|
||||
| CaptureTerminalRequest;
|
||||
| CaptureTerminalRequest
|
||||
| RenameTerminalRequest;
|
||||
|
||||
const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> = new Set([
|
||||
"subscribe_terminals_request",
|
||||
@@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> =
|
||||
"terminal_input",
|
||||
"kill_terminal_request",
|
||||
"capture_terminal_request",
|
||||
"terminal.rename.request",
|
||||
]);
|
||||
|
||||
export class TerminalSessionController {
|
||||
@@ -145,6 +148,8 @@ export class TerminalSessionController {
|
||||
return this.handleKillTerminalRequest(msg);
|
||||
case "capture_terminal_request":
|
||||
return this.handleCaptureTerminalRequest(msg);
|
||||
case "terminal.rename.request":
|
||||
return this.handleRenameTerminalRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -430,6 +435,32 @@ export class TerminalSessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> {
|
||||
const respond = (success: boolean, error: string | null): void => {
|
||||
this.emit({
|
||||
type: "terminal.rename.response",
|
||||
payload: { requestId: msg.requestId, success, error },
|
||||
});
|
||||
};
|
||||
|
||||
const title = msg.title.trim();
|
||||
if (title.length === 0) {
|
||||
respond(false, "Title is required");
|
||||
return;
|
||||
}
|
||||
if (title.length > 200) {
|
||||
respond(false, "Title is too long");
|
||||
return;
|
||||
}
|
||||
if (!this.terminalManager) {
|
||||
respond(false, "Terminal manager not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title);
|
||||
respond(renamed, renamed ? null : "Terminal not found");
|
||||
}
|
||||
|
||||
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
|
||||
if (!this.terminalManager) {
|
||||
this.emit({
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
buildTerminalEnvironment,
|
||||
createTerminal,
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
||||
resolveDefaultTerminalShell,
|
||||
humanizeProcessTitle,
|
||||
normalizeProcessTitle,
|
||||
resolveZshShellIntegrationDir,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
@@ -17,8 +21,92 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
|
||||
const hasZsh = existsSync("/bin/zsh");
|
||||
|
||||
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
|
||||
|
||||
function rowToText(row: TerminalRow): string {
|
||||
return row
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
// Extract text from a single row
|
||||
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||
return rowToText(state.grid[rowIndex]);
|
||||
}
|
||||
|
||||
// Extract all visible lines as array (trimmed, empty lines included)
|
||||
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||
return state.grid.map(rowToText);
|
||||
}
|
||||
|
||||
// Wait for terminal state to match expected lines
|
||||
async function waitForLines(
|
||||
session: TerminalSession,
|
||||
expectedLines: string[],
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const lines = getLines(session.getState());
|
||||
let matches = true;
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
if (lines[i] !== expectedLines[i]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
||||
throw new Error(
|
||||
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForState(
|
||||
session: TerminalSession,
|
||||
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ReturnType<TerminalSession["getState"]>> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const state = session.getState();
|
||||
if (predicate(state)) {
|
||||
return state;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for terminal state predicate to match");
|
||||
}
|
||||
|
||||
async function waitForTitle(
|
||||
session: TerminalSession,
|
||||
predicate: (title: string | undefined) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<string | undefined> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const title = session.getTitle();
|
||||
if (predicate(title)) {
|
||||
return title;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for terminal title predicate to match");
|
||||
}
|
||||
|
||||
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
||||
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||
@@ -28,6 +116,7 @@ const sessions: TerminalSession[] = [];
|
||||
const temporaryDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const session of sessions) {
|
||||
session.kill();
|
||||
}
|
||||
@@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function waitForScheduledTimers(expectedTimerCount: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (vi.getTimerCount() === expectedTimerCount) {
|
||||
return;
|
||||
}
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`);
|
||||
}
|
||||
|
||||
describe("createTerminal", () => {
|
||||
it("keeps full process titles while stripping path prefixes", () => {
|
||||
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
|
||||
@@ -205,6 +305,544 @@ describe("createTerminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||
it("executes a simple echo command", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for initial prompt, then send command
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: "echo hello\r" });
|
||||
|
||||
// After running "echo hello", terminal should show:
|
||||
// Line 0: "$ echo hello"
|
||||
// Line 1: "hello"
|
||||
// Line 2: "$"
|
||||
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
expect(getRowText(state, 0)).toBe("$ echo hello");
|
||||
expect(getRowText(state, 1)).toBe("hello");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
});
|
||||
|
||||
it("captures output from pwd in specified cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: "pwd\r" });
|
||||
|
||||
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
expect(getRowText(state, 0)).toBe("$ pwd");
|
||||
expect(getRowText(state, 1)).toBe("/tmp");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||
it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||
mkdirSync(realZdotdir, { recursive: true });
|
||||
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: homeDir,
|
||||
command: "/bin/zsh",
|
||||
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: realZdotdir,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
|
||||
(resolve) => {
|
||||
session.onExit((info) => resolve(info));
|
||||
},
|
||||
);
|
||||
|
||||
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
|
||||
});
|
||||
|
||||
it("emits the initial title from command args to title listeners", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "npm-cli.js");
|
||||
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForTitle(session, (title) => title === "npm run dev");
|
||||
await waitForState(session, (state) => state.title === "npm run dev");
|
||||
|
||||
expect(seenTitles).toContain("npm run dev");
|
||||
expect(session.getTitle()).toBe("npm run dev");
|
||||
expect(session.getState().title).toBe("npm run dev");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("emits OSC title updates to title listeners", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "Build Log");
|
||||
|
||||
expect(seenTitles).toContain("Build Log");
|
||||
expect(session.getTitle()).toBe("Build Log");
|
||||
expect(session.getState().title).toBe("Build Log");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("keeps preset titles instead of applying OSC title updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
title: "typecheck",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
expect(session.getTitle()).toBe("typecheck");
|
||||
expect(session.getState().title).toBe("typecheck");
|
||||
});
|
||||
|
||||
it("emits command completion from VS Code OSC 633 without visible output", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "emit-command-finished.sh");
|
||||
writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n");
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const commandCompletions: Array<number | null> = [];
|
||||
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||
commandCompletions.push(info.exitCode);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "./emit-command-finished.sh\r" });
|
||||
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([7]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
|
||||
it("ignores malformed VS Code OSC 633 command completion payloads", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
"#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: packageRoot,
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const commandCompletions: Array<number | null> = [];
|
||||
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||
commandCompletions.push(info.exitCode);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" });
|
||||
|
||||
await waitForState(session, () => commandCompletions.length === 1);
|
||||
|
||||
expect(commandCompletions).toEqual([3]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
|
||||
it("debounces rapid title changes and emits only the final title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const seenMessages: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
const unsubscribeMessages = session.subscribe((message) => {
|
||||
if (message.type === "titleChange") {
|
||||
seenMessages.push(message.title);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({
|
||||
type: "input",
|
||||
data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
|
||||
});
|
||||
|
||||
await waitForTitle(session, (title) => title === "Final");
|
||||
|
||||
expect(seenTitles).toEqual(["Final"]);
|
||||
expect(seenMessages).toEqual(["Final"]);
|
||||
|
||||
unsubscribeMessages();
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||
const workingDir = join(homeDir, "dev", "faro");
|
||||
mkdirSync(realZdotdir, { recursive: true });
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
writeFileSync(join(realZdotdir, ".zshenv"), "");
|
||||
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: realZdotdir,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
await waitForTitle(session, (title) => title === "~/dev/faro");
|
||||
|
||||
session.send({ type: "input", data: "sleep 1\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "sleep 1");
|
||||
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
|
||||
});
|
||||
|
||||
it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n");
|
||||
|
||||
const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources");
|
||||
const inaccessiblePackagedIntegrationDir = join(
|
||||
fakeAppRoot,
|
||||
"app.asar",
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"server",
|
||||
"terminal",
|
||||
"shell-integration",
|
||||
"zsh",
|
||||
);
|
||||
const unpackedIntegrationDir = join(
|
||||
fakeAppRoot,
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"server",
|
||||
"terminal",
|
||||
"shell-integration",
|
||||
"zsh",
|
||||
);
|
||||
mkdirSync(unpackedIntegrationDir, { recursive: true });
|
||||
cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true });
|
||||
writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder");
|
||||
|
||||
const env = buildTerminalEnvironment({
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
},
|
||||
zshShellIntegrationDir: inaccessiblePackagedIntegrationDir,
|
||||
});
|
||||
|
||||
const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], {
|
||||
cwd: homeDir,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> ");
|
||||
});
|
||||
|
||||
it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||
const workingDir = join(homeDir, "dev", "faro");
|
||||
mkdirSync(realZdotdir, { recursive: true });
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
writeFileSync(join(realZdotdir, ".zshenv"), "");
|
||||
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: realZdotdir,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const commandCompletions: Array<number | null> = [];
|
||||
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||
commandCompletions.push(info.exitCode);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "false\r" });
|
||||
|
||||
await waitForState(session, () => commandCompletions.includes(1));
|
||||
|
||||
expect(commandCompletions).toEqual([1]);
|
||||
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
|
||||
|
||||
unsubscribeCommandFinished();
|
||||
});
|
||||
|
||||
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "Build Log");
|
||||
|
||||
vi.useFakeTimers();
|
||||
session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" });
|
||||
await waitForScheduledTimers(1);
|
||||
|
||||
session.setTitle("User terminal");
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("ignores later OSC title updates after setting a user title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "Build Log");
|
||||
|
||||
session.setTitle("User terminal");
|
||||
session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("trims user-set titles and treats empty titles as no-ops", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.setTitle(" ");
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
await waitForTitle(session, (title) => title === "Build Log");
|
||||
|
||||
session.setTitle(" User terminal ");
|
||||
|
||||
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||
expect(session.getTitle()).toBe("User terminal");
|
||||
expect(session.getState().title).toBe("User terminal");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[31m = ANSI red (color 1)
|
||||
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
|
||||
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||
|
||||
// The "$" after RED should have default color
|
||||
expect(outputRow[3].char).toBe("$");
|
||||
expect(outputRow[3].fg).toBe(undefined);
|
||||
expect(outputRow[3].fgMode).toBe(undefined);
|
||||
});
|
||||
|
||||
it("captures true color RGB (mode 3)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
|
||||
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check R cell
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
|
||||
|
||||
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
|
||||
const expectedPacked = (255 << 16) | (128 << 8) | 64;
|
||||
expect(outputRow[0].fg).toBe(expectedPacked);
|
||||
});
|
||||
|
||||
it("captures background colors", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[41m = ANSI red background
|
||||
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("B");
|
||||
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
|
||||
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize", () => {
|
||||
it("updates terminal dimensions on resize", async () => {
|
||||
const session = trackSession(
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface TerminalSession {
|
||||
getStateSnapshot(): TerminalStateSnapshot;
|
||||
getReplayPreamble(): string;
|
||||
getTitle(): string | undefined;
|
||||
setTitle(title: string): void;
|
||||
getExitInfo(): TerminalExitInfo | null;
|
||||
kill(): void;
|
||||
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
|
||||
@@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
let exitInfo: TerminalExitInfo | null = null;
|
||||
let recentOutputText = "";
|
||||
let title: string | undefined;
|
||||
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
|
||||
let pendingTitle: string | undefined;
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingInput = "";
|
||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||
let stateRevision = 0;
|
||||
const inputModeTracker = new TerminalInputModeTracker();
|
||||
let titleChangeSubscription: { dispose(): void } | null = null;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
}
|
||||
}
|
||||
|
||||
const lockedTitle = presetTitle?.trim() || undefined;
|
||||
function clearPendingTitleChange(): void {
|
||||
pendingTitle = undefined;
|
||||
if (titleDebounceTimer) {
|
||||
clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disposeTitleChangeSubscription(): void {
|
||||
titleChangeSubscription?.dispose();
|
||||
titleChangeSubscription = null;
|
||||
}
|
||||
|
||||
function setTitle(nextTitle: string): void {
|
||||
const manualTitle = nextTitle.trim();
|
||||
if (!manualTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
titleMode = "manual";
|
||||
disposeTitleChangeSubscription();
|
||||
clearPendingTitleChange();
|
||||
emitTitleChange(manualTitle);
|
||||
}
|
||||
|
||||
const initialManualTitle = presetTitle?.trim() || undefined;
|
||||
const processTitle = command ? [command, ...args].join(" ") : null;
|
||||
let initialTitle = lockedTitle;
|
||||
let initialTitle = initialManualTitle;
|
||||
if (!initialTitle && processTitle) {
|
||||
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
|
||||
}
|
||||
emitTitleChange(initialTitle);
|
||||
|
||||
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
|
||||
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
||||
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
||||
ptyProcess.write("\x1b[?62;4;22c");
|
||||
@@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return true;
|
||||
});
|
||||
|
||||
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
|
||||
if (!lockedTitle) {
|
||||
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
||||
if (titleMode === "auto") {
|
||||
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
@@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
titleDebounceTimer = setTimeout(() => {
|
||||
titleDebounceTimer = null;
|
||||
emitTitleChange(pendingTitle);
|
||||
pendingTitle = undefined;
|
||||
}, TERMINAL_TITLE_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
@@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
clearImmediate(inputFlushImmediate);
|
||||
inputFlushImmediate = null;
|
||||
}
|
||||
if (titleDebounceTimer) {
|
||||
clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = null;
|
||||
}
|
||||
disposeTitleChangeSubscription?.dispose();
|
||||
clearPendingTitleChange();
|
||||
disposeTitleChangeSubscription();
|
||||
disposeCommandLifecycleSubscription.dispose();
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
@@ -1049,6 +1075,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
getStateSnapshot,
|
||||
getReplayPreamble,
|
||||
getTitle,
|
||||
setTitle,
|
||||
getExitInfo,
|
||||
kill,
|
||||
killAndWait,
|
||||
|
||||
@@ -256,6 +256,16 @@ export function createWorkerTerminalManager(
|
||||
getTitle(): string | undefined {
|
||||
return record.info.title;
|
||||
},
|
||||
setTitle(nextTitle: string): void {
|
||||
const manualTitle = nextTitle.trim();
|
||||
if (!manualTitle) {
|
||||
return;
|
||||
}
|
||||
record.info = { ...record.info, title: manualTitle };
|
||||
for (const listener of Array.from(record.titleChangeListeners)) {
|
||||
listener(manualTitle);
|
||||
}
|
||||
},
|
||||
getExitInfo(): TerminalExitInfo | null {
|
||||
return record.exitInfo;
|
||||
},
|
||||
@@ -536,6 +546,15 @@ export function createWorkerTerminalManager(
|
||||
})) as TerminalWorkerStateResult;
|
||||
},
|
||||
|
||||
setTerminalTitle(id: string, title: string): boolean {
|
||||
const session = recordsById.get(id)?.session;
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
session.setTitle(title);
|
||||
return true;
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
||||
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
||||
|
||||
66
packages/server/src/utils/branch-slug.test.ts
Normal file
66
packages/server/src/utils/branch-slug.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { slugify, validateBranchSlug } from "./branch-slug.js";
|
||||
|
||||
describe("branch slug utilities", () => {
|
||||
it("normalizes display names to lowercase branch slugs", () => {
|
||||
expect(slugify("My Feature")).toBe("my-feature");
|
||||
});
|
||||
|
||||
it("collapses punctuation and whitespace to a single hyphen", () => {
|
||||
expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next");
|
||||
});
|
||||
|
||||
it("trims leading and trailing hyphens", () => {
|
||||
expect(slugify(" --- My Feature !!! ")).toBe("my-feature");
|
||||
});
|
||||
|
||||
it("enforces the 50 character slug limit", () => {
|
||||
const slug = slugify("a".repeat(60));
|
||||
|
||||
expect(slug).toBe("a".repeat(50));
|
||||
});
|
||||
|
||||
it("validates branch slugs with clear messages", () => {
|
||||
expect(validateBranchSlug("my-feature")).toEqual({ valid: true });
|
||||
expect(validateBranchSlug("")).toEqual({
|
||||
valid: false,
|
||||
error: "Branch name cannot be empty",
|
||||
});
|
||||
expect(validateBranchSlug("My Feature")).toEqual({
|
||||
valid: false,
|
||||
error:
|
||||
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||
});
|
||||
expect(validateBranchSlug("-my-feature")).toEqual({
|
||||
valid: false,
|
||||
error: "Branch name cannot start or end with a hyphen",
|
||||
});
|
||||
});
|
||||
|
||||
it("is exported through the package subpath", () => {
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"),
|
||||
) as {
|
||||
exports?: Record<string, { types?: string; source?: string; default?: string }>;
|
||||
};
|
||||
|
||||
expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({
|
||||
types: "./dist/server/utils/branch-slug.d.ts",
|
||||
source: "./src/utils/branch-slug.ts",
|
||||
default: "./dist/server/utils/branch-slug.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not import server-only modules", () => {
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8");
|
||||
|
||||
expect(source).not.toMatch(
|
||||
/from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
61
packages/server/src/utils/branch-slug.ts
Normal file
61
packages/server/src/utils/branch-slug.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Validate that a string is a valid git branch name slug.
|
||||
* Must be lowercase alphanumeric with hyphens and forward slashes only.
|
||||
*/
|
||||
export function validateBranchSlug(slug: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!slug || slug.length === 0) {
|
||||
return { valid: false, error: "Branch name cannot be empty" };
|
||||
}
|
||||
|
||||
if (slug.length > 100) {
|
||||
return { valid: false, error: "Branch name too long (max 100 characters)" };
|
||||
}
|
||||
|
||||
const validPattern = /^[a-z0-9-/]+$/;
|
||||
if (!validPattern.test(slug)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||
};
|
||||
}
|
||||
|
||||
if (slug.startsWith("-") || slug.endsWith("-")) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Branch name cannot start or end with a hyphen",
|
||||
};
|
||||
}
|
||||
|
||||
if (slug.includes("--")) {
|
||||
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export const MAX_SLUG_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Convert a string to kebab-case for branch names.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
if (slug.length <= MAX_SLUG_LENGTH) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
|
||||
const lastHyphen = truncated.lastIndexOf("-");
|
||||
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
|
||||
return truncated.slice(0, lastHyphen);
|
||||
}
|
||||
return truncated.replace(/-+$/, "");
|
||||
}
|
||||
@@ -16,8 +16,12 @@ vi.mock("child_process", async () => {
|
||||
const [command, commandArgs] = args;
|
||||
if (command === "git" && Array.isArray(commandArgs)) {
|
||||
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
||||
// `runGitCommand` always prepends `-c core.quotepath=false`; skip it to
|
||||
// find the actual git subcommand.
|
||||
const subcommandIndex =
|
||||
normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0;
|
||||
const isTrackedTextDiff =
|
||||
normalizedArgs[0] === "diff" &&
|
||||
normalizedArgs[subcommandIndex] === "diff" &&
|
||||
normalizedArgs.includes("HEAD") &&
|
||||
!normalizedArgs.includes("--numstat") &&
|
||||
!normalizedArgs.includes("--no-index") &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execFileSync } from "child_process";
|
||||
import { execFileSync, execSync } from "child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveBranchCheckout,
|
||||
resolveRepositoryDefaultBranch,
|
||||
parseWorktreeList,
|
||||
renameCurrentBranch,
|
||||
isPaseoWorktreePath,
|
||||
isDescendantPath,
|
||||
warmCheckoutShortstatInBackground,
|
||||
@@ -263,6 +264,48 @@ describe("checkout git utilities", () => {
|
||||
expect(branch).toBe("feature/rebase-test");
|
||||
});
|
||||
|
||||
it("renames the checked out branch and returns concrete branch names", async () => {
|
||||
execSync("git checkout -b feature/old-name", { cwd: repoDir });
|
||||
|
||||
const result = await renameCurrentBranch(repoDir, "feature/new-name");
|
||||
|
||||
const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim();
|
||||
expect(currentBranch).toBe("feature/new-name");
|
||||
expect(result).toEqual({
|
||||
previousBranch: "feature/old-name",
|
||||
currentBranch: "feature/new-name",
|
||||
});
|
||||
expect(() =>
|
||||
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }),
|
||||
).toThrow();
|
||||
expect(
|
||||
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
|
||||
.toString()
|
||||
.trim(),
|
||||
).toContain("refs/heads/feature/new-name");
|
||||
});
|
||||
|
||||
it("fails when renaming the checked out branch to an existing branch", async () => {
|
||||
execSync("git branch feature/new-name", { cwd: repoDir });
|
||||
execSync("git checkout -b feature/old-name", { cwd: repoDir });
|
||||
|
||||
await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow();
|
||||
|
||||
expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe(
|
||||
"feature/old-name",
|
||||
);
|
||||
expect(
|
||||
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir })
|
||||
.toString()
|
||||
.trim(),
|
||||
).toContain("refs/heads/feature/old-name");
|
||||
expect(
|
||||
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
|
||||
.toString()
|
||||
.trim(),
|
||||
).toContain("refs/heads/feature/new-name");
|
||||
});
|
||||
|
||||
it("handles status/diff/commit in a normal repo", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix";
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
|
||||
const DEFAULT_MAX_DEPTH = 12;
|
||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
|
||||
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
||||
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ export function runGitCommand(
|
||||
logger.trace(traceContext, "Spawning git command");
|
||||
}
|
||||
|
||||
const child = spawnProcess("git", args, {
|
||||
// `core.quotepath=false` makes git emit raw UTF-8 paths instead of
|
||||
// octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`).
|
||||
const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], {
|
||||
cwd: options.cwd,
|
||||
envOverlay,
|
||||
shell: false,
|
||||
|
||||
@@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "./branch-slug.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "./branch-slug.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
@@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise<string> {
|
||||
return commonDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is a valid git branch name slug
|
||||
* Must be lowercase, alphanumeric, hyphens only
|
||||
*/
|
||||
export function validateBranchSlug(slug: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!slug || slug.length === 0) {
|
||||
return { valid: false, error: "Branch name cannot be empty" };
|
||||
}
|
||||
|
||||
if (slug.length > 100) {
|
||||
return { valid: false, error: "Branch name too long (max 100 characters)" };
|
||||
}
|
||||
|
||||
// Check for valid characters: lowercase letters, numbers, hyphens, forward slashes
|
||||
const validPattern = /^[a-z0-9-/]+$/;
|
||||
if (!validPattern.test(slug)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||
};
|
||||
}
|
||||
|
||||
// Cannot start or end with hyphen
|
||||
if (slug.startsWith("-") || slug.endsWith("-")) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Branch name cannot start or end with a hyphen",
|
||||
};
|
||||
}
|
||||
|
||||
// Cannot have consecutive hyphens
|
||||
if (slug.includes("--")) {
|
||||
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const MAX_SLUG_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Convert string to kebab-case for branch names
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
if (slug.length <= MAX_SLUG_LENGTH) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
// Truncate at word boundary (hyphen) if possible
|
||||
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
|
||||
const lastHyphen = truncated.lastIndexOf("-");
|
||||
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
|
||||
return truncated.slice(0, lastHyphen);
|
||||
}
|
||||
return truncated.replace(/-+$/, "");
|
||||
}
|
||||
|
||||
const WORKTREE_PROJECT_HASH_LENGTH = 8;
|
||||
|
||||
function deriveShortAlphanumericHash(value: string): string {
|
||||
|
||||
@@ -15,20 +15,19 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
|
||||
|
||||
### Agents
|
||||
|
||||
| Tool | Function |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. |
|
||||
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
||||
| `send_agent_prompt` | Send a task to a running agent. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name or labels. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. |
|
||||
| Tool | Function |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. |
|
||||
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
||||
| `send_agent_prompt` | Send a task to a running agent. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
|
||||
### Terminals
|
||||
|
||||
@@ -53,11 +52,11 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
|
||||
|
||||
### Providers
|
||||
|
||||
| Tool | Function |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `list_providers` | List configured agent providers, availability, and modes. |
|
||||
| `list_models` | List models for an agent provider. |
|
||||
| `list_provider_features` | List provider-specific features for a draft agent configuration, such as Codex `fast_mode`. |
|
||||
| Tool | Function |
|
||||
| ------------------ | ----------------------------------------------------------------- |
|
||||
| `list_providers` | List configured agent providers, availability, and modes. |
|
||||
| `list_models` | List models for an agent provider. |
|
||||
| `inspect_provider` | Inspect compact provider capabilities and draft feature settings. |
|
||||
|
||||
### Worktrees
|
||||
|
||||
|
||||
@@ -20,25 +20,29 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
|
||||
|
||||
## Agents
|
||||
|
||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`.
|
||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `settings`. Returns `{ agentId, … }`.
|
||||
|
||||
Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent.
|
||||
Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent.
|
||||
|
||||
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
|
||||
|
||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget.
|
||||
|
||||
**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex.
|
||||
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
|
||||
|
||||
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
|
||||
|
||||
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
|
||||
|
||||
## Provider features
|
||||
## Provider discovery
|
||||
|
||||
**`list_provider_features`** — query provider-specific features before setting them. Required: `provider`, `cwd`. Optional: `model`, `modeId`, `thinkingOptionId`, `featureValues`.
|
||||
**`list_providers`** — compact provider availability and modes.
|
||||
|
||||
Only set feature IDs returned by `list_provider_features`. For Codex fast mode, look for `fast_mode` and pass `features: { "fast_mode": true }` to `create_agent`.
|
||||
**`list_models`** — full model list for one provider. Use only when you need model IDs or thinking options; the list can be large.
|
||||
|
||||
**`inspect_provider`** — compact provider capability and feature inspection. Required: `provider`; pass `cwd` when you are not in an agent-scoped session. Optional: `settings` with draft `model`, `modeId`, `thinkingOptionId`, and `features`.
|
||||
|
||||
Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`.
|
||||
|
||||
## Heartbeats
|
||||
|
||||
|
||||
Reference in New Issue
Block a user