mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: surface ACP model and token info in status bar and chat input (#53)
* feat: wire ACP token/context info into status bar and chat input Surface model name and context window usage in the UI: - Redesign StatusBar with Bot icon and model name display - Add contextLimit to TokenState for context window tracking - Wire tokenState from chatStore through ChatView to ChatInput - Add acp:usage_update event listener in useAcpStream (ready for backend plumbing) The ContextRing in ChatInputToolbar will activate once the backend emits acp:usage_update events with context window size data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: show truncated session ID next to copy button in status bar The previous change hid the session ID text and only showed the copy icon. Restore the first 8 characters of the session ID as visible text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove always-red status dot from StatusBar The status dot was permanently red because `setConnected(true)` is never called anywhere in the codebase. Remove the dead code: the dot, the statusColor map, the status prop, and the connectionStatus derivation in AppShell. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use npx for biome in justfile fmt-check recipe The bare `biome` command fails when invoked from lefthook's pre-push hook because node_modules/.bin isn't on PATH. Use `npx biome` to match the pattern used in pre-commit hooks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: respect direct accumulatedTotal in updateTokenState reducer The reducer always recomputed accumulatedTotal as accumulatedInput + accumulatedOutput, silently dropping any directly-passed value. This meant acp:usage_update events (which only provide a total used count) were ignored, keeping the context ring at 0. Now partial.accumulatedTotal takes precedence when provided, falling back to the input + output sum otherwise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,9 +134,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
: undefined;
|
||||
const modelName = activeSession?.modelName;
|
||||
const tokenCount = chatStore.tokenState.totalTokens;
|
||||
const connectionStatus = chatStore.isConnected
|
||||
? ("connected" as const)
|
||||
: ("disconnected" as const);
|
||||
|
||||
const [pendingInitialMessage, setPendingInitialMessage] = useState<
|
||||
string | undefined
|
||||
@@ -507,7 +504,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
modelName={modelName}
|
||||
sessionId={activeTabId ?? undefined}
|
||||
tokenCount={tokenCount}
|
||||
status={connectionStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ interface AcpModelStatePayload {
|
||||
currentModelName?: string;
|
||||
}
|
||||
|
||||
interface AcpUsageUpdatePayload {
|
||||
sessionId: string;
|
||||
used: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function findLatestUnpairedToolRequest(
|
||||
content: MessageContent[],
|
||||
): ToolRequestContent | null {
|
||||
@@ -219,6 +225,18 @@ export function useAcpStream(sessionId: string, enabled: boolean): void {
|
||||
}),
|
||||
);
|
||||
|
||||
// acp:usage_update — update context window token usage
|
||||
unlisteners.push(
|
||||
listen<AcpUsageUpdatePayload>("acp:usage_update", (event) => {
|
||||
if (!active) return;
|
||||
if (event.payload.sessionId !== sessionIdRef.current) return;
|
||||
useChatStore.getState().updateTokenState({
|
||||
accumulatedTotal: event.payload.used,
|
||||
contextLimit: event.payload.size,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Cleanup: mark inactive immediately to prevent stale listeners from
|
||||
// firing during the async unlisten, then tear down actual subscriptions.
|
||||
return () => {
|
||||
|
||||
@@ -206,22 +206,23 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const current = state.tokenState;
|
||||
const inputTokens = partial.inputTokens ?? current.inputTokens;
|
||||
const outputTokens = partial.outputTokens ?? current.outputTokens;
|
||||
const accumulatedInput =
|
||||
partial.accumulatedInput ??
|
||||
current.accumulatedInput + (partial.inputTokens ?? 0);
|
||||
const accumulatedOutput =
|
||||
partial.accumulatedOutput ??
|
||||
current.accumulatedOutput + (partial.outputTokens ?? 0);
|
||||
const accumulatedTotal =
|
||||
partial.accumulatedTotal ?? accumulatedInput + accumulatedOutput;
|
||||
return {
|
||||
tokenState: {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
accumulatedInput:
|
||||
partial.accumulatedInput ??
|
||||
current.accumulatedInput + (partial.inputTokens ?? 0),
|
||||
accumulatedOutput:
|
||||
partial.accumulatedOutput ??
|
||||
current.accumulatedOutput + (partial.outputTokens ?? 0),
|
||||
accumulatedTotal:
|
||||
(partial.accumulatedInput ??
|
||||
current.accumulatedInput + (partial.inputTokens ?? 0)) +
|
||||
(partial.accumulatedOutput ??
|
||||
current.accumulatedOutput + (partial.outputTokens ?? 0)),
|
||||
accumulatedInput,
|
||||
accumulatedOutput,
|
||||
accumulatedTotal,
|
||||
contextLimit: partial.contextLimit ?? current.contextLimit,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -243,6 +243,7 @@ export function ChatView({
|
||||
|
||||
// Wrap sendMessage to handle @ mentioned persona overrides
|
||||
const chatStore = useChatStore();
|
||||
const tokenState = useChatStore((s) => s.tokenState);
|
||||
const handleSend = useCallback(
|
||||
(text: string, personaId?: string) => {
|
||||
if (personaId && personaId !== selectedPersonaId) {
|
||||
@@ -362,6 +363,8 @@ export function ChatView({
|
||||
},
|
||||
})
|
||||
}
|
||||
contextTokens={tokenState.accumulatedTotal}
|
||||
contextLimit={tokenState.contextLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { Bot, Copy, Check } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
interface StatusBarProps {
|
||||
modelName?: string;
|
||||
sessionId?: string;
|
||||
tokenCount?: number;
|
||||
status?: "connected" | "disconnected" | "loading";
|
||||
}
|
||||
|
||||
const statusColor = {
|
||||
connected: "bg-green-500",
|
||||
disconnected: "bg-red-500",
|
||||
loading: "bg-yellow-500",
|
||||
} as const;
|
||||
|
||||
export function StatusBar({
|
||||
modelName,
|
||||
sessionId,
|
||||
tokenCount = 0,
|
||||
status = "disconnected",
|
||||
}: StatusBarProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -37,28 +29,32 @@ export function StatusBar({
|
||||
"bg-background/80 px-3 text-xs text-foreground-secondary",
|
||||
)}
|
||||
>
|
||||
{modelName ? <span>{modelName}</span> : <span />}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Bot className="h-3.5 w-3.5 flex-shrink-0 text-foreground-tertiary" />
|
||||
<span className="truncate text-foreground-secondary">
|
||||
{modelName ?? "No model"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-foreground-tertiary">{sessionId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySessionId}
|
||||
className="rounded p-0.5 text-foreground-tertiary hover:text-foreground-primary transition-colors"
|
||||
aria-label={copied ? "Copied" : "Copy session ID"}
|
||||
>
|
||||
{copied ? <Check size={10} /> : <Copy size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySessionId}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-foreground-tertiary hover:text-foreground-secondary transition-colors"
|
||||
title={`Session: ${sessionId}`}
|
||||
>
|
||||
<span className="font-mono">{sessionId.slice(0, 8)}</span>
|
||||
{copied ? <Check size={10} /> : <Copy size={10} />}
|
||||
</button>
|
||||
)}
|
||||
{tokenCount > 0 && (
|
||||
<span className="text-foreground-tertiary">
|
||||
{tokenCount.toLocaleString()} tokens
|
||||
</span>
|
||||
)}
|
||||
{tokenCount > 0 && <span>{tokenCount.toLocaleString()} tokens</span>}
|
||||
<div
|
||||
role="status"
|
||||
aria-label={status}
|
||||
className={cn("h-1.5 w-1.5 rounded-full", statusColor[status])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TokenState {
|
||||
accumulatedInput: number;
|
||||
accumulatedOutput: number;
|
||||
accumulatedTotal: number;
|
||||
contextLimit: number;
|
||||
}
|
||||
|
||||
export const INITIAL_TOKEN_STATE: TokenState = {
|
||||
@@ -27,6 +28,7 @@ export const INITIAL_TOKEN_STATE: TokenState = {
|
||||
accumulatedInput: 0,
|
||||
accumulatedOutput: 0,
|
||||
accumulatedTotal: 0,
|
||||
contextLimit: 0,
|
||||
};
|
||||
|
||||
// Session
|
||||
|
||||
Reference in New Issue
Block a user