From 1dbf5b251e0e5fd973f78493e5e166b504faccdc Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Mon, 16 Mar 2026 15:29:34 +1100 Subject: [PATCH 1/3] feat(terminal): add IPC channels for live scrollback reduction - Add TERMINAL_REDUCE_SCROLLBACK and TERMINAL_RESTORE_SCROLLBACK channels - Add typed IpcEventMap entries for both new channels - Expose onReduceScrollback/onRestoreScrollback in preload terminal namespace - Add client and controller subscription wrappers - Add reduceScrollback/restoreScrollback methods to TerminalInstanceService - Wire up listeners in setupTerminalStoreListeners with proper cleanup - Reduce skips focused, scrolled-back, and already-reduced terminals - Restore computes correct target from getScrollbackForType + performance mode --- electron/ipc/channels.ts | 2 ++ electron/preload.cts | 7 ++++ shared/types/ipc/api.ts | 4 +++ shared/types/ipc/maps.ts | 2 ++ src/clients/terminalClient.ts | 10 ++++++ src/controllers/TerminalRegistryController.ts | 8 +++++ .../terminal/TerminalInstanceService.ts | 36 +++++++++++++++++++ src/store/terminalStore.ts | 26 ++++++++++++++ 8 files changed, 95 insertions(+) diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 4f1c084dc..74d819fc4 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -54,6 +54,8 @@ export const CHANNELS = { TERMINAL_BACKEND_READY: "terminal:backend-ready", TERMINAL_SEND_KEY: "terminal:send-key", TERMINAL_AGENT_TITLE_STATE: "terminal:agent-title-state", + TERMINAL_REDUCE_SCROLLBACK: "terminal:reduce-scrollback", + TERMINAL_RESTORE_SCROLLBACK: "terminal:restore-scrollback", FILES_SEARCH: "files:search", FILES_READ: "files:read", diff --git a/electron/preload.cts b/electron/preload.cts index 7e1b18b24..ed7702c2d 100644 --- a/electron/preload.cts +++ b/electron/preload.cts @@ -828,6 +828,13 @@ const api: ElectronAPI = { ipcRenderer.on(CHANNELS.TERMINAL_SPAWN_RESULT, handler); return () => ipcRenderer.removeListener(CHANNELS.TERMINAL_SPAWN_RESULT, handler); }, + + onReduceScrollback: ( + callback: (data: { terminalIds: string[]; targetLines: number }) => void + ) => _typedOn(CHANNELS.TERMINAL_REDUCE_SCROLLBACK, callback), + + onRestoreScrollback: (callback: (data: { terminalIds: string[] }) => void) => + _typedOn(CHANNELS.TERMINAL_RESTORE_SCROLLBACK, callback), }, // Files API diff --git a/shared/types/ipc/api.ts b/shared/types/ipc/api.ts index 2dc1f1d68..3b4cb3cf0 100644 --- a/shared/types/ipc/api.ts +++ b/shared/types/ipc/api.ts @@ -260,6 +260,10 @@ export interface ElectronAPI { sendKey(id: string, key: string): void; reportTitleState(id: string, state: "working" | "waiting"): void; onSpawnResult(callback: (id: string, result: SpawnResult) => void): () => void; + onReduceScrollback( + callback: (data: { terminalIds: string[]; targetLines: number }) => void + ): () => void; + onRestoreScrollback(callback: (data: { terminalIds: string[] }) => void): () => void; }; files: { search(payload: FileSearchPayload): Promise; diff --git a/shared/types/ipc/maps.ts b/shared/types/ipc/maps.ts index ae39f160f..bdcd8bdf5 100644 --- a/shared/types/ipc/maps.ts +++ b/shared/types/ipc/maps.ts @@ -1610,6 +1610,8 @@ export interface IpcEventMap { timestamp: number; }; "terminal:backend-ready": void; + "terminal:reduce-scrollback": { terminalIds: string[]; targetLines: number }; + "terminal:restore-scrollback": { terminalIds: string[] }; // Agent events "agent:state-changed": AgentStateChangePayload; diff --git a/src/clients/terminalClient.ts b/src/clients/terminalClient.ts index 78bdd5ecc..5a6d98378 100644 --- a/src/clients/terminalClient.ts +++ b/src/clients/terminalClient.ts @@ -282,4 +282,14 @@ export const terminalClient = { onSpawnResult: (callback: (id: string, result: SpawnResult) => void): (() => void) => { return window.electron.terminal.onSpawnResult(callback); }, + + onReduceScrollback: ( + callback: (data: { terminalIds: string[]; targetLines: number }) => void + ): (() => void) => { + return window.electron.terminal.onReduceScrollback(callback); + }, + + onRestoreScrollback: (callback: (data: { terminalIds: string[] }) => void): (() => void) => { + return window.electron.terminal.onRestoreScrollback(callback); + }, } as const; diff --git a/src/controllers/TerminalRegistryController.ts b/src/controllers/TerminalRegistryController.ts index f6b47082b..bfcc2e014 100644 --- a/src/controllers/TerminalRegistryController.ts +++ b/src/controllers/TerminalRegistryController.ts @@ -334,6 +334,14 @@ class TerminalRegistryController { onSpawnResult(handler: (id: string, result: SpawnResult) => void) { return terminalClient.onSpawnResult(handler); } + + onReduceScrollback(handler: (data: { terminalIds: string[]; targetLines: number }) => void) { + return terminalClient.onReduceScrollback(handler); + } + + onRestoreScrollback(handler: (data: { terminalIds: string[] }) => void) { + return terminalClient.onRestoreScrollback(handler); + } } // Singleton instance diff --git a/src/services/terminal/TerminalInstanceService.ts b/src/services/terminal/TerminalInstanceService.ts index 7ec4bc002..0ff597ed2 100644 --- a/src/services/terminal/TerminalInstanceService.ts +++ b/src/services/terminal/TerminalInstanceService.ts @@ -23,6 +23,9 @@ import { getEffectiveAgentConfig } from "@shared/config/agentRegistry"; import { logDebug, logWarn, logError } from "@/utils/logger"; import { PERF_MARKS } from "@shared/perf/marks"; import { markRendererPerformance } from "@/utils/performance"; +import { useScrollbackStore } from "@/store/scrollbackStore"; +import { usePerformanceModeStore } from "@/store/performanceModeStore"; +import { getScrollbackForType, PERFORMANCE_MODE_SCROLLBACK } from "@/utils/scrollbackConfig"; // eslint-disable-next-line no-control-regex const URXVT_MOUSE_RE = /^\x1b\[\d+;\d+;\d+M/; @@ -1118,6 +1121,39 @@ class TerminalInstanceService { this.rendererPolicy.initializeBackendTier(id, tier); } + reduceScrollback(id: string, targetLines: number): void { + const managed = this.instances.get(id); + if (!managed) return; + if (managed.isFocused) return; + if (managed.isUserScrolledBack) return; + + const currentScrollback = managed.terminal.options.scrollback ?? 0; + if (currentScrollback <= targetLines) return; + + const bufferLength = managed.terminal.buffer.active.length; + managed.terminal.options.scrollback = targetLines; + + if (bufferLength > targetLines) { + managed.terminal.write( + `\r\n\x1b[33m[Canopy] Scrollback reduced to ${targetLines} lines due to memory pressure. Older history is no longer available.\x1b[0m\r\n` + ); + } + } + + restoreScrollback(id: string): void { + const managed = this.instances.get(id); + if (!managed) return; + + const { scrollbackLines } = useScrollbackStore.getState(); + const { performanceMode } = usePerformanceModeStore.getState(); + + const restored = performanceMode + ? PERFORMANCE_MODE_SCROLLBACK + : getScrollbackForType(managed.type, scrollbackLines); + + managed.terminal.options.scrollback = restored; + } + addExitListener(id: string, cb: (exitCode: number) => void): () => void { const managed = this.instances.get(id); if (!managed) return () => {}; diff --git a/src/store/terminalStore.ts b/src/store/terminalStore.ts index 607e18553..6789fb2e3 100644 --- a/src/store/terminalStore.ts +++ b/src/store/terminalStore.ts @@ -513,6 +513,8 @@ let flowStatusUnsubscribe: (() => void) | null = null; let backendCrashedUnsubscribe: (() => void) | null = null; let backendReadyUnsubscribe: (() => void) | null = null; let spawnResultUnsubscribe: (() => void) | null = null; +let reduceScrollbackUnsubscribe: (() => void) | null = null; +let restoreScrollbackUnsubscribe: (() => void) | null = null; let recoveryTimer: NodeJS.Timeout | null = null; let beforeUnloadHandler: (() => void) | null = null; @@ -597,6 +599,14 @@ export function cleanupTerminalStoreListeners() { spawnResultUnsubscribe(); spawnResultUnsubscribe = null; } + if (reduceScrollbackUnsubscribe) { + reduceScrollbackUnsubscribe(); + reduceScrollbackUnsubscribe = null; + } + if (restoreScrollbackUnsubscribe) { + restoreScrollbackUnsubscribe(); + restoreScrollbackUnsubscribe = null; + } if (recoveryTimer) { clearTimeout(recoveryTimer); recoveryTimer = null; @@ -858,6 +868,22 @@ export function setupTerminalStoreListeners() { } }); + reduceScrollbackUnsubscribe = terminalRegistryController.onReduceScrollback( + ({ terminalIds, targetLines }) => { + for (const id of terminalIds) { + terminalInstanceService.reduceScrollback(id, targetLines); + } + } + ); + + restoreScrollbackUnsubscribe = terminalRegistryController.onRestoreScrollback( + ({ terminalIds }) => { + for (const id of terminalIds) { + terminalInstanceService.restoreScrollback(id); + } + } + ); + // Flush pending terminal persistence on window close to prevent data loss beforeUnloadHandler = () => { flushTerminalPersistence(); From 2d34f9a80c65cee6ff533223d217e88f0ab41ca8 Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Mon, 16 Mar 2026 15:35:18 +1100 Subject: [PATCH 2/3] fix(terminal): add missing scrollback channels to preload inline CHANNELS map and update test mock The preload.cts inlines channel names rather than importing from channels.ts (to avoid ESM/CJS format conflicts). TERMINAL_REDUCE_SCROLLBACK and TERMINAL_RESTORE_SCROLLBACK were added to channels.ts but not to the inline copy in preload.cts, causing two typecheck errors. Also added onReduceScrollback and onRestoreScrollback to the terminalClient mock in the processDetectionListeners test so setupTerminalStoreListeners can call them without throwing. Co-Authored-By: Claude Sonnet 4.6 --- electron/preload.cts | 2 ++ .../__tests__/terminalStore.processDetectionListeners.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/electron/preload.cts b/electron/preload.cts index ed7702c2d..9e625e859 100644 --- a/electron/preload.cts +++ b/electron/preload.cts @@ -191,6 +191,8 @@ const CHANNELS = { TERMINAL_BACKEND_READY: "terminal:backend-ready", TERMINAL_SEND_KEY: "terminal:send-key", TERMINAL_AGENT_TITLE_STATE: "terminal:agent-title-state", + TERMINAL_REDUCE_SCROLLBACK: "terminal:reduce-scrollback", + TERMINAL_RESTORE_SCROLLBACK: "terminal:restore-scrollback", // Files channels FILES_SEARCH: "files:search", diff --git a/src/store/__tests__/terminalStore.processDetectionListeners.test.ts b/src/store/__tests__/terminalStore.processDetectionListeners.test.ts index 356c7c4b1..3c00caba2 100644 --- a/src/store/__tests__/terminalStore.processDetectionListeners.test.ts +++ b/src/store/__tests__/terminalStore.processDetectionListeners.test.ts @@ -137,6 +137,8 @@ vi.mock("@/clients", () => ({ onBackendCrashed: onBackendCrashedMock, onBackendReady: onBackendReadyMock, onSpawnResult: onSpawnResultMock, + onReduceScrollback: vi.fn(() => vi.fn()), + onRestoreScrollback: vi.fn(() => vi.fn()), }, appClient: { setState: vi.fn().mockResolvedValue(undefined), From d3bb8c55505a0e508825600dcda918fb5e4b3f65 Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Mon, 16 Mar 2026 15:49:33 +1100 Subject: [PATCH 3/3] fix(terminal): address review findings for scrollback reduction - Fix buffer length check to exclude viewport rows (scrollbackUsed = length - rows) - Account for project-level scrollback override in restoreScrollback - Add useProjectSettingsStore import for project override lookup - Add comprehensive unit tests for reduceScrollback and restoreScrollback --- .../terminal/TerminalInstanceService.ts | 22 +- ...TerminalInstanceService.scrollback.test.ts | 207 ++++++++++++++++++ 2 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 src/services/terminal/__tests__/TerminalInstanceService.scrollback.test.ts diff --git a/src/services/terminal/TerminalInstanceService.ts b/src/services/terminal/TerminalInstanceService.ts index 0ff597ed2..1071a1a4d 100644 --- a/src/services/terminal/TerminalInstanceService.ts +++ b/src/services/terminal/TerminalInstanceService.ts @@ -25,6 +25,7 @@ import { PERF_MARKS } from "@shared/perf/marks"; import { markRendererPerformance } from "@/utils/performance"; import { useScrollbackStore } from "@/store/scrollbackStore"; import { usePerformanceModeStore } from "@/store/performanceModeStore"; +import { useProjectSettingsStore } from "@/store/projectSettingsStore"; import { getScrollbackForType, PERFORMANCE_MODE_SCROLLBACK } from "@/utils/scrollbackConfig"; // eslint-disable-next-line no-control-regex @@ -1130,10 +1131,10 @@ class TerminalInstanceService { const currentScrollback = managed.terminal.options.scrollback ?? 0; if (currentScrollback <= targetLines) return; - const bufferLength = managed.terminal.buffer.active.length; + const scrollbackUsed = managed.terminal.buffer.active.length - managed.terminal.rows; managed.terminal.options.scrollback = targetLines; - if (bufferLength > targetLines) { + if (scrollbackUsed > targetLines) { managed.terminal.write( `\r\n\x1b[33m[Canopy] Scrollback reduced to ${targetLines} lines due to memory pressure. Older history is no longer available.\x1b[0m\r\n` ); @@ -1147,11 +1148,20 @@ class TerminalInstanceService { const { scrollbackLines } = useScrollbackStore.getState(); const { performanceMode } = usePerformanceModeStore.getState(); - const restored = performanceMode - ? PERFORMANCE_MODE_SCROLLBACK - : getScrollbackForType(managed.type, scrollbackLines); + if (performanceMode) { + managed.terminal.options.scrollback = PERFORMANCE_MODE_SCROLLBACK; + return; + } - managed.terminal.options.scrollback = restored; + const isAgent = managed.kind === "agent"; + const projectScrollback = !isAgent + ? useProjectSettingsStore.getState().settings?.terminalSettings?.scrollbackLines + : undefined; + + managed.terminal.options.scrollback = getScrollbackForType( + managed.type, + projectScrollback ?? scrollbackLines + ); } addExitListener(id: string, cb: (exitCode: number) => void): () => void { diff --git a/src/services/terminal/__tests__/TerminalInstanceService.scrollback.test.ts b/src/services/terminal/__tests__/TerminalInstanceService.scrollback.test.ts new file mode 100644 index 000000000..bc5ab411e --- /dev/null +++ b/src/services/terminal/__tests__/TerminalInstanceService.scrollback.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/clients", () => ({ + terminalClient: { + resize: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + write: vi.fn(), + setActivityTier: vi.fn(), + wake: vi.fn(), + getSerializedState: vi.fn(), + getSharedBuffers: vi.fn(async () => ({ + visualBuffers: [], + signalBuffer: null, + })), + acknowledgeData: vi.fn(), + }, + systemClient: { openExternal: vi.fn() }, + appClient: { getHydrationState: vi.fn() }, + projectClient: { + getTerminals: vi.fn().mockResolvedValue([]), + setTerminals: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + onContextLoss: vi.fn(() => ({ dispose: vi.fn() })), + })), +})); + +vi.mock("../TerminalAddonManager", () => ({ + setupTerminalAddons: vi.fn(() => ({ + fitAddon: { fit: vi.fn() }, + serializeAddon: { serialize: vi.fn() }, + webLinksAddon: {}, + imageAddon: {}, + searchAddon: {}, + })), +})); + +const mockScrollbackStore = { scrollbackLines: 5000 }; +vi.mock("@/store/scrollbackStore", () => ({ + useScrollbackStore: { getState: () => mockScrollbackStore }, +})); + +const mockPerformanceModeStore = { performanceMode: false }; +vi.mock("@/store/performanceModeStore", () => ({ + usePerformanceModeStore: { getState: () => mockPerformanceModeStore }, +})); + +const mockProjectSettingsStore: { settings: Record | null } = { settings: null }; +vi.mock("@/store/projectSettingsStore", () => ({ + useProjectSettingsStore: { getState: () => mockProjectSettingsStore }, +})); + +type ScrollbackTestService = { + instances: Map; + reduceScrollback: (id: string, targetLines: number) => void; + restoreScrollback: (id: string) => void; +}; + +function makeMockManaged(overrides: Record = {}) { + const writtenData: string[] = []; + return { + terminal: { + options: { scrollback: 5000 }, + rows: 24, + buffer: { active: { length: 3000 } }, + write: (data: string) => writtenData.push(data), + }, + type: "terminal", + kind: "terminal", + isFocused: false, + isUserScrolledBack: false, + writtenData, + ...overrides, + }; +} + +describe("TerminalInstanceService - Scrollback", () => { + let service: ScrollbackTestService; + + beforeEach(async () => { + vi.clearAllMocks(); + mockScrollbackStore.scrollbackLines = 5000; + mockPerformanceModeStore.performanceMode = false; + mockProjectSettingsStore.settings = null; + + ({ terminalInstanceService: service } = + (await import("../TerminalInstanceService")) as unknown as { + terminalInstanceService: ScrollbackTestService; + }); + service.instances.clear(); + }); + + describe("reduceScrollback", () => { + it("no-ops for unknown terminal ID", () => { + service.reduceScrollback("nonexistent", 500); + }); + + it("skips focused terminals", () => { + const managed = makeMockManaged({ isFocused: true }); + service.instances.set("t1", managed); + + service.reduceScrollback("t1", 500); + expect(managed.terminal.options.scrollback).toBe(5000); + }); + + it("skips user-scrolled-back terminals", () => { + const managed = makeMockManaged({ isUserScrolledBack: true }); + service.instances.set("t1", managed); + + service.reduceScrollback("t1", 500); + expect(managed.terminal.options.scrollback).toBe(5000); + }); + + it("skips when current scrollback already at or below target", () => { + const managed = makeMockManaged(); + managed.terminal.options.scrollback = 300; + service.instances.set("t1", managed); + + service.reduceScrollback("t1", 500); + expect(managed.terminal.options.scrollback).toBe(300); + }); + + it("reduces scrollback and writes notice when scrollback content exceeds target", () => { + const managed = makeMockManaged(); + // 3000 total - 24 viewport = 2976 scrollback lines > 500 target + managed.terminal.buffer.active.length = 3000; + service.instances.set("t1", managed); + + service.reduceScrollback("t1", 500); + + expect(managed.terminal.options.scrollback).toBe(500); + expect(managed.writtenData).toHaveLength(1); + expect(managed.writtenData[0]).toContain("Scrollback reduced to 500 lines"); + }); + + it("reduces scrollback without notice when scrollback content is within target", () => { + const managed = makeMockManaged(); + // 100 total - 24 viewport = 76 scrollback lines < 500 target + managed.terminal.buffer.active.length = 100; + service.instances.set("t1", managed); + + service.reduceScrollback("t1", 500); + + expect(managed.terminal.options.scrollback).toBe(500); + expect(managed.writtenData).toHaveLength(0); + }); + }); + + describe("restoreScrollback", () => { + it("no-ops for unknown terminal ID", () => { + service.restoreScrollback("nonexistent"); + }); + + it("restores to PERFORMANCE_MODE_SCROLLBACK when performance mode is on", () => { + mockPerformanceModeStore.performanceMode = true; + const managed = makeMockManaged(); + managed.terminal.options.scrollback = 50; + service.instances.set("t1", managed); + + service.restoreScrollback("t1"); + + // PERFORMANCE_MODE_SCROLLBACK = 100 + expect(managed.terminal.options.scrollback).toBe(100); + }); + + it("restores using getScrollbackForType for normal terminals", () => { + const managed = makeMockManaged({ type: "terminal" }); + managed.terminal.options.scrollback = 500; + service.instances.set("t1", managed); + + service.restoreScrollback("t1"); + + // getScrollbackForType("terminal", 5000) = min(2000, max(200, floor(5000*0.2))) = 1000 + expect(managed.terminal.options.scrollback).toBe(1000); + }); + + it("uses project-level scrollback override for non-agent terminals", () => { + mockProjectSettingsStore.settings = { terminalSettings: { scrollbackLines: 2000 } }; + const managed = makeMockManaged({ type: "terminal", kind: "terminal" }); + managed.terminal.options.scrollback = 100; + service.instances.set("t1", managed); + + service.restoreScrollback("t1"); + + // getScrollbackForType("terminal", 2000) = min(2000, max(200, floor(2000*0.2))) = 400 + expect(managed.terminal.options.scrollback).toBe(400); + }); + + it("ignores project override for agent terminals", () => { + mockProjectSettingsStore.settings = { terminalSettings: { scrollbackLines: 2000 } }; + const managed = makeMockManaged({ type: "claude", kind: "agent" }); + managed.terminal.options.scrollback = 100; + service.instances.set("t1", managed); + + service.restoreScrollback("t1"); + + // getScrollbackForType("claude", 5000) = min(10000, max(1000, floor(5000*1.0))) = 5000 + expect(managed.terminal.options.scrollback).toBe(5000); + }); + }); +});