From 90b2e54f5b796e5d32310378b938da5f11d801ab Mon Sep 17 00:00:00 2001 From: arasovic Date: Sat, 10 Oct 2026 22:57:22 +0300 Subject: [PATCH] fix(change-ledger): keep counts, ages and titles whole on desktop On surfaces that draw text in a proportional font every part of a row could shrink, so line counts, the edit count and age, ahead and behind and frame titles could read "+1..". They stay whole now; only paths, folders, agents, the branch and a frame's right label give way. --- change-ledger/.claude-plugin/plugin.json | 2 +- change-ledger/hooks/register.tsx | 32 ++++++++++++++--------- change-ledger/tests/change-ledger.test.ts | 29 +++++++++++++++++++- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/change-ledger/.claude-plugin/plugin.json b/change-ledger/.claude-plugin/plugin.json index 124ddc5..f8090fd 100644 --- a/change-ledger/.claude-plugin/plugin.json +++ b/change-ledger/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "change-ledger", - "version": "0.1.4", + "version": "0.1.5", "description": "Lists the files this session edited in a pane (/changes), with line counts and who edited them, beside the git working tree", "author": { "name": "Mehmet Aras", "url": "https://github.com/arasovic" }, "repository": "https://github.com/arasovic/claude-code-mods", diff --git a/change-ledger/hooks/register.tsx b/change-ledger/hooks/register.tsx index 88ee597..95e83e1 100644 --- a/change-ledger/hooks/register.tsx +++ b/change-ledger/hooks/register.tsx @@ -102,9 +102,9 @@ const refreshGit = async ($: EngineInterface) => { // 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), and a Bar draws `parts` -// as real bars in place of its `cells`, `w` wide or stretching. -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, and a Bar draws `parts` as real bars in place of its `cells`, `w` wide or stretching. +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 }[]; w?: number } type Row = (Seg | Bar)[] @@ -195,7 +195,7 @@ export const register: Register = on => { } 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 = '') => { @@ -203,9 +203,11 @@ export const register: Register = on => { return ( - {title} + + {title} + - {right ? {right} : null} + {right ? {right} : null} {rows.map(line)} @@ -241,9 +243,15 @@ export const register: Register = on => { const added = list.reduce((n, f) => n + f.added, 0) const removed = list.reduce((n, f) => n + f.removed, 0) const glance: Row = list.length - ? [{ t: `${list.length} ${list.length === 1 ? 'file' : 'files'} `, b: true }, { t: `+${added}`, c: 'success' }, { t: ` −${removed}`, c: 'error' }] + ? [{ t: `${list.length} ${list.length === 1 ? 'file' : 'files'} `, b: true, keep: true }, { t: `+${added}`, c: 'success', keep: true }, { t: ` −${removed}`, c: 'error', keep: true }] : [{ t: 'No edits yet', d: true }] - if (tree) glance.push({ t: ` ${tree.branch}`, c: 'suggestion' }, ...(tree.ahead ? [{ t: ` ↑${tree.ahead}`, d: true }] : []), ...(tree.behind ? [{ t: ` ↓${tree.behind}`, c: 'warning' }] : [])) + // Off the grid only the branch name gives way. + if (tree) + glance.push( + { t: ` ${tree.branch}`, c: 'suggestion' }, + ...(tree.ahead ? [{ t: ` ↑${tree.ahead}`, d: true, keep: true }] : []), + ...(tree.behind ? [{ t: ` ↓${tree.behind}`, c: 'warning', keep: true }] : []), + ) // The git frame lists every changed path: the ones this session edited get a filled dot, the rest (shell, you) an empty one. const touched = new Set(list.map(f => f.path)) @@ -253,8 +261,8 @@ export const register: Register = on => { ? tree.files.slice(0, GIT_MAX).map(f => { const mine = touched.has(`${tree.root}/${f.path}`) const tone = f.status === '??' || f.status === 'A' ? 'success' : f.status === 'D' ? 'error' : 'warning' - const right: Row = f.added === undefined ? [{ t: f.status === '??' ? 'new' : '', d: true }] : counts(f.added, f.removed ?? 0) - return spread([{ t: mine ? '● ' : '○ ', c: mine ? 'suggestion' : undefined, d: !mine }, { t: f.status.padEnd(3), c: tone, w: 3 }, { t: f.path, d: !mine }], right, inner) + const right: Row = f.added === undefined ? [{ t: f.status === '??' ? 'new' : '', d: true, keep: true }] : counts(f.added, f.removed ?? 0) + return spread([{ t: mine ? '● ' : '○ ', c: mine ? 'suggestion' : undefined, d: !mine, keep: true }, { t: f.status.padEnd(3), c: tone, w: 3 }, { t: f.path, d: !mine }], right, inner) }) : [[{ t: 'Working tree clean.', d: true }]] : [] @@ -269,7 +277,7 @@ export const register: Register = on => { ? shown.flatMap((f, i) => { const bar = statBar(f.added, f.removed, max, 8) const top = spread( - [{ t: name(f.path), b: true }, ...(f.created ? [{ t: ' new', c: 'success' }] : [])], + [{ t: name(f.path), b: true }, ...(f.created ? [{ t: ' new', c: 'success', keep: true }] : [])], [ { cells: [{ t: '■'.repeat(bar.plus), c: 'success' }, { t: '■'.repeat(bar.minus), c: 'error' }, { t: '·'.repeat(bar.rest), d: true }], @@ -280,7 +288,7 @@ export const register: Register = on => { ], inner, ) - const sub = spread([{ t: ` ${folder(f.path, cwd)}`, d: true }], [{ t: `${f.agents.join(', ')} · ${f.edits}× · ${ago(now - f.at)}`, d: true }], inner) + const sub = spread([{ t: ` ${folder(f.path, cwd)}`, d: true }], [{ t: f.agents.join(', '), d: true }, { t: ` · ${f.edits}× · ${ago(now - f.at)}`, d: true, keep: true }], inner) return i ? [[], top, sub] : [top, sub] }) : [[{ t: 'Files Claude edits or writes show here.', d: true }]] diff --git a/change-ledger/tests/change-ledger.test.ts b/change-ledger/tests/change-ledger.test.ts index ea7d124..c83218a 100644 --- a/change-ledger/tests/change-ledger.test.ts +++ b/change-ledger/tests/change-ledger.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'claude-code/testing' +import { expect, mock, test } from 'claude-code/testing' +import type { LedgerFile, LedgerGit } from '../types' import { ago, folder, lineDelta, lines, parseNumstat, parseStatus, statBar } from '../hooks/register' @@ -47,3 +48,29 @@ test('the stat bar scales to the largest change and keeps a cell for a tiny one' expect(statBar(0, 0, 10, 8)).toEqual({ plus: 0, minus: 0, rest: 8 }) expect(ago(90_000)).toBe('1m ago') }) + +const PANE = { title: 'Changes', isFocused: false, bodyColumns: 40, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} } as const + +test('off the terminal only paths, folders, agents and the branch give way', async ($, on) => { + mock.clock(on, { now: 90_000 }) + on('session.cwd', () => ({ value: '/repo' })) + const files: LedgerFile[] = [ + { path: '/repo/src/components/settings/a-rather-long-file-name.tsx', added: 120, removed: 30, edits: 3, at: 0, agents: ['main', 'general-purpose'], created: true }, + ] + const git: LedgerGit = { + root: '/repo', + branch: 'fix/a-branch-name-long-enough-to-run-past-the-pane', + ahead: 2, + behind: 1, + files: [{ status: '??', path: 'notes/an-untracked-file-with-a-long-name.md' }], + } + const held: Record = { files, git } + on('state.get', (_$, e) => ({ value: { value: held[e.key], version: 1 } })) + const ui = await $.ui.mount({ plugin: 'change-ledger', surface: 'desktop', component: 'Pane', props: PANE, requestId: 'changes' }) + 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 [' fix/a-branch-name-long-enough-to-run-past-the-pane', 'notes/an-untracked-file-with-a-long-name.md', 'main, general-purpose']) expect(texts).toContain(t) + for (const t of ['1 file ', '+120', ' −30', ' ↑2', ' ↓1', 'Edits', ' new', ' · 3× · 1m ago', 'Working tree', 'new']) expect(kept).toContain(t) +})