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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Practical defaults:
- Do not hand-edit generated drawlist writers.
- Prefer semantic widgets and recipe-driven styling over manual status rendering.
- Prefer `ui.virtualList` for large collections.
- Screen modes: `config.screen = { mode: "alt" | "inline", inlineRows }` selects alt-screen vs inline presentation (docs/guide/screen-modes.md). Distinct from `executionMode: "inline"` (engine-on-main-thread).
- Screen modes: `config.screen = { mode: "alt" | "inline", inlineRows }` selects alt-screen vs inline presentation (docs/guide/screen-modes.md). Distinct from `executionMode: "inline"` (engine-on-main-thread). Inline apps can `app.printAbove(view)` (commit content into scrollback) and `app.setInlineRows(n)` (runtime region height).

## Skills

Expand Down
10 changes: 10 additions & 0 deletions docs/backend/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ const app = createNodeApp({
});
```

Inline screen-mode runtime APIs (engine ABI 1.4.0+):

- `app.printAbove(view, opts?)` renders a standalone widget tree and commits
it into terminal scrollback above the region (backend
`commitScrollback(drawlist, rows)`).
- `app.setInlineRows(rows)` changes the inline viewport height at runtime
(backend `setInlineRows(rows)`), re-sending the created runtime config with
the new height.
- Both reject unless the backend was created with `screen.mode: "inline"`.

Emoji width policy:

- `emojiWidthPolicy` keeps core text measurement and native rendering aligned.
Expand Down
28 changes: 28 additions & 0 deletions docs/guide/screen-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,34 @@ consumes rows: `ui.panel(...)` adds two border rows plus padding, and stack
gaps add one row per gap. The `examples/inline-status` panel (one status row,
a progress bar, and a caption) needs 9 rows.

## Printing into scrollback

Inline apps can commit finished content into terminal history above the live
region — the pattern agent CLIs use for completed messages and log lines
(Ink's `<Static>`, Bubble Tea's `Println`):

```ts
await app.printAbove(ui.text("[sync] checkpoint reached: 50%"));
```

- The tree renders at the current viewport width; rows default to its
measured height (pass `{ rows }` to pin). Committed blocks become ordinary
scrollback: they scroll with the session and survive app exit.
- Commits are emitted atomically with the next frame (single flush), in
submission order; the live region re-anchors below them.
- Engine bounds apply (1024 rows per commit, 4096 staged rows); exceeding
them rejects with `ZRUI_BACKEND_ERROR`.

## Resizing the region at runtime

```ts
await app.setInlineRows(12);
```

The engine clamps to the live terminal height and emits a resize event, so
layout follows automatically. Rows must be in 1..1024. Both APIs throw in
alt-screen mode.

## Behavior notes

- **Capability fallback**: protocol images (kitty / sixel / iTerm2) require
Expand Down
41 changes: 40 additions & 1 deletion examples/inline-status/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,33 @@ const app = createNodeApp<Record<string, never>>({
},
});

let lastCheckpoint = 0;

function maybePrintCheckpoint(percent: number): void {
if (percent === 0 || percent % 25 !== 0 || percent === lastCheckpoint) return;
lastCheckpoint = percent;
/* Committed lines scroll into terminal history above the live region. */
void app
.printAbove(ui.text(`[sync] checkpoint reached: ${String(percent)}%`, { variant: "caption" }))
.catch((err: unknown) => {
process.stderr.write(`printAbove failed: ${String(err)}\n`);
});
}

const SyncPanel = defineWidget((_props: Record<string, never>, ctx) => {
const [tick, setTick] = ctx.useState(0);
const [percent, setPercent] = ctx.useState(0);
const percentRef = ctx.useRef(0);
percentRef.current = percent;

useInterval(
ctx,
() => {
setTick((value) => value + 1);
setPercent((value) => Math.min(100, value + 1));
setTimeout(() => {
maybePrintCheckpoint(percentRef.current);
}, 0);
},
TICK_MS,
);
Expand All @@ -43,18 +61,39 @@ const SyncPanel = defineWidget((_props: Record<string, never>, ctx) => {
ui.text(`${String(percent)}%`, { variant: "label" }),
]),
ui.progress(percent / 100),
ui.text("scrollback stays above - press q to quit (frame stays visible)", {
ui.text("q quits - +/- resize region - checkpoints print above", {
variant: "caption",
}),
]);
});

app.view(() => SyncPanel({}));

let inlineRows = 9;

app.keys({
q: () => void app.stop(),
escape: () => void app.stop(),
"ctrl+c": () => void app.stop(),
/* Grow/shrink the live region at runtime; layout follows the resize. */
"shift+=": () => {
const nextRows = Math.min(16, inlineRows + 1);
void app.setInlineRows(nextRows).then(
() => {
inlineRows = nextRows;
},
() => {},
);
},
"-": () => {
const nextRows = Math.max(5, inlineRows - 1);
void app.setInlineRows(nextRows).then(
() => {
inlineRows = nextRows;
},
() => {},
);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

await app.run();
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* These must match the C engine's zr_version.h exactly.
*/
export const ZR_ENGINE_ABI_MAJOR = 1;
export const ZR_ENGINE_ABI_MINOR = 3;
export const ZR_ENGINE_ABI_MINOR = 4;
export const ZR_ENGINE_ABI_PATCH = 0;

/**
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/app/__tests__/printAbove.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ZrUiError } from "../../abi.js";
import { defaultTheme } from "../../theme/defaultTheme.js";
import { compileTheme } from "../../theme/theme.js";
import { ui } from "../../ui.js";
import { PRINT_ABOVE_MAX_ROWS, renderViewForScrollback } from "../printAbove.js";

const theme = compileTheme(defaultTheme.definition);

const ZRDL_MAGIC = 0x4c44525a;

function readU32le(bytes: Uint8Array, off: number): number {
return (
((bytes[off] ?? 0) |
((bytes[off + 1] ?? 0) << 8) |
((bytes[off + 2] ?? 0) << 16) |
((bytes[off + 3] ?? 0) << 24)) >>>
0
);
}

test("printAbove render: single text line measures one row and emits ZRDL", () => {
const rendered = renderViewForScrollback(ui.text("hello scrollback"), 40, theme);
assert.equal(rendered.rows, 1);
assert.ok(rendered.bytes.byteLength > 64);
assert.equal(readU32le(rendered.bytes, 0), ZRDL_MAGIC);
/* header cmd_count at offset 24 must be non-zero for painted content */
assert.ok(readU32le(rendered.bytes, 24) > 0);
});

test("printAbove render: column of texts measures its natural height", () => {
/* Default column gap is part of the measured height: 3 texts + 2 gaps. */
const view = ui.column({}, [ui.text("one"), ui.text("two"), ui.text("three")]);
const rendered = renderViewForScrollback(view, 40, theme);
assert.equal(rendered.rows, 5);
});

test("printAbove render: wrapping text grows measured rows at narrow widths", () => {
const long = "a".repeat(30);
const wide = renderViewForScrollback(ui.text(long, { wrap: true }), 40, theme);
const narrow = renderViewForScrollback(ui.text(long, { wrap: true }), 10, theme);
assert.equal(wide.rows, 1);
assert.ok(narrow.rows >= 3);
});

test("printAbove render: explicit rows override is respected", () => {
const rendered = renderViewForScrollback(ui.text("x"), 40, theme, 4);
assert.equal(rendered.rows, 4);
});

test("printAbove render: invalid inputs are rejected", () => {
assert.throws(
() => renderViewForScrollback(ui.text("x"), 0, theme),
(err: unknown) => err instanceof ZrUiError && err.code === "ZRUI_INVALID_PROPS",
);
assert.throws(
() => renderViewForScrollback(ui.text("x"), 40, theme, 0),
(err: unknown) => err instanceof ZrUiError && err.code === "ZRUI_INVALID_PROPS",
);
assert.throws(
() => renderViewForScrollback(ui.text("x"), 40, theme, PRINT_ABOVE_MAX_ROWS + 1),
(err: unknown) => err instanceof ZrUiError && err.code === "ZRUI_INVALID_PROPS",
);
});
52 changes: 52 additions & 0 deletions packages/core/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { DEFAULT_TERMINAL_PROFILE, type TerminalProfile } from "../terminalProfi
import { defaultTheme } from "../theme/defaultTheme.js";
import { compileTheme } from "../theme/theme.js";
import type { ThemeDefinition } from "../theme/tokens.js";
import type { VNode } from "../widgets/types.js";
import {
type InternalRuntimeBreadcrumbHooks,
createRuntimeBreadcrumbHelpers,
Expand All @@ -77,6 +78,7 @@ import { computeKeybindingsEnabled, createAppKeybindingHelpers } from "./createA
import { type ThemeTransitionState, createRenderLoop } from "./createApp/renderLoop.js";
import { createRunSignalController, readProcessLike } from "./createApp/runSignals.js";
import type { TopLevelViewError } from "./createApp/topLevelViewError.js";
import { renderViewForScrollback } from "./printAbove.js";
import { RawRenderer } from "./rawRenderer.js";
import type {
RuntimeBreadcrumbAction,
Expand Down Expand Up @@ -1014,6 +1016,56 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
);
},

async printAbove(view: VNode, printOpts?: Readonly<{ rows?: number }>): Promise<void> {
guards.assertOperational("printAbove");
guards.assertLifecycleIdle("printAbove");
if (inCommit) guards.throwCode("ZRUI_REENTRANT_CALL", "printAbove: called during commit");
if (inRender) {
guards.throwCode(
"ZRUI_UPDATE_DURING_RENDER",
guards.updateDuringRenderDetail("printAbove"),
);
}
sm.assertOneOf(["Running"], "printAbove: app must be running");
const commitFn = backend.commitScrollback?.bind(backend);
if (commitFn === undefined) {
guards.throwCode(
"ZRUI_BACKEND_ERROR",
"printAbove: backend has no scrollback-commit support (inline screen mode required)",
);
return;
}
const cols = viewport?.cols;
if (cols === undefined || cols < 1) {
guards.throwCode("ZRUI_INVALID_STATE", "printAbove: viewport size not known yet");
return;
}
const rendered = renderViewForScrollback(view, cols, theme, printOpts?.rows);
await commitFn(rendered.bytes, rendered.rows);
},

async setInlineRows(rows: number): Promise<void> {
guards.assertOperational("setInlineRows");
guards.assertLifecycleIdle("setInlineRows");
if (inCommit) guards.throwCode("ZRUI_REENTRANT_CALL", "setInlineRows: called during commit");
if (inRender) {
guards.throwCode(
"ZRUI_UPDATE_DURING_RENDER",
guards.updateDuringRenderDetail("setInlineRows"),
);
}
sm.assertOneOf(["Running"], "setInlineRows: app must be running");
const setFn = backend.setInlineRows?.bind(backend);
if (setFn === undefined) {
guards.throwCode(
"ZRUI_BACKEND_ERROR",
"setInlineRows: backend has no inline screen-mode support",
);
return;
}
await setFn(rows);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

dispose(): void {
if (inCommit || inRender || inEventHandlerDepth > 0) {
guards.throwCode("ZRUI_REENTRANT_CALL", "dispose: re-entrant call");
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/app/inspectorOverlayHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ export function createAppWithInspectorOverlay<S>(
replaceRoutes(routes: readonly RouteDefinition<S>[]): void {
app.replaceRoutes(routes);
},
printAbove(view, opts) {
return app.printAbove(view, opts);
},
setInlineRows(rows: number) {
return app.setInlineRows(rows);
},
draw(fn: DrawFn): void {
app.draw(fn);
},
Expand Down
Loading
Loading