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..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", @@ -828,6 +830,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..1071a1a4d 100644 --- a/src/services/terminal/TerminalInstanceService.ts +++ b/src/services/terminal/TerminalInstanceService.ts @@ -23,6 +23,10 @@ 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 { useProjectSettingsStore } from "@/store/projectSettingsStore"; +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 +1122,48 @@ 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 scrollbackUsed = managed.terminal.buffer.active.length - managed.terminal.rows; + managed.terminal.options.scrollback = 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` + ); + } + } + + restoreScrollback(id: string): void { + const managed = this.instances.get(id); + if (!managed) return; + + const { scrollbackLines } = useScrollbackStore.getState(); + const { performanceMode } = usePerformanceModeStore.getState(); + + if (performanceMode) { + managed.terminal.options.scrollback = PERFORMANCE_MODE_SCROLLBACK; + return; + } + + 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 { const managed = this.instances.get(id); if (!managed) return () => {}; 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); + }); + }); +}); 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), 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();