Show accurate desktop context usage in composer

Provider model context windows now feed the composer-level context indicator, so users can see current usage without opening slash panels. The indicator refreshes when the session runtime model changes, keeps a stable loading placeholder, and marks estimate fallback data during reconnect or compaction windows.

Constraint: The desktop UI must use the real session inspection API and session-scoped runtime selection instead of mocked or static context values.
Rejected: Keep context details only in /context or /status | the user explicitly wanted an always-visible composer indicator.
Rejected: Reuse stale context after model switches | different provider windows make identical percentages misleading.
Confidence: high
Scope-risk: moderate
Directive: Do not remove the runtimeSelectionKey refresh path without real multi-provider browser verification.
Tested: cd desktop && bun run test -- pages.test.tsx
Tested: bun run check:desktop
Tested: KEEP_ARTIFACTS=1 desktop/scripts/e2e-context-usage-live-agent-browser.sh
Not-tested: Live browser E2E with every configured provider in the committed script; separate agent-browser sweep verified four providers locally.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-04 21:07:01 +08:00
parent 0a82efdcd9
commit 59436abf09
7 changed files with 889 additions and 5 deletions

View File

@ -0,0 +1,257 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DESKTOP_DIR="$ROOT_DIR/desktop"
if ! command -v agent-browser >/dev/null 2>&1; then
echo "agent-browser is required but was not found in PATH" >&2
exit 1
fi
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
API_PORT="${PORTS%% *}"
WEB_PORT="${PORTS##* }"
BASE_URL="http://127.0.0.1:${API_PORT}"
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
RUN_ID="$(date +%s)-$RANDOM"
SESSION_NAME="cc-haha-context-live-${RUN_ID}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$(mktemp -d "/tmp/cc-haha-context-live-${RUN_ID}-XXXX")}"
PROJECT_DIR="${ARTIFACT_DIR}/project"
SERVER_LOG="${ARTIFACT_DIR}/server.log"
WEB_LOG="${ARTIFACT_DIR}/web.log"
BROWSER_LOG="${ARTIFACT_DIR}/browser.log"
RESULT_FILE="${PROJECT_DIR}/context-live-result.txt"
cleanup() {
local exit_code=$?
if [[ -n "${PREVIOUS_PERMISSION_MODE:-}" ]]; then
curl -fsS -X PUT "${BASE_URL}/api/permissions/mode" \
-H 'Content-Type: application/json' \
-d "{\"mode\":\"${PREVIOUS_PERMISSION_MODE}\"}" >/dev/null 2>&1 || true
fi
if [[ -n "${SESSION_ID:-}" && "${KEEP_SESSION:-0}" != "1" ]]; then
curl -fsS -X DELETE "${BASE_URL}/api/sessions/${SESSION_ID}" >/dev/null 2>&1 || true
fi
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
if [[ -n "${SERVER_PID:-}" ]]; then
kill "${SERVER_PID}" >/dev/null 2>&1 || true
wait "${SERVER_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${WEB_PID:-}" ]]; then
kill "${WEB_PID}" >/dev/null 2>&1 || true
wait "${WEB_PID}" >/dev/null 2>&1 || true
fi
if [[ $exit_code -ne 0 || "${KEEP_ARTIFACTS:-0}" == "1" ]]; then
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
else
rm -rf "${ARTIFACT_DIR}"
fi
}
trap cleanup EXIT
mkdir -p "${PROJECT_DIR}"
cat > "${PROJECT_DIR}/README.md" <<'EOF'
# Context Usage Live E2E
This throwaway project is used by a real-model desktop WebUI test.
EOF
echo "Starting backend on ${BASE_URL}"
(
cd "${ROOT_DIR}"
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}"
) >"${SERVER_LOG}" 2>&1 &
SERVER_PID=$!
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
(
cd "${DESKTOP_DIR}"
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" --strictPort
) >"${WEB_LOG}" 2>&1 &
WEB_PID=$!
wait_for_http() {
local url="$1"
for _ in $(seq 1 120); do
if curl --max-time 2 -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
echo "Timed out waiting for ${url}" >&2
return 1
}
fetch_json_field() {
local url="$1"
local expression="$2"
curl -fsS "$url" | bun -e "const input = await Bun.stdin.text(); const data = JSON.parse(input); ${expression}"
}
AB=(agent-browser --session "${SESSION_NAME}")
browser_text() {
"${AB[@]}" get text body 2>>"${BROWSER_LOG}"
}
wait_for_text() {
local needle="$1"
local attempts="${2:-120}"
for _ in $(seq 1 "${attempts}"); do
if browser_text | grep -Fq "$needle"; then
return 0
fi
sleep 1
done
echo "Timed out waiting for page text: ${needle}" >&2
return 1
}
session_contains() {
local needle="$1"
curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" | NEEDLE="${needle}" bun -e '
const input = await Bun.stdin.text()
const data = JSON.parse(input)
const haystack = JSON.stringify(data)
process.exit(haystack.includes(process.env.NEEDLE ?? "") ? 0 : 1)
'
}
wait_for_session_text() {
local needle="$1"
local attempts="${2:-180}"
for _ in $(seq 1 "${attempts}"); do
if session_contains "${needle}"; then
return 0
fi
sleep 1
done
echo "Timed out waiting for transcript text: ${needle}" >&2
return 1
}
context_label() {
"${AB[@]}" eval "document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]')?.getAttribute('aria-label') || ''" 2>>"${BROWSER_LOG}" || true
}
wait_for_context_indicator() {
for _ in $(seq 1 120); do
if context_label | grep -Fq "Context usage"; then
return 0
fi
sleep 1
done
echo "Timed out waiting for context usage indicator" >&2
return 1
}
show_context_details() {
"${AB[@]}" eval "const el = document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]'); if (el) { el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.focus(); }" >>"${BROWSER_LOG}" 2>&1 || true
}
wait_for_context_window_text() {
for _ in $(seq 1 80); do
show_context_details
if browser_text | grep -Fq "Window"; then
return 0
fi
sleep 0.5
done
echo "Timed out waiting for context usage detail popover" >&2
return 1
}
wait_for_result_file() {
for _ in $(seq 1 240); do
if [[ -f "${RESULT_FILE}" ]] && grep -Fq "context-live-ok" "${RESULT_FILE}"; then
return 0
fi
sleep 2
done
echo "Timed out waiting for real model tool execution" >&2
return 1
}
wait_for_compact_boundary() {
for _ in $(seq 1 240); do
if session_contains "compact_boundary" || session_contains "Context compacted" || session_contains "Compacted"; then
return 0
fi
sleep 2
done
echo "Timed out waiting for real /compact boundary" >&2
return 1
}
wait_for_http "${BASE_URL}/health"
wait_for_http "http://127.0.0.1:${WEB_PORT}"
PREVIOUS_PERMISSION_MODE="$(fetch_json_field "${BASE_URL}/api/permissions/mode" 'console.log(data.mode)')"
curl -fsS -X PUT "${BASE_URL}/api/permissions/mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"bypassPermissions"}' >/dev/null
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
-H 'Content-Type: application/json' \
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
UNIQUE_TITLE="Context Live ${RUN_ID}"
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
CURRENT_MODEL="$(fetch_json_field "${BASE_URL}/api/models/current" 'console.log(data.model?.name || data.model?.id || "current")')"
TAB_STATE="{\"openTabs\":[{\"sessionId\":\"${SESSION_ID}\",\"title\":\"${UNIQUE_TITLE}\",\"type\":\"session\"}],\"activeTabId\":\"${SESSION_ID}\"}"
"${AB[@]}" open "${WEB_URL}" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" wait 1200 >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" eval "localStorage.setItem('cc-haha-locale', 'en'); localStorage.setItem('cc-haha-open-tabs', '${TAB_STATE}'); location.reload();" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" wait 1800 >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" fill '#sidebar-search' "${UNIQUE_TITLE}" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" eval "Array.from(document.querySelectorAll('button')).find((el) => el.textContent?.includes('Context Live'))?.click()" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" wait 1200 >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" wait 'textarea' >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-session-open.png" >/dev/null
wait_for_context_indicator
show_context_details
wait_for_context_window_text
"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-initial-real-context.png" >/dev/null
PROMPT="Use Bash to run exactly: sleep 18 && printf context-live-ok > context-live-result.txt . After the command finishes, reply exactly CONTEXT_LIVE_DONE. Do not edit any other file."
"${AB[@]}" fill 'textarea' "${PROMPT}" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" press 'Enter' >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" wait 3500 >>"${BROWSER_LOG}" 2>&1
wait_for_context_indicator
show_context_details
wait_for_context_window_text
"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-running-real-model-context.png" >/dev/null
wait_for_result_file
wait_for_session_text "CONTEXT_LIVE_DONE" 180
"${AB[@]}" wait 1000 >>"${BROWSER_LOG}" 2>&1
show_context_details
"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-after-real-model-turn-context.png" >/dev/null
COMPACT_PROMPT="/compact Keep a concise summary that mentions context-live-ok and the Bash command result."
"${AB[@]}" fill 'textarea' "${COMPACT_PROMPT}" >>"${BROWSER_LOG}" 2>&1
"${AB[@]}" press 'Enter' >>"${BROWSER_LOG}" 2>&1
wait_for_compact_boundary
show_context_details
wait_for_context_window_text
"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-after-real-compact-context.png" >/dev/null
INSPECTION_SUMMARY="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/inspection?includeContext=1" | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); const ctx = data.context || data.contextEstimate; console.log(JSON.stringify({ model: ctx?.model, totalTokens: ctx?.totalTokens, rawMaxTokens: ctx?.rawMaxTokens, percentage: ctx?.percentage, categories: ctx?.categories?.slice(0, 5)?.map((c) => ({ name: c.name, tokens: c.tokens })) }, null, 2));')"
echo "Context usage live WebUI E2E passed"
echo "Model: ${CURRENT_MODEL}"
echo "Session: ${SESSION_ID}"
echo "API port: ${API_PORT}"
echo "Web port: ${WEB_PORT}"
echo "Artifacts: ${ARTIFACT_DIR}"
echo "Inspection:"
echo "${INSPECTION_SUMMARY}"

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { skillsApi } from '../api/skills'
@ -78,10 +78,12 @@ import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
import { useTabStore } from '../stores/tabStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSessionRuntimeStore.setState({ selections: {} })
})
/**
@ -601,6 +603,337 @@ describe('Content-only pages render without errors', () => {
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows live context usage near the composer', async () => {
const SESSION_ID = 'context-indicator-session'
vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({
active: true,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
model: 'kimi-k2.6',
},
context: {
categories: [
{ name: 'Messages', tokens: 42_000, color: '#2D628F' },
{ name: 'Tools', tokens: 8_000, color: '#8F482F' },
{ name: 'Free space', tokens: 70_000, color: '#9B928C' },
],
totalTokens: 50_000,
maxTokens: 120_000,
rawMaxTokens: 120_000,
percentage: 42,
gridRows: [],
model: 'kimi-k2.6',
memoryFiles: [],
mcpTools: [],
agents: [],
},
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [{ id: 'm-1', type: 'assistant_text', content: 'done', timestamp: Date.now() }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 50_000, output_tokens: 1_000 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(await screen.findByLabelText('Context usage 42%')).toBeInTheDocument()
expect(screen.getByText('120,000')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, {
includeContext: true,
timeout: 45_000,
})
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession keeps a stable context placeholder while context usage loads', async () => {
const SESSION_ID = 'context-loading-session'
vi.mocked(sessionsApi.getInspection).mockImplementationOnce(() => new Promise(() => {}))
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
const indicator = await screen.findByLabelText('Context usage loading')
expect(indicator).toHaveTextContent('--')
expect(indicator).toHaveClass('h-8')
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows context estimate during compaction or reconnect fallback', async () => {
const SESSION_ID = 'context-estimate-session'
vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({
active: false,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
model: 'deepseek-v4-pro',
},
contextEstimate: {
categories: [
{ name: 'Messages', tokens: 72_000, color: '#2D628F' },
{ name: 'Autocompact buffer', tokens: 24_000, color: '#9B928C', isDeferred: true },
],
totalTokens: 72_000,
maxTokens: 1_000_000,
rawMaxTokens: 1_000_000,
percentage: 7,
gridRows: [],
model: 'deepseek-v4-pro',
memoryFiles: [],
mcpTools: [],
agents: [],
},
errors: {
context: 'CLI session is not running',
},
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [
{ id: 'm-1', type: 'system', content: 'Context compacted', timestamp: Date.now() },
{ id: 'm-2', type: 'assistant_text', content: 'ready', timestamp: Date.now() },
],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 72_000, output_tokens: 2_000 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(await screen.findByLabelText('Context usage 7%')).toBeInTheDocument()
expect(screen.getByText('deepseek-v4-pro')).toBeInTheDocument()
expect(screen.getByText('1,000,000')).toBeInTheDocument()
expect(screen.getByText('Estimate')).toBeInTheDocument()
expect(screen.queryByText('Autocompact buffer')).not.toBeInTheDocument()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession refreshes context usage when the selected runtime model changes', async () => {
const SESSION_ID = 'context-runtime-refresh-session'
vi.mocked(sessionsApi.getInspection)
.mockResolvedValueOnce({
active: true,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
model: 'kimi-k2.6',
},
context: {
categories: [{ name: 'Messages', tokens: 26_000, color: '#2D628F' }],
totalTokens: 26_000,
maxTokens: 262_144,
rawMaxTokens: 262_144,
percentage: 10,
gridRows: [],
model: 'kimi-k2.6',
memoryFiles: [],
mcpTools: [],
agents: [],
},
})
.mockResolvedValueOnce({
active: true,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
model: 'glm-4.5-air',
},
context: {
categories: [{ name: 'Messages', tokens: 26_000, color: '#2D628F' }],
totalTokens: 26_000,
maxTokens: 128_000,
rawMaxTokens: 128_000,
percentage: 20,
gridRows: [],
model: 'glm-4.5-air',
memoryFiles: [],
mcpTools: [],
agents: [],
},
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [{ id: 'm-1', type: 'assistant_text', content: 'done', timestamp: Date.now() }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 26_000, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const inspectionCallsBeforeRender = vi.mocked(sessionsApi.getInspection).mock.calls.length
render(<ActiveSession />)
expect(await screen.findByLabelText('Context usage 10%')).toBeInTheDocument()
useSessionRuntimeStore.getState().setSelection(SESSION_ID, {
providerId: 'zhipu-provider',
modelId: 'glm-4.5-air',
})
expect(await screen.findByLabelText('Context usage 20%')).toBeInTheDocument()
await waitFor(() => {
expect(vi.mocked(sessionsApi.getInspection).mock.calls.length - inspectionCallsBeforeRender)
.toBeGreaterThanOrEqual(2)
})
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
useSessionRuntimeStore.setState({ selections: {} })
})
it('AgentTeams renders team strip and members', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')

View File

@ -20,6 +20,7 @@ import { ProjectContextChip } from '../shared/ProjectContextChip'
import { DirectoryPicker } from '../shared/DirectoryPicker'
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
import { ContextUsageIndicator } from './ContextUsageIndicator'
import {
FALLBACK_SLASH_COMMANDS,
findSlashTrigger,
@ -89,6 +90,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
const messageCount = sessionState?.messages?.length ?? 0
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
const runtimeSelectionKey = runtimeSelection
? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}`
: undefined
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
@ -812,6 +820,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
</div>
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && activeTabId && (
<ContextUsageIndicator
sessionId={activeTabId}
chatState={chatState}
messageCount={messageCount}
runtimeSelectionKey={runtimeSelectionKey}
compact={compact}
/>
)}
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={compact} />
)}

View File

@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
import { useTranslation } from '../../i18n'
import type { ChatState } from '../../types/chat'
type Props = {
sessionId: string
chatState: ChatState
messageCount: number
runtimeSelectionKey?: string
compact?: boolean
}
const ACTIVE_REFRESH_MS = 30_000
function formatNumber(value: number | undefined) {
return new Intl.NumberFormat().format(value ?? 0)
}
function formatPercent(value: number | undefined) {
const percent = Math.max(0, Math.min(100, value ?? 0))
return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%`
}
function formatUpdatedAt(timestamp: number | null, t: ReturnType<typeof useTranslation>) {
if (!timestamp) return t('contextIndicator.updatedUnknown')
const elapsedMs = Date.now() - timestamp
if (elapsedMs < 60_000) return t('contextIndicator.updatedNow')
const minutes = Math.max(1, Math.floor(elapsedMs / 60_000))
return t('contextIndicator.updatedMinutes', { count: minutes })
}
function pickUsedContextCategory(context: SessionContextSnapshot) {
const ignored = new Set(['free space', 'autocompact buffer'])
return context.categories
.filter((category) => category.tokens > 0 && !category.isDeferred && !ignored.has(category.name.toLowerCase()))
.sort((a, b) => b.tokens - a.tokens)
.slice(0, 4)
}
export function ContextUsageIndicator({
sessionId,
chatState,
messageCount,
runtimeSelectionKey = '',
compact = false,
}: Props) {
const t = useTranslation()
const [context, setContext] = useState<SessionContextSnapshot | null>(null)
const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
const requestSeq = useRef(0)
const contextIdentityRef = useRef('')
const refresh = useCallback(async () => {
if (!sessionId) return
const seq = requestSeq.current + 1
requestSeq.current = seq
setLoading(true)
setError(null)
try {
const inspection = await sessionsApi.getInspection(sessionId, {
includeContext: true,
timeout: 45_000,
})
if (seq !== requestSeq.current) return
const nextContext = inspection.context ?? inspection.contextEstimate ?? null
const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null
setContext(nextContext)
setContextSource(nextSource)
setError(nextContext ? null : inspection.errors?.context ?? null)
setUpdatedAt(Date.now())
} catch (err) {
if (seq !== requestSeq.current) return
setError(err instanceof Error ? err.message : String(err))
} finally {
if (seq === requestSeq.current) setLoading(false)
}
}, [sessionId])
useEffect(() => {
const contextIdentity = `${sessionId}:${runtimeSelectionKey}`
const identityChanged = contextIdentityRef.current !== contextIdentity
contextIdentityRef.current = contextIdentity
if (identityChanged) {
setContext(null)
setContextSource(null)
setError(null)
setUpdatedAt(null)
}
void refresh()
if (!identityChanged) return
const retryTimer = setTimeout(() => {
void refresh()
}, 2_500)
return () => clearTimeout(retryTimer)
}, [messageCount, refresh, runtimeSelectionKey, sessionId])
useEffect(() => {
if (chatState === 'idle') return
const timer = setInterval(() => {
void refresh()
}, ACTIVE_REFRESH_MS)
return () => clearInterval(timer)
}, [chatState, messageCount, refresh])
const details = useMemo(() => {
if (!context) return []
return pickUsedContextCategory(context)
}, [context])
const percentage = context ? Math.max(0, Math.min(100, context.percentage)) : 0
const usedTokens = context?.totalTokens ?? 0
const maxTokens = context?.rawMaxTokens ?? 0
const freeTokens = Math.max(0, maxTokens - usedTokens)
const strokeColor = percentage >= 90
? 'var(--color-error)'
: percentage >= 75
? 'var(--color-warning)'
: 'var(--color-secondary)'
const ringStyle = {
background: context
? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)`
: 'var(--color-surface-container-high)',
}
const displayPercent = context ? formatPercent(percentage) : '--'
const ariaLabel = context
? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) })
: loading
? t('contextIndicator.loadingAria')
: t('contextIndicator.unavailableAria')
return (
<div className="group/context relative pointer-events-auto">
<button
type="button"
aria-label={ariaLabel}
onClick={() => { void refresh() }}
title={t('contextIndicator.title')}
data-testid="context-usage-indicator"
className={`flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-lowest)] ${
compact ? 'px-2' : 'px-2.5'
}`}
>
<span
className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full"
style={ringStyle}
>
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
{loading && !context ? (
<span className="material-symbols-outlined relative animate-spin text-[13px]">progress_activity</span>
) : (
<span
className="relative h-[5px] w-[5px] rounded-full"
style={{ backgroundColor: context ? strokeColor : 'var(--color-text-tertiary)' }}
/>
)}
</span>
<span className="font-mono text-[11px] font-semibold tabular-nums">
{displayPercent}
</span>
</button>
<div className="pointer-events-none absolute bottom-full right-0 z-40 mb-2 w-[320px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left opacity-0 shadow-[var(--shadow-dropdown)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('contextIndicator.title')}
</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{context?.model ?? t('contextIndicator.modelUnknown')}
</div>
</div>
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
{context ? formatPercent(percentage) : '--'}
</div>
</div>
{context ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3 font-mono text-xs">
<div>
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
</div>
<div>
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
</div>
<div className="col-span-2">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(maxTokens)}</div>
</div>
</div>
{details.length > 0 && (
<div className="mt-4 space-y-2">
{details.map((category) => {
const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0
return (
<div key={category.name}>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
</div>
</div>
)
})}
</div>
)}
<div className="mt-4 text-[11px] text-[var(--color-text-tertiary)]">
{formatUpdatedAt(updatedAt, t)}
{contextSource === 'estimate' && (
<span className="ml-2 inline-flex rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]">
{t('contextIndicator.estimate')}
</span>
)}
</div>
</>
) : (
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{loading ? t('contextIndicator.loading') : error ?? t('contextIndicator.unavailableDetail')}
</div>
)}
</div>
</div>
)
}

View File

@ -297,7 +297,14 @@ function UsageTab({
{models.map((model) => (
<div key={model.model} className="border-t border-[var(--color-inspector-border)] first:border-t-0">
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3">
<div className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-inspector-text)]">{model.displayName || model.model}</div>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-[var(--color-inspector-text)]">{model.displayName || model.model}</div>
{(model.contextWindow > 0 || context?.rawMaxTokens) && (
<div className="mt-1 truncate text-[11px] text-[var(--color-inspector-muted)]">
{t('slash.inspector.status.contextWindow')}: {formatNumber(model.contextWindow || context?.rawMaxTokens)}
</div>
)}
</div>
<div className="text-right text-[12px] font-semibold uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.tokens')}</div>
</div>
<div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3 last:border-b-0">
@ -518,7 +525,9 @@ function StatusTab({
}) {
const mcpServers = Array.isArray(data.status.mcpServers) ? data.status.mcpServers : []
const tools = Array.isArray(data.status.tools) ? data.status.tools : []
const model = data.status.model ?? data.context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown')
const context = data.context ?? data.contextEstimate
const model = data.status.model ?? context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown')
const contextWindow = context?.rawMaxTokens ?? data.usage?.models?.find((entry) => entry.contextWindow > 0)?.contextWindow
const slashCommandCount = (data.status.slashCommandCount ?? 0) > 0
? data.status.slashCommandCount
: commands?.length ?? 0
@ -536,7 +545,11 @@ function StatusTab({
</span>
)}
/>
<MetricCard label={t('slash.inspector.status.activeModel')} value={model} />
<MetricCard
label={t('slash.inspector.status.activeModel')}
value={model}
detail={contextWindow ? `${t('slash.inspector.status.contextWindow')}: ${formatNumber(contextWindow)}` : undefined}
/>
<MetricCard
label={t('slash.inspector.status.mcpConnections')}
value={(
@ -744,7 +757,7 @@ function SessionInspectorPanel({
) : data === null ? (
<LoadingState label={t('slash.inspector.loading')} />
) : selectedTab === 'usage' ? (
<UsageTab usage={data.usage} context={data.context} error={data.errors?.usage} t={t} />
<UsageTab usage={data.usage} context={data.context ?? data.contextEstimate} error={data.errors?.usage} t={t} />
) : selectedTab === 'context' ? (
<ContextTab
context={data.context ?? data.contextEstimate}

View File

@ -720,6 +720,7 @@ export const en = {
'slash.inspector.status.default': 'Default',
'slash.inspector.status.mcpServers': 'MCP servers',
'slash.inspector.status.refresh': 'Refresh',
'slash.inspector.status.contextWindow': 'Context window',
'slash.inspector.usage.emptyTitle': 'No usage data yet',
'slash.inspector.usage.emptyBody': 'Usage appears after the CLI session starts and completes at least one turn.',
'slash.inspector.usage.contextSnapshotNotice': 'Showing token usage from the current context snapshot. Usage updates dynamically as context is fetched.',
@ -755,6 +756,21 @@ export const en = {
'slash.inspector.context.categoryTitle': 'Estimated usage by category',
'slash.inspector.context.noCategoriesTitle': 'No context categories',
'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.',
'contextIndicator.ariaLabel': 'Context usage {percent}',
'contextIndicator.loadingAria': 'Context usage loading',
'contextIndicator.unavailableAria': 'Context usage unavailable',
'contextIndicator.title': 'Context',
'contextIndicator.modelUnknown': 'Unknown model',
'contextIndicator.used': 'Used',
'contextIndicator.free': 'Free',
'contextIndicator.window': 'Window',
'contextIndicator.loading': 'Loading context usage...',
'contextIndicator.unavailable': 'n/a',
'contextIndicator.unavailableDetail': 'Context usage is unavailable for this session.',
'contextIndicator.updatedUnknown': 'Not refreshed yet',
'contextIndicator.updatedNow': 'Updated just now',
'contextIndicator.updatedMinutes': 'Updated {count}m ago',
'contextIndicator.estimate': 'Estimate',
'chat.navigate': 'navigate',
'chat.select': 'select',
'chat.dismiss': 'dismiss',

View File

@ -722,6 +722,7 @@ export const zh: Record<TranslationKey, string> = {
'slash.inspector.status.default': '默认',
'slash.inspector.status.mcpServers': 'MCP 服务',
'slash.inspector.status.refresh': '刷新',
'slash.inspector.status.contextWindow': '上下文窗口',
'slash.inspector.usage.emptyTitle': '暂无用量数据',
'slash.inspector.usage.emptyBody': 'CLI 会话启动并完成至少一轮回复后,这里会显示用量。',
'slash.inspector.usage.contextSnapshotNotice': '当前展示来自上下文快照的 token 用量。上下文数据刷新后会同步更新。',
@ -757,6 +758,21 @@ export const zh: Record<TranslationKey, string> = {
'slash.inspector.context.categoryTitle': '按类别估算',
'slash.inspector.context.noCategoriesTitle': '暂无上下文分类',
'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。',
'contextIndicator.ariaLabel': '上下文用量 {percent}',
'contextIndicator.loadingAria': '上下文用量加载中',
'contextIndicator.unavailableAria': '上下文用量不可用',
'contextIndicator.title': '上下文',
'contextIndicator.modelUnknown': '未知模型',
'contextIndicator.used': '已使用',
'contextIndicator.free': '剩余',
'contextIndicator.window': '窗口',
'contextIndicator.loading': '正在加载上下文用量...',
'contextIndicator.unavailable': '不可用',
'contextIndicator.unavailableDetail': '此会话暂时无法获取上下文用量。',
'contextIndicator.updatedUnknown': '尚未刷新',
'contextIndicator.updatedNow': '刚刚更新',
'contextIndicator.updatedMinutes': '{count} 分钟前更新',
'contextIndicator.estimate': '估算',
'chat.navigate': '导航',
'chat.select': '选择',
'chat.dismiss': '关闭',