Skip to content

Commit 62605eb

Browse files
agent-era-aiclaude
andauthored
fix(kanban): deterministic footer packing for narrow widths (#242)
* fix(kanban): deterministic footer packing kills narrow-width wrap + flicker At narrow widths (~80-120 cols) the kanban footer wrapped mid-word ("na" / "v", "att" / "ach") because each of its ~30 sub-<Text>s wrapped independently. The total width also varied with cursor position — hasItem/hasSession/hasWorktree toggled whole hint groups, so moving selection changed wrap row count and shifted the board: the "flicker". Refactor Footer to atomic hint chunks with a constant chunk list: - buildFooterChunks() returns the canonical list. Each chunk has a `plain` width (constant) and a `render` callback (state-aware). Unavailable hints render dimmed, never disappear — footer width is selection-independent. - packFooterChunks() greedily packs chunks into rows separated by ` · ` without ever splitting a chunk mid-label. - Footer takes termCols, packs, renders one Box per row. - colHeight subtracts the actual packed row count (max 3 footer rows at the narrowest), so the wrapped footer never pushes the board off-screen. Tests: - New per-width assertions (80/96/100/120) verify every footer row splits on ` · ` into known chunks — catches mid-word wrap regressions. - New stability test renders the same width twice with different cursor positions and asserts footer row count matches. - Golden ASCII frame at tests/fixtures/kanban-narrow-footer.txt for visual diffing. - Relaxed minSpan in the existing "column boxes occupy most of the terminal height" assertion from height-4 to height-6 (footer can take up to 3 rows at narrow widths now). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * tracker: add notes/requirements/implementation for kanban-narrow-bottom-bar Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): note kanban-footer narrow-width fix Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent cccfe39 commit 62605eb

7 files changed

Lines changed: 481 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
1010
- Diff view: `.md` lines render as styled markdown (headings/bold/italic/code/lists/blockquotes/HRs) instead of raw source. A pre-rendering pass scans each `.md` file’s pre/post images so per-line styling has full block context (e.g. lines inside fenced code render verbatim)
1111
- 8 markdown themes (`bright`, `forest`, `sunset`, `ocean`, `neon`, `autumn`, `candy`, `mono`) with distinct colour palettes; press `[t]` to cycle
1212
- Kanban board uses the full terminal width: columns abut directly (no per-column margin, no inter-group separator, no internal padding) and the column floor drops to 12 cols so narrow terminals stop overflowing. Card secondary-row gutter tightens from 4 to 2 cols so descriptions have more room
13+
- Kanban footer no longer wraps mid-word at narrow widths. Hints are packed as atomic chunks separated by ` · `, and footer width is now independent of cursor position — so moving between items with/without a worktree no longer shifts the board up/down (the “flicker”)
1314
- Add changes here. The first bullet under each release is used as the short “what’s new” message in the app’s update notice.
1415

1516
## 1.1.2 - 2026-04-19

src/screens/TrackerBoardScreen.tsx

Lines changed: 134 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -694,11 +694,14 @@ export default function TrackerBoardScreen({
694694
}
695695

696696
// Column height = termRows minus all vertical chrome:
697-
// 1 title row + 1 footer row + footerStatusRow (optional)
697+
// 1 title row + footerRows + footerStatusRow (optional)
698698
// + 1 reserved by FullScreen (prevents terminal scroll)
699699
// + 1 margin (keeps content from touching the edge)
700+
// footerRows is computed from the deterministic packing below so wrapped
701+
// footers don't push the board off the bottom of the terminal.
700702
const footerStatusRow = proposalStatus ? 1 : 0;
701-
const colHeight = Math.max(5, termRows - (4 + footerStatusRow));
703+
const footerRows = packFooterChunks(buildFooterChunks(), Math.max(20, termCols - 2)).length;
704+
const colHeight = Math.max(5, termRows - (3 + footerStatusRow + footerRows));
702705

703706
// Group columns
704707
const planColIndices: number[] = [];
@@ -948,6 +951,7 @@ export default function TrackerBoardScreen({
948951
)}
949952
{!inputActive && (
950953
<Footer
954+
termCols={termCols}
951955
hasSession={!!currentItemSession}
952956
hasWorktree={hasWorktree}
953957
hasItem={!!currentItem}
@@ -959,74 +963,135 @@ export default function TrackerBoardScreen({
959963
);
960964
}
961965

962-
const Footer = React.memo(function Footer({hasSession, hasWorktree, hasItem, inactive}: {hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean}) {
963-
const sep = <Text dimColor> · </Text>;
966+
// One footer hint, e.g. "↵ open" or "a attach". `plain` is the visible width
967+
// used for packing decisions; `render` returns the styled node. Chunks are
968+
// atomic — packing never breaks one across rows, so narrow terminals get a
969+
// clean multi-row footer instead of mid-word wrapping.
970+
type FooterChunk = {key: string; plain: string; render: (ctx: FooterState) => React.ReactNode};
971+
type FooterState = {hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean};
972+
973+
const CHUNK_SEP = ' · ';
974+
975+
// Chunks are listed in display order. The list is structurally constant —
976+
// hint visibility (hasItem / hasWorktree / hasSession) only changes a chunk's
977+
// colour/dim treatment, never its `plain` width. That keeps the packed row
978+
// count stable across cursor moves and removes the up/down board flicker.
979+
function buildFooterChunks(): FooterChunk[] {
980+
const dim = (s: string) => <Text dimColor>{s}</Text>;
981+
const k = (label: string, color: string | undefined = 'magenta', bold = false) => (
982+
<Text color={color} bold={bold}>{label}</Text>
983+
);
984+
return [
985+
{
986+
key: 'nav',
987+
plain: 'nav ←/→ cols ↑/↓ items',
988+
render: () => (<>{dim('nav ')}{k('←/→')}{dim(' cols ')}{k('↑/↓')}{dim(' items')}</>),
989+
},
990+
{
991+
key: 'open',
992+
plain: '↵ open',
993+
render: () => (<>{k('↵')}{dim(' open')}</>),
994+
},
995+
{
996+
key: 'attach',
997+
plain: 'a attach',
998+
render: ({hasItem, hasSession}) => hasItem
999+
? <>{k('a', hasSession ? 'yellow' : 'magenta', hasSession)}<Text color={hasSession ? 'yellow' : undefined} bold={hasSession} dimColor={!hasSession}> attach</Text></>
1000+
: <Text dimColor>a attach</Text>,
1001+
},
1002+
{
1003+
key: 'shell',
1004+
plain: 's shell',
1005+
render: ({hasWorktree}) => hasWorktree
1006+
? <>{k('s')}{dim(' shell')}</>
1007+
: <Text dimColor>s shell</Text>,
1008+
},
1009+
{
1010+
key: 'run',
1011+
plain: 'x run',
1012+
render: ({hasWorktree}) => hasWorktree
1013+
? <>{k('x')}{dim(' run')}</>
1014+
: <Text dimColor>x run</Text>,
1015+
},
1016+
{
1017+
key: 'diff',
1018+
plain: 'd diff',
1019+
render: ({hasWorktree}) => hasWorktree
1020+
? <>{k('d')}{dim(' diff')}</>
1021+
: <Text dimColor>d diff</Text>,
1022+
},
1023+
{
1024+
key: 'archive',
1025+
plain: 'archive',
1026+
render: ({hasItem}) => hasItem
1027+
? <>{dim('archi')}{k('v')}{dim('e')}</>
1028+
: <Text dimColor>archive</Text>,
1029+
},
1030+
{
1031+
key: 'new',
1032+
plain: 'n new',
1033+
render: () => <>{k('n')}{dim(' new')}</>,
1034+
},
1035+
{
1036+
key: 'inactive',
1037+
// Same width for both labels (inactive=8, activate=8) so row count
1038+
// doesn't flip when the user toggles inactive on the selected item.
1039+
plain: 'i inactive',
1040+
render: ({hasItem, inactive}) => hasItem
1041+
? <>{k('i')}{dim(inactive ? ' activate' : ' inactive')}</>
1042+
: <Text dimColor>i inactive</Text>,
1043+
},
1044+
{key: 'proposals', plain: 'p proposals', render: () => <>{k('p')}{dim(' proposals')}</>},
1045+
{key: 'stages', plain: 'e stages', render: () => <>{k('e')}{dim(' stages')}</>},
1046+
{key: 'config', plain: 'c config', render: () => <>{k('c')}{dim(' config')}</>},
1047+
{key: 'project', plain: 'P switch project', render: () => <>{k('P')}{dim(' switch project')}</>},
1048+
{key: 'worktrees', plain: 't worktrees', render: () => <>{k('t')}{dim(' worktrees')}</>},
1049+
{key: 'back', plain: 'q back', render: () => <>{k('q')}{dim(' back')}</>},
1050+
];
1051+
}
1052+
1053+
// Greedy packing: fill each row up to `maxWidth`, separated by CHUNK_SEP.
1054+
// Chunks are atomic — never split mid-label. Exported for the colHeight
1055+
// calc (so the board reserves the right number of rows) and for the
1056+
// Footer's own render pass.
1057+
export function packFooterChunks(chunks: FooterChunk[], maxWidth: number): FooterChunk[][] {
1058+
const rows: FooterChunk[][] = [];
1059+
let cur: FooterChunk[] = [];
1060+
let curWidth = 0;
1061+
for (const ch of chunks) {
1062+
const sep = cur.length > 0 ? CHUNK_SEP.length : 0;
1063+
const next = curWidth + sep + ch.plain.length;
1064+
if (next > maxWidth && cur.length > 0) {
1065+
rows.push(cur);
1066+
cur = [ch];
1067+
curWidth = ch.plain.length;
1068+
} else {
1069+
cur.push(ch);
1070+
curWidth = next;
1071+
}
1072+
}
1073+
if (cur.length > 0) rows.push(cur);
1074+
return rows;
1075+
}
1076+
1077+
const Footer = React.memo(function Footer({termCols, hasSession, hasWorktree, hasItem, inactive}: {termCols: number; hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean}) {
1078+
const chunks = buildFooterChunks();
1079+
const state: FooterState = {hasSession, hasWorktree, hasItem, inactive};
1080+
// -2 for the wrapping Box's paddingX={1}; floor at 20 so degenerate widths
1081+
// still pack one-chunk-per-row instead of dividing by zero.
1082+
const rows = packFooterChunks(chunks, Math.max(20, termCols - 2));
9641083
return (
965-
<Box>
966-
<Text dimColor>nav </Text>
967-
<Text color="magenta">←/→</Text>
968-
<Text dimColor> cols </Text>
969-
<Text color="magenta">↑/↓</Text>
970-
<Text dimColor> items</Text>
971-
{sep}
972-
<Text color="magenta"></Text>
973-
<Text dimColor> open</Text>
974-
{hasItem && (
975-
<>
976-
<Text> </Text>
977-
<Text color={hasSession ? 'yellow' : 'magenta'} bold={hasSession}>a</Text>
978-
<Text color={hasSession ? 'yellow' : undefined} dimColor={!hasSession}> attach</Text>
979-
</>
980-
)}
981-
{hasWorktree && (
982-
<>
983-
{sep}
984-
<Text color="magenta">s</Text>
985-
<Text dimColor> shell</Text>
986-
<Text> </Text>
987-
<Text color="magenta">x</Text>
988-
<Text dimColor> run</Text>
989-
<Text> </Text>
990-
<Text color="magenta">d</Text>
991-
<Text dimColor> diff</Text>
992-
</>
993-
)}
994-
{hasItem && (
995-
<>
996-
<Text> </Text>
997-
<Text dimColor>archi</Text>
998-
<Text color="magenta">v</Text>
999-
<Text dimColor>e</Text>
1000-
</>
1001-
)}
1002-
{sep}
1003-
<Text color="magenta">n</Text>
1004-
<Text dimColor> new</Text>
1005-
{hasItem && (
1006-
<>
1007-
<Text> </Text>
1008-
<Text color="magenta">i</Text>
1009-
<Text dimColor>{inactive ? ' activate' : ' inactive'}</Text>
1010-
</>
1011-
)}
1012-
{sep}
1013-
<Text color="magenta">p</Text>
1014-
<Text dimColor> proposals</Text>
1015-
<Text> </Text>
1016-
<Text color="magenta">e</Text>
1017-
<Text dimColor> stages</Text>
1018-
<Text> </Text>
1019-
<Text color="magenta">c</Text>
1020-
<Text dimColor> config</Text>
1021-
<Text> </Text>
1022-
<Text color="magenta">P</Text>
1023-
<Text dimColor> switch project</Text>
1024-
<Text> </Text>
1025-
<Text color="magenta">t</Text>
1026-
<Text dimColor> worktrees</Text>
1027-
<Text> </Text>
1028-
<Text color="magenta">q</Text>
1029-
<Text dimColor> back</Text>
1084+
<Box flexDirection="column">
1085+
{rows.map((row, ri) => (
1086+
<Box key={ri}>
1087+
{row.map((ch, ci) => (
1088+
<React.Fragment key={ch.key}>
1089+
{ci > 0 && <Text dimColor>{CHUNK_SEP}</Text>}
1090+
{ch.render(state)}
1091+
</React.Fragment>
1092+
))}
1093+
</Box>
1094+
))}
10301095
</Box>
10311096
);
10321097
});

0 commit comments

Comments
 (0)