diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 8c764612..d8c24e4a 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -241,6 +241,41 @@ describe('Terminal', () => { term.dispose(); }); + + test('onBell fires on a bell character in bytes', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + + let bellFired = false; + term.onBell(() => { + bellFired = true; + }); + + term.write(new Uint8Array([0x61, 0x07])); + + expect(bellFired).toBe(true); + + term.dispose(); + }); + + test('onBell does not fire on the BEL that ends an OSC sequence', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + + let bells = 0; + term.onBell(() => { + bells++; + }); + + term.write('\x1b]2;title\x07'); + term.write(new TextEncoder().encode('\x1b]8;;https://example.com\x07link\x1b]8;;\x07')); + expect(bells).toBe(0); + + term.write('\x1b]2;title\x07\x07'); + expect(bells).toBe(1); + + term.dispose(); + }); }); describe('Writing', () => { @@ -1021,6 +1056,23 @@ describe('onTitleChange event', () => { expect(receivedTitle).toBe('Title with ST'); term.dispose(); }); + + test('should fire when the sequence is written as bytes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + // Using shared container from beforeEach + if (!container) return; + term.open(container!); + + let receivedTitle = ''; + term.onTitleChange((title) => { + receivedTitle = title; + }); + + term.write(new TextEncoder().encode('\x1b]2;Bytes · Title\x07')); + + expect(receivedTitle).toBe('Bytes · Title'); + term.dispose(); + }); }); }); diff --git a/lib/terminal.ts b/lib/terminal.ts index 92d5f3d0..37c9e5ea 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -38,6 +38,11 @@ import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; import type { ILink, ILinkProvider } from './types'; +/** An OSC sequence with its BEL or ST terminator. */ +const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; + +const utf8Decoder = new TextDecoder(); + // ============================================================================ // Terminal Class // ============================================================================ @@ -566,11 +571,18 @@ export class Terminal implements ITerminalCore { // These need to be sent back to the PTY via onData this.processTerminalResponses(); - // Check for bell character (BEL, \x07) - // WASM doesn't expose bell events, so we detect it in the data stream - if (typeof data === 'string' && data.includes('\x07')) { - this.bellEmitter.fire(); - } else if (data instanceof Uint8Array && data.includes(0x07)) { + // WASM doesn't expose bell or title events, so we detect them in the data + // stream. Bytes are only decoded when they hold a BEL or an OSC sequence. + const text = + typeof data === 'string' + ? data + : data.includes(0x07) || hasOscStart(data) + ? utf8Decoder.decode(data) + : ''; + + // Check for bell character (BEL, \x07), skipping the BEL that ends an OSC + // sequence (titles, hyperlinks, clipboard) + if (text.includes('\x07') && text.replace(OSC_SEQUENCE, '').includes('\x07')) { this.bellEmitter.fire(); } @@ -584,8 +596,8 @@ export class Terminal implements ITerminalCore { // Check for title changes (OSC 0, 1, 2 sequences) // This is a simplified implementation - Ghostty WASM may provide this - if (typeof data === 'string' && data.includes('\x1b]')) { - this.checkForTitleChange(data); + if (text.includes('\x1b]')) { + this.checkForTitleChange(text); } // Call callback if provided @@ -1914,3 +1926,11 @@ export class Terminal implements ITerminalCore { return this.wasmTerm!.hasMouseTracking(); } } + +/** Whether bytes contain ESC ] (the start of an OSC sequence). */ +function hasOscStart(data: Uint8Array): boolean { + for (let i = data.indexOf(0x1b); i !== -1; i = data.indexOf(0x1b, i + 1)) { + if (data[i + 1] === 0x5d) return true; + } + return false; +}