mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
0a82efdcd9
commit
59436abf09
257
desktop/scripts/e2e-context-usage-live-agent-browser.sh
Executable file
257
desktop/scripts/e2e-context-usage-live-agent-browser.sh
Executable 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}"
|
||||
@ -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')
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
232
desktop/src/components/chat/ContextUsageIndicator.tsx
Normal file
232
desktop/src/components/chat/ContextUsageIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user