fix(tui): don't trust DEC 2026 synchronized output under Zellij
isSynchronizedOutputSupported() only excluded tmux, so running inside Zellij under an outer terminal that advertises DEC 2026 (e.g. WezTerm via TERM_PROGRAM) returned true. Zellij, like tmux, sits between us and the outer terminal and chunks the stream, breaking BSU/ESU atomicity and pushing old TUI frames into scrollback as repeated output. Guard on the ZELLIJ env var (set to the session index, e.g. "0") the same way we already guard on TMUX. Also thread an optional env argument through the function so the behavior is unit-testable, mirroring needsAltScreenResizeScrollbackClear() in the same module. Closes #66490
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { needsAltScreenResizeScrollbackClear } from './terminal.js'
|
||||
import { isSynchronizedOutputSupported, needsAltScreenResizeScrollbackClear } from './terminal.js'
|
||||
|
||||
describe('terminal resize quirks', () => {
|
||||
it('uses a deeper alt-screen resize clear for Apple Terminal', () => {
|
||||
@@ -13,3 +13,26 @@ describe('terminal resize quirks', () => {
|
||||
expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'iTerm.app' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('synchronized output detection', () => {
|
||||
it('does not trust an outer terminal DEC 2026 capability under Zellij', () => {
|
||||
// Zellij (like tmux) proxies/chunks the stream, so the outer WezTerm's
|
||||
// DEC 2026 support must not be trusted. Zellij sets ZELLIJ to the session
|
||||
// index — "0" for the first session — so the guard keys on presence.
|
||||
expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', ZELLIJ: '0' })).toBe(false)
|
||||
expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', ZELLIJ: '1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not trust an outer terminal DEC 2026 capability under tmux', () => {
|
||||
expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', TMUX: '/tmp/tmux-1/default,1,0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('still reports support for a DEC 2026 terminal with no multiplexer', () => {
|
||||
expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm' })).toBe(true)
|
||||
expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'iTerm.app' })).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no support for an unknown terminal', () => {
|
||||
expect(isSynchronizedOutputSupported({ TERM: 'xterm-256color' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,16 +67,26 @@ export function isProgressReportingAvailable(): boolean {
|
||||
* Checks if the terminal supports DEC mode 2026 (synchronized output).
|
||||
* When supported, BSU/ESU sequences prevent visible flicker during redraws.
|
||||
*/
|
||||
export function isSynchronizedOutputSupported(): boolean {
|
||||
export function isSynchronizedOutputSupported(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
// tmux parses and proxies every byte but doesn't implement DEC 2026.
|
||||
// BSU/ESU pass through to the outer terminal but tmux has already
|
||||
// broken atomicity by chunking. Skip to save 16 bytes/frame + parser work.
|
||||
if (process.env.TMUX) {
|
||||
if (env.TMUX) {
|
||||
return false
|
||||
}
|
||||
|
||||
const termProgram = process.env.TERM_PROGRAM
|
||||
const term = process.env.TERM
|
||||
// Zellij is the same class of hazard as tmux: it sits between us and the
|
||||
// outer terminal, parsing/proxying (and chunking) the stream, so we can't
|
||||
// trust the outer terminal's DEC 2026 support advertised via TERM_PROGRAM
|
||||
// (e.g. WezTerm). Trusting it wraps frames in BSU/ESU that Zellij has
|
||||
// already broken atomicity on, repeating old frames into scrollback.
|
||||
// Zellij sets ZELLIJ to the session index (e.g. "0"), so guard on presence.
|
||||
if (env.ZELLIJ) {
|
||||
return false
|
||||
}
|
||||
|
||||
const termProgram = env.TERM_PROGRAM
|
||||
const term = env.TERM
|
||||
|
||||
// Modern terminals with known DEC 2026 support
|
||||
if (
|
||||
@@ -92,7 +102,7 @@ export function isSynchronizedOutputSupported(): boolean {
|
||||
}
|
||||
|
||||
// kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
|
||||
if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) {
|
||||
if (term?.includes('kitty') || env.KITTY_WINDOW_ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,17 +122,17 @@ export function isSynchronizedOutputSupported(): boolean {
|
||||
}
|
||||
|
||||
// Zed uses the alacritty_terminal crate which supports DEC 2026
|
||||
if (process.env.ZED_TERM) {
|
||||
if (env.ZED_TERM) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Terminal
|
||||
if (process.env.WT_SESSION) {
|
||||
if (env.WT_SESSION) {
|
||||
return true
|
||||
}
|
||||
|
||||
// VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
|
||||
const vteVersion = process.env.VTE_VERSION
|
||||
const vteVersion = env.VTE_VERSION
|
||||
|
||||
if (vteVersion) {
|
||||
const version = parseInt(vteVersion, 10)
|
||||
|
||||
Reference in New Issue
Block a user