feat(core+feishu): cardkit-v1 typewriter + rich-card statusFooter integration#796
feat(core+feishu): cardkit-v1 typewriter + rich-card statusFooter integration#796Cigarrr wants to merge 19 commits intochenhg5:mainfrom
Conversation
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.
chenhg5
left a comment
There was a problem hiding this comment.
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:
-
Claude session enhancements:
activeModelatomic store for model id from init eventlastUsagewith per-sub-call snapshot policy (input/cache from last assistant event, output from result event)claudeContextWindow()helper for 200k vs 1M detectionGetModel(),GetWorkDir(),GetContextUsage()accessors
-
Test coverage:
TestHandleAssistantCapturesPerSubCallUsagethoroughly validates the split-source usage policy
-
Event extensions:
CacheCreationInputTokens,CacheReadInputTokensfields oncore.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:
|
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 #728 Thanks for the careful chain — the merge-order labels on each PR made the cherry-pick painless. |
chenhg5
left a comment
There was a problem hiding this comment.
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:
- CI is not available on the current head: GitHub reports no checks for this branch, so build/test are not verified.
- Dependency chain is still unresolved: #657 and #795 are still open/unmerged, and #795 currently has requested changes.
- GitHub mergeability is still
UNKNOWN, so we cannot confirm the branch is conflict-free against currentmain.
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
coreto 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.
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:
BuildRichCardinfrastructureStatusFooterSender/StatusFooterUpdaterinterfaces and the structured footer compositionThe 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 newstatusFooter stringparameter (方案 B), which makes #774'sStatusFooterSender.SendWithStatusFooter/StatusFooterUpdater.UpdateMessageWithStatusFooterinterfaces 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:
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.Patchwith 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/cardsstreamRichCardText— PUT element content with monotonicsequencecounterupdateCardEntity— PUT full card body for status flips (Pondering → Done)New core interface
RichCardTextStreamer(incore/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.Replywhich originally took inline card JSON and disabled the typewriter. Reply now also accepts thecard_idreference, enabling typewriter on @-mention triggers too.3. Forced final flush (Path C)
EventResultperforms a forcedstreamRichCardTextfinal flush before flipping the card status to "Done". This catches the last<200ms / <20 charsof 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
sp.discard()only clears the streaming preview, not the rich card. NowcardMessageIDis also deleted viaPreviewCleaner.DeletePreviewMessage.EventResult, so the legacy done emoji becomes redundant noise. Skipped in rich mode.5. Phase B —
statusFooterparameter onBuildRichCardRefactors
RichCardSupporter.BuildRichCardto accept astatusFooter stringparameter (replacing #657'selapsed time.Duration). The footer is rendered inside the rich card as small/dimtext_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:
composeRichStatusFooter()incore/engine.gois 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
UpdateMessageandUpdateMessageWithStatusFooternow route toupdateCardEntity(PUT/cardkit/v1/cards/{id}) when the handle has acardID. Previously they triedIm.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" onEventResult.7. Skip legacy footer paths in rich mode
Rich mode renders footer/contextIndicator/workdir via
composeRichStatusFooter→BuildRichCard'sstatusFooterparam. The legacyappendReplyFooter/contextIndicator += ...calls oncleanResponseare now gated on!hasRichCardto 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,lastRichCardLenare all per-turn locals that need resetting alongsidetextPartsetc. when a queued message is dequeued mid-loop. OtherwiseEventTextfor the queued message's turn wouldStreamRichCardTextagainst the previous turn'scardID, overwriting that card's body. This is the rich-mode-specific cousin of #793'sreplyCtxreset (same dequeue block, different leak vector).9. Defensive negative-clamp on
formatStatusTokenCountPreserves the negative-input invariant from #774's
formatStatusTokenCountafter the helper was upgraded from 2-tier (#774, with clamp) to 3-tier (this PR, adding the1.2Mcase). Tests inclaude_status_footer_test.goexercise this.Tests
TestFormatStatusTokenCount— passesTestStreamPreview_FreezeBeforeFinish— adapted to 2-argsp.finishsignaturepr-2-typewriter-statusfooter's base (3 in core, 1 in codex) carry through from the upstream chain — none related to this PRVerification
Deployed locally to a Lark instance for a week of dogfooding. Verified:
EventResult