diff --git a/session-meter/.claude-plugin/plugin.json b/session-meter/.claude-plugin/plugin.json
index 017c1a0..e8e23ae 100644
--- a/session-meter/.claude-plugin/plugin.json
+++ b/session-meter/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "session-meter",
- "version": "0.4.3",
+ "version": "0.4.4",
"description": "Shows a live session pane (/ctx): context breakdown, usage limits with pace, tool calls and model requests; sends the model a one-time note when limits or context cross a threshold, and can let the model compact or clear on its own",
"author": { "name": "Mehmet Aras", "url": "https://github.com/arasovic" },
"repository": "https://github.com/arasovic/claude-code-mods",
diff --git a/session-meter/hooks/register.tsx b/session-meter/hooks/register.tsx
index 55485cc..9c6a232 100644
--- a/session-meter/hooks/register.tsx
+++ b/session-meter/hooks/register.tsx
@@ -161,9 +161,10 @@ export const toolName = (tool: string) => (tool.startsWith('mcp__') ? (tool.spli
// Rows are runs of styled text. The terminal draws them on a grid of cells, so frames pad and cut them exactly.
// Other surfaces draw text in a proportional font, where spaces line nothing up: there a row is a flex line.
-// `fill` stretches, `w` is a column that many characters wide (`right` aligns it), a Bar draws `parts` as real
-// bars in place of its `cells`, a Spark draws a bar per value, and a Group takes an equal share of the line.
-export type Seg = { t: string; c?: string; d?: boolean; b?: boolean; fill?: boolean; w?: number; right?: boolean }
+// `fill` stretches, `w` is a column that many characters wide (`right` aligns it), `keep` never gives way when
+// the line runs short, a Bar draws `parts` as real bars in place of its `cells`, a Spark draws a bar per value,
+// and a Group takes an equal share of the line.
+export type Seg = { t: string; c?: string; d?: boolean; b?: boolean; fill?: boolean; w?: number; right?: boolean; keep?: boolean }
type Bar = { cells: Seg[]; parts: { n: number; c?: string }[] }
type Spark = { spark: readonly number[]; max: number; c: string }
type Group = { group: Row }
@@ -455,7 +456,7 @@ export const register: Register = (on, options) => {
)
if (s.fill) return
const text = {!s.w ? s.t : s.right ? s.t.trim() : s.t.trimEnd()}
- return s.w ? {text} : text
+ return s.w || s.keep ? {text} : text
})
const line = (row: Row) => (row.length ? {items(row)} : )
const frame = (title: string, tone: string, rows: Row[], right = '') => {
@@ -463,9 +464,11 @@ export const register: Register = (on, options) => {
return (
- {title}
+
+ {title}
+
- {right ? {right} : null}
+ {right ? {right} : null}
{rows.map(line)}
@@ -508,7 +511,8 @@ export const register: Register = (on, options) => {
...used.map(c => ({ mark: '■', c: c.color, name: c.name, n: tokens(c.tokens) })),
...(b.threshold === undefined ? [] : [{ mark: isGrid ? '┊' : '■', c: isGrid ? 'warning' : 'diffRemovedDimmed', name: 'compacts at', n: tokens(b.threshold) }]),
]
- const key = (k: (typeof keys)[number]): Row => spread([{ t: `${k.mark} `, c: k.c }, { t: k.name.slice(0, colW - k.n.length - 3) }], [{ t: k.n, d: true }], colW)
+ const key = (k: (typeof keys)[number]): Row =>
+ spread([{ t: `${k.mark} `, c: k.c, keep: true }, { t: isGrid ? k.name.slice(0, colW - k.n.length - 3) : k.name }], [{ t: k.n, d: true, keep: true }], colW)
const legend: Row[] = []
for (let i = 0; i < keys.length; i += 2) {
const [a, z] = [keys[i], keys[i + 1]]
@@ -521,7 +525,7 @@ export const register: Register = (on, options) => {
const chart = turns.length < 2 ? [] : braille(turns, b.window, chartCols, 2)
const change: Seg = { t: `${delta !== undefined && delta >= 0 ? '+' : '-'}${tokens(Math.abs(delta ?? 0))}`, b: true }
const contextRows: Row[] = [
- spread([{ t: tokens(b.tokens), b: true }, { t: ` of ${tokens(b.window)}`, d: true }], [{ t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true }], inner),
+ spread([{ t: tokens(b.tokens), b: true, keep: true }, { t: ` of ${tokens(b.window)}`, d: true }], [{ t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true, keep: true }], inner),
[],
[{ cells: bar, parts }],
[],
@@ -535,13 +539,13 @@ export const register: Register = (on, options) => {
[{ t: chart[1] ?? '', c: 'suggestion' }, { t: ' last turn', d: true }],
]
: [[{ spark: turns.slice(-chartCols * 2), max: b.window, c: 'suggestion' }, { t: ' ' }, change, { t: ' last turn', d: true }]]),
- ...(b.heaviest.length ? [[] as Row, ...b.heaviest.map(item => spread([{ t: '▸ ', d: true }, { t: item.name }], [{ t: tokens(item.tokens), d: true }], inner))] : []),
+ ...(b.heaviest.length ? [[] as Row, ...b.heaviest.map(item => spread([{ t: '▸ ', d: true }, { t: item.name }], [{ t: tokens(item.tokens), d: true, keep: true }], inner))] : []),
]
// Limits: each window's meter, its reset, and whether the current pace lasts until then.
const limitRows: Row[] = []
const tones: string[] = []
- const glance: Row = [{ t: 'ctx ', d: true }, { t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true }]
+ const glance: Row = [{ t: 'ctx ', d: true }, { t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true, keep: true }]
for (const win of WINDOWS) {
const limit = r.limits.find(l => l.kind === win.kind)
if (!limit) continue
@@ -557,18 +561,20 @@ export const register: Register = (on, options) => {
{ t: ` ${pct} `, c: tone, b: true, w: 6, right: true },
{ t: reset, d: true, w: 8, right: true },
])
+ // The pace line starts under the meter: three cells on the grid, the label's column elsewhere.
+ const under: Seg = isGrid ? { t: ' ' } : { t: '', w: 3 }
limitRows.push(
p.kind === 'measuring'
- ? [{ t: ' measuring pace…', d: true }]
+ ? [under, { t: 'measuring pace…', d: true }]
: p.kind === 'full'
- ? [{ t: ` full in ~${duration(p.inMs)} at this pace`, c: tone }]
- : [{ t: ' lasts until the reset at this pace', d: true }],
+ ? [under, { t: `full in ~${duration(p.inMs)} at this pace`, c: tone }]
+ : [under, { t: 'lasts until the reset at this pace', d: true }],
)
- glance.push({ t: ` ${win.label} `, d: true }, { t: `${Math.round(limit.percentUsed)}%`, c: tone, b: true })
+ glance.push({ t: ` ${win.label} `, d: true }, { t: `${Math.round(limit.percentUsed)}%`, c: tone, b: true, keep: true })
}
// toTimeString starts with the local HH:MM:SS.
const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
- if (sent.length) limitRows.push([], ...sent.map(n => [{ t: `${hhmm(n.at)} `, d: true }, { t: `note sent: ${n.text}` }]))
+ if (sent.length) limitRows.push([], ...sent.map(n => [{ t: `${hhmm(n.at)} `, d: true, keep: true }, { t: `note sent: ${n.text}` }]))
const limitTone = tones.includes('error') ? 'error' : tones.includes('warning') ? 'warning' : 'success'
// Tools and Requests share the rows left below; each keeps at least three.
@@ -584,21 +590,28 @@ export const register: Register = (on, options) => {
? shown.map(t => {
const icon = t.ok === undefined ? { t: '◌', c: 'warning' } : t.ok ? { t: '✓', c: 'success' } : { t: '✗', c: 'error' }
const time = t.ms === undefined ? '…' : elapsed(t.ms)
- return spread([icon, { t: ` ${(t.sub ? `↳${t.name}` : t.name).padEnd(7)} `, b: !t.sub, d: t.sub, w: 11 }, { t: t.target.slice(0, inner - 16), d: true }], [{ t: time, d: true }], inner)
+ // Off the grid the surface cuts the target, and the time never gives way.
+ return spread(
+ [{ ...icon, keep: true }, { t: ` ${(t.sub ? `↳${t.name}` : t.name).padEnd(7)} `, b: !t.sub, d: t.sub, w: 11 }, { t: isGrid ? t.target.slice(0, inner - 16) : t.target, d: true }],
+ [{ t: time, d: true, keep: true }],
+ inner,
+ )
})
: [[{ t: 'No tool calls yet.', d: true }]]
const toolTone = shown.find(t => t.ok !== undefined)?.ok === false ? 'error' : 'success'
// A right-aligned column n wide.
const num = (t: string, n: number, s: Omit = {}): Seg => ({ ...s, t: t.padStart(n), w: n, right: true })
+ // The loop column; off the grid it gives way, so the four numbers stay inside a narrow pane.
+ const loop = (s: Seg): Row => (isGrid ? [s] : [{ ...s, t: s.t.trimEnd(), w: undefined }, { t: '', fill: true }])
const askRows: Row[] = asks.length
? [
- [{ t: 'loop'.padEnd(10), d: true, w: 10 }, num('in', 6, { d: true }), num('out', 6, { d: true }), num('cache', 7, { d: true }), num('time', 7, { d: true })],
+ [...loop({ t: 'loop'.padEnd(10), d: true, w: 10 }), num('in', 6, { d: true }), num('out', 6, { d: true }), num('cache', 7, { d: true }), num('time', 7, { d: true })],
...asks.slice(0, askCount).map(q => {
const hit = q.input ? Math.round((q.cached / q.input) * 100) : 0
const cacheTone = hit < 20 ? 'error' : hit < 50 ? 'warning' : undefined
return [
- { t: q.agent.padEnd(10).slice(0, 10), b: q.agent === 'main', d: q.agent !== 'main', w: 10 },
+ ...loop({ t: isGrid ? q.agent.padEnd(10).slice(0, 10) : q.agent, b: q.agent === 'main', d: q.agent !== 'main', w: 10 }),
num(tokens(q.input), 6),
num(tokens(q.output), 6),
num(`${hit}%`, 7, { c: cacheTone, d: cacheTone === undefined }),
diff --git a/session-meter/tests/session-meter.test.ts b/session-meter/tests/session-meter.test.ts
index 1f3a807..fdb6bdf 100644
--- a/session-meter/tests/session-meter.test.ts
+++ b/session-meter/tests/session-meter.test.ts
@@ -203,3 +203,34 @@ test('a headless session gets no context_reset tool and the suggest note', { opt
expect(text).toContain('suggest /compact')
expect(text).not.toContain('context_reset')
})
+
+const PANE = { title: 'Context', isFocused: false, bodyColumns: 40, placement: 'dock', scroll: { offset: 0, bodyRows: 60 }, view: {} } as const
+
+test('off the terminal only names, targets and labels give way; numbers and titles stay whole', async ($, on) => {
+ mock.clock(on, { now: Date.parse('2026-10-02T08:00:00Z') })
+ const held: Record = {
+ breakdown: {
+ percent: 42,
+ tokens: 84_000,
+ window: 200_000,
+ threshold: 160_000,
+ categories: [{ name: 'Messages and tool results of this session', tokens: 60_000, color: 'suggestion', kind: 'used' }],
+ heaviest: [{ name: 'A memory file with a long name.md', tokens: 9_000 }],
+ },
+ reading: at(42, 30),
+ tools: [{ id: 't1', name: 'Bash', target: 'npm run build -- --filter every-package-in-this-workspace --verbose', sub: false, at: 0, ms: 3_500, ok: true }],
+ requests: [{ at: 0, agent: 'general-purpose', input: 50_000, cached: 40_000, output: 900, ms: 4_200, model: 'claude-a-model-name-long-enough-to-crowd-the-title' }],
+ }
+ on('state.get', (_$, e) => ({ value: { value: held[e.key], version: 1 } }))
+ const ui = await $.ui.mount({ plugin: 'session-meter', surface: 'desktop', component: 'Pane', props: PANE, requestId: 'ctx' })
+ const texts = (await ui.findAll({ type: 'Text' })).map(r => r.text)
+ const kept = (await ui.findAll({ type: 'Box' })).filter(b => b.props.flexShrink === 0).map(b => b.text)
+ await ui.unmount()
+ // The long parts arrive whole for the surface to cut.
+ for (const t of ['Messages and tool results of this session', 'npm run build -- --filter every-package-in-this-workspace --verbose', 'general-purpose', 'a-model-name-long-enough-to-crowd-the-title'])
+ expect(texts).toContain(t)
+ for (const t of ['Context', 'Limits', 'Tools', 'Requests', '84.0k', '42%', '60.0k', '160k', '9.0k', '30%', '✓', '3.5s']) expect(kept).toContain(t)
+ // The pace line starts in the limit label's column, not after three spaces.
+ expect(texts).toContain('measuring pace…')
+ expect(texts.some(t => t.startsWith(' measuring'))).toBe(false)
+})