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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ superset-dev-data/
test-conflict-repo/
.amp/*

# Crush project context
.crush/

# Claude Code session lock (runtime artifact)
.claude/scheduled_tasks.lock
temp/
2 changes: 1 addition & 1 deletion apps/desktop/src/main/terminal-host/pty-subprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const INPUT_QUEUE_HARD_LIMIT_BYTES = 64 * 1024 * 1024; // 64MB
let outputChunks: string[] = [];
let outputBytesQueued = 0;
let outputFlushScheduled = false;
const OUTPUT_FLUSH_INTERVAL_MS = 16; // Match terminal-style frame batching (~60fps)
const OUTPUT_FLUSH_INTERVAL_MS = 0;
const MAX_OUTPUT_BATCH_SIZE_BYTES = 128 * 1024; // 128KB max per flush

// Backpressure - track if stdout is draining
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const SHELL_READY_TIMEOUT_MS = 15_000;
*
* Disable by setting SUPERSET_TERMINAL_BROADCAST_COALESCE=0.
*/
const BROADCAST_COALESCE_INTERVAL_MS = 16;
const BROADCAST_COALESCE_INTERVAL_MS = 0;
const BROADCAST_COALESCE_MAX_BYTES = 131_072;
const BROADCAST_COALESCE_ENABLED =
process.env.SUPERSET_TERMINAL_BROADCAST_COALESCE !== "0";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SessionKilledOverlay } from "./components";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
TERMINAL_OPTIONS,
} from "./config";
import { getDefaultTerminalBg } from "./helpers";
import {
Expand Down Expand Up @@ -521,8 +522,16 @@ export const Terminal = memo(function Terminal({

useEffect(() => {
const xterm = xtermRef.current;
if (!xterm || !terminalTheme) return;
xterm.options.theme = terminalTheme;
if (!xterm) return;
xterm.options.vtExtensions = { ...TERMINAL_OPTIONS.vtExtensions };
xterm.options.scrollOnEraseInDisplay =
TERMINAL_OPTIONS.scrollOnEraseInDisplay;
xterm.options.macOptionIsMeta = TERMINAL_OPTIONS.macOptionIsMeta;
xterm.options.cursorStyle = TERMINAL_OPTIONS.cursorStyle;
xterm.options.cursorInactiveStyle = TERMINAL_OPTIONS.cursorInactiveStyle;
if (terminalTheme) {
xterm.options.theme = terminalTheme;
}
}, [terminalTheme]);

const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery(
Expand Down Expand Up @@ -581,7 +590,7 @@ export const Terminal = memo(function Terminal({
return (
<div
role="application"
className="relative h-full w-full overflow-hidden"
className="relative h-full w-full min-h-0 min-w-0 overflow-hidden"
style={{ backgroundColor: terminalBg }}
onDragOver={handleDragOver}
onDrop={handleDrop}
Expand All @@ -598,8 +607,11 @@ export const Terminal = memo(function Terminal({
!isWorkspaceRunPane && (
<SessionKilledOverlay onRestart={restartTerminal} />
)}
<div className="h-full w-full p-2">
<div ref={terminalRef} className="h-full w-full" />
<div className="h-full w-full min-h-0 min-w-0 overflow-hidden p-2">
<div
ref={terminalRef}
className="h-full w-full min-h-0 min-w-0 overflow-hidden"
/>
</div>
{xtermInstance && typingPreviewText && (
<TerminalTypingPreview xterm={xtermInstance} text={typingPreviewText} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const TERMINAL_OPTIONS: ITerminalOptions = {
allowTransparency: true,
allowProposedApi: true,
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
scrollOnEraseInDisplay: true,
// Allow Option+key to type special characters on international keyboards (e.g., Option+2 = @)
macOptionIsMeta: false,
cursorStyle: "block",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon: SearchAddon;
wrapper: HTMLDivElement;
linkManager: TerminalLinkManager;
openOnce: () => void;
cleanup: () => void;
} {
const {
Expand All @@ -120,13 +121,18 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
const imageAddon = new ImageAddon();

let disposed = false;
let opened = false;
let webglAddon: WebglAddon | null = null;
let webglRafId: number | null = null;

// Open into a detached wrapper div — not the live container.
// Create a detached wrapper div. xterm.open() is deferred until the wrapper
// is attached to a live DOM container.
const wrapper = document.createElement("div");
wrapper.style.width = "100%";
wrapper.style.height = "100%";
xterm.open(wrapper);
wrapper.style.minWidth = "0";
wrapper.style.minHeight = "0";
wrapper.style.overflow = "hidden";

xterm.loadAddon(fitAddon);
xterm.loadAddon(searchAddon);
Expand All @@ -140,39 +146,46 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
// Ligatures not supported by current font
}

// Defer WebGL to rAF — same pattern as v2 terminal-addons.ts.
const rafId = requestAnimationFrame(() => {
if (disposed || suggestedRendererType === "dom") return;
const openOnce = () => {
if (disposed || opened) return;
opened = true;
xterm.open(wrapper);

try {
webglAddon = new WebglAddon();
terminalRendererDebug.info(
"webgl-addon-loaded",
{ suggestedRendererType: suggestedRendererType ?? "auto" },
{
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-loaded"],
},
);
webglAddon.onContextLoss(() => {
webglAddon?.dispose();
// Defer WebGL until after open() so renderer initialization sees a live DOM node.
webglRafId = requestAnimationFrame(() => {
webglRafId = null;
if (disposed || suggestedRendererType === "dom") return;

try {
webglAddon = new WebglAddon();
terminalRendererDebug.info(
"webgl-addon-loaded",
{ suggestedRendererType: suggestedRendererType ?? "auto" },
{
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-loaded"],
},
);
webglAddon.onContextLoss(() => {
webglAddon?.dispose();
webglAddon = null;
terminalRendererDebug.warn("webgl-context-lost", undefined, {
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-context-lost"],
});
xterm.refresh(0, xterm.rows - 1);
});
xterm.loadAddon(webglAddon);
} catch {
suggestedRendererType = "dom";
webglAddon = null;
terminalRendererDebug.warn("webgl-context-lost", undefined, {
terminalRendererDebug.warn("webgl-addon-fallback-dom", undefined, {
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-context-lost"],
fingerprint: ["terminal.renderer", "webgl-fallback-dom"],
});
xterm.refresh(0, xterm.rows - 1);
});
xterm.loadAddon(webglAddon);
} catch {
suggestedRendererType = "dom";
webglAddon = null;
terminalRendererDebug.warn("webgl-addon-fallback-dom", undefined, {
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-fallback-dom"],
});
}
});
}
});
};

const cleanupQuerySuppression = suppressQueryResponses(xterm);

Expand Down Expand Up @@ -233,9 +246,12 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon,
wrapper,
linkManager,
openOnce,
cleanup: () => {
disposed = true;
cancelAnimationFrame(rafId);
if (webglRafId !== null) {
cancelAnimationFrame(webglRafId);
}
cleanupQuerySuppression();
linkManager.dispose();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run";
import { DEBUG_TERMINAL } from "../config";
import { logTerminalWrite, terminalRendererDebug } from "../debug";
import type { TerminalExitReason, TerminalStreamEvent } from "../types";
import { flushWrite, scheduleWrite } from "../v1-terminal-cache";

export interface UseTerminalStreamOptions {
paneId: string;
Expand Down Expand Up @@ -190,21 +189,17 @@ export function useTerminalStream({
terminalRendererDebug.observe("stream-data-bytes", event.data.length, {
data: { paneId },
});

updateModesRef.current(event.data);
logTerminalWrite("stream-data", event.data.length, { paneId });
scheduleWrite(paneId, event.data);
xterm.write(event.data);
updateCwdRef.current(event.data);
} else if (event.type === "exit") {
flushWrite(paneId);
handleTerminalExit(event.exitCode, xterm, event.reason);
} else if (event.type === "disconnect") {
flushWrite(paneId);
setConnectionError(
event.reason || "Connection to terminal daemon lost",
);
} else if (event.type === "error") {
flushWrite(paneId);
handleStreamError(event, xterm);
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import type { Terminal } from "@xterm/xterm";

/**
* Registers parser hooks to suppress terminal query responses from being displayed.
* Registers parser hooks to suppress terminal query responses and queries
* on the renderer's xterm instance.
*
* These handlers intercept specific response-only sequences that should not appear
* as visible text. We only suppress sequences where the response has a DIFFERENT
* format than the query, ensuring we don't break terminal functionality.
* In the desktop terminal architecture, both the daemon-side headless emulator
* and the renderer's xterm process the same PTY output stream. If the renderer
* is allowed to answer terminal queries (DA/DSR/OSC color queries), those
* responses are forwarded back into the PTY and interactive CLIs can receive
* duplicate escape-sequence data.
*
* SAFE to suppress (response-only, query uses different format):
* - CSI R: CPR response (query is CSI 6n)
* - CSI I/O: Focus reports (no query, just mode enable)
* - CSI $y: Mode report (query is CSI $p)
*
* NOT suppressed (would break queries/commands):
* - CSI c: DA query AND response both end in 'c'
* - CSI t: Window query AND response both end in 't'
* - OSC colors: Set command AND response have same format
* We suppress:
* 1. Terminal queries, so the renderer does not generate responses
* 2. Response-only sequences, so echoed responses do not render as garbage
*
* @param terminal - The xterm.js Terminal instance
* @returns Cleanup function to dispose all registered handlers
Expand All @@ -24,22 +21,50 @@ export function suppressQueryResponses(terminal: Terminal): () => void {
const disposables: { dispose: () => void }[] = [];
const parser = terminal.parser;

// CSI sequences ending in 'R' - Cursor Position Report (SAFE)
// Query: ESC[6n (ends in 'n'), Response: ESC[24;1R (ends in 'R')
// Different final bytes, so suppressing 'R' only catches responses
// =========================================================================
// Suppress terminal QUERIES — prevents the renderer from generating a reply.
// The daemon-side headless emulator remains the single source of truth.
// =========================================================================

// DA1 (primary device attributes): CSI c / CSI 0 c
disposables.push(parser.registerCsiHandler({ final: "c" }, () => true));

// DA2 (secondary device attributes): CSI > c
disposables.push(
parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true),
);

// DA3 (tertiary device attributes): CSI = c
disposables.push(
parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true),
);

// DSR queries: CSI n / CSI ? n
disposables.push(parser.registerCsiHandler({ final: "n" }, () => true));
disposables.push(
parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true),
);

// OSC color queries — only suppress actual queries, not set operations.
disposables.push(parser.registerOscHandler(4, (data) => data.includes("?")));
disposables.push(parser.registerOscHandler(10, (data) => data === "?"));
disposables.push(parser.registerOscHandler(11, (data) => data === "?"));
disposables.push(parser.registerOscHandler(12, (data) => data === "?"));

// =========================================================================
// Suppress RESPONSE-ONLY sequences — prevents echoed responses from rendering.
// =========================================================================

// CSI R: Cursor Position Report response (query is CSI 6n)
disposables.push(parser.registerCsiHandler({ final: "R" }, () => true));

// CSI sequences ending in 'I' - Focus In report (SAFE)
// No query - this is sent when terminal gains focus (mode 1004)
// CSI I: Focus In report
disposables.push(parser.registerCsiHandler({ final: "I" }, () => true));

// CSI sequences ending in 'O' - Focus Out report (SAFE)
// No query - this is sent when terminal loses focus (mode 1004)
// CSI O: Focus Out report
disposables.push(parser.registerCsiHandler({ final: "O" }, () => true));

// CSI sequences ending in 'y' with '$' intermediate - Mode Reports (SAFE)
// Query: ESC[?Ps$p (ends in 'p'), Response: ESC[?Ps;Pm$y (ends in 'y')
// Different final bytes, so suppressing '$y' only catches responses
// CSI $y: Mode report response (query is CSI $p)
disposables.push(
parser.registerCsiHandler({ intermediates: "$", final: "y" }, () => true),
);
Expand Down
Loading
Loading