/ ink / terminal.ts
terminal.ts
  1  import { coerce } from 'semver'
  2  import type { Writable } from 'stream'
  3  import { env } from '../utils/env.js'
  4  import { gte } from '../utils/semver.js'
  5  import { getClearTerminalSequence } from './clearTerminal.js'
  6  import type { Diff } from './frame.js'
  7  import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
  8  import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js'
  9  import { link } from './termio/osc.js'
 10  
 11  export type Progress = {
 12    state: 'running' | 'completed' | 'error' | 'indeterminate'
 13    percentage?: number
 14  }
 15  
 16  /**
 17   * Checks if the terminal supports OSC 9;4 progress reporting.
 18   * Supported terminals:
 19   * - ConEmu (Windows) - all versions
 20   * - Ghostty 1.2.0+
 21   * - iTerm2 3.6.6+
 22   *
 23   * Note: Windows Terminal interprets OSC 9;4 as notifications, not progress.
 24   */
 25  export function isProgressReportingAvailable(): boolean {
 26    // Only available if we have a TTY (not piped)
 27    if (!process.stdout.isTTY) {
 28      return false
 29    }
 30  
 31    // Explicitly exclude Windows Terminal, which interprets OSC 9;4 as
 32    // notifications rather than progress indicators
 33    if (process.env.WT_SESSION) {
 34      return false
 35    }
 36  
 37    // ConEmu supports OSC 9;4 for progress (all versions)
 38    if (
 39      process.env.ConEmuANSI ||
 40      process.env.ConEmuPID ||
 41      process.env.ConEmuTask
 42    ) {
 43      return true
 44    }
 45  
 46    const version = coerce(process.env.TERM_PROGRAM_VERSION)
 47    if (!version) {
 48      return false
 49    }
 50  
 51    // Ghostty 1.2.0+ supports OSC 9;4 for progress
 52    // https://ghostty.org/docs/install/release-notes/1-2-0
 53    if (process.env.TERM_PROGRAM === 'ghostty') {
 54      return gte(version.version, '1.2.0')
 55    }
 56  
 57    // iTerm2 3.6.6+ supports OSC 9;4 for progress
 58    // https://iterm2.com/downloads.html
 59    if (process.env.TERM_PROGRAM === 'iTerm.app') {
 60      return gte(version.version, '3.6.6')
 61    }
 62  
 63    return false
 64  }
 65  
 66  /**
 67   * Checks if the terminal supports DEC mode 2026 (synchronized output).
 68   * When supported, BSU/ESU sequences prevent visible flicker during redraws.
 69   */
 70  export function isSynchronizedOutputSupported(): boolean {
 71    // tmux parses and proxies every byte but doesn't implement DEC 2026.
 72    // BSU/ESU pass through to the outer terminal but tmux has already
 73    // broken atomicity by chunking. Skip to save 16 bytes/frame + parser work.
 74    if (process.env.TMUX) return false
 75  
 76    const termProgram = process.env.TERM_PROGRAM
 77    const term = process.env.TERM
 78  
 79    // Modern terminals with known DEC 2026 support
 80    if (
 81      termProgram === 'iTerm.app' ||
 82      termProgram === 'WezTerm' ||
 83      termProgram === 'WarpTerminal' ||
 84      termProgram === 'ghostty' ||
 85      termProgram === 'contour' ||
 86      termProgram === 'vscode' ||
 87      termProgram === 'alacritty'
 88    ) {
 89      return true
 90    }
 91  
 92    // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
 93    if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
 94  
 95    // Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM
 96    if (term === 'xterm-ghostty') return true
 97  
 98    // foot sets TERM=foot or TERM=foot-extra
 99    if (term?.startsWith('foot')) return true
100  
101    // Alacritty may set TERM containing 'alacritty'
102    if (term?.includes('alacritty')) return true
103  
104    // Zed uses the alacritty_terminal crate which supports DEC 2026
105    if (process.env.ZED_TERM) return true
106  
107    // Windows Terminal
108    if (process.env.WT_SESSION) return true
109  
110    // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
111    const vteVersion = process.env.VTE_VERSION
112    if (vteVersion) {
113      const version = parseInt(vteVersion, 10)
114      if (version >= 6800) return true
115    }
116  
117    return false
118  }
119  
120  // -- XTVERSION-detected terminal name (populated async at startup) --
121  //
122  // TERM_PROGRAM is not forwarded over SSH by default, so env-based detection
123  // fails when claude runs remotely inside a VS Code integrated terminal.
124  // XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query
125  // reaches the *client* terminal and the reply comes back through stdin.
126  // App.tsx fires the query when raw mode enables; setXtversionName() is called
127  // from the response handler. Readers should treat undefined as "not yet known"
128  // and fall back to env-var detection.
129  
130  let xtversionName: string | undefined
131  
132  /** Record the XTVERSION response. Called once from App.tsx when the reply
133   *  arrives on stdin. No-op if already set (defend against re-probe). */
134  export function setXtversionName(name: string): void {
135    if (xtversionName === undefined) xtversionName = name
136  }
137  
138  /** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
139   *  integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
140   *  not forwarded over SSH) with the XTVERSION probe result (async, survives
141   *  SSH — query/reply goes through the pty). Early calls may miss the probe
142   *  reply — call lazily (e.g. in an event handler) if SSH detection matters. */
143  export function isXtermJs(): boolean {
144    if (process.env.TERM_PROGRAM === 'vscode') return true
145    return xtversionName?.startsWith('xterm.js') ?? false
146  }
147  
148  // Terminals known to correctly implement the Kitty keyboard protocol
149  // (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
150  // disambiguation. We previously enabled unconditionally (#23350), assuming
151  // terminals silently ignore unknown CSI — but some terminals honor the enable
152  // and emit codepoints our input parser doesn't handle (notably over SSH and
153  // in xterm.js-based terminals like VS Code). tmux is allowlisted because it
154  // accepts modifyOtherKeys and doesn't forward the kitty sequence to the outer
155  // terminal.
156  const EXTENDED_KEYS_TERMINALS = [
157    'iTerm.app',
158    'kitty',
159    'WezTerm',
160    'ghostty',
161    'tmux',
162    'windows-terminal',
163  ]
164  
165  /** True if this terminal correctly handles extended key reporting
166   *  (Kitty keyboard protocol + xterm modifyOtherKeys). */
167  export function supportsExtendedKeys(): boolean {
168    return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
169  }
170  
171  /** True if the terminal scrolls the viewport when it receives cursor-up
172   *  sequences that reach above the visible area. On Windows, conhost's
173   *  SetConsoleCursorPosition follows the cursor into scrollback
174   *  (microsoft/terminal#14774), yanking users to the top of their buffer
175   *  mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform
176   *  is linux but output still routes through conhost. */
177  export function hasCursorUpViewportYankBug(): boolean {
178    return process.platform === 'win32' || !!process.env.WT_SESSION
179  }
180  
181  // Computed once at module load — terminal capabilities don't change mid-session.
182  // Exported so callers can pass a sync-skip hint gated to specific modes.
183  export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
184  
185  export type Terminal = {
186    stdout: Writable
187    stderr: Writable
188  }
189  
190  export function writeDiffToTerminal(
191    terminal: Terminal,
192    diff: Diff,
193    skipSyncMarkers = false,
194  ): void {
195    // No output if there are no patches
196    if (diff.length === 0) {
197      return
198    }
199  
200    // BSU/ESU wrapping is opt-out to keep main-screen behavior unchanged.
201    // Callers pass skipSyncMarkers=true when the terminal doesn't support
202    // DEC 2026 (e.g. tmux) AND the cost matters (high-frequency alt-screen).
203    const useSync = !skipSyncMarkers
204  
205    // Buffer all writes into a single string to avoid multiple write calls
206    let buffer = useSync ? BSU : ''
207  
208    for (const patch of diff) {
209      switch (patch.type) {
210        case 'stdout':
211          buffer += patch.content
212          break
213        case 'clear':
214          if (patch.count > 0) {
215            buffer += eraseLines(patch.count)
216          }
217          break
218        case 'clearTerminal':
219          buffer += getClearTerminalSequence()
220          break
221        case 'cursorHide':
222          buffer += HIDE_CURSOR
223          break
224        case 'cursorShow':
225          buffer += SHOW_CURSOR
226          break
227        case 'cursorMove':
228          buffer += cursorMove(patch.x, patch.y)
229          break
230        case 'cursorTo':
231          buffer += cursorTo(patch.col)
232          break
233        case 'carriageReturn':
234          buffer += '\r'
235          break
236        case 'hyperlink':
237          buffer += link(patch.uri)
238          break
239        case 'styleStr':
240          buffer += patch.str
241          break
242      }
243    }
244  
245    // Add synchronized update end and flush buffer
246    if (useSync) buffer += ESU
247    terminal.stdout.write(buffer)
248  }