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
14 changes: 14 additions & 0 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ export const createTerminalRouter = () => {
terminalManager.detach(input);
}),

/**
* Clear scrollback buffer for terminal (used by Cmd+K / clear command)
* This clears both in-memory scrollback and persistent history file
*/
clearScrollback: publicProcedure
.input(
z.object({
tabId: z.string(),
}),
)
.mutation(async ({ input }) => {
await terminalManager.clearScrollback(input);
}),

getSession: publicProcedure
.input(z.string())
.query(async ({ input: tabId }) => {
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
const ESC = "\x1b";
const BEL = "\x07";

/**
* Pattern to detect clear scrollback sequences:
* - ESC [ 3 J - Clear scrollback buffer (ED3)
* - ESC c - Full terminal reset (RIS)
*/
const CLEAR_SCROLLBACK_PATTERN = new RegExp(`${ESC}\\[3J|${ESC}c`);

/**
* Pattern definitions for terminal query responses.
* Each pattern matches a specific type of response that should be filtered.
Expand Down Expand Up @@ -253,3 +260,15 @@ export function filterTerminalQueryResponses(data: string): string {

// Export patterns for testing
export const patterns = FILTER_PATTERNS;

/**
* Checks if data contains sequences that clear the scrollback buffer.
* Used to detect when the shell sends clear commands (e.g., from `clear` command or Ctrl+L).
*
* Detected sequences:
* - ESC [ 3 J - Clear scrollback buffer (ED3)
* - ESC c - Full terminal reset (RIS)
*/
export function containsClearScrollbackSequence(data: string): boolean {
return CLEAR_SCROLLBACK_PATTERN.test(data);
}
Comment thread
Kitenite marked this conversation as resolved.
10 changes: 7 additions & 3 deletions apps/desktop/src/main/lib/terminal-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ export class HistoryWriter {
await fs.mkdir(dir, { recursive: true });

// Write initial scrollback (recovered from previous session) or truncate
// node-pty produces UTF-8 strings, so we store as UTF-8
if (initialScrollback) {
await fs.writeFile(this.filePath, Buffer.from(initialScrollback));
await fs.writeFile(this.filePath, Buffer.from(initialScrollback, "utf8"));
} else {
await fs.writeFile(this.filePath, Buffer.alloc(0));
}
Expand All @@ -73,7 +74,8 @@ export class HistoryWriter {
write(data: string): void {
if (this.stream && !this.streamErrored) {
try {
this.stream.write(Buffer.from(data));
// node-pty produces UTF-8 strings
this.stream.write(Buffer.from(data, "utf8"));
} catch {
this.streamErrored = true;
}
Expand Down Expand Up @@ -111,7 +113,9 @@ export class HistoryReader {
async read(): Promise<{ scrollback: string; metadata?: SessionMetadata }> {
try {
const filePath = getHistoryFilePath(this.workspaceId, this.tabId);
const scrollback = await fs.readFile(filePath, "utf-8");
// Read as UTF-8 to match how node-pty produces terminal output
// The file is stored as raw bytes from UTF-8 encoded strings
const scrollback = await fs.readFile(filePath, "utf8");

let metadata: SessionMetadata | undefined;
try {
Expand Down
107 changes: 107 additions & 0 deletions apps/desktop/src/main/lib/terminal-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,113 @@ describe("TerminalManager", () => {
});
});

describe("clearScrollback", () => {
it("should clear in-memory scrollback", async () => {
await manager.createOrAttach({
tabId: "tab-clear",
workspaceId: "workspace-1",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

const onDataCallback =
mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0];
if (onDataCallback) {
onDataCallback("some output\n");
}

await manager.clearScrollback({ tabId: "tab-clear" });

const result = await manager.createOrAttach({
tabId: "tab-clear",
workspaceId: "workspace-1",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

expect(result.scrollback).toBe("");
});

it("should reinitialize history file", async () => {
await manager.createOrAttach({
tabId: "tab-clear-history",
workspaceId: "workspace-clear",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

const onDataCallback =
mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0];
if (onDataCallback) {
onDataCallback("output before clear\n");
}

await manager.clearScrollback({ tabId: "tab-clear-history" });

const onExitCallback =
mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0];
if (onExitCallback) {
await onExitCallback({ exitCode: 0, signal: undefined });
}

await manager.cleanup();

const result = await manager.createOrAttach({
tabId: "tab-clear-history",
workspaceId: "workspace-clear",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

expect(result.scrollback).toBe("");
expect(result.wasRecovered).toBe(false);
});

it("should handle non-existent session gracefully", async () => {
const warnSpy = mock(() => {});
const originalWarn = console.warn;
console.warn = warnSpy;

await expect(
manager.clearScrollback({ tabId: "non-existent" }),
).resolves.toBeUndefined();

expect(warnSpy).toHaveBeenCalledWith(
"Cannot clear scrollback for terminal non-existent: session not found",
);

console.warn = originalWarn;
});

it("should clear scrollback when shell sends clear sequence", async () => {
await manager.createOrAttach({
tabId: "tab-shell-clear",
workspaceId: "workspace-1",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

const onDataCallback =
mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0];
if (onDataCallback) {
onDataCallback("some output\n");
// ED3 sequence clears scrollback, then output after the sequence is stored
onDataCallback("\x1b[3Jnew content after clear");
}

const result = await manager.createOrAttach({
tabId: "tab-shell-clear",
workspaceId: "workspace-1",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

// Only content after the clear sequence should remain
expect(result.scrollback).not.toContain("some output");
expect(result.scrollback).toContain("new content after clear");
});
});

describe("multi-session history persistence", () => {
it("should persist history across multiple sessions", async () => {
// Session 1: Create and write data
Expand Down
48 changes: 42 additions & 6 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import os from "node:os";
import * as pty from "node-pty";
import { PORTS } from "shared/constants";
import { getShellArgs, getShellEnv } from "./agent-setup";
import { TerminalEscapeFilter } from "./terminal-escape-filter";
import {
containsClearScrollbackSequence,
TerminalEscapeFilter,
} from "./terminal-escape-filter";
import { HistoryReader, HistoryWriter } from "./terminal-history";

interface TerminalSession {
Expand Down Expand Up @@ -223,18 +226,20 @@ export class TerminalManager extends EventEmitter {
let commandsSent = false;

ptyProcess.onData((data) => {
// Filter terminal query responses for storage only
// xterm needs raw data for proper terminal behavior (DA/DSR/OSC responses)
if (containsClearScrollbackSequence(data)) {
session.scrollback = "";
session.escapeFilter = new TerminalEscapeFilter();
this.reinitializeHistory(session).catch(() => {});
}

// Filter query responses for storage; xterm receives raw data for proper protocol handling
const filteredData = session.escapeFilter.filter(data);
session.scrollback += filteredData;
session.historyWriter?.write(filteredData);
// Emit ORIGINAL data to xterm - it needs to process query responses
this.emit(`data:${tabId}`, data);

// Send initial commands after shell outputs first data (prompt ready)
if (shouldRunCommands && !commandsSent) {
commandsSent = true;
// Small delay ensures shell is fully ready to accept input
setTimeout(() => {
if (session.isAlive) {
const cmdString = `${initialCommands.join(" && ")}\n`;
Expand Down Expand Up @@ -357,6 +362,37 @@ export class TerminalManager extends EventEmitter {
session.lastActive = Date.now();
}

async clearScrollback(params: { tabId: string }): Promise<void> {
const { tabId } = params;
const session = this.sessions.get(tabId);

if (!session) {
console.warn(
`Cannot clear scrollback for terminal ${tabId}: session not found`,
);
return;
}

session.scrollback = "";
session.escapeFilter = new TerminalEscapeFilter();
await this.reinitializeHistory(session);
session.lastActive = Date.now();
}

private async reinitializeHistory(session: TerminalSession): Promise<void> {
if (session.historyWriter) {
await session.historyWriter.close();
session.historyWriter = new HistoryWriter(
session.workspaceId,
session.tabId,
session.cwd,
session.cols,
session.rows,
);
await session.historyWriter.init();
}
}

getSession(tabId: string): {
isAlive: boolean;
cwd: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,18 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
const writeMutation = trpc.terminal.write.useMutation();
const resizeMutation = trpc.terminal.resize.useMutation();
const detachMutation = trpc.terminal.detach.useMutation();
const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation();

const createOrAttachRef = useRef(createOrAttachMutation.mutate);
const writeRef = useRef(writeMutation.mutate);
const resizeRef = useRef(resizeMutation.mutate);
const detachRef = useRef(detachMutation.mutate);
const clearScrollbackRef = useRef(clearScrollbackMutation.mutate);
createOrAttachRef.current = createOrAttachMutation.mutate;
writeRef.current = writeMutation.mutate;
resizeRef.current = resizeMutation.mutate;
detachRef.current = detachMutation.mutate;
clearScrollbackRef.current = clearScrollbackMutation.mutate;

const parentTabIdRef = useRef(parentTabId);
parentTabIdRef.current = parentTabId;
Expand Down Expand Up @@ -302,6 +305,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
},
onClear: () => {
xterm.clear();
clearScrollbackRef.current({ tabId: paneId });
},
});

Expand All @@ -316,7 +320,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
resizeRef.current({ tabId: paneId, cols, rows });
},
);
// Setup paste handler to ensure bracketed paste mode works for TUI apps like opencode
const cleanupPaste = setupPasteHandler(xterm, {
onPaste: (text) => {
commandBufferRef.current += text;
Expand All @@ -333,7 +336,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
cleanupPaste();
cleanupQuerySuppression();
debouncedSetTabAutoTitleRef.current?.cancel?.();
// Detach instead of kill to keep PTY running for reattachment
detachRef.current({ tabId: paneId });
setSubscriptionEnabled(false);
xterm.dispose();
Expand Down
Loading