Repository navigation
fix(tui): collapse encrypted reasoning runs into one updating line - #161
Merged
Merged
Conversation
Refs #53. Plan only; this commit changes no code. Problem Each encrypted reasoning part (empty text, provider metadata set) renders its own "Thought · <duration>" line in ReasoningPartView. Approach 1. New pure module src/routes/session/opaque-reasoning.ts. - isOpaqueReasoning and reasoningText: the predicate ReasoningPartView already uses, moved so the grouping and the view share it. - groupOpaqueReasoning(parts): a run of 2+ opaque parts becomes one { type: "opaque-reasoning", parts } row at the first member's position. A lone opaque part passes through as the same object. Breakers are tool parts, visible text, and visible (non-opaque) reasoning. Parts that render no line (step markers, blank text, empty reasoning) are skipped and do not end a run. 2. AssistantMessage (index.tsx) renders groupOpaqueReasoning(props.parts). Its parts come from the sync store, so the live stream and the rendered history share one path. 3. OpaqueReasoningRunView renders one ReasoningHeader. Count is the number of parts in the run, including one still in flight. Duration is the sum of finished parts. Done when every part is done. While one is in flight the spinner keeps the same count and duration label. 4. The per-part done, unresolved, and duration logic moves into one helper used by ReasoningPartView and the run view, so the two cannot diverge. Tests (red before green) - test/routes/session/opaque-reasoning.test.ts: a run, a lone part, tool and text breaks, invisible parts, a tool error, mixed encrypted and plain reasoning, and a [REDACTED]-only placeholder. - test/routes/session/opaque-reasoning-render.test.tsx: AssistantMessage fed through the sync store. Covers a loaded run, parts arriving live, a tool break, and a message error ending the run. Decisions and non-goals - A run stays within one assistant message. A message's error renders after its parts, so it ends a run by position. - The summed duration is the sum of per-part durations, which is what the old separate lines showed. - Transcript export (util/transcript.ts) and the Luna session review (rest of #53) are out of scope. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 groupOpaqueReasoning collapses each run of consecutive encrypted reasoning parts into one row. A tool part or visible text ends a run. Parts that render no line, such as step markers and blank text, are skipped without ending one. A lone encrypted part passes through as the same object, so it renders exactly as it does today. isOpaqueReasoning and reasoningText move here from ReasoningPartView, so the view and the grouping apply one rule. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 AssistantMessageView renders groupOpaqueReasoning over the message parts. The sync store feeds those parts for both the live stream and loaded history, so both get the same line. A run shows "Thought <count> · <duration>". The count includes a part still in flight, and the duration sums finished parts. While any part is open the line keeps the spinner. ReasoningPartView and the run view share reasoningState, so the done and unresolved rules cannot drift between a single part and a run. AssistantMessage is renamed to AssistantMessageView and exported for the render test. The old name clashed with the type-only AssistantMessage import once exported. It is an internal component with no other readers. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 A plain reasoning part between two encrypted runs keeps its own "Thought: <duration>" header and its body. The runs on either side stay separate lines. The test fails when the renderer ignores the grouping. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 The fixture comments said provider metadata is written on reasoning-end. processor.ts sets it on reasoning-start, and finishReasoning adds time.end. Also drop two non-null assertions that oxlint reports as unnecessary. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Author
|
Session stamp for this PR:
|
Refs #53 Records the TUI reasoning-run change in the fork changeset under Features. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 The run row was rebuilt on every recompute, so the keyed For remounted the run view and dropped any selection on it. A run's row is now its first part, the same object on every recompute. Its members, count, and duration update reactively through `runs`. A lone encrypted part renders through the same run view, so its row keeps its identity when a second part arrives. Parts that are not folded into a run stay in order instead of being dropped by the grouping, so a part type the view does not render yet still reaches the view. Tests: a live regression checks that the run row and its selection survive a third appended part, and it fails on the previous code. Pure tests check that a run keeps its first part's object and that every other part keeps its place. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 The run row rendered its label through ReasoningHeader's Switch, which swaps the text node whenever the state changes. A selection on the "Thought N" label was lost when a part went in flight and then completed. The run row now renders its own header: one text node holds the label for the whole lifecycle, and the spinner is a sibling that appears only while a part is in flight. ReasoningHeader is back to its base form, so its Switch and the other spinners are unchanged. The live identity regression now appends a third part in flight and then completes it. It checks the label node and the selection at both steps. On the previous code it fails at the in-flight label check. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 Keeps the branch current with swxtch (60f779b) before review. The incoming change is the SQLite lock revert in packages/core, which this branch does not touch. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
…Header Refs #53 Encrypted reasoning renders through OpaqueReasoningRunView, and nothing passes `encrypted` to ReasoningHeader any more. The branch was a second path to the same label, so it goes. The header's Switch is unchanged. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
Refs #53 Keeps the branch current with swxtch (8d536e3) before review. The incoming change adds skills.exclude in packages/opencode. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA # Conflicts: # CHANGESET.md
This was referenced Oct 10, 2026
Open
ReasoningPartView sent encrypted parts to the run view, which gave the run view a second caller. Production never reached it: AssistantMessageView renders every encrypted part through groupOpaqueReasoning, and the parts after a run's leader are not rendered at all. The only caller was the History and PreAssistantHistory harnesses in assistant-activity.test.tsx. They now render their reasoning parts through AssistantMessageView, inside the providers that route needs. Refs #173 Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA
brentyates-swx
added a commit
that referenced
this pull request
Oct 10, 2026
Brings in b18e7ff (PR #161) so CI runs on the current base before the merge. No other changes. Claude-Session: https://claude.ai/code/session_01LrS7BTRTnc6eYrSxBFoWAo
brentyates-swx
added a commit
that referenced
this pull request
Oct 11, 2026
Brings in #161, #194 and #201 on top of the reviewed commit 95672df. Conflicts, all resolved with the swxtch side: - packages/core/src/database/sqlite.bun.ts - packages/core/src/database/sqlite.node.ts - packages/effect-sqlite-node/src/index.ts - packages/effect-sqlite-node/src/retry.ts (add/add) - CHANGESET.md This branch carries the unsquashed #162 commits; swxtch carries the same change squashed as #194. For each conflicted code file the reviewed side is byte-identical to #194's version, so the conflict is only #201's edits on top of #194, and the swxtch side keeps both. CHANGESET.md's only conflict hunk is the #201 entry, which this branch never touched. The branch's own changes (session/queue.ts, test/session/prompt.test.ts) merged cleanly and are byte-identical to 95672df. Claude-Session: https://claude.ai/code/session_019DxEchLHHdPjd4GUE1EJAj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Refs #53. The Luna session review in that issue stays open; this PR covers only the TUI rendering half.
Type of change
What does this PR do?
OpenAI Responses models (GPT-6 Luna and Sol through
swx-azure) return encrypted reasoning. Each item arrives as a reasoning part with no title or body, and the TUI printed oneThought · <duration>line per part. A single turn could print dozens of these lines and push the useful output off screen.Each run of consecutive encrypted parts now renders as one line with the count and the total duration, and the line updates as each part arrives:
groupOpaqueReasoning(newpackages/tui/src/routes/session/opaque-reasoning.ts) is a pure function over one assistant message's parts. It returns the rows to render and the members of each run. A run's row is its first part, the same object on every update, so the run's view persists while the run grows.OpaqueReasoningRunView. It keeps one label node for the whole lifecycle, so a selection on the label survives the spinner appearing and going away. The spinner is a sibling that shows only while a part is in flight.reasoningState, that the run row and single parts share.ReasoningHeaderno longer has an encrypted branch, because nothing reaches it.AssistantMessageis renamedAssistantMessageViewand exported for the render and activity tests. The old name clashed with the type-only SDK import once it was exported. The render test and the assistant activity test import it.Not in this PR. Each item is tracked:
_Thinking:_heading per encrypted part: tui: transcript export writes an empty _Thinking:_ heading for each encrypted reasoning part #160.AGENTS.mdbans slashes, but firstmate'sfm/prefix breaks that rule: AGENTS.md bans slashes in branch names, but firstmate branches are fm/<task> #174.index.tsxalready has prettier drift and 16 oxlint warnings on swxtch, which this PR leaves alone: tui: session route index.tsx has pre-existing prettier drift and 16 oxlint warnings #177.How did you verify your code works?
test/routes/session/opaque-reasoning.test.ts: 12 pure tests. They cover a run, a lone part and its identity, tool and text breaks, a failed tool call, parts that render no line, a part type the view does not render, mixed encrypted and plain reasoning, the[REDACTED]placeholder, and the predicate.test/routes/session/opaque-reasoning-render.test.tsx: 6 tests that renderAssistantMessageViewthrough the sync store. They cover a loaded run, parts arriving live, tool and text breaks, plain reasoning between runs, a live run whose label and selection survive a third part going in flight and then completing, and a message error.test/routes/session/passes: 22 tests, including the 4 tests that were already there. The assistant activity test now renders throughAssistantMessageView.bun typecheckpasses inpackages/tui. oxlint reports no warnings on the new files, and prettier is clean on them.Screenshots / recordings
Terminal frames from the render harness, 80 columns, for one transcript: a three-part run, a tool call, one more encrypted part, then text. The "before" frame renders the same parts with grouping disabled, which is how the base renders single parts.
Before:
After:
Checklist
🤖 Generated with Claude Code