* Improve mobile terminal typing and selection * Address mobile terminal review feedback * Remove dead native terminal input branch * Replay bracketed paste in terminal preamble * Stop per-key terminal resize claims * Stabilize terminal resize browser test * Use tracked terminal mode for native paste * Restore terminal toolbar focus handling * Adapt native terminal to active visibility state * Stabilize native mobile terminal interactions * Add native terminal fallback boundary * test(app): expect explicit terminal resize reclaim * fix(app): stabilize terminal buffer coordinates * fix(app): restrict terminal resize ownership * fix(terminal): gate restored input modes * fix(app): recover stalled terminal WebViews * chore(app): remove obsolete terminal harnesses * fix(app): make terminal resize claims explicit * test(app): stabilize native terminal device flows * test(app): track absolute scrollback coordinates * fix(app): isolate terminal WebView streams * fix(terminal): arbitrate size ownership across clients The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it. * test(server): make worker resize assertion portable Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
270 lines
9.1 KiB
TypeScript
270 lines
9.1 KiB
TypeScript
import path from "node:path";
|
|
import { readFileSync } from "node:fs";
|
|
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
|
import { connectDaemonClient } from "./daemon-client-loader";
|
|
import { withProjectOwnership } from "./project-ownership";
|
|
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
|
|
|
export interface SeedWorkspaceDescriptor {
|
|
id: string;
|
|
name: string;
|
|
projectId: string;
|
|
projectDisplayName: string;
|
|
projectRootPath: string;
|
|
workspaceDirectory: string;
|
|
}
|
|
|
|
interface SeedProjectDescriptor {
|
|
projectId: string;
|
|
projectKey?: string;
|
|
}
|
|
|
|
/**
|
|
* The general-purpose E2E daemon client used to seed and drive state out of
|
|
* band (workspaces, agents, terminals) while the UI is exercised through the
|
|
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
|
* prefer those wrappers over reaching for this client directly.
|
|
*/
|
|
export interface SeedDaemonClient {
|
|
connect(): Promise<void>;
|
|
close(): Promise<void>;
|
|
addProject(cwd: string): Promise<{
|
|
project: {
|
|
projectId: string;
|
|
projectKey?: string;
|
|
projectDisplayName: string;
|
|
projectRootPath: string;
|
|
} | null;
|
|
error: string | null;
|
|
}>;
|
|
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
|
renameProject(projectId: string, customName: string | null): Promise<void>;
|
|
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
|
entries: SeedWorkspaceDescriptor[];
|
|
}>;
|
|
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
|
createWorkspace(input: {
|
|
source:
|
|
| { kind: "directory"; path: string; projectId?: string }
|
|
| {
|
|
kind: "worktree";
|
|
cwd?: string;
|
|
projectId?: string;
|
|
action?: "branch-off" | "checkout";
|
|
refName?: string;
|
|
baseBranch?: string;
|
|
githubPrNumber?: number;
|
|
worktreeSlug?: string;
|
|
};
|
|
title?: string;
|
|
}): Promise<{
|
|
workspace: SeedWorkspaceDescriptor | null;
|
|
error: string | null;
|
|
}>;
|
|
/**
|
|
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
|
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
|
* working-tree write authoritative before asserting on it in the UI, instead
|
|
* of racing the filesystem watcher's debounce.
|
|
*/
|
|
checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>;
|
|
createTerminal(
|
|
cwd: string,
|
|
name?: string,
|
|
requestId?: string,
|
|
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
|
): Promise<{
|
|
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
|
error: string | null;
|
|
}>;
|
|
listTerminals(
|
|
cwd?: string,
|
|
requestId?: string,
|
|
options?: { workspaceId?: string },
|
|
): Promise<{
|
|
terminals: Array<{
|
|
id: string;
|
|
name: string;
|
|
cwd: string;
|
|
title?: string;
|
|
activity?: TerminalActivity | null;
|
|
}>;
|
|
error?: string | null;
|
|
}>;
|
|
createAgent(options: {
|
|
provider: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
title?: string;
|
|
modeId?: string;
|
|
model?: string;
|
|
thinkingOptionId?: string;
|
|
featureValues?: Record<string, unknown>;
|
|
initialPrompt?: string;
|
|
labels?: Record<string, string>;
|
|
}): Promise<{ id: string; status: string }>;
|
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
|
entries: Array<{
|
|
agent: {
|
|
id: string;
|
|
provider: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
model: string | null;
|
|
currentModeId: string | null;
|
|
status: string;
|
|
title?: string | null;
|
|
};
|
|
}>;
|
|
}>;
|
|
fetchRecentProviderSessions(options: {
|
|
cwd: string;
|
|
providers: string[];
|
|
limit: number;
|
|
}): Promise<{
|
|
entries: Array<{
|
|
providerId: string;
|
|
providerHandleId: string;
|
|
cwd: string;
|
|
firstPromptPreview?: string | null;
|
|
}>;
|
|
}>;
|
|
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
|
setAgentMode(agentId: string, modeId: string): Promise<unknown>;
|
|
waitForAgentUpsert(
|
|
agentId: string,
|
|
predicate: (snapshot: { status: string }) => boolean,
|
|
timeout?: number,
|
|
): Promise<{ status: string }>;
|
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
|
waitForFinish(
|
|
agentId: string,
|
|
timeout?: number,
|
|
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
|
refreshAgent(agentId: string): Promise<unknown>;
|
|
fetchAgent(options: {
|
|
agentId: string;
|
|
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
|
getLastServerInfoMessage(): {
|
|
features?: {
|
|
projectAdd?: boolean;
|
|
workspaceRecovery?: boolean;
|
|
} | null;
|
|
} | null;
|
|
fetchAgentHistory(options?: {
|
|
page?: { limit: number };
|
|
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
|
subscribeTerminal(
|
|
terminalId: string,
|
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
|
sendTerminalInput(
|
|
terminalId: string,
|
|
message:
|
|
| { type: "input"; data: string }
|
|
| { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" },
|
|
): void;
|
|
captureTerminal(
|
|
terminalId: string,
|
|
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
|
): Promise<{ terminalId: string; lines: string[]; totalLines: number; requestId: string }>;
|
|
onTerminalStreamEvent(
|
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
|
): () => void;
|
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
|
}
|
|
|
|
export async function connectSeedClient(options?: {
|
|
port?: number;
|
|
/** Use only with a private host whose teardown removes its entire PASEO_HOME. */
|
|
projectOwnership?: "client" | "host";
|
|
}): Promise<SeedDaemonClient> {
|
|
const client = await connectDaemonClient<SeedDaemonClient>({
|
|
clientIdPrefix: "seed",
|
|
appVersion: loadAppVersion(),
|
|
port: options?.port,
|
|
});
|
|
return options?.projectOwnership === "host" ? client : withProjectOwnership(client);
|
|
}
|
|
|
|
/**
|
|
* A temp project opened as a workspace, with a seed client connected to drive
|
|
* it out of band. `cleanup` closes the client and removes the project. This is
|
|
* the canonical bootstrap for specs that need a real workspace plus daemon
|
|
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
|
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
|
*/
|
|
export interface SeededWorkspace {
|
|
client: SeedDaemonClient;
|
|
repoPath: string;
|
|
workspaceId: string;
|
|
workspaceName: string;
|
|
workspaceDirectory: string;
|
|
/** Host-local identity used by daemon project operations. */
|
|
projectId: string;
|
|
/** Opaque cross-host key used by grouped project UI. */
|
|
projectKey: string;
|
|
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
|
projectDisplayName: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
export async function seedWorkspace(options: {
|
|
repoPrefix: string;
|
|
title?: string;
|
|
port?: number;
|
|
/** Repo fixture options; only applies to git projects (the default). */
|
|
repo?: Parameters<typeof createTempGitRepo>[1];
|
|
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
|
git?: boolean;
|
|
}): Promise<SeededWorkspace> {
|
|
const project =
|
|
options.git === false
|
|
? await createTempDirectory(options.repoPrefix)
|
|
: await createTempGitRepo(options.repoPrefix, options.repo);
|
|
const client = await connectSeedClient({ port: options.port });
|
|
try {
|
|
const created = await client.createWorkspace({
|
|
source: { kind: "directory", path: project.path },
|
|
title: options.title,
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
|
}
|
|
const workspace = created.workspace;
|
|
const listed = await client.listProjects();
|
|
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
|
if (!descriptor?.projectKey) {
|
|
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
|
}
|
|
return {
|
|
client,
|
|
repoPath: project.path,
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
projectId: workspace.projectId,
|
|
projectKey: descriptor.projectKey,
|
|
projectDisplayName: workspace.projectDisplayName,
|
|
cleanup: async () => {
|
|
await client.removeProject(workspace.projectId).catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await project.cleanup().catch(() => undefined);
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await client.close().catch(() => undefined);
|
|
await project.cleanup().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function loadAppVersion(): string {
|
|
const packageJsonPath = path.resolve(__dirname, "../../../package.json");
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
throw new Error(`Missing app version in ${packageJsonPath}`);
|
|
}
|
|
return packageJson.version;
|
|
}
|