Skip to content

Commit 40db799

Browse files
committed
feat(prompt): show context token usage in the pinned session row
- Pin a persistent session row below the prompt showing cwd, provider/model, and a context usage bar - Show used tokens, the context window, and the percentage beside the bar (e.g. `16K/1M (2%)`) - Abbreviate counts as 840 / 16K / 1M / 1.5M, rounding so an active session never reads as 0K and never renders 1000K - Degrade by tiers on narrow terminals: drop the token counts first, then truncate cwd and provider/model independently so neither starves the other - Count conversation tokens only, excluding the system prompt, built-in tool schemas, and skill frontmatter - Add tests for token formatting, width safety, unicode paths, and unknown or missing context windows Closes #23
1 parent 5f127bf commit 40db799

4 files changed

Lines changed: 447 additions & 18 deletions

File tree

src/components/PromptInput/PromptInput.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import type { SuggestionItem } from './PromptInputFooterSuggestions.js';
119119
import { PromptInputModeIndicator } from './PromptInputModeIndicator.js';
120120
import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js';
121121
import { PromptInputStashNotice } from './PromptInputStashNotice.js';
122+
import { PromptInputStatusBar } from './PromptInputStatusBar.js';
122123
import { useMaybeTruncateInput } from './useMaybeTruncateInput.js';
123124
import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js';
124125
import { useShowFastIconHint } from './useShowFastIconHint.js';
@@ -2009,7 +2010,8 @@ function PromptInput({
20092010
rows
20102011
} = useTerminalSize();
20112012
const promptFrameColumns = isCenteredPrompt && columns >= 80 ? Math.min(columns - 4, Math.max(72, Math.floor(columns * 0.78))) : columns;
2012-
const textInputColumns = promptFrameColumns - 3 - companionReservedColumns(promptFrameColumns, companionSpeaking);
2013+
const promptContentColumns = promptFrameColumns - companionReservedColumns(promptFrameColumns, companionSpeaking);
2014+
const textInputColumns = promptContentColumns - 3;
20132015

20142016
// POC: click-to-position-cursor. Mouse tracking is only enabled inside
20152017
// <AlternateScreen>, so this is dormant in the normal main-screen REPL.
@@ -2306,6 +2308,7 @@ function PromptInput({
23062308
</Box>
23072309
</>}
23082310
<PromptInputFooter apiKeyStatus={apiKeyStatus} debug={debug} exitMessage={exitMessage} vimMode={isVimModeEnabled() ? vimMode : undefined} mode={mode} autoUpdaterResult={autoUpdaterResult} isAutoUpdating={isAutoUpdating} verbose={verbose} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={setIsAutoUpdating} suggestions={suggestions} selectedSuggestion={selectedSuggestion} maxColumnWidth={maxColumnWidth} toolPermissionContext={effectiveToolPermissionContext} helpOpen={helpOpen} suppressHint={input.length > 0} isLoading={isLoading} tasksSelected={tasksSelected} teamsSelected={teamsSelected} bridgeSelected={bridgeSelected} tmuxSelected={tmuxSelected} teammateFooterIndex={teammateFooterIndex} ideSelection={ideSelection} mcpClients={mcpClients} isPasting={isPasting} isInputWrapped={isInputWrapped} messages={messages} isSearching={isSearchingHistory} historyQuery={historyQuery} setHistoryQuery={setHistoryQuery} historyFailedMatch={historyFailedMatch} onOpenTasksDialog={isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined} />
2311+
<PromptInputStatusBar messages={messages} columns={promptContentColumns} />
23092312
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
23102313
{isFullscreenEnvEnabled() ?
23112314
// position=absolute takes zero layout height so the spinner

src/components/PromptInput/PromptInputStatusBar.tsx

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,65 @@
11
import * as React from 'react'
2-
import path from 'path'
3-
import { homedir } from 'os'
42
import { Box, Text } from 'src/ink.js'
3+
import { getSdkBetas } from '../../bootstrap/state.js'
4+
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
5+
import { analyzeContext } from '../../utils/contextAnalysis.js'
6+
import { getContextWindowForModel } from '../../utils/context.js'
57
import { getCwd } from '../../utils/cwd.js'
6-
import type { MCPServerConnection } from '../../services/mcp/types.js'
8+
import { modelDisplayStringForProvider } from '../../utils/model/display.js'
9+
import {
10+
getAPIProvider,
11+
PROVIDER_DISPLAY_NAMES,
12+
} from '../../utils/model/providers.js'
13+
import {
14+
calculateConsumedContextPercentage,
15+
formatSessionStatus,
16+
shortenSessionCwd,
17+
} from './sessionStatus.js'
718

819
type Props = {
9-
mcpClients?: MCPServerConnection[]
20+
messages: Parameters<typeof analyzeContext>[0]
21+
columns: number
1022
}
1123

12-
function shortenCwd(cwd: string): string {
13-
const home = homedir()
14-
if (home && (cwd === home || cwd.startsWith(home + path.sep))) {
15-
return '~' + cwd.slice(home.length)
16-
}
17-
return cwd
18-
}
19-
20-
export function PromptInputStatusBar(_props: Props): React.ReactNode {
21-
const cwd = shortenCwd(getCwd())
24+
export function PromptInputStatusBar({
25+
messages,
26+
columns,
27+
}: Props): React.ReactNode {
28+
const mainLoopModel = useMainLoopModel()
29+
const provider = getAPIProvider()
30+
const contextWindow = getContextWindowForModel(mainLoopModel, getSdkBetas())
31+
const usedContextTokens = React.useMemo(() => {
32+
try {
33+
// Count only conversation content that consumes the initially free
34+
// portion of the window. System prompts, tool schemas, and skill
35+
// frontmatter are injected separately and are deliberately excluded.
36+
return analyzeContext(messages).total
37+
} catch {
38+
// A status-only estimate must never make the prompt unusable.
39+
return null
40+
}
41+
}, [messages])
42+
const cwd = shortenSessionCwd(getCwd())
43+
const status = formatSessionStatus(
44+
{
45+
cwd,
46+
provider: PROVIDER_DISPLAY_NAMES[provider],
47+
model: modelDisplayStringForProvider(mainLoopModel, provider),
48+
usedContextTokens,
49+
contextWindowTokens: contextWindow,
50+
consumedContextPercentage:
51+
usedContextTokens === null
52+
? null
53+
: calculateConsumedContextPercentage(usedContextTokens, contextWindow),
54+
},
55+
columns,
56+
)
57+
if (!status) return null
2258

2359
return (
24-
<Box flexDirection="row" paddingX={2} flexShrink={0}>
25-
<Text color="textMuted" wrap="truncate">
26-
{cwd}
60+
<Box flexDirection="row" paddingX={2} flexShrink={0} overflowX="hidden">
61+
<Text color="textMuted" dimColor wrap="truncate">
62+
{status}
2763
</Text>
2864
</Box>
2965
)
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Persistent session-status formatting tests.
3+
*
4+
* Run: bun run src/components/PromptInput/sessionStatus.test.ts
5+
*/
6+
7+
import path from 'path'
8+
import { stringWidth } from '../../ink/stringWidth.js'
9+
import {
10+
calculateConsumedContextPercentage,
11+
formatSessionStatus,
12+
formatTokenCount,
13+
shortenSessionCwd,
14+
type SessionStatusInfo,
15+
} from './sessionStatus.js'
16+
17+
let passed = 0
18+
let failed = 0
19+
20+
function test(name: string, fn: () => void): void {
21+
try {
22+
fn()
23+
passed++
24+
console.log(` ok ${name}`)
25+
} catch (error: any) {
26+
failed++
27+
console.log(` FAIL ${name}: ${error?.message ?? String(error)}`)
28+
}
29+
}
30+
31+
function assert(condition: unknown, message: string): asserts condition {
32+
if (!condition) throw new Error(message)
33+
}
34+
35+
const baseInfo: SessionStatusInfo = {
36+
cwd: '~/work/tau',
37+
provider: 'Anthropic',
38+
model: 'Claude Sonnet 4.6',
39+
usedContextTokens: 36_000,
40+
contextWindowTokens: 200_000,
41+
consumedContextPercentage: 18,
42+
}
43+
44+
console.log('session status:')
45+
46+
test('collapses the home directory and its descendants', () => {
47+
const home = path.resolve(path.sep, 'Users', 'tau-user')
48+
const project = path.join(home, 'work', 'tau')
49+
50+
assert(shortenSessionCwd(home, home, path) === '~', 'home should become ~')
51+
assert(
52+
shortenSessionCwd(project, home, path) === path.join('~', 'work', 'tau'),
53+
'home descendant should retain its relative path',
54+
)
55+
})
56+
57+
test('does not collapse a sibling which only shares the home prefix', () => {
58+
const parent = path.resolve(path.sep, 'Users')
59+
const home = path.join(parent, 'tau')
60+
const sibling = path.join(parent, 'tau-backup')
61+
62+
assert(
63+
shortenSessionCwd(sibling, home, path) === sibling,
64+
'sibling path must remain absolute',
65+
)
66+
})
67+
68+
test('abbreviates token counts without reading as zero', () => {
69+
assert(formatTokenCount(0) === '0', 'zero should stay exact')
70+
assert(formatTokenCount(840) === '840', 'sub-thousand counts stay exact')
71+
assert(formatTokenCount(16_000) === '16K', 'thousands abbreviate to K')
72+
assert(formatTokenCount(1_200) === '1K', 'a partial thousand rounds to K')
73+
assert(formatTokenCount(999_600) === '1M', 'never renders as 1000K')
74+
assert(formatTokenCount(1_000_000) === '1M', 'a round million drops the .0')
75+
assert(formatTokenCount(1_500_000) === '1.5M', 'millions keep one decimal')
76+
assert(formatTokenCount(200_000) === '200K', 'window sizes abbreviate too')
77+
assert(formatTokenCount(Number.NaN) === '0', 'invalid counts degrade to 0')
78+
})
79+
80+
test('shows tokens used, the window, and the percentage beside the bar', () => {
81+
const status = formatSessionStatus(
82+
{ ...baseInfo, usedContextTokens: 16_000, contextWindowTokens: 1_000_000, consumedContextPercentage: 1.6 },
83+
120,
84+
)
85+
assert(
86+
status ===
87+
'~/work/tau · Anthropic / Claude Sonnet 4.6 · Context ░░░░░░░░░░ 16K/1M (2%)',
88+
`unexpected wide status: ${status}`,
89+
)
90+
})
91+
92+
test('uses the descriptive format when the terminal has room', () => {
93+
const status = formatSessionStatus(baseInfo, 120)
94+
assert(
95+
status ===
96+
'~/work/tau · Anthropic / Claude Sonnet 4.6 · Context ██░░░░░░░░ 36K/200K (18%)',
97+
`unexpected wide status: ${status}`,
98+
)
99+
})
100+
101+
test('shows an unknown context until usage has been measured', () => {
102+
const status = formatSessionStatus(
103+
{
104+
...baseInfo,
105+
usedContextTokens: null,
106+
consumedContextPercentage: null,
107+
},
108+
120,
109+
)
110+
assert(
111+
status.endsWith('Context ░░░░░░░░░░ --'),
112+
`unexpected context label: ${status}`,
113+
)
114+
})
115+
116+
test('omits the window size when the model does not report one', () => {
117+
const status = formatSessionStatus(
118+
{ ...baseInfo, contextWindowTokens: 0 },
119+
120,
120+
)
121+
assert(
122+
status.endsWith('Context ██░░░░░░░░ 18%'),
123+
`unexpected context label: ${status}`,
124+
)
125+
})
126+
127+
test('keeps the token counts on a moderately narrow row', () => {
128+
const columns = 64
129+
const status = formatSessionStatus(baseInfo, columns)
130+
131+
assert(stringWidth(status) <= columns - 4, 'status exceeds padded width')
132+
assert(
133+
status.endsWith('36K/200K (18%)'),
134+
`token counts should survive: ${status}`,
135+
)
136+
assert(status.includes('Anthropic'), 'provider should remain identifiable')
137+
})
138+
139+
test('keeps cwd, provider/model, and context on one narrow row', () => {
140+
const columns = 42
141+
const status = formatSessionStatus(baseInfo, columns)
142+
143+
assert(stringWidth(status) <= columns - 4, 'status exceeds padded width')
144+
assert(status.includes('·'), 'status should retain field separators')
145+
assert(status.includes('Anthropic'), 'provider should remain identifiable')
146+
assert(status.endsWith('█░░░░░ 18%'), 'context bar should remain visible')
147+
})
148+
149+
test('measures only supplied conversation tokens against the full window', () => {
150+
const percentage = calculateConsumedContextPercentage(20_000, 200_000)
151+
assert(percentage === 10, `unexpected consumed percentage: ${percentage}`)
152+
assert(
153+
calculateConsumedContextPercentage(-500, 200_000) === 0,
154+
'negative estimates should clamp to zero',
155+
)
156+
assert(
157+
calculateConsumedContextPercentage(250_000, 200_000) === 100,
158+
'usage should clamp to the context window',
159+
)
160+
assert(
161+
calculateConsumedContextPercentage(20_000, 0) === null,
162+
'invalid context windows should remain unknown',
163+
)
164+
})
165+
166+
test('is display-width safe for unicode and pathological widths', () => {
167+
const unicodeInfo: SessionStatusInfo = {
168+
cwd: '~/项目/非常长的目录名称',
169+
provider: '提供商',
170+
model: '模型-超长名称',
171+
usedContextTokens: 202_000,
172+
contextWindowTokens: 200_000,
173+
consumedContextPercentage: 101.4,
174+
}
175+
176+
for (const columns of [5, 12, 16, 24, 40, 56]) {
177+
const status = formatSessionStatus(unicodeInfo, columns)
178+
assert(
179+
stringWidth(status) <= Math.max(0, columns - 4),
180+
`status exceeds width at ${columns} columns: ${status}`,
181+
)
182+
}
183+
assert(
184+
formatSessionStatus(unicodeInfo, 160).endsWith(
185+
'Context ██████████ 202K/200K (100%)',
186+
),
187+
'percentage should clamp to 100 while the counts stay honest',
188+
)
189+
assert(
190+
formatSessionStatus(unicodeInfo, Number.NaN) === '',
191+
'invalid terminal width should not produce layout output',
192+
)
193+
})
194+
195+
console.log(`\n${passed} passed, ${failed} failed`)
196+
if (failed > 0) process.exit(1)

0 commit comments

Comments
 (0)