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
83 changes: 83 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Implementation Plan: BEL Control Code Event Emission with Decoupled UI Rendering

## Summary

Add bell (BEL / 0x07) event emission to the MogTerm terminal engine and provide default audio + visual bell handlers in the renderer/UI layer. The engine stays decoupled from rendering; it simply fires a callback when BEL is encountered in GROUND state. The renderer subscribes to that callback and provides configurable audio bell (Web Audio API) and visual bell (CSS class flash) behavior.

## Codebase Analysis

### Architecture (two parallel stacks)

The project has **two terminal implementations** side by side:

1. **JS stack** (`src/parser.js` + `src/terminal.js` + `src/renderer.js`): Full-featured parser with state machine, CSI/OSC/ESC support, and a DOM renderer. Tests in `test/terminal.test.js`.
2. **TS stack** (`src/engine.ts` + `src/adapter.ts`): Simpler engine with inline parsing, used by the fixture-based test runner (`test/terminal.test.ts`). No separate parser or renderer.

The task description references `Terminal._execute()` and the `onExecute` callback pattern, which exist in the **JS stack**. The `case 0x07: break` no-op is at `src/terminal.js:139-140`. The existing callback-based event model is in `src/parser.js` (callbacks: `onPrint`, `onExecute`, `onCsiDispatch`, `onEscDispatch`, `onOscDispatch`).

The TS engine (`src/engine.ts`) also handles BEL implicitly — its `groundState()` method ignores codes < 0x20 that aren't explicitly handled (BEL 0x07 falls through to the `code >= 0x20` printable check and is silently dropped).

### Key observations

- **OSC BEL handling**: In `src/parser.js:217`, when the parser is in `OSC_STRING` state and encounters 0x07, it dispatches the OSC payload and returns to GROUND — it does NOT call `onExecute`. This means BEL-as-OSC-terminator already does NOT trigger the execute path. Acceptance criterion #6 is satisfied by the existing parser architecture.
- **Callback pattern**: The Terminal class doesn't use an EventEmitter. Callbacks are simple function properties (e.g., `this.parser.onPrint = (ch) => ...`). We should follow this same pattern by adding an `onBell` callback property on Terminal.
- **Renderer**: `src/renderer.js` takes a Terminal instance and calls `terminal.getState()` to render. It has no event subscription mechanism. We'll add bell handling as part of the renderer's setup.
- **Test patterns**: `test/terminal.test.js` uses a custom assert/test framework. `test/terminal.test.ts` uses `node:test` with fixtures via the adapter. New tests should be added to `test/terminal.test.js` (which directly tests Terminal and Parser) since we're testing event emission behavior that doesn't fit the fixture model.

## Changes

### 1. `src/terminal.js` — Emit bell event

- Add `this.onBell = null` property in constructor (follows existing callback pattern).
- In `_execute()`, change the `case 0x07` from a no-op `break` to call `this.onBell?.()`.
- Reset `this.onBell` to `null` in `reset()` is NOT needed — the callback is owned by the consumer, not terminal state. Same as how parser callbacks aren't reset.

### 2. `src/renderer.js` — Bell handlers in UI layer

- Add constructor options for bell configuration: `bellStyle` with values `'sound'`, `'visual'`, `'both'`, or `'none'` (default: `'both'`).
- Subscribe to `terminal.onBell` in the constructor.
- **Audio bell handler**: Use Web Audio API to play a short beep (800Hz sine wave, 200ms, gain 0.3). Create `AudioContext` lazily on first bell (browser autoplay policy requires user gesture context). Use a shared AudioContext instance.
- **Visual bell handler**: Add CSS class `mogterm-bell-flash` to the container for 150ms, then remove it.
- Add the `mogterm-bell-flash` CSS animation to `src/mogterm.css`.

### 3. `src/engine.ts` — Emit bell event (TS stack parity)

- Add `onBell: (() => void) | null = null` property.
- In `groundState()`, add `if (code === 0x07) { this.onBell?.(); return; }` before the printable character check.

### 4. `test/terminal.test.js` — Unit tests

Add tests:
- **BEL in GROUND state triggers bell callback**: Feed `\x07` to a Terminal, verify `onBell` was called.
- **BEL in GROUND state with surrounding text**: Feed `Hello\x07World`, verify bell fires once and text is correct.
- **OSC-terminating BEL does NOT trigger bell**: Feed `\x1b]0;Title\x07`, verify `onBell` was NOT called (only `_oscDispatch` runs).
- **Multiple BEL characters trigger multiple events**: Feed `\x07\x07\x07`, verify callback called 3 times.
- **No crash when onBell is not set**: Feed `\x07` without setting `onBell` — should not throw.

### 5. `src/mogterm.css` — Visual bell animation

```css
.mogterm-bell-flash {
animation: mogterm-bell-flash 0.15s ease-out;
}

@keyframes mogterm-bell-flash {
0% { filter: brightness(1.8); }
100% { filter: brightness(1); }
}
```

## Scope Assessment

This is a **single** agent task:
- All changes are tightly coupled (engine emits event → renderer handles it → tests verify both).
- Total changes span ~5 files with small modifications each.
- No new dependencies needed.
- No parallelizable boundaries — the renderer handler depends on the engine event, and tests verify the integration.

## Research Sources

- [MDN Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_Web_Audio_API) — AudioContext + OscillatorNode pattern for generating beep sounds.
- [xterm.js onBell API](https://xtermjs.org/docs/api/terminal/classes/terminal/) — Reference for how a mature terminal emulator exposes bell as an event (`terminal.onBell(callback)`), with `bellStyle` and `bellSound` options.
- [xterm.js Issue #3014](https://github.com/xtermjs/xterm.js/issues/3014) — Discussion on bell event emission pattern.
- [xterm.js Issue #1161](https://github.com/xtermjs/xterm.js/issues/1161) — Visual bell API design considerations.
6 changes: 6 additions & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function emptyCell(): Cell {
}

export class MogTermEngine {
public onBell: (() => void) | null = null;
private state: TerminalState;
private parseState: "ground" | "escape" | "csi" = "ground";
private csiParams: string = "";
Expand Down Expand Up @@ -93,6 +94,11 @@ export class MogTermEngine {
return;
}

if (code === 0x07) { // BEL
this.onBell?.();
return;
}

if (code === 0x0a) { // LF
this.lineFeed();
return;
Expand Down
9 changes: 9 additions & 0 deletions src/mogterm.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,12 @@
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

.mogterm-bell-flash {
animation: mogterm-bell-flash 0.15s ease-out;
}

@keyframes mogterm-bell-flash {
0% { filter: brightness(1.8); }
100% { filter: brightness(1); }
}
39 changes: 38 additions & 1 deletion src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class Renderer {
* @param {HTMLElement} container — DOM element to render into
* @param {import('./terminal.js').Terminal} terminal
*/
constructor(container, terminal) {
constructor(container, terminal, options = {}) {
this.container = container;
this.terminal = terminal;

Expand All @@ -31,6 +31,11 @@ export class Renderer {
this.cursorColor = '#ffffff';

this._setup();

// Bell
this.bellStyle = options.bellStyle || 'both';
this._audioCtx = null;
terminal.onBell = () => this._handleBell();
}

_setup() {
Expand Down Expand Up @@ -88,6 +93,38 @@ export class Renderer {
this.container.appendChild(fragment);
}

_handleBell() {
if (this.bellStyle === 'sound' || this.bellStyle === 'both') {
this._playBellSound();
}
if (this.bellStyle === 'visual' || this.bellStyle === 'both') {
this._flashBell();
}
}

_playBellSound() {
try {
if (!this._audioCtx) this._audioCtx = new AudioContext();
const osc = this._audioCtx.createOscillator();
const gain = this._audioCtx.createGain();
osc.connect(gain);
gain.connect(this._audioCtx.destination);
osc.frequency.value = 800;
gain.gain.value = 0.3;
osc.start();
osc.stop(this._audioCtx.currentTime + 0.2);
} catch (e) {
// AudioContext not available (e.g., Node.js environment)
}
}

_flashBell() {
this.container.classList.add('mogterm-bell-flash');
setTimeout(() => {
this.container.classList.remove('mogterm-bell-flash');
}, 150);
}

_cellStyle(attr) {
const s = {};

Expand Down
4 changes: 4 additions & 0 deletions src/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export class Terminal {
// Dirty flag for renderers
this._dirty = true;

// Bell callback
this.onBell = null;

// Parser
this.parser = new Parser();
this._bindParser();
Expand Down Expand Up @@ -137,6 +140,7 @@ export class Terminal {
_execute(code) {
switch (code) {
case 0x07: // BEL
this.onBell?.();
break;
case 0x08: // BS — backspace
if (this.cursorCol > 0) this.cursorCol--;
Expand Down
6 changes: 6 additions & 0 deletions tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"mode": "single",
"claudeMd": "# Project Context\n\nYou are implementing BEL (bell) control code event emission in the MogTerm terminal emulator, plus default audio/visual bell handlers in the UI layer.\n\n## Architecture\n\nMogTerm has two parallel terminal stacks:\n\n1. **JS stack** (primary, where most changes go):\n - `src/parser.js` — State-machine parser with callbacks: `onPrint`, `onExecute`, `onCsiDispatch`, `onEscDispatch`, `onOscDispatch`\n - `src/terminal.js` — Terminal state (cells, cursor, attrs). Wires parser callbacks in `_bindParser()`. The `_execute(code)` method handles C0 control codes — `case 0x07` is currently a no-op `break`.\n - `src/renderer.js` — DOM renderer. Takes `(container, terminal)` in constructor. Renders via `terminal.getState()`.\n - `src/mogterm.css` — Styles for the terminal component.\n\n2. **TS stack** (secondary, needs parity change):\n - `src/engine.ts` — `MogTermEngine` class with inline parsing. BEL (0x07) currently falls through silently in `groundState()`.\n - `src/adapter.ts` — Test fixture runner.\n\n## Exact Changes Required\n\n### 1. `src/terminal.js` — Add `onBell` callback and emit it\n\n**Constructor** (after `this._dirty = true;` at line 70):\n```js\n// Bell callback\nthis.onBell = null;\n```\n\n**`_execute()` method** — Change line 139-140 from:\n```js\ncase 0x07: // BEL\n break;\n```\nTo:\n```js\ncase 0x07: // BEL\n this.onBell?.();\n break;\n```\n\n### 2. `src/renderer.js` — Add bell handlers\n\n**Constructor** — Accept options for bell style. After `this._setup()`, subscribe to `terminal.onBell`:\n- Add `this.bellStyle` property (values: `'sound'`, `'visual'`, `'both'`, `'none'`; default `'both'`).\n- Set `terminal.onBell = () => this._handleBell()`.\n- Initialize `this._audioCtx = null` (lazy AudioContext).\n\n**`_handleBell()` method**:\n- If `bellStyle` is `'sound'` or `'both'`: call `this._playBellSound()`.\n- If `bellStyle` is `'visual'` or `'both'`: call `this._flashBell()`.\n\n**`_playBellSound()` method**:\n```js\n_playBellSound() {\n try {\n if (!this._audioCtx) this._audioCtx = new AudioContext();\n const osc = this._audioCtx.createOscillator();\n const gain = this._audioCtx.createGain();\n osc.connect(gain);\n gain.connect(this._audioCtx.destination);\n osc.frequency.value = 800;\n gain.gain.value = 0.3;\n osc.start();\n osc.stop(this._audioCtx.currentTime + 0.2);\n } catch (e) {\n // AudioContext not available (e.g., Node.js environment)\n }\n}\n```\n\n**`_flashBell()` method**:\n```js\n_flashBell() {\n this.container.classList.add('mogterm-bell-flash');\n setTimeout(() => {\n this.container.classList.remove('mogterm-bell-flash');\n }, 150);\n}\n```\n\n### 3. `src/mogterm.css` — Add visual bell animation\n\nAppend:\n```css\n.mogterm-bell-flash {\n animation: mogterm-bell-flash 0.15s ease-out;\n}\n\n@keyframes mogterm-bell-flash {\n 0% { filter: brightness(1.8); }\n 100% { filter: brightness(1); }\n}\n```\n\n### 4. `src/engine.ts` — Add bell event (TS stack parity)\n\nAdd property: `public onBell: (() => void) | null = null;`\n\nIn `groundState()`, add before the `if (code >= 0x20)` check:\n```ts\nif (code === 0x07) { // BEL\n this.onBell?.();\n return;\n}\n```\n\n### 5. `test/terminal.test.js` — Add bell tests\n\nAdd these tests at the end (before the summary section):\n\n```js\ntest('Terminal: BEL triggers onBell callback', () => {\n const t = new Terminal(80, 24);\n let bellCount = 0;\n t.onBell = () => bellCount++;\n t.write('\\x07');\n assertEqual(bellCount, 1, 'bell triggered once');\n});\n\ntest('Terminal: BEL with surrounding text', () => {\n const t = new Terminal(80, 24);\n let bellCount = 0;\n t.onBell = () => bellCount++;\n t.write('Hello\\x07World');\n assertEqual(bellCount, 1, 'bell triggered once during text');\n assertEqual(t.cells[0][0].char, 'H', 'text before bell intact');\n assertEqual(t.cells[0][5].char, 'W', 'text after bell intact');\n});\n\ntest('Terminal: OSC-terminating BEL does NOT trigger onBell', () => {\n const t = new Terminal(80, 24);\n let bellCount = 0;\n t.onBell = () => bellCount++;\n t.write('\\x1b]0;My Title\\x07');\n assertEqual(bellCount, 0, 'bell NOT triggered for OSC BEL');\n assertEqual(t.title, 'My Title', 'title was set correctly');\n});\n\ntest('Terminal: multiple BEL characters', () => {\n const t = new Terminal(80, 24);\n let bellCount = 0;\n t.onBell = () => bellCount++;\n t.write('\\x07\\x07\\x07');\n assertEqual(bellCount, 3, 'bell triggered three times');\n});\n\ntest('Terminal: BEL without onBell callback does not throw', () => {\n const t = new Terminal(80, 24);\n let threw = false;\n try {\n t.write('\\x07');\n } catch (e) {\n threw = true;\n }\n assert(!threw, 'no error when onBell is null');\n});\n```\n\n## Conventions\n\n- The project uses plain JS (ESM with `type: module` in package.json) for source files and TypeScript for test infrastructure.\n- No build step is required for JS files — they run directly.\n- Tests run with: `npx tsx --test test/**/*.test.ts` (for TS tests) and `node test/terminal.test.js` (for JS tests).\n- Follow existing naming: private methods prefixed with `_`, callbacks are nullable function properties.\n- Keep the Renderer constructor backwards-compatible — new `options` parameter should be optional.\n\n## Gotchas\n\n- `AudioContext` may not be available in Node.js test environments — wrap in try/catch.\n- Browser autoplay policy may block AudioContext creation until user gesture — the lazy init pattern handles this.\n- The `onBell?.()` optional chaining pattern is already used throughout the parser (e.g., `this.onPrint?.(ch)`).\n- Do NOT touch `parser.js` — BEL handling in OSC state already correctly dispatches via `onOscDispatch` without calling `onExecute`, so acceptance criterion #6 is satisfied by existing code.\n- The `reset()` method in Terminal should NOT reset `onBell` to null — it's a consumer-owned callback, not terminal state (same as how parser callbacks aren't reset).",
"subtasks": [],
"integration": null
}
48 changes: 48 additions & 0 deletions test/terminal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,54 @@ test('Terminal: erase in display mode 1 (above)', () => {
assertEqual(t.cells[2][0].char, 'C', 'row 2 preserved');
});

// ─── Bell tests ─────────────────────────────────────────────

test('Terminal: BEL triggers onBell callback', () => {
const t = new Terminal(80, 24);
let bellCount = 0;
t.onBell = () => bellCount++;
t.write('\x07');
assertEqual(bellCount, 1, 'bell triggered once');
});

test('Terminal: BEL with surrounding text', () => {
const t = new Terminal(80, 24);
let bellCount = 0;
t.onBell = () => bellCount++;
t.write('Hello\x07World');
assertEqual(bellCount, 1, 'bell triggered once during text');
assertEqual(t.cells[0][0].char, 'H', 'text before bell intact');
assertEqual(t.cells[0][5].char, 'W', 'text after bell intact');
});

test('Terminal: OSC-terminating BEL does NOT trigger onBell', () => {
const t = new Terminal(80, 24);
let bellCount = 0;
t.onBell = () => bellCount++;
t.write('\x1b]0;My Title\x07');
assertEqual(bellCount, 0, 'bell NOT triggered for OSC BEL');
assertEqual(t.title, 'My Title', 'title was set correctly');
});

test('Terminal: multiple BEL characters', () => {
const t = new Terminal(80, 24);
let bellCount = 0;
t.onBell = () => bellCount++;
t.write('\x07\x07\x07');
assertEqual(bellCount, 3, 'bell triggered three times');
});

test('Terminal: BEL without onBell callback does not throw', () => {
const t = new Terminal(80, 24);
let threw = false;
try {
t.write('\x07');
} catch (e) {
threw = true;
}
assert(!threw, 'no error when onBell is null');
});

// ─── Summary ─────────────────────────────────────────────────

console.log(`\n${passed} passed, ${failed} failed`);
Expand Down