From 045bbee6ce4246f28549400195a841b4cfc5dfcc Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 8 Jul 2026 10:41:41 +0800 Subject: [PATCH 1/3] fix(web-shell): hide sidebar settings text when width is insufficient (#6494) Prevent the 'Settings' label from wrapping to a new line when the sidebar is narrow. Instead, the text is clipped via overflow:hidden and only the gear icon remains visible. --- .../client/components/sidebar/WebShellSidebar.module.css | 7 +++++++ .../client/components/sidebar/WebShellSidebar.tsx | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 4ffd4827dc..1380930c7d 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -945,6 +945,13 @@ .footerButton { flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.footerButtonLabel { + white-space: nowrap; + overflow: hidden; } .collapseButton { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index d3b2a83ed2..f3c93d5162 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -2517,7 +2517,11 @@ export function WebShellSidebar({ - {!collapsed && {t('sidebar.settings')}} + {!collapsed && ( + + {t('sidebar.settings')} + + )} {!collapsed && versionLabel && ( From 29cefd7fb1c43ded6c42c4d29b443e5d658294b9 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 8 Jul 2026 11:06:05 +0800 Subject: [PATCH 2/3] fix(web-shell): count daemon sessions in Daemon Status usage dashboard (#6493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web-shell): count daemon sessions in Daemon Status usage dashboard The Web Shell usage dashboard read usage_record.jsonl exclusively, but only the TUI /clear path ever writes that file — so daemon / Web Shell sessions and any un-cleared session (whose usage lives only in the per-session transcripts) were never counted. Real-world "today" totals could undercount ~20x. Add core loadUsageHistoryWithLive(): the durable persisted history unioned with a bounded replay of recent transcripts, deduped by sessionId (persisted wins, as the authoritative final snapshot). rebuildFromSessionJsonl gains an mtime window and a skip-set (read from each transcript's first line) so the merge is incremental and cheap. The daemon /usage/dashboard route and loadUsageDashboard now use it. The trailing window (35d) keeps the summary + daily charts exact while bounding load latency (~1.7s vs ~13s for a full-year replay on a heavy history); older heatmap cells fall back to persisted data. With no persisted base (fresh machine / pure Web Shell user) it replays the full history, so the heatmap is never silently truncated. Read-only: serving the dashboard never writes ~/.qwen. * fix(web-shell): address review — skip transcripts by filename, add coverage Skip already-persisted transcripts by their filename (`{sessionId}.jsonl`, guaranteed by chatRecordingService) instead of opening each file to read the sessionId from its first line — zero I/O per skipped session on a cache miss. Add tests: the loadUsageDashboard wiring counts a transcript-only (daemon) session, the rebuilt-empty (all-persisted) common case, and a corrupt usage_record.jsonl falling back to a full transcript replay. --- packages/cli/src/serve/routes/usage-stats.ts | 19 ++- .../services/usage-dashboard-service.test.ts | 83 ++++++++++ .../src/services/usage-dashboard-service.ts | 15 +- .../src/services/usageHistoryService.test.ts | 149 ++++++++++++++++++ .../core/src/services/usageHistoryService.ts | 141 ++++++++++++++++- 5 files changed, 385 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/serve/routes/usage-stats.ts b/packages/cli/src/serve/routes/usage-stats.ts index 68c29e67d2..e608087f74 100644 --- a/packages/cli/src/serve/routes/usage-stats.ts +++ b/packages/cli/src/serve/routes/usage-stats.ts @@ -8,16 +8,17 @@ * Read-only usage-dashboard surface behind the Web Shell Daemon Status * "统计 / Usage" tab. Serves the selected range's (`today`/`week`/`month`) * flattened token totals plus a trailing per-day heatmap, computed by core's - * `buildUsageDashboard` from the durable local usage history (global `~/.qwen`, - * cross-project) — the same source the TUI `/stats` command reads. The history - * load runs read-only (`persistRebuild: false`), so serving a GET never writes - * to `~/.qwen`, even when the persisted file is missing and the loader falls - * back to transcript replay. + * `buildUsageDashboard` from the local usage history (global `~/.qwen`, + * cross-project). Uses `loadUsageHistoryWithLive`, which unions the durable + * `usage_record.jsonl` (written only by the TUI `/clear` path) with a replay of + * recent transcripts — so daemon / Web Shell sessions and any in-progress + * session are counted here, unlike the TUI `/stats` view. The load is + * read-only (never writes `~/.qwen`). * * Open GET (no `mutate` gate), consistent with `GET /daemon/status` and * `GET /scheduled-tasks`: it exposes only aggregate local usage counts. * - * The heavy step — `loadUsageHistory` can replay every project's transcripts — + * The heavy step — `loadUsageHistoryWithLive` can replay recent transcripts — * is cached once (range-independent), so toggling Today/7D/30D re-aggregates * cheaply from a single disk read instead of re-loading per range, and * concurrent requests coalesce onto the in-flight load. @@ -26,7 +27,7 @@ import type { Application } from 'express'; import { buildUsageDashboard, - loadUsageHistory, + loadUsageHistoryWithLive, type UsageSummaryRecord, } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; @@ -67,9 +68,7 @@ export function registerUsageStatsRoutes( app: Application, deps: RegisterUsageStatsRoutesDeps = {}, ): void { - const loadHistory = - deps.loadHistory ?? - (() => loadUsageHistory(undefined, { persistRebuild: false })); + const loadHistory = deps.loadHistory ?? (() => loadUsageHistoryWithLive()); const ttlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; // Closure-scoped, range-independent history cache (one daemon per process). diff --git a/packages/core/src/services/usage-dashboard-service.test.ts b/packages/core/src/services/usage-dashboard-service.test.ts index 5c5017d8f5..d1c43afb5f 100644 --- a/packages/core/src/services/usage-dashboard-service.test.ts +++ b/packages/core/src/services/usage-dashboard-service.test.ts @@ -98,6 +98,89 @@ describe('loadUsageDashboard', () => { ); } + /** + * Plant a transcript-only (never-persisted, e.g. daemon / Web Shell) session + * timestamped now, so it lands in the `today` window when replayed. + */ + function plantTranscript(sessionId: string, totalTokens: number): void { + const cwd = '/daemon/project'; + const ts = new Date().toISOString(); + const chatsDir = path.join( + process.env['QWEN_HOME']!, + 'projects', + 'daemon-project', + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + const records = [ + { + sessionId, + cwd, + uuid: 'u1', + parentUuid: null, + timestamp: ts, + type: 'user', + message: { role: 'user', content: 'hi' }, + }, + { + sessionId, + cwd, + uuid: 'u2', + parentUuid: 'u1', + timestamp: ts, + type: 'system', + subtype: 'ui_telemetry', + systemPayload: { + uiEvent: { + 'event.name': 'qwen-code.api_response', + 'event.timestamp': ts, + response_id: 'r1', + model: 'qwen-max', + duration_ms: 1200, + input_token_count: totalTokens * 0.6, + output_token_count: totalTokens * 0.4, + cached_content_token_count: 0, + thoughts_token_count: 0, + total_token_count: totalTokens, + prompt_id: 'p1', + }, + }, + }, + { + sessionId, + cwd, + uuid: 'u3', + parentUuid: 'u2', + timestamp: ts, + type: 'assistant', + message: { role: 'assistant', content: 'ok' }, + }, + ]; + fs.writeFileSync( + path.join(chatsDir, `${sessionId}.jsonl`), + records.map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + } + + it('today totals include a daemon / Web Shell transcript session, not just the persisted file', async () => { + // Guards the wiring: loadUsageDashboard must read the live-merging loader, + // so a session that only lives in a transcript (never persisted by /clear) + // still counts. A revert to the persisted-only loader would drop it. + seed([ + rec({ + sessionId: 'persisted', + timestamp: Date.now(), + model: { totalTokens: 1000, inputTokens: 1000 }, + }), + ]); + plantTranscript('daemon-live', 1600); + + const dash = await loadUsageDashboard({ range: 'today' }); + + expect(dash.summary.sessions).toBe(2); + expect(dash.summary.totalTokens).toBe(2600); + }); + it('flattens today totals across all sessions active today', async () => { const now = Date.now(); seed([ diff --git a/packages/core/src/services/usage-dashboard-service.ts b/packages/core/src/services/usage-dashboard-service.ts index 47b7daca92..385b52894f 100644 --- a/packages/core/src/services/usage-dashboard-service.ts +++ b/packages/core/src/services/usage-dashboard-service.ts @@ -7,7 +7,7 @@ import { aggregateUsage, getTimeRangeBounds, - loadUsageHistory, + loadUsageHistoryWithLive, type TimeRange, type UsageSummaryRecord, } from './usageHistoryService.js'; @@ -275,14 +275,15 @@ export function buildUsageDashboard( /** * Read-only snapshot of local token usage for the daemon usage-dashboard API. - * Loads the global cross-project history (`~/.qwen`) via {@link loadUsageHistory} - * (persisted `usage_record.jsonl`, falling back to transcript replay) and builds - * the dashboard — consistent with the TUI `/stats` view. The load can be I/O - * heavy on large histories, so callers should cache (the daemon route caches the - * loaded records and re-runs {@link buildUsageDashboard} per range). + * Loads the global cross-project history (`~/.qwen`) via + * {@link loadUsageHistoryWithLive} — the persisted `usage_record.jsonl` unioned + * with a replay of recent transcripts — so the totals include daemon / Web Shell + * and in-progress sessions that the persisted file never captures. The load can + * be I/O heavy on large histories, so callers should cache (the daemon route + * caches the loaded records and re-runs {@link buildUsageDashboard} per range). */ export async function loadUsageDashboard( options: LoadUsageDashboardOptions = {}, ): Promise { - return buildUsageDashboard(await loadUsageHistory(), options); + return buildUsageDashboard(await loadUsageHistoryWithLive(), options); } diff --git a/packages/core/src/services/usageHistoryService.test.ts b/packages/core/src/services/usageHistoryService.test.ts index 3dd13e491c..01f22a9876 100644 --- a/packages/core/src/services/usageHistoryService.test.ts +++ b/packages/core/src/services/usageHistoryService.test.ts @@ -12,6 +12,7 @@ import { metricsToUsageRecord, aggregateUsage, loadUsageHistory, + loadUsageHistoryWithLive, persistSessionUsage, } from './usageHistoryService.js'; import { ToolCallDecision } from '../telemetry/tool-call-decision.js'; @@ -626,6 +627,154 @@ describe('loadUsageHistory + persistSessionUsage (issue #4994 regression)', () = for (const m of Object.values(report.models)) totalTokens += m.totalTokens; expect(totalTokens).toBe(1600); }); + + // loadUsageHistoryWithLive: the daemon usage-dashboard loader. Unlike + // loadUsageHistory (persisted file verbatim when non-empty), it unions the + // persisted history with a replay of recent transcripts so daemon / Web Shell + // and in-progress sessions — which only the TUI /clear path ever persists — + // are counted. See issue: Web Shell "today" undercounted ~20x. + function planted(sessionId: string): string { + return path.join( + process.env['QWEN_HOME']!, + 'projects', + 'repro-project', + 'chats', + `${sessionId}.jsonl`, + ); + } + function seedPersisted(records: UsageSummaryRecord[]) { + fs.writeFileSync( + path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'), + records.map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + } + function persistedRec(sessionId: string, totalTokens: number) { + return { + version: 1 as const, + sessionId, + timestamp: Date.now(), + startTime: Date.now() - 60000, + project: '/p', + durationMs: 60000, + totalLatencyMs: 1200, + models: { + 'qwen-max': { + requests: 1, + inputTokens: totalTokens, + outputTokens: 0, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens, + }, + }, + tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, byName: {} }, + files: { linesAdded: 0, linesRemoved: 0 }, + }; + } + + it('withLive: unions a never-persisted (daemon) session with the persisted history', async () => { + // Persisted: a finalized TUI session. Transcript-only: a daemon / Web Shell + // session that /clear never wrote to usage_record.jsonl. + seedPersisted([persistedRec('sess-persisted', 1000)]); + plantChatJsonl('sess-daemon', 1600); + + const merged = await loadUsageHistoryWithLive(); + const ids = merged.map((r) => r.sessionId).sort(); + expect(ids).toEqual(['sess-daemon', 'sess-persisted']); + + const report = aggregateUsage(merged, 'all'); + let totalTokens = 0; + for (const m of Object.values(report.models)) totalTokens += m.totalTokens; + // 1000 persisted + 1600 replayed from the daemon transcript. + expect(totalTokens).toBe(2600); + + // Read-only: the persisted file must still hold only the original record. + const lines = fs + .readFileSync( + path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'), + 'utf8', + ) + .trim() + .split('\n'); + expect(lines).toHaveLength(1); + }); + + it('withLive: a persisted session with a live transcript is not double-counted (persisted wins)', async () => { + // Same sessionId in both the persisted file (authoritative 1000) and the + // transcript (1600). Must appear exactly once, keeping the persisted value. + seedPersisted([persistedRec('sess-both', 1000)]); + plantChatJsonl('sess-both', 1600); + + const merged = await loadUsageHistoryWithLive(); + expect(merged).toHaveLength(1); + expect(merged[0]!.sessionId).toBe('sess-both'); + expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(1000); + }); + + it('withLive: with a persisted base, the trailing window excludes stale transcripts (sinceMs:0 includes them)', async () => { + // A persisted base means the window engages (old days come from the file). + seedPersisted([persistedRec('sess-persisted', 500)]); + plantChatJsonl('sess-old', 1600); + // Age the never-persisted transcript well past the default trailing window. + const stale = Date.now() - 100 * 24 * 60 * 60 * 1000; + fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000); + + // Default window: the stale, never-persisted transcript is not replayed. + const windowed = await loadUsageHistoryWithLive(); + expect(windowed.map((r) => r.sessionId)).toEqual(['sess-persisted']); + // An unbounded window picks it back up alongside the persisted record. + const all = await loadUsageHistoryWithLive({ sinceMs: 0 }); + expect(all.map((r) => r.sessionId).sort()).toEqual([ + 'sess-old', + 'sess-persisted', + ]); + }); + + it('withLive: with no persisted base, replays full history (no silent trailing-window truncation)', async () => { + // No usage_record.jsonl: nothing else covers old history, so an old + // transcript must still be replayed rather than truncated by the window. + plantChatJsonl('sess-old', 1600); + const stale = Date.now() - 100 * 24 * 60 * 60 * 1000; + fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000); + + const merged = await loadUsageHistoryWithLive(); + expect(merged.map((r) => r.sessionId)).toEqual(['sess-old']); + }); + + it('withLive: read-only rebuild — never writes usage_record.jsonl', async () => { + plantChatJsonl('sess-daemon-only', 1600); + const usagePath = path.join( + process.env['QWEN_HOME']!, + 'usage_record.jsonl', + ); + + const merged = await loadUsageHistoryWithLive(); + expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon-only']); + expect(fs.existsSync(usagePath)).toBe(false); + }); + + it('withLive: all sessions persisted (empty rebuild) returns the persisted records as-is', async () => { + // Common case: no live transcripts at all, only the persisted file. + seedPersisted([persistedRec('sess-only', 500)]); + + const merged = await loadUsageHistoryWithLive(); + expect(merged).toHaveLength(1); + expect(merged[0]!.sessionId).toBe('sess-only'); + expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(500); + }); + + it('withLive: a corrupt usage_record.jsonl falls back to a full transcript replay', async () => { + // No usable persisted base (garbage file) — the loader must still surface + // the daemon transcript rather than returning nothing. + fs.writeFileSync( + path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'), + '{ this is not valid json\nalso broken}\n', + ); + plantChatJsonl('sess-daemon', 1600); + + const merged = await loadUsageHistoryWithLive(); + expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon']); + }); }); describe('aggregateUsage — skills', () => { diff --git a/packages/core/src/services/usageHistoryService.ts b/packages/core/src/services/usageHistoryService.ts index dab0a096a1..183d1da8f6 100644 --- a/packages/core/src/services/usageHistoryService.ts +++ b/packages/core/src/services/usageHistoryService.ts @@ -16,6 +16,24 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('USAGE_HISTORY'); +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** + * Trailing window used by {@link loadUsageHistoryWithLive} when merging + * non-persisted (daemon / Web Shell / in-progress) sessions into the history. + * + * Sized to cover the largest summary/daily range the usage-dashboard exposes + * (month = 30 days) plus margin, so the hero totals, breakdown tiles, and the + * per-day line/bar charts are exact. Crucially it is NOT sized for the full + * heatmap span (~12 months): persisted `usage_record.jsonl` records of any age + * are always unioned in, so the heatmap keeps its full history, but a *never- + * persisted* daemon session older than this window is not replayed — that cell + * undercounts slightly. That cosmetic gap is a deliberate trade for load + * latency: replaying a full year of transcripts costs ~13s here vs. ~1.7s for + * this window (heavy Web Shell users accumulate thousands of unpersisted + * transcripts). Widen only alongside a cheaper scan. + */ +const LIVE_REBUILD_WINDOW_DAYS = 35; + export interface UsageSummaryRecord { version: 1; sessionId: string; @@ -204,10 +222,38 @@ export function metricsToUsageRecord( }; } +interface RebuildFromSessionJsonlOptions { + /** + * Session to exclude from the one-time persistence migration (the caller's + * in-progress session — {@link persistSessionUsage} writes its authoritative + * record on `/clear` or exit). It is still returned in the rebuilt records. + */ + skipSessionInRebuild?: string; + /** Persist rebuilt records as a migration. Read-only callers pass `false`. */ + persist?: boolean; + /** + * Only replay transcripts whose file mtime is at/after this epoch-ms. Bounds + * the scan when merging recent live sessions into an already-persisted + * history (see {@link loadUsageHistoryWithLive}); undefined replays all. + */ + sinceMs?: number; + /** + * Session ids already covered by the persisted history. Their transcripts are + * skipped by filename (`{sessionId}.jsonl`) with no file read, avoiding a full + * replay of sessions the persisted file already records authoritatively. + */ + skipSessionIds?: ReadonlySet; +} + async function rebuildFromSessionJsonl( - skipSessionInRebuild?: string, - persist = true, + options: RebuildFromSessionJsonlOptions = {}, ): Promise { + const { + skipSessionInRebuild, + persist = true, + sinceMs, + skipSessionIds, + } = options; const projectsDir = path.join(Storage.getGlobalQwenDir(), 'projects'); try { if (!fs.existsSync(projectsDir)) return []; @@ -243,6 +289,31 @@ async function rebuildFromSessionJsonl( for (const file of files) { try { const filePath = path.join(chatsDir, file); + + // Bound the scan when merging live sessions into a persisted history: + // skip transcripts untouched before `sinceMs`. + if (sinceMs !== undefined) { + let mtimeMs: number; + try { + mtimeMs = fs.statSync(filePath).mtimeMs; + } catch (e) { + debugLogger.debug( + `rebuildFromSessionJsonl: cannot stat ${filePath}: ${e}`, + ); + continue; + } + if (mtimeMs < sinceMs) continue; + } + + // Skip sessions the persisted history already records, before any file + // read: the transcript filename is `{sessionId}.jsonl` + // (chatRecordingService.ts), so the sessionId — the same value the + // full-read path below derives from the first record — needs no I/O. + if (skipSessionIds && skipSessionIds.size > 0) { + const fileSessionId = path.basename(file, '.jsonl'); + if (skipSessionIds.has(fileSessionId)) continue; + } + const records = await jsonl.read(filePath); if (records.length === 0) continue; @@ -337,13 +408,73 @@ export async function loadUsageHistory( } return dedupBySessionId( - await rebuildFromSessionJsonl( + await rebuildFromSessionJsonl({ skipSessionInRebuild, - options?.persistRebuild ?? true, - ), + persist: options?.persistRebuild ?? true, + }), ); } +/** + * Load the durable usage history **and** merge in sessions that were never + * written to `usage_record.jsonl` — notably daemon / Web Shell sessions (only + * the TUI `/clear` path persists usage) and any still-in-progress session. + * + * Unlike {@link loadUsageHistory}, which returns the persisted file verbatim + * whenever it is non-empty (and so silently omits everything not yet + * persisted), this replays recent transcripts for sessions the persisted file + * does not already cover and unions the two. Persisted records win on any + * sessionId conflict — they are the authoritative final snapshot. This is what + * the daemon usage-dashboard reads so its totals reflect live Web Shell + * activity. Read-only: never writes `usage_record.jsonl`. + * + * The transcript scan is bounded to a trailing window (mtime-based) so an + * established history does not pay a full cross-project replay on every load. + */ +export async function loadUsageHistoryWithLive(options?: { + /** + * Only replay transcripts touched at/after this epoch-ms. Defaults to a + * {@link LIVE_REBUILD_WINDOW_DAYS}-day trailing window (covers the dashboard's + * summary + daily charts; see the constant for the heatmap trade-off). + */ + sinceMs?: number; +}): Promise { + let persisted: UsageSummaryRecord[] = []; + try { + const records = await jsonl.read(getUsageHistoryPath()); + persisted = records.filter((r) => r.version === 1); + } catch (e) { + debugLogger.debug( + `loadUsageHistoryWithLive: failed to read usage file: ${e}`, + ); + } + + const persistedIds = new Set(persisted.map((r) => r.sessionId)); + + // The trailing window bounds an *incremental* live merge on top of persisted + // history: old days come from the persisted file, so only recent transcripts + // need replaying. When there is no persisted base (fresh machine, or a user + // who only ever ran Web Shell so `/clear` never persisted), nothing else + // covers older history — replay it all (unbounded) rather than silently + // truncating the dashboard, matching the pre-existing empty-file behavior. + const sinceMs = + options?.sinceMs ?? + (persistedIds.size > 0 + ? Date.now() - LIVE_REBUILD_WINDOW_DAYS * MS_PER_DAY + : undefined); + + const rebuilt = await rebuildFromSessionJsonl({ + persist: false, + sinceMs, + skipSessionIds: persistedIds, + }); + + // Persisted records are the authoritative final snapshot, so they win on any + // sessionId conflict — place them last (dedupBySessionId is last-wins). The + // rebuilt set only adds sessions the persisted file never captured. + return dedupBySessionId([...rebuilt, ...persisted]); +} + export function getTimeRangeBounds(range: TimeRange): { start: Date; end: Date; From 79bc668b71a0c4bf32d0303928d914dcef8491a3 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 8 Jul 2026 11:25:32 +0800 Subject: [PATCH 3/3] feat(cli): Show permission mode badge in footer for DEFAULT mode (#6498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always display the current permission/approval mode in the footer, including when in the default (Ask permissions) mode. Previously, non-default modes showed indicators but the default mode showed nothing, creating ambiguity for users switching between modes. - Add grey ⏸ badge with 'Ask permissions' text for DEFAULT mode - Update both main Footer and AgentFooter to render DEFAULT indicator - Use theme.text.secondary for subtle, unobtrusive styling - Badge is i18n-aware using existing t('Ask permissions') key - Other mode indicators remain unchanged Closes #6496 --- .../components/AutoAcceptIndicator.test.tsx | 49 +++++++++++++++++++ .../src/ui/components/AutoAcceptIndicator.tsx | 3 ++ .../cli/src/ui/components/Footer.test.tsx | 14 ++++++ packages/cli/src/ui/components/Footer.tsx | 4 +- .../ui/components/agent-view/AgentFooter.tsx | 5 +- .../src/ui/components/approvalModeVisuals.ts | 1 + 6 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx new file mode 100644 index 0000000000..e9f58f49ad --- /dev/null +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; + +describe('', () => { + it('renders DEFAULT mode with pause badge and Ask permissions text', () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame()!; + expect(frame).toContain('⏸'); + expect(frame).toContain('Ask permissions'); + }); + + it('renders PLAN mode indicator', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('plan mode'); + }); + + it('renders AUTO_EDIT mode indicator', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('auto-accept edits'); + }); + + it('renders AUTO mode indicator', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('auto mode (classifier-evaluated)'); + }); + + it('renders YOLO mode indicator', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('YOLO mode'); + }); +}); diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx index 60ffd126a9..caa9234c17 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx @@ -45,6 +45,9 @@ export const AutoAcceptIndicator: React.FC = ({ subText = cycleText; break; case ApprovalMode.DEFAULT: + textContent = `⏸ ${t('Ask permissions')}`; + subText = cycleText; + break; default: break; } diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index ba71e4527a..66321a80e3 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -8,6 +8,7 @@ import { render } from 'ink-testing-library'; import { act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Footer } from './Footer.js'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; import * as useStatusLineModule from '../hooks/useStatusLine.js'; import { type UIState, UIStateContext } from '../contexts/UIStateContext.js'; @@ -233,6 +234,19 @@ describe('