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

## Summary

Wire up BEL (0x07) handling in the Terminal class (`src/terminal.js`) to emit a callback event, and add a default visual bell handler in the Renderer (`src/renderer.js`). This is a small, well-scoped feature that touches 4 files.

## Architecture Analysis

The codebase has two layers relevant to this task:

1. **Terminal engine** (`src/terminal.js`) — Pure state machine. Parser callbacks (`onPrint`, `onExecute`, `onCsiDispatch`, etc.) drive state changes. The `_execute()` method already has a `case 0x07: // BEL` stub that does nothing.

2. **Renderer** (`src/renderer.js`) — DOM renderer that reads terminal state and renders it. Currently has no event subscription — it just reads `terminal.getState()`.

The codebase uses a simple **callback property** pattern for events (e.g., `parser.onPrint`, `parser.onExecute`). Following this pattern, the Terminal class should expose an `onBell` callback property.

## Research

xterm.js added `onBell` in v4.12.0 ([Issue #3014](https://github.com/xtermjs/xterm.js/issues/3014)). Their approach: the terminal emits a bell event, and the UI layer subscribes to it independently. The bell event carries no data (it's a `void` callback). This matches our acceptance criteria for full decoupling.

## Changes

### 1. `src/terminal.js` — Add `onBell` callback

- Add `this.onBell = null;` in the constructor (following the pattern of other state like `this.title`).
- In `_execute()`, change `case 0x07` from a bare `break` to `this.onBell?.(); break;`. The optional chaining (`?.`) ensures no error if no handler is registered (AC #5).
- In `reset()`, set `this.onBell` back to `null` is NOT needed — the callback is external configuration, not terminal state. The `title` callback pattern shows that external registrations persist across resets.

### 2. `src/renderer.js` — Add visual bell handler

- Add a `_setupBellHandler()` method called from the constructor that subscribes to `terminal.onBell`.
- The handler adds a CSS class (`mogterm-visual-bell`) to the container for a brief flash, then removes it after the animation completes.
- The flash uses a CSS animation (opacity pulse or border flash) defined inline or via a `<style>` injection to keep it self-contained.

### 3. `src/mogterm.css` — Add visual bell animation

- Add `.mogterm-visual-bell` class with a brief flash keyframe animation.

### 4. `test/terminal.test.js` — Add bell event test

- Add a test that creates a Terminal, registers an `onBell` callback, writes `\x07`, and asserts the callback was invoked (AC #6).
- Add a test verifying no error when BEL is processed without a handler registered.

## Files Changed

| File | Change |
|------|--------|
| `src/terminal.js` | Add `onBell` property, invoke from `case 0x07` |
| `src/renderer.js` | Subscribe to `terminal.onBell`, trigger visual flash |
| `src/mogterm.css` | Add `.mogterm-visual-bell` animation keyframes |
| `test/terminal.test.js` | Add bell callback test |

## Scope Assessment

**Mode: single** — This is a small, tightly coupled feature. All changes are sequential and interdependent (the test needs the callback, the renderer needs the callback). No benefit from parallelism.

## Acceptance Criteria Mapping

| AC | Implementation |
|----|---------------|
| 1. Terminal exposes `onBell` callback | `this.onBell = null` in constructor |
| 2. `case 0x07` invokes bell callback | `this.onBell?.()` in `_execute()` |
| 3. UI/Renderer has visual bell handler | `Renderer._setupBellHandler()` with CSS flash |
| 4. Engine doesn't depend on rendering | `onBell` is a plain callback, no DOM imports |
| 5. No errors without handler | `?.()` optional chaining |
| 6. Test verifies bell event | New test in `terminal.test.js` |
10 changes: 10 additions & 0 deletions src/mogterm.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,13 @@
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

@keyframes mogterm-bell-flash {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}

.mogterm-visual-bell {
animation: mogterm-bell-flash 150ms ease-in-out;
}
8 changes: 8 additions & 0 deletions src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class Renderer {
this.cursorColor = '#ffffff';

this._setup();
this.terminal.onBell = () => this._visualBell();
}

_setup() {
Expand Down Expand Up @@ -139,6 +140,13 @@ export class Renderer {
return this.fgColor;
}

_visualBell() {
this.container.classList.add('mogterm-visual-bell');
this.container.addEventListener('animationend', () => {
this.container.classList.remove('mogterm-visual-bell');
}, { once: true });
}

_color256(n) {
if (n < 16) return ANSI_COLORS[n];
if (n < 232) {
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 (0x07) control code event emission for the MogTerm terminal emulator engine.\n\n## Tech Stack\n- Pure JavaScript (ES modules, `type: module` in package.json)\n- No framework, no bundler\n- Tests: `node test/terminal.test.js` (custom test harness with `test()`, `assert()`, `assertEqual()` helpers)\n- TypeScript test suite also exists: `npx tsx --test test/**/*.test.ts` (uses `node:test`)\n- Browser demo: `demo/index.html` with `<script type=\"module\">`\n\n## Key Files\n- `src/terminal.js` — Terminal class. The `_execute()` method at line 137 has a `case 0x07: // BEL` stub that currently just `break`s. Add `this.onBell?.();` before the break.\n- `src/renderer.js` — Renderer class. Constructor takes `(container, terminal)`. Add a bell handler that subscribes to `terminal.onBell` and triggers a brief CSS visual flash on the container.\n- `src/mogterm.css` — CSS for the Mogterm component. Add a `.mogterm-visual-bell` class with a brief flash animation.\n- `test/terminal.test.js` — Add tests using the existing `test()` / `assert()` / `assertEqual()` pattern. Do NOT use node:test here.\n\n## Conventions\n- Callback pattern: The codebase uses nullable callback properties (e.g., `this.parser.onPrint = (ch) => ...`). Follow this exact pattern for `onBell`.\n- The `onBell` callback takes no arguments and returns nothing (void).\n- Use optional chaining (`?.()`) to safely invoke nullable callbacks — this is the existing pattern in `parser.js` (e.g., `this.onPrint?.(ch)`).\n- Do not add `onBell` reset in `Terminal.reset()` — callbacks are external configuration, not terminal state. The `title` property is reset but that's different (it's display state, not a handler).\n- CSS animations: Use `@keyframes` with a class toggle pattern. Add/remove the class; use `animationend` event to clean up.\n- Keep the visual bell subtle — a brief opacity flash or background color pulse lasting ~150ms.\n\n## Implementation Steps\n1. In `src/terminal.js` constructor (around line 68-70, after `this._dirty = true`): add `this.onBell = null;`\n2. In `src/terminal.js` `_execute()` method, change `case 0x07: // BEL\\n break;` to `case 0x07: // BEL\\n this.onBell?.();\\n break;`\n3. In `src/renderer.js` constructor, after `this._setup()`, add a bell handler: `this.terminal.onBell = () => this._visualBell();`\n4. Add `_visualBell()` method to Renderer: adds `mogterm-visual-bell` class to container, listens for `animationend` to remove it.\n5. In `src/mogterm.css`, add keyframes and the `.mogterm-visual-bell` class.\n6. In `test/terminal.test.js`, add two tests before the Summary section:\n - 'Terminal: BEL triggers onBell callback' — register callback, write `\\x07`, assert it was called\n - 'Terminal: BEL without handler does not throw' — just write `\\x07` with no handler, assert no error\n\n## Gotchas\n- The Renderer sets `terminal.onBell` directly (callback property pattern). If the user also wants to set `onBell`, the Renderer will overwrite it. This is acceptable for this codebase's simplicity level — the task description says \"optionally adds a visual bell handler\".\n- The `mogterm.css` file is used by the `Mogterm` class (`src/mogterm.js`), which is a different component from `Terminal`+`Renderer`. The visual bell CSS could go in either the CSS file or be injected inline by the Renderer. Since the Renderer already sets inline styles in `_setup()`, injecting a `<style>` tag for the animation is also acceptable — but adding to `mogterm.css` is cleaner.\n- Run `node test/terminal.test.js` to verify all tests pass (97 existing + 2 new = 99 expected).\n- The TypeScript tests (`npx tsx --test test/**/*.test.ts`) use fixtures and the engine.ts adapter — they don't test terminal.js directly, so they won't cover bell. The JS tests are the right place.\n\n## Verification\n- `node test/terminal.test.js` should report 0 failures\n- All 6 acceptance criteria must be met",
"subtasks": [],
"integration": null
}
16 changes: 16 additions & 0 deletions test/terminal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,22 @@ test('Terminal: erase in display mode 1 (above)', () => {
assertEqual(t.cells[2][0].char, 'C', 'row 2 preserved');
});

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

test('Terminal: BEL without handler does not throw', () => {
const t = new Terminal(80, 24);
t.write('\x07');
assert(true, 'no error thrown when onBell is null');
});

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

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