Skip to content
Merged
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 @@ -22,6 +22,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin
- **Zig + WASM core** — VT100/VT220/xterm escape sequence parser compiled to a ~12 KB `.wasm` binary (release build)
- **DOM rendering** — native text selection, clipboard, browser find, and screen reader support
- **Dirty-row tracking** — only touched rows are re-rendered each frame via `requestAnimationFrame`
- **Synchronized output** — mode 2026 blocks paint atomically with a bounded recovery deadline
- **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
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/app/api-reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ The Vue `<Terminal>` component adds these props on top of the shared options abo

Instance methods on the vanilla `WTerm` class:

WTerm renders synchronized output blocks (CSI `?2026`) atomically when the mode closes. Each block can hold rendering for at most one second from its opening sequence. Ordinary payload does not extend that deadline. If the deadline expires, WTerm resumes painting until a fresh block begins.

<table>
<thead>
<tr>
Expand Down Expand Up @@ -507,6 +509,10 @@ When no URL is provided, the ~12 KB WASM binary is decoded from a base64 string
<td><code>usingAltScreen(): boolean</code></td>
<td>Whether the alternate screen buffer is active</td>
</tr>
<tr>
<td><code>synchronizedOutput(): boolean</code></td>
<td>Whether synchronized output mode (2026) is active</td>
</tr>
</tbody>
</table>

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 @@ -21,6 +21,7 @@ import { HeroSection } from "@/components/hero-terminal";
- **Zig + WASM core** — VT100/VT220/xterm escape sequence parser compiled to a ~12 KB `.wasm` binary
- **DOM rendering** — native text selection, clipboard, browser find, screen reader support
- **Dirty-row tracking** — only touched rows are re-rendered each frame via `requestAnimationFrame`
- **Synchronized output** — mode 2026 blocks paint atomically with a bounded recovery deadline
- **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
Expand Down
2 changes: 2 additions & 0 deletions packages/@wterm/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const cursor = bridge.getCursor(); // { row, col, visible }
| `cursorKeysApp()` | Whether cursor keys are in application mode |
| `bracketedPaste()` | Whether bracketed paste mode is active |
| `usingAltScreen()` | Whether the alternate screen buffer is active |
| `synchronizedOutput()` | Whether synchronized output mode (2026) is active |
| `synchronizedOutputGeneration()` | Monotonic generation for synchronized output blocks |

### `WebSocketTransport`

Expand Down
8 changes: 8 additions & 0 deletions packages/@wterm/core/src/__tests__/wasm-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ describe("WasmBridge", () => {
bridge.writeString("\x1b[?1049l");
expect(bridge.usingAltScreen()).toBe(false);
});

it("tracks synchronized output mode", () => {
expect(bridge.synchronizedOutput()).toBe(false);
bridge.writeString("\x1b[?2026h");
expect(bridge.synchronizedOutput()).toBe(true);
bridge.writeString("\x1b[?2026l");
expect(bridge.synchronizedOutput()).toBe(false);
});
});

describe("terminal responses", () => {
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 @@ -52,6 +52,8 @@ export interface TerminalCore {
cursorKeysApp(): boolean;
bracketedPaste(): boolean;
usingAltScreen(): boolean;
synchronizedOutput?(): boolean;
synchronizedOutputGeneration?(): number;

// -- Side outputs --
getTitle(): string | null;
Expand Down
8 changes: 8 additions & 0 deletions packages/@wterm/core/src/wasm-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface WasmExports {
getCursorKeysApp(): number;
getBracketedPaste(): number;
getUsingAltScreen(): number;
getSynchronizedOutput(): number;
getSynchronizedOutputGeneration(): number;
getTitlePtr(): number;
getTitleLen(): number;
getTitleChanged(): number;
Expand Down Expand Up @@ -165,6 +167,12 @@ export class WasmBridge implements TerminalCore {
usingAltScreen(): boolean {
return this.exports.getUsingAltScreen() !== 0;
}
synchronizedOutput(): boolean {
return this.exports.getSynchronizedOutput() !== 0;
}
synchronizedOutputGeneration(): number {
return this.exports.getSynchronizedOutputGeneration();
}

getTitle(): string | null {
if (this.exports.getTitleChanged() === 0) return null;
Expand Down
Binary file modified packages/@wterm/core/wasm/wterm.wasm
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/@wterm/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ new WTerm(element: HTMLElement, options?: WTermOptions)
| `focus()` | Focus the terminal element |
| `destroy()` | Clean up event listeners and DOM |

WTerm honors synchronized output mode (CSI `?2026`) by painting the block atomically when the mode closes. Each synchronized block can hold rendering for at most one second from its opening sequence. Ordinary payload does not extend that deadline. If the deadline expires, WTerm resumes painting until a fresh synchronized block begins.

### `WebSocketTransport`

Connect to a PTY backend over WebSocket (re-exported from `@wterm/core`).
Expand Down
68 changes: 68 additions & 0 deletions packages/@wterm/dom/src/__tests__/sync-generation-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { WasmBridge } from "@wterm/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WTerm } from "../wterm.js";

describe("synchronized output generation probe", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
document.body.innerHTML = "";
});

it("gives each real WASM generation a fresh recovery deadline", async () => {
vi.useFakeTimers();
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
cb(performance.now());
return 1;
});

const bridge = await WasmBridge.load();
const element = document.createElement("div");
document.body.appendChild(element);
const term = new WTerm(element, {
autoResize: false,
core: bridge,
});
await term.init();
const clearDirty = vi.spyOn(bridge, "clearDirty");
clearDirty.mockClear();

term.write("\x1b[?2026hA");
await vi.advanceTimersByTimeAsync(600);
term.write("\x1b[?2026l\x1b[?2026hB");
await vi.advanceTimersByTimeAsync(999);

expect(bridge.synchronizedOutputGeneration()).toBe(2);
expect(clearDirty).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2);
expect(clearDirty).toHaveBeenCalledTimes(1);
term.destroy();
});

it("keeps a fixed recovery deadline across ordinary payload", async () => {
vi.useFakeTimers();
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
cb(performance.now());
return 1;
});

const bridge = await WasmBridge.load();
const element = document.createElement("div");
document.body.appendChild(element);
const term = new WTerm(element, {
autoResize: false,
core: bridge,
});
await term.init();
const clearDirty = vi.spyOn(bridge, "clearDirty");
clearDirty.mockClear();

term.write("\x1b[?2026hA");
await vi.advanceTimersByTimeAsync(900);
term.write("B");
await vi.advanceTimersByTimeAsync(101);

expect(clearDirty).toHaveBeenCalledTimes(1);
term.destroy();
});
});
Loading
Loading