Skip to content

feat(core+feishu): cardkit-v1 typewriter + rich-card statusFooter integration#796

Open
Cigarrr wants to merge 19 commits intochenhg5:mainfrom
Cigarrr:pr-2-typewriter-statusfooter
Open

feat(core+feishu): cardkit-v1 typewriter + rich-card statusFooter integration#796
Cigarrr wants to merge 19 commits intochenhg5:mainfrom
Cigarrr:pr-2-typewriter-statusfooter

Conversation

@Cigarrr
Copy link
Copy Markdown
Contributor

@Cigarrr Cigarrr commented Apr 27, 2026

Summary

This PR completes the rich-mode story started by #657 (Card 2.0 opt-in by @AaronZ345) and #774 (structured reply footer): rich-mode cards now stream text character-by-character via the cardkit-v1 element streaming API, and the structured footer is rendered inside the rich card with proper dim/notation styling instead of inline-appended at the bottom of the markdown body.

Dependencies — read first

This PR sits on top of three open PRs and should be reviewed last in the chain:

  1. #657 by @AaronZ345 — adds the BuildRichCard infrastructure
  2. #774 — adds the StatusFooterSender / StatusFooterUpdater interfaces and the structured footer composition
  3. #795 — fixes 4 invalid icon tokens + width_mode in feat(display): [mode=rich] opt-in Card 2.0 single-card turn + reply elapsed time footer #657's fallback config

The branch is layered: pr-657-base#795 → merge #774 → cardkit-v1 typewriter + Phase B integration. Once the upstream chain merges in order, the diff here will become a clean ~10-commit set covering only the typewriter + integration work.

Recommended merge order: #657#795#774 → this PR.

Closes #774 because this PR refactors the footer rendering to flow through BuildRichCard's new statusFooter string parameter (方案 B), which makes #774's StatusFooterSender.SendWithStatusFooter / StatusFooterUpdater.UpdateMessageWithStatusFooter interfaces compose cleanly with the rich-card path. After this PR merges, #774's standalone footer impl is no longer needed.

What this PR adds

1. cardkit-v1 typewriter streaming (Phase A.5)

Rich-mode card body now updates character-by-character via Lark's cardkit-v1 element content API:

POST /open-apis/cardkit/v1/cards         → returns card_id
POST /open-apis/im/v1/messages           → message references the card_id
PUT  /open-apis/cardkit/v1/cards/{id}/elements/main_text/content

The third call is what produces the typewriter feel — Lark's renderer animates the body text glyph-by-glyph as content updates. Previously rich-mode bodies refreshed via Im.Message.Patch with full-card JSON, which silently no-ops on entity-bound messages and doesn't produce the streaming animation.

New helpers in platform/feishu/feishu.go:

  • createCardEntity — POST /cardkit/v1/cards
  • streamRichCardText — PUT element content with monotonic sequence counter
  • updateCardEntity — PUT full card body for status flips (Pondering → Done)

New core interface RichCardTextStreamer (in core/streaming.go) lets the engine call into the platform's per-element text streamer without coupling.

2. Reply-API mirror

@-mention triggers go through Im.Message.Reply which originally took inline card JSON and disabled the typewriter. Reply now also accepts the card_id reference, enabling typewriter on @-mention triggers too.

3. Forced final flush (Path C)

EventResult performs a forced streamRichCardText final flush before flipping the card status to "Done". This catches the last <200ms / <20 chars of body text that the throttle skipped, keeping the typewriter rendering smooth all the way to the final byte.

4. NO_REPLY + done-emoji handling in rich mode

  • NO_REPLY: rich card was being left in "Working" forever because sp.discard() only clears the streaming preview, not the rich card. Now cardMessageID is also deleted via PreviewCleaner.DeletePreviewMessage.
  • Done emoji: rich card's status header already flips to green "Done" on EventResult, so the legacy done emoji becomes redundant noise. Skipped in rich mode.

5. Phase B — statusFooter parameter on BuildRichCard

Refactors RichCardSupporter.BuildRichCard to accept a statusFooter string parameter (replacing #657's elapsed time.Duration). The footer is rendered inside the rich card as small/dim text_size: "notation" markdown blocks separated by <hr>, giving the equivalent visual treatment as #774's standalone footer block but unified into the card body.

3-line statusFooter:

elapsed: 12.3s
claude-opus-4-7[1m] · xhigh · out 168 · in 1 cw 971 cr 40.8k · ctx 4%
~/cc-workspaces/jeeves/occ

composeRichStatusFooter() in core/engine.go is the single composition point. Returns "" during streaming so the footer only appears on the final card.

6. card-entity full-card update via cardkit-v1 PUT

UpdateMessage and UpdateMessageWithStatusFooter now route to updateCardEntity (PUT /cardkit/v1/cards/{id}) when the handle has a cardID. Previously they tried Im.Message.Patch, which is silently no-op for card-entity messages — the symptom was the rich card status header staying stuck at "Pondering" instead of flipping to green "Done" on EventResult.

7. Skip legacy footer paths in rich mode

Rich mode renders footer/contextIndicator/workdir via composeRichStatusFooterBuildRichCard's statusFooter param. The legacy appendReplyFooter / contextIndicator += ... calls on cleanResponse are now gated on !hasRichCard to avoid double-printing model/ctx/workdir into the markdown body of every rich card.

8. Reset rich-mode per-turn state on queued-message dequeue

cardMessageID, toolSteps, partialText, lastRichCardUpdate, lastRichCardLen are all per-turn locals that need resetting alongside textParts etc. when a queued message is dequeued mid-loop. Otherwise EventText for the queued message's turn would StreamRichCardText against the previous turn's cardID, overwriting that card's body. This is the rich-mode-specific cousin of #793's replyCtx reset (same dequeue block, different leak vector).

9. Defensive negative-clamp on formatStatusTokenCount

Preserves the negative-input invariant from #774's formatStatusTokenCount after the helper was upgraded from 2-tier (#774, with clamp) to 3-tier (this PR, adding the 1.2M case). Tests in claude_status_footer_test.go exercise this.

Tests

  • TestFormatStatusTokenCount — passes
  • TestStreamPreview_FreezeBeforeFinish — adapted to 2-arg sp.finish signature
  • All feishu tests pass
  • Pre-existing failures on pr-2-typewriter-statusfooter's base (3 in core, 1 in codex) carry through from the upstream chain — none related to this PR

Verification

Deployed locally to a Lark instance for a week of dogfooding. Verified:

  • Typewriter animation on both Send (no @-mention) and Reply (@-mention) paths
  • Card status header correctly flips Pondering → Done on EventResult
  • Rich card properly cleaned up on NO_REPLY (no orphan Working cards)
  • statusFooter only appears on final card, not during streaming
  • No double-printed model/ctx/workdir in card body (rich mode skips legacy footer)

张彧 and others added 19 commits April 25, 2026 12:49
Based on upstream PR chenhg5#309 (Card 2.0 rich cards with collapsible panel and
streaming, which already contains PR chenhg5#306's header status colors). Resolves
15 conflict hunks by taking main as base and appending Card 2.0 renderer.

Changes:
- core/streaming.go: RichCardSupporter.BuildRichCard gains elapsed
  time.Duration parameter
- core/engine.go: hasRichCard path renders a single streaming card for the
  whole turn; non-RichCardSupporter platforms keep main's compact/legacy
  progress behavior unchanged
- platform/feishu/feishu.go: +~350 lines
  - collapsible tool-step panel with icons
  - thinking verb header, status-colored (blue/green/red)
  - streaming_mode card updates (1500ms / 30-char throttle via engine)
  - splitMarkdownByTables for long tables
  - SetPreviewStatus for header color patches
  - feishuPreviewHandle: +mu/status/lastContent
  - SendPreviewStart / UpdateMessage recognize pre-built card JSON
    (isCardJSON) to skip double-wrapping
- Timing footer: "⏱ 运行中 X 秒..." while streaming, "⏱ 用时 1 分 23 秒"
  on completion, appended as a separate div below main's reply footer
  (model · effort · workdir)

Scope: only platforms implementing RichCardSupporter (currently feishu)
get the new single-card experience. Other platforms unaffected.
The Card 2.0 rich card path used to bypass display.ThinkingMessages /
display.ToolMessages entirely, so /quiet had no effect on feishu: the
card still showed the Thinking... header and accumulated tool steps in
the collapsible panel.

Now:
- EventThinking: when display.ThinkingMessages is false, skip card
  creation. The card is created later by EventText (streaming markdown)
  or by EventResult (final completed card).
- EventToolUse: when display.ToolMessages is false, skip toolSteps
  append and card create/update. Final card from EventResult has
  empty toolSteps, so buildRichCard renders markdown-only (no panel).
…d 2.0

Gate the Card 2.0 hasRichCard path behind a config switch so each fork
user can pick between upstream behavior and the rich card experience
without recompiling.

- DisplayConfig.Mode (*string toml "mode") — "legacy" (default) or "rich"
- core.DisplayCfg.Mode string propagated via SetDisplayConfig
- engine.go: force hasRichCard=false when mode != "rich", so existing
  Card 2.0 branches are only reached when explicitly enabled
- EffectiveDisplay returns the resolved mode; invalid values fall back
  to "legacy"
- config.toml (user) set mode = "rich" to keep current behavior active
When an agent makes many tool calls in one turn (e.g. 50+ MCP queries),
buildRichCard packed every step into the collapsible_panel.elements,
causing two problems:

1. Lark client renders huge collapsible panels poorly — the JSON view
   showed mangled fields where later body elements appeared spliced
   into the last panel step's text.content.
2. The card payload approached/exceeded Feishu's ~30KB interactive card
   limit, at which point the API rejected the card or the client
   rendered it as a JSON dump.

Two minimal mitigations:

- Cap panel rows at 30. Excess steps collapse into a single
  "… and N more steps" row. Tool execution itself is unaffected; only
  the panel preview is condensed.
- After json.Marshal, if the card exceeds 28000 bytes, fall back to
  buildCardJSONWithStatus (markdown body only, no panel). Preserves the
  agent's reply text and status header even on degenerate turns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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
GetDisabledCommands() returns map keys, whose iteration order is
randomized by Go. The assertion expected the input order [new, delete]
but got [delete, new] ~50% of runs. Sort before comparing.
…card fallback

Per Lark official icon library (https://open.feishu.cn/document/feishu-cards/enumerations-for-icons),
4 standard_icon tokens used in chenhg5#657's toolIconMap are not in the official enumeration:
  - terminal-two_outlined (Bash) -> code_outlined
  - file-open_outlined (Read) -> file-link-text_outlined
  - notes_outlined (Write) -> richtext_outlined
  - folder-open_outlined (Glob) -> creat-folder_outlined ("creat" is the official spelling)

Without this fix Lark client renders default placeholder icons or empty icon slots.

Also migrate fallback card config 'wide_screen_mode: true' (schema 1.0)
to 'width_mode: "default"' (schema 2.0) for self-consistency since the
card declares schema: "2.0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	core/engine.go
#	core/streaming_test.go
… (Phase A.5)

B018 Phase A.5 — implements true Lark client-side typewriter rendering for the
markdown body of chenhg5#657 Card 2.0 rich cards. Replaces chenhg5#657's full-card replace
on every EventText with per-element streaming via cardkit-v1.

Three-step send flow (replaces single-step Im.Message.Create):
  1. POST /open-apis/cardkit/v1/cards { type: card_json, data: <cardJSON> }
     -> card_id (numeric string, 14-day TTL)
  2. POST /open-apis/im/v1/messages with content
     {"type":"card","data":{"card_id":"..."}}
     -> message_id
  3. PUT /open-apis/cardkit/v1/cards/{card_id}/elements/main_text/content
     { content: <fullText>, sequence: <monotonic int> }  on each EventText

All HTTP calls use the lark Go SDK generic client.Post/Put with
larkcore.AccessTokenTypeTenant — no raw HTTP, no manual token cache, no
hardcoded domain (SDK auto-handles lark international vs feishu domestic).

Concurrency: feishuPreviewHandle.mu protects the sequence counter through HTTP
completion. Per-card serialization keeps sequence monotonic regardless of
concurrent EventText goroutines. Lark's 50 QPS per-element rate limit is well
above this throttle.

Throttle: cardkit-v1 path uses 200ms / 20 chars (5 updates/sec, smooth
typewriter); fallback to full-card Patch keeps original 1500ms / 30 chars
since Im.Message.Patch is heavier.

Fallback chain (turn never gets stuck):
  - Create Card Entity fails  -> SendPreviewStart falls through to inline
                                  card JSON (= original chenhg5#657 path); handle
                                  cardID stays empty
  - StreamRichCardText returns ErrNotSupported (no cardID) or any error
                              -> engine routes EventText to full-card Patch
                                 via UpdateMessage; cardID kept for next try
  - Reply API path (thread)   -> skips cardkit-v1 entirely (Reply doesn't
                                  document card_id reference support)

Schema validated end-to-end via lark-cli before commit:
  POST /cardkit/v1/cards               -> { code: 0, data.card_id: "..." }
  POST /im/v1/messages with type=card  -> { code: 0, data.message_id: "..." }
  PUT  .../elements/main_text/content  -> { code: 0 }

Files:
  core/streaming.go          + RichCardTextStreamer optional interface
  core/engine.go             EventText: prefer streamer; fallback to full Patch
                             on ErrNotSupported / error; new 200ms throttle
                             when streamer present, else 1500ms
  platform/feishu/feishu.go  + richCardMainTextElementID = "main_text"
                             buildRichCard: markdown element gets element_id
                             feishuPreviewHandle: + cardID, sequence (mu-guarded)
                             SendPreviewStart: two-step flow with fallback
                             + createCardEntity helper
                             + StreamRichCardText (impls RichCardTextStreamer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…abling typewriter on every @-mention)

Phase A.5 v1 conservatively skipped cardkit-v1 in Im.Message.Reply path, fearing
the Reply API might not accept the {type:card,data:{card_id}} content schema
(Lark docs only document it for Im.Message.Create). But shouldUseThreadOrReplyAPI()
returns true on every @-mention turn (rc.messageID != "" + reply-to-trigger
default), which is the dominant CCC usage. So cardkit-v1 streaming was effectively
DEAD on every actual user turn — the engine kept falling through to full-card
Patch (= original chenhg5#657 behavior, no typewriter).

Verified by direct Lark API call:
  POST /open-apis/im/v1/messages/{message_id}/reply
    body: {msg_type: interactive,
           content: '{"type":"card","data":{"card_id":"..."}}'}
  -> { code: 0, data.message_id: "..." }

Reply API DOES accept the same card_id reference content schema. Fix:
unconditionally try createCardEntity for rich card sends; let cardkit-v1
streaming flow through both Reply and Create paths.

Also bumped the create-card-entity-failure log from Debug to Info so we
can spot fallbacks at the default INFO log level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ath C)

In the rich path, EventText drives streamRichCardText through a 200ms / 20-char
throttle. EventResult arrives at turn end and currently jumps straight to
UpdateMessage(finalCard) which rewrites the body and flips status to Done in
one full-card Patch. If the throttle dropped the last <20 chars or last <200ms
of text, the user sees a small "jump-to-final" before the status changes.

This commit inserts a forced StreamRichCardText(parts[0]) before the final
UpdateMessage so the trailing chars typewriter in smoothly first; then the
UpdateMessage just flips status (text already matches). Errors here are
silent (Debug log) — the subsequent UpdateMessage rewrites the body anyway,
so a failure of the catch-up flush degrades to current behavior.

Covers audit Path C identified during Phase A.5 verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ch mode

Two related rich-mode bugs surfaced after Phase A.5 deployment.

1. NO_REPLY + rich mode -> card stuck in 'Working' forever

The rich path tracks the in-flight card via cardMessageID, an engine-local
handle independent of sp.previewMsgID (the streamPreview's own state). The
isSilent branch in EventResult only called sp.discard(), which deletes
sp.previewMsgID -- but in rich mode that handle is usually nil because the
card was created via starter.SendPreviewStart directly, not via the sp
pipeline. Result: the rich card was never deleted or transitioned to Done,
leaving a 'Working' (or 'Pondering') indicator visible to the user even
though the agent had decided to suppress its reply.

Fix: in the isSilent branch, also delete cardMessageID via PreviewCleaner.
NO_REPLY now truly leaves no visible trace.

2. Done emoji is noise in rich mode

The done reaction (green checkmark) was meant to signal 'agent finished' on
platforms where the streaming preview update was silent (Lark in-place
UpdateMessage doesn't ping the user, but the visible result is the same
text). In rich mode the card's header template flips from blue 'Working'
to green 'Done' on EventResult, which is a far more visible 'done' signal
than a separate emoji on the trigger message. The emoji becomes redundant
visual clutter on every turn.

Fix: skip AddDoneReaction when hasRichCard is true. Legacy mode behavior
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
When the engine drains pendingMessages after EventResult, it does an in-place
'continue' on the same processInteractiveEvents loop iteration to handle the
next turn without spawning a fresh goroutine. The reset block already cleared
textParts / segmentStart / toolCount / turnStart / sp / cp, but missed the
rich-mode locals (cardMessageID, toolSteps, partialText, lastRichCardUpdate,
lastRichCardLen).

Concrete failure CC observed:
  1. user sends msg1 -> rich card1 created with cardID1, partialText accumulates
  2. user sends msg2 while msg1 still streaming (queued via TryLock fail)
  3. msg1 EventResult fires -> queued msg2 dequeued -> 'continue' to next loop
  4. EventText for msg2 hits 'cardMessageID != nil' branch -> StreamRichCardText
     pushes msg2's text into card1's main_text element via cardID1
  5. msg1's card now visually shows msg2's reply; msg2 never gets its own card

Fix: nil out cardMessageID + reset the throttle bookkeeping + clear partialText
+ clear toolSteps in the queued-message reset block, mirroring what sp/cp
already do.

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>
…tadata only)

CC observation: in v24 the footer renders from the very first EventThinking
event onward, leading to:
  * "⏱ 运行中 X.X 秒..." line bouncing every throttle tick (visual noise)
  * Token counts (out/in/cw/cr) showing intermediate per-sub-call snapshots
    that are misleading until the turn aggregates the result.
  * workdir static line displayed before the card has any actual content.

Footer is supposed to represent finalized turn metadata — show it only on
EventResult, not during streaming. The header status badge ('Pondering...'
blue → 'Done' green) is already an unambiguous in-progress vs done signal.

Implementation: composeRichStatusFooter returns '' when streaming=true, so
all in-flight BuildRichCard calls produce a card with body+panel only.
At EventResult (streaming=false) the footer appears in one go alongside
the status flip to Done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into card body)

CC observed an extra line appearing in rich-mode card bodies right before the
intended statusFooter:

    claude-opus-4-7[1m] · 剩余 5% · ~/cc-workspaces/jeeves/occ

Root cause: Phase B added composeRichStatusFooter -> BuildRichCard's
statusFooter parameter, but the EventResult-side legacy paths
(contextIndicator suffix + appendReplyFooter) were left running
unconditionally, so every rich card's markdown body got the legacy
single-line footer baked in *before* BuildRichCard rendered the proper
3-line dim footer below the body. End result: model/ctx/workdir printed
twice — once mid-content (legacy), once in the actual footer (rich).

Fix: gate both legacy paths on '!hasRichCard'. Rich mode owns its own footer
exclusively through BuildRichCard; legacy mode behavior unchanged.

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.
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 (Pending CI + Dependencies)

Large PR (+2295/-162, 20 files) completing the rich-mode story. Depends on #657, #774, #795 — CI not started yet on any of them.


What I've reviewed so far:

  1. Claude session enhancements:

    • activeModel atomic store for model id from init event
    • lastUsage with per-sub-call snapshot policy (input/cache from last assistant event, output from result event)
    • claudeContextWindow() helper for 200k vs 1M detection
    • GetModel(), GetWorkDir(), GetContextUsage() accessors
  2. Test coverage:

    • TestHandleAssistantCapturesPerSubCallUsage thoroughly validates the split-source usage policy
  3. Event extensions:

    • CacheCreationInputTokens, CacheReadInputTokens fields on core.Event

Not yet reviewed (due to size):

  • Full feishu cardkit-v1 typewriter implementation
  • Rich-card statusFooter integration
  • Engine processInteractiveEvents changes

Will continue review and approve once:

  1. Dependency PRs (#657, #774, #795) pass CI and are merged
  2. This PR's CI passes

@AaronZ345
Copy link
Copy Markdown
Contributor

Quick update from the #657 side — cherry-picked this PR + #795, #728, #793 onto our local Card 2.0 base, running for a day now.

The cardkit-v1 typewriter genuinely makes Card 2.0 feel finished. Paired with the collapsible tool panel + structured statusFooter, rich-mode clicks in a way the old inline [ctx: ~N%] never did. The cache-read line in particular makes it immediately obvious when a turn blew the cache vs hit it.

#728 peer_bots solved a real pain on our side (5 cc-connect agents sharing one group; bare "Bot" was making the model conflate its own past replies). #793 fixes the thread-quote drift on queued messages.

Thanks for the careful chain — the merge-order labels on each PR made the cherry-pick painless.

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

HOLD for now. I do not see an obvious additional code-level blocker in the rich-card/typewriter/statusFooter direction, but this PR is not merge-ready yet.

Blocking merge conditions:

  1. CI is not available on the current head: GitHub reports no checks for this branch, so build/test are not verified.
  2. Dependency chain is still unresolved: #657 and #795 are still open/unmerged, and #795 currently has requested changes.
  3. GitHub mergeability is still UNKNOWN, so we cannot confirm the branch is conflict-free against current main.

Code review notes:

  • The rich mode remains opt-in via display mode, which limits compatibility risk for existing users.
  • The core/platform boundary looks reasonable: cardkit streaming is exposed via optional interfaces instead of coupling core to Feishu.
  • The added tests cover footer composition, Claude usage accounting, and Feishu card JSON shape. Please ensure full repo CI runs cleanly after the upstream dependency chain is resolved/rebased.

Recommendation: keep this on hold until #657/#795 are resolved, mergeability is known, and CI passes on the final rebased head.

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.

3 participants