Skip to content

feat: fullscreen terminal UI for interactive sessions - #54

Merged
webmatze merged 5 commits into
mainfrom
feat/tui-renderer
Aug 8, 2026
Merged

feat: fullscreen terminal UI for interactive sessions#54
webmatze merged 5 commits into
mainfrom
feat/tui-renderer

Conversation

@webmatze

@webmatze webmatze commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Interactive sessions on a real terminal now render through a fullscreen UI instead of plain print lines.

What it does

The transcript stays in the terminal's scrollback — copyable, searchable, no alternate screen — while the bottom of the screen carries a live region: streaming assistant text, running tools with spinner and elapsed time, a status bar (model, mode, tokens, cost) and the input line. Finished blocks are printed into the scrollback once and never redrawn.

  • Enter submits, Esc clears the input, Up/Down walks prompt history, Ctrl+L redraws from scratch — in any state, including mid-turn and under a panel. Resizing the window redraws it too, once the drag has come to rest.
  • While the agent works, Esc asks it to stop; a second press within two seconds exits.
  • Approval, plan review and the hooks trust prompt are panels answered with a single key (y/n/a, Escape = refuse) — nothing reads from plain stdin any more.
  • --no-tui falls back to the line renderer; --tui asks for the fullscreen one explicitly. It cannot conjure a terminal — raw mode needs stdin and stdout to be one — so where there is none it says why it is downgrading instead of doing so silently. Headless smith run stays plain, so its output remains scriptable.

Layout

file role
src/smith/ui/terminal.cr raw mode, size/SIGWINCH, key parsing (incl. bracketed paste)
src/smith/ui/style.cr spans, styles, display width, wrap/truncate, palette
src/smith/ui/markdown.cr markdown → styled lines
src/smith/ui/view_model.cr the block types the UI draws
src/smith/ui/input_editor.cr the prompt editor and its history
src/smith/ui/app.cr the controller: block list, key loop, modals, drawing
src/smith/ui/renderer.cr the third Output::Renderer — events → blocks
src/smith/ui/gates.cr approver, plan gate and trust prompt as panels
src/smith/presentation.cr the seam: renderer, gates and stray-line output, per mode
src/smith/ui/presentation.cr the fullscreen half of it

The live region has to fit

The live region is redrawn in place, and that only works while it fits on the screen: cursor_up stops at the top row, so a region taller than the screen can never be walked back over — every tick pushed another copy of it into the scrollback. An approval modal stacked on top of a still-running tool block was the usual way to get there, which is why the spinner lines appeared to repeat instead of animating in place. Region lines are also truncated to the terminal width, since a wrapped line occupies two rows and puts the redraw out of step with the screen the same way.

So the region is clamped to the terminal height — but what gives way matters. Lines are assembled as pinned and droppable segments: the modal's question, its choices, the status bar and the prompt keep their rows; the blocks in flight and the modal's body give way behind a ⋮ N more lines above marker. Clamping by tail alone evicted the question first, leaving the user asked to approve an unnamed thing. Where even the pinned lines do not fit — a terminal a handful of rows tall — the question still keeps its row ahead of the keys that answer it.

TuiApprover wraps its summary to the real width instead of a hardcoded 200 columns and elides after 8 lines, so an edit/write call with a page-long payload cannot inflate the panel in the first place.

A resize is the one thing an in-place redraw cannot answer at all: the terminal re-wraps the screen underneath the region, so the rows clear_drawn! walks back over are not the rows it drew — it clears the wrong ones and leaves the old frame standing, which is why the window looked frozen until the next keystroke. It redraws from scratch instead, and waits for the drag to finish first: dragging an edge delivers a SIGWINCH per step, and repainting on each is both wasted work and visible flicker. Three quiet poll windows in a row count as the end of it. The flag is cleared the moment it is seen, because read_key reports Resized for as long as it is up — modal_sync, which runs its own key loop, never cleared it and spun at full tilt when a window was resized while the hooks trust prompt was on screen.

One seam instead of nine branches

The CLI asked if @interactive_tui in nine places — for the renderer, the approver, the trust prompt, the plan gate, the diagnostics IO, and three lines of stray text. It is one question about the same pair of answers, so it is asked once: Smith::Presentation holds what differs, PlainPresentation wraps whichever renderer the run asked for, UI::TuiPresentation wraps the app, and the CLI picks one before an interactive session starts.

What stays in the CLI is the part that was never presentation: whether to ask at all. --yes and a missing TTY still settle that first, and in the same order — the presentation only decides how the asking looks. The one remaining branch on the flag is which session loop runs, which is a fork rather than a variant.

Two things fell out of it: incidental text now goes to prompt_io like a question does, so a headless --json run with background jobs to stop no longer drops a line into the middle of the JSONL stream; and in plain mode that line gains the three-space indent every other incidental line has.

Tests

spec/smith/ui/support/screen.cr is a small grid terminal emulator (text, CR/LF, \e[A/B/G/H/J/K) that keeps what scrolled off the top — which is exactly what the in-place redraw must never produce. The regression specs replay a tall approval modal through it and assert the tool line reaches the scrollback exactly once, that no copy of the panel was ever pushed off the top, and that the question, the marker and the tail of the body appear in that order — down to a six-row terminal. spec/smith/presentation_spec.cr pins both halves of the seam: which gates each builds, and that neither stray text nor a question can reach stdout in JSON mode.

781 examples, 0 failures. crystal tool format --check clean.

🤖 Generated with Claude Code

webmatze and others added 5 commits August 8, 2026 23:52
Interactive sessions on a real terminal now render through a fullscreen
UI instead of plain print lines. The transcript stays in the terminal's
scrollback — copyable and searchable, no alternate screen — while the
bottom of the screen carries a live region: streaming assistant text,
running tools with spinner and elapsed time, a status bar and the input
line. Approval, plan review and the hooks trust prompt become panels
answered with a single key, so nothing reads from plain stdin any more.

`--no-tui` falls back to the line renderer, `--tui` demands the
fullscreen one. Headless runs stay plain so their output is scriptable.

The live region is clamped to the terminal height: it is redrawn in
place, and cursor_up stops at the top row, so a region taller than the
screen could never be walked back over — every tick pushed another copy
of it into the scrollback. An approval modal stacked on top of a still
running tool was the usual way to get there, which is why the spinner
lines appeared to repeat instead of animating. Region lines are also
truncated to the terminal width for the same reason: a wrapped line
occupies two rows and puts the redraw out of step with the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the fullscreen UI claimed but did not do.

The live region is clamped to the terminal height by dropping its oldest
lines, and the modal's title sat at the top of that — so a tool call with
a page-long argument evicted the question first and asked the human to
approve an unnamed thing. Region lines are now assembled as pinned and
droppable segments: the question, the choices, the status bar and the
prompt keep their rows, the body above them gives way. Where even the
pinned lines do not fit, the question still keeps its row ahead of the
keys that answer it.

Enter went around the input editor, and submitting is the only thing that
files a prompt into the history — so Up and Down walked a list nothing
ever wrote to. It goes through the editor now.

Ctrl+L was reachable from the idle state alone, and a garbled screen is
likeliest mid-turn or under a panel. It is handled above the state
machine, and modal_sync (which runs its own key loop) handles its own.

Alongside: one shared duration format instead of two that disagreed
about minutes, `Block#finalized?` in place of a type-switch over six
subclasses, the fullscreen banner built from the renderer's own instead
of a second copy, and the dead surface the UI arrived with removed.
`--tui` is documented as what it is — an explicit ask that warns and
falls back where there is no terminal — rather than a demand it cannot
enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI asked `if @interactive_tui` in nine places — for the renderer,
the approver, the trust prompt, the plan gate, the diagnostics IO, and
three lines of stray text — so every time the UI learned to do something
new, one more branch had to learn about it too.

They are all the same question asked of the same pair of answers, so it
is asked once now. `Smith::Presentation` holds what differs between
plain lines and the fullscreen UI; `PlainPresentation` wraps whichever
renderer the run asked for, `UI::TuiPresentation` wraps the app. The CLI
picks one in choose_presentation! before an interactive session starts
and asks it from then on.

What stays in the CLI is the part that was never presentation: whether
to ask at all. `--yes` and a missing TTY still settle that first, and
still in that order — the presentation only decides how the asking
looks. The one remaining branch on the flag is which session loop runs,
which is a fork, not a variant.

Plain mode's job-shutdown line loses its leading blank and gains the
three-space indent every other incidental line has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A headless --json run that had background jobs to stop printed the stop
line with a bare puts, so it arrived on stdout between two records —
inside whichever one it interrupted, as far as a reader was concerned.

The plain presentation writes both its stray-line forms to the
renderer's prompt_io now, for the reason a question already goes there:
stdout belongs to the stream. In human mode prompt_io is stdout, so
nothing about an ordinary run changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SIGWINCH was trapped and the flag was read, but the answer to it was an
in-place redraw of the live region — which is exactly what a resize makes
impossible. The terminal re-wraps the screen underneath the region, so
the rows clear_drawn! walks back over are no longer the rows it drew: it
clears the wrong ones and leaves the old frame standing. The window ended
up looking frozen until the next keystroke pushed enough through to cover
the debris.

A resize now redraws from scratch, the same path Ctrl+L takes, and waits
for the drag to finish first: dragging an edge delivers a SIGWINCH per
step, and repainting the transcript on each of them is both wasted work
and visible flicker. Three quiet poll windows in a row — roughly 150ms
with nothing else arriving — count as the end of it.

The flag is also cleared the moment it is seen, because read_key reports
Resized for as long as it is up. modal_sync, which runs its own key loop,
never cleared it at all: a window resized while the hooks trust prompt
was on screen spun that loop at full tilt until the question was
answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webmatze
webmatze merged commit 73b5085 into main Aug 8, 2026
2 checks passed
@webmatze
webmatze deleted the feat/tui-renderer branch August 8, 2026 23:08
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