Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8d0f379
spec(renderer): add hardware cursor visibility and positioning
cowboyd Jun 30, 2026
05a07ab
📝 spec(renderer): note §7.6 carve-out in §11.2 output description
cowboyd Jun 30, 2026
7d5ad19
feat(term): emit hardware cursor at text() caret offset
cowboyd Jun 30, 2026
0a4898a
🧪 test(term): cursor hides on caret-present → caret-absent transition
cowboyd Jun 30, 2026
e36b912
🧪 test(term): no cursor bytes when no caret ever declared
cowboyd Jun 30, 2026
aef64d9
🧪 test(term): caret at end-of-content positions one cell past last char
cowboyd Jun 30, 2026
3221667
🧪 test(term): caret lands on correct line after text wrapping
cowboyd Jun 30, 2026
6308513
🧪 test(term): caret accounts for wide-character cell widths
cowboyd Jun 30, 2026
b0597fc
🧪 test(term): first caret declaration wins when multiple present
cowboyd Jun 30, 2026
00d0286
✨ example(term): interactive text input demonstrating caret
cowboyd Jun 30, 2026
0d3afa9
📝 AGENTS.md: reflect §7.6 cursor management exception
cowboyd Jun 30, 2026
a7d47a0
🧪 test(term): document v1 limitation for caret on empty text
cowboyd Jun 30, 2026
ddd8e9d
🎨 example(term): simplify layout and fall back to space for empty input
cowboyd Jun 30, 2026
9d23833
linting & formatting
cowboyd Jun 30, 2026
895565d
📝 spec(renderer): specify caret behavior on empty text content
cowboyd Jul 8, 2026
8d47bad
✨ feat(term): resolve caret cell on empty text content
cowboyd Jul 8, 2026
49f5508
🔥 example(term): drop empty-value workaround now handled by renderer
cowboyd Jul 8, 2026
a9f8c72
📝 spec(renderer): specify caret behavior at wrap boundaries
cowboyd Jul 9, 2026
e926977
🐛 fix(term): resolve caret cell during the render walk
cowboyd Jul 9, 2026
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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ Do not include any agent marketing material (e.g. "Generated with...",

- The renderer MUST NOT perform IO. It produces bytes; the caller writes them.

- The renderer MUST NOT manage terminal state (alternate buffer, cursor
visibility, mouse reporting, keyboard protocol modes).
- The renderer MUST NOT manage terminal state (alternate buffer, mouse
reporting, keyboard protocol modes). Cursor visibility and positioning are
renderer-managed only when a `caret` declaration is present on a `text()`
directive (see renderer-spec.md §7.6).

- Each frame is a complete snapshot. The renderer carries no UI tree state
between frames — only cell buffers for diffing.
Expand Down
143 changes: 143 additions & 0 deletions examples/text-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Buffer } from "node:buffer";
import process from "node:process";
import { each, ensure, main, until } from "effection";
import {
close,
createTerm,
fixed,
grow,
type KeyEvent,
type Op,
open,
rgba,
text,
} from "../../mod.ts";
import { alternateBuffer, progressiveInput, settings } from "../../settings.ts";
import { useInput } from "../use-input.ts";
import { useStdin } from "../use-stdin.ts";

const bg = rgba(20, 20, 30);
const inputBg = rgba(35, 35, 50);
const label = rgba(180, 180, 200);
const hint = rgba(80, 80, 100);

await main(function* () {
let { columns, rows } = terminalSize();

setRawMode(true);

let stdin = yield* useStdin();
let input = useInput(stdin);

let term = yield* until(createTerm({ width: columns, height: rows }));

let tty = settings(alternateBuffer(), progressiveInput(1));
writeStdout(tty.apply);

let value = "";
let caret = 0;

yield* ensure(() => {
setRawMode(false);
writeStdout(tty.revert);
});

let { output } = term.render(frame(value, caret));
writeStdout(output);

for (let event of yield* each(input)) {
if (event.type === "keydown") {
let key = event as KeyEvent;

if (key.ctrl && key.key === "c") {
break;
}

if (key.key === "Escape") {
break;
}

if (key.key === "ArrowLeft") {
if (caret > 0) {
caret--;
}
} else if (key.key === "ArrowRight") {
if (caret < [...value].length) {
caret++;
}
} else if (key.key === "Backspace") {
if (caret > 0) {
let chars = [...value];
chars.splice(caret - 1, 1);
value = chars.join("");
caret--;
}
} else if (
key.key.length === 1 &&
!key.ctrl &&
!key.alt
) {
let chars = [...value];
chars.splice(caret, 0, key.key);
value = chars.join("");
caret++;
}

({ output } = term.render(frame(value, caret)));
writeStdout(output);
}

yield* each.next();
}
});

function frame(value: string, caret: number): Op[] {
return [
open("root", {
layout: {
width: grow(),
height: grow(),
direction: "ttb",
padding: { left: 2, right: 2, top: 1, bottom: 1 },
gap: 1,
},
bg,
}),
open("label", { layout: { height: fixed(1) } }),
text("Name:", { color: label }),
close(),
open("input-box", {
layout: {
width: fixed(40),
height: fixed(1),
padding: { left: 1, right: 1 },
},
bg: inputBg,
}),
text(value, { color: label, caret }),
close(),
open("hint", { layout: { height: fixed(1) } }),
text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }),
close(),
close(),
];
}

function terminalSize(): { columns: number; rows: number } {
return process.stdout.isTTY
? {
columns: process.stdout.columns ?? 80,
rows: process.stdout.rows ?? 24,
}
: { columns: 80, rows: 24 };
}

function setRawMode(enabled: boolean): void {
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
process.stdin.setRawMode(enabled);
}
}

function writeStdout(bytes: Uint8Array): void {
process.stdout.write(Buffer.from(bytes));
}
7 changes: 6 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ export function pack(
);
o += 4;

// Caret offset (code points), or 0xFFFFFFFF when absent.
view.setUint32(o, op.caret ?? 0xFFFFFFFF, true);
o += 4;

let str = encoder.encode(op.content);
o = packString(view, str, o, end, "text content");
break;
Expand Down Expand Up @@ -481,6 +485,7 @@ export interface Text {
fontId?: number;
wrap?: number;
attrs?: number;
caret?: number;
}

interface Snapshot {
Expand Down Expand Up @@ -533,7 +538,7 @@ function packSize(ops: Op[]): number {
break;
}
case OP_TEXT: {
n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg
n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
break;
}
Expand Down
74 changes: 72 additions & 2 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,75 @@ nests clip regions more deeply than the renderer can track:
(see §12.3) before returning, so the caller can detect that some clipping was
not applied.

### 7.6 Hardware cursor visibility and positioning

A `text()` directive MAY declare a `caret` property whose value is a
non-negative integer code-point offset into the directive's `content`. `0` means
"before the first code point"; `[...content].length` means "after the last."
Offsets in between sit immediately before the cell where the corresponding code
point would render.

The cell where a declared caret sits is the cell at which the code point at the
caret's offset would be drawn given the layout engine's text wrapping. For an
offset `N`:

- If `N < [...content].length`, the caret's cell is the display position of the
`N`-th code point (zero-indexed) within the rendered text.
- If `N == [...content].length`, the caret's cell is one display position past
the last rendered code point: on the same wrapped line if there is room, or at
the start of the next line if the layout wraps at the end.
- If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must
keep offsets within bounds.

When the layout engine consumes whitespace at a wrap boundary — dropping it from
the rendered text so that neither wrapped line contains a display cell for those
code points — the caret has no display position of its own for any offset that
falls in that dropped run. In that case the caret's cell is the origin of the
following wrapped line (the display position of the first code point on that
line). This rule keeps the caret on-screen at the point where subsequent input
will land, rather than orphaning it past the end of the previous line.

When `content` is empty, the only in-range offset is `0`. In that case the
caret's cell is the text element's origin — the cell at which the first code
point of `content` would be drawn if `content` were a single space. This is a
rendering commitment implied by the presence of a `caret` declaration, and the
renderer must synthesize whatever minimal geometry is required to honor it. How
this outcome is achieved is implementation-defined.

Display position accounts for code points wider than one cell (CJK, fullwidth
forms, some emoji): such code points occupy two cells, and the caret sits at the
first cell of the pair. Cell widths are determined by the same measurement the
renderer uses to lay out the text itself.

The renderer manages the terminal's hardware cursor based on the presence of
`caret:` declarations:

- When the current frame contains one or more `caret:` declarations, the
rendered `output` MUST, when written to the terminal, leave the hardware
cursor visible at the cell where the first declared caret (in directive order)
sits.

- When the current frame contains no `caret:` declarations:
- If the previous frame contained one, the rendered `output` MUST leave the
hardware cursor hidden.
- Otherwise, the rendered `output` MUST NOT include cursor-positioning or
cursor-visibility bytes; the caller's cursor state is preserved.

The byte-level path the renderer takes to satisfy these outcomes is
implementation-defined. The renderer MAY hide the cursor before cell writes to
prevent flicker, MAY omit such a hide for in-place edits where flicker is
acceptable, MAY emit only a CUP when the caret has moved within an
already-visible state, MAY use whatever escape-sequence path it judges
appropriate. Only the post-frame cursor state above is normative.

If more than one `caret:` declaration is present in a frame, the renderer SHOULD
use the first in directive order for the hardware cursor. Behavior for
additional declarations is intentionally unspecified, leaving room for a future
multi-cursor extension without breaking this contract.

This responsibility is limited to the hardware cursor's position and visibility.
Cursor shape and blink rate remain caller-managed.

---

## 8. Public Rendering API
Expand Down Expand Up @@ -604,7 +673,6 @@ The renderer MUST NOT emit escape sequences for any of the following
terminal-management operations:

- Entering or leaving the alternate screen buffer
- Hiding or showing the cursor
- Setting the cursor shape or blink state
- Enabling or disabling mouse reporting
- Enabling or disabling keyboard protocol modes (e.g., Kitty progressive
Expand All @@ -613,7 +681,9 @@ terminal-management operations:

These are the caller's responsibility. The renderer's output contains only the
escape sequences needed to render the frame content (cursor positioning for cell
writes, SGR attributes for styling, and UTF-8 text).
writes, SGR attributes for styling, and UTF-8 text) and, when a `caret`
declaration is present, the cursor-positioning and cursor-visibility sequences
specified in §7.6.

### 11.3 The renderer does not own application lifecycle

Expand Down
Loading
Loading