diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index dd73725c03240c5079dd94178c3684fb3a409133..bb3a48acc8476770f81521f7e0e1ffeed7886a53 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -53,6 +53,21 @@ async function initRepoWithCommit(cwd: string): Promise { // ── Tests ── describe("git integration", () => { + describe("runTerminalCommand", () => { + it("caps captured output when maxOutputBytes is exceeded", async () => { + const result = await runTerminalCommand({ + command: `node -e "process.stdout.write('x'.repeat(2000))"`, + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 128, + }); + + expect(result.code).toBe(0); + expect(result.stdout.length).toBeLessThanOrEqual(128); + expect(result.stderr).toContain("output truncated"); + }); + }); + // ── initGitRepo ── describe("initGitRepo", () => { diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index c4fc9f907b60f27e6a64e94389479d340c78ff1a..7e299c6c47c8c12a4089a78ee3678b332d63be3e 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -18,6 +18,7 @@ export interface TerminalCommandInput { command: string; cwd: string; timeoutMs?: number; + maxOutputBytes?: number; } export interface TerminalCommandResult { @@ -28,6 +29,36 @@ export interface TerminalCommandResult { timedOut: boolean; } +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +function appendChunkWithinLimit( + target: string, + currentBytes: number, + chunk: Buffer, + maxBytes: number, +): { + next: string; + nextBytes: number; + truncated: boolean; +} { + const remaining = maxBytes - currentBytes; + if (remaining <= 0) { + return { next: target, nextBytes: currentBytes, truncated: true }; + } + if (chunk.length <= remaining) { + return { + next: `${target}${chunk.toString()}`, + nextBytes: currentBytes + chunk.length, + truncated: false, + }; + } + return { + next: `${target}${chunk.subarray(0, remaining).toString()}`, + nextBytes: currentBytes + remaining, + truncated: true, + }; +} + /** Spawn git directly with an argv array — no shell, no quoting needed. */ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { @@ -37,9 +68,13 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { timedOut = true; @@ -50,10 +85,16 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { - stdout += chunk.toString(); + const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes); + stdout = appended.next; + stdoutBytes = appended.nextBytes; + outputTruncated = outputTruncated || appended.truncated; }); child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); + const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes); + stderr = appended.next; + stderrBytes = appended.nextBytes; + outputTruncated = outputTruncated || appended.truncated; }); child.on("error", (error) => { clearTimeout(timeout); @@ -61,6 +102,9 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { clearTimeout(timeout); + if (outputTruncated) { + stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`; + } resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut }); }); }); @@ -69,6 +113,7 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const shellPath = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") @@ -86,7 +131,10 @@ export async function runTerminalCommand( let stdout = ""; let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; let timedOut = false; + let outputTruncated = false; const timeout = setTimeout(() => { timedOut = true; @@ -99,11 +147,17 @@ export async function runTerminalCommand( }, input.timeoutMs ?? 30_000); child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); + const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes); + stdout = appended.next; + stdoutBytes = appended.nextBytes; + outputTruncated = outputTruncated || appended.truncated; }); child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); + const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes); + stderr = appended.next; + stderrBytes = appended.nextBytes; + outputTruncated = outputTruncated || appended.truncated; }); child.on("error", (error) => { @@ -113,6 +167,9 @@ export async function runTerminalCommand( child.on("close", (code, signal) => { clearTimeout(timeout); + if (outputTruncated) { + stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`; + } resolve({ stdout, stderr, diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 5f92a7cf3cd2494e00f775611952623faebeffea..281a716ae79f790c6e384f0dd656140de50c3c70 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -100,6 +100,14 @@ function openInput(overrides: Partial = {}): TerminalOpenInpu }; } +function historyLogName(threadId: string): string { + return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; +} + +function historyLogPath(logsDir: string, threadId = "thread-1"): string { + return path.join(logsDir, historyLogName(threadId)); +} + describe("TerminalManager", () => { const tempDirs: string[] = []; @@ -124,11 +132,12 @@ describe("TerminalManager", () => { it("spawns lazily and reuses running terminal per thread", async () => { const { manager, ptyAdapter } = makeManager(); - const first = await manager.open(openInput()); - const second = await manager.open(openInput()); + const [first, second] = await Promise.all([manager.open(openInput()), manager.open(openInput())]); + const third = await manager.open(openInput()); expect(first.threadId).toBe("thread-1"); expect(second.threadId).toBe("thread-1"); + expect(third.threadId).toBe("thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); manager.dispose(); @@ -162,9 +171,9 @@ describe("TerminalManager", () => { if (!process) return; process.emitData("hello\n"); - await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log"))); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.clear({ threadId: "thread-1" }); - await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === ""); + await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); expect(events.some((event) => event.type === "cleared")).toBe(true); @@ -178,13 +187,13 @@ describe("TerminalManager", () => { expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); - await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log"))); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); const snapshot = await manager.restart(openInput()); expect(snapshot.history).toBe(""); expect(snapshot.status).toBe("running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); - await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === ""); + await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); manager.dispose(); }); @@ -200,7 +209,7 @@ describe("TerminalManager", () => { expect(process).toBeDefined(); if (!process) return; process.emitData("old data\n"); - await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log"))); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); process.emitExit({ exitCode: 0, signal: 0 }); await waitFor(() => events.some((event) => event.type === "exited")); @@ -208,7 +217,7 @@ describe("TerminalManager", () => { expect(reopened.history).toBe(""); expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect(fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8")).toBe(""); + expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); manager.dispose(); }); @@ -237,10 +246,23 @@ describe("TerminalManager", () => { expect(process).toBeDefined(); if (!process) return; process.emitData("bye\n"); - await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log"))); + await waitFor(() => fs.existsSync(historyLogPath(logsDir))); await manager.close({ threadId: "thread-1", deleteHistory: true }); - expect(fs.existsSync(path.join(logsDir, "thread-1.log"))).toBe(false); + expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); + + manager.dispose(); + }); + + it("loads existing legacy transcript filenames and keeps new naming for writes", async () => { + const { manager, logsDir } = makeManager(); + const legacyPath = path.join(logsDir, "thread-1.log"); + fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); + + const snapshot = await manager.open(openInput()); + + expect(snapshot.history).toBe("legacy-line\n"); + expect(fs.existsSync(legacyPath)).toBe(true); manager.dispose(); }); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 64a3df656681c1ff7c95805c3f9befa7cb56d6b3..f13b02280a89a514aa4446cdf17ddbed85dbd71b 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -21,6 +21,7 @@ import { createLogger } from "./logger"; import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; +const DEFAULT_PERSIST_DEBOUNCE_MS = 40; export interface TerminalManagerEvents { event: [event: TerminalEvent]; @@ -68,10 +69,14 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } -function toSafeThreadId(threadId: string): string { +function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } +function toSafeThreadId(threadId: string): string { + return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; +} + export class TerminalManager extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; @@ -79,6 +84,10 @@ export class TerminalManager extends EventEmitter { private readonly ptyAdapter: PtyAdapter; private readonly shellResolver: () => string; private readonly persistQueues = new Map>(); + private readonly persistTimers = new Map>(); + private readonly pendingPersistHistory = new Map(); + private readonly threadLocks = new Map>(); + private readonly persistDebounceMs: number; private readonly logger = createLogger("terminal"); constructor(options: TerminalManagerOptions = {}) { @@ -87,60 +96,63 @@ export class TerminalManager extends EventEmitter { this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); this.shellResolver = options.shellResolver ?? defaultShellResolver; + this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; fs.mkdirSync(this.logsDir, { recursive: true }); } async open(raw: TerminalOpenInput): Promise { const input = terminalOpenInputSchema.parse(raw); - await this.assertValidCwd(input.cwd); - await this.flushPersistQueue(input.threadId); + return this.runWithThreadLock(input.threadId, async () => { + await this.assertValidCwd(input.cwd); - const existing = this.sessions.get(input.threadId); - if (!existing) { - const history = await this.readHistory(input.threadId); - const session: TerminalSessionState = { - threadId: input.threadId, - cwd: input.cwd, - status: "starting", - pid: null, - history, - exitCode: null, - exitSignal: null, - updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - }; - this.sessions.set(input.threadId, session); - this.startSession(session, input, "started"); - return this.snapshot(session); - } + const existing = this.sessions.get(input.threadId); + if (!existing) { + await this.flushPersistQueue(input.threadId); + const history = await this.readHistory(input.threadId); + const session: TerminalSessionState = { + threadId: input.threadId, + cwd: input.cwd, + status: "starting", + pid: null, + history, + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + cols: input.cols, + rows: input.rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + }; + this.sessions.set(input.threadId, session); + this.startSession(session, input, "started"); + return this.snapshot(session); + } - if (existing.cwd !== input.cwd) { - this.stopProcess(existing); - existing.cwd = input.cwd; - existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); - } else if (existing.status === "exited" || existing.status === "error") { - existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); - } + if (existing.cwd !== input.cwd) { + this.stopProcess(existing); + existing.cwd = input.cwd; + existing.history = ""; + await this.persistHistory(existing.threadId, existing.history); + } else if (existing.status === "exited" || existing.status === "error") { + existing.history = ""; + await this.persistHistory(existing.threadId, existing.history); + } + + if (!existing.process) { + this.startSession(existing, input, "started"); + return this.snapshot(existing); + } + + if (existing.cols !== input.cols || existing.rows !== input.rows) { + existing.cols = input.cols; + existing.rows = input.rows; + existing.process.resize(input.cols, input.rows); + existing.updatedAt = new Date().toISOString(); + } - if (!existing.process) { - this.startSession(existing, input, "started"); return this.snapshot(existing); - } - - if (existing.cols !== input.cols || existing.rows !== input.rows) { - existing.cols = input.cols; - existing.rows = input.rows; - existing.process.resize(input.cols, input.rows); - existing.updatedAt = new Date().toISOString(); - } - - return this.snapshot(existing); + }); } async write(raw: TerminalWriteInput): Promise { @@ -166,61 +178,67 @@ export class TerminalManager extends EventEmitter { async clear(raw: TerminalThreadInput): Promise { const input = terminalThreadInputSchema.parse(raw); - const session = this.requireSession(input.threadId); - session.history = ""; - session.updatedAt = new Date().toISOString(); - await this.persistHistory(input.threadId, session.history); - this.emitEvent({ - type: "cleared", - threadId: input.threadId, - createdAt: new Date().toISOString(), + await this.runWithThreadLock(input.threadId, async () => { + const session = this.requireSession(input.threadId); + session.history = ""; + session.updatedAt = new Date().toISOString(); + await this.persistHistory(input.threadId, session.history); + this.emitEvent({ + type: "cleared", + threadId: input.threadId, + createdAt: new Date().toISOString(), + }); }); } async restart(raw: TerminalOpenInput): Promise { const input = terminalOpenInputSchema.parse(raw); - await this.assertValidCwd(input.cwd); + return this.runWithThreadLock(input.threadId, async () => { + await this.assertValidCwd(input.cwd); - let session = this.sessions.get(input.threadId); - if (!session) { - session = { - threadId: input.threadId, - cwd: input.cwd, - status: "starting", - pid: null, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - }; - this.sessions.set(input.threadId, session); - } else { - this.stopProcess(session); - session.cwd = input.cwd; - } + let session = this.sessions.get(input.threadId); + if (!session) { + session = { + threadId: input.threadId, + cwd: input.cwd, + status: "starting", + pid: null, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + cols: input.cols, + rows: input.rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + }; + this.sessions.set(input.threadId, session); + } else { + this.stopProcess(session); + session.cwd = input.cwd; + } - session.history = ""; - await this.persistHistory(input.threadId, session.history); - this.startSession(session, input, "restarted"); - return this.snapshot(session); + session.history = ""; + await this.persistHistory(input.threadId, session.history); + this.startSession(session, input, "restarted"); + return this.snapshot(session); + }); } async close(raw: TerminalCloseInput): Promise { const input = terminalCloseInputSchema.parse(raw); - await this.flushPersistQueue(input.threadId); - const session = this.sessions.get(input.threadId); - if (session) { - this.stopProcess(session); - this.sessions.delete(input.threadId); - } - if (input.deleteHistory) { - await this.deleteHistory(input.threadId); - } + await this.runWithThreadLock(input.threadId, async () => { + const session = this.sessions.get(input.threadId); + if (session) { + this.stopProcess(session); + this.sessions.delete(input.threadId); + } + await this.flushPersistQueue(input.threadId); + if (input.deleteHistory) { + await this.deleteHistory(input.threadId); + } + }); } dispose(): void { @@ -228,6 +246,12 @@ export class TerminalManager extends EventEmitter { this.stopProcess(session); } this.sessions.clear(); + for (const timer of this.persistTimers.values()) { + clearTimeout(timer); + } + this.persistTimers.clear(); + this.pendingPersistHistory.clear(); + this.threadLocks.clear(); this.persistQueues.clear(); } @@ -247,8 +271,9 @@ export class TerminalManager extends EventEmitter { session.updatedAt = new Date().toISOString(); const shell = this.shellResolver(); + let ptyProcess: PtyProcess | null = null; try { - const ptyProcess = this.ptyAdapter.spawn({ + ptyProcess = this.ptyAdapter.spawn({ shell, cwd: session.cwd, cols: session.cols, @@ -272,6 +297,13 @@ export class TerminalManager extends EventEmitter { snapshot: this.snapshot(session), }); } catch (error) { + if (ptyProcess) { + try { + ptyProcess.kill(); + } catch { + // Ignore kill errors during failed startup cleanup. + } + } session.status = "error"; session.pid = null; session.process = null; @@ -346,31 +378,60 @@ export class TerminalManager extends EventEmitter { } private queuePersist(threadId: string, history: string): void { - const task = async () => { - await fs.promises.writeFile(this.historyPath(threadId), history, "utf8"); - }; - const previous = this.persistQueues.get(threadId) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(task); - this.persistQueues.set(threadId, next); - void next.finally(() => { - if (this.persistQueues.get(threadId) === next) { - this.persistQueues.delete(threadId); - } - }); + this.pendingPersistHistory.set(threadId, history); + this.schedulePersist(threadId); } private async persistHistory(threadId: string, history: string): Promise { + this.clearPersistTimer(threadId); + this.pendingPersistHistory.delete(threadId); + await this.enqueuePersistWrite(threadId, history); + } + + private enqueuePersistWrite(threadId: string, history: string): Promise { const task = async () => { await fs.promises.writeFile(this.historyPath(threadId), history, "utf8"); }; const previous = this.persistQueues.get(threadId) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(task); + const next = previous + .catch(() => undefined) + .then(task) + .catch((error) => { + this.logger.warn("failed to persist terminal history", { + threadId, + error: error instanceof Error ? error.message : String(error), + }); + }); this.persistQueues.set(threadId, next); - await next.finally(() => { + const finalized = next.finally(() => { if (this.persistQueues.get(threadId) === next) { this.persistQueues.delete(threadId); } + if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) { + this.schedulePersist(threadId); + } }); + void finalized.catch(() => undefined); + return finalized; + } + + private schedulePersist(threadId: string): void { + if (this.persistTimers.has(threadId)) return; + const timer = setTimeout(() => { + this.persistTimers.delete(threadId); + const pendingHistory = this.pendingPersistHistory.get(threadId); + if (pendingHistory === undefined) return; + this.pendingPersistHistory.delete(threadId); + void this.enqueuePersistWrite(threadId, pendingHistory); + }, this.persistDebounceMs); + this.persistTimers.set(threadId, timer); + } + + private clearPersistTimer(threadId: string): void { + const timer = this.persistTimers.get(threadId); + if (!timer) return; + clearTimeout(timer); + this.persistTimers.delete(threadId); } private async readHistory(threadId: string): Promise { @@ -381,6 +442,19 @@ export class TerminalManager extends EventEmitter { await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8"); } return capped; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + try { + const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8"); + const capped = capHistory(raw, this.historyLineLimit); + if (capped !== raw) { + await fs.promises.writeFile(this.legacyHistoryPath(threadId), capped, "utf8"); + } + return capped; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return ""; @@ -391,7 +465,10 @@ export class TerminalManager extends EventEmitter { private async deleteHistory(threadId: string): Promise { try { - await fs.promises.rm(this.historyPath(threadId), { force: true }); + await Promise.all([ + fs.promises.rm(this.historyPath(threadId), { force: true }), + fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }), + ]); } catch (error) { this.logger.warn("failed to delete terminal history", { threadId, @@ -401,9 +478,21 @@ export class TerminalManager extends EventEmitter { } private async flushPersistQueue(threadId: string): Promise { - const pending = this.persistQueues.get(threadId); - if (!pending) return; - await pending.catch(() => undefined); + this.clearPersistTimer(threadId); + + while (true) { + const pendingHistory = this.pendingPersistHistory.get(threadId); + if (pendingHistory !== undefined) { + this.pendingPersistHistory.delete(threadId); + await this.enqueuePersistWrite(threadId, pendingHistory); + } + + const pending = this.persistQueues.get(threadId); + if (!pending) { + return; + } + await pending.catch(() => undefined); + } } private async assertValidCwd(cwd: string): Promise { @@ -449,4 +538,29 @@ export class TerminalManager extends EventEmitter { private historyPath(threadId: string): string { return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`); } + + private legacyHistoryPath(threadId: string): string { + return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); + } + + private async runWithThreadLock( + threadId: string, + task: () => Promise, + ): Promise { + const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + this.threadLocks.set(threadId, current); + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + if (this.threadLocks.get(threadId) === current) { + this.threadLocks.delete(threadId); + } + } + } } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 484b86e22ad82afdd5e1789b188c15c9ce61ab53..8a86a87869a0a4f0f03de861444ff6e3664c600a 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -382,6 +382,22 @@ describe("WebSocket Server", () => { expect((push.data as TerminalEvent).type).toBe("output"); }); + it("detaches terminal event listener on stop for injected manager", async () => { + const terminalManager = new MockTerminalManager(); + server = createTestServer({ + cwd: "/test", + terminalManager: terminalManager as unknown as TerminalManager, + }); + await server.start(); + + expect(terminalManager.listenerCount("event")).toBe(1); + + await server.stop(); + server = null; + + expect(terminalManager.listenerCount("event")).toBe(0); + }); + it("returns validation errors for invalid terminal open params", async () => { server = createTestServer({ cwd: "/test" }); await server.start(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index af3f25bc0da5cc45866d1daa57531740c101eaa8..d010c4a7c4626fe45fff67c509dd47bfc4f86d4b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,6 +9,7 @@ import { EDITORS, WS_CHANNELS, WS_METHODS, + type TerminalEvent, type WsPush, type WsRequest, type WsResponse, @@ -112,7 +113,7 @@ export function createServer(options: ServerOptions) { logOutgoingPush(push, recipients); }); - terminalManager.on("event", (event) => { + const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { type: "push", channel: WS_CHANNELS.terminalEvent, @@ -127,7 +128,8 @@ export function createServer(options: ServerOptions) { } } logOutgoingPush(push, recipients); - }); + }; + terminalManager.on("event", onTerminalEvent); // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { @@ -401,6 +403,7 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { + terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); terminalManager.dispose(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0644c159a29635d4eed31f2dd11290e546a8b9de..8274c78eb045edb553838e30413a0e85035058bb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1207,7 +1207,6 @@ export default function ChatView() { - {activeThread.terminalOpen && api && activeProject && ( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3c4d9a7704e7c467e8e58ea706f79d8a9bafb70f..b157ddced20c3982fefd4de2ee5a33f9aee3a2da 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -152,6 +152,20 @@ export default function ThreadTerminalDrawer({ [], ); + const fitAndResizeTerminal = useCallback(() => { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + activeFitAddon.fit(); + void api.terminal + .resize({ + threadId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); + }, [api, threadId]); + useEffect(() => { const clampedHeight = clampDrawerHeight(height); setDrawerHeight(clampedHeight); @@ -201,8 +215,11 @@ export default function ThreadTerminalDrawer({ useEffect(() => { const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - if (clampedHeight !== drawerHeightRef.current) { + const changed = clampedHeight !== drawerHeightRef.current; + if (changed) { setDrawerHeight(clampedHeight); + } else { + fitAndResizeTerminal(); } if (!resizeStateRef.current) { syncHeight(clampedHeight); @@ -212,7 +229,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight]); + }, [fitAndResizeTerminal, syncHeight]); useEffect(() => { return () => { @@ -264,20 +281,6 @@ export default function ThreadTerminalDrawer({ return false; }); - const resizeAndSync = () => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - activeFitAddon.fit(); - void api.terminal - .resize({ - threadId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); - }; - const inputDisposable = terminal.onData((data) => { void api.terminal .write({ threadId, data }) @@ -363,14 +366,12 @@ export default function ThreadTerminalDrawer({ } }); - window.addEventListener("resize", resizeAndSync); - const fitTimer = window.setTimeout(resizeAndSync, 30); + const fitTimer = window.setTimeout(fitAndResizeTerminal, 30); void openTerminal(); return () => { disposed = true; window.clearTimeout(fitTimer); - window.removeEventListener("resize", resizeAndSync); unsubscribe(); inputDisposable.dispose(); themeObserver.disconnect(); @@ -378,7 +379,7 @@ export default function ThreadTerminalDrawer({ fitAddonRef.current = null; terminal.dispose(); }; - }, [api, cwd, threadId]); + }, [api, cwd, fitAndResizeTerminal, threadId]); useEffect(() => { const terminal = terminalRef.current; @@ -408,7 +409,7 @@ export default function ThreadTerminalDrawer({ return () => { window.cancelAnimationFrame(frame); }; - }, [api, drawerHeight, threadId]); + }, [api, cwd, drawerHeight, threadId]); return (