fix(cli): rework session recap rendering and add blur threshold setting#3482
Merged
wenshao merged 8 commits intoQwenLM:mainfrom Apr 21, 2026
Merged
fix(cli): rework session recap rendering and add blur threshold setting#3482wenshao merged 8 commits intoQwenLM:mainfrom
wenshao merged 8 commits intoQwenLM:mainfrom
Conversation
The 5-minute blur threshold was hard-coded. Confirmed from Claude Code's own binary (v2.1.113) that 5 minutes is their default as well (and that they shift to 60 minutes when 1h prompt-cache is active) — so the default stays, but expose it as `general.sessionRecapAway ThresholdMinutes` for users who briefly alt-tab often and don't want recaps piling up, or who want to lower it for testing. Non-positive / unset values fall back to the 5-minute default, so dropping the key has the same behavior as before.
…rds) The earlier "exactly one sentence, 80-char cap" was an over-correction to a single in-the-moment ask. Going back to it: the natural shape of "current task + next action" is two clauses, and forcing them into a single sentence either crams them with a semicolon or drops the next action entirely on complex sessions. Adopt Claude Code's prompt verbatim (extracted from the v2.1.113 binary): "under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents." Add a Chinese-budget note (~80 chars) and keep the <recap>...</recap> wrapping that protects against reasoning-model preambles leaking into the UI. The sticky banner already re-measures controls height when the recap toggles, so a 2-line render lays out cleanly. Sweep "one-line" out of user-facing copy (settings description, slash-command description, feature docs, design doc) so the documentation matches the new shape.
Verified from the Claude Code v2.1.113 binary that the slash-command description IS literally "Generate a one-line session recap now" even though the underlying prompt allows 1-2 sentences. Claude Code is deliberately setting a tighter user expectation than the prompt guarantees, which keeps the surface feel "glanceable". Mirror that asymmetry: keep the prompt at 1-2 sentences (the previous commit) for behavioral parity, but put "one-line" back in the user- visible copy (slash-command description, settings description, user docs). Internal design doc keeps the accurate "1-2 sentence" wording.
Earlier I read the user's complaint that the recap "scrolled away" as "the recap should be sticky above the input box," and built a sticky banner accordingly. Disassembly of the Claude Code v2.1.113 binary shows the actual behavior is the opposite: their away_summary is a plain `type:"system", subtype:"away_summary"` message dispatched through the standard message renderer (no Static, no anchor, no flexbox pinning) — it scrolls with the conversation like every other system message. Tear out the sticky-banner machinery so recap matches that: - Recap is back in the `HistoryItemWithoutId` union and `addItem`'d into history (both from `/recap` and from auto-trigger), so it serializes into session saves and behaves like every other history item — no special clear paths, no resume-wrapper, no layout-effect re-measure dance. - `useAwaySummary` takes `addItem` again instead of a setter callback. - `AwayRecapMessage` renders the way Claude Code does: a 2-column gutter with `※`, then bold "recap: " and italic content, all in dim color. Drop the prior `StatusMessage`-shaped layout that fused prefix and label into "※ recap:". - Remove the AppContainer plumbing, the slashCommandProcessor state, the UIStateContext fields, the DefaultAppLayout / ScreenReader placement blocks, the test-utils mocks, and the noninteractive stub. Restore `useResumeCommand.handleResume` to a void return since callers no longer need the success boolean. Sweep the design doc so the architecture diagram, files table, and hook deps reflect the inline-history flow.
wenshao
commented
Apr 20, 2026
Collaborator
Author
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review
wenshao
commented
Apr 20, 2026
Collaborator
Author
wenshao
left a comment
There was a problem hiding this comment.
[Review Summary]
Overall, the architectural shift from a sticky UI banner to an inline history item is a great UX improvement, aligning well with Claude Code's behavior. The history-based implementation simplifies state management and removes the need for complex UI hooks. I've left a minor note regarding the TypeScript typings for handleResume.
— gemini-3.1-pro-preview via gemini cli review
Two consecutive blur cycles, each over the threshold but with no new user activity in between, would each fire their own auto-recap and add two near-duplicate entries to history (same task, slightly different wording from temperature-driven LLM variance). Reported case: leaving the terminal twice while a /review of one PR was still on screen produced two recaps both about that same review. Add a `shouldFireRecap` gate before kicking off the LLM call: - Need at least 3 user messages in history total (don't fire on a near-empty session). - If a previous away_recap is already in history, need at least 2 new user messages since that one before another can fire. Same shape as Claude Code's `Ic1` gate (`Sc1=3`, `Rc1=2`). Read history through a ref so this isn't in the effect's deps and the effect doesn't re-run on every message.
Per gemini review on QwenLM#3482: the interface declared this as `() => void` but the implementation is `async` and returns `Promise<void>`. The mismatch silently lost the chainable promise — tests had to launder it through `as unknown as Promise<void> | undefined` just to await. Tighten the interface to `Promise<void>` and drop the cast in the "closes the dialog immediately" test.
yiliang114
reviewed
Apr 21, 2026
Per yiliang114 review on QwenLM#3482: the manual `/recap` path persists across `/resume` because the slash-command processor records every output history item via `chatRecorder.recordSlashCommand({ phase: 'result', outputHistoryItems })`, but the auto path called `addItem` directly and bypassed that recorder. The result was an asymmetry where users who triggered recap manually saw it after `/resume`, while users whose recap fired automatically lost it. Mirror the manual recording from useAwaySummary's `.then` callback — record only the `result` phase (not invocation, since we don't want a fake `> /recap` user line replayed) with the away-recap item as the single output. Wrapped in try/catch because recap is best-effort and must never surface a failure to the user. Add useAwaySummary.test.ts covering: - the recording path is taken on a successful auto-trigger - the dedup gate (`shouldFireRecap`) suppresses the LLM call entirely, including the recording, when no new user turns happened since the last recap
CI's `tsc --build` (stricter than local `tsc --noEmit`) rejected the
direct `item as Record<string, unknown>` cast: HistoryItemAwayRecap's
literal `type: 'away_recap'` field doesn't overlap with `unknown`,
TS2352. Use the `{ ...item } as Record<string, unknown>` spread
pattern that the rest of the codebase (arenaCommand,
slashCommandProcessor's serializer) already uses for the same
SlashCommandRecordPayload field.
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.
Summary
Follow-up to #3478. Aligns five pieces of recap behavior with Claude Code's
/recap, including reverting the sticky-banner placement that #3478 introduced based on a misread of the original report.1. Render inline in history (was: sticky banner above input)
Claude Code's recap is a regular history item that scrolls with the conversation, not a sticky banner pinned above the input. The sticky placement in #3478 came from misinterpreting the original bug report. Tear out the banner machinery (state in
slashCommandProcessor,UIStateContextfield,DefaultAppLayout/ScreenReaderAppLayoutplacement, thehandleResumewrapper that cleared it on session switch, the layout-effect re-measure, mocks) andaddItemthe recap into history.AwayRecapMessagenow renders as※+ boldrecap:+ italic content, all dim — matching Claude Code's visual shape.2. Configurable away threshold (default still 5 minutes)
5 minutes matches Claude Code's default. Add
general.sessionRecapAwayThresholdMinutes(number, default 5) for users who briefly alt-tab often and don't want recaps piling up, or who want a shorter value for testing. Non-positive / unset values fall back to 5 — dropping the key behaves identically to before.3. Prompt aligned with Claude Code
Replace the previous "exactly one sentence, ≤80 chars" rule with the looser "under 40 words, 1-2 plain sentences" budget Claude Code uses. The natural shape of "current task + next action" is two clauses; forcing them into a single sentence either crams them with a semicolon or drops the next action on complex sessions. Add a Chinese-budget note (~80 chars) since the English-only spec drifts off-budget for CJK users. Keep our
<recap>...</recap>wrapping which protects against reasoning-model preambles leaking into the UI (we serve glm/qwen/etc. that emit thinking text; Anthropic models don't).4. Slash-command and settings copy
Restore "one-line" in the slash-command description (
Generate a one-line session recap now) and the settings description even though the prompt allows 1-2 sentences. This deliberately sets a tighter user expectation than the prompt guarantees, keeping the surface feel "glanceable". The internal design doc keeps the accurate "1-2 sentence" wording.5. Dedup back-to-back auto-recaps with no new turns between
Reported case: leaving the terminal twice while a
/reviewof one PR was still on screen produced two recaps both about that same review (slightly different wording from temperature-driven LLM variance). Two consecutive blur cycles, each over the threshold but with no new user activity in between, would each fire their own auto-recap and add two near-duplicate entries to history.Add a
shouldFireRecapgate before kicking off the LLM call:away_recapis already in history, need at least 2 new user messages since that one before another can fire.Same shape as Claude Code's gate (their constants are
Sc1=3total user messages andRc1=2user messages since the last recap). Read history through a ref so this isn't in the effect's deps and the effect doesn't re-run on every message.Intentional remaining differences
showSessionRecapfastModelis unset, an ambient recap would fire on the user's main coding model)<recap>...</recap>)Test plan
Automated checks:
npm run typecheck— passesslashCommandProcessor,useResumeCommand,btwCommand,clearCommandvitest suites — 72/72 passManual verification (run in a tmux session against the built CLI with
general.showSessionRecap: trueandfastModelset):/recaprenders in history (not pinned above input)※ recap: …line appears immediately below the> /recapuser echo, just like any other history item — not floating above the divider/recap, sendingsay hi brieflyproduced✦ 嗨。below the recap line, pushing recap upward; the input-box separator did not stay glued to recapAwayRecapMessageshape:※gutter +recap:label + content※ recap: Setting up chat context …rendered with the asterism in a 2-col gutter; bold/italic styling not visible in tmux capture but layout matches the specsessionRecapAwayThresholdMinuteshonored0.1(≈6 s), an 8-second focus-out / focus-in dance triggered the auto-recap; recap appeared inline in history without further user action※ recap: 正在为本次聊天建立上下文...appeared once※ recap:(count went to 2)MaxListenersExceededWarningon screen or in~/.qwen/debug/latesttmux focus-event reproducer used for cases 5–8: