feat: terminal reattach, explorer tab memory, worktree terminals

This commit is contained in:
Mohamed Boudra
2026-02-15 14:05:29 +07:00
parent 1f72ce63cd
commit 9244f1fbd6
31 changed files with 2009 additions and 385 deletions
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
} from "./host-routes";
describe("parseHostAgentDraftRouteFromPathname", () => {
it("parses draft route server id", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({
serverId: "local",
});
});
it("parses encoded server id", () => {
expect(
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent")
).toEqual({
serverId: "team host",
});
});
it("does not match agent detail routes", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull();
});
});
describe("parseHostAgentRouteFromPathname", () => {
it("continues parsing detail routes", () => {
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
serverId: "local",
agentId: "abc123",
});
});
});
+29 -16
View File
@@ -12,6 +12,14 @@ function encodeSegment(value: string): string {
return encodeURIComponent(value);
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
if (!raw) {
return null;
}
try {
return trimNonEmpty(decodeURIComponent(raw));
} catch {
return trimNonEmpty(raw);
}
return trimNonEmpty(decodeSegment(raw));
}
export function parseHostAgentRouteFromPathname(
@@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname(
return null;
}
const decode = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const serverId = trimNonEmpty(decode(encodedServerId));
const agentId = trimNonEmpty(decode(encodedAgentId));
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
if (!serverId || !agentId) {
return null;
}
@@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname(
return { serverId, agentId };
}
export function parseHostAgentDraftRouteFromPathname(
pathname: string
): { serverId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/);
if (!match) {
return null;
}
const encodedServerId = match[1];
if (!encodedServerId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
if (!serverId) {
return null;
}
return { serverId };
}
export function buildHostAgentDraftRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
@@ -115,4 +129,3 @@ export function mapPathnameToServer(
}
return `${base}/agent`;
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
getTerminalResumeOffset,
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
updateTerminalResumeOffset,
withPromiseTimeout,
} from "./terminal-attach";
describe("terminal-attach", () => {
it("computes bounded exponential retry delays", () => {
expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250);
expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500);
expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000);
});
it("matches retryable attach errors", () => {
expect(
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "stream ended before ack" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "permission denied" })
).toBe(false);
});
it("reads and updates resume offsets monotonically", () => {
const offsets = new Map<string, number>();
const terminalId = "term-1";
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBeUndefined();
updateTerminalResumeOffset({
terminalId,
offset: 8,
resumeOffsetByTerminalId: offsets,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBe(8);
// Stale offsets must not move resume backwards.
updateTerminalResumeOffset({
terminalId,
offset: 3,
resumeOffsetByTerminalId: offsets,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBe(8);
});
it("resolves before timeout when promise completes", async () => {
await expect(
withPromiseTimeout({
promise: Promise.resolve("ok"),
timeoutMs: 50,
timeoutMessage: "timed out",
})
).resolves.toBe("ok");
});
it("rejects when timeout wins", async () => {
await expect(
withPromiseTimeout({
promise: new Promise<string>(() => {}),
timeoutMs: 10,
timeoutMessage: "timed out",
})
).rejects.toThrow("timed out");
});
});
+83
View File
@@ -0,0 +1,83 @@
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
"terminal not found",
"timed out",
"timeout",
"connection",
"network",
"disconnected",
"stream ended",
] as const;
export function getTerminalResumeOffset(input: {
terminalId: string;
resumeOffsetByTerminalId: Map<string, number>;
}): number | undefined {
const offset = input.resumeOffsetByTerminalId.get(input.terminalId);
if (typeof offset !== "number" || !Number.isFinite(offset)) {
return undefined;
}
const normalizedOffset = Math.max(0, Math.floor(offset));
return normalizedOffset;
}
export function updateTerminalResumeOffset(input: {
terminalId: string;
offset: number;
resumeOffsetByTerminalId: Map<string, number>;
}): void {
if (!Number.isFinite(input.offset)) {
return;
}
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousOffset =
getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetByTerminalId: input.resumeOffsetByTerminalId,
}) ?? -1;
if (normalizedOffset <= previousOffset) {
return;
}
input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset);
}
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
const clampedAttempt = Math.max(0, input.attempt);
const exponentialDelay = 250 * (2 ** clampedAttempt);
return Math.min(2_000, exponentialDelay);
}
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
const normalized = input.message.toLowerCase();
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
normalized.includes(pattern)
);
}
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, Math.max(0, input.durationMs));
});
}
export async function withPromiseTimeout<T>(input: {
promise: Promise<T>;
timeoutMs: number;
timeoutMessage: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(input.timeoutMessage));
}, Math.max(0, input.timeoutMs));
});
try {
return await Promise.race([input.promise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
@@ -6,6 +6,7 @@ import {
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
shouldInterceptDomTerminalKey,
} from "./terminal-keys";
@@ -96,4 +97,41 @@ describe("terminal key helpers", () => {
expect(mapTerminalDataToKey("\x03")).toBeNull();
expect(mapTerminalDataToKey("")).toBeNull();
});
it("clears pending modifiers when fallback input cannot map to a key", () => {
expect(
resolvePendingModifierDataInput({
data: "hello",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
).toEqual({
mode: "raw",
clearPendingModifiers: true,
});
});
it("maps pending modifier fallback to key transport when possible", () => {
expect(
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
).toEqual({
mode: "key",
key: "c",
clearPendingModifiers: true,
});
});
it("keeps raw mode unchanged when no pending modifiers exist", () => {
expect(
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
).toEqual({
mode: "raw",
clearPendingModifiers: false,
});
});
});
+35
View File
@@ -157,3 +157,38 @@ export function mapTerminalDataToKey(data: string): string | null {
return null;
}
export function resolvePendingModifierDataInput(args: {
data: string;
pendingModifiers: PendingTerminalModifiers;
}):
| {
mode: "key";
key: string;
clearPendingModifiers: true;
}
| {
mode: "raw";
clearPendingModifiers: boolean;
} {
if (!hasPendingTerminalModifiers(args.pendingModifiers)) {
return {
mode: "raw",
clearPendingModifiers: false,
};
}
const mappedKey = mapTerminalDataToKey(args.data);
if (!mappedKey) {
return {
mode: "raw",
clearPendingModifiers: true,
};
}
return {
mode: "key",
key: mappedKey,
clearPendingModifiers: true,
};
}