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) +})