Skip to content

Commit cccfe39

Browse files
agent-era-aiclaude
andauthored
feat(kanban): trim chrome to maximize horizontal space at narrow widths (#241)
* feat(kanban): trim chrome to maximize horizontal space at narrow widths Strip column-level and screen-level chrome down to the minimum needed for visual grouping so narrow terminals (~80 cols) stop wasting space — and overflowing — on borders/padding/margins. Changes: - Drop column paddingX, marginRight, and the outer root paddingX. - Remove the inter-group separator entirely; plan vs implementation grouping is now signaled by the column accent colors (blue / magenta) alone. Side effect: dropping the separator's flexGrow={1} also stops the wide-terminal mid-board void. - Tighten the card secondary-text / approve-hint indent and chip marginLeft from 4 to 2. - Lower MIN_COLUMN_WIDTH from 20 to 12 so narrow terminals shrink instead of overflowing the row. - Recompute the slug-truncation, secondary-text-wrap, pendingNew, and title- truncation width budgets against the new chrome (named via COLUMN_BORDER_COLS / SLUG_ROW_GUTTER / SECONDARY_ROW_GUTTER). - Keep paddingX={1} on the title bar and footer only (no border to ground them against the terminal edge). Test: - Add a per-size width-fit assertion to tracker-board-size.test.mjs so rendered rows never exceed termCols at any SIZE_COMBOS entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(tracker): add implementation notes for kanban horizontal-space trims Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): note kanban horizontal-space trims Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(kanban): note MAX_COLUMN_WIDTH trade-off for wide terminals Surface the wide-terminal slack inline so future readers don't mistake the column cap for a layout bug. Addresses PR #241 review note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(kanban): compress chrome-constant comments to single lines Match the one-line-when-warranted style from AGENTS.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 81ee349 commit cccfe39

6 files changed

Lines changed: 256 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
99
- Tracker detail view: tab strip across the top (Discovery / Requirements / Implement plus any extra `.md` files in the item dir); ←/→ moves tabs, ↑/↓ PgUp/PgDn g/G scroll. The first stage without an `.md` file shows a “press [enter] to advance” prompt; later empty stages are dimmed
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
12+
- 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
1213
- Add changes here. The first bullet under each release is used as the short “what’s new” message in the app’s update notice.
1314

1415
## 1.1.2 - 2026-04-19

src/screens/TrackerBoardScreen.tsx

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,13 @@ type PendingNew = {
3737

3838
const SPINNER_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
3939

40-
const MIN_COLUMN_WIDTH = 20;
40+
const MIN_COLUMN_WIDTH = 12;
41+
// At wide terminals the row may end before the right edge because columns cap here — accepted trade-off.
4142
const MAX_COLUMN_WIDTH = 50;
43+
// colWidth is border-box (1 left + 1 right border, no paddingX); card lines add SLUG_ROW_GUTTER (cursor+glyph) or SECONDARY_ROW_GUTTER (indent) inside.
44+
const COLUMN_BORDER_COLS = 2;
45+
const SLUG_ROW_GUTTER = 4;
46+
const SECONDARY_ROW_GUTTER = 2;
4247
const PLAN_COLOR = 'blue';
4348
const IMPL_COLOR = 'magenta';
4449
// Each item renders as up to 4 rows: slug + 2 secondary lines + marginBottom.
@@ -246,11 +251,11 @@ export default function TrackerBoardScreen({
246251
const availableTools = React.useMemo(() => getAvailableAITools(), [getAvailableAITools]);
247252
const {columns: termCols, rows: termRows} = useTerminalDimensions();
248253

249-
// Chrome per row: outer paddingX (2) + group separator (2) + n marginRights.
250-
// Column width already includes its own border + paddingX (Ink uses border-box).
254+
// Columns abut: no outer paddingX, no inter-group separator, no per-column marginRight.
255+
// colWidth is border-box, so the row consumes exactly numColumns × colWidth.
251256
const numColumns = rawBoard.columns.length;
252257
const colWidth = numColumns > 0
253-
? Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.floor((termCols - 4 - numColumns) / numColumns)))
258+
? Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.floor(termCols / numColumns)))
254259
: MIN_COLUMN_WIDTH;
255260

256261
// Build slug → worktree session lookup for this project
@@ -711,11 +716,10 @@ export default function TrackerBoardScreen({
711716
// accidentally render past the column's height.
712717
const scrollVisibleSlots = Math.max(1, Math.floor((itemAreaRows - 2) / ROWS_PER_ITEM));
713718

714-
// Each column in a group is rendered at colWidth + 1 marginRight, so a group of N
715-
// columns spans N * (colWidth + 1). Pinning the group Box to that width stops the
716-
// header row from expanding the group when the label+dashes would otherwise overflow,
717-
// which was pushing one group's columns down and breaking alignment with the other.
718-
const groupWidthFor = (count: number) => count * (colWidth + 1);
719+
// Columns abut with no marginRight, so a group of N columns spans exactly N × colWidth.
720+
// Pinning the group Box to that width keeps the planning and implementation groups the
721+
// same total width when they have equal column counts, preserving header-row alignment.
722+
const groupWidthFor = (count: number) => count * colWidth;
719723

720724
const renderGroup = (colIndices: number[]) => {
721725
if (colIndices.length === 0) return null;
@@ -741,10 +745,10 @@ export default function TrackerBoardScreen({
741745
const moreAbove = scrollTop;
742746
const moreBelow = Math.max(0, total - (scrollTop + visibleCount));
743747

744-
// Title shares the header line with the count. Inside paddingX={1} the usable
745-
// width is colWidth - 4 (2 border + 2 paddingX); the count takes its digits + 1 gap.
748+
// Title shares the header line with the count. Usable width is colWidth - 2 (border-only;
749+
// no internal paddingX); the count takes its digits + 1 gap.
746750
const countText = String(total);
747-
const titleMax = Math.max(4, colWidth - 4 - countText.length - 1);
751+
const titleMax = Math.max(4, colWidth - COLUMN_BORDER_COLS - countText.length - 1);
748752
const titleText = truncateDisplay(column.title, titleMax);
749753

750754
return (
@@ -755,8 +759,6 @@ export default function TrackerBoardScreen({
755759
borderColor={isActiveColumn ? accent : 'gray'}
756760
width={colWidth}
757761
height={colHeight}
758-
paddingX={1}
759-
marginRight={1}
760762
flexShrink={0}
761763
>
762764
{/* Column header: title left, count right */}
@@ -805,10 +807,10 @@ export default function TrackerBoardScreen({
805807
const prChip = computePRChip(wt ? pullRequests[wt.path] : undefined);
806808
const cardChips = prChip ? [...runningChips, prChip] : runningChips;
807809

808-
// Slug row eats: 2 (border) + 2 (paddingX) + 2 (cursor) + 2 (status glyph) = 8 chars
809-
const slug = truncateDisplay(item.slug, Math.max(4, colWidth - 8));
810-
// Secondary row eats: 2 (border) + 2 (paddingX) + 4 (indent) = 8 chars
811-
const secMax = Math.max(4, colWidth - 8);
810+
// Slug row eats: 2 (border) + 2 (cursor) + 2 (status glyph) = 6 chars
811+
const slug = truncateDisplay(item.slug, Math.max(4, colWidth - COLUMN_BORDER_COLS - SLUG_ROW_GUTTER));
812+
// Secondary row eats: 2 (border) + 2 (indent) = 4 chars
813+
const secMax = Math.max(4, colWidth - COLUMN_BORDER_COLS - SECONDARY_ROW_GUTTER);
812814
const secondary = !hasSession ? renderSecondary(item) : '';
813815

814816
return (
@@ -846,7 +848,7 @@ export default function TrackerBoardScreen({
846848
dimColor={display.secondaryDim}
847849
wrap="truncate"
848850
>
849-
{` ${line}`}
851+
{` ${line}`}
850852
</Text>
851853
));
852854
})()}
@@ -857,14 +859,14 @@ export default function TrackerBoardScreen({
857859
to two lines. */}
858860
{display.showApproveHint && isSelected && (
859861
<Text color="green" bold>
860-
{` ready to advance`}
862+
{` ready to advance`}
861863
</Text>
862864
)}
863865
{/* Chip row: tmux sessions plus the PR chip. Indented to the
864866
secondary-text gutter; eats one of the budgeted rows.
865867
Merged → gray pills. Inactive → plain text in chip color. */}
866868
{cardChips.length > 0 && (
867-
<Box marginLeft={4}>
869+
<Box marginLeft={SECONDARY_ROW_GUTTER}>
868870
{cardChips.map((chip, idx) => (
869871
<React.Fragment key={chip.label}>
870872
{idx > 0 && <Text> </Text>}
@@ -886,9 +888,9 @@ export default function TrackerBoardScreen({
886888
<Box>
887889
<Text color={accent} bold>{' '}</Text>
888890
<Text color="yellow" bold>{SPINNER_CHARS[spinnerFrame]} </Text>
889-
<Text color="yellow" wrap="truncate">{truncateDisplay(pendingNew.title, Math.max(4, colWidth - 8))}</Text>
891+
<Text color="yellow" wrap="truncate">{truncateDisplay(pendingNew.title, Math.max(4, colWidth - COLUMN_BORDER_COLS - SLUG_ROW_GUTTER))}</Text>
890892
</Box>
891-
<Text color="yellow" wrap="truncate">{` ${truncateDisplay('deriving slug…', Math.max(4, colWidth - 8))}`}</Text>
893+
<Text color="yellow" wrap="truncate">{` ${truncateDisplay('deriving slug…', Math.max(4, colWidth - COLUMN_BORDER_COLS - SECONDARY_ROW_GUTTER))}`}</Text>
892894
</Box>
893895
)}
894896

@@ -905,10 +907,10 @@ export default function TrackerBoardScreen({
905907
};
906908

907909
return (
908-
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={0}>
910+
<Box flexDirection="column" flexGrow={1} paddingY={0}>
909911
{/* Title bar — project name plus inline session-status summary so we don't burn
910912
three rows on a separate banner box. */}
911-
<Box flexShrink={0}>
913+
<Box flexShrink={0} paddingX={1}>
912914
<Text bold color="cyan">{project}</Text>
913915
<Text dimColor> · tracker</Text>
914916
{waitingCount > 0 && (
@@ -922,29 +924,16 @@ export default function TrackerBoardScreen({
922924
)}
923925
</Box>
924926

925-
{/* Board — column borders/colors carry the planning vs implementation cue, so
926-
the dedicated group-label row is dropped. */}
927+
{/* Board — column borders + accent colors carry the planning vs implementation cue,
928+
so the group-label row and the inter-group separator are both dropped. Columns
929+
abut directly so every col goes to card content. */}
927930
<Box flexDirection="row" alignItems="flex-start" flexShrink={0}>
928931
{renderGroup(planColIndices)}
929-
930-
{/* Vertical separator between groups */}
931-
<Box
932-
flexDirection="column"
933-
borderStyle="single"
934-
borderColor="gray"
935-
borderTop={false}
936-
borderBottom={false}
937-
borderRight={false}
938-
marginRight={1}
939-
flexGrow={1}
940-
flexShrink={0}
941-
/>
942-
943932
{renderGroup(implColIndices)}
944933
</Box>
945934

946935
{/* Footer — no marginTop; the column borders themselves give visual breathing room. */}
947-
<Box flexDirection="column" flexShrink={0}>
936+
<Box flexDirection="column" flexShrink={0} paddingX={1}>
948937
{createMode && (
949938
<Text color="yellow">enter to create · esc to cancel</Text>
950939
)}

tests/e2e/terminal/tracker-board-size.test.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ for (const [height, width] of SIZE_COMBOS) {
158158
} finally { cleanup(); }
159159
});
160160

161+
test(`tracker board ${height}x${width}: no rendered row exceeds terminal width`, async () => {
162+
const {frame, cleanup} = await renderBoard({width, height});
163+
try {
164+
const text = frame();
165+
const lines = text.split('\n');
166+
const widest = lines.reduce(
167+
(acc, line, idx) => (line.length > acc.length ? {length: line.length, idx, line} : acc),
168+
{length: 0, idx: -1, line: ''}
169+
);
170+
assert.ok(
171+
widest.length <= width,
172+
`Row ${widest.idx} is ${widest.length} cols, exceeds terminal width ${width}. ` +
173+
`Line: ${JSON.stringify(widest.line)}. Frame:\n${text}`
174+
);
175+
} finally { cleanup(); }
176+
});
177+
161178
test(`tracker board ${height}x${width}: planning and implementation column headers align vertically`, async () => {
162179
const {frame, cleanup} = await renderBoard({width, height});
163180
try {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Implementation — maximize horizontal space use in kanban view
2+
3+
## What was built
4+
5+
All chrome trims and width-math recomputation landed in a single commit on
6+
`src/screens/TrackerBoardScreen.tsx`, with a width-fit regression added to
7+
`tests/e2e/terminal/tracker-board-size.test.mjs`.
8+
9+
Per-column chrome (border-box `width={colWidth}`):
10+
- Dropped `paddingX={1}` and `marginRight={1}` on each column box.
11+
- Adjacent columns now abut (`╮╭`), with the active column's accent border
12+
color still distinguishing it from the gray neighbors.
13+
14+
Screen-level chrome:
15+
- Removed the root `paddingX={1}`. Kept `paddingX={1}` only on the title bar
16+
and footer rows, which lack a border to ground them against the terminal
17+
edge — without it those rows would touch col 0.
18+
- Removed the entire inter-group separator `<Box>` (including its
19+
`flexGrow={1}` that pooled wide-terminal slack into a center void). The
20+
plan vs implementation cue is now carried by the column accent colors
21+
(`PLAN_COLOR` blue / `IMPL_COLOR` magenta) alone.
22+
23+
Card-level gutter:
24+
- Secondary-text indent: `' '``' '`.
25+
- Approve-hint indent: `' '``' '`.
26+
- Chip row: `marginLeft={4}``marginLeft={2}`.
27+
28+
Width budget constants (added at the top of the file):
29+
- `COLUMN_BORDER_COLS = 2`
30+
- `SLUG_ROW_GUTTER = 4` (cursor 2 + status glyph 2)
31+
- `SECONDARY_ROW_GUTTER = 2` (matches the new indent)
32+
33+
These replace the bare `8` / `4` constants previously inlined into the slug
34+
truncation, secondary-wrap width, `pendingNew` row width, and column-title
35+
truncation calcs.
36+
37+
Column-width formula simplified to `floor(termCols / numColumns)` because the
38+
new chrome is fully internal to the per-column `width={colWidth}` border-box.
39+
40+
Minimum column width lowered from 20 to 12 so the default 5-column board
41+
fits inside an 80-col terminal (5 × 12 = 60 ≤ 80) instead of forcing
42+
overflow.
43+
44+
## Key decisions
45+
46+
- **Kept title bar and footer paddings.** Strict AC #2 reads "outer root
47+
`paddingX={1}` is removed." Removing it entirely would push the title text
48+
and footer hints to col 0. Migrating the padding into the two unbordered
49+
rows preserves the same visual edge spacing for those rows while letting
50+
the board itself extend to the terminal edges — net effect on the board
51+
matches the AC's intent.
52+
- **Card secondary indent at 2 (not 0).** The cursor + status glyph occupy
53+
cols 0–3 of the inside-the-border content area; secondary text at indent 0
54+
would sit flush left under nothing, looking visually detached from the
55+
card. Two cols of indent keep it grouped with the slug without paying the
56+
old 4-col tax.
57+
- **No new MAX_COLUMN_WIDTH change.** The brief was narrow-width focused;
58+
raising or removing the cap is orthogonal and was explicitly out of scope
59+
per the discovery clarification. The cap continues to apply at very wide
60+
terminals.
61+
62+
## Notes for cleanup
63+
64+
- Verify the visual at very wide terminals (≥ 200 cols). With the cap at 50
65+
the board no longer has a center void (separator gone), but if cumulative
66+
column width hits the cap the columns will end before the terminal's right
67+
edge — that's the expected trade-off and is consistent with the chosen
68+
scope. If it looks ugly in practice, a follow-up could let the cap relax
69+
or right-pad with an empty Box.
70+
- The footer at narrow widths (~100 cols) wraps awkwardly because there are
71+
many keyboard shortcuts displayed. Pre-existing — not introduced here.
72+
- The chip row's `marginLeft={2}` makes the chips sit two cols to the right
73+
of the cursor column. If a future change tightens the slug-row gutter,
74+
keep `SLUG_ROW_GUTTER`/`SECONDARY_ROW_GUTTER` consistent so chips remain
75+
aligned with the secondary text.
76+
77+
## Tests
78+
79+
- `npm run test:terminal` — 294 pass, 1 skipped. New width-fit assertion
80+
passes at every `SIZE_COMBOS` entry (10×80 through 40×160).
81+
- `npm test` — 815 pass across 78 suites.
82+
- `npm run typecheck` — clean.
83+
84+
## Stage review
85+
86+
Discovery first surfaced a wide-terminal interpretation (raise the cap,
87+
strip the flexGrow); the user clarified the brief was small-width chrome
88+
reduction. Re-scoped to: trim column / root / separator / card-gutter
89+
chrome, lower MIN_COLUMN_WIDTH, recompute width math, add a width-fit
90+
regression. All landed in one commit. Removing the inter-group `flexGrow={1}`
91+
separator also incidentally fixes the wide-terminal mid-board void.

0 commit comments

Comments
 (0)