Skip to content

feat: structured per-turn reply footer with platform-specific small-text styling#774

Closed
Cigarrr wants to merge 1 commit intochenhg5:mainfrom
Cigarrr:feat/structured-status-footer
Closed

feat: structured per-turn reply footer with platform-specific small-text styling#774
Cigarrr wants to merge 1 commit intochenhg5:mainfrom
Cigarrr:feat/structured-status-footer

Conversation

@Cigarrr
Copy link
Copy Markdown
Contributor

@Cigarrr Cigarrr commented Apr 25, 2026

Closes #773.

Note: this PR previously bundled an unrelated streaming-preview UX fix
(single-card under fully-quiet display) — that work has been split off
into #777, which can land independently. This PR is now strictly the
structured per-turn reply footer.

Summary

  • 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 block.
  • Implements both interfaces in Feishu 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.
  • Adds a claudecode footer builder that consumes the four cache-token fields claudecode emits per turn; renders e.g. claude-opus-4-7[1m] · out 168 · in 1 cw 971 cr 40.8k · ctx 4% on line 1, ~/workspace/path on line 2. Falls back to the existing buildReplyFooter for other agents.
  • Improves appendReplyFooter so 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.
  • Adds three per-project config toggles (all default 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

  • All new interfaces are optional. Platforms that don't opt in keep the existing inline behavior unchanged.
  • All new methods are additive. The only signature change is streamPreview.finish (package-internal in core/).
  • show_context_indicator is 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 to false still get the same effect — a reply with no ctx % displayed.
  • The default footer shape and content are preserved; when a platform implements StatusFooterSender, the same footer string is rendered with platform-specific small-text styling. Otherwise it goes through appendReplyFooter, with multi-line italic + --- improvements.

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 will get 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 a statusFooter parameter; routes via StatusFooterUpdater when available, falls back to inline append otherwise
    • the "text unchanged since last UpdateMessage" short-circuit now requires no pending statusFooter as well, so a footer can never be silently dropped when the body matches
  • core/engine.go:
    • track statusFooter separately from the response body
    • new sendChunksWithStatusFooter helper threads the footer through every EventResult send branch (toolCount > 0 unsent remainder, suppressDuplicate, p.Send fallback)
    • appendReplyFooter: per-line italic + --- separator
    • new buildClaudeStatusLineFooter + formatStatusTokenCount; takes precedence when cache tokens are present, gated by replyFooterEnabled × showContextIndicator × showWorkdirIndicator
    • buildReplyFooter (legacy single-line) also wires the per-line toggles for symmetry
    • removed the standalone [ctx: ~N%] suffix path (now subsumed by the footer)
  • agent/claudecode/session.go:
    • capture model id from stream-json system init event
    • parse 4-field usage via shared parseClaudeUsage helper
    • read ContextWindow from the model id heuristic (opus-4-7[1m] → 1M, haiku-4-5 → 200k)
    • capture per-sub-call ContextUsage from each assistant event for input/cache fields, and only the result event's output_tokens for OutputTokens — the result aggregates cache_read_input_tokens across every sub-call (sums cache hits and clamps ctx % to 100% on long agentic turns), while per-assistant output_tokens in stream-json is a placeholder of 1 (only the result has the real total)
    • 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 + UI toggle

Tests

go test -count=1 -tags no_web ./... passes. New tests of note:

  • core/claude_status_footer_test.go: full toggle matrix on buildClaudeStatusLineFooter (nil/no-cache/full-render/footer-disabled/hide-context/hide-workdir/hide-both); same matrix on the legacy buildReplyFooter; direct unit tests for sendChunksWithStatusFooter covering 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 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.

Test plan

  • go build -tags no_web ./...
  • go vet -tags no_web ./...
  • go test -count=1 -tags no_web ./... (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: TestProcessInteractiveEvents_AppendsReplyFooterWhenEnabled, _ReplyFooterPrefersSessionRuntimeState, TestResolveLocalDirPath_AcceptsSubdir)
  • Live testing on Feishu — verified the notation-sized footer renders correctly under streaming-preview finalize and the inline fallback works for non-Feishu paths

🤖 Generated with Claude Code

@Cigarrr
Copy link
Copy Markdown
Contributor Author

Cigarrr commented Apr 25, 2026

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.

@Cigarrr Cigarrr closed this Apr 25, 2026
@Cigarrr Cigarrr reopened this Apr 25, 2026
…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
@Cigarrr Cigarrr force-pushed the feat/structured-status-footer branch from a65e53c to 13c2988 Compare April 25, 2026 16:40
Copy link
Copy Markdown
Owner

@chenhg5 chenhg5 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

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 / StatusFooterUpdater with inline fallback for unsupported platforms
  • buildClaudeStatusLineFooter only 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_tokens inflation problem — smart design, well-documented in code comments
  • parseClaudeUsage helper centralizes token extraction (DRY)
  • show_workdir_indicator config adds fine-grained control subordinate to reply_footer
  • formatStatusTokenCount (1.0k style) is compact and readable
  • appendReplyFooter now per-line italic wrapping + --- separator
  • sp.finish() extended to accept statusFooter for streaming preview finalization
  • Feishu card implementation uses notation text 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.

@Cigarrr
Copy link
Copy Markdown
Contributor Author

Cigarrr commented Apr 26, 2026

@chenhg5 @AaronZ345 — flagging a potential semantic conflict with #765 before either lands. Both PRs touch claudecode's [ctx: ~N%] indicator but compute "input" differently, and on real agentic turns the difference is large.

The disagreement

#765 in handleResult does inputTokens += cache_read_input_tokens + cache_creation_input_tokens. The PR text and review describe this as "matches how Anthropic bills context" — true for billing, but result.usage aggregates across all sub-calls in a tool-using turn, so feeding it into a context-occupancy indicator clamps ctx % at 100% on long turns.

#774 captures input/cache from each assistant event (per-sub-call) and uses the LAST one as the ctx snapshot.

Why I believe result.usage is aggregated, not last-call

From @anthropic-ai/claude-agent-sdk@0.2.119 sdk.d.ts:3071-3091:

SDKResultSuccess = {
  type: 'result';
  num_turns: number;
  total_cost_usd: number;             // unambiguously a sum
  usage: NonNullableUsage;            // sibling of total_cost_usd
  modelUsage: Record<string, ModelUsage>;
  ...
};

total_cost_usd is unambiguously aggregated, and modelUsage is structured as per-model totals. By structural symmetry usage is the per-turn aggregate, not a snapshot of the last call.

Empirical check on real transcripts

I measured this against three actual long turns from a CCD session on my machine (~/.claude/projects/<wid>/<sid>.jsonl — each assistant event has its own per-call usage):

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.

@AaronZ345
Copy link
Copy Markdown
Contributor

Thanks for the careful analysis @Cigarrr — the per-sub-call snapshot vs. result-aggregate distinction is a fair point, and the codex precedent (agent/codex/context_usage.go:290 choosing LastTokenUsage over TotalTokenUsage) plus your transcript measurements are convincing. The per-model context window part of #765 is also already covered by your claudeContextWindow helper in #774, so #765 is fully subsumed.

Closing #765 in favor of #774. 🙏

@AaronZ345
Copy link
Copy Markdown
Contributor

AaronZ345 commented Apr 26, 2026

@Cigarrr Thanks for the careful breakdown — you're right.

The codex precedent (agent/codex/context_usage.go:290 choosing LastTokenUsage over TotalTokenUsage) plus your transcript measurements (45×–73× ratio on long agentic turns) make it clear that result.usage is the per-turn aggregate, not a per-call snapshot. Summing cache_read_input_tokens from there into the ctx % indicator clamps it at 100% on long turns, exactly as you measured. The per-sub-call snapshot captured in handleAssistant is the right source for "context used right now."

AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 26, 2026
…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.
@Cigarrr
Copy link
Copy Markdown
Contributor Author

Cigarrr commented Apr 27, 2026

Closing — superseded by #796.

#796 refactors footer rendering to flow through BuildRichCard's new statusFooter string parameter (方案 B), which composes cleanly with the rich-card path and obsoletes this PR's StatusFooterSender / StatusFooterUpdater interface design. The structured footer ends up rendered inside the cardkit-v1 rich card with proper dim/notation styling, instead of inline-appended at the bottom of the markdown body.

Issue #773 stays open as the underlying bug; #796 is the active implementation.

@Cigarrr Cigarrr closed this Apr 27, 2026
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 27, 2026
…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.
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
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.
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
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.
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
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.
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
… 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>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…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>
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.

feat: render per-turn reply footer with platform-specific small-text styling

3 participants