Skip to content

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

Open
Nowaker wants to merge 1 commit into
anomalyco:devfrom
Nowaker:tui-history-crop
Open

Nowaker wants to merge 1 commit into
anomalyco:devfrom
Nowaker:tui-history-crop

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 7, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #53642

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 (all parts of a kept message stay loaded), and nothing else limits what the transcript renders. This PR makes the hidden part visible and loadable, and the limit configurable.

Divider. Wherever messages are hidden, the transcript shows a horizontal rule with the count centered on it and the actions below:

  ───────────────────────────── 7,051 messages hidden ─────────────────────────────
                        load 50 above   load 50 below   load all

A gap of 50 or fewer shows only load all 23. "Above" fills the gap from its older edge (right after the first prompt, or the session's first messages when no prompt is pinned); "below" fills it from its newer edge, extending the recent messages backwards. The actions are clickable and are palette commands: "Load 50 hidden messages above the divider", "... below the divider", "Load all hidden messages" (/load-hidden), with keybinds messages_hidden_above, messages_hidden_below, messages_hidden_all, unbound by default. Loading grows the window by what it loaded, so new output doesn't drop it again straight away, 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, so loading into an off-screen gap doesn't slide the transcript.

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 divider when older messages are hidden, default true. This changes what a long session looks like today (the first prompt appears at the top instead of being dropped). Happy to flip the default to false if you'd rather keep the current look by default.

Store. sync.tsx 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 instead of shift()ing index 0; an update to a message inside the gap is ignored rather than reinserted (today such an update is inserted and immediately shifted off again).

API. GET /session/:id/message only pages backwards, so the same route gains:

  • after - messages strictly newer than a cursor or message ID, oldest first; X-Next-Cursor/Link continue forward;
  • order=asc - without an anchor, page from the oldest message;
  • before also accepts a message ID, not just a cursor;
  • X-Total-Count on paged responses.

Conflicting combinations (before+after, after+order=desc, before+order=asc) are 400. Unpaged requests are unchanged. The TUI anchors on message IDs it already has, because a cursor goes stale once the message it points at has been dropped. It asks for the first prompt (limit=1&order=asc) only when the newest page doesn't cover the session, so short sessions make exactly one request as today. The experimental /api/session/:id/message route already pages both ways but reads the session_message projection, which sessions predating the event tables don't have.

Memory and CPU by limit

One real session of 7,152 messages (part JSON for the newest 50 / 100 / 200 messages: 336 KB / 959 KB / 2.47 MB), opened with -s in an isolated TUI (scratch XDG_*, throwaway tmux socket, 160x50) on Linux, then left idle on the session. RSS is the TUI process (VmRSS) 90 s after start and after 10 idle minutes; idle CPU is utime+stime per minute averaged over idle minutes 4-10 (minutes 1-3 still settle). One run per row, strictly one at a time, on a busy desktop (load average around 50).

Build transcript.max_messages RSS after load RSS after 10 min idle Idle CPU
dev 100 (fixed) 648 MB 614 MB 2.05%
this PR 50 658 MB 572 MB 1.99%
this PR 100 (default) 686 MB 610 MB 1.95%
this PR 200 737 MB 658 MB 1.95%

Halving the limit saves about 40 MB of settled RSS on this session; doubling it costs about 50 MB. Idle CPU does not depend on the limit: an idle TUI showing the session spends about 2% of a core whatever the limit. At the default the PR settles at the same RSS as dev (610 vs 614 MB). Its after-load figure is higher, by the pinned first prompt (here a 300+ line prompt) and run-to-run noise.

How did you verify your code works?

  • packages/opencode: test/session/messages-pagination.test.ts (forward pages, message-ID anchors, missing anchor) and test/server/session-messages.test.ts (asc + after, X-Total-Count, Link, 400s) - 54 + 6 pass.
  • packages/tui: test/cli/cmd/tui/sync-hidden-messages.test.tsx covers the first prompt pinned above the gap, keep_first_prompt: false, an uncropped session making one request, loading below then above until the gap closes, "above" without a pinned prompt starting from the oldest message, live messages hiding the oldest recent message but never the pinned prompt, a hidden message's update staying hidden, an uncropped session starting to pin once it outgrows the limit, and "load all" keeping later messages. bun test and bun typecheck pass.
  • Real TUI, isolated XDG_* dirs, throwaway tmux socket, a copy of a real 7,152-message session:
# dev: ctrl+g lands mid-session, nothing says anything is missing
     + Thought: 4.5s
  ┃  $ export CI=true DEBIAN_FRONTEND=noninteractive ...

# this PR: first prompt on top, then
  ───────────────────────── 7,051 messages hidden ─────────────────────────
                    load 50 above   load 50 below   load all
# click "load 50 below" -> 7,001 hidden, the 50 appear under the divider
# click "load 50 above" -> the session's next messages appear under the first prompt
# palette "Load all hidden messages" -> all 7,152 loaded in about a second

Checklist

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

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.

AI-Tool: opencode 1.18.34-vt-126-907b3bc518
AI-Model: anthropic/claude-opus-5-5
AI-Variant: high
AI-Platform: linux
AI-Harness: Vibeterm 52e9db3
AI-Session-ID: ses_eec5b71ddffe4A47jCysfkQh6l
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

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

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.

[FEATURE]: show and load the messages the TUI hides in long sessions

1 participant