Skip to content

Repository files navigation

textual-flowview

CI Python Textual License: MIT

The virtualized feed for AI-agent, chat, and log TUIs in Textual.

Building a terminal UI for an AI agent, an assistant chat, or a live log? You hit the same wall: thousands of variable-height entries — Markdown, tool calls, code blocks, streaming tokens, per-message status — and a plain VerticalScroll that mounts one widget per message grinds to a halt.

textual-flowview is the widget for exactly that. It paints only the visible rows (O(viewport) — one widget regardless of N), so a 10k-message transcript scrolls as smoothly as ten, and it ships the things those feeds actually need:

  • streaming updates — mutate an entry and re-present it, cheaply, while the gutter tracks its state independently;
  • a per-message state gutter — running / success / error / cancelled, with live spinners, without re-rendering the body;
  • sticky-bottom follow — pinned to the newest, released when you scroll up (Slack / Discord / Claude Code behaviour);
  • infinite scroll-back — lazy-load older history without losing your place;
  • Rich renderables per messagePanel, Table, Syntax, Markdown, Spinner, ProgressBar drop straight in.

Under the hood it's a general variable-height virtualized list — timelines, git history, notifications, mail work too — but it's shaped for the agent / chat / log feed. The widget only ever deals with a Model + Presenter; it never sees your data type.

Activity feed with a per-state gutter, streaming, and sticky-bottom

Collapsible groups with a sticky header Minimap replacing the scrollbar, errors visible at a glance

Why virtualize? (measured)

The same list built two ways — a Textual VerticalScroll with one Static widget per row vs a FlowView — at N rows (examples/benchmark.py, mid-range laptop):

rows build (container → flowview) widgets (container → flowview) full re-layout / resize
100 120 → 84 ms 101 → 1 138 → 138 ms
400 278 → 86 ms 401 → 1 299 → 135 ms
1000 583 → 96 ms 1001 → 1 553 → 150 ms
2000 1155 → 117 ms 2001 → 1 1123 → 171 ms (6.6× faster)

The container mounts one widget per row — O(N) DOM, layout, and memory that grows with the list. FlowView paints the visible rows, so it is O(viewport): one widget regardless of N, a flat build and re-layout, and scrolling that stays smooth. Rendered rows are cached for what's on screen (plus overscan) and released once they scroll out, so the render cache stays O(viewport) too rather than growing with everything you've scrolled past; scrolling back re-renders them synchronously from the retained presentation (sub-millisecond, no placeholder). See docs/memory.md for what's cached, what it costs, and the levers — worth a read if your entries carry images.

examples/compare.py shows it live with an FPS meter on the same 1500-row list. Flip c / f for the backend and d for the content:

The same 1500-row list: a Static-per-row container vs a FlowView, with a live FPS meter

  • static rows (plain text, rendered once): the container drops to ~14 FPS with 1500 widgets while FlowView holds ~60 FPS with 3 — a full re-layout every scrolled frame vs painting the viewport.
  • dynamic rows (a rich ProgressBar re-rendering every frame) narrow the gap — both sides then spend most of the frame re-rendering the visible bars — but FlowView still leads and never grows past its viewport-sized widget count.

For a true visual side-by-side, run each pane in its own process (its own render loop) — a 2×2 split covers both axes:

PYTHONPATH=src python examples/compare.py flowview  static
PYTHONPATH=src python examples/compare.py container static
PYTHONPATH=src python examples/compare.py flowview  dynamic
PYTHONPATH=src python examples/compare.py container dynamic

Core ideas

  • FlowModel[T] owns an ordered collection of items and knows nothing about the UI.
  • Entry is the single stable handle to a displayed item — the only way to update or remove it.
  • FlowPresenter is the only component that knows the concrete item type; it turns an item into a Presentation (height + renderable).
  • FlowView draws Presentations and manages the viewport. It never sees your data type.

Handle-based API

append / insert return an Entry. There is no model.update(item) — the entry is the identity, which keeps mutable and in-place-mutated items safe:

from textual_flowview import FlowModel

conversation = FlowModel()

entry = conversation.append(ChatMessage(role="assistant", text=""))
entry.item.text += "Hello"
entry.update()                 # bumps revision → re-present
entry.item.text += " World"
entry.update()

entry.remove()                 # no-op if already removed

Body & Gutter (state / metadata)

Each row is split into a gutter and a body:

┌────────┬──────────────────────┬────────┐
│ Gutter │         Body         │ Gutter │   ← the right gutter is optional
└────────┴──────────────────────┴────────┘
  • The body is what FlowPresenter produces.
  • A gutter is what a FlowDecorator produces — status markers, icons, badges, timestamps. There's one on the left by default; add a right one with right_decorator / right_gutter_width (e.g. a state marker on the left and a timestamp or a scrollbar-style indicator on the right). The two are fully independent; the body simply gets whatever width is left over.

Each gutter can be shown or hidden at runtime — the configured width is remembered and handed back to the body while hidden (the list reflows):

flow.hide_gutter("right")          # or "left"
flow.show_gutter("right")
flow.toggle_gutter("left")         # -> new visibility (bool)
flow.set_gutter_visible("left", False)
flow.left_gutter_visible           # -> bool   (also right_gutter_visible)

flow.body_width                    # width the presenter gets (both gutters removed)
flow.left_gutter_effective_width   # cells the gutter takes now (0 when hidden)

body_width is the width passed to FlowPresenter.present — it shrinks and grows with the gutters, unlike region.width (the whole content width). Assert against it when you need to verify a gutter actually gave its width back.

They update independently. Changing an entry's state or metadata redraws only the gutter — the body is not re-presented and nothing re-layouts, so high-frequency status updates stay cheap:

from textual_flowview import EntryState, StateDecorator

flow = FlowView(
    model=model,
    presenter=ChatPresenter(),
    decorator=StateDecorator(),   # left gutter: markers per EntryState
    gutter_width=2,
    # right_decorator=TimeGutter(), right_gutter_width=6,  # optional second gutter
)

entry = model.append(msg)
entry.set_state(EntryState.RUNNING)   # gutter only  ✻
entry.update()                        # body re-presented
entry.set_state(EntryState.SUCCESS)   # gutter only  ✓
entry.set_metadata("time", "09:31")   # gutter only, arbitrary data

EntryState provides DEFAULT / RUNNING / SUCCESS / ERROR / CANCELLED. Write your own decorator for full control:

class MyDecorator:
    def decorate(self, entry, width, height):
        return Text("●" if entry.metadata.get("unread") else " ", style="cyan")

A presenter exception both renders an error body and flips the entry to EntryState.ERROR (so the gutter shows it) without crashing the app.

Collapse & group collapse

Two kinds of collapse fall out of the design:

Per-item collapse is purely a presenter concern — no library feature needed. Keep a collapsed flag on your item; present a compact renderable when set; call entry.update() to reflow:

async def present(self, item, width):
    if item.collapsed:
        return Presentation(height=1, renderable=Text(f"▸ {item.title}"))
    return Presentation(height=full, renderable=Panel(...))   # ▾ expanded

Group collapse uses the library's entry-visibility primitive. Hidden entries stay in the model and keep their cached presentation (showing them again is instant and never re-presents), but contribute no height and aren't drawn:

entry.hidden          # bool
entry.hide()          # exclude from the view
entry.show()          # re-include
entry.set_hidden(True)

A collapsible header is then just hiding a run of child entries — see examples/groups.py:

def collapse_group(header, children):
    header.item.collapsed = True
    header.update()                 # redraw the ▸ chevron
    for child in children:
        child.hide()                # the group-collapse primitive

Which entries belong to a group is up to you — grouping policy varies by app, so the library ships the visibility primitive rather than a fixed hierarchy.

Sticky headers

Pin the current group's header to the top while scrolling through it. Tell FlowView which entries are headers with a predicate; the pinned header swaps as you cross group boundaries, and the next header pushes the previous one up:

FlowView(
    model=model,
    presenter=presenter,
    sticky_header=lambda e: e.item.kind == "header",
)

Style the pinned rows via the flowview--sticky-header component class — it's unstyled by default (FlowView ships no colours), so give it a background if you want the pinned header to stand out:

FlowView > .flowview--sticky-header { background: $panel; }

See examples/groups.py.

Headers & footers

There's no dedicated header/footer prop, on purpose — the two cases are already covered:

  • Fixed chrome (a title bar, toolbar, or input that stays put) is a plain widget outside the FlowView — just put a Static / Panel / Input above or below it in compose (see the input bar in examples/chat.py).
  • A band that scrolls with the content (an intro banner, a "load more" footer, a "— start of history —" marker) is a regular entryinsert(0, …) for a header, append(…) for a footer, presented however you like. It scrolls with the list for free, and sticky_header can even pin it. A "load more" footer entry reports clicks through FlowView.Clicked.

Minimap (scrollbar replacement)

FlowMinimap is a thin overview strip that replaces the scrollbar: it compresses the whole flow into one column, painting each row in the colour of its most notable state (red = error) and highlighting the on-screen range as the "window" (a content-aware scroll thumb). Click or drag it to jump.

from textual.containers import Horizontal
from textual_flowview import FlowView, FlowMinimap

def compose(self):
    self.view = FlowView(model=m, presenter=p)
    with Horizontal():
        yield self.view
        yield FlowMinimap(flow_view=self.view)

# hide the native scrollbar so the minimap stands in for it:
# CSS:  FlowView { scrollbar-size-vertical: 0; }

State→colour is overridable (FlowMinimap(..., colors={EntryState.ERROR: "magenta"})) and the window band is themed via the flowminimap--window component class. See examples/minimap.py (a 400-line scan log — errors are visible at a glance).

Keys & focus

FlowView is unopinionated about keys — it defines no BINDINGS of its own, so it never conflicts with your app's shortcuts. The only keys it responds to are the standard scroll keys (arrows, Home/End, PageUp/PageDown) inherited from Textual's ScrollableContainer, and only while the widget is focused — Textual resolves keys through the focus chain, so FlowView never globally captures them. Ctrl+C copy is Textual's own Screen binding, not ours.

Customize with any standard Textual mechanism:

  • override keys in your App / Screen / a FlowView subclass via BINDINGS;
  • set flow.can_focus = False so it never grabs scroll keys (wheel still works);
  • bind your own keys to the public methods (scroll_to_bottom, find_next, copy_entry, …).

The library never binds a key to an action for you — that's your app's call.

Spacing & full-row background

FlowView(spacing=1) puts blank rows between entries (default 1; set 0 to pack them). The gap is real layout — scrolling, hit-testing, and the minimap all account for it.

Draw something in that gap with separator. spacing stays the source of truth for the gap's height; separator is what fills those rows — any Rich renderable (a plain string counts), or a callable(above, below) for context-dependent dividers (return None to leave a gap blank):

from rich.rule import Rule

FlowView(spacing=1, separator="────────")          # a rule between every entry
FlowView(spacing=1, separator=Rule(style="grey30"))  # full-width, styled

# contextual — a date divider only when the day changes (above/below are Entry):
FlowView(spacing=1, separator=lambda above, below:
    day_header(below.item) if above.item.day != below.item.day else None)

A multi-row separator just needs the matching spacing (e.g. spacing=2 for a two-line divider).

Give an entry a full-row background — painted edge to edge across the gutter, body, and trailing padding — via Presentation.background, so a message reads as one continuous coloured block (no hand-rolled full-width grid, no gutter-colour coordination):

async def present(self, item, width):
    body = Text(item.text)
    return Presentation(
        height=..., renderable=body,
        background="#2b2f37" if item.role == "user" else None,
    )

Scroll anchoring

from textual_flowview import Anchor

FlowView(model=..., presenter=..., anchor=Anchor.CURRENT)          # default
FlowView(model=..., presenter=..., anchor=Anchor.STICKY_BOTTOM)    # chat / log

STICKY_BOTTOM follows new items only while the user is already at the bottom — scrolling up to read history stops the auto-follow (Slack / Discord / Claude Code behaviour). A scroll-up during early streaming counts too: even before there's room to move (content still fits, max_scroll_y == 0), the gesture is the reader's signal to stop following, so new content won't yank them back to the tail.

Reading the follow state. flow.following is True while the view is auto-following its sticky edge (the tail for STICKY_BOTTOM, the head for STICKY_TOP), False once the reader scrolls away, and False for a non-sticky anchor. It flips post FollowChanged (event.following is the new state) — handle that instead of inferring "has the reader left the tail?" from max_scroll_y / scroll_offset, which can't tell "parked at the bottom, following" from "scrolled up during early streaming".

class MyApp(App):
    def on_flow_view_follow_changed(self, event: FlowView.FollowChanged) -> None:
        self.at_tail = event.following

For a newest-on-top feed, prepend with model.insert(0, item) and use Anchor.STICKY_TOP — the mirror of STICKY_BOTTOM: it stays pinned to the top (showing the newest) while the user is at the top, and stops following once they scroll down. CURRENT also works for prepend, but keeps the current position instead of following the top.

Infinite scroll (lazy-load history)

FlowView posts ReachedTop / ReachedBottom when scrolling brings an edge within reach_threshold rows — handle them to lazy-load more. They're edge-triggered: fire once on approach, re-arm when you scroll away.

Prepend a page with model.insert_many(0, items) (or extend at the bottom): one batch is one reflow, and it preserves the scroll position — the line you're reading stays put while older items appear above, no jump. (insert / append preserve position too; insert_many just does a page in a single reflow.)

flow = FlowView(model=log, presenter=..., reach_threshold=2,
                anchor=Anchor.STICKY_BOTTOM)

class MyApp(App):
    def on_flow_view_reached_top(self, event: FlowView.ReachedTop) -> None:
        older = fetch_older_page()          # a real app would await this
        log.insert_many(0, older)           # one reflow, position kept

This works in both directions: newest-at-bottom (STICKY_BOTTOM, scroll up → ReachedTopinsert_many(0, ...) for older) and newest-at-top (STICKY_TOP, scroll down → ReachedBottomextend(...) for older; appending below never shifts the view).

See examples/infinite.py (a log that pages in older lines as you scroll up).

Smooth scrolling (overscan & read-ahead)

FlowView only presents what's on (or near) screen. Two knobs control how much it prepares ahead of time so scrolling reveals real content, not placeholders:

FlowView(model=..., presenter=..., overscan=4, read_ahead=None)
  • overscan — extra rows presented above and below the viewport (a static cushion around the visible range).
  • read_ahead — extra rows pre-presented in the direction you're scrolling, on top of overscan. None (default) uses one viewport height; 0 disables it. Larger = smoother fast scrolling at the cost of presenting more up front.

Empty & loading states

When there are no entries to draw (an empty model, or every entry hidden), empty is shown across the viewport; empty_align places it vertically ("top" / "middle" (default) / "bottom"). Horizontal alignment and styling live in the renderable itself — wrap it in rich.align.Align / Panel as you like (FlowView adds no colours of its own):

from rich.align import Align

FlowView(model=..., presenter=..., empty=Align.center("No messages yet"))

This is distinct from placeholder (default "Loading..."), which is drawn per-entry for rows not presented yet. Both empty and placeholder are re-rendered on every paint, so a time-based renderable (e.g. rich.spinner.Spinner) animates — as long as something is repainting; set animation_fps > 0 to drive that repaint (see below).

Current entry (one cursor, keyboard + mouse)

There is one current entry — a single cursor moved by both the keyboard and the mouse, exactly like Textual's ListView. It's opt-in (off by default, so a plain feed never highlights or steals a click) and turned on with selectable=True:

flow = FlowView(model=..., presenter=..., selectable=True)

class MyApp(App):
    def on_flow_view_highlighted(self, event: FlowView.Highlighted) -> None:
        browse(event.entry)         # cursor MOVED (arrow key or click), or None
    def on_flow_view_selected(self, event: FlowView.Selected) -> None:
        commit(event.entry)         # cursor COMMITTED (Enter / Space / click)

Two events, one cursor: Highlighted fires as the cursor moves over entries (browsing); Selected fires when it's committed (a deliberate pick). That split is the whole model — move vs. act.

  • ↑/↓ move one entry (the view follows), PageUp/PageDown by a page, Home/End to first/last, Enter/Space commit, and a click both moves the cursor and commits it. The API those keys call: set_current(entry) (move), move_current(delta), current_first() / current_last(), activate() (commit), and the current property.
  • Keybindings are the product's to own — FlowView exposes the cursor as actions and ships only focus-scoped, overridable defaults. With the feature off (the default) the arrow / page / home / end keys just scroll and Enter/Space bubble to your app. Clicks still post FlowView.Clicked (with the in-entry position) either way, so you can hit-test presenter-drawn controls without turning the cursor on. Independent of Textual's native text selection and copy (below), which stays available regardless.
  • The current row is the flowview--highlight component class, unstyled by default — give it a colour:
FlowView > .flowview--highlight { background: $accent 30%; }

See examples/highlight.py.

Text cursor & visual mode (vim-style)

FlowView has a text cursor — a character cursor over the rendered content (across entries), with a visual mode for selection and yank, like vim visual. It's drawn with the widget's own text selection, so a yank copies exactly what's highlighted. There is no separate "copy mode" to enter: the movement keys are always live, and c shows/hides the cursor block.

flow = FlowView(model=..., presenter=..., selectable=True)  # `c` toggles the cursor
# or FlowView(..., cursor=True) to show it on mount

Two zoom levels, one cursor. While the cursor is hidden (the default), j/k (and /) move the entry cursor (current), or scroll when not selectable. Press c to show the cursor and the vim keys drive it at the character level. The text cursor is synced with the entry highlight — moving it moves current and posts Highlighted — so you can navigate and then select in one flow.

Visual mode is the only real mode: v / V starts a selection at the cursor, movement extends it, y yanks, Esc cancels. While selecting, the anchor (and the entry highlight) is frozen — no Highlighted fires — so the content you saw at v doesn't shift mid-select (a consumer may mutate an entry in its Highlighted handler). On exit the highlight catches up to where the cursor ended.

Every motion is a public method (and an action the default keys map onto): cursor_move, cursor_line_start / line_end / first_nonblank, cursor_word_forward / word_back / word_end, cursor_top / bottom, cursor_entry / entry_start / entry_end, visual() / visual_line(), yank(), cursor_scroll_center / to_top / to_bottom, cursor_scroll_line_down / line_up, cursor_scroll_half_page_down / _up / cursor_scroll_page_down / _up, show_cursor() / hide_cursor() / toggle_cursor() / cursor_visible.

The default vim keys: c show/hide cursor, h/j/k/l, w/b/e, 0/$/^, g/G, [/] (current entry top/bottom), v, V, y, * / n / N (search the selection, then next/prev), zz/zt/zb, Ctrl-E/Ctrl-Y (line), Ctrl-D/Ctrl-U (half page), Ctrl-F/Ctrl-B (page), Esc (cancel a selection). Char-level keys (h/l/w/y/…) bubble to your app while the cursor is hidden, so a plain feed doesn't steal them; j/k stay live for navigation. They're normal focus-scoped BINDINGS — subclass FlowView and override them to rebind. See examples/copy_mode.py.

Clipboard. Yank goes through write_clipboard(), which by default uses Textual's App.copy_to_clipboard — the terminal's OSC 52 escape. That does not work on macOS Terminal and can be swallowed by tmux/ssh, with no acknowledgement. For a reliable path, pass clipboard= (a Callable[[str], bool | None]) or override write_clipboard to shell out to pbcopy / xclip / wl-copy — a per-view seam, so you don't override app-wide copy, and the sink's result is observable.

Windows / git bash mojibake. OSC 52 sends the text as base64 of UTF-8 bytes; git bash's mintty then decodes it under its configured character set. If that isn't UTF-8, non-ASCII garbles on paste (ASCII survives) — a terminal charset mismatch, not a copy bug. Quick fix: set mintty to UTF-8 (Options → Text → Character set) and export LANG=…UTF-8. Robust fix (charset-independent): bypass OSC 52 with a native Unicode sink via clipboard=, e.g. clipboard=lambda t: (pyperclip.copy(t), True)[1] (pyperclip writes CF_UNICODETEXT directly, so no code-page in the loop).

Search the selection. search_selection() (default *) searches for the current visual selection — or the word under the cursor if there's no selection — and jumps to the next occurrence; search_next() / search_previous() (n / N) repeat, wrapping. search(query) searches an arbitrary string. They're async (a match may need its entry presented first). This is a text search over the content; the entry-level find / find_next by predicate is separate.

Give search the whole model with search_text=. FlowView only ever sees the Presentation a presenter produced, and an entry that has never scrolled into view has none — so by default a text search can only see rows that have already been rendered, and silently misses matches further down a long transcript. Hand it a way to read an item and the whole model becomes searchable:

FlowView(model=..., presenter=..., search_text=lambda msg: msg.text)

It's used to find the matching entry (cheap, no rendering), and only that entry is then presented to locate the exact row and column — a miss renders nothing at all. Return what the body renders: anything your presenter adds that isn't in the returned string won't be found.

cursor_scrolloff keeps N rows of context above/below the cursor (vim scrolloff); the view scrolls early to preserve it. It's capped at half the viewport, so flow.cursor_scrolloff = 999 pins the cursor to the centre and scrolls the content under it. Ctrl-E / Ctrl-Y (cursor_scroll_line_down / cursor_scroll_line_up) scroll the view a row while the cursor stays on its buffer row until scrolloff forces it along.

Rich renderables, indicators & animation

An entry's view is a plain rich.console.RenderableType — the same type a Textual widget returns from render(). So any Rich/Textual renderable drops straight into a Presentation: Panel, Table, Syntax, Markdown, and the built-in indicators rich.spinner.Spinner and rich.progress_bar.ProgressBar — no custom drawing.

Because a Presentation carries an explicit height, renderables whose height a Static can't auto-measure — a rich.progress_bar.ProgressBar is the classic one, it collapses to zero rows in a bare Static — just work in a FlowView. You tell FlowView the height; there's no per-widget styles.height to remember.

Images are just renderables too, so they compose with text in one entry (Group to stack, Table.grid / Columns for an avatar beside a message). On Kitty / WezTerm they're real pixelstextual-image's renderable uses the Kitty graphics protocol in Unicode-placeholder mode, which is cell-based, so it virtualizes and clips correctly as the feed scrolls; other terminals fall back to a half-block approximation automatically. See examples/image.py (avatars + text, an inline picture).

An animated GIF is just image frames advanced on a timer — pair the image renderable with animate_entry, which ticks only while the entry is on screen (an off-screen GIF stops animating automatically). See examples/gif.py.

FlowView caches an entry's render, so animation needs a clock. There is one animation primitiveanimate_entry — and the callback decides what to re-render, so the gutter and the body animate the same way:

view.animate_entry(entry, interval, callback)   # ticks only while on screen

animate_entry(entry, interval, callback) ties a timer to the viewport: FlowView pauses it when the entry scrolls off screen and resumes it when it scrolls back, so off-screen entries do no work. stop_entry_animation(entry) (or the returned handle's .stop()) cancels it; removal cleans it up. The callback re-renders whichever part changed:

# body — content that changes over time (progress bar, "thinking…")
def advance(e):
    e.item.progress = min(1.0, e.item.progress + 0.05)
    e.update()                       # re-present the body
    if e.item.progress >= 1.0:
        view.stop_entry_animation(e)
view.animate_entry(entry, 1 / 15, advance)

# gutter — a time-based decorator (rich.spinner.Spinner().render(time))
view.animate_entry(entry, 1 / 12, view.refresh_gutter)   # re-derive the gutter

refresh_gutter(entry) is the gutter counterpart of entry.update() (re-derives the gutter, never the body). And a plain entry.update() on an off-screen entry is cheap too: FlowView defers the re-present and reflow until it scrolls into view.

Shorthand: FlowView(animation_fps=12) auto-drives the gutter for all visible entries (equivalent to refresh_gutter on each, with no per-entry registration) — handy for "every RUNNING entry spins" with a time-based decorator.

See examples/progress.py (a gutter spinner via animation_fps, body progress via animate_entry).

Viewport overlay (screensavers, transitions, shaders)

play_overlay paints a full-viewport animation over everything — screen- relative (fills the visible window, doesn't scroll with content) and non-destructive (the model, scroll position and cursor are untouched, so it restores the exact prior view on stop):

def frames(width, height, covered):   # a per-frame iterator sized to the viewport
    # `covered` = the visible lines the overlay is hiding (top to bottom), exactly
    # as painted — gutters included, since the overlay covers the full width — so
    # the effect can act on the current screen (dissolve it, rain it away) without
    # recomputing it from the scroll offset. `len(covered) == height` always
    # (one string per row; "" past the end of the content).
    ...                               # yield Rich renderables (one per frame)

flow.play_overlay(frames, fps=30, loop=True)   # start
flow.stop_overlay()                            # stop -> exact prior view restored
flow.overlay_active                            # -> bool

frames(width, height, covered) is re-invoked on resize (and, with loop=True, each cycle — so it always sees the current screen, and a factory that picks a random effect cycles through different ones). oneshot (loop=False, the default) plays once, then clears the overlay (revealing the content beneath) and posts FlowView.OverlayFinished — ideal for intros/reveals where you've already set the real content underneath. It's driven by FlowView's own timer clock, the same mechanism as the other animations. The low-level hook is the flow.overlay property (assign a renderable to paint, None to clear) if you'd rather push frames from your own timer.

FlowView owns painting the viewport; the effect, and any trigger policy (idle → screensaver, an intro, a transition), are yours — nothing here knows about "screensavers".

Renderable-agnostic — no effect library dependency. FlowView's only runtime dependency is textual; it never imports an effects library and speaks only in Rich renderables. To use TerminalTextEffects you install it yourself and bridge each frame (an ANSI string) with Text.from_ansi — the whole adapter is a few lines:

# pip install terminaltexteffects   (yours to add — not pulled in by flowview)
from rich.text import Text
from terminaltexteffects.effects.effect_beams import Beams

def frames(width, height):
    effect = Beams("hello")
    effect.terminal_config.canvas_width = width
    effect.terminal_config.canvas_height = height
    return (Text.from_ansi(f) for f in effect)   # ← the whole bridge

flow.play_overlay(frames, fps=30, loop=True)

This is deliberate: bundling TTE (or wrapping its API in a helper here) would couple FlowView to a specific effects library and burden consumers who don't use one. The seam is frames → RenderableType, so any ANSI/Rich frame source works. See examples/screensaver.py (idle-triggered, random TTE effect, dismiss on any key).

Viewport-scoped resources (the general hook)

animate_entry is a convenience over the general primitive: tie any resource's lifecycle to whether an entry is on screen. track_visibility runs on_show when the entry enters the viewport and on_hide when it leaves — and also on stop / removal, so a resource is always released:

view.track_visibility(
    entry,
    on_show=lambda e: e.item.stream.subscribe(),    # acquire when visible
    on_hide=lambda e: e.item.stream.unsubscribe(),  # release when hidden
)

Use it for anything scoped to visibility — a data subscription, a video, a lazily-loaded image, a timer. Returns a VisibilityHandle; .stop() unregisters (releasing if currently shown).

Shedding a heavy body off-screen. FlowView can't see inside a renderable — it doesn't know which entries carry an image — so which bodies are expensive is yours to decide. The lever is the ordinary one: swap the item for a light version and update(). Paired with track_visibility, a scrolled-past image degrades to a caption and comes back when you return to it:

view.track_visibility(
    entry,
    on_hide=lambda e: (e.item.drop_image(), e.update()),     # -> "🖼 chart.png"
    on_show=lambda e: (e.item.restore_image(), e.update()),
)

The superseded presentation is released immediately, even though the entry is off-screen when it happens, so the memory actually comes back (its height is remembered, so nothing on screen shifts). See docs/memory.md for the full picture.

Interactive Textual widgets (Button, Select, Input) are a different thing — FlowView paints renderables rather than mounting child widgets, so those aren't hosted per entry. For clickable controls, draw them and hit-test FlowView.Clicked (below).

Interaction & content replacement

FlowView paints Rich renderables — it doesn't mount a real widget per row. To build clickable controls inside the flow (buttons, option chips, an intervention selector), draw them in the presenter and hit-test the click: FlowView.Clicked reports the entry and the position within it, so you know which control was pressed.

class MyApp(App):
    def on_flow_view_clicked(self, event: FlowView.Clicked) -> None:
        entry, col, row = event.entry, event.x, event.y   # x,y local to the entry body
        ...

Replace a specific item's content at any time — mutate it and entry.update(), or swap the whole object with entry.set_item(new) (handy for immutable items via dataclasses.replace). Either re-presents just that entry. See examples/intervention.py for a clickable selector that resolves via set_item.

Incremental streaming (patch_rows)

update / set_item re-present the whole body, so streaming a growing entry chunk-by-chunk costs O(size × chunks) — the classic LLM-reply freeze. For that, render the changed tail yourself and splice it in:

entry.patch_rows(safe_row, tail_strips)

FlowView keeps rows [0:safe_row] as-is (no re-render) and swaps the tail — O(tail) per chunk. You provide tail_strips (a list of textual.strip.Strip, rendered at the width present was called with) and safe_row, the first row that may still change — the safe watermark you compute for your content.

⚠️ safe_row may only point at a row that will never change again. FlowView will not revisit [0:safe_row] — you've declared it final — so this is a scalpel, not an unconditional incremental engine. It's always correct for append-only output (plain text past the last hard \n doesn't reflow at a fixed width). It is not correct to apply naively to markdown: a line's rendering isn't final until its block closes*bold becomes italic when the * arrives, a ``` fence re-renders the whole block as code when it closes, a delimiter row snaps the paragraphs above into a table, === retroactively makes the line above an <h1>. For markdown, set safe_row to the first row of a closed block and re-render the still-open block (everything below) on every patch — still O(open block), not O(size). Renderables whose layout depends on total content (tables, Columns, right-justify, content-sized panels) can't be patched mid-stream at all; patch at completion or use set_item. Keep the item in sync (a resize re-presents the full body and invalidates the pre-rendered widths).

A presenter can also return pre-rendered rows directly with Presentation(strips=[...]).

Design: interactive widgets live outside the flow

FlowView paints renderables; it does not mount a Textual widget per entry — that's what keeps it O(viewport) instead of O(N) and lets it scroll thousands of variable-height items smoothly. As a deliberate consequence, real interactive widgets (Input, Select, Button) are not embedded in the flow. Instead:

  • Display / light interaction lives in the flow — presenter renderables plus FlowView.Clicked hit-testing (option chips, buttons drawn as text).
  • Real editing widgets live outside the flow — dock a normal Textual widget (a composer, an editor, a modal) and wire it to the flow through the existing messages: Clicked / Selected flow out, and model.append() / entry.set_item() drive updates back in.
def compose(self):
    yield self.view                 # FlowView (history)
    yield Input(id="editor")        # real interactive widget, docked outside

def on_flow_view_clicked(self, ev: FlowView.Clicked):
    self._editing = ev.entry                       # app state: which entry
    editor = self.query_one("#editor", Input)
    editor.value = ev.entry.item.text              # drive the external widget
    editor.focus()

def on_input_submitted(self, ev: Input.Submitted):
    self._editing.set_item(replace(self._editing.item, text=ev.value))  # update the entry

So the library provides the flow↔app plumbing (Clicked/Selected out, append/update/set_item in); interactive widgets are the app's own, external, and updated dynamically through it. The only thing this rules out is a real editing widget rendered inline and scrolling with a specific entry; if a UX needs that, dock or modal it instead (see issue #1). examples/reyn_poc/ follows this pattern (a docked composer over a painted conversation).

Search

Query entries with a predicate (over the item, state, or metadata) and jump to hits. Search covers the whole model — including hidden entries inside collapsed groups — and reveal() un-hides a hit before scrolling to it:

errors = flow.find(lambda e: e.state is EntryState.ERROR)

hit = flow.find_next(lambda e: "TODO" in e.item.text)   # after the selection, wraps
flow.find_previous(predicate)
if hit:
    flow.reveal(hit)        # un-hide if collapsed, then scroll into view

Public API (widget)

Method Effect
scroll_to_top() / scroll_to_bottom() Jump to either edge.
scroll_to_entry(entry) Put entry at the top.
ensure_visible(entry) Scroll the minimum to reveal entry.
reveal(entry) Un-hide if collapsed, then ensure visible.

All four jump methods take animate=True (and an optional duration) for a smooth scroll instead of an instant snap — e.g. flow.scroll_to_entry(hit, animate=True, duration=0.3). Content presents as it scrolls past. The default stays instant. A fresh animated jump supersedes one already in flight (it redirects, even reversing direction); stop_scroll_animation() stops an in-flight animated scroll where it is (a no-op when nothing is animating).

scroll_to_entry also takes align — where the entry lands in the viewport: "start" (top, the default), "center", "end" (bottom), or "nearest" (minimal scroll — the same as ensure_visible). Center a search hit so its context is visible: flow.scroll_to_entry(hit, align="center"). | set_current(entry) / move_current(delta) / current_first() / current_last() / activate() / current | The current entry — one cursor, keyboard + mouse (selectable=True). | | find(pred) / find_next(pred) / find_previous(pred) | Search entries. | | entry_text(entry) / copy_entry(entry) | Get / copy an entry's rendered text. |

Clipboard copy uses Textual's own App.copy_to_clipboard (OSC 52): entry_text(entry) returns the entry's rendered body as plain text, and copy_entry(entry) copies it and returns it. Bind it to a key for a copy action (see examples/showcase.py, y).

Mouse text selection

FlowView plugs into Textual's native text selection: drag to select across entries, and Ctrl+C copies (Textual's built-in binding). It works by stamping each rendered cell with its content offset and implementing get_selection, so selections are stable across scrolling and use the standard screen--selection style. No configuration needed — it's on by default (ALLOW_SELECT).

Selection is virtual, not viewport-bound: a drag (auto-scrolling past the edge) or Ctrl+A select-all spans the whole list, not just the painted rows. Because extraction reads presented content, rows that have never been on screen extract as the loading placeholder until they're presented — scrolling through them (as a drag does) presents them first.

Examples

PYTHONPATH=src python examples/dashboard.py       # 400 live hosts, viewport-scoped animation
PYTHONPATH=src python examples/showcase.py       # live AI-agent activity feed
PYTHONPATH=src python examples/groups.py          # collapsible groups + sticky headers
PYTHONPATH=src python examples/intervention.py    # clickable in-flow selector
PYTHONPATH=src python examples/gutters.py         # two gutters: unread (left) + age (right)
PYTHONPATH=src python examples/scroll_anim.py     # animated jumps, redirect, stop-in-place
PYTHONPATH=src python examples/highlight.py        # opt-in keyboard highlight (↑/↓ + Enter)
PYTHONPATH=src python examples/copy_mode.py        # vim-style text cursor: c, then hjkl/v/y
PYTHONPATH=src python examples/infinite.py        # infinite scroll: lazy-load older history
PYTHONPATH=src python examples/progress.py        # Rich Spinner + ProgressBar in entries
PYTHONPATH=src python examples/minimap.py         # minimap replacing the scrollbar
PYTHONPATH=src python examples/chat.py            # streaming chat
PYTHONPATH=src python examples/image.py           # images + text in the feed (needs: pip install textual-image pillow; real pixels on Kitty)
PYTHONPATH=src python examples/gif.py             # animated GIF in an entry, auto-pauses off-screen (needs: pip install rich-pixels pillow)
PYTHONPATH=src python examples/screensaver.py     # idle viewport overlay (needs: pip install terminaltexteffects)
PYTHONPATH=src python examples/compare.py         # live FPS: VerticalScroll vs FlowView
PYTHONPATH=src python examples/benchmark.py       # prints the benchmark table above

showcase.py demonstrates variable-height panels, a colored per-state gutter, streaming updates, and sticky-bottom auto-follow in one screen.

License

MIT

About

The virtualized feed widget for AI-agent, chat, and log TUIs in Textual — variable-height, O(viewport), streaming, sticky-bottom, infinite scroll.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages