Skip to content

fix(tui): collapse encrypted reasoning runs into one updating line - #161

Merged
brentyates-swx merged 12 commits into
swxtchfrom
fm/opencode-thought-collapse-53
Oct 10, 2026
Merged

brentyates-swx merged 12 commits into
swxtchfrom
fm/opencode-thought-collapse-53

Conversation

@brentyates-swx

@brentyates-swx brentyates-swx commented Oct 10, 2026 •

Copy link
Copy Markdown

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

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

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 one Thought · <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:

Thought 3 · 6.0s
  • groupOpaqueReasoning (new packages/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.
  • The run row is 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.
  • A tool call, visible text, or visible non-encrypted reasoning ends a run. A message error ends a run too, because it renders after the message's parts.
  • Parts that render no line (step markers, blank text, empty reasoning) stay in order and render nothing. They do not end a run.
  • While a part is in flight, the line keeps the spinner and its count includes that part. The duration sums the finished parts.
  • Non-encrypted reasoning renders as before. The done, unresolved, and duration rules sit in one helper, reasoningState, that the run row and single parts share. ReasoningHeader no longer has an encrypted branch, because nothing reaches it.
  • AssistantMessage is renamed AssistantMessageView and 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:

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 render AssistantMessageView through 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.
  • The lifecycle regression fails before the label fix. At the in-flight check, the label is a new node. It passes with the fix.
  • Reverting each change turns the matching tests red: dropping transparent parts from the rows, copying a run's first part on every update, and reading run members once instead of reactively.
  • test/routes/session/ passes: 22 tests, including the 4 tests that were already there. The assistant activity test now renders through AssistantMessageView.
  • bun typecheck passes in packages/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:

   Thought · 1.0s

   Thought · 2.0s

   Thought · 3.0s

   $ ls

   Thought · 1.0s

   Here is the answer.

After:

   Thought 3 · 6.0s

   $ ls

   Thought · 1.0s

   Here is the answer.

Checklist

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

🤖 Generated with Claude Code

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
@brentyates-swx

Copy link
Copy Markdown
Author

Session stamp for this PR:

  • Session ID: e548b2ce-cde5-4bde-844e-535e815af08a
  • Working repo (launch cwd): /home/byates/fm-worktrees/fm-llmrouter/opencode-thought-collapse-53/swx-opencode

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
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
brentyates-swx merged commit b18e7ff into swxtch Oct 10, 2026
3 checks passed
@brentyates-swx
brentyates-swx deleted the fm/opencode-thought-collapse-53 branch October 10, 2026 16:22
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
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