santree 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ function buildTree(files) {
5
+ const root = { kind: "dir", name: "", children: [] };
6
+ for (const file of files) {
7
+ const parts = file.path.split("/");
8
+ let cursor = root;
9
+ for (let i = 0; i < parts.length; i++) {
10
+ const part = parts[i];
11
+ const isLeaf = i === parts.length - 1;
12
+ if (isLeaf) {
13
+ cursor.children.push({ kind: "file", name: part, file });
14
+ }
15
+ else {
16
+ let next = cursor.children.find((c) => c.kind === "dir" && c.name === part);
17
+ if (!next) {
18
+ next = { kind: "dir", name: part, children: [] };
19
+ cursor.children.push(next);
20
+ }
21
+ cursor = next;
22
+ }
23
+ }
24
+ }
25
+ // Collapse single-child directory chains for compactness.
26
+ collapseChains(root);
27
+ return root;
28
+ }
29
+ function collapseChains(dir) {
30
+ for (const child of dir.children) {
31
+ if (child.kind !== "dir")
32
+ continue;
33
+ while (child.children.length === 1 && child.children[0].kind === "dir") {
34
+ const only = child.children[0];
35
+ child.name = `${child.name}/${only.name}`;
36
+ child.children = only.children;
37
+ }
38
+ collapseChains(child);
39
+ }
40
+ }
41
+ function statusColor(status) {
42
+ switch (status) {
43
+ case "A":
44
+ return "green";
45
+ case "D":
46
+ return "red";
47
+ case "M":
48
+ return "yellow";
49
+ case "R":
50
+ return "magenta";
51
+ case "C":
52
+ return "blue";
53
+ default:
54
+ return "gray";
55
+ }
56
+ }
57
+ function renderTree(dir, depth, rows, fileCounter) {
58
+ const sorted = [...dir.children].sort((a, b) => {
59
+ // Directories first, then files
60
+ if (a.kind !== b.kind)
61
+ return a.kind === "dir" ? -1 : 1;
62
+ return a.name.localeCompare(b.name);
63
+ });
64
+ const indent = " ".repeat(depth);
65
+ for (const child of sorted) {
66
+ if (child.kind === "dir") {
67
+ rows.push({
68
+ prefix: indent,
69
+ label: `${child.name}/`,
70
+ color: "blue",
71
+ bold: true,
72
+ fileIndex: null,
73
+ });
74
+ renderTree(child, depth + 1, rows, fileCounter);
75
+ }
76
+ else {
77
+ const idx = fileCounter.value++;
78
+ rows.push({
79
+ prefix: indent,
80
+ label: `${child.file.status} ${child.name}`,
81
+ color: statusColor(child.file.status),
82
+ fileIndex: idx,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ // Flatten files in the same order as the rendered tree, so fileIndex matches
88
+ // the order users see when navigating with j/k.
89
+ export function flattenTreeFiles(files) {
90
+ const tree = buildTree(files);
91
+ const ordered = [];
92
+ const walk = (dir) => {
93
+ const sorted = [...dir.children].sort((a, b) => {
94
+ if (a.kind !== b.kind)
95
+ return a.kind === "dir" ? -1 : 1;
96
+ return a.name.localeCompare(b.name);
97
+ });
98
+ for (const c of sorted) {
99
+ if (c.kind === "dir")
100
+ walk(c);
101
+ else
102
+ ordered.push(c.file);
103
+ }
104
+ };
105
+ walk(tree);
106
+ return ordered;
107
+ }
108
+ /**
109
+ * Computes the diff overlay layout — body height, pane widths, rendered tree
110
+ * rows, and the effective scroll offset (clamped to keep selection visible).
111
+ *
112
+ * Shared between DiffOverlay (rendering) and the dashboard mouse handler
113
+ * (mapping click coords back to file indices).
114
+ */
115
+ export const DIFF_LEFT_MIN = 20;
116
+ export const DIFF_RIGHT_MIN = 20;
117
+ export const DIFF_DIVIDER_WIDTH = 1;
118
+ export function defaultDiffLeftWidth(width) {
119
+ return Math.min(48, Math.max(24, Math.floor(width * 0.32)));
120
+ }
121
+ export function clampDiffLeftWidth(leftWidth, width) {
122
+ const max = Math.max(DIFF_LEFT_MIN, width - DIFF_DIVIDER_WIDTH - DIFF_RIGHT_MIN);
123
+ return Math.max(DIFF_LEFT_MIN, Math.min(leftWidth, max));
124
+ }
125
+ export function computeDiffLayout(opts) {
126
+ const headerHeight = 2;
127
+ // Keymap footer lives in the dashboard's global CommandBar — don't reserve
128
+ // a row here or we'd render two stacked keymap rows.
129
+ const bodyHeight = Math.max(3, opts.height - headerHeight);
130
+ const requestedLeft = opts.leftWidthOverride ?? defaultDiffLeftWidth(opts.width);
131
+ const leftWidth = clampDiffLeftWidth(requestedLeft, opts.width);
132
+ const rightWidth = Math.max(DIFF_RIGHT_MIN, opts.width - leftWidth - DIFF_DIVIDER_WIDTH);
133
+ const rows = [];
134
+ const tree = buildTree(opts.files);
135
+ renderTree(tree, 0, rows, { value: 0 });
136
+ const selectedRowIdx = rows.findIndex((r) => r.fileIndex === opts.fileIndex);
137
+ const totalRows = rows.length;
138
+ const maxScroll = Math.max(0, totalRows - bodyHeight);
139
+ let effectiveScroll = Math.min(opts.fileScrollOffset, maxScroll);
140
+ if (selectedRowIdx >= 0) {
141
+ if (selectedRowIdx < effectiveScroll) {
142
+ effectiveScroll = selectedRowIdx;
143
+ }
144
+ else if (selectedRowIdx >= effectiveScroll + bodyHeight) {
145
+ effectiveScroll = selectedRowIdx - bodyHeight + 1;
146
+ }
147
+ }
148
+ return { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx };
149
+ }
150
+ function colorizeDiffLine(line) {
151
+ if (line.startsWith("diff --git") || line.startsWith("index ")) {
152
+ return { text: line, color: "yellow", bold: true };
153
+ }
154
+ if (line.startsWith("+++") || line.startsWith("---")) {
155
+ return { text: line, color: "yellow", dim: true };
156
+ }
157
+ if (line.startsWith("@@")) {
158
+ return { text: line, color: "cyan" };
159
+ }
160
+ if (line.startsWith("+")) {
161
+ return { text: line, color: "green" };
162
+ }
163
+ if (line.startsWith("-")) {
164
+ return { text: line, color: "red" };
165
+ }
166
+ return { text: line };
167
+ }
168
+ // Content from external diff tools (delta, diff-so-fancy, etc.) ships its own
169
+ // ANSI escapes. Detecting them lets us skip our manual colorize and render the
170
+ // raw text — Ink passes ANSI through to the terminal natively.
171
+ function looksLikeAnsi(text) {
172
+ return /\x1b\[[0-9;]*[A-Za-z]/.test(text);
173
+ }
174
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
175
+ /**
176
+ * Truncate a line to `max` visible columns, preserving any ANSI escape
177
+ * sequences along the way. Ink's built-in `wrap="truncate"` measures by string
178
+ * length (counting escapes as visible chars) and stops short or, with very long
179
+ * lines, lets content bleed past the box's right edge — which on the diff
180
+ * pane caused lines to spill into the column to the right. Doing the math
181
+ * ourselves avoids both bugs.
182
+ */
183
+ function truncateVisible(s, max) {
184
+ if (max <= 0)
185
+ return "";
186
+ if (s.replace(ANSI_RE, "").length <= max)
187
+ return s;
188
+ let out = "";
189
+ let visible = 0;
190
+ let i = 0;
191
+ while (i < s.length && visible < max - 1) {
192
+ const ch = s[i];
193
+ if (ch === "\x1b" && s[i + 1] === "[") {
194
+ ANSI_RE.lastIndex = i;
195
+ const m = ANSI_RE.exec(s);
196
+ if (m && m.index === i) {
197
+ out += m[0];
198
+ i += m[0].length;
199
+ continue;
200
+ }
201
+ }
202
+ out += ch;
203
+ visible++;
204
+ i++;
205
+ }
206
+ return out + "…";
207
+ }
208
+ // ── Component ─────────────────────────────────────────────────────────
209
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, }) {
210
+ const layout = computeDiffLayout({
211
+ width,
212
+ height,
213
+ files,
214
+ fileIndex,
215
+ fileScrollOffset,
216
+ leftWidthOverride,
217
+ });
218
+ const { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx } = layout;
219
+ const visibleRows = rows.slice(effectiveScroll, effectiveScroll + bodyHeight);
220
+ // Right pane: split content into lines and slice for scroll. If the content
221
+ // already carries ANSI escapes (from an external diff tool), pass them
222
+ // through as-is; otherwise apply our built-in line-prefix colorization.
223
+ // Clamp scroll so the deepest position lands the last line at the bottom.
224
+ const isExternalContent = content ? looksLikeAnsi(content) : false;
225
+ const rawLines = content ? content.split("\n") : [];
226
+ const allLines = isExternalContent
227
+ ? rawLines.map((text) => ({ text }))
228
+ : rawLines.map(colorizeDiffLine);
229
+ const maxContentScroll = Math.max(0, allLines.length - bodyHeight);
230
+ const effectiveContentScroll = Math.min(Math.max(0, contentScrollOffset), maxContentScroll);
231
+ const visibleLines = allLines.slice(effectiveContentScroll, effectiveContentScroll + bodyHeight);
232
+ const totalFiles = files.length;
233
+ const currentFile = files[fileIndex];
234
+ // Pre-compute path truncation. Letting each <Text> rely on wrap="truncate"
235
+ // inside a flex row caused the path to wrap onto a new line when the row
236
+ // overflowed — pushing the tabs above off-screen. Manual truncation on the
237
+ // only variable-length segment keeps the header strictly one line.
238
+ const filesLabel = `(${totalFiles} ${totalFiles === 1 ? "file" : "files"})`;
239
+ const meta = ` ${ticketId} vs ${baseBranch} ${filesLabel}`;
240
+ const sep = " • ";
241
+ const consumed = "Diff".length + meta.length;
242
+ const pathRoom = Math.max(0, width - consumed - sep.length);
243
+ let truncatedPath = "";
244
+ if (currentFile) {
245
+ const p = currentFile.path;
246
+ truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
247
+ }
248
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), _jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
249
+ const absIdx = effectiveScroll + i;
250
+ const isSelected = absIdx === selectedRowIdx;
251
+ const text = `${row.prefix}${row.label}`;
252
+ // Selected row keeps its own color (file-status hue or directory
253
+ // blue) but gets the theme-aware selection bg + bold so it stays
254
+ // readable in light and dark modes alike.
255
+ return (_jsx(Text, { color: row.color, backgroundColor: isSelected ? selectionBg : undefined, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
256
+ })) }), _jsx(Box, { flexDirection: "column", height: bodyHeight, children: Array.from({ length: bodyHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: "\u2502" }, i))) }), _jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: 1, children: loadingContent ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading diff..." })] })) : !currentFile ? (_jsx(Text, { dimColor: true, children: "Select a file" })) : visibleLines.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty diff)" })) : (visibleLines.map((line, i) => {
257
+ // rightWidth includes the paddingLeft={1} of the wrapper Box,
258
+ // so usable column count is rightWidth - 1.
259
+ const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
260
+ return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
261
+ })) })] })] }));
262
+ }
@@ -6,8 +6,25 @@ interface Props {
6
6
  scrollOffset: number;
7
7
  height: number;
8
8
  width: number;
9
- creatingForTicket: string | null;
10
- deletingForTicket: string | null;
9
+ /** Theme-adapted selection background (light/dark). Falls back to dark navy. */
10
+ selectionBg?: string;
11
11
  }
12
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, creatingForTicket, deletingForTicket, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export type ListRow = {
13
+ kind: "header";
14
+ name: string;
15
+ count: number;
16
+ isFirst: boolean;
17
+ } | {
18
+ kind: "status-header";
19
+ name: string;
20
+ type: string;
21
+ count: number;
22
+ } | {
23
+ kind: "issue";
24
+ issue: DashboardIssue;
25
+ flatIndex: number;
26
+ depth: number;
27
+ };
28
+ export declare function buildIssueListRows(groups: ProjectGroup[], flatIssues: DashboardIssue[]): ListRow[];
29
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
13
30
  export {};
@@ -21,130 +21,101 @@ function stateColor(type, name) {
21
21
  return "yellow";
22
22
  }
23
23
  }
24
- function priorityIndicator(priority) {
25
- switch (priority) {
26
- case 1:
27
- return { text: "!!!", color: "red" };
28
- case 2:
29
- return { text: "!! ", color: "yellow" };
30
- case 3:
31
- return { text: "! ", color: "blue" };
32
- case 4:
33
- return { text: "· ", color: "gray" };
34
- default:
35
- return { text: " ", color: "gray" };
36
- }
24
+ /**
25
+ * One-character priority marker, only rendered for urgent (P1) and high (P2)
26
+ * — everything else is a single space so columns still align. This is one of
27
+ * the few places we use color, so it stands out without shouting.
28
+ */
29
+ function priorityMarker(priority) {
30
+ if (priority === 1)
31
+ return { glyph: "", color: "red" };
32
+ if (priority === 2)
33
+ return { glyph: "", color: "yellow" };
34
+ return { glyph: " ", color: "gray" };
35
+ }
36
+ function workIndicator(wt) {
37
+ if (!wt)
38
+ return { glyph: "·", color: "gray" };
39
+ return { glyph: "✓", color: "green" };
37
40
  }
38
- function checksIndicator(checks) {
41
+ function ciIndicator(checks) {
39
42
  if (!checks || checks.length === 0)
40
- return { text: "-", color: "gray" };
43
+ return { glyph: "·", color: "gray" };
41
44
  if (checks.some((c) => c.bucket === "fail"))
42
- return { text: "✗", color: "red" };
45
+ return { glyph: "✗", color: "red" };
43
46
  if (checks.every((c) => c.bucket === "pass"))
44
- return { text: "✓", color: "green" };
45
- return { text: "●", color: "yellow" };
47
+ return { glyph: "✓", color: "green" };
48
+ return { glyph: "●", color: "yellow" };
46
49
  }
47
- function prIndicator(pr) {
48
- if (!pr)
49
- return { text: "-", color: "gray" };
50
- const label = `#${pr.number}`;
51
- if (pr.state === "MERGED")
52
- return { text: label, color: "magenta" };
53
- if (pr.state === "CLOSED")
54
- return { text: label, color: "red" };
55
- if (pr.isDraft)
56
- return { text: label, color: "gray" };
57
- return { text: label, color: "green" };
58
- }
59
- function sessionIndicator(wt, isCreating, isDeleting) {
60
- if (isDeleting)
61
- return { text: " deleting", color: "red" };
62
- if (isCreating)
63
- return { text: " creating", color: "yellow" };
64
- if (!wt)
65
- return { text: " -", color: "gray" };
66
- // Session state takes priority over session ID
67
- if (wt.sessionState === "waiting")
68
- return { text: " waiting!", color: "red" };
69
- if (wt.sessionState === "active")
70
- return { text: " active", color: "green" };
71
- if (wt.sessionState === "idle")
72
- return { text: " idle", color: "yellow" };
73
- if (wt.sessionId)
74
- return { text: " " + wt.sessionId.slice(0, 8), color: "cyan" };
75
- return { text: " none", color: "red" };
76
- }
77
- function buildRows(groups, flatIssues) {
78
- const rows = [{ kind: "columns" }];
79
- // Build a map from issue identifier to flat index
50
+ export function buildIssueListRows(groups, flatIssues) {
51
+ const rows = [];
80
52
  const indexMap = new Map();
81
53
  flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
82
54
  function pushIssueWithChildren(di, depth) {
83
- rows.push({
84
- kind: "issue",
85
- issue: di,
86
- flatIndex: indexMap.get(di.issue.identifier) ?? -1,
87
- depth,
88
- });
55
+ const flatIndex = indexMap.get(di.issue.identifier) ?? -1;
56
+ rows.push({ kind: "issue", issue: di, flatIndex, depth });
89
57
  if (di.children) {
90
58
  for (const child of di.children) {
91
59
  pushIssueWithChildren(child, depth + 1);
92
60
  }
93
61
  }
94
62
  }
95
- for (const group of groups) {
63
+ groups.forEach((group, gi) => {
96
64
  const totalIssues = group.statusGroups.reduce((sum, sg) => sum + sg.issues.length, 0);
97
- rows.push({ kind: "header", name: group.name, count: totalIssues });
65
+ rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
98
66
  for (const sg of group.statusGroups) {
99
67
  rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
100
68
  for (const di of sg.issues) {
101
69
  pushIssueWithChildren(di, 0);
102
70
  }
103
71
  }
104
- }
72
+ });
105
73
  return rows;
106
74
  }
107
- const FOOTER_HEIGHT = 2;
108
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, creatingForTicket, deletingForTicket, }) {
109
- const rows = buildRows(groups, flatIssues);
110
- const listHeight = height - FOOTER_HEIGHT;
111
- const visible = rows.slice(scrollOffset, scrollOffset + listHeight);
112
- // 2 cursor + 2 dot + 4 priority + 11 id + title + 9 session + 1 space + 6 pr + 1 space + 2 checks
113
- const prColWidth = 6;
114
- const checksColWidth = 2;
115
- const sessionColWidth = 9;
116
- const priorityColWidth = 4;
117
- const fixedWidth = 2 + 2 + priorityColWidth + 11 + sessionColWidth + 1 + prColWidth + 1 + checksColWidth;
118
- const titleMaxWidth = Math.max(width - fixedWidth, 10);
119
- const footerRule = "".repeat(width);
120
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: visible.map((row, i) => {
121
- if (row.kind === "columns") {
122
- const labelPad = 14 + priorityColWidth + titleMaxWidth;
123
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "".padEnd(labelPad) }), _jsx(Text, { dimColor: true, children: "session".padStart(sessionColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "pr".padStart(prColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
124
- }
125
- if (row.kind === "header") {
126
- return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, bold: true, children: ["── ", row.name, " (", row.count, ")", " ──"] }) }, `h-${i}`));
127
- }
128
- if (row.kind === "status-header") {
129
- return (_jsx(Box, { children: _jsxs(Text, { color: stateColor(row.type, row.name), dimColor: true, children: [" ", row.name, " (", row.count, ")"] }) }, `sh-${i}`));
130
- }
131
- const { issue, flatIndex, depth } = row;
132
- const selected = flatIndex === selectedIndex;
133
- const di = issue;
134
- const sc = stateColor(di.issue.state.type, di.issue.state.name);
135
- const isCreating = di.issue.identifier === creatingForTicket;
136
- const isDeleting = di.issue.identifier === deletingForTicket;
137
- const sess = sessionIndicator(di.worktree, isCreating, isDeleting);
138
- const ci = checksIndicator(di.checks);
139
- const pr = prIndicator(di.pr);
140
- const prio = priorityIndicator(di.issue.priority);
141
- const cursor = selected ? ">" : " ";
142
- const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
143
- const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
144
- const title = di.issue.title.length > adjustedTitleWidth
145
- ? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
146
- : di.issue.title;
147
- const bg = selected ? "#1e3a5f" : undefined;
148
- return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(adjustedTitleWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
149
- }) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Shift + \u2191\u2193" }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "E" }), _jsx(Text, { color: "white", children: " Workspace" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), _jsx(Text, { color: "white", children: " Refresh" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
75
+ // Issue row anatomy:
76
+ // priority(1) space(1) │ dot(1) space(2) id(11) space(1) title(rest) │ pad │ WT(2) │ space(2) │ CI(2)
77
+ // Each right-side column is 2 chars wide (matching the 2-char header label).
78
+ // Glyphs are 1 char and rendered right-aligned within their column.
79
+ const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
80
+ const RIGHT_FIXED = 2 + 2 + 2; // 6 WT + 2 spaces + CI
81
+ const TITLE_GAP = 2; // minimum spacing between title and the right columns
82
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
83
+ const rows = buildIssueListRows(groups, flatIssues);
84
+ const visible = rows.slice(scrollOffset, scrollOffset + height);
85
+ const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - RIGHT_FIXED - TITLE_GAP, 10);
86
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: height, children: visible.map((row, i) => {
87
+ if (row.kind === "header") {
88
+ // On the first project header, also render the WT/CI column
89
+ // labels right-aligned to the worktree/CI glyph columns
90
+ // keeps the labels discoverable without burning a row.
91
+ // Label "WT CI" is 5 chars; the "W" lines up with the WT
92
+ // glyph at column (width - RIGHT_FIXED + 1).
93
+ const namePart = `${row.name} ${row.count}`;
94
+ const labelText = "WT CI";
95
+ const labelPad = row.isFirst
96
+ ? Math.max(2, width - namePart.length - labelText.length)
97
+ : 0;
98
+ return (_jsxs(Box, { marginTop: i === 0 ? 0 : 1, children: [_jsx(Text, { bold: true, children: row.name }), _jsxs(Text, { dimColor: true, children: [" ", row.count] }), row.isFirst && _jsx(Text, { dimColor: true, children: `${" ".repeat(labelPad)}${labelText}` })] }, `h-${i}`));
99
+ }
100
+ if (row.kind === "status-header") {
101
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: stateColor(row.type, row.name), children: [" ", row.name] }), _jsxs(Text, { dimColor: true, children: [" ", row.count] })] }, `sh-${i}`));
102
+ }
103
+ const { issue, flatIndex, depth } = row;
104
+ const selected = flatIndex === selectedIndex;
105
+ const di = issue;
106
+ const sc = stateColor(di.issue.state.type, di.issue.state.name);
107
+ const prio = priorityMarker(di.issue.priority);
108
+ const work = workIndicator(di.worktree);
109
+ const ci = ciIndicator(di.checks);
110
+ const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
111
+ const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
112
+ const title = di.issue.title.length > adjustedTitleWidth
113
+ ? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
114
+ : di.issue.title;
115
+ const bg = selected ? selectionBg : undefined;
116
+ // Pad between title and the right columns so the W/CI markers stay
117
+ // pinned to the right edge regardless of title length.
118
+ const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - RIGHT_FIXED);
119
+ return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }, di.issue.identifier));
120
+ }) }) }));
150
121
  }
@@ -5,5 +5,11 @@ interface Props {
5
5
  height: number;
6
6
  width: number;
7
7
  }
8
+ export type ReviewActionItem = {
9
+ key: string;
10
+ label: string;
11
+ color: string;
12
+ };
13
+ export declare function buildReviewActions(item: EnrichedReviewPR): ReviewActionItem[];
8
14
  export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
9
15
  export {};
@@ -18,7 +18,7 @@ function relativeTime(dateStr) {
18
18
  const months = Math.floor(days / 30);
19
19
  return `${months}mo ago`;
20
20
  }
21
- function buildActions(item) {
21
+ export function buildReviewActions(item) {
22
22
  const items = [];
23
23
  if (item.worktree) {
24
24
  items.push({ key: "r", label: "AI Review", color: "cyan" });
@@ -146,12 +146,9 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
146
146
  }
147
147
  }
148
148
  // ── Build actions footer ─────────────────────────────────────────
149
- const actionItems = buildActions(item);
150
- const actionsHeight = 2; // separator + action row
151
- const scrollableHeight = height - actionsHeight;
152
149
  const totalLines = lines.length;
153
- const canScroll = totalLines > scrollableHeight;
154
- const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
150
+ const canScroll = totalLines > height;
151
+ const contentRows = canScroll ? height - 2 : height;
155
152
  const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
156
153
  const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
157
154
  let scrollArrow = null;
@@ -162,5 +159,5 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
162
159
  }
163
160
  // Truncate lines to panel width to prevent overflow into left pane
164
161
  const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
165
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: rule }) }), _jsx(Box, { children: actionItems.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : "white", children: [" ", item.label] })] }, j))) })] }));
162
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) }))] }));
166
163
  }
@@ -5,7 +5,9 @@ interface Props {
5
5
  scrollOffset: number;
6
6
  height: number;
7
7
  width: number;
8
+ /** Theme-adapted selection background. Falls back to dark navy. */
9
+ selectionBg?: string;
8
10
  }
9
11
  export declare function getReviewListRowCount(flatReviews: EnrichedReviewPR[]): number;
10
- export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
11
13
  export {};
@@ -9,20 +9,20 @@ function checksIndicator(checks) {
9
9
  return { text: "\u2713", color: "green" };
10
10
  return { text: "\u25cf", color: "yellow" };
11
11
  }
12
- const FOOTER_HEIGHT = 2;
13
12
  const HEADER_ROWS = 1;
14
13
  export function getReviewListRowCount(flatReviews) {
15
14
  return HEADER_ROWS + flatReviews.length;
16
15
  }
17
- export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }) {
18
- const listHeight = height - FOOTER_HEIGHT;
16
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
17
+ // Keymap footer lives in the dashboard's global CommandBar \u2014 use the full
18
+ // pane height for the list so we don't render two stacked keymap rows.
19
+ const listHeight = height;
19
20
  const numColWidth = 6;
20
21
  const authorColWidth = 12;
21
22
  const changesColWidth = 10;
22
23
  const checksColWidth = 2;
23
24
  const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
24
25
  const titleMaxWidth = Math.max(width - fixedWidth, 10);
25
- const footerRule = "\u2500".repeat(width);
26
26
  const totalRows = HEADER_ROWS + flatReviews.length;
27
27
  const visibleStart = scrollOffset;
28
28
  const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
@@ -46,8 +46,8 @@ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, h
46
46
  : pr.author.login;
47
47
  const changes = `+${item.additions} -${item.deletions}`;
48
48
  const ci = checksIndicator(item.checks);
49
- const bg = selected ? "#1e3a5f" : undefined;
50
- rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
49
+ const bg = selected ? selectionBg : undefined;
50
+ rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
51
51
  }
52
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
52
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: listHeight, children: rows }) }));
53
53
  }