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.
- The constrained layout system is an alternate-screen feature.
TuiMainScreenkeeps its existing terminal-scrollback rendering model.- Interactive mode uses two different compositions, but shares the same component instances and behavior.
- The public layout primitives are initially:
VStackHStackScrollView- existing overlays
- The frame-specific layout tree is internal. API users construct a component tree and never manipulate layout boxes, rectangles, hit-test nodes, or scroll ancestry.
- Rebuild the internal layout tree on each requested render. Do not rebuild component state.
- Rely on existing leaf render caches, especially
Markdown,Text,Image, andBox. Do not introduce a second framework-level render cache initially. Editordoes not currently cache its rendered lines, but it is small and active; this is not expected to be the dominant cost.- Keep
interactive-mode.tschanges declarative and minimal. Layout, clipping, scrolling, hit testing, and event routing belong inpackages/tui. - Mouse wheel support is an enhancement. Configurable keyboard scrolling must always remain available.
The terminal owns scrolling in main-screen mode. The application cannot reliably provide:
- sticky rows
- independently scrollable nested regions
- full-height side-by-side panes
- reliable mouse hit testing for content moved into terminal scrollback
- arbitrary repainting of off-screen regions without replaying or clearing scrollback
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.
- Constrained root layout in
TuiAltScreen. - Vertical and horizontal stack layout.
- Vertical scrolling with follow-end behavior.
- Sticky coding-agent dock.
- Existing mouse-wheel and keyboard transcript scrolling.
- Wheel routing based on the region under the pointer.
- Scroll chaining for nested scroll views.
- Existing overlay rendering must continue to work.
- Existing cursor positioning and IME support must continue to work.
- Existing hyperlink clicking and mouse text selection must continue to work.
- Existing Kitty image behavior must not regress for the transcript use case.
TuiMainScreenbehavior and output order must remain unchanged.- Leaving alt mode must still print a complete logical final document.
- Wide-terminal sidebars.
- Independently scrollable transcript and sidebar.
- Sticky top regions.
- Layout-aware overlays.
- Scrollbars and unread-line indicators.
- Transcript virtualization.
- CSS-compatible flexbox.
- Grid layout.
- Wrapped flex rows.
- Arbitrary absolute positioning; overlays already cover this need.
- Percentage sizing unless it falls out naturally from existing size utilities.
- Virtualized transcript rendering.
- Incremental layout-tree mutation.
- A public API for custom components to create or mutate internal layout nodes.
- Reworking every existing component to understand height constraints.
- Giving main-screen mode fake sticky or nested-scroll semantics.
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.
export 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:
- Public
render(width)provides an unbounded-height rendering for compatibility and debugging. - Constrained behavior is invoked internally by
TuiAltScreenthrough the internal layout engine. - Children are arranged from top to bottom.
gaprows appear only between visible children.- The cross axis defaults to
stretch.
export 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:
- Children are arranged from left to right.
- Child widths are allocated from
basis,grow,shrink,minSize, andmaxSize. - Shorter children are padded according to
align. - Compose ANSI lines using existing ANSI-aware slicing/compositing utilities. Never use plain string length or raw substring for terminal columns.
- Initial image support only needs to preserve current vertical transcript behavior. See the image section for horizontal limitations.
export 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:
- Requested
+3, moved+3: return0. - Requested
+3, only one row remained: move one and return+2. - Requested
-3, already at the top: return-3.
Behavior:
- In constrained layout, the child is measured/rendered at unbounded height and clipped to the allocated viewport.
- In public unbounded
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 currentTuiAltScreen.stickToBottom:- start in follow mode
- content growth keeps the view at the end
- scrolling away from the end disables follow mode
- reaching or explicitly scrolling to the end enables follow mode
- Scrolling must request a render.
- Preserve
scrollTopwhen 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:
- painting visible terminal rows
- clipping nested children
- hit testing from terminal coordinates
- translating to component-local coordinates
- walking ancestors
- identifying scroll ancestors
- locating cursor markers
- retaining enough mapping for selection and hyperlinks
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:
Markdowncaches text, width, and rendered lines.Textcaches text, width, and rendered lines.Imagecaches width and rendered lines.Boxcaches based on width/background/child output.- several coding-agent animation and tool components have their own caches.
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.
- Evaluate
visibleagainst the terminal viewport dimensions. - Remove invisible entries before calculating gaps or size distribution.
basis: "auto"uses the child's intrinsic size on the main axis.- Numeric
basisuses the given cell count. - Clamp basis to
minSize/maxSize. - For wrapped leaves, width allocation must happen before intrinsic height is known.
HStacktherefore allocates widths before measuring child heights.VStackrenders/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:
- Compute shrinkable entries (
shrink > 0and current size aboveminSize). - Distribute required shrink proportional to
shrinkand current basis, or another deterministic documented policy. - Repeat if an entry reaches
minSizebefore overflow is resolved. - If constraints still cannot be satisfied, clip at the parent boundary.
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:
- Preserve at least one transcript row when terminal height permits.
- Preserve the focused editor/selector cursor.
- Preserve at least one footer row when possible.
- Clip/truncate widgets and pending/status content before hiding the focused editor.
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:
- create exactly
terminal.rowsbase rows in constrained alt mode - respect each box's rectangle and accumulated clip
- use ANSI-aware column slicing
- reset styles between independently painted regions
- preserve
CURSOR_MARKERuntil cursor extraction - compose horizontal children without style leakage
- produce lines no wider than terminal width
Reuse:
sliceByColumn()compositeTuiLine()visibleWidth()- existing line-reset normalization
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.
- Child content is laid out at its full natural height.
- The child's painted origin is translated by
-scrollTop. - Accumulate the scroll view's rectangle into the clip.
- Only paint child rows intersecting the viewport.
- Record the scroll box in the layout tree for hit testing and ancestor walking.
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.
- Reject boxes outside their clip.
- Traverse higher layers/frontmost children first.
- Return the deepest visible box containing the terminal coordinate.
- Preserve the ancestor chain for event bubbling.
For a wheel event:
- Hit test at the pointer.
- Starting at the deepest box, walk toward the root.
- Offer the delta to each encountered
ScrollView. - If
overscrollis"chain", pass unused delta to the next scroll ancestor. - If
overscrollis"contain", stop even when delta remains. - If no hit ancestor consumes the delta, offer it to the frame's primary scroll view.
- Consume recognized mouse sequences so raw mouse bytes never reach the editor.
Expected behavior:
- Wheel over transcript: scroll transcript.
- Wheel over a future sidebar: scroll sidebar.
- Wheel over a nested scroll view: scroll inner view, then chain at its boundary.
- Wheel over non-scrollable dock/footer: scroll primary transcript.
- Wheel interaction must not steal keyboard focus from the editor.
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.bottom
Route these actions to:
- an explicitly active scroll region, if future multi-pane navigation sets one
- otherwise the primary scroll view
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.
- Existing
TUI.setFocus(component)remains the public keyboard-focus API. - Keyboard focus and wheel-scroll target are separate. Scrolling a sidebar must not move focus away from the editor unless explicitly requested.
- During paint, find
CURSOR_MARKERin the final composited frame. - Cursor row/column must include stack offsets, scroll translations, overlay offsets, and horizontal-pane offsets.
- Only show the hardware cursor according to existing
showHardwareCursorbehavior. - Layout containment checks used by overlay focus restoration must understand layout roots and nested layout components.
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:
- Anchor and focus begin from terminal-screen coordinates.
- Apply highlight against the current committed visible frame.
- Copy text from the selected visible rows/columns using ANSI-aware slicing and
stripTerminalSequences(). - Blank/padded areas contribute no text beyond required line separation.
- Continue snapping selection columns to grapheme boundaries.
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:
- click without drag may call
openUrl - dragging does not activate a URL
- release after drag copies via OSC 52
The initial required image case is the existing vertically scrolling transcript.
Preserve:
- Kitty image metadata and reserved rows
- cropping when the top of a Kitty image is above the scroll viewport
- deletion/redraw when image-containing rows change
- iTerm2 fallback to text in alt mode
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:
- an image-bearing component in an
HStackmay be required to occupy the full row/width - do not silently corrupt adjacent pane output
- add a focused test for whatever fallback policy is chosen
Do not regress existing vertical image tests.
Keep the current overlay stack and positioning API.
Initial integration:
- Paint the base constrained layout into terminal-height lines.
- Composite existing overlays over those lines using current overlay logic.
- Extract the cursor from the final result.
- Apply differential rendering.
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.
Suggested 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:
scrollTopcontentLineCountstickToBottom
Compatibility 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:
ScrollViewemits its complete child rather than a clipped viewport.- The coding-agent transcript appears first and the dock appears once after it.
- Do not print terminal-height padding rows.
- Strip cursor markers.
- Preserve existing line resets and image cleanup.
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:
headerContainerloadedResourcesContainerchatContainerpendingMessagesContainerstatusContainerwidgetContainerAboveeditorContainerwidgetContainerBelow
Build 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.
- message rendering
- streaming updates
- tool updates
- widget APIs
- editor replacement
- extension selectors/input/editor
- built-in selectors
- queue rendering
- status indicators
- focus changes
- overlays
- theme invalidation
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.ts
Likely modified files:
packages/tui/src/tui.tspackages/tui/src/TuiAltScreen.tspackages/tui/src/index.tspackages/tui/src/keybindings.tsonly if new configurable actions are requiredpackages/coding-agent/src/modes/interactive/interactive-mode.tspackages/tui/test/tui-alt-screen.test.ts- new focused layout tests under
packages/tui/test/ packages/coding-agent/test/interactive-tui.test.tspackages/tui/README.mdpackages/coding-agent/docs/usage.mdpackages/coding-agent/docs/keybindings.mdif keyboard behavior changespackages/tui/CHANGELOG.mdpackages/coding-agent/CHANGELOG.md
Do not modify released changelog sections. Add entries under the existing ## [Unreleased] subsections.
Add focused unit tests for both axes:
- auto-sized children
- numeric basis
- positive grow distribution
- shrink distribution
- min/max clamping
- deterministic odd-cell rounding
- gaps only between visible children
- conditional visibility
- cross-axis alignment
- child output wider than allocation is clipped safely
- ANSI styles/hyperlinks do not leak between horizontal children
- CJK, emoji, and combining-character boundaries in horizontal clipping
- initial
follow: "end"position - content growth while following
- manual upward scroll disables follow
- reaching bottom reenables follow
- explicit
scrollToEnd()reenables follow - viewport growth/shrink while following
- viewport growth/shrink while manually positioned
scrollBy()returns unused positive/negative delta- nested scroll chaining
overscroll: "contain"- child shorter than viewport
- empty child
- child width change
- cursor marker remains visible when focused content is clipped
- generated rectangles for nested V/H stacks
- accumulated clipping
- hit testing returns deepest visible box
- clipped boxes are not hit-testable
- local coordinate translation
- layer ordering
- only visible rows are painted from a scroll view
- each frame uses fresh geometry after resize/content change
- cached leaf line arrays are accepted by reference and not mutated
Extend packages/tui/test/tui-alt-screen.test.ts:
- legacy
addChild()path still behaves as current implicit scrolling - explicit layout root renders terminal-height frame
- fixed dock remains unchanged while transcript scrolls
- transcript viewport height accounts for dock height
- dock growth/shrink while following
- dock growth/shrink while manually scrolled
- Shift+PageUp/Down targets primary ScrollView
- Ctrl+Home/End targets primary ScrollView
- wheel over transcript scrolls transcript
- wheel over non-scrollable dock falls back to primary transcript
- nested scroll consumes first and bubbles unused delta
- mouse-disabled mode still supports keyboard scrolling
- cursor row is correct inside dock
- cursor row is correct inside scrolled content
- overlay compositing remains screen-relative
- overlay focus behavior remains correct
- OSC 8 click remains correct after horizontal/vertical offsets
- selection/copy works in transcript
- selection/copy works in dock without mapping to transcript rows
- terminal resize recomputes layout
- oversized dock does not lose the focused cursor
- stopping prints complete transcript plus dock exactly once
- no terminal padding rows in final output
Retain and pass all existing image tests:
- Kitty cropping at viewport top
- image deletion/redraw
- iTerm2 fallback
- no stale image placements
- existing main-screen tests pass unchanged
- interactive main-screen child order/rendered output is unchanged
- custom footer remains at the bottom in flow
- no layout root or application scrolling is installed in main-screen mode
In packages/coding-agent/test/interactive-tui.test.ts or a focused new test:
- renderer capability is exposed only for alt mode
- main mode mounts flow composition
- alt mode mounts transcript ScrollView plus dock
- pending/status/widgets/editor/footer are in the dock
- custom footer replacement updates
footerContainer - editor replacement does not rebuild the root layout
- widget updates do not rebuild the public component composition
Prefer inspecting component composition or using VirtualTerminal; do not use real provider APIs.
After implementation changes:
- Run each modified/new focused test from the relevant package root using the repository-prescribed Vitest invocation.
- Run
npm run checkfrom the repository root and fix all errors, warnings, and infos. - Do not run
npm testor the full Vitest suite. - Optionally use the repository's
./test.shfor all non-e2e tests if broader validation is warranted. - Manually exercise alt mode in tmux using the procedure in
AGENTS.md:- long transcript
- wheel/trackpad scrolling
- Shift+PageUp/Down
- streaming while manually scrolled
- return to bottom/follow
- multiline editor
- autocomplete open
- settings/model/tree selectors replacing editor
- extension widget above and below editor
- custom footer
- terminal resize
- hyperlink click
- mouse selection/copy
- Kitty image where available
- Manually smoke-test main-screen mode to ensure terminal scrollback behavior is unchanged.
- Add stack allocation unit tests and shared axis allocator.
- Implement
VStackunbounded rendering and constrained internal layout. - Implement
HStackwith ANSI-safe composition. - Implement
ScrollViewstate and unit tests independent of terminal ANSI output. - Implement internal layout frame generation and painting.
- Add hit testing and scroll-ancestor traversal.
- Integrate explicit and implicit layout roots into
TuiAltScreen. - Move current global alt scrolling behavior behind the implicit primary
ScrollViewcompatibility path. - Preserve selection, hyperlinks, cursor, overlays, and image handling one subsystem at a time, running existing tests after each step.
- Add coding-agent grouping containers and the two small composition branches.
- Refactor custom footer replacement to use
footerContainer. - Add integration tests, docs, and changelog entries.
- Run focused tests and
npm run check. - Perform tmux/manual smoke tests in both modes.
The implementation is complete when:
- Main-screen mode behaves as before and preserves terminal scrollback.
- Alt-screen mode has a scrollable transcript and fixed bottom dock.
- Streaming follows the transcript end only while follow mode is active.
- Manual scrolling remains stable while new output arrives.
- Mouse wheel routes to the appropriate scroll view and chains at boundaries.
- Keyboard navigation works with mouse disabled.
- Editor/selector focus and IME cursor placement remain correct.
- Widgets and custom footers remain extension-compatible and fixed in alt mode.
- Hyperlinks, selection, overlays, and Kitty transcript images do not regress.
- Leaving alt mode prints the complete logical document once.
- Layout boxes are internal and rebuilt per requested frame.
- Expensive leaf rendering continues to use existing component caches.
- All focused tests and
npm run checkpass.