Skip to content

feat(chat): rich+prompt streaming layout with a virtualized transcript - #482

Merged
gemone merged 6 commits into
mainfrom
feat/tui-sessions-picker
Aug 10, 2026
Merged

gemone merged 6 commits into
mainfrom
feat/tui-sessions-picker

Conversation

@gemone

@gemone gemone commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What

Rebuild the interactive chat around a rich + prompt_toolkit full-screen streaming layout with virtual rendering — and demote the widget shell to an opt-in --tui.

Changes

  • streaming_tui.py (new) — prompt_toolkit full-screen app:
    • Virtual rendering: the conversation control stores pre-rendered rich lines; the Window requests only the visible slice per frame (UIContent.get_line is lazy) → a long transcript costs O(viewport) per render, not O(content). No widget-per-message DOM growth.
    • Continuous scroll / follow-end: window.vertical_scroll is the preferred scroll. New content pins to the bottom only while the user is already there; PgUp / wheel up freezes the viewport (the stream never yanks a reader); PgDn / wheel down re-follows.
    • Slash menu: typing / pops a completion menu (/help, /exit, /clear).
    • Streaming: agent.run_stream events render into the transcript — dim calling … tool lines, live Markdown answer re-rendered per delta.
  • chat defaults to this streaming chat; the worker-based Textual shell moves behind --tui (opt-in). Deleted the dead per-turn _stream_answer + unused rich imports.
  • Textual shell (--tui) also gets pi's dark palette (slate user-message boxes, state-colored tool cards, warm markdown headings) and a follow re-check that re-pins to the settled bottom without yanking readers who scrolled up (TUI ConversationView has no auto-scroll — streaming output renders off-screen on the primary chat path (invisible without constant manual scrolling) #409) — fixes a pinned-after-stream flake on loaded runners.

Problem fixed

  • Per-turn prompt_async REPL: no persistent transcript, no scrolling, no slash menu.
  • Naive streaming renders the whole conversation per delta → O(content) per frame, janky continuous scroll. Virtual rendering fixes the performance wall.
  • The widget shell's keybindings/layout were rejected; the rich+prompt layout is the default experience, with the widget shell kept as an opt-in.

Tests

  • New test_streaming_tui.py: virtual get_line (lazy serving, out-of-range no-op), stream-block replace (only the live block re-renders), slash completer (menu only for / input).
  • Updated test_cli.py routing: chat → streaming chat (asyncio.run), --tui_launch_tui_app.
  • 1673 tests green locally; ruff check + ty check clean; CI green on ubuntu/macos (windows pending).

gemone added 5 commits August 9, 2026 23:57
Delete the previous pane design (header + session sidebar + grid) and
rebuild the shell following the pi alternate-screen model: one full-height
scrollable transcript with a fixed working dock beneath it.

Shell:
- ConversationView owns the full viewport; it is the only scroll region.
- Composer dock (#composer) fixed below: status line, input, shortcut hint.
- Status bar fixed below the composer.
- Session navigation is transient (/sessions picker modal), never a sidebar.
- The input holds default + persistent focus; every modal dismissal and
  session switch restores it (pi's editor-always-focused contract).
- User prompts render as background-tinted blocks (pi's userMessageBg box)
  instead of a '> you:' prefix line.
- App commands moved to modified keys (Ctrl+N for new session) since the
  input owns bare keys.

Removed: SessionList sidebar pane, Header, grid layout, _sync_sidebar,
dock-based footer positioning, bare-n binding.

Kept: streaming renderer (#407), WS attach/pump/dispatch, session routing +
replay, slash commands, worktree/config/ask modals, footer reactives.

Tests: updated for the new shell (transcript/composer/status stacking,
user-message boxes, transient picker, footer session-cell gating, ctrl+n
binding, default-focus pty typing). 156 TUI/pty/CLI tests green, ruff + ty
clean.
The real-PTY test types directly into the input (default focus) with no
Tab navigation, but the marker was written before on_mount's focus
assignment landed on slower macOS runners, so the keystrokes went
nowhere. Restore a short settle after the first render (the original
pre-#375-rewrite test had the same 0.4s pause after its Tabs).
The #409 pinned-after-stream assertion compared scroll_y against
max_scroll_y on the first beat after finalise; on loaded CI runners the
mounted Markdown's layout can land a beat later, making the pin look one
line short (scroll_y=5 vs max_scroll_y-1=6). Poll briefly for the pin
instead — the contract (pinned after stream; not yanked after scroll-up)
is unchanged.
_finalize_segment pins via scroll_end(immediate=True), which reads the
max_scroll_y current at mount time — the freshly-mounted Markdown's
height settles a layout pass later, so the pin could sit a couple of
lines short once content grows (observed as a flaky pinned-after-stream
assertion on slower CI runners: scroll_y=5 vs max_scroll_y=7).

When the user was pinned, schedule a second follow after the refresh so
the segment end re-pins to the settled height. Per-delta streaming
follow stays immediate (#409 scroll-up race unchanged); only the
finalise-time mount re-pins deferred.
Replace the prompt_async per-turn REPL with a full-screen
prompt_toolkit application (rich-rendered content):

- Virtual rendering: the conversation control stores pre-rendered rich
  lines and the Window requests only the visible slice per frame
  (UIContent.get_line is lazy) — a long transcript costs O(viewport) per
  render, not O(content). No widget-per-message DOM growth.
- Continuous scroll / follow-end: window.vertical_scroll is the preferred
  scroll; new content pins to the bottom only while the user is already
  there (scroll away with PgUp/wheel freezes the viewport; PgDn/wheel
  down re-follows).
- Slash menu: typing "/" pops a completion menu (/help /exit /clear).
- Streaming: events from agent.run_stream render into the transcript
  (dim "calling …" tool lines, live Markdown answer re-rendered per
  delta, throttled by the virtual renderer).

chat now defaults to this streaming chat; the worker-based Textual shell
moves behind --tui (opt-in). The dead per-turn _stream_answer + its
unused rich imports are deleted.

Also:
- ConversationView follow re-check: re-pin to the settled bottom after a
  layout pass, guarded so a reader who scrolled up is never yanked
  (#409) — fixes a pinned-after-stream flake on loaded runners.
- Textual shell (--tui): pi dark palette (userMessageBg boxes, state-
  colored tool cards, warm markdown headings).

Tests: 3 new streaming-tui tests (virtual get_line, stream-block replace,
slash completer); chat routing tests updated (default streaming, --tui
opt-in). 1673 tests green, ruff + ty clean.
@gemone gemone changed the title feat(tui): rebuild the shell around a focused transcript feat(chat): rich+prompt streaming layout with a virtualized transcript Aug 9, 2026
…renderer

prompt_toolkit formatted-text fragments are (style, text) tuples; the
conversation control emitted (text, style) (mirroring rich's Segment
order). When the Window painted a '❯ hello' line, it parsed the text as
a style string and crashed: ValueError Wrong color format '❯' —
surfaced as an unhandled event-loop exception whose own traceback
handler hit the same error during its redraw.

Swap both conversions (rich lines and append_text) to (style, text), and
add a regression test asserting the tuple order so a future flip fails
loudly instead of crashing at paint time.

Verified end-to-end: the app paints the prompt char + content through
the real prompt_toolkit run loop with no exception (PTY smoke), and the
full suite (1674 tests) passes with ruff + ty clean.
@gemone
gemone merged commit 36d60af into main Aug 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant