diff --git a/src/daemon.ts b/src/daemon.ts index e074afc7..612917cf 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -8,6 +8,7 @@ import { loadMetadataEndpointByProjectId, removeMetadataEndpoint } from "./metad import { requestJson } from "./http-client.js"; import { log } from "./debug.js"; import { listAllProjectsExposeItems } from "./expose-control.js"; +import { getExposePreviewSnapshot, trackExposePreviewItems } from "./expose-preview-cache.js"; import { RelayClient, type RelayNotificationPush, type RelayStatusSnapshot } from "./relay-client.js"; import { MobilePushThrottle } from "./mobile-push-throttle.js"; import { clearCredentials, loadCredentials, setRemoteEnabled } from "./credentials.js"; @@ -663,13 +664,31 @@ export class AimuxDaemon { return { status, body: `${message}\n`, contentType: "text/plain; charset=utf-8" }; } - private exposeItemsRoute(): DaemonRouteResponse { - const items = listAllProjectsExposeItems().map((item) => ({ - ...serializeFastControlItem(item), - projectId: item.projectId, - projectName: item.projectName, - projectRoot: item.projectRoot, - })); + private exposeItemsRoute(routeUrl: URL): DaemonRouteResponse { + const includePreview = routeUrl.searchParams.get("includePreview") === "1"; + const rawItems = listAllProjectsExposeItems(); + if (includePreview) { + const itemsByProjectRoot = new Map(); + for (const item of rawItems) { + const projectItems = itemsByProjectRoot.get(item.projectRoot) ?? []; + projectItems.push(item); + itemsByProjectRoot.set(item.projectRoot, projectItems); + } + for (const [projectRoot, projectItems] of itemsByProjectRoot) { + trackExposePreviewItems(projectRoot, projectItems); + } + } + const items = rawItems.map((item) => { + const previewSnapshot = includePreview + ? getExposePreviewSnapshot(item.projectRoot, item.target.windowId) + : undefined; + return { + ...serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item), + projectId: item.projectId, + projectName: item.projectName, + projectRoot: item.projectRoot, + }; + }); return { status: 200, body: { ok: true, items } }; } @@ -3352,7 +3371,7 @@ export class AimuxDaemon { if (method === "GET" && pathname === CORE_API_ROUTES.exposeItems) { if (actor) return { status: 403, body: { ok: false, error: "expose routes are loopback-only" } }; - return this.exposeItemsRoute(); + return this.exposeItemsRoute(routeUrl); } if (method === "POST" && pathname === CORE_API_ROUTES.exposeFocus) { diff --git a/src/expose-preview-cache.test.ts b/src/expose-preview-cache.test.ts new file mode 100644 index 00000000..3063876b --- /dev/null +++ b/src/expose-preview-cache.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ExposePreviewCache, + EXPOSE_PREVIEW_CAPTURE_LINES, + getExposePreviewSnapshot, + trackExposePreviewItems, +} from "./expose-preview-cache.js"; +import type { FastControlItem } from "./fast-control.js"; + +function item( + id: string, + windowId: string, + target: Partial = {}, +): Pick { + return { + id, + target: { sessionName: "aimux-test", windowId, windowIndex: 1, windowName: "codex", ...target }, + }; +} + +describe("ExposePreviewCache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("captures tracked targets as preview snapshots", async () => { + const tmux = { + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + } finally { + cache.stop(); + } + + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + expect(cache.get("@1")).toEqual({ + output: "output for @1\n", + capturedAt: "2026-07-20T13:00:00.000Z", + source: "capture", + windowId: "@1", + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + lineCount: EXPOSE_PREVIEW_CAPTURE_LINES, + }); + expect(tmux.captureTargetAsync).not.toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + }); + + it("keeps the last good snapshot when capture fails", async () => { + const tmux = { + captureTargetAsync: vi.fn(async () => "first output\n"), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + tmux.captureTargetAsync.mockImplementation(async () => { + throw new Error("tmux unavailable"); + }); + await cache.refreshNow(); + } finally { + cache.stop(); + } + + expect(cache.get("@1")?.output).toBe("first output\n"); + }); + + it("does not capture until demanded and stops scheduling after the active window", async () => { + vi.useFakeTimers(); + const tmux = { + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + intervalMs: 1000, + activeMs: 1500, + now: () => new Date(Date.now()), + }); + + cache.start(); + try { + vi.advanceTimersByTime(5000); + expect(tmux.captureTargetAsync).not.toHaveBeenCalled(); + + cache.trackItems([item("a", "@1")]); + await vi.runOnlyPendingTimersAsync(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1000); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(2); + } finally { + cache.stop(); + } + }); + + it("captures the active demand union until old demand expires", async () => { + let nowMs = Date.parse("2026-07-20T13:00:00.000Z"); + const tmux = { + captureTargetAsync: vi.fn(async (target) => `output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + activeMs: 1000, + now: () => new Date(nowMs), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + expect(cache.get("@1")?.output).toBe("output for @1\n"); + tmux.captureTargetAsync.mockClear(); + + nowMs += 500; + cache.trackItems([item("b", "@2")]); + await cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@1" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + tmux.captureTargetAsync.mockClear(); + + nowMs += 600; + expect(cache.get("@1")).toBeUndefined(); + await cache.refreshNow(); + } finally { + cache.stop(); + } + + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + expect(tmux.captureTargetAsync).toHaveBeenCalledWith(expect.objectContaining({ windowId: "@2" }), { + startLine: -EXPOSE_PREVIEW_CAPTURE_LINES, + includeEscapes: true, + }); + }); + + it("accepts in-flight captures after identical re-demand", async () => { + let resolveCapture: ((output: string) => void) | undefined; + const tmux = { + captureTargetAsync: vi.fn( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }), + ), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + const refresh = cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + cache.trackItems([item("a-fresh", "@1")]); + resolveCapture?.("late output\n"); + await refresh; + + expect(cache.get("@1")?.output).toBe("late output\n"); + } finally { + cache.stop(); + } + }); + + it("ignores in-flight captures after demand expires and is renewed", async () => { + let nowMs = Date.parse("2026-07-20T13:00:00.000Z"); + let resolveCapture: ((output: string) => void) | undefined; + const tmux = { + captureTargetAsync: vi.fn( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }), + ), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + activeMs: 1000, + now: () => new Date(nowMs), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + const refresh = cache.refreshNow(); + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(1); + + nowMs += 1001; + cache.trackItems([item("a-renewed", "@1")]); + resolveCapture?.("late output\n"); + await refresh; + + expect(cache.get("@1")).toBeUndefined(); + } finally { + cache.stop(); + } + }); + + it("evicts tracked targets after repeated capture failures", async () => { + const tmux = { + captureTargetAsync: vi.fn(async () => { + throw new Error("tmux unavailable"); + }), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + await cache.refreshNow(); + await cache.refreshNow(); + await cache.refreshNow(); + + expect(tmux.captureTargetAsync).toHaveBeenCalledTimes(3); + expect(cache.get("@1")).toBeUndefined(); + } finally { + cache.stop(); + } + }); + + it("registers running caches for daemon global expose responses", async () => { + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux: { captureTargetAsync: async () => "registered output\n" }, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + cache.trackItems([item("a", "@1")]); + await cache.refreshNow(); + expect(getExposePreviewSnapshot("/repo", "@1")?.output).toBe("registered output\n"); + expect(getExposePreviewSnapshot("/repo/../repo", "@1")?.output).toBe("registered output\n"); + } finally { + cache.stop(); + } + expect(getExposePreviewSnapshot("/repo", "@1")).toBeUndefined(); + }); + + it("tracks demand through the project registry", async () => { + const tmux = { + captureTargetAsync: vi.fn(async (target) => `registry output for ${target.windowId}\n`), + }; + const cache = new ExposePreviewCache({ + projectRoot: "/repo", + tmux, + now: () => new Date("2026-07-20T13:00:00.000Z"), + }); + + cache.start(); + try { + trackExposePreviewItems("/repo/../repo", [item("a", "@1")]); + await cache.refreshNow(); + expect(cache.get("@1")?.output).toBe("registry output for @1\n"); + } finally { + cache.stop(); + } + }); +}); diff --git a/src/expose-preview-cache.ts b/src/expose-preview-cache.ts new file mode 100644 index 00000000..bfbdbd05 --- /dev/null +++ b/src/expose-preview-cache.ts @@ -0,0 +1,225 @@ +import { resolve as pathResolve } from "node:path"; +import type { FastControlItem } from "./fast-control.js"; +import type { ExposePreviewSnapshot } from "./project-api-contract.js"; +import { TmuxRuntimeManager } from "./tmux/runtime-manager.js"; +import type { TmuxTarget } from "./tmux/runtime-manager.js"; + +export const EXPOSE_PREVIEW_CAPTURE_LINES = 40; +const EXPOSE_PREVIEW_REFRESH_MS = 1000; +const EXPOSE_PREVIEW_ACTIVE_MS = 10_000; +const EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES = 3; + +type ExposePreviewTarget = Pick; +type TrackedExposePreviewTarget = ExposePreviewTarget & { expiresAt: number; generation: number }; + +export interface ExposePreviewCacheLike { + start(): void; + stop(): void; + trackItems(items: ExposePreviewTarget[]): void; + get(windowId: string): ExposePreviewSnapshot | undefined; +} + +export interface ExposePreviewCacheOptions { + projectRoot: string; + tmux?: Pick; + intervalMs?: number; + activeMs?: number; + lineCount?: number; + now?: () => Date; +} + +const cachesByProjectRoot = new Map(); + +function normalizedProjectRoot(projectRoot: string): string { + return pathResolve(projectRoot); +} + +export function getExposePreviewSnapshot(projectRoot: string, windowId: string): ExposePreviewSnapshot | undefined { + return cachesByProjectRoot.get(normalizedProjectRoot(projectRoot))?.get(windowId); +} + +export function trackExposePreviewItems(projectRoot: string, items: ExposePreviewTarget[]): void { + cachesByProjectRoot.get(normalizedProjectRoot(projectRoot))?.trackItems(items); +} + +function registerExposePreviewCache(projectRoot: string, cache: ExposePreviewCacheLike): void { + cachesByProjectRoot.set(normalizedProjectRoot(projectRoot), cache); +} + +function unregisterExposePreviewCache(projectRoot: string, cache: ExposePreviewCacheLike): void { + const normalized = normalizedProjectRoot(projectRoot); + if (cachesByProjectRoot.get(normalized) === cache) cachesByProjectRoot.delete(normalized); +} + +export class ExposePreviewCache implements ExposePreviewCacheLike { + private readonly tmux: Pick; + private readonly intervalMs: number; + private readonly activeMs: number; + private readonly lineCount: number; + private readonly now: () => Date; + private readonly snapshots = new Map(); + private readonly trackedTargets = new Map(); + private readonly failureCounts = new Map(); + private timer: ReturnType | null = null; + private running = false; + private refreshing = false; + private refreshPending = false; + private generation = 0; + + constructor(private readonly options: ExposePreviewCacheOptions) { + this.tmux = options.tmux ?? new TmuxRuntimeManager(); + this.intervalMs = options.intervalMs ?? EXPOSE_PREVIEW_REFRESH_MS; + this.activeMs = options.activeMs ?? EXPOSE_PREVIEW_ACTIVE_MS; + this.lineCount = options.lineCount ?? EXPOSE_PREVIEW_CAPTURE_LINES; + this.now = options.now ?? (() => new Date()); + } + + start(): void { + if (this.running) return; + this.running = true; + registerExposePreviewCache(this.options.projectRoot, this); + } + + stop(): void { + this.running = false; + unregisterExposePreviewCache(this.options.projectRoot, this); + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + + trackItems(items: ExposePreviewTarget[]): void { + const now = this.now().getTime(); + this.pruneExpired(now); + const expiresAt = now + this.activeMs; + let changed = false; + const nextGeneration = this.generation + 1; + for (const item of items) { + const current = this.trackedTargets.get(item.target.windowId); + if (current && sameTarget(current.target, item.target)) { + current.id = item.id; + current.expiresAt = expiresAt; + continue; + } + if (current) { + this.snapshots.delete(item.target.windowId); + this.failureCounts.delete(item.target.windowId); + } + this.trackedTargets.set(item.target.windowId, { ...item, expiresAt, generation: nextGeneration }); + changed = true; + } + if (changed) this.generation += 1; + if (items.length > 0) this.schedule(0); + } + + get(windowId: string): ExposePreviewSnapshot | undefined { + this.pruneExpired(this.now().getTime()); + return this.snapshots.get(windowId); + } + + async refreshNow(): Promise { + await this.refresh(); + } + + private schedule(delayMs = this.intervalMs): void { + if (!this.running) return; + if (this.refreshing) { + if (delayMs === 0) this.refreshPending = true; + return; + } + if (this.timer) { + if (delayMs !== 0) return; + clearTimeout(this.timer); + this.timer = null; + } + this.timer = setTimeout(() => { + this.timer = null; + void this.refresh(); + }, delayMs); + this.timer.unref?.(); + } + + private async refresh(): Promise { + const now = this.now().getTime(); + if (!this.running) return; + if (this.refreshing) { + this.refreshPending = true; + return; + } + this.pruneExpired(now); + if (this.trackedTargets.size === 0) return; + this.refreshing = true; + const refreshGeneration = this.generation; + try { + const targets = [...this.trackedTargets.values()]; + for (const item of targets) { + if (this.generation !== refreshGeneration) break; + if (this.now().getTime() >= item.expiresAt || this.trackedTargets.get(item.target.windowId) !== item) continue; + await this.capture(item); + } + } finally { + this.refreshing = false; + this.pruneExpired(this.now().getTime()); + if (this.refreshPending) { + this.refreshPending = false; + this.schedule(0); + } else if (this.trackedTargets.size > 0) { + this.schedule(); + } + } + } + + private async capture(item: TrackedExposePreviewTarget): Promise { + const { target } = item; + try { + const output = await this.tmux.captureTargetAsync(target, { + startLine: -this.lineCount, + includeEscapes: true, + }); + if (!this.isCurrentTarget(item)) return; + this.failureCounts.delete(target.windowId); + this.snapshots.set(target.windowId, { + output, + capturedAt: this.now().toISOString(), + source: "capture", + windowId: target.windowId, + startLine: -this.lineCount, + lineCount: this.lineCount, + }); + } catch { + if (!this.isCurrentTarget(item)) return; + const failures = (this.failureCounts.get(target.windowId) ?? 0) + 1; + this.failureCounts.set(target.windowId, failures); + if (failures >= EXPOSE_PREVIEW_MAX_CAPTURE_FAILURES) { + this.trackedTargets.delete(target.windowId); + this.snapshots.delete(target.windowId); + this.failureCounts.delete(target.windowId); + } + } + } + + private isCurrentTarget(item: TrackedExposePreviewTarget): boolean { + const current = this.trackedTargets.get(item.target.windowId); + return Boolean( + current && + current.generation === item.generation && + this.now().getTime() < current.expiresAt && + sameTarget(current.target, item.target), + ); + } + + private pruneExpired(now: number): void { + let changed = false; + for (const [windowId, item] of this.trackedTargets) { + if (now < item.expiresAt) continue; + this.trackedTargets.delete(windowId); + this.snapshots.delete(windowId); + this.failureCounts.delete(windowId); + changed = true; + } + if (changed) this.generation += 1; + } +} + +function sameTarget(left: TmuxTarget, right: TmuxTarget): boolean { + return left.windowId === right.windowId; +} diff --git a/src/metadata-server.test.ts b/src/metadata-server.test.ts index ded622b8..67124bc5 100644 --- a/src/metadata-server.test.ts +++ b/src/metadata-server.test.ts @@ -2090,6 +2090,76 @@ describe("MetadataServer threads API", () => { } }); + it("attaches cached expose preview snapshots to switchable-agent responses", async () => { + server?.stop(); + const getProjectSession = TmuxRuntimeManager.prototype.getProjectSession; + const listManagedWindows = TmuxRuntimeManager.prototype.listManagedWindows; + const listWindows = TmuxRuntimeManager.prototype.listWindows; + const isWindowAlive = TmuxRuntimeManager.prototype.isWindowAlive; + const previewSnapshot = { + output: "warm preview\n", + capturedAt: "2026-07-20T13:00:00.000Z", + source: "capture" as const, + windowId: "@7", + startLine: -40, + lineCount: 40, + }; + const exposePreviewCache = { + start: vi.fn(), + stop: vi.fn(), + trackItems: vi.fn(), + get: vi.fn((windowId: string) => (windowId === "@7" ? previewSnapshot : undefined)), + }; + + TmuxRuntimeManager.prototype.getProjectSession = () => ({ sessionName: "aimux-test" }) as any; + TmuxRuntimeManager.prototype.listManagedWindows = () => + [ + { + target: { sessionName: "aimux-test", windowId: "@7", windowIndex: 7, windowName: "codex" }, + metadata: { + kind: "agent", + sessionId: "agent-1", + command: "codex", + args: [], + toolConfigKey: "codex", + worktreePath: repoRoot, + }, + }, + ] as any; + TmuxRuntimeManager.prototype.listWindows = () => [ + { id: "@7", index: 7, name: "codex", active: true, activity: 12 }, + ]; + TmuxRuntimeManager.prototype.isWindowAlive = () => true; + server = new MetadataServer({ exposePreviewCache }); + await server.start(); + + try { + const endpoint = server.getAddress(); + expect(endpoint).toBeTruthy(); + const base = `http://${endpoint!.host}:${endpoint!.port}${PROJECT_API_ROUTES.controls.switchableAgents}`; + const withoutPreview = await fetch(`${base}?scope=all&labelFormat=raw`); + const withoutPreviewBody = (await withoutPreview.json()) as { ok: boolean; items: Array> }; + expect(withoutPreview.status).toBe(200); + expect(withoutPreviewBody.items[0]?.previewSnapshot).toBeUndefined(); + expect(exposePreviewCache.trackItems).not.toHaveBeenCalled(); + + const response = await fetch(`${base}?scope=all&labelFormat=raw&includePreview=1`); + const body = (await response.json()) as { ok: boolean; items: Array> }; + + expect(response.status).toBe(200); + expect(body.items[0]?.previewSnapshot).toEqual(previewSnapshot); + expect(exposePreviewCache.trackItems).toHaveBeenCalledWith([ + expect.objectContaining({ target: expect.objectContaining({ windowId: "@7" }) }), + ]); + expect(exposePreviewCache.start).toHaveBeenCalledTimes(1); + } finally { + TmuxRuntimeManager.prototype.getProjectSession = getProjectSession; + TmuxRuntimeManager.prototype.listManagedWindows = listManagedWindows; + TmuxRuntimeManager.prototype.listWindows = listWindows; + TmuxRuntimeManager.prototype.isWindowAlive = isWindowAlive; + } + }); + it("serves a reconciled coordination worklist from desktop state + notifications", async () => { server?.stop(); server = new MetadataServer({ diff --git a/src/metadata-server.ts b/src/metadata-server.ts index e91ee45e..e4c091b0 100644 --- a/src/metadata-server.ts +++ b/src/metadata-server.ts @@ -158,6 +158,7 @@ import { import { loadConfig } from "./config.js"; import { describeSessionRestorability } from "./session-restorability.js"; import { shouldRelaunchFreshSession } from "./session-fresh-relaunch.js"; +import { ExposePreviewCache, type ExposePreviewCacheLike } from "./expose-preview-cache.js"; import { runTmuxExpose } from "./tmux/expose.js"; import { buildGraveyardViewModel } from "./multiplexer/graveyard-view-model.js"; import { @@ -642,6 +643,7 @@ export interface MetadataServerOptions { | Promise<{ sessionId: string; output: string; startLine?: number; parsed?: ParsedAgentOutput }> | { sessionId: string; output: string; startLine?: number; parsed?: ParsedAgentOutput }; }; + exposePreviewCache?: ExposePreviewCacheLike | false; } type InteractionDisplay = { @@ -1364,9 +1366,17 @@ export class MetadataServer { private shellStateFlushTimer: ReturnType | null = null; private exposeServer: NetServer | null = null; private exposeSocketPath: string | null = null; + private readonly exposePreviewCache: ExposePreviewCacheLike | null; constructor(private readonly options: MetadataServerOptions = {}) { this.projectRoot = options.projectRoot?.trim() || metadataProjectRoot(); + const defaultExposePreviewCache = options.lifecycle?.readAgentOutput + ? new ExposePreviewCache({ + projectRoot: this.currentProjectRoot(), + }) + : null; + this.exposePreviewCache = + options.exposePreviewCache === false ? null : (options.exposePreviewCache ?? defaultExposePreviewCache); this.eventBus = options.events?.bus ?? new ProjectEventBus(); this.unsubscribeAlertSink = this.eventBus.subscribe((event) => { if (event.type !== "alert") return; @@ -1393,6 +1403,7 @@ export class MetadataServer { error: error instanceof Error ? error.message : String(error), }); }); + this.exposePreviewCache?.start(); } private publishEndpoint(): void { @@ -1418,6 +1429,7 @@ export class MetadataServer { this.server?.close(); this.server = null; this.stopExposeSocket(); + this.exposePreviewCache?.stop(); if (this.desktopStateRefreshTimer) clearTimeout(this.desktopStateRefreshTimer); this.desktopStateRefreshTimer = null; if (this.shellStateFlushTimer) clearTimeout(this.shellStateFlushTimer); @@ -2615,7 +2627,8 @@ export class MetadataServer { const currentPath = url.searchParams.get("currentPath")?.trim() || undefined; const scope = url.searchParams.get("scope") === "all" ? "all" : "worktree"; const rawLabels = url.searchParams.get("labelFormat") === "raw"; - const items = listSwitchableAgentItems( + const includePreview = url.searchParams.get("includePreview") === "1"; + const rawItems = listSwitchableAgentItems( { projectRoot: this.currentProjectRoot(), currentClientSession, @@ -2625,10 +2638,17 @@ export class MetadataServer { }, new TmuxRuntimeManager(), { scope }, - ).map((item) => ({ - ...serializeFastControlItem(item), - label: rawLabels || !item.lastUsedAt ? item.label : `${item.label} · ${formatRelativeRecency(item.lastUsedAt)}`, - })); + ); + if (includePreview) this.exposePreviewCache?.trackItems(rawItems); + const items = rawItems.map((item) => { + const previewSnapshot = includePreview ? this.exposePreviewCache?.get(item.target.windowId) : undefined; + const serialized = serializeFastControlItem(previewSnapshot ? { ...item, previewSnapshot } : item); + return { + ...serialized, + label: + rawLabels || !item.lastUsedAt ? item.label : `${item.label} · ${formatRelativeRecency(item.lastUsedAt)}`, + }; + }); send(res, 200, { ok: true, items }); return; } diff --git a/src/project-api-contract.ts b/src/project-api-contract.ts index 27113fc4..7b5b0121 100644 --- a/src/project-api-contract.ts +++ b/src/project-api-contract.ts @@ -1184,6 +1184,7 @@ export interface SwitchableAgentsInput { currentWindowId?: string; currentPath?: string; scope?: "all" | "worktree"; + includePreview?: "1"; } export type ExposePreviewSnapshotSource = "capture" | "tap"; diff --git a/src/tmux/expose-model.test.ts b/src/tmux/expose-model.test.ts index 9b0661bf..58937908 100644 --- a/src/tmux/expose-model.test.ts +++ b/src/tmux/expose-model.test.ts @@ -89,6 +89,7 @@ describe("loadExposeScopeItems", () => { expect(requested.pathname).toBe("/control/switchable-agents"); expect(requested.searchParams.get("scope")).toBe("worktree"); expect(requested.searchParams.get("labelFormat")).toBe("raw"); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(requested.searchParams.get("currentWindowId")).toBe("@2"); expect(view).toMatchObject({ scope: "worktree", scopeLabel: "this worktree", sublabel: "none" }); expect(view.items.map((i) => i.id)).toEqual(["wt-agent"]); @@ -134,6 +135,7 @@ describe("loadExposeScopeItems", () => { const view = await loadExposeScopeItems("project", context, createProjectStateDir(), { requestJsonFn }); const requested = new URL(requestJsonFn.mock.calls[0]![0]); expect(requested.searchParams.get("scope")).toBe("all"); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(view).toMatchObject({ scope: "project", scopeLabel: "all worktrees", sublabel: "worktree" }); expect(view.items.map((i) => i.id)).toEqual(["project-agent"]); }); @@ -150,6 +152,7 @@ describe("loadExposeScopeItems", () => { const requested = new URL(requestJsonFn.mock.calls[0]![0]); expect(requested.pathname).toBe("/core/expose/items"); expect(requested.searchParams.get("scope")).toBe(null); + expect(requested.searchParams.get("includePreview")).toBe("1"); expect(view).toMatchObject({ scope: "global", scopeLabel: "all projects", sublabel: "project-worktree" }); expect(view.items.map((i) => i.id)).toEqual(["global-agent"]); }); diff --git a/src/tmux/expose-model.ts b/src/tmux/expose-model.ts index 3610aa5d..a6971040 100644 --- a/src/tmux/expose-model.ts +++ b/src/tmux/expose-model.ts @@ -96,6 +96,7 @@ export async function loadExposeScopeItems( if (scope === "global") { const endpoint = deps.daemonEndpoint ?? getDaemonBaseUrl(); const url = new URL(CORE_API_ROUTES.exposeItems, endpoint.endsWith("/") ? endpoint : `${endpoint}/`); + url.searchParams.set("includePreview", "1"); const items = await requestExposeItems(url, deps); return { scope, @@ -109,6 +110,7 @@ export async function loadExposeScopeItems( const url = new URL(PROJECT_API_ROUTES.controls.switchableAgents, endpoint.endsWith("/") ? endpoint : `${endpoint}/`); url.searchParams.set("scope", scope === "worktree" ? "worktree" : "all"); url.searchParams.set("labelFormat", "raw"); + url.searchParams.set("includePreview", "1"); appendFocusContext(url, context); const items = await requestExposeItems(url, deps); return { diff --git a/src/tmux/runtime-manager.ts b/src/tmux/runtime-manager.ts index 1d2ed4a6..d040d9a7 100644 --- a/src/tmux/runtime-manager.ts +++ b/src/tmux/runtime-manager.ts @@ -914,6 +914,13 @@ export class TmuxRuntimeManager { return this.exec(args); } + async captureTargetAsync(target: TmuxTarget, options: CaptureTargetOptions = {}): Promise { + const startLine = options.startLine ?? "-"; + const args = ["capture-pane", "-p", "-J", "-t", target.windowId, "-S", String(startLine)]; + if (options.includeEscapes) args.splice(3, 0, "-e"); + return await this.execAsync(args); + } + resizeTarget(target: TmuxTarget, cols: number, rows: number): void { this.exec(["resize-window", "-t", target.windowId, "-x", String(cols), "-y", String(rows)]); }