Skip to content

simulate: print the job view into the terminal instead of windowing it in the alt screen - #928

Merged
u9g merged 7 commits into
mainfrom
jason/simulate-detail-scrollback
Jul 31, 2026
Merged

simulate: print the job view into the terminal instead of windowing it in the alt screen#928
u9g merged 7 commits into
mainfrom
jason/simulate-detail-scrollback

Conversation

@u9g

@u9g u9g commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Opening a job in lk agent simulate windowed renderDetail inside the alt screen: instructions, transcript, and logs were paged through a viewport ~12 rows shorter than the terminal, with ↑ N more lines markers, and the terminal's own scrolling, selection, and search were unavailable for the one view that is mostly text worth reading.

The job view now leaves the alt screen and is printed with tea.Println, so the terminal owns it. The list view is untouched — it keeps the alt screen, its page windowing, and its cursor. Going back re-enters the alt screen.

How it behaves

  • flushDetail runs after every message and emits only what the view gained since the last call, so growth while a job is still running (a log line, the transcript arriving with the summary) appends instead of redrawing. A render that rewrites what came before is reprinted whole, since scrollback cannot be edited in place; a resize rebaselines without reprinting, because printed text cannot re-wrap.
  • Opening a job clears the screen and scrollback first, so the terminal holds one job at a time rather than every job visited this run. Erasing only the previous job is not possible: once its rows scroll off, ESC[3J is the only sequence that reaches them, and it takes the whole scrollback with it — including whatever preceded the run. That trade-off is deliberate.
  • While a job is open the live region is just its status line and the hint bar, and up/down/pgup/pgdown are swallowed so they cannot move the list cursor hidden underneath.
  • j and l sit either side of k on the home row and now stand in for the left/right arrows: l opens, j backs out.

Removes detailScrollOff, scrolledDetail, and scrollActive's detail case.

Two wrapping bugs the change exposed

Both were invisible in the alt screen and became real once rows were printed to the terminal:

  1. lipgloss padding reached the scrollback. Style.Render pads a Width(n) block — after wrapping it runs alignTextHorizontal, which pads every row out to the widest line or to n. Printed rows kept that padding, and it came along when the text was selected and copied. wrapLines now calls ansi.Wrap, which is the wrap lipgloss itself runs (via x/cellbuf) before the align pass, so the padding is never added rather than added and trimmed back off. Measured on a representative result sentence at width 40: lipgloss padded 5/5 rows; ansi.Wrap, ansi.Wordwrap and cellbuf.Wrap padded 0/5 with identical line breaks. Boxes and modals keep Style.Render — their padding is structural.
  2. Body text wrapped to the full terminal width. On a 200-column terminal a paragraph was set to 194 columns, which is hard to read back across and orphans the last words on their own line (193 + 7 for a typical result sentence). proseWidth caps the measure at 100 columns and keeps the existing floor; narrow terminals are unchanged.

x/ansi was already in the module graph as a lipgloss dependency; go.mod only promotes it to a direct require.

Verification

  • go build ./..., go vet ./cmd/lk, go test ./cmd/lk pass.
  • The clear must land after the alt screen is left — tea.Println is silently dropped while the alt screen is active, and a raw write inside a Cmd is not ordered against the event loop (Program.Send only queues the message), so the sequences ride along inside the print. Verified by driving a bubbletea program of the same shape through a pty and ordering the captured bytes: enterAlt @18 → exitAlt @68 → ESC[3J @89 → job text @93, and again on the second open/close cycle.
  • Tests added cover logic only, not rendered output: the append/reprint decision (detailTail), the wrap measure arithmetic (proseWidth), that wrapLines rows carry no right padding, and the key handling (asserting on model fields).

u9g added 7 commits July 31, 2026 11:31
… the alt screen

Opening a job used to window renderDetail inside the alt screen: a job's
instructions, transcript, and logs were paged through a ~12-row-shorter
viewport with "N more lines" markers, and the terminal's own scrollback,
selection, and search were unavailable for the one view that is mostly
text worth reading.

The job view now leaves the alt screen and is printed with tea.Println,
so the terminal owns it. flushDetail runs after every message and emits
only what the view gained since the last call, so growth while a job is
still running (a log line, the transcript arriving with the summary)
appends instead of redrawing. A render that rewrites what came before is
reprinted whole, since scrollback cannot be edited in place; a resize
rebaselines without reprinting, because printed text cannot re-wrap.

The list view is untouched: it keeps the alt screen, its page windowing,
and its cursor. Going back re-enters the alt screen and leaves the job in
the scrollback. While a job is open the live region is just its status
line and the hint bar, and up/down/pgup/pgdown are swallowed so they
cannot move the list cursor hidden underneath.

Removes detailScrollOff, scrolledDetail, and scrollActive's detail case.
lipgloss's Style.Render pads a Width(n) block: after wrapping it runs
alignTextHorizontal, which pads every row out to the widest line or to n,
whichever is larger. That is invisible in the alt screen but is real
whitespace once a row is printed to the terminal — a job view arrived with
a ragged column of trailing spaces beside the instructions, expectations,
result, transcript, and logs, and the padding came along when the text was
selected and copied.

wrapLines now calls ansi.Wrap, which is the wrap lipgloss itself runs (via
x/cellbuf) before the align pass, so the padding is never added rather than
added and trimmed back off. The detail and transcript renderers go through
wrapLines instead of each holding a fixed-width style of their own. Boxes
and modals keep Style.Render — their padding is structural.

x/ansi was already in the module graph as a lipgloss dependency; this only
promotes it to a direct require.
Instructions, expectations, the result, the transcript, and the summary all
wrapped to the terminal width less their indent. On a 200-column terminal
that sets a paragraph to 194 columns, which is hard to read back across and
leaves the last few words orphaned on a line of their own (193 + 7 for a
typical result sentence).

proseWidth caps the measure at 100 columns and keeps the existing floor, so
wide terminals get a readable column instead of a full-width one; narrow
terminals are unchanged.
Visiting several jobs left every one of them stacked in the terminal, so
scrolling up from the current job ran into the previous one. Opening a job
now erases the screen and the scrollback ahead of the print, and the
terminal holds one job at a time.

Erasing only the previous job is not possible: once its rows scroll off,
ESC[3J is the only sequence that reaches them, and it takes the whole
scrollback with it — including whatever preceded the run.

The sequences ride along with the print rather than being written to stdout
from inside a Cmd. A Cmd's side effects are not ordered against the event
loop (Program.Send only queues the message), so a direct write could land
while the alt screen was still active and clear that buffer instead; going
through tea.Println puts the clear in the same message as the text, after
the alt screen is left, written by the renderer that owns stdout.
j and l sit either side of k on the home row, so they stand in for the left
and right arrows: l opens the job under the cursor, j backs out of a job or
an expanded description. The hint bar keeps naming the arrows, which are what
a first-time reader will reach for.
The cap was justified partly as fixing an orphaned last line, which it does
not: the sentence that prompted it still ends in a 7-column "failed." at 100
columns, the same tail it had at 194. Greedy first-fit puts whatever is left
on the final line at any measure; avoiding that needs optimal-fit line
breaking with a short-last-line penalty (Knuth-Plass), which nothing in the
Go terminal stack offers.

What the cap does buy is the line length itself, so the comment now says
that and records where the number comes from.
@u9g
u9g merged commit 6554595 into main Jul 31, 2026
22 checks passed
@u9g
u9g deleted the jason/simulate-detail-scrollback branch July 31, 2026 19:32
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.

2 participants