santree 0.3.0 → 0.5.0

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.
Files changed (50) hide show
  1. package/README.md +55 -2
  2. package/dist/commands/dashboard.js +538 -188
  3. package/dist/commands/doctor.js +164 -13
  4. package/dist/commands/helpers/statusline.js +10 -2
  5. package/dist/commands/helpers/text-editor.d.ts +13 -0
  6. package/dist/commands/helpers/text-editor.js +118 -0
  7. package/dist/commands/update.d.ts +15 -0
  8. package/dist/commands/update.js +72 -0
  9. package/dist/commands/worktree/create.d.ts +1 -0
  10. package/dist/commands/worktree/create.js +30 -38
  11. package/dist/commands/worktree/diff.d.ts +13 -0
  12. package/dist/commands/worktree/diff.js +76 -0
  13. package/dist/lib/ai.d.ts +12 -2
  14. package/dist/lib/ai.js +48 -14
  15. package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
  16. package/dist/lib/dashboard/DetailPanel.js +235 -89
  17. package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
  18. package/dist/lib/dashboard/DiffOverlay.js +243 -0
  19. package/dist/lib/dashboard/IssueList.d.ts +20 -3
  20. package/dist/lib/dashboard/IssueList.js +74 -103
  21. package/dist/lib/dashboard/MultilineTextArea.js +225 -82
  22. package/dist/lib/dashboard/Overlays.js +1 -1
  23. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
  24. package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
  25. package/dist/lib/dashboard/ReviewList.d.ts +3 -1
  26. package/dist/lib/dashboard/ReviewList.js +3 -3
  27. package/dist/lib/dashboard/data.js +14 -8
  28. package/dist/lib/dashboard/external-editor.d.ts +12 -0
  29. package/dist/lib/dashboard/external-editor.js +74 -0
  30. package/dist/lib/dashboard/theme.d.ts +24 -0
  31. package/dist/lib/dashboard/theme.js +113 -0
  32. package/dist/lib/dashboard/types.d.ts +52 -1
  33. package/dist/lib/dashboard/types.js +81 -0
  34. package/dist/lib/git.d.ts +26 -4
  35. package/dist/lib/git.js +45 -33
  36. package/dist/lib/multiplexer/cmux.d.ts +2 -0
  37. package/dist/lib/multiplexer/cmux.js +97 -0
  38. package/dist/lib/multiplexer/index.d.ts +4 -0
  39. package/dist/lib/multiplexer/index.js +20 -0
  40. package/dist/lib/multiplexer/none.d.ts +2 -0
  41. package/dist/lib/multiplexer/none.js +22 -0
  42. package/dist/lib/multiplexer/tmux.d.ts +2 -0
  43. package/dist/lib/multiplexer/tmux.js +82 -0
  44. package/dist/lib/multiplexer/types.d.ts +23 -0
  45. package/dist/lib/multiplexer/types.js +3 -0
  46. package/dist/lib/session-signal.js +5 -8
  47. package/dist/lib/version.d.ts +55 -0
  48. package/dist/lib/version.js +224 -0
  49. package/package.json +1 -1
  50. package/shell/init.zsh.njk +45 -15
@@ -0,0 +1,50 @@
1
+ import type { DiffFile } from "./types.js";
2
+ interface Props {
3
+ width: number;
4
+ height: number;
5
+ ticketId: string;
6
+ baseBranch: string;
7
+ files: DiffFile[];
8
+ fileIndex: number;
9
+ fileScrollOffset: number;
10
+ content: string | null;
11
+ contentScrollOffset: number;
12
+ loadingFiles: boolean;
13
+ loadingContent: boolean;
14
+ error: string | null;
15
+ /** Theme-adapted selection background. Falls back to dark navy. */
16
+ selectionBg?: string;
17
+ }
18
+ interface RenderedRow {
19
+ prefix: string;
20
+ label: string;
21
+ color?: string;
22
+ dim?: boolean;
23
+ bold?: boolean;
24
+ fileIndex: number | null;
25
+ }
26
+ export declare function flattenTreeFiles(files: DiffFile[]): DiffFile[];
27
+ export interface DiffLayout {
28
+ bodyHeight: number;
29
+ leftWidth: number;
30
+ rightWidth: number;
31
+ rows: RenderedRow[];
32
+ effectiveScroll: number;
33
+ selectedRowIdx: number;
34
+ }
35
+ /**
36
+ * Computes the diff overlay layout — body height, pane widths, rendered tree
37
+ * rows, and the effective scroll offset (clamped to keep selection visible).
38
+ *
39
+ * Shared between DiffOverlay (rendering) and the dashboard mouse handler
40
+ * (mapping click coords back to file indices).
41
+ */
42
+ export declare function computeDiffLayout(opts: {
43
+ width: number;
44
+ height: number;
45
+ files: DiffFile[];
46
+ fileIndex: number;
47
+ fileScrollOffset: number;
48
+ }): DiffLayout;
49
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
50
+ export {};
@@ -0,0 +1,243 @@
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 function computeDiffLayout(opts) {
116
+ const headerHeight = 2;
117
+ const footerHeight = 1;
118
+ const bodyHeight = Math.max(3, opts.height - headerHeight - footerHeight);
119
+ const leftWidth = Math.min(48, Math.max(24, Math.floor(opts.width * 0.32)));
120
+ const rightWidth = Math.max(20, opts.width - leftWidth - 1);
121
+ const rows = [];
122
+ const tree = buildTree(opts.files);
123
+ renderTree(tree, 0, rows, { value: 0 });
124
+ const selectedRowIdx = rows.findIndex((r) => r.fileIndex === opts.fileIndex);
125
+ const totalRows = rows.length;
126
+ const maxScroll = Math.max(0, totalRows - bodyHeight);
127
+ let effectiveScroll = Math.min(opts.fileScrollOffset, maxScroll);
128
+ if (selectedRowIdx >= 0) {
129
+ if (selectedRowIdx < effectiveScroll) {
130
+ effectiveScroll = selectedRowIdx;
131
+ }
132
+ else if (selectedRowIdx >= effectiveScroll + bodyHeight) {
133
+ effectiveScroll = selectedRowIdx - bodyHeight + 1;
134
+ }
135
+ }
136
+ return { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx };
137
+ }
138
+ function colorizeDiffLine(line) {
139
+ if (line.startsWith("diff --git") || line.startsWith("index ")) {
140
+ return { text: line, color: "yellow", bold: true };
141
+ }
142
+ if (line.startsWith("+++") || line.startsWith("---")) {
143
+ return { text: line, color: "yellow", dim: true };
144
+ }
145
+ if (line.startsWith("@@")) {
146
+ return { text: line, color: "cyan" };
147
+ }
148
+ if (line.startsWith("+")) {
149
+ return { text: line, color: "green" };
150
+ }
151
+ if (line.startsWith("-")) {
152
+ return { text: line, color: "red" };
153
+ }
154
+ return { text: line };
155
+ }
156
+ // Content from external diff tools (delta, diff-so-fancy, etc.) ships its own
157
+ // ANSI escapes. Detecting them lets us skip our manual colorize and render the
158
+ // raw text — Ink passes ANSI through to the terminal natively.
159
+ function looksLikeAnsi(text) {
160
+ return /\x1b\[[0-9;]*[A-Za-z]/.test(text);
161
+ }
162
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
163
+ /**
164
+ * Truncate a line to `max` visible columns, preserving any ANSI escape
165
+ * sequences along the way. Ink's built-in `wrap="truncate"` measures by string
166
+ * length (counting escapes as visible chars) and stops short or, with very long
167
+ * lines, lets content bleed past the box's right edge — which on the diff
168
+ * pane caused lines to spill into the column to the right. Doing the math
169
+ * ourselves avoids both bugs.
170
+ */
171
+ function truncateVisible(s, max) {
172
+ if (max <= 0)
173
+ return "";
174
+ if (s.replace(ANSI_RE, "").length <= max)
175
+ return s;
176
+ let out = "";
177
+ let visible = 0;
178
+ let i = 0;
179
+ while (i < s.length && visible < max - 1) {
180
+ const ch = s[i];
181
+ if (ch === "\x1b" && s[i + 1] === "[") {
182
+ ANSI_RE.lastIndex = i;
183
+ const m = ANSI_RE.exec(s);
184
+ if (m && m.index === i) {
185
+ out += m[0];
186
+ i += m[0].length;
187
+ continue;
188
+ }
189
+ }
190
+ out += ch;
191
+ visible++;
192
+ i++;
193
+ }
194
+ return out + "…";
195
+ }
196
+ // ── Component ─────────────────────────────────────────────────────────
197
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", }) {
198
+ const layout = computeDiffLayout({ width, height, files, fileIndex, fileScrollOffset });
199
+ const { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx } = layout;
200
+ const visibleRows = rows.slice(effectiveScroll, effectiveScroll + bodyHeight);
201
+ // Right pane: split content into lines and slice for scroll. If the content
202
+ // already carries ANSI escapes (from an external diff tool), pass them
203
+ // through as-is; otherwise apply our built-in line-prefix colorization.
204
+ // Clamp scroll so the deepest position lands the last line at the bottom.
205
+ const isExternalContent = content ? looksLikeAnsi(content) : false;
206
+ const rawLines = content ? content.split("\n") : [];
207
+ const allLines = isExternalContent
208
+ ? rawLines.map((text) => ({ text }))
209
+ : rawLines.map(colorizeDiffLine);
210
+ const maxContentScroll = Math.max(0, allLines.length - bodyHeight);
211
+ const effectiveContentScroll = Math.min(Math.max(0, contentScrollOffset), maxContentScroll);
212
+ const visibleLines = allLines.slice(effectiveContentScroll, effectiveContentScroll + bodyHeight);
213
+ const totalFiles = files.length;
214
+ const currentFile = files[fileIndex];
215
+ // Pre-compute path truncation. Letting each <Text> rely on wrap="truncate"
216
+ // inside a flex row caused the path to wrap onto a new line when the row
217
+ // overflowed — pushing the tabs above off-screen. Manual truncation on the
218
+ // only variable-length segment keeps the header strictly one line.
219
+ const filesLabel = `(${totalFiles} ${totalFiles === 1 ? "file" : "files"})`;
220
+ const meta = ` ${ticketId} vs ${baseBranch} ${filesLabel}`;
221
+ const sep = " • ";
222
+ const consumed = "Diff".length + meta.length;
223
+ const pathRoom = Math.max(0, width - consumed - sep.length);
224
+ let truncatedPath = "";
225
+ if (currentFile) {
226
+ const p = currentFile.path;
227
+ truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
228
+ }
229
+ 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) => {
230
+ const absIdx = effectiveScroll + i;
231
+ const isSelected = absIdx === selectedRowIdx;
232
+ const text = `${row.prefix}${row.label}`;
233
+ // Selected row keeps its own color (file-status hue or directory
234
+ // blue) but gets the theme-aware selection bg + bold so it stays
235
+ // readable in light and dark modes alike.
236
+ return (_jsx(Text, { color: row.color, backgroundColor: isSelected ? selectionBg : undefined, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
237
+ })) }), _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) => {
238
+ // rightWidth includes the paddingLeft={1} of the wrapper Box,
239
+ // so usable column count is rightWidth - 1.
240
+ const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
241
+ return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
242
+ })) })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "j/k file \u2022 J/K scroll \u2022 g/G top/bot \u2022 click select \u2022 wheel scroll \u2022 q close" }) })] }));
243
+ }
@@ -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
  }