Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions electron/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions shared/types/ipc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSearchResult>;
Expand Down
2 changes: 2 additions & 0 deletions shared/types/ipc/maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/clients/terminalClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions src/controllers/TerminalRegistryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/services/terminal/TerminalInstanceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand Down Expand Up @@ -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 () => {};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null } = { settings: null };
vi.mock("@/store/projectSettingsStore", () => ({
useProjectSettingsStore: { getState: () => mockProjectSettingsStore },
}));

type ScrollbackTestService = {
instances: Map<string, unknown>;
reduceScrollback: (id: string, targetLines: number) => void;
restoreScrollback: (id: string) => void;
};

function makeMockManaged(overrides: Record<string, unknown> = {}) {
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading