Skip to content

fix(cli): rework session recap rendering and add blur threshold setting#3482

Merged
wenshao merged 8 commits intoQwenLM:mainfrom
wenshao:recap-threshold-configurable
Apr 21, 2026
Merged

fix(cli): rework session recap rendering and add blur threshold setting#3482
wenshao merged 8 commits intoQwenLM:mainfrom
wenshao:recap-threshold-configurable

Conversation

@wenshao
Copy link
Copy Markdown
Collaborator

@wenshao wenshao commented Apr 20, 2026

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, UIStateContext field, DefaultAppLayout / ScreenReaderAppLayout placement, the handleResume wrapper that cleared it on session switch, the layout-effect re-measure, mocks) and addItem the recap into history. AwayRecapMessage now renders as + bold recap: + 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 /review of 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 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 gate (their constants are Sc1=3 total user messages and Rc1=2 user 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

Claude Code qwen-code
Default for showSessionRecap on (built-in cheap fast model makes ambient calls negligible) off (we have no equivalent free fast model — if fastModel is unset, an ambient recap would fire on the user's main coding model)
Reasoning-tag stripping (<recap>...</recap>) not present retained (we serve models that emit thinking text)

Test plan

Automated checks:

  • npm run typecheck — passes
  • slashCommandProcessor, useResumeCommand, btwCommand, clearCommand vitest suites — 72/72 pass

Manual verification (run in a tmux session against the built CLI with general.showSessionRecap: true and fastModel set):

# Scenario Result Evidence
1 /recap renders in history (not pinned above input) ※ recap: … line appears immediately below the > /recap user echo, just like any other history item — not floating above the divider
2 Recap scrolls with the conversation (the core behavior change vs. #3478) After /recap, sending say hi briefly produced ✦ 嗨。 below the recap line, pushing recap upward; the input-box separator did not stay glued to recap
3 AwayRecapMessage shape: 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 spec
4 Generated content is 1–2 sentences (not crammed into one) Sample output: "Setting up chat context and answering a quick arithmetic check. Next: send your actual coding task or question so we can start." — two clean sentences, task + next action
5 sessionRecapAwayThresholdMinutes honored With 0.1 (≈6 s), an 8-second focus-out / focus-in dance triggered the auto-recap; recap appeared inline in history without further user action
6 First focus-cycle after ≥3 user turns fires recap After 3 arithmetic prompts + 8 s blur/focus, ※ recap: 正在为本次聊天建立上下文... appeared once
7 Second focus-cycle with no new user turns is suppressed Repeating the 8 s blur/focus dance immediately, without typing anything, produced no new recap (history recap count stayed at 1 across 25 s of waiting)
8 Third focus-cycle after 2 new user turns fires again Sending two more arithmetic prompts, then another 8 s focus dance, produced a second ※ recap: (count went to 2)
9 Listener-leak suppression from #3478 still holds 30 retries logged across followup-speculation + recap, 0 MaxListenersExceededWarning on screen or in ~/.qwen/debug/latest

tmux focus-event reproducer used for cases 5–8:

echo '{"fastModel":"<your-fast-model>","general":{"showSessionRecap":true,"sessionRecapAwayThresholdMinutes":0.1}}' \
  > /tmp/recap-test/.qwen/settings.json
tmux new-session -d -s recap-test -x 200 -y 50
tmux send-keys -t recap-test "QWEN_WORKING_DIR=/tmp/recap-test npm --prefix <repo> start" Enter
# wait for boot, type a few prompts, wait for the model replies
printf '\033[O' | tmux load-buffer - && tmux paste-buffer -t recap-test  # blur
sleep 8
printf '\033[I' | tmux load-buffer - && tmux paste-buffer -t recap-test  # focus
# the recap line should appear within a few seconds
image image

wenshao added 4 commits April 21, 2026 00:42
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 wenshao changed the title feat(cli): make recap away-threshold configurable fix(cli): align session recap with Claude Code Apr 20, 2026
@wenshao wenshao changed the title fix(cli): align session recap with Claude Code fix(cli): scroll recap with history, add threshold setting, tighten prompt Apr 20, 2026
@wenshao wenshao changed the title fix(cli): scroll recap with history, add threshold setting, tighten prompt fix(cli): rework session recap rendering and add blur threshold setting Apr 20, 2026
Copy link
Copy Markdown
Collaborator Author

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/cli/src/ui/hooks/useResumeCommand.ts Outdated
wenshao added 2 commits April 21, 2026 07:24
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.
Comment thread packages/cli/src/ui/hooks/useAwaySummary.ts
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
@wenshao wenshao requested a review from yiliang114 April 21, 2026 05:18
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.
Copy link
Copy Markdown
Collaborator

@yiliang114 yiliang114 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@wenshao wenshao merged commit afbb5e7 into QwenLM:main Apr 21, 2026
24 of 25 checks passed
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.

2 participants