From b8dbdba7a2da94ac6ffb387f6f9f05341f1461ad Mon Sep 17 00:00:00 2001 From: daropotter <93242689+daropotter@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:32:23 +0200 Subject: [PATCH 1/4] feat(terminal-progress): add kitty support to terminal detection Detect kitty via KITTY_WINDOW_ID (set by kitty itself for every process it launches, so it works even without shell integration / TERM_PROGRAM) or TERM_PROGRAM=kitty. kitty has reported OSC 9;4 progress since 0.39 (percentage in tab title) and draws an in-window progress bar since 0.47. --- packages/terminal-progress/README.md | 4 ++++ packages/terminal-progress/package.json | 3 ++- packages/terminal-progress/src/tui.ts | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/terminal-progress/README.md b/packages/terminal-progress/README.md index 1465a4d..adfdb51 100644 --- a/packages/terminal-progress/README.md +++ b/packages/terminal-progress/README.md @@ -12,12 +12,16 @@ Requires OpenCode 2. For OpenCode 1, install the `opencode-v1` dist-tag | --------------------------------------------------------- | ----------------------------------------------------------- | | [Ghostty](https://ghostty.org) | `TERM_PROGRAM=ghostty` | | [iTerm2](https://iterm2.com) | `TERM_PROGRAM=iTerm.app`, `LC_TERMINAL`, `ITERM_SESSION_ID` | +| [kitty](https://sw.kovidgoyal.net/kitty/) | `KITTY_WINDOW_ID`, `TERM_PROGRAM=kitty` | | [WezTerm](https://wezfurlong.org/wezterm/) | `TERM_PROGRAM=WezTerm`, `WEZTERM_EXECUTABLE` | | [Windows Terminal](https://github.com/microsoft/terminal) | `WT_SESSION` | The plugin automatically detects which terminal is in use and becomes a no-op if none of the above are found. tmux passthrough is handled transparently when `$TMUX` is set. +kitty renders OSC 9;4 progress as a percentage in the tab title since 0.39 and draws an in-window +progress bar since 0.47 (see the `progress_bar` option in `kitty.conf`). + Set `OPENCODE_TERMINAL_PROGRESS=0` (or `false`/`no`) to disable progress reporting. ## Progress states diff --git a/packages/terminal-progress/package.json b/packages/terminal-progress/package.json index 61a3982..ee0fa40 100644 --- a/packages/terminal-progress/package.json +++ b/packages/terminal-progress/package.json @@ -1,7 +1,7 @@ { "name": "opencode-terminal-progress", "version": "1.0.2", - "description": "OpenCode CLI plugin that shows agent progress in terminal tabs (Ghostty, iTerm2, WezTerm, Windows Terminal)", + "description": "OpenCode CLI plugin that shows agent progress in terminal tabs (Ghostty, iTerm2, kitty, WezTerm, Windows Terminal)", "author": { "name": "Pedro Pombeiro", "email": "pedropombeiro@users.noreply.github.com" @@ -29,6 +29,7 @@ "terminal", "progress", "iterm2", + "kitty", "wezterm", "windows-terminal", "osc" diff --git a/packages/terminal-progress/src/tui.ts b/packages/terminal-progress/src/tui.ts index be89343..32b3d49 100644 --- a/packages/terminal-progress/src/tui.ts +++ b/packages/terminal-progress/src/tui.ts @@ -2,10 +2,13 @@ import { closeSync, openSync, writeSync } from 'node:fs'; import type { Plugin } from '@opencode/plugin/tui'; import { createAgentStateTracker, createViewFilter } from '../../_shared/src/index.ts'; -type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty'; +type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty' | 'kitty'; function detectTerminal(): Terminal | undefined { const env = process.env; + if (env['KITTY_WINDOW_ID'] || env['TERM_PROGRAM'] === 'kitty') { + return 'kitty'; + } if (env['TERM_PROGRAM'] === 'ghostty') { return 'ghostty'; } From 26f24ba97019af53fcacfc0fc9f89845886bc408 Mon Sep 17 00:00:00 2001 From: daropotter <93242689+daropotter@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:36:00 +0200 Subject: [PATCH 2/4] feat(terminal-progress): gate kitty support on 0.47+ kitty only renders an in-window progress bar since 0.47 (progress_bar option). Older versions show at most a percentage in the tab title (0.39-0.46), and before 0.38 OSC 9;4 was misread as a notification. Keep the plugin a no-op unless the running kitty is confirmed to be at least 0.47. Version is read from TERM_PROGRAM_VERSION when the shell integration exported it, otherwise on Linux by querying the running emulator binary via /proc//exe. --- packages/terminal-progress/README.md | 7 ++- packages/terminal-progress/src/tui.ts | 62 ++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/terminal-progress/README.md b/packages/terminal-progress/README.md index adfdb51..74e03db 100644 --- a/packages/terminal-progress/README.md +++ b/packages/terminal-progress/README.md @@ -19,8 +19,11 @@ Requires OpenCode 2. For OpenCode 1, install the `opencode-v1` dist-tag The plugin automatically detects which terminal is in use and becomes a no-op if none of the above are found. tmux passthrough is handled transparently when `$TMUX` is set. -kitty renders OSC 9;4 progress as a percentage in the tab title since 0.39 and draws an in-window -progress bar since 0.47 (see the `progress_bar` option in `kitty.conf`). +kitty is supported on **0.47+**: kitty draws an in-window progress bar since 0.47 (see the +`progress_bar` option in `kitty.conf`), while 0.39-0.46 only showed a percentage in the tab title +and versions before 0.38 treated OSC 9;4 as notifications. The plugin therefore stays inactive on +kitty unless it can confirm the version is at least 0.47, read from `TERM_PROGRAM_VERSION` or, on +Linux, from the running kitty binary via `/proc//exe`. Set `OPENCODE_TERMINAL_PROGRESS=0` (or `false`/`no`) to disable progress reporting. diff --git a/packages/terminal-progress/src/tui.ts b/packages/terminal-progress/src/tui.ts index 32b3d49..bc6a3e1 100644 --- a/packages/terminal-progress/src/tui.ts +++ b/packages/terminal-progress/src/tui.ts @@ -1,13 +1,63 @@ -import { closeSync, openSync, writeSync } from 'node:fs'; +import { closeSync, openSync, readlinkSync, writeSync } from 'node:fs'; import type { Plugin } from '@opencode/plugin/tui'; -import { createAgentStateTracker, createViewFilter } from '../../_shared/src/index.ts'; +import { createAgentStateTracker, createViewFilter, exec } from '../../_shared/src/index.ts'; type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty' | 'kitty'; -function detectTerminal(): Terminal | undefined { +// kitty renders an in-window progress bar only since 0.47 ("progress_bar" option). +// Older versions (0.39-0.46) only showed a percentage in the tab title, and before +// 0.38 OSC 9;4 was treated as a notification. We only enable kitty when we can +// confirm the version is at least this. +const KITTY_MIN_SUPPORTED = [0, 47, 0] as const; + +function parseKittyVersion(value: string): readonly number[] | undefined { + const match = value.match(/(?:^|\s)(\d+)\.(\d+)(?:\.(\d+))?/); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; +} + +async function detectKittyVersion(): Promise { + const env = process.env; + const programVersion = env['TERM_PROGRAM_VERSION']; + if (programVersion) { + const parsed = parseKittyVersion(programVersion); + if (parsed) return parsed; + } + // The shell integration may not be active, so fall back to querying the running + // kitty binary itself (Linux): /proc//exe points at the emulator that + // is actually writing to this terminal. + const kittyPid = env['KITTY_PID']; + if (kittyPid) { + try { + const exe = readlinkSync(`/proc/${kittyPid}/exe`); + const result = await exec(exe, ['--version']); + if (result.code === 0) { + const parsed = parseKittyVersion(result.stdout); + if (parsed) return parsed; + } + } catch { + // no /proc (e.g. macOS) or the process is gone + } + } + return undefined; +} + +function isSupportedKitty(version: readonly number[]): boolean { + for (let i = 0; i < KITTY_MIN_SUPPORTED.length; i++) { + if ((version[i] ?? 0) > KITTY_MIN_SUPPORTED[i]) return true; + if ((version[i] ?? 0) < KITTY_MIN_SUPPORTED[i]) return false; + } + return true; +} + +async function detectTerminal(): Promise { const env = process.env; if (env['KITTY_WINDOW_ID'] || env['TERM_PROGRAM'] === 'kitty') { - return 'kitty'; + const version = await detectKittyVersion(); + if (version && isSupportedKitty(version)) { + return 'kitty'; + } + return undefined; } if (env['TERM_PROGRAM'] === 'ghostty') { return 'ghostty'; @@ -62,10 +112,10 @@ function createOsc(): Osc | undefined { export default { id: 'opencode-terminal-progress', - setup(context) { + async setup(context) { const progressEnv = process.env['OPENCODE_TERMINAL_PROGRESS']; if (progressEnv && /^(0|false|no)$/i.test(progressEnv)) return; - if (!detectTerminal()) return; + if (!(await detectTerminal())) return; const osc = createOsc(); if (!osc) return; From f44f120d38b7adc7ca00d131e7174c2593f4c957 Mon Sep 17 00:00:00 2001 From: daropotter <93242689+daropotter@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:04:16 +0200 Subject: [PATCH 3/4] fix(terminal-progress): keep kitty progress report alive without changing visuals kitty clears any OSC 9;4 progress ~60 s after the last report, so a single report per state change made the indicator disappear during longer runs. Re-report the active state every 30 s while it lasts, but only on kitty and without changing what is reported: busy stays indeterminate (9;4;3), matching the canonical ConEmu/OSC 9;4 behaviour and the visuals of the other supported terminals (spinner/pulse on Windows Terminal, Ghostty, WezTerm, VTE, iTerm2). --- packages/terminal-progress/src/tui.ts | 35 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/terminal-progress/src/tui.ts b/packages/terminal-progress/src/tui.ts index bc6a3e1..76ac254 100644 --- a/packages/terminal-progress/src/tui.ts +++ b/packages/terminal-progress/src/tui.ts @@ -115,18 +115,42 @@ export default { async setup(context) { const progressEnv = process.env['OPENCODE_TERMINAL_PROGRESS']; if (progressEnv && /^(0|false|no)$/i.test(progressEnv)) return; - if (!(await detectTerminal())) return; + const terminal = await detectTerminal(); + if (!terminal) return; const osc = createOsc(); if (!osc) return; const progress = (code: string): void => osc.write(`9;4;${code}`); + const isKitty = terminal === 'kitty'; + + let stateTimer: ReturnType | undefined; + const clearStateTimer = (): void => { + if (stateTimer) { + clearInterval(stateTimer); + stateTimer = undefined; + } + }; + + const setState = (code: string): void => { + clearStateTimer(); + progress(code); + // kitty clears any progress ~60 s after the last OSC 9;4 report, so while a + // state is active we re-report it periodically to keep the indicator alive. + // Other terminals keep the progress state until told otherwise. + if (isKitty) { + stateTimer = setInterval(() => progress(code), 30_000); + } + }; const tracker = createAgentStateTracker({ - onWaiting: () => progress('4;50'), - onBusy: () => progress('3'), - onIdle: () => progress('0'), - onError: () => progress('2'), + onWaiting: () => setState('4;50'), + onBusy: () => setState('3'), + onIdle: () => { + clearStateTimer(); + progress('0'); + }, + onError: () => setState('2'), }); const shows = createViewFilter(context, tracker.tracks); @@ -136,6 +160,7 @@ export default { return () => { stop(); + clearStateTimer(); progress('0'); osc.close(); }; From 1c1c564a551d6e79a279c493a5890af97ef1f124 Mon Sep 17 00:00:00 2001 From: daropotter <93242689+daropotter@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:40:51 +0200 Subject: [PATCH 4/4] fix(terminal-progress): detect kitty without TERM_PROGRAM and resolve its real version kitty never sets TERM_PROGRAM or TERM_PROGRAM_VERSION, so the version was often read from whatever else set them (e.g. tmux 3.4 passing the 0.47 gate on an older kitty), and a KITTY_WINDOW_ID leaked into a nested Ghostty or WezTerm hijacked their detection. - Treat the host as kitty only when TERM_PROGRAM is unset, tmux or kitty. - Trust TERM_PROGRAM_VERSION only alongside TERM_PROGRAM=kitty. - Run /proc//exe directly (works after upgrades), only if it resolves to a kitty binary, with a 2 s timeout. - Fall back to the binary in KITTY_INSTALLATION_DIR (Linux and macOS). - Move detection into kitty.ts with unit tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/terminal-progress/README.md | 8 +- packages/terminal-progress/src/kitty.test.ts | 184 +++++++++++++++++++ packages/terminal-progress/src/kitty.ts | 84 +++++++++ packages/terminal-progress/src/tui.ts | 59 +----- 4 files changed, 278 insertions(+), 57 deletions(-) create mode 100644 packages/terminal-progress/src/kitty.test.ts create mode 100644 packages/terminal-progress/src/kitty.ts diff --git a/packages/terminal-progress/README.md b/packages/terminal-progress/README.md index 74e03db..c42447c 100644 --- a/packages/terminal-progress/README.md +++ b/packages/terminal-progress/README.md @@ -21,9 +21,11 @@ are found. tmux passthrough is handled transparently when `$TMUX` is set. kitty is supported on **0.47+**: kitty draws an in-window progress bar since 0.47 (see the `progress_bar` option in `kitty.conf`), while 0.39-0.46 only showed a percentage in the tab title -and versions before 0.38 treated OSC 9;4 as notifications. The plugin therefore stays inactive on -kitty unless it can confirm the version is at least 0.47, read from `TERM_PROGRAM_VERSION` or, on -Linux, from the running kitty binary via `/proc//exe`. +and earlier versions treated OSC 9;4 as notifications. kitty does not set `TERM_PROGRAM`, so it is +detected via `KITTY_WINDOW_ID` (also inside tmux), unless another terminal nested in kitty has set +`TERM_PROGRAM`. The plugin stays inactive unless it can confirm the version is at least 0.47 by +running `kitty --version` on the running emulator (`/proc//exe`, Linux) or on the binary +in `KITTY_INSTALLATION_DIR` (Linux and macOS). Set `OPENCODE_TERMINAL_PROGRESS=0` (or `false`/`no`) to disable progress reporting. diff --git a/packages/terminal-progress/src/kitty.test.ts b/packages/terminal-progress/src/kitty.test.ts new file mode 100644 index 0000000..b416b73 --- /dev/null +++ b/packages/terminal-progress/src/kitty.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'bun:test'; +import { + detectKitty, + detectKittyVersion, + isKittyHost, + isSupportedKitty, + kittyBinaries, + parseKittyVersion, + type KittyDeps, +} from './kitty.ts'; + +function deps(links: Record, versions: Record) { + const calls: string[] = []; + const value: KittyDeps = { + readlink(path) { + const target = links[path]; + if (target === undefined) throw new Error('ENOENT'); + return target; + }, + async version(binary) { + calls.push(binary); + return versions[binary]; + }, + }; + return { deps: value, calls }; +} + +describe('parseKittyVersion', () => { + test('parses kitty --version output', () => { + expect(parseKittyVersion('kitty 0.49.2 created by Kovid Goyal\n')).toEqual([0, 49, 2]); + }); + + test('defaults the patch component to 0', () => { + expect(parseKittyVersion('0.47')).toEqual([0, 47, 0]); + }); + + test('rejects unparsable input', () => { + expect(parseKittyVersion('kitty')).toBeUndefined(); + }); +}); + +describe('isSupportedKitty', () => { + test.each([ + [[0, 47, 0], true], + [[0, 49, 2], true], + [[1, 0, 0], true], + [[0, 46, 9], false], + [[0, 38, 0], false], + ])('%p -> %p', (version, expected) => { + expect(isSupportedKitty(version)).toBe(expected); + }); +}); + +describe('isKittyHost', () => { + test('detects kitty without TERM_PROGRAM', () => { + expect(isKittyHost({ KITTY_WINDOW_ID: '1', TERM: 'xterm-kitty' })).toBe(true); + }); + + test('detects kitty through tmux', () => { + expect(isKittyHost({ KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'tmux' })).toBe(true); + }); + + test('detects an explicit TERM_PROGRAM=kitty', () => { + expect(isKittyHost({ TERM_PROGRAM: 'kitty' })).toBe(true); + }); + + test('ignores a leaked KITTY_WINDOW_ID in a nested terminal', () => { + expect(isKittyHost({ KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'ghostty' })).toBe(false); + expect(isKittyHost({ KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'WezTerm' })).toBe(false); + }); + + test('is false outside kitty', () => { + expect(isKittyHost({ TERM_PROGRAM: 'ghostty' })).toBe(false); + expect(isKittyHost({})).toBe(false); + }); +}); + +describe('kittyBinaries', () => { + test('uses the running kitty via /proc first', () => { + const { deps: d } = deps({ '/proc/42/exe': '/opt/kitty.app/bin/kitty' }, {}); + expect( + kittyBinaries({ KITTY_PID: '42', KITTY_INSTALLATION_DIR: '/opt/kitty.app/lib/kitty' }, d), + ).toEqual(['/proc/42/exe', '/opt/kitty.app/bin/kitty', '/opt/kitty.app/MacOS/kitty']); + }); + + test('accepts a running kitty whose binary was replaced', () => { + const { deps: d } = deps({ '/proc/42/exe': '/usr/bin/kitty (deleted)' }, {}); + expect(kittyBinaries({ KITTY_PID: '42' }, d)).toEqual(['/proc/42/exe']); + }); + + test('skips a reused KITTY_PID that is not kitty', () => { + const { deps: d } = deps({ '/proc/42/exe': '/usr/bin/bash' }, {}); + expect(kittyBinaries({ KITTY_PID: '42' }, d)).toEqual([]); + }); + + test('skips /proc when unavailable', () => { + const { deps: d } = deps({}, {}); + expect( + kittyBinaries( + { + KITTY_PID: '42', + KITTY_INSTALLATION_DIR: '/Applications/kitty.app/Contents/Resources/kitty', + }, + d, + ), + ).toEqual([ + '/Applications/kitty.app/Contents/bin/kitty', + '/Applications/kitty.app/Contents/MacOS/kitty', + ]); + }); +}); + +describe('detectKittyVersion', () => { + test('ignores TERM_PROGRAM_VERSION owned by another program', async () => { + const { deps: d } = deps({}, {}); + expect( + await detectKittyVersion( + { KITTY_WINDOW_ID: '1', TERM_PROGRAM: 'tmux', TERM_PROGRAM_VERSION: '3.4' }, + d, + ), + ).toBeUndefined(); + }); + + test('trusts TERM_PROGRAM_VERSION when TERM_PROGRAM is kitty', async () => { + const { deps: d, calls } = deps({}, {}); + expect( + await detectKittyVersion({ TERM_PROGRAM: 'kitty', TERM_PROGRAM_VERSION: '0.48.1' }, d), + ).toEqual([0, 48, 1]); + expect(calls).toEqual([]); + }); + + test('falls back to KITTY_INSTALLATION_DIR', async () => { + const { deps: d, calls } = deps( + {}, + { '/Applications/kitty.app/Contents/MacOS/kitty': 'kitty 0.47.0 created by Kovid Goyal' }, + ); + expect( + await detectKittyVersion( + { + KITTY_WINDOW_ID: '1', + KITTY_INSTALLATION_DIR: '/Applications/kitty.app/Contents/Resources/kitty', + }, + d, + ), + ).toEqual([0, 47, 0]); + expect(calls).toEqual([ + '/Applications/kitty.app/Contents/bin/kitty', + '/Applications/kitty.app/Contents/MacOS/kitty', + ]); + }); + + test('ignores output that is not from kitty', async () => { + const { deps: d } = deps({}, { '/x/bin/kitty': 'something 1.0.0' }); + expect(await detectKittyVersion({ KITTY_INSTALLATION_DIR: '/x/lib/kitty' }, d)).toBeUndefined(); + }); +}); + +describe('detectKitty', () => { + test('enables kitty 0.47+', async () => { + const { deps: d } = deps( + { '/proc/7/exe': '/usr/bin/kitty' }, + { '/proc/7/exe': 'kitty 0.49.2 created by Kovid Goyal' }, + ); + expect(await detectKitty({ KITTY_WINDOW_ID: '1', KITTY_PID: '7' }, d)).toBe(true); + }); + + test('disables older kitty even inside tmux', async () => { + const { deps: d } = deps( + { '/proc/7/exe': '/usr/bin/kitty' }, + { '/proc/7/exe': 'kitty 0.35.2 created by Kovid Goyal' }, + ); + expect( + await detectKitty( + { KITTY_WINDOW_ID: '1', KITTY_PID: '7', TERM_PROGRAM: 'tmux', TERM_PROGRAM_VERSION: '3.4' }, + d, + ), + ).toBe(false); + }); + + test('disables kitty when the version is unknown', async () => { + const { deps: d } = deps({}, {}); + expect(await detectKitty({ KITTY_WINDOW_ID: '1' }, d)).toBe(false); + }); +}); diff --git a/packages/terminal-progress/src/kitty.ts b/packages/terminal-progress/src/kitty.ts new file mode 100644 index 0000000..7fbe2ac --- /dev/null +++ b/packages/terminal-progress/src/kitty.ts @@ -0,0 +1,84 @@ +import { readlinkSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { exec } from '../../_shared/src/index.ts'; + +type Env = Record; +type Version = readonly number[]; + +export const KITTY_MIN_SUPPORTED = [0, 47, 0] as const; + +export interface KittyDeps { + readlink(path: string): string; + version(binary: string): Promise; +} + +const defaultDeps: KittyDeps = { + readlink: (path) => readlinkSync(path), + async version(binary) { + const result = await exec(binary, ['--version'], { timeout: 2000 }); + return result.code === 0 ? result.stdout : undefined; + }, +}; + +export function parseKittyVersion(value: string): Version | undefined { + const match = value.match(/(?:^|\s)(\d+)\.(\d+)(?:\.(\d+))?/); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; +} + +export function isSupportedKitty(version: Version): boolean { + for (let i = 0; i < KITTY_MIN_SUPPORTED.length; i++) { + if ((version[i] ?? 0) > KITTY_MIN_SUPPORTED[i]) return true; + if ((version[i] ?? 0) < KITTY_MIN_SUPPORTED[i]) return false; + } + return true; +} + +export function isKittyHost(env: Env): boolean { + const termProgram = env['TERM_PROGRAM']; + if (termProgram === 'kitty') return true; + if (!env['KITTY_WINDOW_ID']) return false; + return !termProgram || termProgram === 'tmux'; +} + +export function kittyBinaries(env: Env, deps: Pick = defaultDeps): string[] { + const binaries: string[] = []; + const pid = env['KITTY_PID']; + if (pid && /^\d+$/.test(pid)) { + const exe = `/proc/${pid}/exe`; + try { + if (basename(deps.readlink(exe).replace(/ \(deleted\)$/, '')) === 'kitty') { + binaries.push(exe); + } + } catch { + // no /proc (e.g. macOS) or the process is gone + } + } + const installDir = env['KITTY_INSTALLATION_DIR']; + if (installDir) { + binaries.push(join(installDir, '..', '..', 'bin', 'kitty')); + binaries.push(join(installDir, '..', '..', 'MacOS', 'kitty')); + } + return binaries; +} + +export async function detectKittyVersion( + env: Env, + deps: KittyDeps = defaultDeps, +): Promise { + if (env['TERM_PROGRAM'] === 'kitty' && env['TERM_PROGRAM_VERSION']) { + const parsed = parseKittyVersion(env['TERM_PROGRAM_VERSION']); + if (parsed) return parsed; + } + for (const binary of kittyBinaries(env, deps)) { + const output = await deps.version(binary); + const parsed = output && /^kitty\s/.test(output) ? parseKittyVersion(output) : undefined; + if (parsed) return parsed; + } + return undefined; +} + +export async function detectKitty(env: Env, deps: KittyDeps = defaultDeps): Promise { + const version = await detectKittyVersion(env, deps); + return !!version && isSupportedKitty(version); +} diff --git a/packages/terminal-progress/src/tui.ts b/packages/terminal-progress/src/tui.ts index 76ac254..7a1164a 100644 --- a/packages/terminal-progress/src/tui.ts +++ b/packages/terminal-progress/src/tui.ts @@ -1,63 +1,14 @@ -import { closeSync, openSync, readlinkSync, writeSync } from 'node:fs'; +import { closeSync, openSync, writeSync } from 'node:fs'; import type { Plugin } from '@opencode/plugin/tui'; -import { createAgentStateTracker, createViewFilter, exec } from '../../_shared/src/index.ts'; +import { createAgentStateTracker, createViewFilter } from '../../_shared/src/index.ts'; +import { detectKitty, isKittyHost } from './kitty.ts'; type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty' | 'kitty'; -// kitty renders an in-window progress bar only since 0.47 ("progress_bar" option). -// Older versions (0.39-0.46) only showed a percentage in the tab title, and before -// 0.38 OSC 9;4 was treated as a notification. We only enable kitty when we can -// confirm the version is at least this. -const KITTY_MIN_SUPPORTED = [0, 47, 0] as const; - -function parseKittyVersion(value: string): readonly number[] | undefined { - const match = value.match(/(?:^|\s)(\d+)\.(\d+)(?:\.(\d+))?/); - if (!match) return undefined; - return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; -} - -async function detectKittyVersion(): Promise { - const env = process.env; - const programVersion = env['TERM_PROGRAM_VERSION']; - if (programVersion) { - const parsed = parseKittyVersion(programVersion); - if (parsed) return parsed; - } - // The shell integration may not be active, so fall back to querying the running - // kitty binary itself (Linux): /proc//exe points at the emulator that - // is actually writing to this terminal. - const kittyPid = env['KITTY_PID']; - if (kittyPid) { - try { - const exe = readlinkSync(`/proc/${kittyPid}/exe`); - const result = await exec(exe, ['--version']); - if (result.code === 0) { - const parsed = parseKittyVersion(result.stdout); - if (parsed) return parsed; - } - } catch { - // no /proc (e.g. macOS) or the process is gone - } - } - return undefined; -} - -function isSupportedKitty(version: readonly number[]): boolean { - for (let i = 0; i < KITTY_MIN_SUPPORTED.length; i++) { - if ((version[i] ?? 0) > KITTY_MIN_SUPPORTED[i]) return true; - if ((version[i] ?? 0) < KITTY_MIN_SUPPORTED[i]) return false; - } - return true; -} - async function detectTerminal(): Promise { const env = process.env; - if (env['KITTY_WINDOW_ID'] || env['TERM_PROGRAM'] === 'kitty') { - const version = await detectKittyVersion(); - if (version && isSupportedKitty(version)) { - return 'kitty'; - } - return undefined; + if (isKittyHost(env)) { + return (await detectKitty(env)) ? 'kitty' : undefined; } if (env['TERM_PROGRAM'] === 'ghostty') { return 'ghostty';