From c1f7874a26a2129202fe3ee38b7eeb2890e4be33 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Mon, 29 Jun 2026 14:04:46 +0200 Subject: [PATCH] Bound terminal replay on attach --- client/electron/embedded-terminal.ts | 5 +++-- client/electron/ipc-handlers.ts | 2 +- client/electron/preload.ts | 4 ++-- client/electron/terminal-buffer.ts | 10 +++++++++- client/src/electron.ts | 2 +- client/tests/terminal-buffer.test.mjs | 16 ++++++++++++++++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/client/electron/embedded-terminal.ts b/client/electron/embedded-terminal.ts index 2c12b4a..5902c39 100644 --- a/client/electron/embedded-terminal.ts +++ b/client/electron/embedded-terminal.ts @@ -69,6 +69,7 @@ import { import { DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS, appendBoundedTerminalOutput, + terminalReplayBufferTail, } from "./terminal-buffer.js"; import { OutputAckGate } from "./terminal-output-ack.js"; import { agentConfig, terminalLaunch } from "./terminal-launch.js"; @@ -250,8 +251,8 @@ export function getEmbeddedTerminalBuffer(id: string): string { return outputBuffers.get(id) ?? ""; } -export function attachEmbeddedTerminalBuffer(id: string): string { - const buffer = getEmbeddedTerminalBuffer(id); +export function attachEmbeddedTerminalBuffer(id: string, maxChars?: number | null): string { + const buffer = terminalReplayBufferTail(getEmbeddedTerminalBuffer(id), maxChars); pendingOutput.delete(id); outputAckGate.clear(id); if (pendingOutput.size === 0) clearOutputFlushTimer(); diff --git a/client/electron/ipc-handlers.ts b/client/electron/ipc-handlers.ts index 9242e1b..b8d5a3d 100644 --- a/client/electron/ipc-handlers.ts +++ b/client/electron/ipc-handlers.ts @@ -188,7 +188,7 @@ export function registerIpcHandlers(appRoot: string): void { handle("embeddedTerminal:restore", (_event, allowedWorkspaces?: string[]): Promise => restoreEmbeddedTerminals(allowedWorkspaces), ); - handle("embeddedTerminal:attachBuffer", (_event, id: string): string => attachEmbeddedTerminalBuffer(id)); + handle("embeddedTerminal:attachBuffer", (_event, id: string, maxChars?: number): string => attachEmbeddedTerminalBuffer(id, maxChars)); handle("embeddedTerminal:buffer", (_event, id: string): string => getEmbeddedTerminalBuffer(id)); handle("agentMessages:list", (_event, workspace?: string, limit?: number): AgentMessage[] => listEmbeddedAgentMessages(workspace, limit)); handle("agentMessages:send", (_event, request: SendAgentMessageRequest): Promise => sendAgentMessage({ ...request, source: "ui" })); diff --git a/client/electron/preload.ts b/client/electron/preload.ts index 726474c..aec402c 100644 --- a/client/electron/preload.ts +++ b/client/electron/preload.ts @@ -122,7 +122,7 @@ export type WorkspaceApi = { writeEmbeddedTerminal: (id: string, data: string) => Promise; renameEmbeddedTerminal: (id: string, title: string) => Promise; resizeEmbeddedTerminal: (id: string, cols: number, rows: number) => Promise; - attachEmbeddedTerminalBuffer: (id: string) => Promise; + attachEmbeddedTerminalBuffer: (id: string, maxChars?: number) => Promise; getEmbeddedTerminalBuffer: (id: string) => Promise; listAgentMessages: (workspace?: string, limit?: number) => Promise; sendAgentMessage: (request: SendAgentMessageRequest) => Promise; @@ -257,7 +257,7 @@ const api: WorkspaceApi = { writeEmbeddedTerminal: (id, data) => ipcRenderer.invoke("embeddedTerminal:write", id, data), renameEmbeddedTerminal: (id, title) => ipcRenderer.invoke("embeddedTerminal:rename", id, title), resizeEmbeddedTerminal: (id, cols, rows) => ipcRenderer.invoke("embeddedTerminal:resize", id, cols, rows), - attachEmbeddedTerminalBuffer: (id) => ipcRenderer.invoke("embeddedTerminal:attachBuffer", id), + attachEmbeddedTerminalBuffer: (id, maxChars) => ipcRenderer.invoke("embeddedTerminal:attachBuffer", id, maxChars), getEmbeddedTerminalBuffer: (id) => ipcRenderer.invoke("embeddedTerminal:buffer", id), listAgentMessages: (workspace, limit) => ipcRenderer.invoke("agentMessages:list", workspace, limit), sendAgentMessage: (request) => ipcRenderer.invoke("agentMessages:send", request), diff --git a/client/electron/terminal-buffer.ts b/client/electron/terminal-buffer.ts index 164958f..60dc47b 100644 --- a/client/electron/terminal-buffer.ts +++ b/client/electron/terminal-buffer.ts @@ -4,6 +4,7 @@ export const DEFAULT_TERMINAL_BUFFER_MAX_CHARS = 40_000; export const MIN_TERMINAL_BUFFER_MAX_CHARS = 1_000; export const MAX_TERMINAL_BUFFER_MAX_CHARS = 200_000; +export const DEFAULT_TERMINAL_REPLAY_MAX_CHARS = DEFAULT_TERMINAL_BUFFER_MAX_CHARS; export const DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS = 64_000; export const TERMINAL_OUTPUT_TRUNCATED_NOTICE = "\r\n\x1b[33m[Athena truncated terminal output backlog]\x1b[0m\r\n"; @@ -13,7 +14,7 @@ export type TerminalBufferResult = { max_chars: number; }; -export function boundedTerminalBufferMaxChars(value: string | null): number { +export function boundedTerminalBufferMaxChars(value: string | number | null | undefined): number { const parsed = Number(value ?? DEFAULT_TERMINAL_BUFFER_MAX_CHARS); if (!Number.isFinite(parsed)) return DEFAULT_TERMINAL_BUFFER_MAX_CHARS; return Math.max( @@ -26,6 +27,13 @@ export function terminalBufferTail(value: string, maxChars: number): string { return value.length > maxChars ? value.slice(-maxChars) : value; } +export function terminalReplayBufferTail( + value: string, + maxChars: string | number | null | undefined = DEFAULT_TERMINAL_REPLAY_MAX_CHARS, +): string { + return terminalBufferTail(value, boundedTerminalBufferMaxChars(maxChars)); +} + export function formatTerminalBuffer(value: string, maxChars: number): TerminalBufferResult { const buffer = terminalBufferTail(value, maxChars); return { diff --git a/client/src/electron.ts b/client/src/electron.ts index 6f53581..7aee596 100644 --- a/client/src/electron.ts +++ b/client/src/electron.ts @@ -213,7 +213,7 @@ type WorkspaceApi = { writeEmbeddedTerminal: (id: string, data: string) => Promise; renameEmbeddedTerminal: (id: string, title: string) => Promise; resizeEmbeddedTerminal: (id: string, cols: number, rows: number) => Promise; - attachEmbeddedTerminalBuffer: (id: string) => Promise; + attachEmbeddedTerminalBuffer: (id: string, maxChars?: number) => Promise; getEmbeddedTerminalBuffer: (id: string) => Promise; listAgentMessages: (workspace?: string, limit?: number) => Promise; sendAgentMessage: (request: SendAgentMessageRequest) => Promise; diff --git a/client/tests/terminal-buffer.test.mjs b/client/tests/terminal-buffer.test.mjs index c0330ce..ec7e565 100644 --- a/client/tests/terminal-buffer.test.mjs +++ b/client/tests/terminal-buffer.test.mjs @@ -3,11 +3,13 @@ import test from "node:test"; import { DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS, + DEFAULT_TERMINAL_REPLAY_MAX_CHARS, TERMINAL_OUTPUT_TRUNCATED_NOTICE, appendBoundedTerminalOutput, boundedTerminalBufferMaxChars, formatTerminalBuffer, terminalBufferTail, + terminalReplayBufferTail, } from "../dist-electron/terminal-buffer.js"; test("terminal buffer max chars uses default and clamps bounds", () => { @@ -31,6 +33,20 @@ test("format terminal buffer reports returned char count and limit", () => { }); }); +test("terminal replay buffer defaults to the smaller renderer replay cap", () => { + const output = "x".repeat(DEFAULT_TERMINAL_REPLAY_MAX_CHARS + 5); + + assert.equal(terminalReplayBufferTail(output).length, DEFAULT_TERMINAL_REPLAY_MAX_CHARS); + assert.equal(terminalReplayBufferTail(output), output.slice(-DEFAULT_TERMINAL_REPLAY_MAX_CHARS)); +}); + +test("terminal replay buffer accepts explicit bounded caps", () => { + const output = "abcdef".repeat(1000); + + assert.equal(terminalReplayBufferTail(output, 200_000), output); + assert.equal(terminalReplayBufferTail(output, 10), output.slice(-1_000)); +}); + test("pending terminal output is capped to the newest text", () => { const maxChars = TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 4; const output = "abcdefghij".repeat(10);