249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import { coerce } from 'semver'
|
|
import type { Writable } from 'stream'
|
|
import { env } from '../utils/env.js'
|
|
import { gte } from '../utils/semver.js'
|
|
import { getClearTerminalSequence } from './clearTerminal.js'
|
|
import type { Diff } from './frame.js'
|
|
import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
|
|
import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js'
|
|
import { link } from './termio/osc.js'
|
|
|
|
export type Progress = {
|
|
state: 'running' | 'completed' | 'error' | 'indeterminate'
|
|
percentage?: number
|
|
}
|
|
|
|
/**
|
|
* Checks if the terminal supports OSC 9;4 progress reporting.
|
|
* Supported terminals:
|
|
* - ConEmu (Windows) - all versions
|
|
* - Ghostty 1.2.0+
|
|
* - iTerm2 3.6.6+
|
|
*
|
|
* Note: Windows Terminal interprets OSC 9;4 as notifications, not progress.
|
|
*/
|
|
export function isProgressReportingAvailable(): boolean {
|
|
// Only available if we have a TTY (not piped)
|
|
if (!process.stdout.isTTY) {
|
|
return false
|
|
}
|
|
|
|
// Explicitly exclude Windows Terminal, which interprets OSC 9;4 as
|
|
// notifications rather than progress indicators
|
|
if (process.env.WT_SESSION) {
|
|
return false
|
|
}
|
|
|
|
// ConEmu supports OSC 9;4 for progress (all versions)
|
|
if (
|
|
process.env.ConEmuANSI ||
|
|
process.env.ConEmuPID ||
|
|
process.env.ConEmuTask
|
|
) {
|
|
return true
|
|
}
|
|
|
|
const version = coerce(process.env.TERM_PROGRAM_VERSION)
|
|
if (!version) {
|
|
return false
|
|
}
|
|
|
|
// Ghostty 1.2.0+ supports OSC 9;4 for progress
|
|
// https://ghostty.org/docs/install/release-notes/1-2-0
|
|
if (process.env.TERM_PROGRAM === 'ghostty') {
|
|
return gte(version.version, '1.2.0')
|
|
}
|
|
|
|
// iTerm2 3.6.6+ supports OSC 9;4 for progress
|
|
// https://iterm2.com/downloads.html
|
|
if (process.env.TERM_PROGRAM === 'iTerm.app') {
|
|
return gte(version.version, '3.6.6')
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Checks if the terminal supports DEC mode 2026 (synchronized output).
|
|
* When supported, BSU/ESU sequences prevent visible flicker during redraws.
|
|
*/
|
|
export function isSynchronizedOutputSupported(): 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) return false
|
|
|
|
const termProgram = process.env.TERM_PROGRAM
|
|
const term = process.env.TERM
|
|
|
|
// Modern terminals with known DEC 2026 support
|
|
if (
|
|
termProgram === 'iTerm.app' ||
|
|
termProgram === 'WezTerm' ||
|
|
termProgram === 'WarpTerminal' ||
|
|
termProgram === 'ghostty' ||
|
|
termProgram === 'contour' ||
|
|
termProgram === 'vscode' ||
|
|
termProgram === 'alacritty'
|
|
) {
|
|
return true
|
|
}
|
|
|
|
// kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
|
|
if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
|
|
|
|
// Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM
|
|
if (term === 'xterm-ghostty') return true
|
|
|
|
// foot sets TERM=foot or TERM=foot-extra
|
|
if (term?.startsWith('foot')) return true
|
|
|
|
// Alacritty may set TERM containing 'alacritty'
|
|
if (term?.includes('alacritty')) return true
|
|
|
|
// Zed uses the alacritty_terminal crate which supports DEC 2026
|
|
if (process.env.ZED_TERM) return true
|
|
|
|
// Windows Terminal
|
|
if (process.env.WT_SESSION) return true
|
|
|
|
// VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
|
|
const vteVersion = process.env.VTE_VERSION
|
|
if (vteVersion) {
|
|
const version = parseInt(vteVersion, 10)
|
|
if (version >= 6800) return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// -- XTVERSION-detected terminal name (populated async at startup) --
|
|
//
|
|
// TERM_PROGRAM is not forwarded over SSH by default, so env-based detection
|
|
// fails when claude runs remotely inside a VS Code integrated terminal.
|
|
// XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query
|
|
// reaches the *client* terminal and the reply comes back through stdin.
|
|
// App.tsx fires the query when raw mode enables; setXtversionName() is called
|
|
// from the response handler. Readers should treat undefined as "not yet known"
|
|
// and fall back to env-var detection.
|
|
|
|
let xtversionName: string | undefined
|
|
|
|
/** Record the XTVERSION response. Called once from App.tsx when the reply
|
|
* arrives on stdin. No-op if already set (defend against re-probe). */
|
|
export function setXtversionName(name: string): void {
|
|
if (xtversionName === undefined) xtversionName = name
|
|
}
|
|
|
|
/** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
|
|
* integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
|
|
* not forwarded over SSH) with the XTVERSION probe result (async, survives
|
|
* SSH — query/reply goes through the pty). Early calls may miss the probe
|
|
* reply — call lazily (e.g. in an event handler) if SSH detection matters. */
|
|
export function isXtermJs(): boolean {
|
|
if (process.env.TERM_PROGRAM === 'vscode') return true
|
|
return xtversionName?.startsWith('xterm.js') ?? false
|
|
}
|
|
|
|
// Terminals known to correctly implement the Kitty keyboard protocol
|
|
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
|
|
// disambiguation. We previously enabled unconditionally (#23350), assuming
|
|
// terminals silently ignore unknown CSI — but some terminals honor the enable
|
|
// and emit codepoints our input parser doesn't handle (notably over SSH and
|
|
// in xterm.js-based terminals like VS Code). tmux is allowlisted because it
|
|
// accepts modifyOtherKeys and doesn't forward the kitty sequence to the outer
|
|
// terminal.
|
|
const EXTENDED_KEYS_TERMINALS = [
|
|
'iTerm.app',
|
|
'kitty',
|
|
'WezTerm',
|
|
'ghostty',
|
|
'tmux',
|
|
'windows-terminal',
|
|
]
|
|
|
|
/** True if this terminal correctly handles extended key reporting
|
|
* (Kitty keyboard protocol + xterm modifyOtherKeys). */
|
|
export function supportsExtendedKeys(): boolean {
|
|
return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
|
|
}
|
|
|
|
/** True if the terminal scrolls the viewport when it receives cursor-up
|
|
* sequences that reach above the visible area. On Windows, conhost's
|
|
* SetConsoleCursorPosition follows the cursor into scrollback
|
|
* (microsoft/terminal#14774), yanking users to the top of their buffer
|
|
* mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform
|
|
* is linux but output still routes through conhost. */
|
|
export function hasCursorUpViewportYankBug(): boolean {
|
|
return process.platform === 'win32' || !!process.env.WT_SESSION
|
|
}
|
|
|
|
// Computed once at module load — terminal capabilities don't change mid-session.
|
|
// Exported so callers can pass a sync-skip hint gated to specific modes.
|
|
export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
|
|
|
|
export type Terminal = {
|
|
stdout: Writable
|
|
stderr: Writable
|
|
}
|
|
|
|
export function writeDiffToTerminal(
|
|
terminal: Terminal,
|
|
diff: Diff,
|
|
skipSyncMarkers = false,
|
|
): void {
|
|
// No output if there are no patches
|
|
if (diff.length === 0) {
|
|
return
|
|
}
|
|
|
|
// BSU/ESU wrapping is opt-out to keep main-screen behavior unchanged.
|
|
// Callers pass skipSyncMarkers=true when the terminal doesn't support
|
|
// DEC 2026 (e.g. tmux) AND the cost matters (high-frequency alt-screen).
|
|
const useSync = !skipSyncMarkers
|
|
|
|
// Buffer all writes into a single string to avoid multiple write calls
|
|
let buffer = useSync ? BSU : ''
|
|
|
|
for (const patch of diff) {
|
|
switch (patch.type) {
|
|
case 'stdout':
|
|
buffer += patch.content
|
|
break
|
|
case 'clear':
|
|
if (patch.count > 0) {
|
|
buffer += eraseLines(patch.count)
|
|
}
|
|
break
|
|
case 'clearTerminal':
|
|
buffer += getClearTerminalSequence()
|
|
break
|
|
case 'cursorHide':
|
|
buffer += HIDE_CURSOR
|
|
break
|
|
case 'cursorShow':
|
|
buffer += SHOW_CURSOR
|
|
break
|
|
case 'cursorMove':
|
|
buffer += cursorMove(patch.x, patch.y)
|
|
break
|
|
case 'cursorTo':
|
|
buffer += cursorTo(patch.col)
|
|
break
|
|
case 'carriageReturn':
|
|
buffer += '\r'
|
|
break
|
|
case 'hyperlink':
|
|
buffer += link(patch.uri)
|
|
break
|
|
case 'styleStr':
|
|
buffer += patch.str
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add synchronized update end and flush buffer
|
|
if (useSync) buffer += ESU
|
|
terminal.stdout.write(buffer)
|
|
}
|