mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* 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>
191 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
}
|