Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
- 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)
- 8 markdown themes (`bright`, `forest`, `sunset`, `ocean`, `neon`, `autumn`, `candy`, `mono`) with distinct colour palettes; press `[t]` to cycle
- 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
- 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”)
- Add changes here. The first bullet under each release is used as the short “what’s new” message in the app’s update notice.

## 1.1.2 - 2026-04-19
Expand Down
203 changes: 134 additions & 69 deletions src/screens/TrackerBoardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -694,11 +694,14 @@ export default function TrackerBoardScreen({
}

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

// Group columns
const planColIndices: number[] = [];
Expand Down Expand Up @@ -948,6 +951,7 @@ export default function TrackerBoardScreen({
)}
{!inputActive && (
<Footer
termCols={termCols}
hasSession={!!currentItemSession}
hasWorktree={hasWorktree}
hasItem={!!currentItem}
Expand All @@ -959,74 +963,135 @@ export default function TrackerBoardScreen({
);
}

const Footer = React.memo(function Footer({hasSession, hasWorktree, hasItem, inactive}: {hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean}) {
const sep = <Text dimColor> · </Text>;
// One footer hint, e.g. "↵ open" or "a attach". `plain` is the visible width
// used for packing decisions; `render` returns the styled node. Chunks are
// atomic — packing never breaks one across rows, so narrow terminals get a
// clean multi-row footer instead of mid-word wrapping.
type FooterChunk = {key: string; plain: string; render: (ctx: FooterState) => React.ReactNode};
type FooterState = {hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean};

const CHUNK_SEP = ' · ';

// Chunks are listed in display order. The list is structurally constant —
// hint visibility (hasItem / hasWorktree / hasSession) only changes a chunk's
// colour/dim treatment, never its `plain` width. That keeps the packed row
// count stable across cursor moves and removes the up/down board flicker.
function buildFooterChunks(): FooterChunk[] {
const dim = (s: string) => <Text dimColor>{s}</Text>;
const k = (label: string, color: string | undefined = 'magenta', bold = false) => (
<Text color={color} bold={bold}>{label}</Text>
);
return [
{
key: 'nav',
plain: 'nav ←/→ cols ↑/↓ items',
render: () => (<>{dim('nav ')}{k('←/→')}{dim(' cols ')}{k('↑/↓')}{dim(' items')}</>),
},
{
key: 'open',
plain: '↵ open',
render: () => (<>{k('↵')}{dim(' open')}</>),
},
{
key: 'attach',
plain: 'a attach',
render: ({hasItem, hasSession}) => hasItem
? <>{k('a', hasSession ? 'yellow' : 'magenta', hasSession)}<Text color={hasSession ? 'yellow' : undefined} bold={hasSession} dimColor={!hasSession}> attach</Text></>
: <Text dimColor>a attach</Text>,
},
{
key: 'shell',
plain: 's shell',
render: ({hasWorktree}) => hasWorktree
? <>{k('s')}{dim(' shell')}</>
: <Text dimColor>s shell</Text>,
},
{
key: 'run',
plain: 'x run',
render: ({hasWorktree}) => hasWorktree
? <>{k('x')}{dim(' run')}</>
: <Text dimColor>x run</Text>,
},
{
key: 'diff',
plain: 'd diff',
render: ({hasWorktree}) => hasWorktree
? <>{k('d')}{dim(' diff')}</>
: <Text dimColor>d diff</Text>,
},
{
key: 'archive',
plain: 'archive',
render: ({hasItem}) => hasItem
? <>{dim('archi')}{k('v')}{dim('e')}</>
: <Text dimColor>archive</Text>,
},
{
key: 'new',
plain: 'n new',
render: () => <>{k('n')}{dim(' new')}</>,
},
{
key: 'inactive',
// Same width for both labels (inactive=8, activate=8) so row count
// doesn't flip when the user toggles inactive on the selected item.
plain: 'i inactive',
render: ({hasItem, inactive}) => hasItem
? <>{k('i')}{dim(inactive ? ' activate' : ' inactive')}</>
: <Text dimColor>i inactive</Text>,
},
{key: 'proposals', plain: 'p proposals', render: () => <>{k('p')}{dim(' proposals')}</>},
{key: 'stages', plain: 'e stages', render: () => <>{k('e')}{dim(' stages')}</>},
{key: 'config', plain: 'c config', render: () => <>{k('c')}{dim(' config')}</>},
{key: 'project', plain: 'P switch project', render: () => <>{k('P')}{dim(' switch project')}</>},
{key: 'worktrees', plain: 't worktrees', render: () => <>{k('t')}{dim(' worktrees')}</>},
{key: 'back', plain: 'q back', render: () => <>{k('q')}{dim(' back')}</>},
];
}

// Greedy packing: fill each row up to `maxWidth`, separated by CHUNK_SEP.
// Chunks are atomic — never split mid-label. Exported for the colHeight
// calc (so the board reserves the right number of rows) and for the
// Footer's own render pass.
export function packFooterChunks(chunks: FooterChunk[], maxWidth: number): FooterChunk[][] {
const rows: FooterChunk[][] = [];
let cur: FooterChunk[] = [];
let curWidth = 0;
for (const ch of chunks) {
const sep = cur.length > 0 ? CHUNK_SEP.length : 0;
const next = curWidth + sep + ch.plain.length;
if (next > maxWidth && cur.length > 0) {
rows.push(cur);
cur = [ch];
curWidth = ch.plain.length;
} else {
cur.push(ch);
curWidth = next;
}
}
if (cur.length > 0) rows.push(cur);
return rows;
}

const Footer = React.memo(function Footer({termCols, hasSession, hasWorktree, hasItem, inactive}: {termCols: number; hasSession: boolean; hasWorktree: boolean; hasItem: boolean; inactive: boolean}) {
const chunks = buildFooterChunks();
const state: FooterState = {hasSession, hasWorktree, hasItem, inactive};
// -2 for the wrapping Box's paddingX={1}; floor at 20 so degenerate widths
// still pack one-chunk-per-row instead of dividing by zero.
const rows = packFooterChunks(chunks, Math.max(20, termCols - 2));
return (
<Box>
<Text dimColor>nav </Text>
<Text color="magenta">←/→</Text>
<Text dimColor> cols </Text>
<Text color="magenta">↑/↓</Text>
<Text dimColor> items</Text>
{sep}
<Text color="magenta">↵</Text>
<Text dimColor> open</Text>
{hasItem && (
<>
<Text> </Text>
<Text color={hasSession ? 'yellow' : 'magenta'} bold={hasSession}>a</Text>
<Text color={hasSession ? 'yellow' : undefined} dimColor={!hasSession}> attach</Text>
</>
)}
{hasWorktree && (
<>
{sep}
<Text color="magenta">s</Text>
<Text dimColor> shell</Text>
<Text> </Text>
<Text color="magenta">x</Text>
<Text dimColor> run</Text>
<Text> </Text>
<Text color="magenta">d</Text>
<Text dimColor> diff</Text>
</>
)}
{hasItem && (
<>
<Text> </Text>
<Text dimColor>archi</Text>
<Text color="magenta">v</Text>
<Text dimColor>e</Text>
</>
)}
{sep}
<Text color="magenta">n</Text>
<Text dimColor> new</Text>
{hasItem && (
<>
<Text> </Text>
<Text color="magenta">i</Text>
<Text dimColor>{inactive ? ' activate' : ' inactive'}</Text>
</>
)}
{sep}
<Text color="magenta">p</Text>
<Text dimColor> proposals</Text>
<Text> </Text>
<Text color="magenta">e</Text>
<Text dimColor> stages</Text>
<Text> </Text>
<Text color="magenta">c</Text>
<Text dimColor> config</Text>
<Text> </Text>
<Text color="magenta">P</Text>
<Text dimColor> switch project</Text>
<Text> </Text>
<Text color="magenta">t</Text>
<Text dimColor> worktrees</Text>
<Text> </Text>
<Text color="magenta">q</Text>
<Text dimColor> back</Text>
<Box flexDirection="column">
{rows.map((row, ri) => (
<Box key={ri}>
{row.map((ch, ci) => (
<React.Fragment key={ch.key}>
{ci > 0 && <Text dimColor>{CHUNK_SEP}</Text>}
{ch.render(state)}
</React.Fragment>
))}
</Box>
))}
</Box>
);
});
Expand Down
Loading
Loading