diff --git a/README.md b/README.md index 1968c1b..1befcdb 100644 --- a/README.md +++ b/README.md @@ -292,9 +292,29 @@ This eliminates the need for polling—perfect for long-running processes like b | Variable | Default | Description | | ---------------------- | ---------- | -------------------------------------------------- | | `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session | +| `PTY_SANITIZE_OUTPUT` | `true` | Strip ANSI/VT escape sequences from `pty_read` results and `` notifications before they are stored in the OpenCode session. Set to `false` to preserve raw terminal output. | | `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) | | `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) | +### Plugin Options + +The plugin also accepts options from your opencode config (V1 `plugin` array entry / V2 `plugins` entry): + +```jsonc +{ + "plugin": [ + ["opencode-pty", { "sanitizeOutput": false }] + ] +} +``` + +| Option | Default | Description | +| ----------------- | ------- | ------------------------------------------------------------------ | +| `sanitizeOutput` | `true` | Same as `PTY_SANITIZE_OUTPUT`; when both are set, the option wins. | +| `port` | — | Fixed port for the Web UI observer server (V2 only). | +| `hostname` | — | Hostname for the Web UI observer server (V2 only). | +| `autostart` | `false` | Start the Web UI observer server on plugin init (V2 only). | + ### Permissions This plugin respects OpenCode's [permission settings](https://opencode.ai/docs/permissions/) for the `bash` tool. Commands spawned via `pty_spawn` are checked against your `permission.bash` configuration. diff --git a/src/plugin.ts b/src/plugin.ts index 6365c03..d39e1a4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -5,14 +5,19 @@ import { ptyWrite } from './plugin/pty/tools/write.ts' import { ptyRead } from './plugin/pty/tools/read.ts' import { ptyList } from './plugin/pty/tools/list.ts' import { ptyKill } from './plugin/pty/tools/kill.ts' +import { setAnsiSanitizationOverride } from './plugin/pty/sanitize.ts' import { PTYServer } from './web/server/server.ts' import open from 'open' const ptyOpenClientCommand = 'pty-open-background-spy' const ptyShowServerUrlCommand = 'pty-show-server-url' -export const PTYPlugin = async (context: PluginContext): Promise => { +export const PTYPlugin = async ( + context: PluginContext, + options?: { sanitizeOutput?: boolean } +): Promise => { const { client } = context + setAnsiSanitizationOverride(options?.sanitizeOutput) const adapter = createV1Adapter(context) installHostAdapter(adapter) let ptyServer: PTYServer | undefined diff --git a/src/plugin/pty/notification-manager.ts b/src/plugin/pty/notification-manager.ts index 7e05609..dca21ab 100644 --- a/src/plugin/pty/notification-manager.ts +++ b/src/plugin/pty/notification-manager.ts @@ -2,6 +2,7 @@ import type { SessionNotifier } from '../../adapters/types.ts' import type { PTYSession } from './types.ts' import type { OpencodeClient } from '@opencode-ai/sdk' import { NOTIFICATION_LINE_TRUNCATE, NOTIFICATION_TITLE_TRUNCATE } from '../constants.ts' +import { sanitizeAnsi } from './sanitize.ts' export class NotificationManager implements SessionNotifier { private client: OpencodeClient | null = null @@ -69,11 +70,15 @@ export function buildExitNotification(session: PTYSession, exitCode: number): st for (let i = lineCount - 1; i >= 0; i--) { const bufferLines = session.buffer.read(i, 1) const line = bufferLines[0] - if (line !== undefined && line.trim() !== '') { + if (line !== undefined) { + const sanitized = sanitizeAnsi(line) + if (sanitized.trim() === '') { + continue + } lastLine = - line.length > NOTIFICATION_LINE_TRUNCATE - ? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...` - : line + sanitized.length > NOTIFICATION_LINE_TRUNCATE + ? `${sanitized.slice(0, NOTIFICATION_LINE_TRUNCATE)}...` + : sanitized break } } diff --git a/src/plugin/pty/output-manager.ts b/src/plugin/pty/output-manager.ts index 5db4163..7b8ad19 100644 --- a/src/plugin/pty/output-manager.ts +++ b/src/plugin/pty/output-manager.ts @@ -1,4 +1,5 @@ import type { PTYSession, ReadResult, SearchResult } from './types.ts' +import { sanitizeAnsi } from './sanitize.ts' export class OutputManager { write(session: PTYSession, data: string): boolean { @@ -11,7 +12,7 @@ export class OutputManager { } read(session: PTYSession, offset: number = 0, limit?: number): ReadResult { - const lines = session.buffer.read(offset, limit) + const lines = session.buffer.read(offset, limit).map((line) => sanitizeAnsi(line)) const totalLines = session.buffer.length const hasMore = offset + lines.length < totalLines return { lines, totalLines, offset, hasMore } @@ -24,6 +25,12 @@ export class OutputManager { const paginatedMatches = limit !== undefined ? allMatches.slice(offset, offset + limit) : allMatches.slice(offset) const hasMore = offset + paginatedMatches.length < totalMatches - return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore } + return { + matches: paginatedMatches.map((match) => ({ ...match, text: sanitizeAnsi(match.text) })), + totalMatches, + totalLines, + offset, + hasMore, + } } } diff --git a/src/plugin/pty/sanitize.ts b/src/plugin/pty/sanitize.ts new file mode 100644 index 0000000..35c9b0e --- /dev/null +++ b/src/plugin/pty/sanitize.ts @@ -0,0 +1,32 @@ +let optionOverride: boolean | undefined + +/** + * Applies the `sanitizeOutput` plugin option (from the opencode plugin config). + * Precedence: explicit option > PTY_SANITIZE_OUTPUT env var > default (true). + */ +export function setAnsiSanitizationOverride(value: boolean | undefined): void { + optionOverride = value +} + +export function isAnsiSanitizationEnabled(): boolean { + if (optionOverride !== undefined) { + return optionOverride + } + const rawValue = process.env.PTY_SANITIZE_OUTPUT ?? 'true' + return rawValue.toLowerCase() !== 'false' && rawValue !== '0' +} + +/** + * Strips ANSI/VT escape sequences (CSI, OSC, and lone ESC) from text before + * it is returned in `pty_read` results or `` notifications. + * + * The raw buffer is kept untouched so the Web UI / xterm.js stream still + * receives the original terminal output. + */ +export function sanitizeAnsi(text: string): string { + if (!isAnsiSanitizationEnabled()) { + return text + } + + return Bun.stripANSI(text) +} diff --git a/src/v2/index.ts b/src/v2/index.ts index ad04483..bfd6ec4 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -4,6 +4,7 @@ import type { ServerOptions } from '../web/server/server.ts' import { getOrCreateServer, registerV2Commands } from './commands.ts' import { V2SessionNotifier } from './notifier.ts' import { registerV2Tools } from './tools.ts' +import { setAnsiSanitizationOverride } from '../plugin/pty/sanitize.ts' import { define, type PluginContextV2, type PluginV2 } from './types.ts' export * from './commands.ts' @@ -18,6 +19,7 @@ export * from './types.ts' export const Plugin: PluginV2 = define({ id: 'opencode-pty', setup: async (ctx: PluginContextV2) => { + setAnsiSanitizationOverride(ctx.options?.sanitizeOutput) // opencode v2 plugin contexts are server clients: `ctx.session.prompt` // wakes a session with a user prompt, preserving the session's current // model by construction. Pre-2.0 hosts without the session domain still diff --git a/src/v2/types.ts b/src/v2/types.ts index 0ea7220..d47b5c6 100644 --- a/src/v2/types.ts +++ b/src/v2/types.ts @@ -18,6 +18,14 @@ export interface OpencodePtyOptions { * Default is false (started on-demand when slash command is executed). */ autostart?: boolean + + /** + * Strip ANSI/VT escape sequences from output returned by `pty_read` and + * `` notifications (they re-execute in the host TUI otherwise). + * Default is true (or the PTY_SANITIZE_OUTPUT env var when set). + * The raw buffer is never modified; the Web UI stream stays untouched. + */ + sanitizeOutput?: boolean } /** diff --git a/test/notification-manager.test.ts b/test/notification-manager.test.ts index b0cbb56..b3a40d0 100644 --- a/test/notification-manager.test.ts +++ b/test/notification-manager.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, mock } from 'bun:test' import type { OpencodeClient } from '@opencode-ai/sdk' import { RingBuffer } from '../src/plugin/pty/buffer.ts' -import { NotificationManager } from '../src/plugin/pty/notification-manager.ts' +import { + buildExitNotification, + NotificationManager, +} from '../src/plugin/pty/notification-manager.ts' import type { PTYSession } from '../src/plugin/pty/types.ts' type PromptPayload = { @@ -141,6 +144,17 @@ describe('NotificationManager', () => { ) }) + it('strips ANSI escape sequences from the last line in exit notifications', () => { + const buffer = new RingBuffer() + buffer.append('line 1\n\x1b[31m\x1b[?9001h\x1b[2J\x1b]0;title\x07done\x1b[0m\n') + + const session = createSession({ buffer }) + const text = buildExitNotification(session, 0) + + expect(text).toContain('Last Line: done') + expect(text).not.toContain('\x1b') + }) + it('includes timeout context when the session timed out', async () => { const promptAsync = mock(async (_payload: PromptPayload) => {}) const manager = new NotificationManager() @@ -159,3 +173,16 @@ describe('NotificationManager', () => { expect(text).toContain('Process reached its PTY timeout and was stopped automatically.') }) }) + +describe('buildExitNotification edge cases', () => { + it('skips lines that become empty after sanitization', () => { + const buffer = new RingBuffer() + buffer.append('real output\n') + buffer.append('\x1b[2J\x1b[?25l\n') + + const session = createSession({ buffer }) + const text = buildExitNotification(session, 0) + + expect(text).toContain('Last Line: real output') + }) +}) diff --git a/test/output-manager.test.ts b/test/output-manager.test.ts new file mode 100644 index 0000000..e56821a --- /dev/null +++ b/test/output-manager.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'bun:test' +import { RingBuffer } from '../src/plugin/pty/buffer.ts' +import { OutputManager } from '../src/plugin/pty/output-manager.ts' +import type { PTYSession } from '../src/plugin/pty/types.ts' + +function createSession(bufferContent: string): PTYSession { + const buffer = new RingBuffer() + buffer.append(bufferContent) + return { + id: 'pty_test', + title: 'Test Session', + command: 'echo', + args: ['hello'], + workdir: '/tmp', + status: 'running', + pid: 12345, + createdAt: new Date(), + parentSessionId: 'parent-session-id', + notifyOnExit: false, + timedOut: false, + buffer, + process: null, + } +} + +describe('OutputManager', () => { + const manager = new OutputManager() + + it('strips ANSI escape sequences from read output', () => { + const session = createSession('\x1b[31mred\x1b[0m\n\x1b[?9001h\x1b[2Jplain\n') + + const result = manager.read(session) + + expect(result.lines).toEqual(['red', 'plain']) + }) + + it('strips ANSI escape sequences from search matches', () => { + const session = createSession('\x1b[31mred error\x1b[0m\n\x1b[32mgreen ok\x1b[0m\n') + + const result = manager.search(session, /error/) + + expect(result.matches).toHaveLength(1) + expect(result.matches[0]?.text).toBe('red error') + expect(result.matches[0]?.lineNumber).toBe(1) + }) + + it('leaves plain text unchanged', () => { + const session = createSession('hello world\nno escapes\n') + + const result = manager.read(session) + + expect(result.lines).toEqual(['hello world', 'no escapes']) + }) +}) diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts new file mode 100644 index 0000000..75219a9 --- /dev/null +++ b/test/sanitize.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'bun:test' +import { sanitizeAnsi } from '../src/plugin/pty/sanitize.ts' + +describe('sanitizeAnsi', () => { + it('strips CSI escape sequences', () => { + expect(sanitizeAnsi('\x1b[?9001h')).toBe('') + expect(sanitizeAnsi('\x1b[?1004h')).toBe('') + expect(sanitizeAnsi('\x1b[?25l')).toBe('') + expect(sanitizeAnsi('\x1b[2J')).toBe('') + expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('red') + }) + + it('strips OSC escape sequences', () => { + expect(sanitizeAnsi('\x1b]0;window title\x07')).toBe('') + expect(sanitizeAnsi('before\x1b]0;title\x07after')).toBe('beforeafter') + }) + + it('strips a lone ESC character', () => { + expect(sanitizeAnsi('hello\x1b')).toBe('hello') + expect(sanitizeAnsi('hello\x1b\x1b')).toBe('hello') + }) + + it('leaves plain text unchanged', () => { + expect(sanitizeAnsi('hello world')).toBe('hello world') + expect(sanitizeAnsi('line1\nline2')).toBe('line1\nline2') + expect(sanitizeAnsi('error: file not found')).toBe('error: file not found') + }) + + it('handles mixed real-world terminal output', () => { + const raw = + '\x1b[?9001h\x1b[?1004h\x1b[?25l\x1b[2J\x1b]0;playwright\x07Running 1 test\n\x1b[32m✓ passed\x1b[0m' + expect(sanitizeAnsi(raw)).toBe('Running 1 test\n✓ passed') + }) + + it('can be disabled via PTY_SANITIZE_OUTPUT=false', () => { + const original = process.env.PTY_SANITIZE_OUTPUT + process.env.PTY_SANITIZE_OUTPUT = 'false' + try { + expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m') + } finally { + process.env.PTY_SANITIZE_OUTPUT = original + } + }) +}) + +describe('sanitizeOutput plugin option', () => { + it('plugin option overrides the env var', async () => { + const { setAnsiSanitizationOverride } = await import('../src/plugin/pty/sanitize.ts') + const original = process.env.PTY_SANITIZE_OUTPUT + process.env.PTY_SANITIZE_OUTPUT = 'true' + try { + setAnsiSanitizationOverride(false) + expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m') + setAnsiSanitizationOverride(true) + expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('red') + } finally { + setAnsiSanitizationOverride(undefined) + process.env.PTY_SANITIZE_OUTPUT = original + } + }) + + it('falls back to the env var when no option is set', async () => { + const { setAnsiSanitizationOverride } = await import('../src/plugin/pty/sanitize.ts') + const original = process.env.PTY_SANITIZE_OUTPUT + process.env.PTY_SANITIZE_OUTPUT = 'false' + try { + setAnsiSanitizationOverride(undefined) + expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m') + } finally { + process.env.PTY_SANITIZE_OUTPUT = original + } + }) +})