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:
Wes
2026-04-01 11:12:04 -07:00
committed by GitHub
parent 10632df518
commit 396ab4154b
6 changed files with 58 additions and 42 deletions
-4
View File
@@ -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 () => {
+12 -11
View File
@@ -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>
);
+23 -27
View File
@@ -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>
);
+2
View File
@@ -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