Compare commits

...
Author SHA1 Message Date
arkon 525f02f502 chore: version packages 2026-03-05 17:59:33 +01:00
arkonandClaude Opus 4.6 e07c59477d fix: disable WebGL renderer to prevent Chrome page unresponsive crashes
Root cause: xterm.js WebGL addon performs synchronous GPU ReadPixels
calls during terminal.write(). When heavy terminal output floods in
(~1MB/4s from active Claude sessions), single-frame writes of 70-105KB
block Chrome's main thread for 10+ seconds, triggering "page
unresponsive" dialogs. This happens both during tab switches (buffer
load + live SSE data competing) and during normal use (Ink redraw
bursts).

Fix: disable WebGL by default, use canvas renderer instead. Canvas
handles the same workloads without GPU stalls. Re-enable with ?webgl
URL param for testing.

Also: gate live SSE terminal writes during the entire selectSession()
buffer load sequence (not just during chunkedTerminalWrite), preventing
live data from competing with historical buffer restoration.

Crash investigation data (from server-side breadcrumb collection):
- 27 flushes totaling 937KB in 4 seconds preceded crash
- Two 105KB and one 104KB single-frame flushes observed
- Crash occurred when user switched tabs during output flood
- Tab froze for 2m54s before recovering
- Backend always stable (0 crashes); pure frontend GPU issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 17:52:28 +01:00
arkonandClaude Opus 4.6 2b8a522cbd fix: add server-side crash breadcrumbs and remove --drop:console from build
The --drop:console esbuild flag was silently stripping all diagnostic
console.* calls from production builds, making crash investigation
impossible. Remove it temporarily for debugging.

Add server-side crash breadcrumb collection:
- Frontend writes breadcrumbs to localStorage AND POSTs to /api/crash-diag
- Server stores latest breadcrumbs in memory, readable via GET /api/crash-diag
- Granular breadcrumbs in selectSession: CACHE_WRITE, FETCH_START,
  FETCH_DONE, REWRITE, FOCUS, SELECT_DONE
- 2s heartbeat beacon so breadcrumbs survive tab freezes
- text/plain content-type parser for navigator.sendBeacon compatibility

Initial findings from breadcrumbs:
- Crash happens during selectSession() for sessions with large buffers
- Pattern: cached buffer exists from prior visit + live SSE data arriving
- Backend is always stable (0 crashes); this is a pure frontend issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 16:44:17 +01:00
arkonandClaude Opus 4.6 4abe055182 feat: add frontend crash diagnostics for page unresponsive investigation
Add global error handlers, long task detection, WebGL context tracking,
and performance timing to identify root cause of intermittent Chrome
"page unresponsive" freezes during session switching and typing.

Diagnostics:
- window error/unhandledrejection handlers ([CRASH-DIAG] prefix)
- PerformanceObserver for long tasks (>200ms main thread blocks)
- WebGL context loss/restore tracking on all canvases
- ?nowebgl URL param to disable WebGL renderer for testing
- Timing on flushPendingWrites, chunkedTerminalWrite, selectSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 12:42:32 +01:00
arkon 15a3b3996b chore: version packages 2026-03-05 00:28:13 +01:00
arkonandSigurður Guðbrandsson 1b76e6e2e2 fix: prevent Chrome freeze and shell feedback delay from flicker filter
Bug 1: Every incoming SSE terminal event reset the 50ms flush timer, not
just cursor-up events. During active Claude runs the timer never fired,
accumulating MBs in flickerFilterBuffer that froze Chrome on flush.
Fix: only reset timer on cursor-up events; add 256KB safety valve.

Bug 2: Shell sessions emit cursor-up on every keystroke for readline
prompt redraws, triggering the flicker filter and delaying feedback.
Fix: skip cursor-up filter for shell mode; disable local echo overlay.

Based on PR #31 by @SGudbrandsson.

Co-Authored-By: Sigurður Guðbrandsson <SGudbrandsson@users.noreply.github.com>
2026-03-05 00:27:07 +01:00
arkonandClaude Opus 4.6 f8b81b8478 fix: eliminate WebGL re-render flicker during tab switch
Stop toggling WebGL renderer off/on around large buffer writes in
chunkedTerminalWrite(). The dispose+loadAddon cycle caused visible
re-render flashes and (before the deferred fix) synchronous GPU stalls
from ReadPixels blocking the main thread. Instead, keep WebGL active
and rely on 32KB chunked writes to keep per-frame render work under
~5ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 16:01:16 +01:00
arkonandClaude Opus 4.6 d79e25d5d8 test: comprehensive CJK wide character test plan for PR #30
Covers all 7 test plan items: Chinese overlap prevention, double-width
spacing, Japanese/Korean input, cursor positioning, line wrapping at
column boundaries, ASCII regression, and teammate terminal panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:23 +01:00
arkonandClaude Opus 4.6 b1df5319d5 fix: prevent page unresponsive crashes from WebGL GPU stalls during session switch
Disable WebGL renderer during large buffer loads (>32KB) and fall back to
canvas, which handles bulk ANSI writes without synchronous GPU ReadPixels
calls. Re-enable WebGL after the buffer load completes so live terminal
streaming still benefits from GPU acceleration.

Also reduce chunk size from 128KB to 32KB and use chunked writes for
cached buffer restores instead of synchronous terminal.write().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 13:18:02 +01:00
arkonandClaude Opus 4.6 fc92d8a8a4 fix: restore CJK wide character support in ZerolagInput overlay (#30)
Add charCellWidth/stringCellWidth helpers for Unicode-aware width detection,
fix makeLine to use for...of iteration with visual column positioning, and
fix line splitting in _render to use visual column widths instead of string
length. CJK/fullwidth characters now correctly occupy 2 cell widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:12:30 +01:00
arkonandClaude Opus 4.6 c7cd4f9e17 fix: prevent page unresponsive crashes from WebGL GPU stalls during session switch
Reduce terminal chunk size from 128KB to 32KB and use double-RAF between
chunks to give the WebGL renderer time to flush GPU operations. Also switch
cached buffer restore from direct terminal.write() to chunked writes —
the synchronous 256KB write was blocking the main thread for 5+ seconds
via synchronous ReadPixels calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 13:09:10 +01:00
16 changed files with 2983 additions and 1632 deletions
+18
View File
@@ -1,5 +1,23 @@
# aicodeman
## 0.3.5
### Patch Changes
- Fix Chrome "page unresponsive" crashes caused by xterm.js WebGL renderer GPU stalls during heavy terminal output. Disable WebGL by default (canvas renderer used instead), gate SSE terminal writes during tab switches, and add crash diagnostics with server-side breadcrumb collection.
## 0.3.4
### Patch Changes
- Fix Chrome tab freeze from flicker filter buffer accumulation during active sessions, and fix shell mode feedback delay by excluding shell sessions from cursor-up filter
## 0.3.3
### Patch Changes
- fix: eliminate WebGL re-render flicker during tab switch by keeping renderer active instead of toggling it off/on around large buffer writes
## 0.3.2
### Patch Changes
+2 -2
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.3.2 (must match `package.json`)
**Version**: 0.3.5 (must match `package.json`)
## Project Overview
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` ★ (~11.7K lines) + 9 JS modules | |
| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | |
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.3.2",
"version": "0.3.5",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -1,4 +1,50 @@
import type { RenderParams, FontStyle } from './types.js';
import type { RenderParams, FontStyle, XtermTerminal } from './types.js';
// ─── CJK / fullwidth character width detection ───────────────────────
/**
* Get visual cell width of a single character.
* CJK wide characters occupy 2 cells, others occupy 1.
* Prefers the terminal's Unicode addon when available.
*/
export function charCellWidth(terminal: XtermTerminal | null | undefined, ch: string): number {
if (terminal?.unicode?.getStringCellWidth) {
return terminal.unicode.getStringCellWidth(ch);
}
// Fallback: detect CJK wide characters by Unicode range
const code = ch.codePointAt(0);
if (
code !== undefined &&
code >= 0x1100 &&
(code <= 0x115f || // Hangul Jamo
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Ideographic
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
(code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A
(code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified, Yi
(code >= 0xa960 && code <= 0xa97c) || // Hangul Jamo Extended-A
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
(code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
(code >= 0x1f000 && code <= 0x1fbff) || // Mahjong, Domino, Emoji
(code >= 0x20000 && code <= 0x2ffff) || // CJK Unified Ext B-F
(code >= 0x30000 && code <= 0x3ffff)) // CJK Unified Ext G+
)
return 2;
return 1;
}
/**
* Get visual cell width of a string (sum of all character widths).
*/
export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: string): number {
let w = 0;
for (const ch of str) w += charCellWidth(terminal, ch);
return w;
}
// ─── Overlay rendering ────────────────────────────────────────────────
/**
* Render the overlay content into the container element.
@@ -6,100 +52,114 @@ import type { RenderParams, FontStyle } from './types.js';
* Creates per-character `<span>` elements positioned on an exact grid
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
* occurs with normal DOM text flow.
*
* CJK wide characters are rendered with double-width spans.
*/
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const { lines, startCol, totalCols, cellW, cellH, charTop, charHeight, promptRow, font, showCursor, cursorColor } = params;
const {
lines,
startCol,
totalCols,
cellW,
cellH,
charTop,
charHeight,
promptRow,
font,
showCursor,
cursorColor,
terminal,
} = params;
// Position container at prompt row.
container.style.left = '0px';
container.style.top = (promptRow * cellH) + 'px';
// Position container at prompt row.
container.style.left = '0px';
container.style.top = promptRow * cellH + 'px';
// Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = '';
const fullWidthPx = totalCols * cellW;
// Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = '';
const fullWidthPx = totalCols * cellW;
for (let i = 0; i < lines.length; i++) {
const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
const topPx = i * cellH;
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font);
container.appendChild(lineEl);
for (let i = 0; i < lines.length; i++) {
const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
container.appendChild(lineEl);
}
// Block cursor at end of last line (use visual width for CJK support)
if (showCursor) {
const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
if (cursorCol < totalCols) {
const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block';
cursor.style.left = cursorCol * cellW + 'px';
cursor.style.top = (lines.length - 1) * cellH + 'px';
cursor.style.width = cellW + 'px';
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
}
}
// Block cursor at end of last line
if (showCursor) {
const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + lastLine.length;
if (cursorCol < totalCols) {
const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block';
cursor.style.left = (cursorCol * cellW) + 'px';
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
cursor.style.width = cellW + 'px';
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
}
}
container.style.display = '';
container.style.display = '';
}
/**
* Create a styled line `<div>` with per-character grid positioning.
*
* Each character gets its own `<span>` placed at `i * cellW` pixels.
* This matches xterm's canvas renderer where each glyph occupies exactly
* one cell width, regardless of the actual glyph metrics.
* Each character gets its own `<span>` positioned by visual column offset.
* CJK wide characters occupy 2 cell widths.
*/
function makeLine(
text: string,
leftPx: number,
topPx: number,
widthPx: number,
cellH: number,
cellW: number,
charTop: number,
charHeight: number,
font: FontStyle,
text: string,
leftPx: number,
topPx: number,
widthPx: number,
cellH: number,
cellW: number,
_charTop: number,
_charHeight: number,
font: FontStyle,
terminal?: XtermTerminal | null
): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px';
el.style.top = topPx + 'px';
el.style.width = widthPx + 'px';
// Extend background 1px past cell boundary to cover the compositing
// seam between the overlay layer (z-index:7) and the canvas layer below.
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = (cellH + 1) + 'px';
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px';
el.style.top = topPx + 'px';
el.style.width = widthPx + 'px';
// Extend background 1px past cell boundary to cover the compositing
// seam between the overlay layer (z-index:7) and the canvas layer below.
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = cellH + 1 + 'px';
// Spans fill the full cell height with matching lineHeight for natural
// CSS vertical centering. No transform — any sub-pixel overhang past
// the line div causes visible anti-aliasing artifacts at the boundary.
// The ≤0.5px difference from canvas ceil() rounding is imperceptible.
// CJK wide chars occupy 2 cells — position by visual column offset
let colOffset = 0;
for (const ch of text) {
const cw = charCellWidth(terminal, ch);
const span = document.createElement('span');
// No ligatures — canvas renders each glyph independently.
span.style.cssText =
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
"font-feature-settings:'liga' 0,'calt' 0";
span.style.left = colOffset * cellW + 'px';
span.style.top = '0px';
span.style.width = cw * cellW + 'px';
span.style.height = cellH + 'px';
span.style.lineHeight = cellH + 'px';
span.style.fontFamily = font.fontFamily;
span.style.fontSize = font.fontSize;
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
span.textContent = ch;
el.appendChild(span);
colOffset += cw;
}
for (let i = 0; i < text.length; i++) {
const span = document.createElement('span');
// No ligatures — canvas renders each glyph independently.
span.style.cssText =
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
"font-feature-settings:'liga' 0,'calt' 0";
span.style.left = (i * cellW) + 'px';
span.style.top = '0px';
span.style.width = cellW + 'px';
span.style.height = cellH + 'px';
span.style.lineHeight = cellH + 'px';
span.style.fontFamily = font.fontFamily;
span.style.fontSize = font.fontSize;
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
span.textContent = text[i];
el.appendChild(span);
}
return el;
return el;
}
+114 -105
View File
@@ -5,28 +5,35 @@
* Consumers pass their real Terminal instance — we only use these properties.
*/
export interface XtermTerminal {
readonly element: HTMLElement | undefined;
readonly cols: number;
readonly rows: number;
readonly options: {
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
theme?: {
background?: string;
foreground?: string;
cursor?: string;
};
readonly element: HTMLElement | undefined;
readonly cols: number;
readonly rows: number;
readonly options: {
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
theme?: {
background?: string;
foreground?: string;
cursor?: string;
};
readonly buffer: {
readonly active: {
readonly viewportY: number;
readonly baseY: number;
getLine(y: number): {
translateToString(trimRight?: boolean): string;
} | undefined;
};
};
readonly buffer: {
readonly active: {
readonly viewportY: number;
readonly baseY: number;
getLine(y: number):
| {
translateToString(trimRight?: boolean): string;
}
| undefined;
};
};
/** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */
readonly unicode?: {
getStringCellWidth(str: string): number;
activeVersion?: string;
};
}
/**
@@ -35,18 +42,18 @@ export interface XtermTerminal {
* The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
*/
export interface XtermAddon {
activate(terminal: XtermTerminal): void;
dispose(): void;
activate(terminal: XtermTerminal): void;
dispose(): void;
}
/**
* Position of the prompt in the terminal viewport.
*/
export interface PromptPosition {
/** Viewport-relative row (0 = top of viewport) */
row: number;
/** Column of the prompt marker character */
col: number;
/** Viewport-relative row (0 = top of viewport) */
row: number;
/** Column of the prompt marker character */
col: number;
}
/**
@@ -60,112 +67,114 @@ export interface PromptPosition {
* - `custom`: Full escape hatch — provide your own finder function
*/
export type PromptFinder =
| { type: 'character'; char: string; offset?: number }
| { type: 'regex'; pattern: RegExp; offset?: number }
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
| { type: 'character'; char: string; offset?: number }
| { type: 'regex'; pattern: RegExp; offset?: number }
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
/**
* Configuration options for ZerolagInputAddon.
*/
export interface ZerolagInputOptions {
/**
* How to find the prompt in the terminal buffer.
*
* The `offset` controls how many characters after the prompt marker
* the user input begins (e.g., `"> "` = offset 2).
*
* @default { type: 'character', char: '>', offset: 2 }
*/
prompt?: PromptFinder;
/**
* How to find the prompt in the terminal buffer.
*
* The `offset` controls how many characters after the prompt marker
* the user input begins (e.g., `"> "` = offset 2).
*
* @default { type: 'character', char: '>', offset: 2 }
*/
prompt?: PromptFinder;
/**
* Z-index for the overlay element.
* @default 7
*/
zIndex?: number;
/**
* Z-index for the overlay element.
* @default 7
*/
zIndex?: number;
/**
* Background color for the overlay.
* Set to `'transparent'` to disable the opaque background.
* @default Read from terminal.options.theme.background
*/
backgroundColor?: string;
/**
* Background color for the overlay.
* Set to `'transparent'` to disable the opaque background.
* @default Read from terminal.options.theme.background
*/
backgroundColor?: string;
/**
* Foreground color for overlay text.
* @default Read from terminal.options.theme.foreground
*/
foregroundColor?: string;
/**
* Foreground color for overlay text.
* @default Read from terminal.options.theme.foreground
*/
foregroundColor?: string;
/**
* Whether to show a block cursor at the end of the overlay text.
* @default true
*/
showCursor?: boolean;
/**
* Whether to show a block cursor at the end of the overlay text.
* @default true
*/
showCursor?: boolean;
/**
* Cursor color (block cursor at end of text).
* @default Read from terminal.options.theme.cursor
*/
cursorColor?: string;
/**
* Cursor color (block cursor at end of text).
* @default Read from terminal.options.theme.cursor
*/
cursorColor?: string;
/**
* Scroll debounce time in ms for re-rendering when user scrolls
* back to the bottom of the terminal.
* @default 50
*/
scrollDebounceMs?: number;
/**
* Scroll debounce time in ms for re-rendering when user scrolls
* back to the bottom of the terminal.
* @default 50
*/
scrollDebounceMs?: number;
}
/**
* Read-only state snapshot of the overlay.
*/
export interface ZerolagInputState {
/** Characters typed but not yet acknowledged by the server */
pendingText: string;
/** Number of characters flushed to PTY but echo not yet received */
flushedLength: number;
/** Text content of the flushed portion */
flushedText: string;
/** Whether the overlay is currently visible */
visible: boolean;
/** Last detected prompt position, if any */
promptPosition: PromptPosition | null;
/** Characters typed but not yet acknowledged by the server */
pendingText: string;
/** Number of characters flushed to PTY but echo not yet received */
flushedLength: number;
/** Text content of the flushed portion */
flushedText: string;
/** Whether the overlay is currently visible */
visible: boolean;
/** Last detected prompt position, if any */
promptPosition: PromptPosition | null;
}
/** Cell dimensions in CSS pixels. */
export interface CellDimensions {
width: number;
height: number;
/** Vertical offset (px) from cell top to where characters render. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
width: number;
height: number;
/** Vertical offset (px) from cell top to where characters render. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
}
/** Parameters for the overlay renderer. */
export interface RenderParams {
lines: string[];
startCol: number;
totalCols: number;
cellW: number;
cellH: number;
/** Vertical offset (px) from cell top to character rendering area. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
promptRow: number;
font: FontStyle;
showCursor: boolean;
cursorColor: string;
lines: string[];
startCol: number;
totalCols: number;
cellW: number;
cellH: number;
/** Vertical offset (px) from cell top to character rendering area. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
promptRow: number;
font: FontStyle;
showCursor: boolean;
cursorColor: string;
/** Terminal instance for CJK wide character width detection */
terminal?: XtermTerminal | null;
}
/** Cached font style properties for overlay rendering. */
export interface FontStyle {
fontFamily: string;
fontSize: string;
fontWeight: string;
color: string;
backgroundColor: string;
letterSpacing: string;
fontFamily: string;
fontSize: string;
fontWeight: string;
color: string;
backgroundColor: string;
letterSpacing: string;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,843 @@
/**
* Comprehensive CJK wide character test plan for PR #30.
*
* Tests all 7 items from the test plan:
* 1. Chinese text input — no character overlap
* 2. CJK text renders with correct double-width spacing
* 3. Japanese (こんにちは) and Korean (안녕하세요) input
* 4. Cursor positions correctly after CJK characters
* 5. Long CJK input wraps at correct column boundary
* 6. Existing ASCII input is unaffected
* 7. Teammate terminal panels render CJK correctly (app.js embedded copy)
*/
import { describe, it, expect, afterEach } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
import { createMockTerminal } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// ─── Shared fixtures ─────────────────────────────────────────────────
const FONT: FontStyle = {
fontFamily: 'monospace',
fontSize: '14px',
fontWeight: 'normal',
color: '#eeeeee',
backgroundColor: '#0d0d0d',
letterSpacing: '',
};
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
return {
lines: ['hello'],
startCol: 2,
totalCols: 80,
cellW: 10,
cellH: 17,
charTop: 2,
charHeight: 14,
promptRow: 0,
font: FONT,
showCursor: true,
cursorColor: '#e0e0e0',
...overrides,
};
}
/** Extract span data from a rendered line div */
function getSpans(lineDiv: HTMLDivElement) {
const spans: { text: string; left: number; width: number }[] = [];
for (let i = 0; i < lineDiv.children.length; i++) {
const span = lineDiv.children[i] as HTMLSpanElement;
spans.push({
text: span.textContent || '',
left: parseFloat(span.style.left),
width: parseFloat(span.style.width),
});
}
return spans;
}
// ─── Addon setup helpers ─────────────────────────────────────────────
let cleanups: (() => void)[] = [];
afterEach(() => {
for (const fn of cleanups) fn();
cleanups = [];
});
function tracked(lines: string[] = ['$ '], promptChar = '$') {
const mock = createMockTerminal({ buffer: { lines }, cols: 80 });
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: promptChar, offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
return { addon, mock };
}
// ═══════════════════════════════════════════════════════════════════════
// TEST 1: Chinese text input — no character overlap
// ═══════════════════════════════════════════════════════════════════════
describe('Test 1: Chinese text — no character overlap', () => {
it('consecutive Chinese characters have contiguous non-overlapping spans', () => {
const container = document.createElement('div');
const text = '你好世界';
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans.length).toBe(4);
// Each span: left = previous span's (left + width), width = 20px (2 cells)
for (let i = 0; i < spans.length; i++) {
expect(spans[i].width).toBe(20); // 2 cells * 10px
if (i > 0) {
const expectedLeft = spans[i - 1].left + spans[i - 1].width;
expect(spans[i].left).toBe(expectedLeft);
}
}
});
it('Chinese sentence (simulated pinyin output) renders without gaps or overlaps', () => {
const container = document.createElement('div');
const text = '我是一个测试';
renderOverlay(container, makeParams({ lines: [text], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans.length).toBe(6);
// Verify contiguous positioning
let expectedLeft = 0;
for (const span of spans) {
expect(span.left).toBe(expectedLeft);
expect(span.width).toBe(16); // 2 * 8px
expectedLeft += span.width;
}
// Total visual width should be 6 chars * 2 cells * 8px = 96px
expect(expectedLeft).toBe(96);
});
it('mixed Chinese + ASCII has no gaps between spans', () => {
const container = document.createElement('div');
const text = 'hello你好world';
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
// h(10) e(10) l(10) l(10) o(10) 你(20) 好(20) w(10) o(10) r(10) l(10) d(10)
expect(spans.length).toBe(12);
// Verify contiguity: no gaps between any adjacent spans
for (let i = 1; i < spans.length; i++) {
const prevEnd = spans[i - 1].left + spans[i - 1].width;
expect(spans[i].left).toBe(prevEnd);
}
});
it('addChar with Chinese characters accumulates correctly in addon', () => {
const { addon } = tracked();
for (const ch of '你好世界') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('你好世界');
expect(addon.hasPending).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 2: CJK text renders with correct double-width spacing
// ═══════════════════════════════════════════════════════════════════════
describe('Test 2: CJK double-width spacing', () => {
it('charCellWidth returns 2 for CJK Unified Ideographs (0x4E00-0x9FFF)', () => {
// Common Chinese characters
const chars = '中文测试你好世界天地人';
for (const ch of chars) {
expect(charCellWidth(null, ch)).toBe(2);
}
});
it('charCellWidth returns 2 for CJK Extension A (0x3400-0x4DBF)', () => {
expect(charCellWidth(null, '\u3400')).toBe(2); // First Extension A char
expect(charCellWidth(null, '\u4DB5')).toBe(2); // One of the last Extension A chars
});
it('charCellWidth returns 2 for CJK Radicals (0x2E80-0x2EFF)', () => {
expect(charCellWidth(null, '\u2E80')).toBe(2); // CJK Radical Repeat
});
it('charCellWidth returns 2 for fullwidth ASCII forms (0xFF01-0xFF5E)', () => {
expect(charCellWidth(null, '\uFF01')).toBe(2); // !
expect(charCellWidth(null, '\uFF21')).toBe(2); // A
expect(charCellWidth(null, '\uFF41')).toBe(2); // a
expect(charCellWidth(null, '\uFF10')).toBe(2); // 0
});
it('charCellWidth returns 2 for CJK Compatibility Ideographs (0xF900-0xFAFF)', () => {
expect(charCellWidth(null, '\uF900')).toBe(2);
});
it('stringCellWidth calculates correct visual width for CJK strings', () => {
expect(stringCellWidth(null, '你好')).toBe(4); // 2 + 2
expect(stringCellWidth(null, '你好世界')).toBe(8); // 4 * 2
expect(stringCellWidth(null, 'hi你好')).toBe(6); // 1 + 1 + 2 + 2
expect(stringCellWidth(null, '你a好b')).toBe(6); // 2 + 1 + 2 + 1
expect(stringCellWidth(null, 'abc你好def')).toBe(10); // 3 + 4 + 3
});
it('CJK span widths are exactly 2 * cellW pixels', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
});
it('ASCII span widths remain 1 * cellW pixels', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
});
it('terminal unicode addon is preferred over fallback when available', () => {
const mockTerminal = {
unicode: {
getStringCellWidth: (s: string) => {
// Custom width: treat 'W' as wide
return s === 'W' ? 2 : 1;
},
},
} as any;
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
expect(charCellWidth(mockTerminal, 'a')).toBe(1);
// Fallback ignores terminal addon result for actual CJK
expect(charCellWidth(null, '你')).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 3: Japanese (こんにちは) and Korean (안녕하세요) input
// ═══════════════════════════════════════════════════════════════════════
describe('Test 3: Japanese and Korean input', () => {
describe('Japanese', () => {
it('charCellWidth returns 2 for Hiragana (0x3040-0x309F)', () => {
const hiragana = 'あいうえおかきくけこさしすせそ';
for (const ch of hiragana) {
expect(charCellWidth(null, ch)).toBe(2);
}
});
it('charCellWidth returns 2 for Katakana (0x30A0-0x30FF)', () => {
const katakana = 'アイウエオカキクケコサシスセソ';
for (const ch of katakana) {
expect(charCellWidth(null, ch)).toBe(2);
}
});
it('Japanese greeting renders with correct span positions', () => {
const container = document.createElement('div');
const text = 'こんにちは';
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans.length).toBe(5);
// こ(0,20) ん(20,20) に(40,20) ち(60,20) は(80,20)
expect(spans[0]).toEqual({ text: 'こ', left: 0, width: 20 });
expect(spans[1]).toEqual({ text: 'ん', left: 20, width: 20 });
expect(spans[2]).toEqual({ text: 'に', left: 40, width: 20 });
expect(spans[3]).toEqual({ text: 'ち', left: 60, width: 20 });
expect(spans[4]).toEqual({ text: 'は', left: 80, width: 20 });
});
it('mixed Japanese + ASCII positions correctly', () => {
const container = document.createElement('div');
const text = 'hello こんにちは';
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
// h(0) e(10) l(20) l(30) o(40) space(50) こ(60) ん(80) に(100) ち(120) は(140)
expect(spans.length).toBe(11);
expect(spans[5]).toEqual({ text: ' ', left: 50, width: 10 }); // space
expect(spans[6]).toEqual({ text: 'こ', left: 60, width: 20 }); // first CJK after ASCII
});
it('addon handles Japanese input via addChar', () => {
const { addon } = tracked();
for (const ch of 'こんにちは') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('こんにちは');
});
it('addon handles Japanese input via appendText (paste)', () => {
const { addon } = tracked();
addon.appendText('こんにちは世界');
expect(addon.pendingText).toBe('こんにちは世界');
expect(addon.hasPending).toBe(true);
});
it('stringCellWidth correct for Japanese greeting', () => {
expect(stringCellWidth(null, 'こんにちは')).toBe(10); // 5 * 2
});
});
describe('Korean', () => {
it('charCellWidth returns 2 for Hangul Syllables (0xAC00-0xD7A3)', () => {
const hangul = '가나다라마바사아자차카타파하';
for (const ch of hangul) {
expect(charCellWidth(null, ch)).toBe(2);
}
});
it('charCellWidth returns 2 for Hangul Jamo (0x1100-0x115F)', () => {
expect(charCellWidth(null, '\u1100')).toBe(2); // ᄀ
expect(charCellWidth(null, '\u1112')).toBe(2); // ᄒ
});
it('Korean greeting renders with correct span positions', () => {
const container = document.createElement('div');
const text = '안녕하세요';
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans.length).toBe(5);
expect(spans[0]).toEqual({ text: '안', left: 0, width: 20 });
expect(spans[1]).toEqual({ text: '녕', left: 20, width: 20 });
expect(spans[2]).toEqual({ text: '하', left: 40, width: 20 });
expect(spans[3]).toEqual({ text: '세', left: 60, width: 20 });
expect(spans[4]).toEqual({ text: '요', left: 80, width: 20 });
});
it('addon handles Korean input', () => {
const { addon } = tracked();
addon.appendText('안녕하세요');
expect(addon.pendingText).toBe('안녕하세요');
expect(addon.hasPending).toBe(true);
});
it('removeChar removes Korean characters one at a time', () => {
const { addon } = tracked();
addon.appendText('안녕');
addon.removeChar();
expect(addon.pendingText).toBe('안');
addon.removeChar();
expect(addon.pendingText).toBe('');
});
it('stringCellWidth correct for Korean greeting', () => {
expect(stringCellWidth(null, '안녕하세요')).toBe(10); // 5 * 2
});
});
describe('mixed CJK scripts', () => {
it('Chinese + Japanese + Korean in one string', () => {
const text = '你好こんにちは안녕';
expect(stringCellWidth(null, text)).toBe(18); // 9 chars * 2 each
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
// All 9 CJK chars: contiguous double-width spans
expect(spans.length).toBe(9);
let expectedLeft = 0;
for (const span of spans) {
expect(span.left).toBe(expectedLeft);
expect(span.width).toBe(20);
expectedLeft += 20;
}
});
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 4: Cursor positions correctly after CJK characters
// ═══════════════════════════════════════════════════════════════════════
describe('Test 4: Cursor positioning after CJK', () => {
it('cursor after Chinese text on first line', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好世界'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// cursorCol = startCol(2) + stringCellWidth('你好世界')(8) = 10
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('100px'); // 10 * 10px
});
it('cursor after Japanese text on first line', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['こんにちは'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// cursorCol = 2 + 10 = 12
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('120px');
});
it('cursor after Korean text on first line', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['안녕하세요'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// cursorCol = 2 + 10 = 12
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('120px');
});
it('cursor after mixed ASCII + CJK', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['hi你好'],
startCol: 3,
cellW: 10,
showCursor: true,
})
);
// cursorCol = 3 + stringCellWidth('hi你好')(6) = 9
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('90px');
});
it('cursor on wrapped line after CJK text', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好世界', '再见'],
startCol: 2,
cellW: 10,
cellH: 20,
showCursor: true,
})
);
// Last line is '再见', starts at col 0 (wrapped line), width = 4
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('40px'); // 0 + 4 = 4, * 10 = 40
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
it('cursor hidden when it would exceed totalCols', () => {
const container = document.createElement('div');
// 4 CJK chars = 8 visual cols, startCol=73, cursorCol = 73 + 8 = 81 > 80
renderOverlay(
container,
makeParams({
lines: ['你好世界'],
startCol: 73,
totalCols: 80,
cellW: 10,
showCursor: true,
})
);
// Should be 1 line div, no cursor span (cursor at col 81 >= totalCols 80)
// Actually cursor check is cursorCol < totalCols, so at 81 it's hidden
const children = container.children;
// If cursor is rendered, last child would be a cursor span
// With cursorCol 81 >= 80, cursor should NOT be rendered
expect(children.length).toBe(1); // only line div
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 5: Long CJK input wraps at correct column boundary
// ═══════════════════════════════════════════════════════════════════════
describe('Test 5: CJK line wrapping at column boundaries', () => {
it('CJK chars wrap when they would overflow first line', () => {
const container = document.createElement('div');
// totalCols=10, startCol=2 → firstLineCols=8
// Each CJK char = 2 cols → first line fits 4 chars (8 cols)
// 5th char wraps to second line
const text = '你好世界啊'; // 5 chars = 10 visual cols
renderOverlay(
container,
makeParams({
lines: ['你好世界', '啊'],
startCol: 2,
totalCols: 10,
cellW: 10,
cellH: 20,
})
);
expect(container.children.length).toBe(3); // 2 line divs + cursor
const line1 = container.children[0] as HTMLDivElement;
expect(line1.children.length).toBe(4); // 你好世界
expect(line1.style.left).toBe('20px'); // startCol * cellW
const line2 = container.children[1] as HTMLDivElement;
expect(line2.children.length).toBe(1); // 啊
expect(line2.style.left).toBe('0px'); // wrapped line starts at col 0
expect(line2.style.top).toBe('20px'); // second row
});
it('CJK char that would partially overflow stays on next line', () => {
// totalCols=9, startCol=2 → firstLineCols=7
// CJK chars are 2-wide. 3 chars = 6 cols (fits). 4th char = 8 cols > 7. Wraps.
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好世', '界'],
startCol: 2,
totalCols: 9,
cellW: 10,
})
);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.children.length).toBe(3); // 3 CJK chars fit (6 cols <= 7)
const line2 = container.children[1] as HTMLDivElement;
expect(line2.children.length).toBe(1); // 界 wraps
});
it('addon _render splits CJK text into visual lines correctly', () => {
// Narrow terminal: 10 cols, startCol=2 → firstLineCols=8 → 4 CJK chars
const mock = createMockTerminal({
buffer: { lines: ['$ '] },
cols: 10,
});
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: '$', offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
// Type 6 CJK chars (12 visual cols)
for (const ch of '你好世界再见') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('你好世界再见');
expect(addon.hasPending).toBe(true);
});
it('mixed ASCII + CJK wraps correctly', () => {
const container = document.createElement('div');
// totalCols=10, startCol=2 → firstLineCols=8
// 'ab' = 2 cols, '你好' = 4 cols, 'cd' = 2 cols → total 8 cols (fits line 1)
// '世' = 2 cols → wraps to line 2
renderOverlay(
container,
makeParams({
lines: ['ab你好cd', '世'],
startCol: 2,
totalCols: 10,
cellW: 10,
})
);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.children.length).toBe(6); // a,b,你,好,c,d
const line2 = container.children[1] as HTMLDivElement;
expect(line2.children.length).toBe(1); // 世
});
it('odd column count: CJK char does not split across boundary', () => {
// totalCols=11, startCol=2 → firstLineCols=9
// 4 CJK chars = 8 cols (fits). 5th CJK = 10 cols > 9 (wraps).
// 1 empty column remains on first line (CJK can't fit in 1 col).
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好世界', '啊'],
startCol: 2,
totalCols: 11,
cellW: 10,
})
);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.children.length).toBe(4); // 4 chars = 8 cols, 5th would need 10 > 9
const line2 = container.children[1] as HTMLDivElement;
expect(line2.children.length).toBe(1);
});
it('CJK fills entire wrapped line', () => {
const container = document.createElement('div');
// totalCols=6, startCol=0 → firstLineCols=6
// 3 CJK chars = 6 cols (fills line 1), next 3 fill line 2
renderOverlay(
container,
makeParams({
lines: ['你好世', '界再见'],
startCol: 0,
totalCols: 6,
cellW: 10,
})
);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.children.length).toBe(3);
const line2 = container.children[1] as HTMLDivElement;
expect(line2.children.length).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 6: Existing ASCII input is unaffected
// ═══════════════════════════════════════════════════════════════════════
describe('Test 6: ASCII input — no regression', () => {
it('ASCII characters still get 1-cell-wide spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abcdef'], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
for (const span of spans) {
expect(span.width).toBe(10); // 1 * cellW
}
});
it('ASCII span positions are sequential at 1-cell intervals', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['xyz'], cellW: 8.4 }));
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans[0].left).toBe(0);
expect(spans[1].left).toBeCloseTo(8.4, 5);
expect(spans[2].left).toBeCloseTo(16.8, 5);
});
it('ASCII cursor positions at correct column', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['hello'],
startCol: 3,
cellW: 10,
showCursor: true,
})
);
// cursor at startCol(3) + 5 = 8
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('80px');
});
it('charCellWidth returns 1 for all printable ASCII', () => {
for (let code = 32; code < 127; code++) {
const ch = String.fromCharCode(code);
expect(charCellWidth(null, ch)).toBe(1);
}
});
it('stringCellWidth equals length for pure ASCII', () => {
expect(stringCellWidth(null, 'hello world')).toBe(11);
expect(stringCellWidth(null, 'test123!@#')).toBe(10);
expect(stringCellWidth(null, '')).toBe(0);
});
it('ASCII multi-line wrapping is unaffected', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['abcde', 'fgh'],
startCol: 5,
totalCols: 10,
cellW: 10,
cellH: 20,
})
);
expect(container.children.length).toBe(3); // 2 lines + cursor
const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement;
expect(line1.children.length).toBe(5);
expect(line2.children.length).toBe(3);
expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line2.style.left).toBe('0px');
});
it('addon addChar/removeChar/clear work for ASCII', () => {
const { addon } = tracked();
addon.addChar('h');
addon.addChar('e');
addon.addChar('l');
addon.addChar('l');
addon.addChar('o');
expect(addon.pendingText).toBe('hello');
addon.removeChar();
expect(addon.pendingText).toBe('hell');
addon.clear();
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════
// TEST 7: Teammate terminal panels render CJK correctly
//
// Teammate panels share the global xterm-zerolag-input.js vendor bundle
// (loaded via <script> in index.html). This is the IIFE build of the
// same package source tested in tests 1-6. The build pipeline is:
// src/overlay-renderer.ts → tsup → dist/index.global.js → vendor copy
//
// Since all terminals (main + teammate) use the same LocalEchoOverlay
// class from the global scope, CJK correctness is guaranteed by:
// (a) The package source handles CJK correctly (tests 1-6 above)
// (b) The IIFE build bundles the exact same charCellWidth / makeLine code
//
// These tests verify the exported module includes CJK-aware functions
// and that teammate-style terminal instances work identically.
// ═══════════════════════════════════════════════════════════════════════
describe('Test 7: Teammate terminal panels render CJK correctly', () => {
it('package exports charCellWidth and stringCellWidth', () => {
// These are the CJK-aware functions that the IIFE build exposes
expect(typeof charCellWidth).toBe('function');
expect(typeof stringCellWidth).toBe('function');
});
it('ZerolagInputAddon (used by LocalEchoOverlay) handles CJK in teammate terminals', () => {
// Simulate a teammate terminal panel: separate terminal instance,
// same addon class, different prompt character
const mock = createMockTerminal({
buffer: { lines: ['❯ '] },
cols: 40,
});
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: '❯', offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
// Type CJK in teammate terminal
for (const ch of '你好世界') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('你好世界');
expect(addon.hasPending).toBe(true);
});
it('teammate terminal with narrow width wraps CJK correctly', () => {
// Teammate panels are often narrower (sidebar, split view)
const mock = createMockTerminal({
buffer: { lines: ['❯ '] },
cols: 12, // narrow panel
});
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: '❯', offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
// Type 6 CJK chars (12 visual cols) with startCol=2 → only 10 available
// First line: 5 CJK = 10 cols. 6th wraps.
for (const ch of '你好世界再见') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('你好世界再见');
});
it('mixed CJK scripts render identically in teammate and main terminals', () => {
// Verify that the same input produces the same overlay output
// regardless of which terminal instance it's on
const text = '你好こんにちは안녕';
// "Main" terminal
const container1 = document.createElement('div');
renderOverlay(container1, makeParams({ lines: [text], cellW: 10 }));
const line1 = container1.children[0] as HTMLDivElement;
const spans1 = getSpans(line1);
// "Teammate" terminal (same params — same bundle)
const container2 = document.createElement('div');
renderOverlay(container2, makeParams({ lines: [text], cellW: 10 }));
const line2 = container2.children[0] as HTMLDivElement;
const spans2 = getSpans(line2);
// Identical rendering
expect(spans1.length).toBe(spans2.length);
for (let i = 0; i < spans1.length; i++) {
expect(spans1[i]).toEqual(spans2[i]);
}
});
it('CJK rendering correct in overlay with teammate-typical prompt (❯)', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['こんにちは世界'],
startCol: 2, // after ❯ prompt
cellW: 10,
showCursor: true,
})
);
const lineDiv = container.children[0] as HTMLDivElement;
const spans = getSpans(lineDiv);
expect(spans.length).toBe(7);
// All CJK, all double-width, contiguous
let expectedLeft = 0;
for (const span of spans) {
expect(span.left).toBe(expectedLeft);
expect(span.width).toBe(20);
expectedLeft += 20;
}
// Cursor position: startCol(2) + 14 visual cols = 16
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('160px');
});
});
@@ -1,300 +1,420 @@
import { describe, it, expect } from 'vitest';
import { renderOverlay } from '../src/overlay-renderer.js';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
fontSize: '14px',
fontWeight: 'normal',
color: '#eeeeee',
backgroundColor: '#0d0d0d',
letterSpacing: '',
fontFamily: 'monospace',
fontSize: '14px',
fontWeight: 'normal',
color: '#eeeeee',
backgroundColor: '#0d0d0d',
letterSpacing: '',
};
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
return {
lines: ['hello'],
startCol: 2,
totalCols: 80,
cellW: 8.4,
cellH: 17,
charTop: 2,
charHeight: 14,
promptRow: 10,
font: FONT,
showCursor: true,
cursorColor: '#e0e0e0',
...overrides,
};
return {
lines: ['hello'],
startCol: 2,
totalCols: 80,
cellW: 8.4,
cellH: 17,
charTop: 2,
charHeight: 14,
promptRow: 10,
font: FONT,
showCursor: true,
cursorColor: '#e0e0e0',
...overrides,
};
}
describe('renderOverlay', () => {
it('positions container at prompt row', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe((5 * 17) + 'px');
expect(container.style.left).toBe('0px');
});
it('positions container at prompt row', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe(5 * 17 + 'px');
expect(container.style.left).toBe('0px');
});
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
// Line div + cursor span
expect(container.children.length).toBe(2);
// Line div + cursor span
expect(container.children.length).toBe(2);
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px');
const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px');
const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px');
});
const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px');
});
it('sets span width to cellW', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('9.5px');
});
it('sets span width to cellW', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('9.5px');
});
it('applies font styles to spans', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
fontWeight: 'bold',
color: '#ff0000',
backgroundColor: '#000000',
letterSpacing: '0.5px',
};
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['A'], font }));
it('applies font styles to spans', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
fontWeight: 'bold',
color: '#ff0000',
backgroundColor: '#000000',
letterSpacing: '0.5px',
};
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['A'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px');
});
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px');
});
it('offsets first line by startCol', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
// First line left = startCol * cellW
expect(lineDiv.style.left).toBe('50px');
});
it('offsets first line by startCol', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
// First line left = startCol * cellW
expect(lineDiv.style.left).toBe('50px');
});
it('renders cursor at end of text', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['ab'],
startCol: 3,
cellW: 10,
cellH: 20,
showCursor: true,
cursorColor: '#ff00ff',
}));
it('renders cursor at end of text', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['ab'],
startCol: 3,
cellW: 10,
cellH: 20,
showCursor: true,
cursorColor: '#ff00ff',
})
);
// Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
// cursorCol = startCol(3) + text.length(2) = 5
expect(cursor.style.left).toBe('50px');
expect(cursor.style.width).toBe('10px');
expect(cursor.style.height).toBe('20px');
// jsdom normalizes hex to rgb()
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
});
// Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
// cursorCol = startCol(3) + text.length(2) = 5
expect(cursor.style.left).toBe('50px');
expect(cursor.style.width).toBe('10px');
expect(cursor.style.height).toBe('20px');
// jsdom normalizes hex to rgb()
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
});
it('does not render cursor when showCursor is false', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
// Only line div, no cursor
expect(container.children.length).toBe(1);
});
it('does not render cursor when showCursor is false', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
// Only line div, no cursor
expect(container.children.length).toBe(1);
});
it('renders multi-line text', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'second'],
startCol: 5,
cellW: 10,
cellH: 20,
}));
it('renders multi-line text', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'second'],
startCol: 5,
cellW: 10,
cellH: 20,
})
);
// 2 line divs + cursor
expect(container.children.length).toBe(3);
// 2 line divs + cursor
expect(container.children.length).toBe(3);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line1.style.top).toBe('0px');
expect(line1.children.length).toBe(5); // 'first'
const line1 = container.children[0] as HTMLDivElement;
expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line1.style.top).toBe('0px');
expect(line1.children.length).toBe(5); // 'first'
const line2 = container.children[1] as HTMLDivElement;
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
expect(line2.style.top).toBe('20px'); // second row
expect(line2.children.length).toBe(6); // 'second'
});
const line2 = container.children[1] as HTMLDivElement;
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
expect(line2.style.top).toBe('20px'); // second row
expect(line2.children.length).toBe(6); // 'second'
});
it('clears previous content on re-render', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(container.children.length).toBe(2); // line + cursor
it('clears previous content on re-render', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(container.children.length).toBe(2); // line + cursor
renderOverlay(container, makeParams({ lines: ['xy'] }));
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
renderOverlay(container, makeParams({ lines: ['xy'] }));
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(2); // x, y
});
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(2); // x, y
});
it('shows container (display not none)', () => {
const container = document.createElement('div');
container.style.display = 'none';
renderOverlay(container, makeParams());
expect(container.style.display).toBe('');
});
it('shows container (display not none)', () => {
const container = document.createElement('div');
container.style.display = 'none';
renderOverlay(container, makeParams());
expect(container.style.display).toBe('');
});
// ─── Anti-flicker / compositing seam tests ────────────────────
// ─── Anti-flicker / compositing seam tests ────────────────────
it('line div height extends 1px past cellH to cover compositing seam', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
// cellH + 1 = 20px — the extra 1px covers the compositing seam
expect(lineDiv.style.height).toBe('20px');
});
it('line div height extends 1px past cellH to cover compositing seam', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
// cellH + 1 = 20px — the extra 1px covers the compositing seam
expect(lineDiv.style.height).toBe('20px');
});
it('line div height is cellH+1 for various cell heights', () => {
for (const cellH of [15, 17, 19, 22]) {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe((cellH + 1) + 'px');
}
});
it('line div height is cellH+1 for various cell heights', () => {
for (const cellH of [15, 17, 19, 22]) {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe(cellH + 1 + 'px');
}
});
it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'second'],
cellH: 19,
}));
const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px');
expect(line2.style.height).toBe('20px');
});
it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'second'],
cellH: 19,
})
);
const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px');
expect(line2.style.height).toBe('20px');
});
// ─── Span vertical centering tests ────────────────────────────
// ─── Span vertical centering tests ────────────────────────────
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px');
});
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px');
});
it('span top is 0px (no vertical offset / no transform)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
it('span top is 0px (no vertical offset / no transform)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
// ─── Font rendering tests ─────────────────────────────────────
it('span disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
expect(span.style.cssText).toContain("font-feature-settings:");
expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0");
});
it('span disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
expect(span.style.cssText).toContain('font-feature-settings:');
expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0");
});
it('span has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center');
});
it('span has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center');
});
it('span has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none');
});
it('span has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
// ─── Multi-line cursor positioning ────────────────────────────
it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'ab'],
startCol: 5,
cellW: 10,
cellH: 20,
showCursor: true,
}));
// Cursor at end of second line: col = 0 + 2 = 2
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'ab'],
startCol: 5,
cellW: 10,
cellH: 20,
showCursor: true,
})
);
// Cursor at end of second line: col = 0 + 2 = 2
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
// ─── charTop/charHeight passed through ────────────────────────
// ─── charTop/charHeight passed through ────────────────────────
it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div');
expect(() => renderOverlay(container, makeParams({
lines: ['test'],
charTop: 2,
charHeight: 14,
}))).not.toThrow();
expect(container.children.length).toBeGreaterThan(0);
});
it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div');
expect(() =>
renderOverlay(
container,
makeParams({
lines: ['test'],
charTop: 2,
charHeight: 14,
})
)
).not.toThrow();
expect(container.children.length).toBeGreaterThan(0);
});
// ─── Line div positioning regression ──────────────────────────
// ─── Line div positioning regression ──────────────────────────
it('line div background color matches font.backgroundColor', () => {
const container = document.createElement('div');
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
renderOverlay(container, makeParams({ lines: ['x'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
});
it('line div background color matches font.backgroundColor', () => {
const container = document.createElement('div');
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
renderOverlay(container, makeParams({ lines: ['x'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
});
it('empty line produces line div with no spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [''] }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0);
});
it('empty line produces line div with no spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [''] }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0);
});
// ─── CJK wide character support ───────────────────────────────
it('CJK characters get double-width spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a你b'], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3);
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
expect(spanA.style.width).toBe('10px'); // 1 cell
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
expect(spanCJK.textContent).toBe('你');
expect(spanCJK.style.left).toBe('10px'); // col 1
expect(spanCJK.style.width).toBe('20px'); // 2 cells
const spanB = lineDiv.children[2] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('30px'); // col 3
expect(spanB.style.width).toBe('10px'); // 1 cell
});
it('cursor position accounts for CJK width', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// 你(2) + 好(2) = 4 visual cols, cursor at startCol(2) + 4 = 6
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('60px');
});
it('mixed ASCII and CJK characters position correctly', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
// h(col 0), i(col 1), 你(col 2, width 2)
const spanH = lineDiv.children[0] as HTMLSpanElement;
expect(spanH.style.left).toBe('0px');
const spanI = lineDiv.children[1] as HTMLSpanElement;
expect(spanI.style.left).toBe('8px');
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
expect(spanCJK.style.left).toBe('16px');
expect(spanCJK.style.width).toBe('16px');
});
});
describe('charCellWidth', () => {
it('returns 1 for ASCII characters', () => {
expect(charCellWidth(null, 'a')).toBe(1);
expect(charCellWidth(null, '!')).toBe(1);
expect(charCellWidth(null, ' ')).toBe(1);
});
it('returns 2 for CJK ideographs', () => {
expect(charCellWidth(null, '你')).toBe(2);
expect(charCellWidth(null, '好')).toBe(2);
expect(charCellWidth(null, '中')).toBe(2);
});
it('returns 2 for Japanese hiragana', () => {
expect(charCellWidth(null, 'こ')).toBe(2);
expect(charCellWidth(null, 'ん')).toBe(2);
});
it('returns 2 for Korean syllables', () => {
expect(charCellWidth(null, '안')).toBe(2);
expect(charCellWidth(null, '녕')).toBe(2);
});
it('returns 2 for fullwidth forms', () => {
expect(charCellWidth(null, '\uff01')).toBe(2); // !
expect(charCellWidth(null, '\uff21')).toBe(2); // A
});
it('uses terminal unicode addon when available', () => {
const mockTerminal = {
unicode: { getStringCellWidth: (s: string) => (s === 'W' ? 2 : 1) },
} as any;
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
expect(charCellWidth(mockTerminal, 'n')).toBe(1);
});
});
describe('stringCellWidth', () => {
it('sums individual character widths', () => {
expect(stringCellWidth(null, 'abc')).toBe(3);
expect(stringCellWidth(null, '你好')).toBe(4);
expect(stringCellWidth(null, 'a你b')).toBe(4);
});
it('returns 0 for empty string', () => {
expect(stringCellWidth(null, '')).toBe(0);
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -56,7 +56,7 @@ appendFileSync(
);
// 4. Minify frontend assets
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
+189 -20
View File
@@ -78,6 +78,82 @@
// MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js
// DeepgramProvider, VoiceInput are in voice-input.js
// ═══════════════════════════════════════════════════════════════
// Global Error & Performance Diagnostics
// ═══════════════════════════════════════════════════════════════
// Writes breadcrumbs to localStorage so they survive tab freezes.
// After a crash, check: localStorage.getItem('codeman-crash-diag')
const _crashDiag = {
_entries: [],
_maxEntries: 50,
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
}
};
// Log previous crash breadcrumbs on startup
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
} catch {}
_crashDiag.log('PAGE LOAD');
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
setInterval(() => {
try {
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
}
} catch {}
}, 2000);
window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack);
});
window.addEventListener('unhandledrejection', (e) => {
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack);
});
// Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive"
if (typeof PerformanceObserver !== 'undefined') {
try {
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`);
}
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch { /* longtask not supported */ }
}
// Track WebGL context loss/restore events on all canvases
const _origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
const ctx = _origGetContext.call(this, type, ...args);
if (type === 'webgl2' || type === 'webgl') {
this.addEventListener('webglcontextlost', (e) => {
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
});
this.addEventListener('webglcontextrestored', () => {
_crashDiag.log('WEBGL_RESTORED');
console.warn('[CRASH-DIAG] WebGL context restored');
});
}
return ctx;
};
@@ -542,12 +618,23 @@ class CodemanApp {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure)
if (typeof WebglAddon !== 'undefined') {
// WebGL renderer disabled — canvas renderer used instead.
// xterm.js WebGL addon causes synchronous GPU ReadPixels calls during large
// terminal writes (buffer loads, heavy Ink output) that block Chrome's main
// thread for 10+ seconds, triggering "page unresponsive" crashes.
// Canvas renderer handles the same workloads without GPU stalls.
// Re-enable with ?webgl=1 URL param for testing.
this._webglAddon = null;
if (new URLSearchParams(location.search).has('webgl') && typeof WebglAddon !== 'undefined') {
try {
const webglAddon = new WebglAddon.WebglAddon();
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
this.terminal.loadAddon(webglAddon);
this._webglAddon = new WebglAddon.WebglAddon();
this._webglAddon.onContextLoss(() => {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
this._webglAddon.dispose();
this._webglAddon = null;
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled via ?webgl param');
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
}
@@ -1095,18 +1182,47 @@ class CodemanApp {
// Ink's status bar updates use cursor-up + erase-line + rewrite, which can split
// across render frames causing old/new status text to overlap (garbled output).
// Buffering for 50ms ensures the full redraw arrives atomically.
const hasCursorUpRedraw = /\x1b\[\d{1,2}A/.test(data);
//
// Shell mode is excluded: shell readline also uses cursor-up for prompt redraws
// (e.g. zsh syntax highlighting on every keystroke), and there's no Ink status bar
// to protect. Applying the filter in shell mode delays character feedback until the
// user stops typing for 50ms, making the terminal feel unresponsive.
const isShellMode = session?.mode === 'shell';
const hasCursorUpRedraw = !isShellMode && /\x1b\[\d{1,2}A/.test(data);
if (hasCursorUpRedraw || (this.flickerFilterActive && !flickerFilterEnabled)) {
this.flickerFilterActive = true;
this.flickerFilterBuffer += data;
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
// Only reset the 50ms timer on cursor-up events (start of a new Ink redraw cycle).
// Non-cursor-up events while the filter is active are trailing data from the same
// redraw — don't extend the deadline further. Without this guard, a busy Claude
// session emitting terminal data faster than SYNC_WAIT_TIMEOUT_MS never flushes,
// accumulating MBs in flickerFilterBuffer that freeze Chrome all at once.
if (hasCursorUpRedraw) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
}
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
} else if (!this.flickerFilterTimeout) {
// Safety: if no timer is running for some reason, ensure we eventually flush.
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS);
}
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
// Safety valve: if buffer grew very large (e.g. from a burst before the timer fired),
// flush immediately to avoid writing a huge block all at once.
if (this.flickerFilterBuffer.length > 256 * 1024) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
}
return;
}
@@ -1236,6 +1352,11 @@ class CodemanApp {
} catch { return null; }
}
});
} else if (session.mode === 'shell') {
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
// Disable it by clearing any pending text.
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
} else {
// Claude Code: scan for ❯ prompt character
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
@@ -1250,13 +1371,18 @@ class CodemanApp {
flushPendingWrites() {
if (this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now();
// Extract segments, stripping DEC 2026 markers
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
const segments = extractSyncSegments(this.pendingWrites.join(''));
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0);
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
const joined = this.pendingWrites.join('');
this.pendingWrites = [];
// Write all segments in a single batch (atomic within this frame)
// xterm.js internally batches multiple write() calls within same frame
const segments = extractSyncSegments(joined);
// Write all segments in a single batch (atomic within this frame).
// xterm.js internally batches multiple write() calls within same frame.
// Never discard content from incomplete sync blocks — xterm.js doesn't support
// DEC 2026 natively anyway, so strip the marker and write content regardless.
// Discarding causes real data loss (including Ink's erase-line escapes).
@@ -1268,6 +1394,8 @@ class CodemanApp {
if (content) this.terminal.write(content);
}
}
const _dt = performance.now() - _t0;
if (_dt > 100) console.warn(`[CRASH-DIAG] flushPendingWrites took ${_dt.toFixed(0)}ms (${_joinedLen} bytes)`);
// Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) {
@@ -1349,23 +1477,34 @@ class CodemanApp {
resolve();
};
// For small buffers, write directly
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
finish();
return;
}
// Large buffers: write in chunks across animation frames.
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
// avoiding GPU stalls without needing to toggle the renderer.
let offset = 0;
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
// Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(finish);
return;
}
const _ct0 = performance.now();
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk);
const _cdt = performance.now() - _ct0;
_chunkCount++;
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
offset += chunkSize;
// Schedule next chunk on next frame
@@ -1588,6 +1727,7 @@ class CodemanApp {
// Async handlers have their own internal try/catch for fetch errors.
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
}
@@ -1646,6 +1786,7 @@ class CodemanApp {
_onSessionTerminal(data) {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
this.batchTerminalWrite(data.data);
}
}
@@ -3307,6 +3448,10 @@ class CodemanApp {
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
@@ -3435,20 +3580,33 @@ class CodemanApp {
// filter catches most cases, but _restoringFlushedState provides a
// belt-and-suspenders guard for any edge cases.
this._restoringFlushedState = true;
// Gate live SSE terminal writes for the ENTIRE buffer load sequence.
// Without this, SSE events arriving during the fetch() gap compete with
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
try {
// Instant cache restore — show previous buffer immediately while network fetch runs
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
this.terminal.write(cachedBuffer);
await this.chunkedTerminalWrite(cachedBuffer);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
}
_crashDiag.log('FETCH_START');
const tailSize = 256 * 1024;
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
const data = await res.json();
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
@@ -3456,6 +3614,7 @@ class CodemanApp {
// very visible, causing the terminal to flash blank then repaint.
const needsRewrite = data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
// Show truncation indicator if buffer was cut
@@ -3464,7 +3623,7 @@ class CodemanApp {
}
// Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer);
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
@@ -3482,7 +3641,13 @@ class CodemanApp {
this.terminal.reset();
}
// Buffer load complete — drop the guard so user input clears state normally
// Buffer load complete — unblock live SSE writes and flush any queued events.
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
// the chunked write (small buffer, cache hit, or empty), we must call it here.
if (this._isLoadingBuffer) {
this._finishBufferLoad();
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
// Restore flushed offset and text for this session so the overlay positions
@@ -3582,9 +3747,13 @@ class CodemanApp {
}
});
_crashDiag.log('FOCUS');
this.terminal.focus();
this.terminal.scrollToBottom();
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad();
this._restoringFlushedState = false;
console.error('Failed to load session terminal:', err);
}
+1 -1
View File
@@ -52,7 +52,7 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal data (smaller to avoid WebGL GPU stalls)
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
+10 -10
View File
@@ -20,7 +20,7 @@
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.0"></script>
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -1674,14 +1674,14 @@
<!-- Lines drawn dynamically -->
</svg>
<script defer src="constants.js?v=0.3.0"></script>
<script defer src="mobile-handlers.js?v=0.3.0"></script>
<script defer src="voice-input.js?v=0.3.0"></script>
<script defer src="notification-manager.js?v=0.3.0"></script>
<script defer src="keyboard-accessory.js?v=0.3.0"></script>
<script defer src="app.js?v=0.3.0"></script>
<script defer src="ralph-wizard.js?v=0.3.0"></script>
<script defer src="api-client.js?v=0.3.0"></script>
<script defer src="subagent-windows.js?v=0.3.0"></script>
<script defer src="constants.js?v=0.3.2"></script>
<script defer src="mobile-handlers.js?v=0.3.2"></script>
<script defer src="voice-input.js?v=0.3.2"></script>
<script defer src="notification-manager.js?v=0.3.2"></script>
<script defer src="keyboard-accessory.js?v=0.3.2"></script>
<script defer src="app.js?v=0.3.2"></script>
<script defer src="ralph-wizard.js?v=0.3.2"></script>
<script defer src="api-client.js?v=0.3.2"></script>
<script defer src="subagent-windows.js?v=0.3.2"></script>
</body>
</html>
+24
View File
@@ -630,6 +630,30 @@ export class WebServer extends EventEmitter {
}
});
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
});
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
});
this.app.post('/api/crash-diag', (req, reply) => {
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
reply.code(204).send();
});
this.app.get('/api/crash-diag', (_req, reply) => {
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
});
// Register all route modules
const ctx = this.createRouteContext();
registerPushRoutes(this.app, ctx);
+5 -1
View File
@@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
const age = (key: string) => (isActive(key) ? pressAge : 99);
// Backspace/delete key highlight
const deleteHighlight = activeKey === '⌫' && pressAge < 4 ? 1 - pressAge / 4 : 0;
const deleteBg = deleteHighlight > 0 ? lerpColor(SPECIAL_BG, KEY_BG_ACTIVE, deleteHighlight) : SPECIAL_BG;
// Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
const letterKeyW = 33;
// Row 2 has 9 keys → same key width but centered with side padding
@@ -126,7 +130,7 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
width: 42,
height: KEY_H,
borderRadius: 5,
background: SPECIAL_BG,
background: deleteBg,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
+57 -26
View File
@@ -16,17 +16,17 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
// ─── Scene timing (frames @ 30fps) ───
const TITLE_DUR = 60;
const PHONES_DUR = 30;
const TYPING_DUR = 420;
const TYPING_DUR = 468;
const HOLD_DUR = 60;
const OUTRO_DUR = 45;
const TITLE_START = 0;
const PHONES_START = TITLE_DUR; // 60
const TYPING_START = PHONES_START + PHONES_DUR; // 90
const HOLD_START = TYPING_START + TYPING_DUR; // 510
const OUTRO_START = HOLD_START + HOLD_DUR; // 570
const HOLD_START = TYPING_START + TYPING_DUR; // 558
const OUTRO_START = HOLD_START + HOLD_DUR; // 618
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 615
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
// ─── iPhone 17 Pro safe area ───
const SAFE_AREA_TOP = 59; // Below Dynamic Island
@@ -42,25 +42,61 @@ const TERMINAL_TOP = SAFE_AREA_TOP + 52;
const TERMINAL_LEFT = 14;
const TERMINAL_FONT = 22; // Large for video readability
// ─── Typing schedule ───
const TYPED_TEXT = 'fix the auth bug in the login flow';
// ─── Typing schedule (with typo + backspace correction) ───
const CORRECT_TEXT = 'fix the auth bug in the login flow';
const FRAME_GAP = 12; // ~400ms between keystrokes
const TYPO_INDEX = 28; // After "logi", type "m" instead of "n"
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
const LAGGY_DELAYS = [
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
26, 32, 28, 34,
];
type KeyEvent = { frame: number; char: string; lagDelay: number };
type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number };
const TYPING_SCHEDULE: KeyEvent[] = TYPED_TEXT.split('').map((char, i) => ({
frame: i * FRAME_GAP,
char,
lagDelay: LAGGY_DELAYS[i % LAGGY_DELAYS.length],
}));
const buildSchedule = (): KeyAction[] => {
const actions: KeyAction[] = [];
let idx = 0;
const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length];
const CONFIRM_DELAY = 14;
// Type correctly up to typo point: "fix the auth bug in the logi"
for (let i = 0; i < TYPO_INDEX; i++) {
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
idx++;
}
// Typo: type "m" instead of "n"
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) });
idx++;
// Backspace to fix it
actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) });
idx++;
// Type correct remaining: "n flow"
for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) {
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
idx++;
}
return actions;
};
const TYPING_SCHEDULE = buildSchedule();
/** Replay actions in order up to current frame, computing the visible text buffer */
const computeVisibleText = (frame: number, withLag: boolean): string => {
let buffer = '';
for (const a of TYPING_SCHEDULE) {
const threshold = withLag ? a.frame + a.lagDelay : a.frame;
if (frame < threshold) break; // TCP-ordered: stop at first unresolved
if (a.action === 'backspace') buffer = buffer.slice(0, -1);
else buffer += a.char;
}
return buffer;
};
// ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
const IOSStatusBar: React.FC = () => (
@@ -295,13 +331,8 @@ const OutroScene: React.FC = () => {
const TypingDemo: React.FC = () => {
const frame = useCurrentFrame();
const laggyChars = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame + ev.lagDelay).map((ev) => ev.char);
const laggyTyped = laggyChars.join('');
const zerolagOverlay = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame).map((ev) => ({
char: ev.char,
confirmed: frame >= ev.frame + CONFIRM_DELAY,
}));
const laggyTyped = computeVisibleText(frame, true);
const zerolagTyped = computeVisibleText(frame, false);
let activeKey: string | undefined;
let pressAge = 99;
@@ -326,12 +357,12 @@ const TypingDemo: React.FC = () => {
}}
>
<div>
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
</div>
<div>
<MobileCodeman typed="" overlayChars={zerolagOverlay} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
</div>
</div>
</AbsoluteFill>
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
<div>
<MobileCodeman typed="" cursorVisible noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
</div>
<div>
<MobileCodeman typed="" cursorVisible noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
</div>
</div>
</AbsoluteFill>