diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 390def1..dd23ecf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -751,6 +751,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" name = "codeg" version = "0.0.16" dependencies = [ + "agent-client-protocol-schema", "base64 0.22.1", "bzip2", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8909ab8..7d397b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ sea-orm-migration = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio toml = "0.8" notify = "6" base64 = "0.22" +agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_usage"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = "2" diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 3d1f1ea..8bcca6f 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1653,6 +1653,10 @@ fn emit_conversation_update( update: SessionUpdate, ) { match update { + SessionUpdate::UserMessageChunk(_) => { + // User echo chunks are informational for transcript sync and + // currently not rendered in live ACP UI. + } SessionUpdate::AgentMessageChunk(ContentChunk { content: ContentBlock::Text(text), .. @@ -1665,6 +1669,9 @@ fn emit_conversation_update( }, ); } + SessionUpdate::AgentMessageChunk(_) => { + // Non-text chunks are currently not surfaced in live streaming UI. + } SessionUpdate::AgentThoughtChunk(ContentChunk { content: ContentBlock::Text(text), .. @@ -1677,6 +1684,9 @@ fn emit_conversation_update( }, ); } + SessionUpdate::AgentThoughtChunk(_) => { + // Non-text thought chunks are currently ignored. + } SessionUpdate::ToolCall(tc) => { let content = serialize_tool_call_content(&tc.content); let raw_input = json_value_to_text(&tc.raw_input); @@ -1762,6 +1772,16 @@ fn emit_conversation_update( }, ); } + SessionUpdate::UsageUpdate(update) => { + let _ = app_handle.emit( + "acp://event", + AcpEvent::UsageUpdate { + connection_id: connection_id.into(), + used: update.used, + size: update.size, + }, + ); + } other => { // Log unhandled update types for debugging eprintln!("[ACP] Unhandled SessionUpdate: {:?}", other); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 197b6ae..c791ed8 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -129,6 +129,12 @@ pub enum AcpEvent { connection_id: String, commands: Vec, }, + /// Session usage/context window updated during conversation + UsageUpdate { + connection_id: String, + used: u64, + size: u64, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/components/layout/status-bar-tokens.tsx b/src/components/layout/status-bar-tokens.tsx index 466e948..df6b7c7 100644 --- a/src/components/layout/status-bar-tokens.tsx +++ b/src/components/layout/status-bar-tokens.tsx @@ -1,8 +1,10 @@ "use client" +import { useCallback, useSyncExternalStore } from "react" import { Coins } from "lucide-react" import { useTranslations } from "next-intl" import { useSessionStats } from "@/contexts/session-stats-context" +import { useConnectionStore } from "@/contexts/acp-connections-context" import { Popover, PopoverContent, @@ -31,13 +33,49 @@ function formatPercent(percent: number | null): string { export function StatusBarTokens() { const t = useTranslations("Folder.statusBar.tokens") + const store = useConnectionStore() const { sessionStats } = useSessionStats() const usage = sessionStats?.total_usage - const contextUsed = sessionStats?.context_window_used_tokens ?? null - const contextMax = sessionStats?.context_window_max_tokens ?? null + const subscribeActiveKey = useCallback( + (cb: () => void) => store.subscribeActiveKey(cb), + [store] + ) + const getActiveKey = useCallback(() => store.getActiveKey(), [store]) + const activeKey = useSyncExternalStore( + subscribeActiveKey, + getActiveKey, + getActiveKey + ) + + const subscribeConn = useCallback( + (cb: () => void) => { + if (!activeKey) return () => {} + return store.subscribeKey(activeKey, cb) + }, + [store, activeKey] + ) + const getConnSnapshot = useCallback( + () => (activeKey ? store.getConnection(activeKey) : undefined), + [store, activeKey] + ) + const activeConn = useSyncExternalStore( + subscribeConn, + getConnSnapshot, + getConnSnapshot + ) + + const liveContextUsed = activeConn?.usage?.used ?? null + const liveContextMax = activeConn?.usage?.size ?? null + + const contextUsed = + liveContextUsed ?? sessionStats?.context_window_used_tokens ?? null + const contextMax = + liveContextMax ?? sessionStats?.context_window_max_tokens ?? null const contextPercentRaw = - sessionStats?.context_window_usage_percent ?? + (liveContextUsed != null && liveContextMax != null && liveContextMax > 0 + ? (liveContextUsed / liveContextMax) * 100 + : sessionStats?.context_window_usage_percent) ?? (contextUsed != null && contextMax != null && contextMax > 0 ? (contextUsed / contextMax) * 100 : null) diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 4f10c13..03ed93b 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -33,6 +33,7 @@ import type { PermissionOptionInfo, SessionConfigOptionInfo, SessionModeStateInfo, + SessionUsageUpdateInfo, FixAction, PromptCapabilitiesInfo, PromptInputBlock, @@ -88,6 +89,7 @@ export interface ConnectionState { modes: SessionModeStateInfo | null configOptions: SessionConfigOptionInfo[] | null availableCommands: AvailableCommandInfo[] | null + usage: SessionUsageUpdateInfo | null liveMessage: LiveMessage | null pendingPermission: PendingPermission | null error: string | null @@ -177,6 +179,11 @@ type Action = contextKey: string commands: AvailableCommandInfo[] } + | { + type: "USAGE_UPDATE" + contextKey: string + usage: SessionUsageUpdateInfo + } type StreamingAction = | { type: "CONTENT_DELTA"; contextKey: string; text: string } @@ -441,6 +448,7 @@ function connectionsReducer( modes: null, configOptions: null, availableCommands: null, + usage: null, liveMessage: null, pendingPermission: null, error: null, @@ -883,6 +891,23 @@ function connectionsReducer( return next } + case "USAGE_UPDATE": { + const conn = state.get(action.contextKey) + if (!conn) return state + if ( + conn.usage?.used === action.usage.used && + conn.usage?.size === action.usage.size + ) { + return state + } + const next = new Map(state) + next.set(action.contextKey, { + ...conn, + usage: action.usage, + }) + return next + } + default: return state } @@ -1395,6 +1420,17 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { commands: e.commands, }) break + case "usage_update": + flushStreamingQueue() + dispatch({ + type: "USAGE_UPDATE", + contextKey, + usage: { + used: e.used, + size: e.size, + }, + }) + break } }, [dispatch, enqueueStreamingAction, flushStreamingQueue, t] diff --git a/src/lib/types.ts b/src/lib/types.ts index bfea806..c3b1f86 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -402,6 +402,11 @@ export interface AvailableCommandInfo { input_hint?: string | null } +export interface SessionUsageUpdateInfo { + used: number + size: number +} + // ACP events pushed from Rust backend (discriminated by "type" field) export type AcpEvent = | { type: "content_delta"; connection_id: string; text: string } @@ -481,6 +486,12 @@ export type AcpEvent = connection_id: string commands: AvailableCommandInfo[] } + | { + type: "usage_update" + connection_id: string + used: number + size: number + } // Connection info returned by acp_list_connections export interface ConnectionInfo {