Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/terminal-progress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ 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 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 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/<KITTY_PID>/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.

## Progress states
Expand Down
3 changes: 2 additions & 1 deletion packages/terminal-progress/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -29,6 +29,7 @@
"terminal",
"progress",
"iterm2",
"kitty",
"wezterm",
"windows-terminal",
"osc"
Expand Down
184 changes: 184 additions & 0 deletions packages/terminal-progress/src/kitty.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, versions: Record<string, string>) {
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);
});
});
84 changes: 84 additions & 0 deletions packages/terminal-progress/src/kitty.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
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<string | undefined>;
}

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<KittyDeps, 'readlink'> = 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<Version | undefined> {
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<boolean> {
const version = await detectKittyVersion(env, deps);
return !!version && isSupportedKitty(version);
}
45 changes: 37 additions & 8 deletions packages/terminal-progress/src/tui.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { closeSync, openSync, writeSync } from 'node:fs';
import type { Plugin } from '@opencode/plugin/tui';
import { createAgentStateTracker, createViewFilter } from '../../_shared/src/index.ts';
import { detectKitty, isKittyHost } from './kitty.ts';

type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty';
type Terminal = 'iterm2' | 'wezterm' | 'windows-terminal' | 'ghostty' | 'kitty';

function detectTerminal(): Terminal | undefined {
async function detectTerminal(): Promise<Terminal | undefined> {
const env = process.env;
if (isKittyHost(env)) {
return (await detectKitty(env)) ? 'kitty' : undefined;
}
if (env['TERM_PROGRAM'] === 'ghostty') {
return 'ghostty';
}
Expand Down Expand Up @@ -59,21 +63,45 @@ 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;
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<typeof setInterval> | 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);
Expand All @@ -83,6 +111,7 @@ export default {

return () => {
stop();
clearStateTimer();
progress('0');
osc.close();
};
Expand Down
Loading