Skip to content

Commit abc7a36

Browse files
committed
fix(desktop): keep terminal rendering responsive
1 parent ec3f997 commit abc7a36

2 files changed

Lines changed: 51 additions & 13 deletions

File tree

  • apps
    • desktop/src/main/terminal
    • sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session

apps/desktop/src/main/terminal/session.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ const FLUSH_INTERVAL_MS = 8
4747
*/
4848
const PAUSE_HIGH_WATER_CHARS = 512 * 1024
4949

50+
/**
51+
* How far a retained buffer may overshoot its limit before being trimmed.
52+
*
53+
* Trimming to the exact limit means copying the entire buffer on every chunk
54+
* once it is full — a quarter of a megabyte per chunk, hundreds of times a
55+
* second under heavy output, on the process that also feeds the renderer.
56+
* That copying is what makes the panel stutter while something is printing.
57+
* Letting the buffer overshoot and trimming in one go amortizes it to a single
58+
* copy per slack-sized batch.
59+
*/
60+
const TRIM_SLACK_CHARS = 64_000
61+
5062
/** Control keys mapped to the bytes a terminal actually sends. */
5163
const CONTROL_KEY_BYTES: Record<TerminalControlKey, string> = {
5264
'ctrl-c': '\u0003',
@@ -534,7 +546,7 @@ export class TerminalSession {
534546
this.trackAltScreen(text)
535547
this.emulator.write(text)
536548
this.scrollback += text
537-
if (this.scrollback.length > MAX_SCROLLBACK_CHARS) {
549+
if (this.scrollback.length > MAX_SCROLLBACK_CHARS + TRIM_SLACK_CHARS) {
538550
this.scrollback = this.scrollback.slice(-MAX_SCROLLBACK_CHARS)
539551
}
540552
if (this.pendingCommand?.capturing) {
@@ -614,7 +626,10 @@ export class TerminalSession {
614626
pending.output += text
615627
return
616628
}
617-
pending.overflow = (pending.overflow + text).slice(-MAX_CAPTURE_CHARS)
629+
pending.overflow += text
630+
if (pending.overflow.length > MAX_CAPTURE_CHARS + TRIM_SLACK_CHARS) {
631+
pending.overflow = pending.overflow.slice(-MAX_CAPTURE_CHARS)
632+
}
618633
}
619634

620635
/**

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ const DARK_THEME = {
7878
brightWhite: '#f0f6fc',
7979
}
8080

81+
/**
82+
* Loads the WebGL renderer and drops it if its context dies.
83+
*
84+
* WebGL is a large win on heavy output, but the context can be lost long after
85+
* it loads: a GPU process restart, a driver hiccup, the window moving between
86+
* GPUs, or simply too many live contexts — each terminal tab holds its own and
87+
* the browser silently drops the oldest past its limit. An addon left loaded
88+
* after that keeps painting a dead surface while the buffer moves on, which is
89+
* what makes rows freeze or tear mid-scroll. Disposing falls back to xterm's
90+
* DOM renderer: slower, but it cannot go stale.
91+
*
92+
* There is no canvas tier because `@xterm/addon-canvas` has no release for
93+
* xterm 6 — every published version, latest beta included, peers on xterm 5.
94+
*/
95+
function attachWebglRenderer(terminal: Terminal): void {
96+
try {
97+
const webgl = new WebglAddon()
98+
webgl.onContextLoss(() => webgl.dispose())
99+
terminal.loadAddon(webgl)
100+
} catch {
101+
// No usable WebGL on this machine; the DOM renderer stays in place.
102+
}
103+
}
104+
81105
/**
82106
* One terminal's xterm instance.
83107
*
@@ -116,13 +140,7 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
116140
terminal.unicode.activeVersion = '11'
117141

118142
terminal.open(host)
119-
// WebGL is a big win on heavy output but is unavailable in some GPU
120-
// configurations; the DOM renderer is a correct, slower fallback.
121-
try {
122-
terminal.loadAddon(new WebglAddon())
123-
} catch {
124-
// Canvas/DOM renderer remains in place.
125-
}
143+
attachWebglRenderer(terminal)
126144

127145
terminalRef.current = terminal
128146
fitRef.current = fit
@@ -151,9 +169,8 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
151169
// widths — which is the flickering, scrolling, newline-spewing mess. Only
152170
// the size the drag settles on is worth telling the PTY about.
153171
//
154-
// Hidden tabs are skipped because `invisible` keeps layout: without this
155-
// every background terminal would repaint through the same sequence, for a
156-
// resize nobody is watching. They refit when activated.
172+
// Hidden tabs are skipped because they measure 0x0, and fitting that would
173+
// resize their PTY to nonsense. They refit on activation.
157174
let resizeTimer: ReturnType<typeof setTimeout> | null = null
158175
const observer = new ResizeObserver(() => {
159176
if (resizeTimer) clearTimeout(resizeTimer)
@@ -205,7 +222,13 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
205222
return () => cancelAnimationFrame(frame)
206223
}, [active])
207224

208-
return <div ref={hostRef} className={cn('absolute inset-0 px-2 py-1', !active && 'invisible')} />
225+
// An inactive tab is `display: none`, not merely invisible. xterm watches its
226+
// element with an IntersectionObserver and pauses rendering once it stops
227+
// intersecting — which `visibility: hidden` never does, since it still
228+
// occupies its box. Left that way, every background terminal keeps painting
229+
// output nobody is looking at, out of the active terminal's frame budget.
230+
// xterm re-measures and does a full refresh when the element comes back.
231+
return <div ref={hostRef} className={cn('absolute inset-0 px-2 py-1', !active && 'hidden')} />
209232
}
210233

211234
/**

0 commit comments

Comments
 (0)