diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..350e086 --- /dev/null +++ b/PLAN.md @@ -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. diff --git a/src/engine.ts b/src/engine.ts index 1ef378e..c66d613 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -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 = ""; @@ -93,6 +94,11 @@ export class MogTermEngine { return; } + if (code === 0x07) { // BEL + this.onBell?.(); + return; + } + if (code === 0x0a) { // LF this.lineFeed(); return; diff --git a/src/mogterm.css b/src/mogterm.css index 85eabea..c30dcf2 100644 --- a/src/mogterm.css +++ b/src/mogterm.css @@ -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); } +} diff --git a/src/renderer.js b/src/renderer.js index 8698723..096a808 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -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; @@ -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() { @@ -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 = {}; diff --git a/src/terminal.js b/src/terminal.js index c7b1d2d..ab002f2 100644 --- a/src/terminal.js +++ b/src/terminal.js @@ -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(); @@ -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--; diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..16482d8 --- /dev/null +++ b/tasks.json @@ -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 +} diff --git a/test/terminal.test.js b/test/terminal.test.js index c0b7e38..7e985ad 100644 --- a/test/terminal.test.js +++ b/test/terminal.test.js @@ -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`);