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 message —
Panel,Table,Syntax,Markdown,Spinner,ProgressBardrop 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.
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:
- 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
ProgressBarre-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 dynamicFlowModel[T]owns an ordered collection of items and knows nothing about the UI.Entryis the single stable handle to a displayed item — the only way to update or remove it.FlowPresenteris the only component that knows the concrete item type; it turns an item into aPresentation(height + renderable).FlowViewdrawsPresentations and manages the viewport. It never sees your data type.
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 removedEach row is split into a gutter and a body:
┌────────┬──────────────────────┬────────┐
│ Gutter │ Body │ Gutter │ ← the right gutter is optional
└────────┴──────────────────────┴────────┘
- The body is what
FlowPresenterproduces. - A gutter is what a
FlowDecoratorproduces — status markers, icons, badges, timestamps. There's one on the left by default; add a right one withright_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 dataEntryState 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.
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(...)) # ▾ expandedGroup 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 primitiveWhich 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.
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.
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/Inputabove or below it incompose(see the input bar inexamples/chat.py). - A band that scrolls with the content (an intro banner, a "load more"
footer, a "— start of history —" marker) is a regular entry —
insert(0, …)for a header,append(…)for a footer, presented however you like. It scrolls with the list for free, andsticky_headercan even pin it. A "load more" footer entry reports clicks throughFlowView.Clicked.
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).
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/ aFlowViewsubclass viaBINDINGS; - set
flow.can_focus = Falseso 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.
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,
)from textual_flowview import Anchor
FlowView(model=..., presenter=..., anchor=Anchor.CURRENT) # default
FlowView(model=..., presenter=..., anchor=Anchor.STICKY_BOTTOM) # chat / logSTICKY_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.followingFor 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.
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 keptThis works in both directions: newest-at-bottom (STICKY_BOTTOM, scroll up →
ReachedTop → insert_many(0, ...) for older) and newest-at-top (STICKY_TOP,
scroll down → ReachedBottom → extend(...) for older; appending below never
shifts the view).
See examples/infinite.py (a log that pages in older lines as you scroll up).
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;0disables it. Larger = smoother fast scrolling at the cost of presenting more up front.
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).
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 thecurrentproperty. - 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--highlightcomponent class, unstyled by default — give it a colour:
FlowView > .flowview--highlight { background: $accent 30%; }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 mountTwo 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 viaclipboard=, e.g.clipboard=lambda t: (pyperclip.copy(t), True)[1](pyperclip writesCF_UNICODETEXTdirectly, 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.
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 pixels — textual-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 primitive — animate_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 screenanimate_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 gutterrefresh_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).
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 # -> boolframes(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).
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-testFlowView.Clicked(below).
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.
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_rowmay 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\ndoesn'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 —*boldbecomes 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, setsafe_rowto 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 useset_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=[...]).
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.Clickedhit-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/Selectedflow out, andmodel.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 entrySo 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).
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| 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).
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.
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 aboveshowcase.py demonstrates variable-height panels, a colored per-state gutter,
streaming updates, and sticky-bottom auto-follow in one screen.
MIT
