/ utils / fullscreen.ts
fullscreen.ts
  1  import { spawnSync } from 'child_process'
  2  import { getIsInteractive } from '../bootstrap/state.js'
  3  import { logForDebugging } from './debug.js'
  4  import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
  5  import { execFileNoThrow } from './execFileNoThrow.js'
  6  
  7  let loggedTmuxCcDisable = false
  8  let checkedTmuxMouseHint = false
  9  
 10  /**
 11   * Cached result from `tmux display-message -p '#{client_control_mode}'`.
 12   * undefined = not yet queried (or probe failed) — env heuristic stays authoritative.
 13   */
 14  let tmuxControlModeProbed: boolean | undefined
 15  
 16  /**
 17   * Env-var heuristic for iTerm2's tmux integration mode (`tmux -CC` / `tmux -2CC`).
 18   *
 19   * In `-CC` mode, iTerm2 renders tmux panes as native splits — tmux runs
 20   * as a server (TMUX is set) but iTerm2 is the actual terminal emulator
 21   * for each pane, so TERM_PROGRAM stays `iTerm.app` and TERM is iTerm2's
 22   * default (xterm-*). Contrast with regular tmux-inside-iTerm2, where tmux
 23   * overwrites TERM_PROGRAM to `tmux` and sets TERM to screen-* or tmux-*.
 24   *
 25   * This heuristic has known holes (SSH often doesn't propagate TERM_PROGRAM;
 26   * .tmux.conf can override TERM) — probeTmuxControlModeSync() is the
 27   * authoritative backstop. Kept as a zero-subprocess fast path.
 28   */
 29  function isTmuxControlModeEnvHeuristic(): boolean {
 30    if (!process.env.TMUX) return false
 31    if (process.env.TERM_PROGRAM !== 'iTerm.app') return false
 32    // Belt-and-suspenders: in regular tmux TERM is screen-* or tmux-*;
 33    // in -CC mode iTerm2 sets its own TERM (xterm-*).
 34    const term = process.env.TERM ?? ''
 35    return !term.startsWith('screen') && !term.startsWith('tmux')
 36  }
 37  
 38  /**
 39   * Sync one-shot probe: asks tmux directly whether this client is in control
 40   * mode via `#{client_control_mode}`. Runs on first isTmuxControlMode() call
 41   * when the env heuristic can't decide; result is cached.
 42   *
 43   * Sync (spawnSync) because the answer gates whether we enter fullscreen — an
 44   * async probe raced against React render and lost: coder-tmux (ssh → tmux -CC
 45   * on a remote box) doesn't propagate TERM_PROGRAM, so the env heuristic missed,
 46   * and by the time the async probe resolved we'd already entered alt-screen with
 47   * mouse tracking enabled. Mouse wheel is dead in iTerm2's -CC integration, so
 48   * users couldn't scroll at all.
 49   *
 50   * Cost: one ~5ms subprocess, only when $TMUX is set AND $TERM_PROGRAM is unset
 51   * (the SSH-into-tmux case). Local iTerm2 -CC and non-tmux paths skip the spawn.
 52   *
 53   * The TMUX env check MUST come first — without it, display-message would
 54   * query whatever tmux server happens to be running rather than our client.
 55   */
 56  function probeTmuxControlModeSync(): void {
 57    // Seed cache with heuristic result so early returns below don't leave it
 58    // undefined — isTmuxControlMode() is called 15+ times per render, and an
 59    // undefined cache would re-enter this function (re-spawning tmux in the
 60    // failure case) on every call.
 61    tmuxControlModeProbed = isTmuxControlModeEnvHeuristic()
 62    if (tmuxControlModeProbed) return
 63    if (!process.env.TMUX) return
 64    // Only probe when iTerm might be involved: TERM_PROGRAM is iTerm.app
 65    // (covered above) or not set (SSH often doesn't propagate it). When
 66    // TERM_PROGRAM is explicitly a non-iTerm terminal, skip — tmux -CC is
 67    // an iTerm-only feature, so the subprocess would be wasted.
 68    if (process.env.TERM_PROGRAM) return
 69    let result
 70    try {
 71      result = spawnSync(
 72        'tmux',
 73        ['display-message', '-p', '#{client_control_mode}'],
 74        { encoding: 'utf8', timeout: 2000 },
 75      )
 76    } catch {
 77      // spawnSync can throw on some platforms (e.g. ENOENT on Windows if tmux
 78      // is absent and the runtime surfaces it as an exception rather than in
 79      // result.error). Treat the same as a non-zero exit.
 80      return
 81    }
 82    // Non-zero exit / spawn error: tmux too old (format var added in 2.4) or
 83    // unavailable. Keep the heuristic result cached.
 84    if (result.status !== 0) return
 85    tmuxControlModeProbed = result.stdout.trim() === '1'
 86  }
 87  
 88  /**
 89   * True when running under `tmux -CC` (iTerm2 integration mode).
 90   *
 91   * The alt-screen / mouse-tracking path in fullscreen mode is unrecoverable
 92   * in -CC mode (double-click corrupts terminal state; mouse wheel is dead),
 93   * so callers auto-disable fullscreen.
 94   *
 95   * Lazily probes tmux on first call when the env heuristic can't decide.
 96   */
 97  export function isTmuxControlMode(): boolean {
 98    if (tmuxControlModeProbed === undefined) probeTmuxControlModeSync()
 99    return tmuxControlModeProbed ?? false
100  }
101  
102  export function _resetTmuxControlModeProbeForTesting(): void {
103    tmuxControlModeProbed = undefined
104    loggedTmuxCcDisable = false
105  }
106  
107  /**
108   * Runtime env-var check only. Ants default to on (CLAUDE_CODE_NO_FLICKER=0
109   * to opt out); external users default to off (CLAUDE_CODE_NO_FLICKER=1 to
110   * opt in).
111   */
112  export function isFullscreenEnvEnabled(): boolean {
113    // Explicit user opt-out always wins.
114    if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_NO_FLICKER)) return false
115    // Explicit opt-in overrides auto-detection (escape hatch).
116    if (isEnvTruthy(process.env.CLAUDE_CODE_NO_FLICKER)) return true
117    // Auto-disable under tmux -CC: alt-screen + mouse tracking corrupts
118    // terminal state on double-click and mouse wheel is dead.
119    if (isTmuxControlMode()) {
120      if (!loggedTmuxCcDisable) {
121        loggedTmuxCcDisable = true
122        logForDebugging(
123          'fullscreen disabled: tmux -CC (iTerm2 integration mode) detected · set CLAUDE_CODE_NO_FLICKER=1 to override',
124        )
125      }
126      return false
127    }
128    return process.env.USER_TYPE === 'ant'
129  }
130  
131  /**
132   * Whether fullscreen mode should enable SGR mouse tracking (DEC 1000/1002/1006).
133   * Set CLAUDE_CODE_DISABLE_MOUSE=1 to keep alt-screen + virtualized scroll
134   * (keyboard PgUp/PgDn/Ctrl+Home/End still work) but skip mouse capture,
135   * so tmux/kitty/terminal-native copy-on-select keeps working.
136   *
137   * Compare with CLAUDE_CODE_NO_FLICKER=0 which is all-or-nothing — it also
138   * disables alt-screen and virtualized scrollback.
139   */
140  export function isMouseTrackingEnabled(): boolean {
141    return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MOUSE)
142  }
143  
144  /**
145   * Whether mouse click handling is disabled (clicks/drags ignored, wheel still
146   * works). Set CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1 to prevent accidental clicks
147   * from triggering cursor positioning, text selection, or message expansion.
148   *
149   * Fullscreen-specific — only reachable when CLAUDE_CODE_NO_FLICKER is active.
150   */
151  export function isMouseClicksDisabled(): boolean {
152    return isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MOUSE_CLICKS)
153  }
154  
155  /**
156   * True when the fullscreen alt-screen layout is actually rendering —
157   * requires an interactive REPL session AND the env var not explicitly
158   * set falsy. Headless paths (--print, SDK, in-process teammates) never
159   * enter fullscreen, so features that depend on alt-screen re-rendering
160   * should gate on this.
161   */
162  export function isFullscreenActive(): boolean {
163    return getIsInteractive() && isFullscreenEnvEnabled()
164  }
165  
166  /**
167   * One-time hint for tmux users in fullscreen with `mouse off`.
168   *
169   * tmux's `mouse` option is session-scoped by design — there is no
170   * pane-level equivalent. We used to `tmux set mouse on` when entering
171   * alt-screen so wheel scrolling worked, but that changed mouse behavior
172   * for every sibling pane (vim, less, shell) and leaked on kill-pane or
173   * when multiple CC instances raced on restore. Now we leave tmux state
174   * alone — same as vim/less/htop — and just tell the user their options.
175   *
176   * Fire-and-forget from REPL startup. Returns the hint text once per
177   * session if TMUX is set, fullscreen is active, and tmux's current
178   * `mouse` option is off; null otherwise.
179   */
180  export async function maybeGetTmuxMouseHint(): Promise<string | null> {
181    if (!process.env.TMUX) return null
182    // tmux -CC auto-disables fullscreen above, but belt-and-suspenders.
183    if (!isFullscreenActive() || isTmuxControlMode()) return null
184    if (checkedTmuxMouseHint) return null
185    checkedTmuxMouseHint = true
186    // -A includes inherited values: `show -v mouse` returns empty when the
187    // option is set globally (`set -g mouse on` in .tmux.conf) but not at
188    // session level — which is the common case. -A gives the effective value.
189    const { stdout, code } = await execFileNoThrow(
190      'tmux',
191      ['show', '-Av', 'mouse'],
192      { useCwd: false, timeout: 2000 },
193    )
194    if (code !== 0 || stdout.trim() === 'on') return null
195    return "tmux detected · scroll with PgUp/PgDn · or add 'set -g mouse on' to ~/.tmux.conf for wheel scroll"
196  }
197  
198  /** Test-only: reset module-level once-per-session flags. */
199  export function _resetForTesting(): void {
200    loggedTmuxCcDisable = false
201    checkedTmuxMouseHint = false
202  }