Skip to content
Open
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
52 changes: 52 additions & 0 deletions lib/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});

Expand Down
34 changes: 27 additions & 7 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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();
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}