Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/tui/docs/status-line-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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, 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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const descriptions: Record<Exclude<TuiStatusLineItem, 'build-mode'>, 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', '已配置的自定义命令输出'],
};

Expand Down
28 changes: 20 additions & 8 deletions packages/tui/src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -249,11 +254,12 @@ function buildStatusSegment(
{ shrinkPriority: 48, dropPriority: 10 },
);
case 'context-remaining':
case 'context-meter':
return createStatusSegment(
[
renderContextRemaining(state, 'full'),
renderContextRemaining(state, 'compact'),
renderContextRemaining(state, 'minimal'),
renderContextRemaining(state, 'full', item === 'context-meter'),
renderContextRemaining(state, 'compact', item === 'context-meter'),
renderContextRemaining(state, 'minimal', item === 'context-meter'),
],
{ shrinkPriority: 50, dropPriority: 80 },
);
Expand Down Expand Up @@ -443,19 +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}%`;
? `Context ${gauge}${remaining}% left`
: `Ctx ${gauge}${remaining}%`;
return chalk.hex(color)(label);
}

Expand Down
5 changes: 4 additions & 1 deletion packages/tui/src/tui/shell/status-line-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -34,6 +34,7 @@ export const TUI_STATUS_LINE_ITEMS = [
'token-quota',
'cache-read-ratio',
'context-remaining',
'context-meter',
'custom-command',
] as const;

Expand Down Expand Up @@ -67,6 +68,8 @@ const TUI_STATUS_LINE_ITEM_ALIASES: Readonly<Record<string, TuiStatusLineItem>>
'cache-read': 'cache-read-ratio',
context: 'context-remaining',
'context-left': 'context-remaining',
'context-bar': 'context-meter',
'context-gauge': 'context-meter',
custom: 'custom-command',
};

Expand Down
191 changes: 191 additions & 0 deletions packages/tui/test/unit/tui-status-line-items.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -582,3 +584,192 @@ 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).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 },
};
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(render({ ...BASE_STATE, statusLineItems: ['context-meter'], contextWindowTokens: 100_000 })).toBe('');
expect(
stripAnsi(
render({
...BASE_STATE,
statusLineItems: ['context-meter'],
contextUsage: { usedTokens: Number.NaN, contextWindowTokens: 100_000 },
}),
),
).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');
});
});
29 changes: 29 additions & 0 deletions packages/tui/test/unit/tui-workspace-status-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
Loading