init claude-code
This commit is contained in:
+248
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user