logancyang_obsidian-copilot/src/agentMode/session/AgentChatUIState.ts
Zero Liu 29254f6f16
feat(agent-mode): per-session token/context meter in the composer (#2654)
* feat(agent-mode): capture per-session token usage (context meter data)

Normalize what each coding-agent backend already reports into one
backend-agnostic SessionUsage, land it on AgentSession, expose it via
getSessionUsage(), and persist the latest snapshot in chat frontmatter so a
resumed session shows it immediately. No UI yet (Phase 2).

- session/types: SessionUsage + usage_update SessionUpdate variant
- sdk translator: emit usage from the Claude SDK result (used = input +
  cache_read + cache_creation + output; window = max modelUsage.contextWindow)
- acp/wireTranslate: map ACP usage_update (size/used/cost) to the domain
- acp/AcpBackendProcess: used-only prompt-result fallback, gated so it never
  clobbers a live occupancy figure (ACP totalTokens is cumulative, not in-context)
- AgentSession: store/notify usage, precedence carries a known window forward,
  seed on load; AgentSessionManager threads persistence load/save
- AgentChatPersistenceManager: round-trip usage as frontmatter JSON

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): context-window meter in the composer

A circular meter beside the composer's context glyph, filled to the % of the
agent session's context window used; accent normally, warning color past 85%.
Click opens a popover with used/total tokens, an input·output·cache breakdown,
and estimated session cost. Falls back to the legacy count-only chip when a
backend reports usage but no window, and renders nothing before the first turn.

- useSessionUsage: subscribe to the backend's live SessionUsage
- AgentContextMeter: SVG ring + popover + TokenCounter fallback; owns its
  leading separator so it hides cleanly; guards non-finite values
- thread an optional usageIndicator slot through ChatInput → ContextControl →
  ChatContextMenu (pure pass-through; legacy simple chat unaffected)
- AgentHome mounts the meter for all agent sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): move context meter into the New Chat / History control row

Relocate the meter from the composer's context bar into AgentChatControls,
left of the New Chat button — the agent-mode analog of where the legacy
TokenCounter sat, matching the original intent. The ring trigger is now a
plain icon button (no inline % text; the percentage stays in the popover).

As a result the meter no longer needs the shared composer plumbing: the
optional usageIndicator slot threaded through ChatInput → ContextControl →
ChatContextMenu is fully removed, so those shared components are byte-identical
to before this feature and the legacy simple chat is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): horizontal context-window bar in the meter popover

Revamp the popover to a cleaner, less technical layout: a "Context window"
label with `used / total (percent)` and the estimated cost on the same line
(cost at the right edge), above a horizontal progress bar (reusing the shared
Progress primitive). Tokens now format with one decimal + k/M (e.g.
248.0k / 1.0M). Drops the input/output/cache breakdown. The compact ring stays
as the control-row trigger; the warning color still shows on the ring and the
percentage past 85%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(agent-mode): keep the context-window label on one line

Use the smaller ui font token for the popover header and widen it (w-72 → w-80)
with a non-wrapping label, so "Context window" no longer breaks onto two lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): open the context meter popover on hover

Drive the popover's open state from hover/focus instead of click, with a short
close delay and content-hover retention so moving onto the card doesn't dismiss
it. Auto-focus is suppressed so opening on hover never steals focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-mode): drive context meter from occupancy, not cumulative usage

The Claude SDK result message's `usage` sums every API call in a turn (tool
loops re-read the whole context from cache each iteration), so dividing it
by the window pegged the meter after tool-heavy turns even when the live
context still fit. Source occupancy from the last top-level assistant
message's own per-call usage instead, and pair it with that model's window —
prefix-matching the bare model id ("claude-opus-4-8") against the suffixed
`modelUsage` key ("claude-opus-4-8[1m]"), never `Math.max` across models.

Also: a windowless snapshot (ACP's cumulative prompt-result fallback) no
longer borrows a prior window to render a bogus percentage ring; it stays
count-only until a live occupancy update supersedes it.

Verified against runtime frame logs: a num_turns:10 Claude result summed
~646k tokens while real per-call occupancy was ~108k; opencode/codex report
occupancy directly via usage_update (this path was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): use Tooltip for the context ring, drop manual popover timer

The ring meter opened its stats card via a Popover with a hand-rolled hover
state and a setTimeout close delay (to bridge trigger→content). Replace it
with the shared Radix Tooltip, which handles hover/focus open-close and
hoverable content natively — removing the open state, close timer, and the
onMouse/onFocus/onBlur handlers. Relies on the TooltipProvider already at the
chat-view root, alongside the sibling control buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): drop session cost from the context meter, show usage only

Remove `costUsd` from `SessionUsage` and everywhere that fed it — the Claude
SDK `total_cost_usd` mapping, the ACP `usage_update` `cost.amount` mapping, and
the meter's cost label + `formatUsd`. The meter now focuses solely on context
occupancy (used / window and the % ring). This also moots the ACP cost-currency
concern (raised in review), since no cost is surfaced at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:36:11 -07:00

191 lines
5.8 KiB
TypeScript

import { logError, logWarn } from "@/logger";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import type {
AgentChatMessage,
AgentQuestionAnswers,
AgentTodoListEntry,
AskUserQuestionPrompt,
BackendId,
BackendState,
CurrentPlan,
PermissionPrompt,
PlanDecisionAction,
PromptContent,
SessionUsage,
} from "@/agentMode/session/types";
import type { MessageContext } from "@/types/message";
/**
* `AgentChatBackend` implementation backed by an `AgentSession`. The Agent
* Mode UI tree consumes this exclusively — it knows nothing about the legacy
* `ChatUIState` / `ChatManager` stack.
*
* Edit, regenerate, and persistence operations are intentionally absent —
* they don't have ACP semantics and Agent Mode chat persistence is deferred.
*/
export class AgentChatUIState implements AgentChatBackend {
private listeners = new Set<() => void>();
constructor(private readonly session: AgentSession) {
// Forward message, status, and model changes. The chat UI gates the
// send button on `isStarting()`, so it needs to re-render when status
// transitions out of `"starting"`.
this.session.subscribe({
onMessagesChanged: () => this.notifyListeners(),
onStatusChanged: () => this.notifyListeners(),
onModelChanged: () => this.notifyListeners(),
onCurrentPlanChanged: () => this.notifyListeners(),
onCurrentTodoListChanged: () => this.notifyListeners(),
});
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(): void {
for (const l of this.listeners) {
try {
l();
} catch (e) {
logWarn("[AgentChatUIState] listener threw", e);
}
}
}
/**
* Append a user message and kick off the ACP turn. Returns the new user
* message id synchronously plus a `turn` promise the caller can await for
* loading-state lifecycle (Stop button, input lock).
*/
sendMessage(
text: string,
context?: MessageContext,
promptContent?: PromptContent[],
mentionedAgents?: ReadonlyArray<BackendId>
): { id: string; turn: Promise<void> } {
const { userMessageId, turn } = this.session.sendPrompt(
text,
context,
promptContent,
mentionedAgents
);
this.notifyListeners();
const wrapped = turn.then(
() => undefined,
(err) => {
logError("[AgentMode] turn failed", err);
}
);
return { id: userMessageId, turn: wrapped };
}
async cancel(): Promise<void> {
await this.session.cancel();
}
async deleteMessage(id: string): Promise<boolean> {
// Refuse delete during an in-flight turn: the placeholder assistant
// message is what streaming notifications target, and removing it would
// leave the session writing into a vanished id.
const status = this.session.getStatus();
if (status === "running" || status === "awaiting_permission") {
logWarn("[AgentChatUIState] delete refused while turn is in flight");
return false;
}
const ok = this.session.store.deleteMessage(id);
if (ok) this.notifyListeners();
return ok;
}
clearMessages(): void {
this.session.store.clear();
this.notifyListeners();
}
getMessages(): AgentChatMessage[] {
return this.session.store.getDisplayMessages();
}
isStarting(): boolean {
return this.session.getStatus() === "starting";
}
getBackendState(): BackendState | null {
return this.session.getState();
}
canSwitchModel(): boolean | null {
return this.session.canSwitchModel();
}
canSwitchEffort(): boolean | null {
return this.session.canSwitchEffort();
}
canSwitchMode(): boolean | null {
return this.session.canSwitchMode();
}
hasPendingPlanPermission(): boolean {
return this.session.hasPendingPlanPermission();
}
getPendingToolPermissions(): PermissionPrompt[] {
return this.session.getPendingToolPermissions();
}
resolveToolPermission(toolCallId: string, optionId: string): void {
this.session.resolveToolPermission(toolCallId, optionId);
this.notifyListeners();
}
getPendingAskUserQuestions(): AskUserQuestionPrompt[] {
return this.session.getPendingAskUserQuestions();
}
resolveAskUserQuestion(requestId: string, answers: AgentQuestionAnswers): void {
this.session.resolveAskUserQuestion(requestId, answers);
this.notifyListeners();
}
getCurrentPlan(): CurrentPlan | null {
return this.session.getCurrentPlan();
}
getCurrentTodoList(): AgentTodoListEntry[] | null {
return this.session.getCurrentTodoList();
}
getSessionUsage(): SessionUsage | null {
return this.session.getSessionUsage();
}
async resolvePlanProposal(
proposalId: string,
decision: PlanDecisionAction,
feedbackText?: string
): Promise<void> {
const plan = this.session.getCurrentPlan();
if (!plan || plan.id !== proposalId || plan.decision !== "pending") return;
if (!plan.permissionGated || !plan.pendingToolCallId) {
logWarn("[AgentChatUIState] non-gated plan card has no resolution path");
return;
}
const trimmedFeedback = decision === "feedback" ? feedbackText?.trim() : undefined;
// Resolve the underlying ACP permission. Approve unblocks the agent
// and continues the same turn; reject denies with `"User declined"`;
// feedback rides the typed text through the same deny `message` so
// the agent revises in-turn instead of receiving a separate
// follow-up prompt.
this.session.resolvePlanProposalPermission(
plan.pendingToolCallId,
decision === "approve",
trimmedFeedback
);
this.session.finalizePlanDecision(plan.id);
this.notifyListeners();
}
}