Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin
- **Themes** — CSS custom properties with built-in Default, Solarized Dark, Monokai, and Light themes
- **Alternate screen buffer** — `vim`, `less`, `htop`, and similar apps work correctly
- **Scrollback history** — configurable ring buffer
- **Wide Unicode cells** — CJK, fullwidth, and emoji codepoints keep cursor-addressed redraws aligned
- **24-bit color** — full RGB SGR support
- **Auto-resize** — `ResizeObserver`-based terminal resizing
- **WebSocket transport** — connect to a PTY backend with binary framing and reconnection
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/app/api-reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ interface CellData {
fg: number; // Foreground color index (256 = default)
bg: number; // Background color index (256 = default)
flags: number; // Style flags (bold, italic, underline, etc.)
width?: number; // 1 = narrow, 2 = wide leading cell, 0 = continuation
}

interface CursorState {
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/app/core/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const cursor = bridge.getCursor();
// → { row: 1, col: 13, visible: true }
```

Wide single-codepoint characters such as CJK, fullwidth forms, and emoji expose `width: 2` on the leading cell and `width: 0` on the continuation cell. Custom renderers should skip continuation cells.

## WebSocketTransport

Connect to a PTY backend over WebSocket with automatic reconnection and send buffering.
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/app/ghostty/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ pnpm --filter @wterm/ghostty rebuild-wasm
</tr>
<tr>
<td>Unicode</td>
<td>Single codepoints</td>
<td>Single codepoints with wide-cell support</td>
<td>Full grapheme clusters</td>
</tr>
<tr>
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/app/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { HeroSection } from "@/components/hero-terminal";
- **Themes** — CSS custom properties with built-in Default, Solarized Dark, Monokai, and Light themes
- **Alternate screen buffer** — `vim`, `less`, `htop` work correctly
- **Scrollback history** — configurable ring buffer
- **Wide Unicode cells** — CJK, fullwidth, and emoji codepoints stay aligned during cursor-addressed redraws
- **24-bit color** — full RGB SGR support
- **Auto-resize** — `ResizeObserver`-based terminal resizing
- **WebSocket transport** — connect to a PTY backend with reconnection
19 changes: 19 additions & 0 deletions e2e/tests/terminal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ test.describe("rendering", () => {
test("displays greeting text", async ({ page }) => {
await expect(page.locator(".wterm")).toContainText("Welcome to wterm!");
});

test("keeps cursor-addressed redraws aligned after wide characters", async ({
page,
}) => {
const terminal = page.locator(".wterm");
await terminal.click();

await page.keyboard.type(
'printf "\\033[2J\\033[H\\360\\237\\223\\201abcd\\033[1;4Hx"',
{ delay: 5 },
);
await page.keyboard.press("Enter");

await expect(terminal).toContainText("📁axcd", { timeout: 5000 });
await expect(terminal).not.toContainText("📁abxd");
await expect(
terminal.locator(".term-wide").filter({ hasText: "📁" }),
).toHaveCount(1);
});
});

test.describe("keyboard input", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/@wterm/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const bridge = await WasmBridge.load();
bridge.init(80, 24);
bridge.writeString("Hello, world!\r\n");

const cell = bridge.getCell(0, 0); // { char, fg, bg, flags }
const cell = bridge.getCell(0, 0); // { char, fg, bg, flags, width }
const cursor = bridge.getCursor(); // { row, col, visible }
```

Expand All @@ -59,7 +59,7 @@ const cursor = bridge.getCursor(); // { row, col, visible }
| `writeString(str)` | Write a UTF-8 string to the terminal |
| `writeRaw(data: Uint8Array)` | Write raw bytes to the terminal |
| `resize(cols, rows)` | Resize the terminal grid |
| `getCell(row, col)` | Get cell data (`{ char, fg, bg, flags }`) |
| `getCell(row, col)` | Get cell data (`{ char, fg, bg, flags, width }`) |
| `getCursor()` | Get cursor state (`{ row, col, visible }`) |
| `getCols()` / `getRows()` | Get current grid dimensions |
| `isDirtyRow(row)` | Check if a row needs re-rendering |
Expand Down
18 changes: 18 additions & 0 deletions packages/@wterm/core/src/__tests__/wasm-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ describe("WasmBridge", () => {
expect(bridge.getCell(0, 1).char).toBe(105); // 'i'
});

it("tracks wide cells and continuation cells", () => {
bridge.writeString("📁a");
expect(bridge.getCell(0, 0).char).toBe(0x1f4c1);
expect(bridge.getCell(0, 0).width).toBe(2);
expect(bridge.getCell(0, 1).width).toBe(0);
expect(bridge.getCell(0, 2).char).toBe(97); // 'a'
expect(bridge.getCursor().col).toBe(3);
});

it("keeps cursor-positioned redraws aligned after wide characters", () => {
bridge.writeString("📁abcd");
bridge.writeString("\x1b[1;4Hx");
expect(bridge.getCell(0, 2).char).toBe(97); // 'a'
expect(bridge.getCell(0, 3).char).toBe(120); // 'x'
expect(bridge.getCell(0, 4).char).toBe(99); // 'c'
expect(bridge.getCell(0, 5).char).toBe(100); // 'd'
});

it("writes to correct position after cursor movement", () => {
bridge.writeString("AB\r\nCD");
expect(bridge.getCell(0, 0).char).toBe(65); // 'A'
Expand Down
2 changes: 2 additions & 0 deletions packages/@wterm/core/src/terminal-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export interface CellData {
fg: number;
bg: number;
flags: number;
/** Display width: 1 = narrow, 2 = wide leading cell, 0 = wide continuation cell. */
width?: number;
/** Resolved 24-bit foreground color (0xRRGGBB). Present when the core provides true color. */
fgRgb?: number;
/** Resolved 24-bit background color (0xRRGGBB). Present when the core provides true color. */
Expand Down
2 changes: 2 additions & 0 deletions packages/@wterm/core/src/wasm-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class WasmBridge implements TerminalCore {
fg: dv.getUint16(offset + 4, true),
bg: dv.getUint16(offset + 6, true),
flags: dv.getUint8(offset + 8),
width: dv.getUint8(offset + 9),
};
}

Expand Down Expand Up @@ -187,6 +188,7 @@ export class WasmBridge implements TerminalCore {
fg: dv.getUint16(off + 4, true),
bg: dv.getUint16(off + 6, true),
flags: dv.getUint8(off + 8),
width: dv.getUint8(off + 9),
};
}

Expand Down
Binary file modified packages/@wterm/core/wasm/wterm.wasm
Binary file not shown.
47 changes: 45 additions & 2 deletions packages/@wterm/dom/src/__tests__/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ function createMockBridge(cols: number, rows: number, grid: CellData[][] = []) {
};
}

function makeCell(char: string, fg = 256, bg = 256, flags = 0): CellData {
return { char: char.codePointAt(0)!, fg, bg, flags };
function makeCell(
char: string,
fg = 256,
bg = 256,
flags = 0,
width = 1,
): CellData {
return { char: char.codePointAt(0)!, fg, bg, flags, width };
}

describe("Renderer", () => {
Expand Down Expand Up @@ -66,6 +72,43 @@ describe("Renderer", () => {
expect(text).toContain("i");
});

it("renders wide cells once and skips continuation cells", () => {
const grid = [
[
makeCell(String.fromCodePoint(0x1f4c1), 256, 256, 0, 2),
{ char: 0, fg: 256, bg: 256, flags: 0, width: 0 },
makeCell("a"),
makeCell("b"),
],
];
const bridge = createMockBridge(4, 1, grid);
const renderer = new Renderer(container);
renderer.render(bridge as any);

const row = container.querySelector(".term-row");
expect(row?.textContent).toBe(`${String.fromCodePoint(0x1f4c1)}ab`);
expect(container.querySelector(".term-wide")?.textContent).toBe(
String.fromCodePoint(0x1f4c1),
);
});

it("places the cursor correctly after a wide cell", () => {
const grid = [
[
makeCell(String.fromCodePoint(0x1f4c1), 256, 256, 0, 2),
{ char: 0, fg: 256, bg: 256, flags: 0, width: 0 },
makeCell("a"),
makeCell("b"),
],
];
const bridge = createMockBridge(4, 1, grid);
bridge.getCursor = () => ({ row: 0, col: 3, visible: true });
const renderer = new Renderer(container);
renderer.render(bridge as any);

expect(container.querySelector(".term-cursor")?.textContent).toBe("b");
});

it("applies cursor class to cursor position", () => {
const grid = [[makeCell("A"), makeCell("B")]];
const bridge = createMockBridge(2, 1, grid);
Expand Down
56 changes: 47 additions & 9 deletions packages/@wterm/dom/src/renderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TerminalCore } from "@wterm/core";
import type { CellData, TerminalCore } from "@wterm/core";

const DEFAULT_COLOR = 256;
const FLAG_BOLD = 0x01;
Expand Down Expand Up @@ -238,14 +238,7 @@ export class Renderer {

private _buildRowContent(
rowEl: HTMLDivElement,
getCell: (col: number) => {
char: number;
fg: number;
bg: number;
flags: number;
fgRgb?: number;
bgRgb?: number;
},
getCell: (col: number) => CellData,
lineLen: number,
cursorCol: number,
rowIndex: number,
Expand Down Expand Up @@ -286,10 +279,55 @@ export class Renderer {
}
};

const appendStyledSpan = (
className: string,
style: string,
text: string,
) => {
const classAttr = className ? ` class="${className}"` : "";
const styleAttr = style ? ` style="${style}"` : "";
html += `<span${classAttr}${styleAttr}>${escapeHTML(text)}</span>`;
};

for (let col = 0; col < this.cols; col++) {
const cell = getCell(col);
const inBounds = col < lineLen;
const cp = inBounds ? cell.char : 0;
const width = inBounds ? (cell.width ?? 1) : 1;

if (inBounds && width === 0) {
flushRun(col);
if (col === cursorCol) {
appendStyledSpan("term-cursor", "", " ");
}
Comment on lines +300 to +302

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (col === cursorCol) {
appendStyledSpan("term-cursor", "", " ");
}

When cursor is on a wide character's continuation cell (width=0), a redundant cursor-styled space span is emitted, breaking grid alignment.

Fix on Vercel

runStyle = "";
runText = "";
runStart = col + 1;
continue;
}

if (inBounds && width === 2) {
flushRun(col);

const ch = cp >= 32 ? String.fromCodePoint(cp) : " ";
const style = buildCellStyle(
cell.fg,
cell.bg,
cell.flags,
cell.fgRgb,
cell.bgRgb,
);
const cls =
cursorCol >= col && cursorCol < col + 2
? "term-wide term-cursor"
: "term-wide";
appendStyledSpan(cls, style, ch);

runStyle = "";
runText = "";
runStart = col + 2;
continue;
}

if (inBounds && cp >= 0x2580 && cp <= 0x259f) {
flushRun(col);
Expand Down
5 changes: 5 additions & 0 deletions packages/@wterm/dom/src/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
overflow: hidden;
}

.term-wide {
width: 2ch;
overflow: hidden;
}

.term-cursor {
outline: 1px solid var(--term-cursor);
outline-offset: -1px;
Expand Down
6 changes: 5 additions & 1 deletion src/cell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ pub const FLAG_REVERSE: u8 = 0x20;
pub const FLAG_INVISIBLE: u8 = 0x40;
pub const FLAG_STRIKETHROUGH: u8 = 0x80;

pub const WIDTH_CONTINUATION: u8 = 0;
pub const WIDTH_NARROW: u8 = 1;
pub const WIDTH_WIDE: u8 = 2;

/// 12-byte extern struct with C-compatible layout so JS can read directly from WASM memory.
pub const Cell = extern struct {
char: u32 = ' ',
fg: u16 = DEFAULT_COLOR,
bg: u16 = DEFAULT_COLOR,
flags: u8 = 0,
_pad1: u8 = 0,
width: u8 = WIDTH_NARROW,
_pad2: u8 = 0,
_pad3: u8 = 0,

Expand Down
20 changes: 17 additions & 3 deletions src/grid.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const Cell = @import("cell.zig").Cell;
const cell_mod = @import("cell.zig");
const Cell = cell_mod.Cell;

pub const MAX_COLS: u16 = 256;
pub const MAX_ROWS: u16 = 256;
Expand Down Expand Up @@ -59,8 +60,21 @@ pub const Grid = struct {

pub fn clearRangeAs(self: *Grid, row: u16, start_col: u16, end_col: u16, blank: Cell) void {
if (row >= self.rows) return;
const end = if (end_col > self.cols) self.cols else end_col;
var c = start_col;
var start = if (start_col > self.cols) self.cols else start_col;
var end = if (end_col > self.cols) self.cols else end_col;

if (start < end) {
if (start < self.cols and self.cells[row][start].width == cell_mod.WIDTH_CONTINUATION and start > 0) {
start -= 1;
}
if (end < self.cols and self.cells[row][end].width == cell_mod.WIDTH_CONTINUATION) {
end += 1;
} else if (end > 0 and end < self.cols and self.cells[row][end - 1].width == cell_mod.WIDTH_WIDE) {
end += 1;
}
}

var c = start;
while (c < end) : (c += 1) {
self.cells[row][c] = blank;
}
Expand Down
Loading
Loading