From 3d06380457a10f6355ef3dbfe22a0e5981f8398b Mon Sep 17 00:00:00 2001 From: drowzeys Date: Sat, 19 Sep 2026 15:18:58 -0400 Subject: [PATCH 1/2] feat(tui): add a context-meter status line item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line can already show remaining context headroom as a bare percentage (context-remaining), but a numeric readout is easy to miss while working. Add an opt-in context-meter item that renders the same remaining-headroom value as a compact gauge, e.g. 'Context ▕██████░░▏ 77% left', so the line drains and recolors as the Session approaches its context window. - New canonical item 'context-meter' with 'context-bar' and 'context-gauge' aliases; not part of the default item order. - Renderer shares the runtime usage snapshot, model fallback, and error/warning thresholds with 'context-remaining', and shrinks to a shorter gauge before collapsing to the 'Ctx N%' minimal variant. - Settings picker gains English/Chinese descriptions; the shared status line documentation covers the item, aliases, and opt-in semantics. Validation: tsc -p tsconfig.standalone.json clean; vitest tui-status-line-items.test.ts, tui-workspace-status-line.test.ts, and status-line-picker.test.ts pass (98 tests); node scripts/source-inventory.mjs passes with no inventory changes. --- packages/tui/docs/status-line-config.md | 4 +- .../tui/features/settings/status-line-copy.ts | 1 + packages/tui/src/tui/shell/chrome.ts | 36 +++++++++++ .../tui/src/tui/shell/status-line-items.ts | 3 + .../test/unit/tui-status-line-items.test.ts | 63 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index 9975de665..fe87ba3d1 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -60,6 +60,7 @@ Configuration loading checks that the value is an array. The TUI validates indiv | `token-quota` | Token quota / plan | | `cache-read-ratio` | Aggregate cache read ratio for the current session, such as `Cache 80%` | | `context-remaining` | Remaining context percentage from the same runtime snapshot as `/context`; hidden if unavailable | +| `context-meter` | Remaining context gauge, e.g. `Context ▕██████░░▏ 77% left`, from the same runtime snapshot as `/context`; hidden if unavailable; opt-in | | `custom-command` | External command stdout; first line inline by default, optional separate multiline block; opt-in | `cache-read-ratio` aggregates persisted session usage as `cacheRead / (input + cacheRead + cacheWrite)`, where `input` is fresh uncached input. It uses existing provider usage fields; missing cache counts contribute zero, so sessions mixing providers can underestimate cache reads. The item is hidden before any prompt tokens exist. In-process notifications refresh the session after usage persists; read failures stay silent. `/usage` also shows Read, Fresh, and Write token counts. @@ -78,6 +79,7 @@ Configuration loading checks that the value is an array. The TUI validates indiv | `quota` / `token-plan` | `token-quota` | | `cache-read` | `cache-read-ratio` | | `context` / `context-left` | `context-remaining` | +| `context-bar` / `context-gauge` | `context-meter` | | `custom` | `custom-command` | ## Defaults @@ -90,7 +92,7 @@ current-dir · session-title · git-branch · review-link · plan-mode · approv `build-mode` is **not a default**. Explicitly listing it is the only way to enable the machine-readable protocol; the build type does not matter. It may appear anywhere in the list and takes over the entire line to keep free text out of the strict record. Remove it to disable the protocol. -By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` remains opt-in. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. +By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` and `context-meter` remain opt-in; `context-meter` renders the same remaining-headroom value and thresholds as `context-remaining` with an added gauge, so it replaces rather than complements the percentage item in explicit lists. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. `custom-command` is also **not a default**. It runs an external command and requires both an explicit item and `tui.customStatusLine.command`. diff --git a/packages/tui/src/tui/features/settings/status-line-copy.ts b/packages/tui/src/tui/features/settings/status-line-copy.ts index b6ed99f33..9ee787c42 100644 --- a/packages/tui/src/tui/features/settings/status-line-copy.ts +++ b/packages/tui/src/tui/features/settings/status-line-copy.ts @@ -52,6 +52,7 @@ const descriptions: Record, readonly [s 'token-quota': ['Account token quota', '账户额度'], 'cache-read-ratio': ['Session cache read ratio', '会话缓存读取比例'], 'context-remaining': ['Remaining context window', '剩余上下文窗口'], + 'context-meter': ['Remaining context gauge', '剩余上下文刻度条'], 'custom-command': ['Configured custom command output', '已配置的自定义命令输出'], }; diff --git a/packages/tui/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index b65c55254..ec334b4fb 100644 --- a/packages/tui/src/tui/shell/chrome.ts +++ b/packages/tui/src/tui/shell/chrome.ts @@ -257,6 +257,15 @@ function buildStatusSegment( ], { shrinkPriority: 50, dropPriority: 80 }, ); + case 'context-meter': + return createStatusSegment( + [ + renderContextMeter(state, 'full'), + renderContextMeter(state, 'compact'), + renderContextMeter(state, 'minimal'), + ], + { shrinkPriority: 50, dropPriority: 80 }, + ); case 'custom-command': return createStatusSegment( [ @@ -459,6 +468,33 @@ function renderContextRemaining( return chalk.hex(color)(label); } +/** + * Renders remaining context headroom as a compact gauge. + * + * Uses the same snapshot and thresholds as `context-remaining`: the filled + * cells show how much headroom is left, so the gauge drains and recolors as + * the Session approaches its context window. Returns an empty string when no + * usage is known, for the same reasons as the percentage variant. + */ +function renderContextMeter( + state: TuiShellState, + density: 'full' | 'compact' | 'minimal' = 'full', +): string { + const remaining = contextRemainingPercent(state); + if (remaining === undefined) return ''; + const color = remaining <= 10 ? colors.error : remaining <= 25 ? colors.warning : colors.muted; + const cells = density === 'full' ? 8 : 6; + const filled = Math.round((remaining / 100) * cells); + const gauge = `▕${'█'.repeat(filled)}${'░'.repeat(cells - filled)}▏`; + const label = + density === 'minimal' + ? `Ctx ${remaining}%` + : density === 'full' + ? `Context ${gauge} ${remaining}% left` + : `Ctx ${gauge} ${remaining}%`; + return chalk.hex(color)(label); +} + function renderSubagentIdentity(state: TuiShellState, compact = false): string { if (state.sessionRole !== 'subagent') return ''; const agent = formatStatusContextLabel(state.sessionAgentName?.trim() || 'sub-agent'); diff --git a/packages/tui/src/tui/shell/status-line-items.ts b/packages/tui/src/tui/shell/status-line-items.ts index 98ed52959..7cffc5ff6 100644 --- a/packages/tui/src/tui/shell/status-line-items.ts +++ b/packages/tui/src/tui/shell/status-line-items.ts @@ -34,6 +34,7 @@ export const TUI_STATUS_LINE_ITEMS = [ 'token-quota', 'cache-read-ratio', 'context-remaining', + 'context-meter', 'custom-command', ] as const; @@ -67,6 +68,8 @@ const TUI_STATUS_LINE_ITEM_ALIASES: Readonly> 'cache-read': 'cache-read-ratio', context: 'context-remaining', 'context-left': 'context-remaining', + 'context-bar': 'context-meter', + 'context-gauge': 'context-meter', custom: 'custom-command', }; diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index 23ca62d79..aa3e5d48f 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -582,3 +582,66 @@ describe('review link status item', () => { expect(narrow).toContain('!312'); }); }); + +describe('context meter item', () => { + it('resolves the canonical id and its aliases', () => { + expect(parseTuiStatusLineItem('context-meter')).toBe('context-meter'); + expect(parseTuiStatusLineItem('context-bar')).toBe('context-meter'); + expect(parseTuiStatusLineItem('context-gauge')).toBe('context-meter'); + }); + + it('stays out of the default status line', () => { + expect(TUI_STATUS_LINE_DEFAULT_ITEMS).not.toContain('context-meter'); + const state = { + ...BASE_STATE, + contextUsage: { usedTokens: 20_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).not.toContain('▕'); + }); + + it('renders the remaining-headroom gauge when configured explicitly', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 20_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).toContain('Context ▕██████░░▏ 80% left'); + }); + + it('drains the gauge with the remaining headroom', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).toContain('▕████░░░░▏ 50% left'); + }); + + it('shrinks to a shorter gauge in narrow terminals', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + }; + const narrow = stripAnsi(render(state, 20)); + expect(narrow).toContain('Ctx'); + expect(narrow).toContain('50%'); + expect(narrow).not.toContain('left'); + expect(narrow).not.toContain('▕████░░░░▏'); + }); + + it('is hidden without usage and never shows NaN', () => { + expect(stripAnsi(render({ ...BASE_STATE, statusLineItems: ['context-meter'] }))).not.toContain( + '▕', + ); + expect( + stripAnsi( + render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: Number.NaN, contextWindowTokens: 100_000 }, + }), + ), + ).not.toContain('NaN'); + }); +}); From 787248eec16c38d1f21b7443223c5e6bafee1a64 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sun, 20 Sep 2026 11:04:34 +0800 Subject: [PATCH 2/2] fix(tui): deduplicate context meter and share headroom rendering Review and harden the opt-in contribution from drowzeys/minimax-code commit 4076c6f521eadccc05c1179c9a73baf004a0a7af (issue #217). Preserve the original author commit; cover aliases, priority, fallback, thresholds, width fitting, missing data and localized settings. --- packages/tui/docs/status-line-config.md | 2 +- packages/tui/src/tui/shell/chrome.ts | 62 +++----- .../tui/src/tui/shell/status-line-items.ts | 2 +- .../test/unit/tui-status-line-items.test.ts | 136 +++++++++++++++++- .../unit/tui-workspace-status-line.test.ts | 29 ++++ .../settings/status-line-picker.test.ts | 17 +++ 6 files changed, 199 insertions(+), 49 deletions(-) diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index fe87ba3d1..c1edaa8cf 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -92,7 +92,7 @@ current-dir · session-title · git-branch · review-link · plan-mode · approv `build-mode` is **not a default**. Explicitly listing it is the only way to enable the machine-readable protocol; the build type does not matter. It may appear anywhere in the list and takes over the entire line to keep free text out of the strict record. Remove it to disable the protocol. -By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` and `context-meter` remain opt-in; `context-meter` renders the same remaining-headroom value and thresholds as `context-remaining` with an added gauge, so it replaces rather than complements the percentage item in explicit lists. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. +By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` and `context-meter` remain opt-in; `context-meter` renders the same remaining-headroom value and thresholds as `context-remaining` with an added gauge, and takes precedence over `context-remaining` when both are listed, regardless of order. Other items retain their order, and the meter stays at its configured position. It shrinks from eight gauge cells to six, then to `Ctx N%` as space runs out. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. `custom-command` is also **not a default**. It runs an external command and requires both an explicit item and `tui.customStatusLine.command`. diff --git a/packages/tui/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index ec334b4fb..117435b61 100644 --- a/packages/tui/src/tui/shell/chrome.ts +++ b/packages/tui/src/tui/shell/chrome.ts @@ -134,7 +134,12 @@ export class TuiStatusLine implements Component { function resolveStatusLineItems(state: TuiShellState): readonly TuiStatusLineItem[] { const items = state.statusLineItems ?? TUI_STATUS_LINE_DEFAULT_ITEMS; if (!items.includes('build-mode')) { - if (state.statusLineItems !== undefined) return items; + if (state.statusLineItems !== undefined) { + // The opt-in meter replaces the percentage at the meter's configured position. + return items.includes('context-meter') + ? items.filter((item) => item !== 'context-remaining') + : items; + } const remaining = contextRemainingPercent(state); return items.filter((item) => remaining === undefined ? item !== 'context-remaining' : item !== 'context-window', @@ -249,20 +254,12 @@ function buildStatusSegment( { shrinkPriority: 48, dropPriority: 10 }, ); case 'context-remaining': - return createStatusSegment( - [ - renderContextRemaining(state, 'full'), - renderContextRemaining(state, 'compact'), - renderContextRemaining(state, 'minimal'), - ], - { shrinkPriority: 50, dropPriority: 80 }, - ); case 'context-meter': return createStatusSegment( [ - renderContextMeter(state, 'full'), - renderContextMeter(state, 'compact'), - renderContextMeter(state, 'minimal'), + renderContextRemaining(state, 'full', item === 'context-meter'), + renderContextRemaining(state, 'compact', item === 'context-meter'), + renderContextRemaining(state, 'minimal', item === 'context-meter'), ], { shrinkPriority: 50, dropPriority: 80 }, ); @@ -452,46 +449,25 @@ function contextRemainingPercent(state: TuiShellState): number | undefined { return Math.round(((contextWindow - used) / contextWindow) * 100); } +/** Both presentations share usage, fallback, thresholds and the minimal label. */ function renderContextRemaining( state: TuiShellState, density: 'full' | 'compact' | 'minimal' = 'full', + meter = false, ): string { const remaining = contextRemainingPercent(state); if (remaining === undefined) return ''; const color = remaining <= 10 ? colors.error : remaining <= 25 ? colors.warning : colors.muted; + let gauge = ''; + if (meter && density !== 'minimal') { + const cells = density === 'full' ? 8 : 6; + const filled = Math.round((remaining / 100) * cells); + gauge = `▕${'█'.repeat(filled)}${'░'.repeat(cells - filled)}▏ `; + } const label = density === 'full' - ? `Context ${remaining}% left` - : density === 'compact' - ? `Ctx ${remaining}%` - : `Ctx ${remaining}%`; - return chalk.hex(color)(label); -} - -/** - * Renders remaining context headroom as a compact gauge. - * - * Uses the same snapshot and thresholds as `context-remaining`: the filled - * cells show how much headroom is left, so the gauge drains and recolors as - * the Session approaches its context window. Returns an empty string when no - * usage is known, for the same reasons as the percentage variant. - */ -function renderContextMeter( - state: TuiShellState, - density: 'full' | 'compact' | 'minimal' = 'full', -): string { - const remaining = contextRemainingPercent(state); - if (remaining === undefined) return ''; - const color = remaining <= 10 ? colors.error : remaining <= 25 ? colors.warning : colors.muted; - const cells = density === 'full' ? 8 : 6; - const filled = Math.round((remaining / 100) * cells); - const gauge = `▕${'█'.repeat(filled)}${'░'.repeat(cells - filled)}▏`; - const label = - density === 'minimal' - ? `Ctx ${remaining}%` - : density === 'full' - ? `Context ${gauge} ${remaining}% left` - : `Ctx ${gauge} ${remaining}%`; + ? `Context ${gauge}${remaining}% left` + : `Ctx ${gauge}${remaining}%`; return chalk.hex(color)(label); } diff --git a/packages/tui/src/tui/shell/status-line-items.ts b/packages/tui/src/tui/shell/status-line-items.ts index 7cffc5ff6..c92149c0f 100644 --- a/packages/tui/src/tui/shell/status-line-items.ts +++ b/packages/tui/src/tui/shell/status-line-items.ts @@ -9,7 +9,7 @@ * (`tui.statusLine`), so renaming one is a breaking change. Legacy aliases keep * older configuration working. * - * `cache-read-ratio`, `context-remaining` and `review-link` are conditional + * `cache-read-ratio`, `context-remaining`, `context-meter` and `review-link` are conditional * items. They only render when the Runtime surfaces their matching projection * into the shell state, so unsupported or not-yet-recorded metrics stay silent. * diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index aa3e5d48f..090d07fcb 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { applyTuiRenderTheme, getTuiThemeSnapshot, tuiChalk, tuiColors } from '../../src/tui/theme/runtime.js'; +import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from '../../src/tui/theme/palettes.js'; import { TuiStatusLine } from '../../src/tui/shell/chrome.js'; import { stripAnsi, visibleWidth } from '../../src/tui/rendering/text.js'; import { setCapabilityOverrides } from '../../src/tui/engine/public.js'; @@ -591,7 +593,11 @@ describe('context meter item', () => { }); it('stays out of the default status line', () => { - expect(TUI_STATUS_LINE_DEFAULT_ITEMS).not.toContain('context-meter'); + expect(TUI_STATUS_LINE_DEFAULT_ITEMS).toEqual([ + 'current-dir', 'session-title', 'git-branch', 'review-link', 'plan-mode', + 'approval-mode', 'model-with-reasoning', 'context-window', 'subagent', + 'token-quota', 'context-remaining', + ]); const state = { ...BASE_STATE, contextUsage: { usedTokens: 20_000, contextWindowTokens: 100_000 }, @@ -631,9 +637,7 @@ describe('context meter item', () => { }); it('is hidden without usage and never shows NaN', () => { - expect(stripAnsi(render({ ...BASE_STATE, statusLineItems: ['context-meter'] }))).not.toContain( - '▕', - ); + expect(render({ ...BASE_STATE, statusLineItems: ['context-meter'], contextWindowTokens: 100_000 })).toBe(''); expect( stripAnsi( render({ @@ -645,3 +649,127 @@ describe('context meter item', () => { ).not.toContain('NaN'); }); }); + +describe('context meter presentation contract', () => { + it.each([ + ['context-remaining', 'model', 'context-bar', 'context-gauge'], + ['context-gauge', 'model', 'context-left'], + ])('prefers the meter at its configured position for %j', (...configured) => { + const statusLineItems = parseTuiStatusLineItems(configured); + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems, + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + })); + expect(line.match(/50%/gu)).toHaveLength(1); + expect(line).toContain('Context ▕████░░░░▏ 50% left'); + expect(line.indexOf('Context') < line.indexOf('m2')).toBe(configured[0] === 'context-gauge'); + }); +}); + +describe('context meter boundaries', () => { + it.each([ + [undefined, 100_000, 50], + [200_000, 100_000, 75], + [Number.NaN, 100_000, 50], + [0, 100_000, 50], + [-1, 100_000, 50], + [Number.POSITIVE_INFINITY, 100_000, 50], + ])('resolves snapshot window %s before model window %s', (snapshot, model, remaining) => { + for (const item of ['context-meter', 'context-remaining'] as const) { + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems: [item], + contextWindowTokens: model, + contextUsage: { usedTokens: 50_000, contextWindowTokens: snapshot }, + })); + expect(line).toContain(`${remaining}% left`); + } + }); + + it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'hides the meter when neither window is usable (%s)', (window) => { + expect(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextWindowTokens: window, + contextUsage: { usedTokens: 1, contextWindowTokens: window }, + })).toBe(''); + }, + ); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'hides invalid usage %s even with valid model capacity', (usedTokens) => { + expect(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextWindowTokens: 100_000, + contextUsage: { usedTokens }, + })).toBe(''); + }, + ); + + it.each([ + [-1, '▕████████▏ 100% left'], + [0, '▕████████▏ 100% left'], + [100, '▕░░░░░░░░▏ 0% left'], + [150, '▕░░░░░░░░▏ 0% left'], + ])('clamps used tokens %d to a valid gauge', (usedTokens, expected) => { + expect(stripAnsi(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens, contextWindowTokens: 100 }, + }))).toContain(expected); + }); + + it.each([ + [30, 'Context ▕████░░░░▏ 50% left'], + [20, 'Ctx ▕███░░░▏ 50%'], + [7, 'Ctx 50%'], + [6, ''], + ])('fits a width of %d with the expected presentation', (width, expected) => { + const line = render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50, contextWindowTokens: 100 }, + }, width); + expect(stripAnsi(line).trim()).toBe(expected); + expect(line.split('\n').every((row) => visibleWidth(row) <= width)).toBe(true); + }); + + it('uses the same warning and error boundaries as the percentage item', () => { + const previous = getTuiThemeSnapshot(); + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + try { + for (const [remaining, color] of [ + [26, tuiColors.muted], [25, tuiColors.warning], + [11, tuiColors.warning], [10, tuiColors.error], [0, tuiColors.error], + ] as const) { + for (const item of ['context-meter', 'context-remaining'] as const) { + const line = render({ + ...BASE_STATE, + statusLineItems: [item], + contextUsage: { usedTokens: 100 - remaining, contextWindowTokens: 100 }, + }).trim(); + expect(line).not.toBe(stripAnsi(line)); + expect(line).toBe(tuiChalk.hex(color)(stripAnsi(line))); + } + } + } finally { + applyTuiRenderTheme( + previous.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + previous.colorLevel, + ); + } + }); + + it('keeps build-mode in control when the meter is selected', () => { + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems: ['context-meter', 'build-mode', 'context-remaining'], + contextUsage: { usedTokens: 50, contextWindowTokens: 100 }, + })); + expect(line).toContain('[V]'); + expect(line).not.toContain('Context'); + }); +}); diff --git a/packages/tui/test/unit/tui-workspace-status-line.test.ts b/packages/tui/test/unit/tui-workspace-status-line.test.ts index 3d3f016f6..5c1cb47b3 100644 --- a/packages/tui/test/unit/tui-workspace-status-line.test.ts +++ b/packages/tui/test/unit/tui-workspace-status-line.test.ts @@ -508,3 +508,32 @@ describe('TuiWorkspaceStatusLine custom status command', () => { expect(runProcess).toHaveBeenCalledTimes(1); }); }); + + +describe('context meter projection', () => { + it('previews and refreshes current usage without retaining cleared data', () => { + const runtime = { getWorkspaceGitMetadata: vi.fn() }; + const state = { + version: '0.1', workspace: '/repo', runtimeStatus: 'ready' as const, + statusLineItems: ['context-meter', 'context-remaining'] as const, + contextWindowTokens: 100, + }; + const status = new TuiWorkspaceStatusLine(state, runtime, vi.fn()); + try { + expect(status.preview(['context-meter'], 80, 3).unavailable).toEqual(['context-meter']); + expect(status.render(80)).toEqual([]); + status.setState({ ...state, contextUsage: { usedTokens: 20 } }); + const preview = status.preview(['context-meter', 'context-remaining'], 80, 3); + expect(preview.unavailable).toEqual([]); + expect(preview.lines.join('')).toContain('▕██████░░▏ 80% left'); + expect(status.render(80).join('').match(/80%/gu)).toHaveLength(1); + status.setState({ ...state, contextUsage: { usedTokens: 90 } }); + expect(status.render(80).join('')).toContain('▕█░░░░░░░▏ 10% left'); + status.setState(state); + expect(status.render(80)).toEqual([]); + expect(runtime.getWorkspaceGitMetadata).not.toHaveBeenCalled(); + } finally { + status.dispose(); + } + }); +}); diff --git a/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts b/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts index ff9ab3490..1103ef1b9 100644 --- a/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts +++ b/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts @@ -145,3 +145,20 @@ describe('status line picker', () => { expect(picker.render(120).join('\n')).not.toContain('0%'); }); }); + + +describe('context meter settings', () => { + it.each([ + ['en', 'Remaining context gauge'], + ['zh-Hans', '剩余上下文刻度条'], + ])('describes and saves the opt-in item in %s', async (locale, description) => { + const { picker, options } = fixture({ locale }); + picker.handleInput('context-meter'); + expect(picker.render(100).join('\n')).toContain(description); + expect(picker.render(100).join('\n')).toContain('[ ] context-meter'); + picker.handleInput(' '); + picker.handleInput('\r'); + await vi.waitFor(() => expect(options.onClose).toHaveBeenCalledOnce()); + expect(options.save).toHaveBeenCalledWith(['model', 'current-dir', 'context-meter']); + }); +});