Skip to content

feat(tui): show and load the messages a long session hides - #56280

Open
Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tui-history-crop
Open

Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tui-history-crop

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 17, 2026

Copy link
Copy Markdown

Note: I'm aware v1 is now the maintenance branch and new features go to v2 only. This PR is here for others to use, and I'll submit the v2 version once I've migrated my whole plugin system to it.

Issue for this PR

Closes #56279

The v1 version of #53660 (closed; it targeted dev). Two commits: the first is #53660's change rebased onto v1, the second the three-row bar and the costed load all.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The TUI keeps a session's newest 100 messages loaded and silently drops the oldest one for every new message past that. The limit counts messages only, and nothing else limits what the transcript renders. This PR makes the hidden part visible and loadable, and the limit configurable.

Bar. Wherever messages are hidden, the transcript shows three rows:

                 load 50 above   load 100 above
──── 3,250 messages hidden:  load all (slow / memory heavy) ────
                 load 50 below   load 100 below
  • "Above" fills the gap from its older edge (right after the pinned first prompt, or the session's first messages when none is pinned); "below" from its newer edge, extending the recent messages backwards.
  • A page is offered only while it is smaller than the gap: 51-100 hidden shows only the 50 pages, 50 or fewer shows only the rule.
  • When the count and load all don't fit on the rule, the rule shows N hidden and load all gets its own row under it.
  • While a load runs its label reads loading… and the other actions ignore clicks.
  • Loading grows the window by what it loaded, so new output doesn't drop it again straight away; load all stops dropping messages for that session. A scrolled-up reader keeps their place.
  • Every action is clickable and a palette command ("Load 50/100 hidden messages above/below the divider", "Load all hidden messages", /load-hidden), with keybinds messages_hidden_above, messages_hidden_above_100, messages_hidden_below, messages_hidden_below_100, messages_hidden_all, unbound by default.

load all label. Computed per gap from a measured 10 ms and 0.5 MB per hidden message: load all below 300 hidden, load all (slow) at 300-499 (an estimated freeze of 3 s or more), load all (slow / memory heavy) from 500 (an estimated 250 MB or more, about a third of an idle TUI). The palette title carries the same note.

Settings (tui.json):

  • transcript.max_messages - newest messages kept loaded, default 100 (the old constant).
  • transcript.keep_first_prompt - keep the session's first prompt above the bar when older messages are hidden, default true. This changes what a long session looks like today; happy to flip the default to false.

API. GET /session/:id/message only paged backwards, so it gains after (a cursor or message ID; newer messages, oldest first), order=asc (page from the oldest message), message IDs for before as well as cursors, and X-Total-Count. Conflicting combinations are 400; unpaged requests are unchanged. The TUI anchors on message IDs it already has, because a cursor goes stale once its message is dropped, and asks for the first prompt only when the newest page doesn't cover the session.

Memory and time of load all

Ten real sessions of 2,900-13,300 messages, copied into a scratch database, opened in an isolated TUI (160x50, scratch XDG_*), one run at a time on a busy 18-core Linux desktop. RSS is the TUI process once idle. "Half" is transcript.max_messages: 50 against the default 100. "Transcript shown" runs from pressing load all until the bar leaves the screen; "back to idle" until a 2 s window uses under 15% of a core.

Messages Parts DB bytes RSS half (50) RSS default (100) RSS after load all Load all: transcript shown Load all: back to idle CPU-s to idle
13,261 45,052 48 MB 665 MB 828 MB 5,162 MB 164 s 248 s 120
7,152 33,798 107 MB 711 MB 747 MB 3,902 MB 80 s 255 s 115
4,310 20,262 126 MB 882 MB 867 MB 3,215 MB 61 s 433 s 178
6,417 24,231 67 MB 785 MB 831 MB 3,039 MB 58 s 62 s 28
4,227 22,585 63 MB 774 MB 706 MB 3,404 MB 47 s 136 s 53
3,438 13,396 22 MB 777 MB 856 MB 2,182 MB 29 s 32 s 16
4,777 20,603 73 MB 780 MB 722 MB 3,109 MB 44 s 51 s 27
4,155 20,252 58 MB 782 MB 864 MB 2,977 MB 32 s not idle in 900 s 390
2,912 12,608 47 MB 796 MB 729 MB 2,826 MB 19 s 25 s 19
3,224 16,141 54 MB 778 MB 714 MB 2,395 MB 15 s 712 s 244
  • Half the window against the default is within run-to-run noise (mean 773 vs 786 MB): an open TUI is 700-900 MB whatever the window holds.
  • Load all grows RSS by 1.3-4.3 GB (median 0.52 MB per hidden message) and takes 15-164 s before the transcript shows (median 9.3 ms per message), which is where the label's constants come from.
  • In 6 of 10 sessions the TUI stays busy for minutes after load all with nothing changing on screen. That is outside this PR.

How did you verify your code works?

  • packages/tui: bun typecheck; bun test. test/routes/session/hidden-divider.test.tsx renders the bar at 40, 80 and 160 columns, the 51-100 and single-message gaps, the label thresholds, clicks and the loading state. test/cli/cmd/tui/sync-hidden-messages.test.tsx covers the pinned first prompt, keep_first_prompt: false, an uncropped session making one request, 50- and 100-message pages from both edges, live messages hiding the oldest recent message but never the pinned prompt, and load all.
  • packages/opencode: test/session/messages-pagination.test.ts and test/server/session-messages.test.ts (forward pages, message-ID anchors, X-Total-Count, Link, the 400s).
  • My fork's build in a real TUI in tmux with isolated XDG dirs, on a copy of a 2,912-message session: the three rows above with 2,811 messages hidden: load all (slow / memory heavy); clicking load 100 below -> 2,711 messages hidden.

Screenshots / recordings

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

This PR is AI generated, but under direct supervision and on request of @Nowaker.

The TUI keeps the newest 100 messages of a session loaded and drops the
oldest one for every new message past that, silently. Scrolling to the
top of a long session lands somewhere in the middle of the work, the
first prompt is the first thing to go, and nothing brings older
messages back.

Wherever messages are hidden the transcript now shows a divider: a
horizontal rule with the hidden count centered on it, and below it
"load 50 above", "load 50 below" and "load all" (just "load all N" for
a gap of 50 or fewer). The actions are clickable and are palette
commands ("Load 50 hidden messages above/below the divider", "Load all
hidden messages", /load-hidden) with keybinds messages_hidden_above,
messages_hidden_below and messages_hidden_all, unbound by default.
"Above" fills the gap from its older edge, "below" from its newer
edge. Loading grows the window by what it loaded so new output doesn't
immediately drop it again, and "load all" stops dropping messages for
that session. When the view is scrolled away from the bottom, the block
at the top of the viewport is put back on the same row after a load.

tui.json gains transcript.max_messages (default 100, the old constant)
and transcript.keep_first_prompt (default true), which keeps the
session's first prompt above the divider when older messages are
hidden.

The sync store tracks each session's gap as head (messages before it),
count (hidden) and limit (newest messages kept). A new message past the
limit hides the oldest message after the head, and an update to a
message inside the gap is ignored instead of reinserted.

GET /session/:id/message pages backwards only, so it gains after (a
cursor or message id; newer messages, oldest first), order=asc (page
from the oldest message), message ids for before as well as cursors,
and X-Total-Count on paged responses. The TUI anchors on message ids it
already holds, because a cursor goes stale once the message it points
at has been dropped. The first prompt is requested only when the
newest page doesn't cover the whole session.
…load all

The hidden-messages divider was a rule with the count and one row of
"load 50 above", "load 50 below" and "load all". It is now three rows:
"load 50 above   load 100 above" above the rule, the rule itself
carrying "N messages hidden:  load all", and "load 50 below   load 100
below" under it. A page is offered only while it is smaller than the
gap, so a gap of 51-100 shows only the 50 pages and a gap of 50 or
fewer shows only the rule. When the count and "load all" do not fit on
the rule together, the rule drops to "N hidden" and "load all" moves to
its own centered row under it. While a load is in flight its label
reads "loading…" and every action ignores clicks.

"load all" now says what it costs. Loading every hidden message of ten
real sessions of 2,900 to 13,300 messages took a median 9.3 ms per
message before the transcript showed and grew TUI memory by a median
0.52 MB per message (1.3 to 4.3 GB per session). The label is computed
per gap from 10 ms and 0.5 MB per message: "(slow)" once the estimate
passes 3 s, "(slow / memory heavy)" once it also passes 250 MB, about a
third of an idle TUI. That is 300 and 500 hidden messages. The palette
command title carries the same note.

The divider moves to routes/session/hidden-divider.tsx with a pure
layout and takes its width as a prop, so its frames are tested at 40,
80 and 160 columns. sync.session.load takes a page size; new commands
session.hidden.above.100 and session.hidden.below.100 with keybinds
messages_hidden_above_100 and messages_hidden_below_100, unbound by
default.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Nowaker added a commit to Nowaker/opencode that referenced this pull request Oct 17, 2026
AI-Tool: opencode 1.19.0-vt-322-dfb56f76c7
AI-Model: anthropic/claude-opus-5-5
AI-Variant: high
AI-Platform: linux
AI-Harness: Vibeterm 77eca6db
AI-Session-ID: ses_eec5b71ddffe4A47jCysfkQh6l

This branch has not been deployed

No deployments
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