Skip to content

Commit b3270b9

Browse files
feat: add virtual list sticky-follow mode, tool approval dialog (#405)
* feat: add virtual list sticky-follow mode, tool approval dialog event blocking, and max blob size configuration * feat: add render request mechanism for tool approvals and refine sticky scrolling logic for list tails * fix: finalize sticky follow dialog merge checks --------- Co-authored-by: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com>
1 parent dfc7d7f commit b3270b9

20 files changed

Lines changed: 592 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ The format is based on Keep a Changelog and the project follows Semantic Version
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **core/virtual-list**: Added `ensureVisibleIndex` with sticky follow mode for log and chat tail-follow behavior.
12+
- **core/tool-approval**: Added the tool approval dialog modal surface and mouse action routing.
13+
- **core/rendering**: Added `maxBlobBytes` app config for drawlist blob payload limits.
14+
15+
### Documentation
16+
17+
- Documented virtual list sticky follow behavior, tool approval dialog modal input behavior, and `maxBlobBytes` engine config.
18+
919
## [0.1.0-alpha.71] - 2026-04-27
1020

1121
### Documentation

docs/backend/engine-config.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const app = createNodeApp({
1414
config: {
1515
fpsCap: 30,
1616
maxDrawlistBytes: 4 << 20, // 4 MiB
17+
maxBlobBytes: 1 << 20, // 1 MiB
1718
drawlistValidateParams: false, // trusted inputs, skip validation
1819
maxFramesInFlight: 2,
1920
},
@@ -27,10 +28,18 @@ type AppConfig = Readonly<{
2728
fpsCap?: number;
2829
maxEventBytes?: number;
2930
maxDrawlistBytes?: number;
31+
maxBlobBytes?: number;
32+
rootPadding?: number;
33+
breakpoints?: Readonly<{
34+
smMax?: number;
35+
mdMax?: number;
36+
lgMax?: number;
37+
}>;
3038
drawlistValidateParams?: boolean;
3139
drawlistReuseOutputBuffer?: boolean;
3240
drawlistEncodedStringCacheCap?: number;
3341
maxFramesInFlight?: number;
42+
themeTransitionFrames?: number;
3443
internal_onRender?: (metrics: AppRenderMetrics) => void;
3544
internal_onLayout?: (snapshot: AppLayoutSnapshot) => void;
3645
}>;
@@ -86,6 +95,24 @@ config: {
8695
}
8796
```
8897

98+
### maxBlobBytes
99+
100+
| Detail | Value |
101+
|----------|-------|
102+
| Type | `number` (positive integer) |
103+
| Default | `512 << 10` (512 KiB) |
104+
105+
Maximum total byte size of the drawlist blob pool for a frame. If binary payloads
106+
exceed this limit during frame construction, the frame is rejected. Increase this
107+
value only for applications that intentionally render large binary-backed
108+
surfaces such as images or canvases.
109+
110+
```typescript
111+
config: {
112+
maxBlobBytes: 1 << 20, // 1 MiB blob pool
113+
}
114+
```
115+
89116
### drawlistValidateParams
90117

91118
| Detail | Value |
@@ -129,7 +156,7 @@ remains safe regardless of the pipelining depth.
129156
| Detail | Value |
130157
|----------|-------|
131158
| Type | `number` (non-negative integer) |
132-
| Default | `1024` |
159+
| Default | `131072` |
133160

134161
Maximum number of UTF-8 encoded strings to cache across frames. The drawlist
135162
builder maintains a cache of encoded strings to avoid redundant
@@ -142,7 +169,7 @@ application renders many unique but repeated strings across frames.
142169

143170
```typescript
144171
config: {
145-
drawlistEncodedStringCacheCap: 2048, // larger cache for text-heavy UIs
172+
drawlistEncodedStringCacheCap: 262144, // larger cache for text-heavy UIs
146173
}
147174
```
148175

@@ -177,10 +204,13 @@ config: {
177204
| `fpsCap` | `60` |
178205
| `maxEventBytes` | `1048576` (1 MiB) |
179206
| `maxDrawlistBytes` | `2097152` (2 MiB) |
207+
| `maxBlobBytes` | `524288` (512 KiB) |
208+
| `rootPadding` | `0` |
180209
| `drawlistValidateParams` | `true` (app runtime default) |
181210
| `drawlistReuseOutputBuffer` | `true` |
182-
| `drawlistEncodedStringCacheCap` | `1024` |
211+
| `drawlistEncodedStringCacheCap` | `131072` |
183212
| `maxFramesInFlight` | `1` |
213+
| `themeTransitionFrames` | `0` |
184214

185215
## See Also
186216

docs/widgets/tool-approval-dialog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ ui.toolApprovalDialog({
3737
- `ToolRequest` includes metadata like `toolId`, `toolName`, `riskLevel`, and optional `fileChanges`.
3838
- `fileChanges` entries include `path`, `changeType`, and optional `preview`/`oldPath` for renames.
3939
- Default dialog size is `50x15` cells when `width`/`height` are omitted.
40+
- When open, the dialog is a modal layer with a dim backdrop.
41+
- Backdrop clicks and clicks behind the dialog are blocked; only the dialog action regions route mouse presses.
4042

4143
## Related
4244

docs/widgets/virtual-list.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ ui.virtualList({
4848
| `measureItemHeight` | `(item, index, ctx) => number` | internal measurer | Optional custom measurement callback for estimate mode |
4949
| `renderItem` | `(item, index, focused) => VNode` | **required** | Render function for each item |
5050
| `overscan` | `number` | `3` | Extra items rendered above/below viewport |
51+
| `ensureVisibleIndex` | `number` | - | Item index to keep visible after render, useful for tail-following logs or chat |
52+
| `ensureVisibleMode` | `"always" \| "sticky"` | `"always"` | Whether `ensureVisibleIndex` always repositions or follows only while attached |
5153
| `keyboardNavigation` | `boolean` | `true` | Enable arrow/page/home/end navigation |
5254
| `wrapAround` | `boolean` | `false` | Wrap selection from end to start |
5355
| `scrollDirection` | `"traditional" \| "natural"` | `"traditional"` | Mouse-wheel direction mode for this list |
@@ -62,6 +64,9 @@ ui.virtualList({
6264
- **Mouse scroll wheel** scrolls the list (3 lines per tick).
6365
- `scrollDirection: "traditional"` (default): wheel down moves viewport down.
6466
- `scrollDirection: "natural"`: wheel down moves content down (trackpad-style).
67+
- `ensureVisibleIndex` keeps a target item visible after render.
68+
- `ensureVisibleMode: "always"` scrolls whenever the target is outside the viewport.
69+
- `ensureVisibleMode: "sticky"` follows the target only while the user remains attached.
6570
- The `onScroll` callback fires for both keyboard navigation and mouse wheel input.
6671

6772
## Notes
@@ -72,6 +77,8 @@ ui.virtualList({
7277
- Returned `measureItemHeight` values are normalized before entering the measured-height cache: non-finite or non-positive values fall back to `estimatedHeight`, and positive values are truncated to whole cells with a minimum of 1.
7378
- In estimate mode, visible items are measured and cached, then scroll math is corrected on the following frame.
7479
- Measured-height cache is reused only while viewport width and item count stay the same; changing either triggers remeasurement.
80+
- Sticky follow is disabled when keyboard or wheel input moves away from the target, and re-enabled when the viewport reaches the target again.
81+
- Oversized sticky targets anchor their bottom edge to avoid scroll oscillation.
7582
- `renderItem` receives a `focused` flag for styling.
7683
- `selectionStyle` still applies to the selected row in estimate mode.
7784
- The `range` passed to `onScroll` is `[startIndex, endIndex)` and includes overscan.

packages/core/etc/core.api.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export type AppConfig = Readonly<{
160160
fpsCap?: number;
161161
maxEventBytes?: number;
162162
maxDrawlistBytes?: number;
163+
maxBlobBytes?: number;
163164
rootPadding?: number;
164165
breakpoints?: Readonly<{
165166
smMax?: number;
@@ -7375,6 +7376,7 @@ export type VirtualListLocalState = Readonly<{
73757376
viewportHeight: number;
73767377
startIndex: number;
73777378
endIndex: number;
7379+
stickyFollowActive?: boolean;
73787380
measuredHeights?: ReadonlyMap<number, number>;
73797381
measuredWidth?: number;
73807382
measuredItemCount?: number;
@@ -7387,6 +7389,7 @@ export type VirtualListLocalStatePatch = Readonly<{
73877389
viewportHeight?: number;
73887390
startIndex?: number;
73897391
endIndex?: number;
7392+
stickyFollowActive?: boolean;
73907393
measuredHeights?: ReadonlyMap<number, number>;
73917394
measuredWidth?: number;
73927395
measuredItemCount?: number;
@@ -7411,6 +7414,8 @@ export type VirtualListProps<T = unknown> = Readonly<{
74117414
measureItemHeight?: (item: T, index: number, ctx: VirtualListMeasureItemHeightCtx) => number;
74127415
overscan?: number;
74137416
renderItem: (item: T, index: number, focused: boolean) => VNode;
7417+
ensureVisibleIndex?: number;
7418+
ensureVisibleMode?: "always" | "sticky";
74147419
onScroll?: (scrollTop: number, visibleRange: [number, number]) => void;
74157420
onSelect?: (item: T, index: number) => void;
74167421
keyboardNavigation?: boolean;
@@ -8016,8 +8021,8 @@ export type ZrUiErrorCode = "ZRUI_INVALID_STATE" | "ZRUI_MODE_CONFLICT" | "ZRUI_
80168021
// src/runtime/instances.ts:56:3 - (ae-forgotten-export) The symbol "UnknownCallback_2" needs to be exported by the entry point index.d.ts
80178022
// src/runtime/instances.ts:134:3 - (ae-forgotten-export) The symbol "InstanceId" needs to be exported by the entry point index.d.ts
80188023
// src/runtime/instances.ts:161:3 - (ae-forgotten-export) The symbol "AppStateSelection" needs to be exported by the entry point index.d.ts
8019-
// src/runtime/localState.ts:401:3 - (ae-forgotten-export) The symbol "TreeFlatCache" needs to be exported by the entry point index.d.ts
8020-
// src/runtime/localState.ts:406:3 - (ae-forgotten-export) The symbol "TreePrefixCache" needs to be exported by the entry point index.d.ts
8024+
// src/runtime/localState.ts:412:3 - (ae-forgotten-export) The symbol "TreeFlatCache" needs to be exported by the entry point index.d.ts
8025+
// src/runtime/localState.ts:417:3 - (ae-forgotten-export) The symbol "TreePrefixCache" needs to be exported by the entry point index.d.ts
80218026
// src/runtime/widgetMeta/focusInfo.ts:8:3 - (ae-forgotten-export) The symbol "RuntimeInstance" needs to be exported by the entry point index.d.ts
80228027
// src/testing/renderer.ts:93:3 - (ae-forgotten-export) The symbol "TestRecordedOp" needs to be exported by the entry point index.d.ts
80238028
// src/widgets/composition.ts:88:3 - (ae-forgotten-export) The symbol "UnknownCallback" needs to be exported by the entry point index.d.ts

packages/core/src/app/__tests__/widgetBehavior.contracts.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,144 @@ describe("modal, overlay, and focus behavior contracts", () => {
475475
assert.equal(renderer.getFocusedId(), "cancel");
476476
});
477477

478+
test("tool approval dialog mouse clicks activate action regions", () => {
479+
const renderer = new WidgetRenderer<void>({
480+
backend: createNoopBackend(),
481+
requestRender: () => {},
482+
});
483+
484+
let open = true;
485+
let allowSessionCount = 0;
486+
let closeCount = 0;
487+
const view = () =>
488+
ui.layers([
489+
ui.button({ id: "background", label: "Background action" }),
490+
...(open
491+
? [
492+
ui.toolApprovalDialog({
493+
id: "approval",
494+
open: true,
495+
request: {
496+
toolId: "terminal",
497+
toolName: "terminal",
498+
command: "rm -rf ./dist",
499+
riskLevel: "high",
500+
},
501+
onPress: () => {},
502+
onAllowForSession: () => {
503+
allowSessionCount++;
504+
},
505+
onClose: () => {
506+
closeCount++;
507+
open = false;
508+
},
509+
}),
510+
]
511+
: []),
512+
]);
513+
514+
submit(renderer, view, { cols: 80, rows: 24 });
515+
const rect = renderer.getRectByIdIndex().get("approval");
516+
assert.ok(rect !== undefined, "approval rect should exist");
517+
if (!rect) return;
518+
519+
const x = rect.x + 22;
520+
const y = rect.y + rect.h - 2;
521+
renderer.routeEngineEvent(mouseEvent(x, y, 3, { buttons: 1 }));
522+
renderer.routeEngineEvent(mouseEvent(x, y, 4));
523+
524+
assert.equal(renderer.getFocusedId(), "approval");
525+
assert.equal(allowSessionCount, 1);
526+
assert.equal(closeCount, 1);
527+
});
528+
529+
test("tool approval dialog blocks backdrop clicks from background widgets", () => {
530+
const renderer = new WidgetRenderer<void>({
531+
backend: createNoopBackend(),
532+
requestRender: () => {},
533+
});
534+
535+
let backgroundPresses = 0;
536+
const view = () =>
537+
ui.layers([
538+
ui.button({
539+
id: "background",
540+
label: "Background action",
541+
onPress: () => {
542+
backgroundPresses++;
543+
},
544+
}),
545+
ui.toolApprovalDialog({
546+
id: "approval",
547+
open: true,
548+
request: {
549+
toolId: "terminal",
550+
toolName: "terminal",
551+
command: "rm -rf ./dist",
552+
riskLevel: "high",
553+
},
554+
onPress: () => {},
555+
onClose: () => {},
556+
}),
557+
]);
558+
559+
submit(renderer, view, { cols: 80, rows: 24 });
560+
const bgCenter = centerOf(renderer, "background");
561+
renderer.routeEngineEvent(mouseEvent(bgCenter.x, bgCenter.y, 3, { buttons: 1 }));
562+
renderer.routeEngineEvent(mouseEvent(bgCenter.x, bgCenter.y, 4));
563+
564+
assert.equal(backgroundPresses, 0);
565+
});
566+
567+
test("tool approval dialog keeps backdrop blocking after layout-only resize", () => {
568+
const renderer = new WidgetRenderer<void>({
569+
backend: createNoopBackend(),
570+
requestRender: () => {},
571+
});
572+
573+
let backgroundPresses = 0;
574+
const view = () =>
575+
ui.layers([
576+
ui.button({
577+
id: "background",
578+
label: "Background action",
579+
onPress: () => {
580+
backgroundPresses++;
581+
},
582+
}),
583+
ui.toolApprovalDialog({
584+
id: "approval",
585+
open: true,
586+
request: {
587+
toolId: "terminal",
588+
toolName: "terminal",
589+
command: "rm -rf ./dist",
590+
riskLevel: "high",
591+
},
592+
onPress: () => {},
593+
onClose: () => {},
594+
}),
595+
]);
596+
597+
submit(renderer, view, { cols: 80, rows: 24 });
598+
599+
const resized = renderer.submitFrame(
600+
view,
601+
undefined,
602+
{ cols: 100, rows: 24 },
603+
defaultTheme,
604+
noRenderHooks(),
605+
{ commit: false, layout: true, checkLayoutStability: false },
606+
);
607+
assert.equal(resized.ok, true, "layout-only frame should render");
608+
609+
const bgCenter = centerOf(renderer, "background");
610+
renderer.routeEngineEvent(mouseEvent(bgCenter.x, bgCenter.y, 3, { buttons: 1 }));
611+
renderer.routeEngineEvent(mouseEvent(bgCenter.x, bgCenter.y, 4));
612+
613+
assert.equal(backgroundPresses, 0);
614+
});
615+
478616
test("top layer above a modal still receives mouse presses", () => {
479617
const renderer = new WidgetRenderer<void>({
480618
backend: createNoopBackend(),

packages/core/src/app/createApp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
375375
const rawRenderer = new RawRenderer({
376376
backend,
377377
maxDrawlistBytes: config.maxDrawlistBytes,
378+
maxBlobBytes: config.maxBlobBytes,
378379
...(opts.config?.drawlistValidateParams === undefined
379380
? {}
380381
: { drawlistValidateParams: opts.config.drawlistValidateParams }),
@@ -384,6 +385,7 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
384385
const widgetRenderer = new WidgetRenderer<S>({
385386
backend,
386387
maxDrawlistBytes: config.maxDrawlistBytes,
388+
maxBlobBytes: config.maxBlobBytes,
387389
rootPadding: config.rootPadding,
388390
breakpointThresholds: config.breakpointThresholds,
389391
terminalProfile,

packages/core/src/app/createApp/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type ResolvedAppConfig = Readonly<{
2020
fpsCap: number;
2121
maxEventBytes: number;
2222
maxDrawlistBytes: number;
23+
maxBlobBytes: number;
2324
rootPadding: number;
2425
breakpointThresholds: ResponsiveBreakpointThresholds;
2526
drawlistValidateParams: boolean;
@@ -35,6 +36,7 @@ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({
3536
fpsCap: 60,
3637
maxEventBytes: 1 << 20 /* 1 MiB */,
3738
maxDrawlistBytes: 2 << 20 /* 2 MiB */,
39+
maxBlobBytes: 512 << 10 /* 512 KiB */,
3840
rootPadding: 0,
3941
breakpointThresholds: normalizeBreakpointThresholds(undefined),
4042
drawlistValidateParams: true,
@@ -83,6 +85,10 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
8385
config.maxDrawlistBytes === undefined
8486
? DEFAULT_CONFIG.maxDrawlistBytes
8587
: requirePositiveInt("maxDrawlistBytes", config.maxDrawlistBytes);
88+
const maxBlobBytes =
89+
config.maxBlobBytes === undefined
90+
? DEFAULT_CONFIG.maxBlobBytes
91+
: requirePositiveInt("maxBlobBytes", config.maxBlobBytes);
8692
const rootPadding =
8793
config.rootPadding === undefined
8894
? DEFAULT_CONFIG.rootPadding
@@ -120,6 +126,7 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
120126
fpsCap,
121127
maxEventBytes,
122128
maxDrawlistBytes,
129+
maxBlobBytes,
123130
rootPadding,
124131
breakpointThresholds,
125132
drawlistValidateParams,

packages/core/src/app/rawRenderer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class RawRenderer {
5151
backend: RuntimeBackend;
5252
builder?: DrawlistBuilder;
5353
maxDrawlistBytes?: number;
54+
maxBlobBytes?: number;
5455
drawlistValidateParams?: boolean;
5556
drawlistReuseOutputBuffer?: boolean;
5657
drawlistEncodedStringCacheCap?: number;
@@ -59,6 +60,7 @@ export class RawRenderer {
5960
this.backend = opts.backend;
6061
const builderOpts = {
6162
...(opts.maxDrawlistBytes === undefined ? {} : { maxDrawlistBytes: opts.maxDrawlistBytes }),
63+
...(opts.maxBlobBytes === undefined ? {} : { maxBlobBytes: opts.maxBlobBytes }),
6264
...(opts.drawlistValidateParams === undefined
6365
? {}
6466
: { validateParams: opts.drawlistValidateParams }),

0 commit comments

Comments
 (0)