feat: structured per-turn reply footer with platform-specific small-text styling#774
feat: structured per-turn reply footer with platform-specific small-text styling#774Cigarrr wants to merge 1 commit intochenhg5:mainfrom
Conversation
|
Closing to iterate — adding per-segment config controls (reply_footer toggle currently bypassed by the new CCD footer path; will re-open after that's resolved). Issue #773 stays open. |
…mall-text styling
Currently every agent reply gets an inline italic-wrapped footer
appended directly to the response markdown ('*model · effort · ctx
· ~/dir*'). On platforms with rich card support this looks visually
identical to the body — there's no way to render the footer with
smaller/dimmer styling that signals "metadata, not content".
Adds two optional Platform interfaces — `StatusFooterSender`
(one-shot Send path) and `StatusFooterUpdater` (streaming-preview
finalize path) — that take the body content and footer as separate
strings, letting each platform decide how to render the footer.
Platforms that do not implement them keep the existing inline
behavior unchanged.
For Feishu / Lark, implements both interfaces by emitting an extra
`tag: "markdown"` card element with `text_size: "notation"`
separated from the body by `hr`. Matches the convention used by
larksuite/openclaw-lark and renders the footer at a smaller font
with grey color.
Adds a richer claudecode footer builder that consumes the four
cache-token fields claudecode emits per turn (input,
cache_creation, cache_read, output) and renders e.g.
claude-opus-4-7[1m] · out 168 · in 1 cw 971 cr 40.8k · ctx 4%
~/cc-workspaces/jeeves/occ
This takes precedence when cache tokens are present; other agents
continue to use the existing single-line footer.
Adds three per-project config toggles (all default `true`):
* `reply_footer` — master toggle for the per-turn footer.
* `show_context_indicator` — controls the footer's first line
(model · effort · token usage · ctx %). Repurposed from its
previous job (the standalone `[ctx: ~N%]` suffix on plain
replies, removed in favor of the footer's first line).
* `show_workdir_indicator` — controls the footer's second line
(workspace directory).
Other notable changes:
* `streamPreview.finish` now accepts a `statusFooter` parameter
and routes via `StatusFooterUpdater` when available; falls back
to inline `appendReplyFooter` otherwise. The "text unchanged
since last UpdateMessage" short-circuit additionally requires
no pending statusFooter, so a footer is never silently dropped
when the body matches.
* New `sendChunksWithStatusFooter` helper threads the footer
through every EventResult send branch (tool-count remainder,
suppress-duplicate, p.Send fallback) so the structured footer
survives every code path.
* `appendReplyFooter` italic-wraps each footer line individually
so multi-line footers don't break the italic span; inserts a
markdown `---` separator between body and footer for clearer
visual boundaries on inline-rendering platforms.
* claudecode now captures per-sub-call usage from each `assistant`
event for the runtime `ContextUsage` snapshot and only pulls
`output_tokens` from the result event. The result event's
`cache_read_input_tokens` is summed across every sub-call that
hit the cached prefix; on long agentic turns that sum exceeds
the model context window and clamps `ctx %` to 100%. The per-
assistant `output_tokens` in stream-json is a placeholder of 1
(only the result has the real total), so they come from
different sources.
* Removes the standalone `[ctx: ~N%]` suffix path
(`contextIndicator` / `modelContextWindow`) — its information
is now in the footer's first line, gated by the same flag.
Why claudecode lives in core: per CLAUDE.md rule 1 (no hardcoded
platform/agent names in core), the claudecode-specific footer
builder gates on the *signal* (presence of cache tokens in
`ContextUsage`) rather than on `agent.Name() == "claudecode"`.
Any agent that populates `CacheCreationInputTokens` /
`CachedInputTokens` in result events gets the same richer footer.
Detailed changes:
* `core/interfaces.go`:
- new `StatusFooterSender` / `StatusFooterUpdater` optional interfaces
- `ContextUsage` gains `CacheCreationInputTokens`
* `core/message.go`: `Event` gains `CacheCreationInputTokens` / `CacheReadInputTokens`
* `core/streaming.go`:
- `streamPreview.finish` accepts `statusFooter` and routes via `StatusFooterUpdater`
- the unchanged-text short-circuit now also requires no pending footer
* `core/engine.go`:
- track `statusFooter` separately from the response body
- `sendChunksWithStatusFooter` helper threads the footer through every send branch
- new `buildClaudeStatusLineFooter` + `formatStatusTokenCount`
- `buildReplyFooter` (legacy single-line) wires the per-line toggles
- `appendReplyFooter`: per-line italic + `---` separator
- removes `[ctx: ~N%]` suffix, `contextIndicator`, `modelContextWindow`
- new `showWorkdirIndicator` field + setter; `replyFooterEnabled`
gates `buildClaudeStatusLineFooter` and `buildReplyFooter`
* `agent/claudecode/session.go`:
- capture model id from stream-json `system` init event
- shared `parseClaudeUsage` helper for the four-field `usage` map
- capture per-sub-call `ContextUsage` from each assistant event
(input/cache fields), update OutputTokens from the result event
- read `ContextWindow` from the model id heuristic
- expose `GetModel` / `GetWorkDir` / `GetContextUsage`
* `platform/feishu/feishu.go`:
- `SendWithStatusFooter` / `UpdateMessageWithStatusFooter`
- `buildCardJSONWithStatusFooter` helper (markdown body + hr +
markdown footer with `text_size: "notation"`)
- extracted `patchCardMessage` helper
* `config/config.go`, `config.example.toml`: `ShowWorkdirIndicator`
field; comments and example updated for the three toggles
* `core/management.go`, `cmd/cc-connect/main.go`: load and reload
all three toggles on startup, restart, and the management API
PATCH path
* `web/src/api/projects.ts`, `web/src/pages/Projects/ProjectDetail.tsx`:
`show_workdir_indicator` field and UI toggle
Tests: `go test -count=1 -tags no_web ./...` passes (3 pre-existing
failures in `core/` are unrelated — they fail on the unmodified `main`
branch as well on macOS due to a `/var` ↔ `/private/var` symlink in
the path-resolution tests). New tests cover:
* `core/claude_status_footer_test.go`: full toggle matrix for
`buildClaudeStatusLineFooter` (nil/no-cache/full/footer-disabled/
hide-context/hide-workdir/hide-both); same matrix for the legacy
`buildReplyFooter`; direct unit tests for
`sendChunksWithStatusFooter` covering footer-sender success,
inline fallback, no-footer, and non-implementing platforms;
`appendReplyFooter` italic + `---` per-line.
* `core/streaming_test.go::TestStreamPreview_FinishAppliesFooterEvenWhenBodyUnchanged`:
regression for the short-circuit drop.
* `agent/claudecode/session_test.go::TestHandleAssistantCapturesPerSubCallUsage`:
regression for the inflated cache_read aggregation; verifies
input/cache come from the last assistant event, OutputTokens
from the result.
* `config/config_test.go::TestSaveProjectSettings_ExtraFields`:
round-trip for `show_workdir_indicator`.
* `platform/feishu/card_test.go`: `TestBuildCardJSONWithStatusFooter`
+ `_EmptyFooterFallsThrough`.
Closes chenhg5#773
a65e53c to
13c2988
Compare
chenhg5
left a comment
There was a problem hiding this comment.
Review Summary
Comprehensive, well-architected reply footer system. Proper separation of concerns: agent provides usage data, engine composes footer, platform renders with native styling.
✅ Good:
- Clean interface design:
StatusFooterSender/StatusFooterUpdaterwith inline fallback for unsupported platforms buildClaudeStatusLineFooteronly activates when cache-token signals are present (other agents fall through to default footer)- Per-sub-call usage tracking in claudeSession avoids the aggregated
cache_read_input_tokensinflation problem — smart design, well-documented in code comments parseClaudeUsagehelper centralizes token extraction (DRY)show_workdir_indicatorconfig adds fine-grained control subordinate toreply_footerformatStatusTokenCount(1.0k style) is compact and readableappendReplyFooternow per-line italic wrapping +---separatorsp.finish()extended to accept statusFooter for streaming preview finalization- Feishu card implementation uses
notationtext size for dim footer - 420-line test file with thorough coverage (token formatting, model detection, effort, workdir, combined)
- Config changes wired through main.go, management API, web UI, config validation, and reload
🟡 Note: overlap with #765
Both PRs add claudeContextWindow / modelContextWindow and cache token handling in claudeSession. After both merge, one should be consolidated. Not a blocker — the behavior is compatible.
LGTM. Architecture sound, comprehensive tests, CI green. Closes #773.
|
@chenhg5 @AaronZ345 — flagging a potential semantic conflict with #765 before either lands. Both PRs touch claudecode's The disagreement#765 in #774 captures input/cache from each Why I believe
|
| Turn (sub-calls) | sum across sub-calls (input+cc+cr) | last sub-call only | ratio |
|---|---|---|---|
| 121 | 14,250,540 | 195,005 | 73× |
| 58 | 13,916,266 | 278,440 | 50× |
| 48 | 20,532,706 | 451,655 | 45× |
For any sane window (200k or 1M), the aggregated value clamps ctx % at 100% on long agentic turns. The last sub-call's value is the actual current prompt size — which is what users mentally model when looking at an indicator.
(Reproducible on any CCD transcript: each assistant event's cache_read_input_tokens grows turn-over-turn as the cached prefix accumulates; summing all of them across sub-calls gives a value that has nothing to do with current context occupancy.)
Codex precedent in this repo
agent/codex/context_usage.go:290 already faces this exact problem and chooses LastTokenUsage over TotalTokenUsage for the context indicator. Same split applies here.
Suggested resolution: both numbers are useful, just for different questions
| Question | Right source | Notes |
|---|---|---|
| "How full is my context right now?" (UI footer ctx %) | Last assistant event's per-call usage |
Snapshot — won't clamp |
"How much did this turn cost in tokens / dollars?" (telemetry, /cost) |
result.usage (aggregated) |
Real billing total |
This PR already preserves both: lastUsage (per-sub-call, drives the footer ctx %) and EventResult.CacheCreationInputTokens / CacheReadInputTokens (from result.usage, aggregated, kept around for billing/log). They live side-by-side without conflict.
Bonus: SDK already exposes contextWindow
ModelUsage.contextWindow: number (sdk.d.ts:1086) is in result.modelUsage[model]. Both PRs currently parse the window from the model-id string ([1m] substring). Reading the SDK field directly would be more robust to future model variants — but that's a follow-up cleanup, not a blocker for either PR.
Happy to be told I've misread the SDK types or that result.usage has different semantics. If result.usage is in fact last-call (not aggregated), then #765 is correct as-is and this whole concern goes away. The transcript measurement is reproducible on any CCD session, if anyone wants to confirm independently.
|
Thanks for the careful analysis @Cigarrr — the per-sub-call snapshot vs. result-aggregate distinction is a fair point, and the codex precedent ( |
|
@Cigarrr Thanks for the careful breakdown — you're right. The codex precedent ( |
…r-model window Two bugs broke the [ctx: ~N%] indicator in the claudecode path: 1. handleResult sourced the indicator's input from the result event's usage. The result event aggregates usage across every sub-call in a tool-using turn, summing cache_read_input_tokens — on long agentic turns this is many times the actual context window, so [ctx: ~N%] clamps at 100% and stops being useful. 2. contextIndicator divided by a hardcoded 200_000. Users on claude-opus-4-7[1m] saw 5x inflated percentages. Fix: - Capture per-sub-call usage (input + cache_creation + cache_read) from each assistant event into lastInputTokens. The latest snapshot is the real "context used right now" — what the indicator should show. - handleResult sources InputTokens from this snapshot; only output_tokens is still read from the result event itself. - ContextWindow is stamped via modelContextWindow(cs.model): "[1m]" / "-1m" suffix → 1_000_000, otherwise 200_000. Credit to @Cigarrr (chenhg5#774) for catching that result.usage is a per-turn aggregate, not a per-call snapshot — the codex agent's LastTokenUsage / TotalTokenUsage split (agent/codex/context_usage.go:290) is the same distinction.
|
Closing — superseded by #796. #796 refactors footer rendering to flow through Issue #773 stays open as the underlying bug; #796 is the active implementation. |
…r-model window Two bugs broke the [ctx: ~N%] indicator in the claudecode path: 1. handleResult sourced the indicator's input from the result event's usage. The result event aggregates usage across every sub-call in a tool-using turn, summing cache_read_input_tokens — on long agentic turns this is many times the actual context window, so [ctx: ~N%] clamps at 100% and stops being useful. 2. contextIndicator divided by a hardcoded 200_000. Users on claude-opus-4-7[1m] saw 5x inflated percentages. Fix: - Capture per-sub-call usage (input + cache_creation + cache_read) from each assistant event into lastInputTokens. The latest snapshot is the real "context used right now" — what the indicator should show. - handleResult sources InputTokens from this snapshot; only output_tokens is still read from the result event itself. - ContextWindow is stamped via modelContextWindow(cs.model): "[1m]" / "-1m" suffix → 1_000_000, otherwise 200_000. Credit to @Cigarrr (chenhg5#774) for catching that result.usage is a per-turn aggregate, not a per-call snapshot — the codex agent's LastTokenUsage / TotalTokenUsage split (agent/codex/context_usage.go:290) is the same distinction.
…ard via unified statusFooter (方案 B) The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the rich card path. Phase B reintroduces it cleanly through the planned uniform 'statusFooter string' interface so elapsed time, claudecode model/ctx status, and workdir all flow through the same composition pipeline -- no duplicate elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict. Interface refactor: RichCardSupporter.BuildRichCard(..., elapsed time.Duration) ^ removed (..., statusFooter string) ^ added Multi-line, '\n' separated. Empty string = footer hidden. Engine adds composeRichStatusFooter() helper (3 lines, each toggleable): line 1: ⏱ <i18n elapsed> (subject to e.replyFooterEnabled) line 2: model · effort · ctx (subject to e.showContextIndicator) line 3: <workdir> (subject to e.showWorkdirIndicator) Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths: ZH streaming: "⏱ 运行中 12.3 秒..." ZH done: "⏱ 用时 1 分 23 秒" EN streaming: "⏱ Running for 12.3s..." EN done: "⏱ Elapsed 1m 23s" ja/es fall back to EN format -- promote to MsgKey i18n later if needed. Feishu renderer: - drops the local elapsed/footerMap branch (engine owns formatting now) - splits incoming statusFooter on '\n', renders each line as its own markdown element with text_size:notation + text_color:grey - inserts <hr> between body markdown and footer for visual separation - dead formatElapsedCN helper removed All 8 engine BuildRichCard call sites updated to the new signature; feishu Platform.BuildRichCard wrapper signature updated; buildRichCard() inner renderer signature updated. Per-project [display].mode override deferred to a follow-up commit; the existing global [display].mode keeps working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oter line Three issues observed after Phase B v1 deployment, all fixed: 1) [bug] Rich card status header stuck in 'Pondering...' (working blue), never flips to green 'Done' on EventResult. 2) [bug] Two cards rendered per turn: one stuck-working + one final Done. 3) [bug] Footer line 2 only shows 'X% left' — missing in / out / cw / cr token counts that the original chenhg5#774 design specified. Root causes: (1)+(2): Im.Message.Patch is silently no-op for messages bound to a cardkit-v1 entity (= the rich-mode path that uses card_id reference content). Per Lark docs, full-content updates on entity-referenced messages must use the cardkit-v1 PUT /open-apis/cardkit/v1/cards/{card_id} endpoint with body {card:{type:'card_json',data:<json>}, sequence:N}. The previous Patch returned success-shaped responses but didn't actually re-render, so the engine never saw an error to fall back from -- yet the UI stayed in 'Working' state. The TWO-cards observation was the wrap-up: when the engine concluded the Patch had no effect, the EventResult fall-through to p.Send() created a brand-new card rendering the final state, while the original card stayed orphaned in working state. (3): My Phase B composeRichStatusFooter reused replyFooterContextText and replyFooterUsageText, both of which produce the legacy 'X% left' or aggregate usage label. Neither emits the per-field 'in N cw N cr N out N' breakdown that chenhg5#774's original buildClaudeStatusLineFooter helper produced. Fixes: feishu.UpdateMessage now branches on h.cardID: cardID != '' -> updateCardEntity (cardkit-v1 PUT /cards/{card_id}) cardID == '' -> Im.Message.Patch (legacy inline-card path) updateCardEntity reuses h.sequence (mu-protected, monotonic) so its sequence is consistent with streamRichCardText's per-element updates. buildClaudeStatusLineFooter helper added in core/engine.go, replacing the old composeRichStatusFooter line-2 logic. Output: claude-opus-4-7[1m] · xhigh · out 168 · in 1 cw 971 cr 40.8k · ctx 4% formatStatusTokenCount renders 168 / 40.8k / 1.2M based on magnitude. Phase B v1 fallback path (replyFooterUsageText) preserved as line 2 only for non-claudecode agents that don't populate ContextUsage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-tier formatStatusTokenCount introduced for cardkit-v1 footer displaced the 2-tier version from chenhg5#774 that clamped negative inputs to 0. Restore the clamp — defensive against parser glitches that emit a negative-looking int (e.g. an int64 cast under heavy concurrency). Re-aligns with TestFormatStatusTokenCount in claude_status_footer_test.go.
…ard via unified statusFooter (方案 B) The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the rich card path. Phase B reintroduces it cleanly through the planned uniform 'statusFooter string' interface so elapsed time, claudecode model/ctx status, and workdir all flow through the same composition pipeline -- no duplicate elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict. Interface refactor: RichCardSupporter.BuildRichCard(..., elapsed time.Duration) ^ removed (..., statusFooter string) ^ added Multi-line, '\n' separated. Empty string = footer hidden. Engine adds composeRichStatusFooter() helper (3 lines, each toggleable): line 1: ⏱ <i18n elapsed> (subject to e.replyFooterEnabled) line 2: model · effort · ctx (subject to e.showContextIndicator) line 3: <workdir> (subject to e.showWorkdirIndicator) Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths: ZH streaming: "⏱ 运行中 12.3 秒..." ZH done: "⏱ 用时 1 分 23 秒" EN streaming: "⏱ Running for 12.3s..." EN done: "⏱ Elapsed 1m 23s" ja/es fall back to EN format -- promote to MsgKey i18n later if needed. Feishu renderer: - drops the local elapsed/footerMap branch (engine owns formatting now) - splits incoming statusFooter on '\n', renders each line as its own markdown element with text_size:notation + text_color:grey - inserts <hr> between body markdown and footer for visual separation - dead formatElapsedCN helper removed All 8 engine BuildRichCard call sites updated to the new signature; feishu Platform.BuildRichCard wrapper signature updated; buildRichCard() inner renderer signature updated. Per-project [display].mode override deferred to a follow-up commit; the existing global [display].mode keeps working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oter line Three issues observed after Phase B v1 deployment, all fixed: 1) [bug] Rich card status header stuck in 'Pondering...' (working blue), never flips to green 'Done' on EventResult. 2) [bug] Two cards rendered per turn: one stuck-working + one final Done. 3) [bug] Footer line 2 only shows 'X% left' — missing in / out / cw / cr token counts that the original chenhg5#774 design specified. Root causes: (1)+(2): Im.Message.Patch is silently no-op for messages bound to a cardkit-v1 entity (= the rich-mode path that uses card_id reference content). Per Lark docs, full-content updates on entity-referenced messages must use the cardkit-v1 PUT /open-apis/cardkit/v1/cards/{card_id} endpoint with body {card:{type:'card_json',data:<json>}, sequence:N}. The previous Patch returned success-shaped responses but didn't actually re-render, so the engine never saw an error to fall back from -- yet the UI stayed in 'Working' state. The TWO-cards observation was the wrap-up: when the engine concluded the Patch had no effect, the EventResult fall-through to p.Send() created a brand-new card rendering the final state, while the original card stayed orphaned in working state. (3): My Phase B composeRichStatusFooter reused replyFooterContextText and replyFooterUsageText, both of which produce the legacy 'X% left' or aggregate usage label. Neither emits the per-field 'in N cw N cr N out N' breakdown that chenhg5#774's original buildClaudeStatusLineFooter helper produced. Fixes: feishu.UpdateMessage now branches on h.cardID: cardID != '' -> updateCardEntity (cardkit-v1 PUT /cards/{card_id}) cardID == '' -> Im.Message.Patch (legacy inline-card path) updateCardEntity reuses h.sequence (mu-protected, monotonic) so its sequence is consistent with streamRichCardText's per-element updates. buildClaudeStatusLineFooter helper added in core/engine.go, replacing the old composeRichStatusFooter line-2 logic. Output: claude-opus-4-7[1m] · xhigh · out 168 · in 1 cw 971 cr 40.8k · ctx 4% formatStatusTokenCount renders 168 / 40.8k / 1.2M based on magnitude. Phase B v1 fallback path (replyFooterUsageText) preserved as line 2 only for non-claudecode agents that don't populate ContextUsage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-tier formatStatusTokenCount introduced for cardkit-v1 footer displaced the 2-tier version from chenhg5#774 that clamped negative inputs to 0. Restore the clamp — defensive against parser glitches that emit a negative-looking int (e.g. an int64 cast under heavy concurrency). Re-aligns with TestFormatStatusTokenCount in claude_status_footer_test.go.
…ard via unified statusFooter (方案 B) The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the rich card path. Phase B reintroduces it cleanly through the planned uniform 'statusFooter string' interface so elapsed time, claudecode model/ctx status, and workdir all flow through the same composition pipeline -- no duplicate elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict. Interface refactor: RichCardSupporter.BuildRichCard(..., elapsed time.Duration) ^ removed (..., statusFooter string) ^ added Multi-line, '\n' separated. Empty string = footer hidden. Engine adds composeRichStatusFooter() helper (3 lines, each toggleable): line 1: ⏱ <i18n elapsed> (subject to e.replyFooterEnabled) line 2: model · effort · ctx (subject to e.showContextIndicator) line 3: <workdir> (subject to e.showWorkdirIndicator) Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths: ZH streaming: "⏱ 运行中 12.3 秒..." ZH done: "⏱ 用时 1 分 23 秒" EN streaming: "⏱ Running for 12.3s..." EN done: "⏱ Elapsed 1m 23s" ja/es fall back to EN format -- promote to MsgKey i18n later if needed. Feishu renderer: - drops the local elapsed/footerMap branch (engine owns formatting now) - splits incoming statusFooter on '\n', renders each line as its own markdown element with text_size:notation + text_color:grey - inserts <hr> between body markdown and footer for visual separation - dead formatElapsedCN helper removed All 8 engine BuildRichCard call sites updated to the new signature; feishu Platform.BuildRichCard wrapper signature updated; buildRichCard() inner renderer signature updated. Per-project [display].mode override deferred to a follow-up commit; the existing global [display].mode keeps working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oter line Three issues observed after Phase B v1 deployment, all fixed: 1) [bug] Rich card status header stuck in 'Pondering...' (working blue), never flips to green 'Done' on EventResult. 2) [bug] Two cards rendered per turn: one stuck-working + one final Done. 3) [bug] Footer line 2 only shows 'X% left' — missing in / out / cw / cr token counts that the original chenhg5#774 design specified. Root causes: (1)+(2): Im.Message.Patch is silently no-op for messages bound to a cardkit-v1 entity (= the rich-mode path that uses card_id reference content). Per Lark docs, full-content updates on entity-referenced messages must use the cardkit-v1 PUT /open-apis/cardkit/v1/cards/{card_id} endpoint with body {card:{type:'card_json',data:<json>}, sequence:N}. The previous Patch returned success-shaped responses but didn't actually re-render, so the engine never saw an error to fall back from -- yet the UI stayed in 'Working' state. The TWO-cards observation was the wrap-up: when the engine concluded the Patch had no effect, the EventResult fall-through to p.Send() created a brand-new card rendering the final state, while the original card stayed orphaned in working state. (3): My Phase B composeRichStatusFooter reused replyFooterContextText and replyFooterUsageText, both of which produce the legacy 'X% left' or aggregate usage label. Neither emits the per-field 'in N cw N cr N out N' breakdown that chenhg5#774's original buildClaudeStatusLineFooter helper produced. Fixes: feishu.UpdateMessage now branches on h.cardID: cardID != '' -> updateCardEntity (cardkit-v1 PUT /cards/{card_id}) cardID == '' -> Im.Message.Patch (legacy inline-card path) updateCardEntity reuses h.sequence (mu-protected, monotonic) so its sequence is consistent with streamRichCardText's per-element updates. buildClaudeStatusLineFooter helper added in core/engine.go, replacing the old composeRichStatusFooter line-2 logic. Output: claude-opus-4-7[1m] · xhigh · out 168 · in 1 cw 971 cr 40.8k · ctx 4% formatStatusTokenCount renders 168 / 40.8k / 1.2M based on magnitude. Phase B v1 fallback path (replyFooterUsageText) preserved as line 2 only for non-claudecode agents that don't populate ContextUsage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-tier formatStatusTokenCount introduced for cardkit-v1 footer displaced the 2-tier version from chenhg5#774 that clamped negative inputs to 0. Restore the clamp — defensive against parser glitches that emit a negative-looking int (e.g. an int64 cast under heavy concurrency). Re-aligns with TestFormatStatusTokenCount in claude_status_footer_test.go.
… path After cherry-picking chenhg5#774 (reply footer refactor) the contextIndicator function no longer exists. Upstream chenhg5#608 (relay unsolicited agent events between user turns) added a fresh call to it in runUnsolicitedReader's EventResult branch, breaking the build after rebase onto current origin/main. chenhg5#774's design intent is that ctx usage should appear in the unified reply footer, not as an inline `[ctx: ~N%]` suffix. The foreground reply path already follows that convention. Mirroring that for the unsolicited reply path means simply omitting the ctx suffix here — buildReplyFooter requires Agent + workspace context that the unsolicited reader doesn't carry, and an unsolicited reply is a less common path where the small UX regression is acceptable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d recovery The cherry-picked chenhg5#774 commit (reply footer refactor) re-introduced the old TestStreamPreview_FreezeDeletesOnFinish expectations from main when adding the statusFooter parameter to finish(), but the cherry-picked chenhg5#795 commit (Lark icon + width_mode follow-up to chenhg5#657) had already updated finish() with degraded-recovery behavior — it now attempts UpdateMessage on the degraded preview and returns true on success without deleting. Restore the post-chenhg5#795 expectation: finish should return true when recovery via UpdateMessage succeeds (mockCleanerPlatform embeds mockUpdaterPlatform so the recovery path completes), and drop the "expected 1 delete call" assertion that no longer applies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #773.
Summary
StatusFooterSender(one-shot Send path) andStatusFooterUpdater(streaming-preview finalize path) — that take the body content and footer as separate strings, letting each platform decide how to render the footer block.tag: "markdown"card element withtext_size: "notation"separated from the body byhr. Matches the convention used bylarksuite/openclaw-lark.claude-opus-4-7[1m] · out 168 · in 1 cw 971 cr 40.8k · ctx 4%on line 1,~/workspace/pathon line 2. Falls back to the existingbuildReplyFooterfor other agents.appendReplyFooterso multi-line footers don't break the italic span (each line individually*line*-wrapped) and inserts a markdown---separator between body and footer for clearer visual boundaries on inline-rendering platforms.true):reply_footer— master toggle for the per-turn reply footer.show_context_indicator— controls the footer's first line (model · effort · token usage · ctx %). The legacy[ctx: ~N%]suffix on plain replies is removed; that information now lives in the footer's first line and is gated by this same flag.show_workdir_indicator— controls the footer's second line (workspace directory).Compatibility
streamPreview.finish(package-internal incore/).show_context_indicatoris repurposed but its previous job (deciding whether to surface the ctx %) is now done in the new location (footer line 1). Operators who set it tofalsestill get the same effect — a reply with no ctx % displayed.StatusFooterSender, the same footer string is rendered with platform-specific small-text styling. Otherwise it goes throughappendReplyFooter, with multi-line italic +---improvements.Why claudecode lives in core
Per
CLAUDE.mdrule 1 (no hardcoded platform/agent names in core), the claudecode-specific footer builder gates on the signal (presence of cache tokens inContextUsage) rather than onagent.Name() == \"claudecode\". Any agent that populatesCacheCreationInputTokens/CachedInputTokensin result events will get the same richer footer.Detailed changes
core/interfaces.go:StatusFooterSender/StatusFooterUpdateroptional interfacesContextUsagegainsCacheCreationInputTokenscore/message.go:EventgainsCacheCreationInputTokens/CacheReadInputTokenscore/streaming.go:streamPreview.finishaccepts astatusFooterparameter; routes viaStatusFooterUpdaterwhen available, falls back to inline append otherwisecore/engine.go:statusFooterseparately from the response bodysendChunksWithStatusFooterhelper threads the footer through every EventResult send branch (toolCount > 0unsent remainder,suppressDuplicate,p.Sendfallback)appendReplyFooter: per-line italic +---separatorbuildClaudeStatusLineFooter+formatStatusTokenCount; takes precedence when cache tokens are present, gated byreplyFooterEnabled×showContextIndicator×showWorkdirIndicatorbuildReplyFooter(legacy single-line) also wires the per-line toggles for symmetry[ctx: ~N%]suffix path (now subsumed by the footer)agent/claudecode/session.go:systeminit eventparseClaudeUsagehelperContextWindowfrom the model id heuristic (opus-4-7[1m]→ 1M,haiku-4-5→ 200k)ContextUsagefrom eachassistantevent for input/cache fields, and only the result event'soutput_tokensfor OutputTokens — the result aggregatescache_read_input_tokensacross every sub-call (sums cache hits and clampsctx %to 100% on long agentic turns), while per-assistantoutput_tokensin stream-json is a placeholder of 1 (only the result has the real total)GetModel/GetWorkDir/GetContextUsageplatform/feishu/feishu.go:SendWithStatusFooter/UpdateMessageWithStatusFooterbuildCardJSONWithStatusFooterhelper (markdown body + hr + markdown footer withtext_size: \"notation\")patchCardMessagehelperconfig/config.go+config.example.toml:ShowWorkdirIndicatorfield; comments and example updated for the three togglescore/management.go+cmd/cc-connect/main.go: load and reload all three toggles on startup, restart, and the management API PATCH pathweb/src/api/projects.ts+web/src/pages/Projects/ProjectDetail.tsx:show_workdir_indicatorfield + UI toggleTests
go test -count=1 -tags no_web ./...passes. New tests of note:core/claude_status_footer_test.go: full toggle matrix onbuildClaudeStatusLineFooter(nil/no-cache/full-render/footer-disabled/hide-context/hide-workdir/hide-both); same matrix on the legacybuildReplyFooter; direct unit tests forsendChunksWithStatusFootercovering footer-sender success / inline fallback / no-footer / non-implementing platforms.core/streaming_test.go::TestStreamPreview_FinishAppliesFooterEvenWhenBodyUnchanged: regression for the short-circuit drop.agent/claudecode/session_test.go::TestHandleAssistantCapturesPerSubCallUsage: regression for the inflatedcache_readaggregation; verifies input/cache come from the last assistant event, OutputTokens from the result.config/config_test.go::TestSaveProjectSettings_ExtraFields: round-trip forshow_workdir_indicator.platform/feishu/card_test.go::TestBuildCardJSONWithStatusFooter+_EmptyFooterFallsThrough.Test plan
go build -tags no_web ./...go vet -tags no_web ./...go test -count=1 -tags no_web ./...(3 pre-existing failures incore/are unrelated — they fail on the unmodifiedmainbranch as well on macOS due to a/var↔/private/varsymlink in the path-resolution tests:TestProcessInteractiveEvents_AppendsReplyFooterWhenEnabled,_ReplyFooterPrefersSessionRuntimeState,TestResolveLocalDirPath_AcceptsSubdir)🤖 Generated with Claude Code