Compare commits

...
Author SHA1 Message Date
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
14 changed files with 1271 additions and 117 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# aicodeman # aicodeman
## 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 ## 0.3.2
### Patch Changes ### 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` 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` 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.3 (must match `package.json`)
## Project Overview ## 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` | | | **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` | | | **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` | | | **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 | | **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. ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.3.2", "version": "0.3.3",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "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. * Render the overlay content into the container element.
@@ -6,13 +52,28 @@ import type { RenderParams, FontStyle } from './types.js';
* Creates per-character `<span>` elements positioned on an exact grid * Creates per-character `<span>` elements positioned on an exact grid
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that * matching xterm.js's canvas renderer. This avoids sub-pixel drift that
* occurs with normal DOM text flow. * occurs with normal DOM text flow.
*
* CJK wide characters are rendered with double-width spans.
*/ */
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { 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. // Position container at prompt row.
container.style.left = '0px'; container.style.left = '0px';
container.style.top = (promptRow * cellH) + 'px'; container.style.top = promptRow * cellH + 'px';
// Clear and rebuild (typically 1-3 line divs, negligible cost) // Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = ''; container.innerHTML = '';
@@ -20,22 +81,22 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const leftPx = i === 0 ? startCol * cellW : 0; const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx; const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH; const topPx = i * cellH;
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font); const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
container.appendChild(lineEl); container.appendChild(lineEl);
} }
// Block cursor at end of last line // Block cursor at end of last line (use visual width for CJK support)
if (showCursor) { if (showCursor) {
const lastLine = lines[lines.length - 1]; const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0; const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + lastLine.length; const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
if (cursorCol < totalCols) { if (cursorCol < totalCols) {
const cursor = document.createElement('span'); const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block'; cursor.style.cssText = 'position:absolute;display:inline-block';
cursor.style.left = (cursorCol * cellW) + 'px'; cursor.style.left = cursorCol * cellW + 'px';
cursor.style.top = ((lines.length - 1) * cellH) + 'px'; cursor.style.top = (lines.length - 1) * cellH + 'px';
cursor.style.width = cellW + 'px'; cursor.style.width = cellW + 'px';
cursor.style.height = cellH + 'px'; cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor; cursor.style.backgroundColor = cursorColor;
@@ -49,9 +110,8 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
/** /**
* Create a styled line `<div>` with per-character grid positioning. * Create a styled line `<div>` with per-character grid positioning.
* *
* Each character gets its own `<span>` placed at `i * cellW` pixels. * Each character gets its own `<span>` positioned by visual column offset.
* This matches xterm's canvas renderer where each glyph occupies exactly * CJK wide characters occupy 2 cell widths.
* one cell width, regardless of the actual glyph metrics.
*/ */
function makeLine( function makeLine(
text: string, text: string,
@@ -60,9 +120,10 @@ function makeLine(
widthPx: number, widthPx: number,
cellH: number, cellH: number,
cellW: number, cellW: number,
charTop: number, _charTop: number,
charHeight: number, _charHeight: number,
font: FontStyle, font: FontStyle,
terminal?: XtermTerminal | null
): HTMLDivElement { ): HTMLDivElement {
const el = document.createElement('div'); const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none'; el.style.cssText = 'position:absolute;pointer-events:none';
@@ -74,22 +135,20 @@ function makeLine(
// seam between the overlay layer (z-index:7) and the canvas layer below. // 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 // The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured. // text rendering starts), so no canvas content is obscured.
el.style.height = (cellH + 1) + 'px'; el.style.height = cellH + 1 + 'px';
// Spans fill the full cell height with matching lineHeight for natural // CJK wide chars occupy 2 cells — position by visual column offset
// CSS vertical centering. No transform — any sub-pixel overhang past let colOffset = 0;
// the line div causes visible anti-aliasing artifacts at the boundary. for (const ch of text) {
// The ≤0.5px difference from canvas ceil() rounding is imperceptible. const cw = charCellWidth(terminal, ch);
for (let i = 0; i < text.length; i++) {
const span = document.createElement('span'); const span = document.createElement('span');
// No ligatures — canvas renders each glyph independently. // No ligatures — canvas renders each glyph independently.
span.style.cssText = span.style.cssText =
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
"font-feature-settings:'liga' 0,'calt' 0"; "font-feature-settings:'liga' 0,'calt' 0";
span.style.left = (i * cellW) + 'px'; span.style.left = colOffset * cellW + 'px';
span.style.top = '0px'; span.style.top = '0px';
span.style.width = cellW + 'px'; span.style.width = cw * cellW + 'px';
span.style.height = cellH + 'px'; span.style.height = cellH + 'px';
span.style.lineHeight = cellH + 'px'; span.style.lineHeight = cellH + 'px';
span.style.fontFamily = font.fontFamily; span.style.fontFamily = font.fontFamily;
@@ -97,8 +156,9 @@ function makeLine(
span.style.fontWeight = font.fontWeight; span.style.fontWeight = font.fontWeight;
span.style.color = font.color; span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing; if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
span.textContent = text[i]; span.textContent = ch;
el.appendChild(span); el.appendChild(span);
colOffset += cw;
} }
return el; return el;
+11 -2
View File
@@ -22,11 +22,18 @@ export interface XtermTerminal {
readonly active: { readonly active: {
readonly viewportY: number; readonly viewportY: number;
readonly baseY: number; readonly baseY: number;
getLine(y: number): { getLine(y: number):
| {
translateToString(trimRight?: boolean): string; translateToString(trimRight?: boolean): string;
} | undefined; }
| undefined;
}; };
}; };
/** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */
readonly unicode?: {
getStringCellWidth(str: string): number;
activeVersion?: string;
};
} }
/** /**
@@ -158,6 +165,8 @@ export interface RenderParams {
font: FontStyle; font: FontStyle;
showCursor: boolean; showCursor: boolean;
cursorColor: string; cursorColor: string;
/** Terminal instance for CJK wide character width detection */
terminal?: XtermTerminal | null;
} }
/** Cached font style properties for overlay rendering. */ /** Cached font style properties for overlay rendering. */
@@ -9,7 +9,7 @@ import type {
} from './types.js'; } from './types.js';
import { getCellDimensions } from './cell-dimensions.js'; import { getCellDimensions } from './cell-dimensions.js';
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js'; import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
import { renderOverlay } from './overlay-renderer.js'; import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 }; const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
const DEFAULT_Z_INDEX = 7; const DEFAULT_Z_INDEX = 7;
@@ -59,9 +59,8 @@ const DEFAULT_CURSOR = '#e0e0e0';
export class ZerolagInputAddon implements XtermAddon { export class ZerolagInputAddon implements XtermAddon {
private _terminal: XtermTerminal | null = null; private _terminal: XtermTerminal | null = null;
private _overlay: HTMLDivElement | null = null; private _overlay: HTMLDivElement | null = null;
private _options: Required< private _options: Required<Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'>> &
Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'> ZerolagInputOptions;
> & ZerolagInputOptions;
// Text state // Text state
private _pendingText = ''; private _pendingText = '';
@@ -110,8 +109,7 @@ export class ZerolagInputAddon implements XtermAddon {
// Create overlay container // Create overlay container
this._overlay = document.createElement('div'); this._overlay = document.createElement('div');
this._overlay.style.cssText = this._overlay.style.cssText = `position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
`position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
// Insert into xterm DOM // Insert into xterm DOM
const screen = terminal.element?.querySelector('.xterm-screen'); const screen = terminal.element?.querySelector('.xterm-screen');
@@ -140,7 +138,9 @@ export class ZerolagInputAddon implements XtermAddon {
this._render(); this._render();
}, this._options.scrollDebounceMs); }, this._options.scrollDebounceMs);
} }
} catch { /* ignore */ } } catch {
/* ignore */
}
}; };
const viewport = terminal.element?.querySelector('.xterm-viewport'); const viewport = terminal.element?.querySelector('.xterm-viewport');
@@ -466,7 +466,9 @@ export class ZerolagInputAddon implements XtermAddon {
this._bufferDetectDone = true; this._bufferDetectDone = true;
return afterPrompt; return afterPrompt;
} }
} catch { /* ignore */ } } catch {
/* ignore */
}
return null; return null;
} }
@@ -478,14 +480,8 @@ export class ZerolagInputAddon implements XtermAddon {
this._font.fontFamily = t.options.fontFamily || 'monospace'; this._font.fontFamily = t.options.fontFamily || 'monospace';
this._font.fontSize = (t.options.fontSize || 14) + 'px'; this._font.fontSize = (t.options.fontSize || 14) + 'px';
this._font.fontWeight = String(t.options.fontWeight || 'normal'); this._font.fontWeight = String(t.options.fontWeight || 'normal');
this._font.backgroundColor = this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? DEFAULT_BG;
this._options.backgroundColor ?? this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? DEFAULT_FG;
t.options.theme?.background ??
DEFAULT_BG;
this._font.color =
this._options.foregroundColor ??
t.options.theme?.foreground ??
DEFAULT_FG;
this._font.letterSpacing = ''; this._font.letterSpacing = '';
// Prefer computed styles from rendered rows (matches actual rendering) // Prefer computed styles from rendered rows (matches actual rendering)
@@ -573,21 +569,39 @@ export class ZerolagInputAddon implements XtermAddon {
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey; this._lastRenderKey = renderKey;
// Split text into visual lines matching terminal character-wrap // Split into visual lines by column width (CJK wide chars = 2 cols)
const firstLineCols = Math.max(1, totalCols - startCol); const firstLineCols = Math.max(1, totalCols - startCol);
const chars = [...displayText]; // proper Unicode iteration
const lines: string[] = []; const lines: string[] = [];
let remaining = displayText; let ci = 0;
lines.push(remaining.slice(0, firstLineCols)); // First line: remaining columns after prompt
remaining = remaining.slice(firstLineCols); {
while (remaining.length > 0) { let lineStr = '';
lines.push(remaining.slice(0, totalCols)); let lineCols = 0;
remaining = remaining.slice(totalCols); while (ci < chars.length) {
const cw = charCellWidth(this._terminal, chars[ci]);
if (lineCols + cw > firstLineCols) break;
lineStr += chars[ci];
lineCols += cw;
ci++;
}
lines.push(lineStr);
}
// Subsequent lines: full terminal width
while (ci < chars.length) {
let lineStr = '';
let lineCols = 0;
while (ci < chars.length) {
const cw = charCellWidth(this._terminal, chars[ci]);
if (lineCols + cw > totalCols) break;
lineStr += chars[ci];
lineCols += cw;
ci++;
}
lines.push(lineStr);
} }
const cursorColor = const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? DEFAULT_CURSOR;
this._options.cursorColor ??
this._terminal.options.theme?.cursor ??
DEFAULT_CURSOR;
renderOverlay(this._overlay, { renderOverlay(this._overlay, {
lines, lines,
@@ -601,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon {
font: this._font, font: this._font,
showCursor: this._options.showCursor, showCursor: this._options.showCursor,
cursorColor, cursorColor,
terminal: this._terminal,
}); });
} catch { } catch {
// Hide on render error but preserve pendingText — // Hide on render error but preserve pendingText —
@@ -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,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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'; import type { RenderParams, FontStyle } from '../src/types.js';
const FONT: FontStyle = { const FONT: FontStyle = {
@@ -32,7 +32,7 @@ describe('renderOverlay', () => {
it('positions container at prompt row', () => { it('positions container at prompt row', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 })); renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe((5 * 17) + 'px'); expect(container.style.top).toBe(5 * 17 + 'px');
expect(container.style.left).toBe('0px'); expect(container.style.left).toBe('0px');
}); });
@@ -101,14 +101,17 @@ describe('renderOverlay', () => {
it('renders cursor at end of text', () => { it('renders cursor at end of text', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
container,
makeParams({
lines: ['ab'], lines: ['ab'],
startCol: 3, startCol: 3,
cellW: 10, cellW: 10,
cellH: 20, cellH: 20,
showCursor: true, showCursor: true,
cursorColor: '#ff00ff', cursorColor: '#ff00ff',
})); })
);
// Last child is cursor (after line div) // Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement; const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
@@ -129,12 +132,15 @@ describe('renderOverlay', () => {
it('renders multi-line text', () => { it('renders multi-line text', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
container,
makeParams({
lines: ['first', 'second'], lines: ['first', 'second'],
startCol: 5, startCol: 5,
cellW: 10, cellW: 10,
cellH: 20, cellH: 20,
})); })
);
// 2 line divs + cursor // 2 line divs + cursor
expect(container.children.length).toBe(3); expect(container.children.length).toBe(3);
@@ -184,16 +190,19 @@ describe('renderOverlay', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH })); renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe((cellH + 1) + 'px'); expect(lineDiv.style.height).toBe(cellH + 1 + 'px');
} }
}); });
it('multi-line overlay has cellH+1 height on each line div', () => { it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
container,
makeParams({
lines: ['first', 'second'], lines: ['first', 'second'],
cellH: 19, cellH: 19,
})); })
);
const line1 = container.children[0] as HTMLDivElement; const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement; const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px'); expect(line1.style.height).toBe('20px');
@@ -230,7 +239,7 @@ describe('renderOverlay', () => {
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings // Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled // 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('font-feature-settings:');
expect(span.style.cssText).toContain("'liga' 0"); expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0"); expect(span.style.cssText).toContain("'calt' 0");
}); });
@@ -255,13 +264,16 @@ describe('renderOverlay', () => {
it('cursor on wrapped line uses col 0 as base', () => { it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
container,
makeParams({
lines: ['first', 'ab'], lines: ['first', 'ab'],
startCol: 5, startCol: 5,
cellW: 10, cellW: 10,
cellH: 20, cellH: 20,
showCursor: true, showCursor: true,
})); })
);
// Cursor at end of second line: col = 0 + 2 = 2 // Cursor at end of second line: col = 0 + 2 = 2
const cursor = container.children[container.children.length - 1] as HTMLSpanElement; const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10 expect(cursor.style.left).toBe('20px'); // 2 * 10
@@ -272,11 +284,16 @@ describe('renderOverlay', () => {
it('accepts charTop and charHeight params without error', () => { it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div'); const container = document.createElement('div');
expect(() => renderOverlay(container, makeParams({ expect(() =>
renderOverlay(
container,
makeParams({
lines: ['test'], lines: ['test'],
charTop: 2, charTop: 2,
charHeight: 14, charHeight: 14,
}))).not.toThrow(); })
)
).not.toThrow();
expect(container.children.length).toBeGreaterThan(0); expect(container.children.length).toBeGreaterThan(0);
}); });
@@ -297,4 +314,107 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0); 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);
});
}); });
@@ -376,7 +376,10 @@ describe('ZerolagInputAddon', () => {
prompt: { type: 'character', char: '\u276f', offset: 2 }, prompt: { type: 'character', char: '\u276f', offset: 2 },
}); });
mock.terminal.loadAddon(addon); mock.terminal.loadAddon(addon);
cleanups.push(() => { addon.dispose(); mock.cleanup(); }); cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const text = addon.readPromptText(); const text = addon.readPromptText();
expect(text).toBe('hello'); expect(text).toBe('hello');
@@ -536,7 +539,10 @@ describe('ZerolagInputAddon', () => {
prompt: { type: 'character', char: '$', offset: 2 }, prompt: { type: 'character', char: '$', offset: 2 },
}); });
mock.terminal.loadAddon(addon); mock.terminal.loadAddon(addon);
cleanups.push(() => { addon.dispose(); mock.cleanup(); }); cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
// Switch to regex // Switch to regex
addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 }); addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 });
@@ -647,6 +653,58 @@ describe('ZerolagInputAddon', () => {
}); });
}); });
describe('CJK wide character support', () => {
it('addChar works with CJK characters', () => {
const { addon } = tracked();
addon.addChar('你');
addon.addChar('好');
expect(addon.pendingText).toBe('你好');
});
it('appendText works with CJK characters', () => {
const { addon } = tracked();
addon.appendText('こんにちは');
expect(addon.pendingText).toBe('こんにちは');
});
it('removeChar removes CJK characters correctly', () => {
const { addon } = tracked();
addon.addChar('你');
addon.addChar('好');
addon.removeChar();
expect(addon.pendingText).toBe('你');
});
it('mixed ASCII and CJK renders without error', () => {
const { addon } = tracked();
addon.addChar('h');
addon.addChar('i');
addon.addChar('你');
addon.addChar('好');
expect(addon.pendingText).toBe('hi你好');
expect(addon.hasPending).toBe(true);
});
it('CJK line wrapping accounts for double-width', () => {
// With 10 cols and startCol=2, first line has 8 available cols
// Each CJK char takes 2 cols, so 4 CJK chars fill the first line
const { addon } = tracked(['$ '], '$');
// Type 5 CJK chars — should overflow first line
for (const ch of '你好世界啊') {
addon.addChar(ch);
}
expect(addon.pendingText).toBe('你好世界啊');
expect(addon.hasPending).toBe(true);
});
it('Korean text renders without error', () => {
const { addon } = tracked();
addon.appendText('안녕하세요');
expect(addon.pendingText).toBe('안녕하세요');
expect(addon.hasPending).toBe(true);
});
});
describe('addChar implicit buffer detection', () => { describe('addChar implicit buffer detection', () => {
it('first keystroke detects existing buffer text as flushed', () => { it('first keystroke detects existing buffer text as flushed', () => {
const { addon } = tracked(['$ existing']); const { addon } = tracked(['$ existing']);
+15 -7
View File
@@ -542,12 +542,14 @@ class CodemanApp {
const container = document.getElementById('terminalContainer'); const container = document.getElementById('terminalContainer');
this.terminal.open(container); this.terminal.open(container);
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure) // Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure).
// Store reference so we can disable during large buffer loads to prevent GPU stalls.
this._webglAddon = null;
if (typeof WebglAddon !== 'undefined') { if (typeof WebglAddon !== 'undefined') {
try { try {
const webglAddon = new WebglAddon.WebglAddon(); this._webglAddon = new WebglAddon.WebglAddon();
webglAddon.onContextLoss(() => { webglAddon.dispose(); }); this._webglAddon.onContextLoss(() => { this._webglAddon.dispose(); this._webglAddon = null; });
this.terminal.loadAddon(webglAddon); this.terminal.loadAddon(this._webglAddon);
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ } } catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
} }
@@ -1349,13 +1351,16 @@ class CodemanApp {
resolve(); resolve();
}; };
// For small buffers, write directly // For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) { if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer); this.terminal.write(cleanBuffer);
finish(); finish();
return; 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; let offset = 0;
const writeChunk = () => { const writeChunk = () => {
if (offset >= cleanBuffer.length) { if (offset >= cleanBuffer.length) {
@@ -3436,12 +3441,15 @@ class CodemanApp {
// belt-and-suspenders guard for any edge cases. // belt-and-suspenders guard for any edge cases.
this._restoringFlushedState = true; this._restoringFlushedState = true;
try { 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); const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer) { if (cachedBuffer) {
this.terminal.clear(); this.terminal.clear();
this.terminal.reset(); this.terminal.reset();
this.terminal.write(cachedBuffer); await this.chunkedTerminalWrite(cachedBuffer);
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
} }
+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 BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay 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 TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling 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-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.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-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 --> <!-- 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> <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) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -1674,14 +1674,14 @@
<!-- Lines drawn dynamically --> <!-- Lines drawn dynamically -->
</svg> </svg>
<script defer src="constants.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.0"></script> <script defer src="mobile-handlers.js?v=0.3.2"></script>
<script defer src="voice-input.js?v=0.3.0"></script> <script defer src="voice-input.js?v=0.3.2"></script>
<script defer src="notification-manager.js?v=0.3.0"></script> <script defer src="notification-manager.js?v=0.3.2"></script>
<script defer src="keyboard-accessory.js?v=0.3.0"></script> <script defer src="keyboard-accessory.js?v=0.3.2"></script>
<script defer src="app.js?v=0.3.0"></script> <script defer src="app.js?v=0.3.2"></script>
<script defer src="ralph-wizard.js?v=0.3.0"></script> <script defer src="ralph-wizard.js?v=0.3.2"></script>
<script defer src="api-client.js?v=0.3.0"></script> <script defer src="api-client.js?v=0.3.2"></script>
<script defer src="subagent-windows.js?v=0.3.0"></script> <script defer src="subagent-windows.js?v=0.3.2"></script>
</body> </body>
</html> </html>
+5 -1
View File
@@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase(); activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
const age = (key: string) => (isActive(key) ? pressAge : 99); 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 // Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
const letterKeyW = 33; const letterKeyW = 33;
// Row 2 has 9 keys → same key width but centered with side padding // 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, width: 42,
height: KEY_H, height: KEY_H,
borderRadius: 5, borderRadius: 5,
background: SPECIAL_BG, background: deleteBg,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
+57 -26
View File
@@ -16,17 +16,17 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
// ─── Scene timing (frames @ 30fps) ─── // ─── Scene timing (frames @ 30fps) ───
const TITLE_DUR = 60; const TITLE_DUR = 60;
const PHONES_DUR = 30; const PHONES_DUR = 30;
const TYPING_DUR = 420; const TYPING_DUR = 468;
const HOLD_DUR = 60; const HOLD_DUR = 60;
const OUTRO_DUR = 45; const OUTRO_DUR = 45;
const TITLE_START = 0; const TITLE_START = 0;
const PHONES_START = TITLE_DUR; // 60 const PHONES_START = TITLE_DUR; // 60
const TYPING_START = PHONES_START + PHONES_DUR; // 90 const TYPING_START = PHONES_START + PHONES_DUR; // 90
const HOLD_START = TYPING_START + TYPING_DUR; // 510 const HOLD_START = TYPING_START + TYPING_DUR; // 558
const OUTRO_START = HOLD_START + HOLD_DUR; // 570 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 ─── // ─── iPhone 17 Pro safe area ───
const SAFE_AREA_TOP = 59; // Below Dynamic Island 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_LEFT = 14;
const TERMINAL_FONT = 22; // Large for video readability const TERMINAL_FONT = 22; // Large for video readability
// ─── Typing schedule ─── // ─── Typing schedule (with typo + backspace correction) ───
const TYPED_TEXT = 'fix the auth bug in the login flow'; const CORRECT_TEXT = 'fix the auth bug in the login flow';
const FRAME_GAP = 12; // ~400ms between keystrokes 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) // Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
const LAGGY_DELAYS = [ const LAGGY_DELAYS = [
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22, 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, 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) => ({ const buildSchedule = (): KeyAction[] => {
frame: i * FRAME_GAP, const actions: KeyAction[] = [];
char, let idx = 0;
lagDelay: LAGGY_DELAYS[i % LAGGY_DELAYS.length], 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) ─── // ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
const IOSStatusBar: React.FC = () => ( const IOSStatusBar: React.FC = () => (
@@ -295,13 +331,8 @@ const OutroScene: React.FC = () => {
const TypingDemo: React.FC = () => { const TypingDemo: React.FC = () => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const laggyChars = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame + ev.lagDelay).map((ev) => ev.char); const laggyTyped = computeVisibleText(frame, true);
const laggyTyped = laggyChars.join(''); const zerolagTyped = computeVisibleText(frame, false);
const zerolagOverlay = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame).map((ev) => ({
char: ev.char,
confirmed: frame >= ev.frame + CONFIRM_DELAY,
}));
let activeKey: string | undefined; let activeKey: string | undefined;
let pressAge = 99; let pressAge = 99;
@@ -326,12 +357,12 @@ const TypingDemo: React.FC = () => {
}} }}
> >
<div> <div>
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation /> <MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} 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>
<div> <div>
<MobileCodeman typed="" overlayChars={zerolagOverlay} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation /> <MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} 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>
</div> </div>
</AbsoluteFill> </AbsoluteFill>
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
<div> <div>
<MobileCodeman typed="" cursorVisible noAnimation /> <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>
<div> <div>
<MobileCodeman typed="" cursorVisible noAnimation /> <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>
</div> </div>
</AbsoluteFill> </AbsoluteFill>