tui-plan.md
Implement a constrained layout system for TuiAltScreen and use it to keep the coding-agent transcript scrollable while the pending/status/widget/editor/footer area remains fixed at the bottom.
This document is an implementation handoff. It records the decisions made during design discussion and should be treated as the intended scope unless implementation findings require revisiting a decision.
TuiMainScreen keeps its existing terminal-scrollback rendering model.VStackHStackScrollViewMarkdown, Text, Image, and Box. Do not introduce a second framework-level render cache initially.Editor does not currently cache its rendered lines, but it is small and active; this is not expected to be the dominant cost.interactive-mode.ts changes declarative and minimal. Layout, clipping, scrolling, hit testing, and event routing belong in packages/tui.The terminal owns scrolling in main-screen mode. The application cannot reliably provide:
Therefore, do not pretend the same constrained viewport semantics exist in TuiMainScreen.
Main-screen interactive mode remains a vertically rendered document:
header
loaded resources
chat
pending messages
status
widgets above
editor / replacement UI
widgets below
footer
Alternate-screen interactive mode becomes:
┌─────────────────────────────────────────────┐
│ scrollable transcript │
│ │
│ header │
│ loaded resources │
│ chat/messages/tool output │
│ │
├─────────────────────────────────────────────┤
│ pending messages │
│ working/retry/compaction status │
│ widgets above editor │
│ editor or temporary replacement UI │
│ widgets below editor │
│ footer │
└─────────────────────────────────────────────┘
Pending messages and status belong in the fixed region. Hiding active queue/working state while the user reads older output would be surprising.
TuiAltScreen.TuiMainScreen behavior and output order must remain unchanged.Use one axis-neutral entry type for both vertical and horizontal stacks.
export interface StackEntryOptions {
/** Initial size on the stack's main axis. Defaults to "auto". */
basis?: number | "auto";
/** Share of positive remaining space. Defaults to 0. */
grow?: number;
/** Relative willingness to shrink when content overflows. Defaults to 1. */
shrink?: number;
/** Minimum allocated size on the main axis. Defaults to 0. */
minSize?: number;
/** Maximum allocated size on the main axis. */
maxSize?: number;
/** Conditionally omit this entry for a viewport size. */
visible?: (viewport: { width: number; height: number }) => boolean;
}
export interface StackEntry extends StackEntryOptions {
component: Component;
}
export type StackChild = Component | StackEntry;
export interface StackOptions {
gap?: number;
align?: "stretch" | "start" | "center" | "end";
}
Use explicit fields in implementations. Do not use TypeScript parameter properties because root-configured source must remain erasable in Node strip-only mode.
VStackexport class VStack implements Component {
constructor(children?: StackChild[], options?: StackOptions);
addChild(component: Component, options?: StackEntryOptions): void;
removeChild(component: Component): void;
clear(): void;
invalidate(): void;
render(width: number): string[];
}
Behavior:
render(width) provides an unbounded-height rendering for compatibility and debugging.TuiAltScreen through the internal layout engine.gap rows appear only between visible children.stretch.HStackexport class HStack implements Component {
constructor(children?: StackChild[], options?: StackOptions);
addChild(component: Component, options?: StackEntryOptions): void;
removeChild(component: Component): void;
clear(): void;
invalidate(): void;
render(width: number): string[];
}
Behavior:
basis, grow, shrink, minSize, and maxSize.align.ScrollViewexport interface ScrollViewOptions {
axis?: "vertical";
/** Follow content growth while positioned at the end. */
follow?: "none" | "end";
/** Designate this view as the fallback target for global scroll actions. */
primary?: boolean;
/** Bubble unused wheel delta to an outer scroll view. */
overscroll?: "chain" | "contain";
/** Reserved for a later visible scrollbar implementation. */
scrollbar?: "hidden" | "auto" | "always";
}
export class ScrollView implements Component {
constructor(component: Component, options?: ScrollViewOptions);
get scrollTop(): number;
get isFollowingEnd(): boolean;
scrollBy(lines: number): number;
scrollToStart(): void;
scrollToEnd(): void;
invalidate(): void;
render(width: number): string[];
}
scrollBy() returns unused delta so nested scrolling can chain:
const remaining = scrollView.scrollBy(delta);
Examples:
+3, moved +3: return 0.+3, only one row remained: move one and return +2.-3, already at the top: return -3.Behavior:
render(width), render the complete child. This is needed for final-document output and debugging, not to emulate viewport behavior in main-screen mode.follow: "end" behaves like current TuiAltScreen.stickToBottom:
scrollTop when viewport height changes, unless following the end.Do not add constrained layout methods to every TUI implementation as though main-screen mode supports them.
Add an explicit capability:
export interface ViewportTUI extends TUI {
setLayoutRoot(component: Component | undefined): void;
}
export function isViewportTUI(tui: TUI): tui is ViewportTUI;
TuiAltScreen implements ViewportTUI. TuiMainScreen does not.
The type guard should test a stable capability, not rely on application-level instanceof. The concrete implementation may use a symbol or method-presence check.
TuiAltScreen behavior when no explicit layout root is set must remain compatible with current users of addChild(). Treat its existing children as an implicit vertically stacked document in an implicit primary ScrollView.
Do not export these types from packages/tui/src/index.ts.
Suggested module: packages/tui/src/layout.ts.
interface LayoutConstraints {
width: number;
/** Undefined means unbounded height. */
height: number | undefined;
}
interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
interface LayoutBox {
component: Component;
rect: LayoutRect;
clip: LayoutRect;
children: LayoutBox[];
parent?: LayoutBox;
/** Leaf-rendered lines. Keep the returned array by reference. */
lines?: readonly string[];
/** Present when this box represents a ScrollView viewport. */
scrollView?: ScrollView;
/** Z/layer ordering for hit testing when needed. */
layer: number;
}
interface LayoutFrame {
root: LayoutBox;
width: number;
height: number;
lines: string[];
primaryScrollView?: ScrollView;
}
The exact shape may change during implementation, but it must support:
The public component tree is long-lived and stateful:
VStack
├─ ScrollView
│ └─ chat container
└─ dock VStack
├─ editor container
└─ footer container
The internal layout tree is a transient frame snapshot:
box root rect 0,0,120,40
├─ scroll box rect 0,0,120,31 clip 0,0,120,31
│ └─ content rect 0,-85,120,116
└─ dock box rect 0,31,120,9
Rebuild the layout tree for every requested frame. Replace the committed frame atomically after successful painting so input is always routed against the last displayed geometry.
Do not mutate component state merely to generate layout geometry, except for intentional ScrollView clamping/follow state.
A fresh frame performs:
const nextLayout = layout(root, terminalBounds);
const nextScreen = paint(nextLayout);
writeScreenDiff(previousScreen, nextScreen);
currentLayout = nextLayout;
For a leaf component:
const lines = component.render(width);
Keep lines by reference in the layout box. Most expensive leaves already cache by content and width:
Markdown caches text, width, and rendered lines.Text caches text, width, and rendered lines.Image caches width and rendered lines.Box caches based on width/background/child output.Editor, Input, selectors, footer, and some small leaves recompute. This is acceptable initially.
Do not add a separate WeakMap<Component, RenderCache> in the layout engine until profiling shows a need. A second cache risks becoming stale because existing components own their invalidation semantics.
The first correct implementation may call existing Container.render(width), which flattens child arrays. Markdown parsing/highlighting will still be cached, so this is acceptable for the initial implementation.
If easy and safe, optimize exact base Container instances as structural vertical stacks so layout can retain child line arrays and heights without flattening the whole transcript. Do not bypass overridden rendering in Container subclasses such as message/tool components. Treat subclasses as leaves unless they explicitly opt into internal structural layout.
Do not make this optimization a prerequisite for correctness.
Only rebuild a layout frame after requestRender() schedules a render. There is no independent layout loop.
The implementation should use one shared stack allocator parameterized by axis.
visible against the terminal viewport dimensions.basis: "auto" uses the child's intrinsic size on the main axis.basis uses the given cell count.minSize/maxSize.HStack therefore allocates widths before measuring child heights.VStack renders/measures auto-height children at the allocated width before distributing remaining height.Distribute positive remaining space among entries with grow > 0, proportional to grow, respecting maxSize.
Use deterministic integer rounding. Allocate leftover cells in child order so layouts do not jitter frame to frame.
When total basis exceeds available size:
shrink > 0 and current size above minSize).shrink and current basis, or another deterministic documented policy.minSize before overflow is resolved.A focused cursor must not disappear merely because a leaf is clipped. When clipping a leaf vertically and its lines contain CURSOR_MARKER, choose a visible line window containing the marker where possible.
The transcript should be flexible and the dock should prefer intrinsic height:
new VStack([
{
component: transcriptScrollView,
basis: 0,
grow: 1,
shrink: 1,
minSize: 1,
},
{
component: dock,
basis: "auto",
grow: 0,
shrink: 1,
minSize: 1,
},
]);
The implementation must define sensible behavior for very small terminals and oversized custom widgets. Preferred priority:
This may require coding-agent-specific stack entry minSize/shrink settings rather than adding domain-specific priority rules to generic TUI layout.
The layout engine may continue using ANSI strings per terminal row rather than introducing a full cell object model.
Painting must:
terminal.rows base rows in constrained alt modeCURSOR_MARKER until cursor extractionReuse:
sliceByColumn()compositeTuiLine()visibleWidth()Paint each child at its allocated y. Skip children and line ranges that do not intersect the accumulated clip.
Paint each child at its allocated x. Pad short lines to the allocated width before composing adjacent children. Apply reset boundaries so one child's style or OSC 8 hyperlink does not leak into another.
-scrollTop.Keep terminal mouse parsing in TuiAltScreen, but convert parsed sequences into normalized events before routing:
interface TuiMouseEvent {
type: "press" | "release" | "move" | "wheel";
x: number;
y: number;
button: number;
deltaX: number;
deltaY: number;
}
The exact public visibility of this type is optional. The initial wheel router can remain internal.
Hit test the committed layout frame, not the frame currently being constructed.
For a wheel event:
ScrollView.overscroll is "chain", pass unused delta to the next scroll ancestor.overscroll is "contain", stop even when delta remains.Expected behavior:
Preserve current behavior of ignoring horizontal wheel events for a vertical-only scroll view. If an event contains both axes, consume only the supported vertical portion and document the policy.
Do not depend on detecting mouse support. Terminals do not provide a sufficiently reliable universal capability signal.
Keyboard navigation is always available through existing configurable actions:
tui.altScreen.pageUptui.altScreen.pageDowntui.altScreen.toptui.altScreen.bottomRoute these actions to:
For the first coding-agent layout there is only one scroll view, so the transcript is always the keyboard target.
If future layouts introduce multiple keyboard-selectable scroll regions, add configurable actions to TUI_KEYBINDINGS; never hardcode key checks.
TUI.setFocus(component) remains the public keyboard-focus API.CURSOR_MARKER in the final composited frame.showHardwareCursor behavior.The current alt renderer maps selection rows directly into one global logical document. That assumption no longer holds once fixed and horizontal regions exist.
For the first implementation, preserve visible-screen selection semantics:
stripTerminalSequences().If maintaining selection across frame changes is required, store enough source mapping in painted rows to translate screen rows into leaf line references. Do not map fixed dock rows to unrelated transcript rows.
Hyperlink clicking can continue reading OSC 8 metadata from the committed screen line at the clicked column. Ensure the final composed line, rather than an unshifted child line, is used.
Maintain current behavior:
openUrlThe initial required image case is the existing vertically scrolling transcript.
Preserve:
Horizontal composition of image protocol lines is not required to become fully general in the first implementation. Terminal image placements do not behave like ordinary ANSI text. Document and defensively handle the limitation:
HStack may be required to occupy the full row/widthDo not regress existing vertical image tests.
Keep the current overlay stack and positioning API.
Initial integration:
Existing overlays are not required to become nested ScrollView layout roots in the first implementation. However, base-layout hit testing must not break overlay focus or input ownership.
A later phase can give each overlay its own constrained layout tree and include overlay boxes as higher hit-test layers.
TuiAltScreen refactorSuggested state after the change:
private layoutRoot?: Component;
private currentLayout?: LayoutFrame;
private implicitScrollView?: ScrollView;
Move these responsibilities out of TuiAltScreen global fields and into ScrollView where applicable:
scrollTopcontentLineCountstickToBottomCompatibility getters/methods such as viewportTop, isFollowingOutput, scrollBy(), scrollToTop(), and scrollToBottom() may delegate to the primary/implicit scroll view so existing tests and consumers continue to work. Do not preserve backward compatibility if it materially complicates the implementation unless tests/public API indicate these methods are relied upon; check exports and usage before removal.
doRender() becomes conceptually:
const root = this.layoutRoot ?? this.getImplicitLegacyRoot();
const nextLayout = layoutConstrained(root, width, height);
let screen = paint(nextLayout);
screen = this.compositeOverlays(screen, width, height);
screen = this.applySelection(screen);
const cursor = this.extractCursorPosition(screen, height);
// Normalize, crop defensive overflow, diff, write.
this.currentLayout = nextLayout;
When callers only use tui.addChild():
implicit ScrollView(primary, follow=end)
└─ implicit vertical document of TuiAltScreen.children
This preserves the current standalone TuiAltScreen API and tests.
The implicit root must observe subsequent addChild(), removeChild(), and clear() mutations.
When leaving alt mode, render the explicit or implicit root with unbounded height:
ScrollView emits its complete child rather than a clipped viewport.Do not use only the last visible frame as the exit document.
File: packages/coding-agent/src/modes/interactive/interactive-mode.ts
Changes should remain small.
private documentContainer: Container;
private footerContainer: Container;
The existing component containers remain unchanged:
headerContainerloadedResourcesContainerchatContainerpendingMessagesContainerstatusContainerwidgetContainerAboveeditorContainerwidgetContainerBelowBuild the transcript group once:
this.documentContainer.addChild(this.headerContainer);
this.documentContainer.addChild(this.loadedResourcesContainer);
this.documentContainer.addChild(this.chatContainer);
Build the footer slot once:
this.footerContainer.addChild(this.footer);
Preserve exact current ordering:
this.ui.addChild(this.documentContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footerContainer);
Because documentContainer is visually transparent, its three children render exactly where they do today.
const transcript = new ScrollView(this.documentContainer, {
follow: "end",
primary: true,
overscroll: "chain",
});
const dock = new VStack([
{ component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
{ component: this.statusContainer, shrink: 1, minSize: 0 },
{ component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
{ component: this.editorContainer, shrink: 1, minSize: 3 },
{ component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
{ component: this.footerContainer, shrink: 1, minSize: 1 },
]);
const root = new VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]);
viewportTui.setLayoutRoot(root);
Use isViewportTUI(this.ui) for narrowing. Since options.alt selected the renderer, failure to obtain the capability is an internal programming error rather than a silent fallback.
Refactor setExtensionFooter() so it never removes/adds root TUI children:
this.footerContainer.clear();
this.footerContainer.addChild(this.customFooter ?? this.footer);
this.ui.requestRender();
Continue disposing replaced custom footers.
These features mutate existing stable containers and should automatically appear in the correct layout.
Revisit this code:
if (hadActiveStatusIndicator && !this.options.alt && this.ui.getClearOnShrink()) {
this.statusContainer.addChild(this.idleStatus);
}
The main-screen workaround should remain main-screen-only. Constrained alt layout should naturally clear released rows.
Likely new files:
packages/tui/src/layout.ts — internal constraints, boxes, layout, paint, hit testingpackages/tui/src/components/v-stack.tspackages/tui/src/components/h-stack.tspackages/tui/src/components/scroll-view.tsLikely modified files:
packages/tui/src/tui.tspackages/tui/src/tui-alt-screen.tspackages/tui/src/index.tspackages/tui/src/keybindings.ts only if new configurable actions are requiredpackages/coding-agent/src/modes/interactive/interactive-mode.tspackages/tui/test/tui-alt-screen.test.tspackages/tui/test/packages/coding-agent/test/interactive-tui.test.tspackages/tui/README.mdpackages/coding-agent/docs/usage.mdpackages/coding-agent/docs/keybindings.md if keyboard behavior changespackages/tui/CHANGELOG.mdpackages/coding-agent/CHANGELOG.mdDo not modify released changelog sections. Add entries under the existing ## [Unreleased] subsections.
Add focused unit tests for both axes:
follow: "end" positionscrollToEnd() reenables followscrollBy() returns unused positive/negative deltaoverscroll: "contain"Extend packages/tui/test/tui-alt-screen.test.ts:
addChild() path still behaves as current implicit scrollingRetain and pass all existing image tests:
In packages/coding-agent/test/interactive-tui.test.ts or a focused new test:
footerContainerPrefer inspecting component composition or using VirtualTerminal; do not use real provider APIs.
After implementation changes:
npm run check from the repository root and fix all errors, warnings, and infos.npm test or the full Vitest suite../test.sh for all non-e2e tests if broader validation is warranted.AGENTS.md:
VStack unbounded rendering and constrained internal layout.HStack with ANSI-safe composition.ScrollView state and unit tests independent of terminal ANSI output.TuiAltScreen.ScrollView compatibility path.footerContainer.npm run check.The implementation is complete when:
npm run check pass.