santree 0.5.5 → 0.6.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 (35) hide show
  1. package/dist/commands/dashboard.js +228 -67
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/helpers/squirrel.d.ts +2 -0
  4. package/dist/commands/helpers/squirrel.js +12 -0
  5. package/dist/commands/worktree/commit.d.ts +9 -1
  6. package/dist/commands/worktree/commit.js +58 -14
  7. package/dist/lib/ai.d.ts +26 -0
  8. package/dist/lib/ai.js +53 -0
  9. package/dist/lib/claude-todos.d.ts +37 -0
  10. package/dist/lib/claude-todos.js +98 -0
  11. package/dist/lib/dashboard/DetailPanel.js +99 -9
  12. package/dist/lib/dashboard/IssueList.js +2 -0
  13. package/dist/lib/dashboard/MultilineTextArea.js +24 -11
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +76 -3
  16. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
  17. package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
  18. package/dist/lib/dashboard/ReviewList.js +12 -15
  19. package/dist/lib/dashboard/data.js +158 -7
  20. package/dist/lib/dashboard/types.d.ts +45 -10
  21. package/dist/lib/dashboard/types.js +40 -7
  22. package/dist/lib/diff-parse.d.ts +25 -0
  23. package/dist/lib/diff-parse.js +60 -0
  24. package/dist/lib/git.d.ts +22 -0
  25. package/dist/lib/git.js +41 -0
  26. package/dist/lib/github.d.ts +6 -0
  27. package/dist/lib/github.js +29 -0
  28. package/dist/lib/open-url.d.ts +10 -0
  29. package/dist/lib/open-url.js +20 -0
  30. package/dist/lib/squirrel-loader.d.ts +9 -0
  31. package/dist/lib/squirrel-loader.js +322 -0
  32. package/dist/lib/trackers/index.d.ts +13 -0
  33. package/dist/lib/trackers/index.js +19 -0
  34. package/package.json +1 -1
  35. package/prompts/fill-commit.njk +79 -0
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import Spinner from "ink-spinner";
4
- import TextInput from "ink-text-input";
5
4
  import { MultilineTextArea } from "./MultilineTextArea.js";
6
5
  export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phase, message, error, dispatch, onSubmit, }) {
7
6
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Commit & Push" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), gitStatus ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Changes:" }), gitStatus
@@ -19,11 +18,85 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
19
18
  color = "yellow";
20
19
  }
21
20
  return (_jsxs(Text, { color: color, children: [" ", line] }, i));
22
- }), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
21
+ }), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to write the message?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 let Claude draft a short message"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "m" }), " ", "Manual \u2014 type it yourself"] })] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Drafting commit message with Claude..."] })), phase === "awaiting-message" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit commit message" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: () => onSubmit(message), onCancel: () => dispatch({ type: "COMMIT_CANCEL" }), width: width, height: Math.max(3, Math.min(6, height - 12)), placeholder: "(empty)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " commit · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " cancel"] })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), phase !== "awaiting-message" && phase !== "done" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
23
22
  }
24
23
  export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }) {
25
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
24
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
26
25
  .split("\n")
27
26
  .slice(0, Math.max(4, height - 12))
28
27
  .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line || " " }, i))), (body ?? "").split("\n").length > Math.max(4, height - 12) && (_jsxs(Text, { dimColor: true, children: ["\u2026+", (body ?? "").split("\n").length - Math.max(4, height - 12), " more lines"] }))] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " create ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " open in browser ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "confirm" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
29
28
  }
29
+ const LEGEND = [
30
+ {
31
+ title: "Issue list",
32
+ rows: [
33
+ { glyph: "▎", color: "red", meaning: "Urgent (P1) priority" },
34
+ { glyph: "▎", color: "yellow", meaning: "High (P2) priority" },
35
+ { glyph: "●", color: "green", meaning: "State: started / In Progress" },
36
+ { glyph: "●", color: "blue", meaning: "State: unstarted / In Review" },
37
+ { glyph: "●", color: "gray", meaning: "State: backlog / orphaned" },
38
+ { glyph: "●", color: "magenta", meaning: "State: main repo (your non-worktree checkout)" },
39
+ { glyph: "✓", color: "green", meaning: "WT column: worktree exists" },
40
+ { glyph: "·", color: "gray", meaning: "WT column: no worktree" },
41
+ { glyph: "✓", color: "green", meaning: "CI column: all checks passing" },
42
+ { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
43
+ { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
44
+ { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
45
+ ],
46
+ },
47
+ {
48
+ title: "Detail panel — Worktree",
49
+ rows: [
50
+ { glyph: "● dirty", color: "yellow", meaning: "Uncommitted changes" },
51
+ { glyph: "✓ clean", color: "green", meaning: "Working tree clean" },
52
+ { glyph: "↑ N", color: "cyan", meaning: "N commits ahead of base" },
53
+ { glyph: "↓ N behind", color: "yellow", meaning: "Main repo: N commits to pull from origin" },
54
+ { glyph: "◆", color: "red", meaning: "Session needs input (permission prompt)" },
55
+ { glyph: "◆", color: "green", meaning: "Session active (Claude is working)" },
56
+ { glyph: "◆", color: "yellow", meaning: "Session idle (waiting for prompt)" },
57
+ { glyph: "◇", color: "cyan", meaning: "Session id stored, no live signal" },
58
+ { glyph: "◇", color: "gray", meaning: "No session" },
59
+ ],
60
+ },
61
+ {
62
+ title: "Detail panel — Tasks (Claude todos)",
63
+ rows: [
64
+ { glyph: "◐", color: "yellow", meaning: "Task in progress" },
65
+ { glyph: "◯", color: "gray", meaning: "Task pending" },
66
+ { glyph: "✓", color: "green", meaning: "Task completed" },
67
+ ],
68
+ },
69
+ {
70
+ title: "Section icons",
71
+ rows: [
72
+ { glyph: "⎇", color: "cyan", meaning: "Worktree / Branch" },
73
+ { glyph: "◉", color: "cyan", meaning: "Pull Request" },
74
+ { glyph: "✓", color: "cyan", meaning: "Checks" },
75
+ { glyph: "★", color: "cyan", meaning: "Reviews" },
76
+ { glyph: "⎈", color: "cyan", meaning: "Tasks (Claude todos)" },
77
+ { glyph: "◎", color: "cyan", meaning: "Linked tracker ticket (review tab)" },
78
+ ],
79
+ },
80
+ ];
81
+ export function HelpOverlay({ width, height }) {
82
+ const lines = [];
83
+ for (const section of LEGEND) {
84
+ lines.push({ text: section.title, bold: true });
85
+ for (const row of section.rows) {
86
+ lines.push({
87
+ text: "",
88
+ segments: [
89
+ { text: " " },
90
+ { text: row.glyph.padEnd(3, " "), color: row.color, bold: true },
91
+ { text: " " },
92
+ { text: row.meaning, dim: true },
93
+ ],
94
+ });
95
+ }
96
+ lines.push({ text: "" });
97
+ }
98
+ // Trim trailing blank
99
+ if (lines[lines.length - 1]?.text === "")
100
+ lines.pop();
101
+ return (_jsx(Box, { width: width, height: height, flexDirection: "column", alignItems: "center", justifyContent: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Dashboard glyph reference" }), _jsx(Text, { children: " " }), lines.map((line, i) => (_jsx(Box, { children: line.segments ? (_jsx(Text, { children: line.segments.map((seg, j) => (_jsx(Text, { color: seg.color, bold: seg.bold, dimColor: seg.dim, children: seg.text }, j))) })) : (_jsx(Text, { bold: line.bold, dimColor: line.dim, children: line.text || " " })) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press ? or Esc to close" })] }) }));
102
+ }
@@ -10,6 +10,13 @@ export type ReviewActionItem = {
10
10
  label: string;
11
11
  color: string;
12
12
  };
13
+ /**
14
+ * Action footer for the reviews tab. The factory mirrors `buildIssueActions`
15
+ * over in DetailPanel — same shape so the dashboard's action-row renderer
16
+ * doesn't need a per-tab branch. Disabled-state semantics: when an action
17
+ * doesn't apply (no ticket, no worktree), we omit the entry rather than
18
+ * dimming it, matching the issues tab's convention.
19
+ */
13
20
  export declare function buildReviewActions(item: EnrichedReviewPR): ReviewActionItem[];
14
21
  export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
15
22
  export {};
@@ -18,6 +18,27 @@ function relativeTime(dateStr) {
18
18
  const months = Math.floor(days / 30);
19
19
  return `${months}mo ago`;
20
20
  }
21
+ function stateColor(type) {
22
+ switch (type) {
23
+ case "started":
24
+ return "green";
25
+ case "unstarted":
26
+ return "blue";
27
+ case "backlog":
28
+ return "gray";
29
+ case "orphaned":
30
+ return "gray";
31
+ default:
32
+ return "yellow";
33
+ }
34
+ }
35
+ /**
36
+ * Action footer for the reviews tab. The factory mirrors `buildIssueActions`
37
+ * over in DetailPanel — same shape so the dashboard's action-row renderer
38
+ * doesn't need a per-tab branch. Disabled-state semantics: when an action
39
+ * doesn't apply (no ticket, no worktree), we omit the entry rather than
40
+ * dimming it, matching the issues tab's convention.
41
+ */
21
42
  export function buildReviewActions(item) {
22
43
  const items = [];
23
44
  if (item.worktree) {
@@ -27,7 +48,11 @@ export function buildReviewActions(item) {
27
48
  else {
28
49
  items.push({ key: "w", label: "Checkout", color: "cyan" });
29
50
  }
30
- items.push({ key: "o", label: "Open PR", color: "gray" });
51
+ items.push({ key: "v", label: "View diff", color: "cyan" });
52
+ if (item.ticket) {
53
+ items.push({ key: "o", label: "Open ticket", color: "gray" });
54
+ }
55
+ items.push({ key: "p", label: "Open PR", color: "gray" });
31
56
  if (item.worktree) {
32
57
  items.push({ key: "d", label: "Remove", color: "red" });
33
58
  }
@@ -37,104 +62,249 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
37
62
  if (!item) {
38
63
  return (_jsx(Box, { width: width, height: height, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No PR selected" }) }));
39
64
  }
40
- const { pr } = item;
65
+ const { pr, ticket, worktree, checks, reviews, comments } = item;
41
66
  const lines = [];
42
- const rule = "\u2500".repeat(width);
67
+ const rule = "".repeat(width);
68
+ const ruleLine = { text: rule, dim: true };
43
69
  // ── Hero ──────────────────────────────────────────────────────────
44
70
  lines.push({ text: `#${pr.number} ${pr.title}`, bold: true });
45
- const meta = [`by ${pr.author.login}`];
46
- if (pr.isDraft)
47
- meta.push("draft");
48
- meta.push(relativeTime(pr.updatedAt));
49
- lines.push({ text: meta.join(" \u00b7 "), color: "cyan" });
50
- // ── Changes ──────────────────────────────────────────────────────
51
- if (item.changedFiles > 0) {
52
- lines.push({
53
- text: `${item.changedFiles} files +${item.additions} -${item.deletions}`,
54
- color: "green",
71
+ // Labeled metadata block author + lines changed live here (the list
72
+ // strips them to keep navigation tight). Two-column "label value" rows
73
+ // keep the eye scannable.
74
+ const labelWidth = 9;
75
+ const lbl = (s) => s.padEnd(labelWidth);
76
+ // Display name first; login is the unique handle, parenthesized so the
77
+ // reviewer can still recognize someone they only know by username.
78
+ const authorSegs = [
79
+ { text: " " },
80
+ { text: lbl("Author"), dim: true },
81
+ { text: item.authorName ?? pr.author.login, color: "cyan" },
82
+ ];
83
+ if (item.authorName) {
84
+ authorSegs.push({ text: " " });
85
+ authorSegs.push({ text: `@${pr.author.login}`, dim: true });
86
+ }
87
+ if (pr.isDraft) {
88
+ authorSegs.push({ text: " " });
89
+ authorSegs.push({ text: "draft", color: "yellow" });
90
+ }
91
+ lines.push({ text: "", segments: authorSegs });
92
+ const changeSegs = [{ text: " " }, { text: lbl("Changed"), dim: true }];
93
+ if (item.additions > 0 || item.deletions > 0 || item.changedFiles > 0) {
94
+ changeSegs.push({ text: `+${item.additions}`, color: "green" });
95
+ changeSegs.push({ text: " " });
96
+ changeSegs.push({ text: `−${item.deletions}`, color: "red" });
97
+ changeSegs.push({ text: " " });
98
+ changeSegs.push({
99
+ text: `${item.changedFiles} file${item.changedFiles === 1 ? "" : "s"}`,
100
+ dim: true,
55
101
  });
56
102
  }
57
- // ── Branch ───────────────────────────────────────────────────────
58
- if (item.branch) {
59
- lines.push({ text: rule, dim: true });
60
- lines.push({ text: "BRANCH", dim: true });
61
- lines.push({ text: ` ${item.branch}` });
62
- if (item.baseBranch) {
63
- lines.push({ text: ` base: ${item.baseBranch}`, dim: true });
64
- }
103
+ else {
104
+ changeSegs.push({ text: "—", dim: true });
65
105
  }
66
- // ── Worktree ─────────────────────────────────────────────────────
67
- if (item.worktree) {
68
- lines.push({ text: rule, dim: true });
69
- lines.push({ text: "WORKTREE", dim: true });
70
- lines.push({ text: ` ${item.worktree.path}`, dim: true });
71
- const statusParts = [];
72
- if (item.worktree.dirty)
73
- statusParts.push("dirty");
74
- if (item.worktree.commitsAhead > 0)
75
- statusParts.push(`+${item.worktree.commitsAhead} ahead`);
76
- if (statusParts.length > 0) {
77
- lines.push({ text: ` ${statusParts.join(" ")}`, color: "yellow" });
106
+ lines.push({ text: "", segments: changeSegs });
107
+ lines.push({
108
+ text: "",
109
+ segments: [
110
+ { text: " " },
111
+ { text: lbl("Updated"), dim: true },
112
+ { text: relativeTime(pr.updatedAt) },
113
+ ],
114
+ });
115
+ // ── Linked Ticket ─────────────────────────────────────────────────
116
+ // Only rendered when the active tracker resolved an issue from the PR's
117
+ // branch — gives the reviewer the same "why does this PR exist" context
118
+ // the issues tab has always had.
119
+ if (ticket) {
120
+ lines.push(ruleLine);
121
+ const tc = stateColor(ticket.state.type);
122
+ const ticketHeader = [
123
+ { text: "◎ ", color: "cyan", bold: true },
124
+ { text: ticket.identifier, bold: true },
125
+ { text: " " },
126
+ { text: ticket.title },
127
+ ];
128
+ lines.push({ text: "", segments: ticketHeader });
129
+ const ticketStatus = [
130
+ { text: " ● ", color: tc },
131
+ { text: ticket.state.name, color: tc },
132
+ { text: " · ", dim: true },
133
+ { text: ticket.priorityLabel },
134
+ ];
135
+ if (ticket.labels.length > 0) {
136
+ ticketStatus.push({ text: " · ", dim: true });
137
+ ticketStatus.push({ text: ticket.labels.join(", "), dim: true });
138
+ }
139
+ lines.push({ text: "", segments: ticketStatus });
140
+ if (ticket.description) {
141
+ lines.push({ text: "" });
142
+ for (const dLine of ticket.description.trimEnd().split("\n")) {
143
+ lines.push({ text: dLine });
144
+ }
78
145
  }
79
- else {
80
- lines.push({ text: " \u2713 clean", color: "green" });
146
+ }
147
+ // ── Pull Request ──────────────────────────────────────────────────
148
+ lines.push(ruleLine);
149
+ const prDraft = pr.isDraft ? " · draft" : "";
150
+ lines.push({
151
+ text: "",
152
+ segments: [
153
+ { text: "◉ ", color: "cyan", bold: true },
154
+ { text: "Pull Request", bold: true },
155
+ { text: " " },
156
+ { text: `#${pr.number}`, color: "green", bold: true },
157
+ { text: " " },
158
+ { text: "OPEN", color: "green" },
159
+ { text: prDraft, dim: true },
160
+ ],
161
+ });
162
+ if (pr.url) {
163
+ lines.push({ text: ` ${pr.url}`, dim: true });
164
+ }
165
+ // ── Branch ────────────────────────────────────────────────────────
166
+ if (item.branch) {
167
+ lines.push(ruleLine);
168
+ lines.push({
169
+ text: "",
170
+ segments: [
171
+ { text: "⎇ ", color: "cyan", bold: true },
172
+ { text: "Branch", bold: true },
173
+ { text: " " },
174
+ { text: item.branch },
175
+ ...(item.baseBranch
176
+ ? [
177
+ { text: " " },
178
+ { text: "→", dim: true },
179
+ { text: " " },
180
+ { text: item.baseBranch, dim: true },
181
+ ]
182
+ : []),
183
+ ],
184
+ });
185
+ }
186
+ // ── Worktree ──────────────────────────────────────────────────────
187
+ if (worktree) {
188
+ lines.push(ruleLine);
189
+ const dirty = worktree.dirty;
190
+ lines.push({
191
+ text: "",
192
+ segments: [
193
+ { text: "⎇ ", color: "cyan", bold: true },
194
+ { text: "Worktree", bold: true },
195
+ { text: " " },
196
+ {
197
+ text: dirty ? "● dirty" : "✓ clean",
198
+ color: dirty ? "yellow" : "green",
199
+ },
200
+ ],
201
+ });
202
+ lines.push({ text: ` ${worktree.path}`, dim: true });
203
+ if (worktree.commitsAhead > 0) {
204
+ lines.push({
205
+ text: "",
206
+ segments: [{ text: " " }, { text: `↑ ${worktree.commitsAhead} ahead`, color: "cyan" }],
207
+ });
81
208
  }
82
209
  }
83
- // ── Description ──────────────────────────────────────────────────
210
+ // ── Description (PR body) ─────────────────────────────────────────
84
211
  if (item.body) {
85
- lines.push({ text: rule, dim: true });
86
- lines.push({ text: "DESCRIPTION", dim: true });
87
- lines.push({ text: "" });
212
+ lines.push(ruleLine);
213
+ lines.push({
214
+ text: "",
215
+ segments: [
216
+ { text: "✎ ", color: "cyan", bold: true },
217
+ { text: "Description", bold: true },
218
+ ],
219
+ });
88
220
  for (const line of item.body.trimEnd().split("\n")) {
89
221
  lines.push({ text: line });
90
222
  }
91
- lines.push({ text: "" });
92
- }
93
- // ── Checks ───────────────────────────────────────────────────────
94
- if (item.checks && item.checks.length > 0) {
95
- const passCount = item.checks.filter((c) => c.bucket === "pass").length;
96
- lines.push({ text: rule, dim: true });
97
- lines.push({ text: `CHECKS ${passCount}/${item.checks.length} passing`, dim: true });
98
- for (const check of item.checks) {
99
- if (check.bucket === "pass") {
100
- lines.push({ text: ` \u2713 ${check.name}`, color: "green" });
101
- }
102
- else if (check.bucket === "fail") {
103
- const desc = check.description ? ` \u2014 ${check.description}` : "";
104
- lines.push({ text: ` \u2717 ${check.name}${desc}`, color: "red" });
105
- }
106
- else {
107
- lines.push({ text: ` \u25cf ${check.name} (pending)`, color: "yellow" });
108
- }
223
+ }
224
+ // ── Checks ────────────────────────────────────────────────────────
225
+ if (checks && checks.length > 0) {
226
+ const passing = checks.filter((c) => c.bucket === "pass");
227
+ const failing = checks.filter((c) => c.bucket === "fail");
228
+ const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
229
+ const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
230
+ lines.push(ruleLine);
231
+ const headerSegs = [
232
+ { text: "✓ ", color: "cyan", bold: true },
233
+ { text: "Checks", bold: true },
234
+ { text: " " },
235
+ { text: `${passing.length}/${checks.length} passing`, color: headerColor },
236
+ ];
237
+ if (failing.length > 0) {
238
+ headerSegs.push({ text: " · ", dim: true });
239
+ headerSegs.push({ text: `${failing.length} failing`, color: "red" });
240
+ }
241
+ if (pending.length > 0) {
242
+ headerSegs.push({ text: " · ", dim: true });
243
+ headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
244
+ }
245
+ lines.push({ text: "", segments: headerSegs });
246
+ // Order: failing first (most important), then pending, then passing.
247
+ for (const check of failing) {
248
+ const desc = check.description ? ` — ${check.description}` : "";
249
+ lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
250
+ }
251
+ for (const check of pending) {
252
+ lines.push({ text: ` ● ${check.name}`, color: "yellow" });
253
+ }
254
+ for (const check of passing) {
255
+ lines.push({ text: ` ✓ ${check.name}`, color: "green" });
109
256
  }
110
257
  }
111
- // ── Reviews ──────────────────────────────────────────────────────
112
- if (item.reviews && item.reviews.length > 0) {
113
- lines.push({ text: rule, dim: true });
114
- lines.push({ text: "REVIEWS", dim: true });
115
- for (const review of item.reviews) {
116
- const author = review.author.login;
258
+ // ── Reviews ───────────────────────────────────────────────────────
259
+ if (reviews && reviews.length > 0) {
260
+ lines.push(ruleLine);
261
+ lines.push({
262
+ text: "",
263
+ segments: [
264
+ { text: "★ ", color: "cyan", bold: true },
265
+ { text: "Reviews", bold: true },
266
+ ],
267
+ });
268
+ for (const review of reviews) {
117
269
  const rc = review.state === "APPROVED"
118
270
  ? "green"
119
271
  : review.state === "CHANGES_REQUESTED"
120
272
  ? "red"
121
273
  : "yellow";
122
- lines.push({ text: ` ${author} ${review.state}`, color: rc });
274
+ lines.push({
275
+ text: "",
276
+ segments: [
277
+ { text: ` ${review.author.login}` },
278
+ { text: " " },
279
+ { text: review.state, color: rc },
280
+ ],
281
+ });
123
282
  }
124
283
  }
125
- // ── Comments ─────────────────────────────────────────────────────
126
- if (item.comments && item.comments.length > 0) {
127
- lines.push({ text: rule, dim: true });
128
- lines.push({ text: `COMMENTS ${item.comments.length}`, dim: true });
284
+ // ── Comments ──────────────────────────────────────────────────────
285
+ if (comments && comments.length > 0) {
286
+ lines.push(ruleLine);
287
+ lines.push({
288
+ text: "",
289
+ segments: [
290
+ { text: "● ", color: "cyan", bold: true },
291
+ { text: "Comments", bold: true },
292
+ { text: " " },
293
+ { text: `${comments.length}`, dim: true },
294
+ ],
295
+ });
129
296
  // Show last 5 comments
130
- const recent = item.comments.slice(-5);
297
+ const recent = comments.slice(-5);
131
298
  for (const comment of recent) {
132
299
  lines.push({ text: "" });
133
300
  lines.push({
134
- text: ` ${comment.author} ${relativeTime(comment.createdAt)}`,
135
- color: "cyan",
301
+ text: "",
302
+ segments: [
303
+ { text: ` ${comment.author}`, color: "cyan" },
304
+ { text: " " },
305
+ { text: relativeTime(comment.createdAt), dim: true },
306
+ ],
136
307
  });
137
- // Truncate long comments to ~4 lines
138
308
  const bodyLines = comment.body.trimEnd().split("\n");
139
309
  const maxLines = 4;
140
310
  for (let i = 0; i < Math.min(bodyLines.length, maxLines); i++) {
@@ -145,7 +315,7 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
145
315
  }
146
316
  }
147
317
  }
148
- // ── Build actions footer ─────────────────────────────────────────
318
+ // ── Render with scroll handling ───────────────────────────────────
149
319
  const totalLines = lines.length;
150
320
  const canScroll = totalLines > height;
151
321
  const contentRows = canScroll ? height - 2 : height;
@@ -155,9 +325,31 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
155
325
  if (canScroll) {
156
326
  const atTop = clampedOffset === 0;
157
327
  const atBottom = clampedOffset + contentRows >= totalLines;
158
- scrollArrow = atTop ? "\u2193 scroll" : atBottom ? "\u2191 scroll" : "\u2191\u2193 scroll";
328
+ scrollArrow = atTop ? " scroll" : atBottom ? " scroll" : "↑↓ scroll";
159
329
  }
160
- // Truncate lines to panel width to prevent overflow into left pane
161
- const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
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 }) }))] }));
330
+ // Pre-truncate to the panel width Ink's `wrap="truncate"` on segments
331
+ // inside flex rows is unreliable and lets long URLs/branch names spill onto
332
+ // the next row, shifting everything down. Mirrors DetailPanel's clampers.
333
+ const clamp = (text) => text.length > width ? text.slice(0, Math.max(0, width - 1)) + "…" : text;
334
+ const clampSegments = (segs) => {
335
+ let remaining = width;
336
+ const out = [];
337
+ for (const seg of segs) {
338
+ if (remaining <= 0)
339
+ break;
340
+ if (seg.text.length <= remaining) {
341
+ out.push(seg);
342
+ remaining -= seg.text.length;
343
+ }
344
+ else {
345
+ out.push({
346
+ ...seg,
347
+ text: seg.text.slice(0, Math.max(0, remaining - 1)) + "…",
348
+ });
349
+ remaining = 0;
350
+ }
351
+ }
352
+ return out;
353
+ };
354
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: line.segments ? (_jsx(Text, { children: clampSegments(line.segments).map((seg, j) => (_jsx(Text, { color: seg.color, bold: seg.bold, dimColor: seg.dim, children: seg.text }, j))) })) : (_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 }) }))] }));
163
355
  }
@@ -4,32 +4,33 @@ function checksIndicator(checks) {
4
4
  if (!checks || checks.length === 0)
5
5
  return { text: "-", color: "gray" };
6
6
  if (checks.some((c) => c.bucket === "fail"))
7
- return { text: "\u2717", color: "red" };
7
+ return { text: "", color: "red" };
8
8
  if (checks.every((c) => c.bucket === "pass"))
9
- return { text: "\u2713", color: "green" };
10
- return { text: "\u25cf", color: "yellow" };
9
+ return { text: "", color: "green" };
10
+ return { text: "", color: "yellow" };
11
11
  }
12
12
  const HEADER_ROWS = 1;
13
13
  export function getReviewListRowCount(flatReviews) {
14
14
  return HEADER_ROWS + flatReviews.length;
15
15
  }
16
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
17
+ // Keymap footer lives in the dashboard's global CommandBar use the full
18
18
  // pane height for the list so we don't render two stacked keymap rows.
19
19
  const listHeight = height;
20
+ // Per the user request, the list keeps only the columns that aid
21
+ // navigation: PR number, title, CI. Author + lines-changed live in the
22
+ // detail panel where they have room to be readable.
20
23
  const numColWidth = 6;
21
- const authorColWidth = 12;
22
- const changesColWidth = 10;
23
24
  const checksColWidth = 2;
24
- const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
25
- const titleMaxWidth = Math.max(width - fixedWidth, 10);
25
+ const fixedWidth = 2 + numColWidth + 1 + checksColWidth;
26
+ const titleMaxWidth = Math.max(width - fixedWidth - 1, 10);
26
27
  const totalRows = HEADER_ROWS + flatReviews.length;
27
28
  const visibleStart = scrollOffset;
28
29
  const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
29
30
  const rows = [];
30
31
  for (let rowIdx = visibleStart; rowIdx < visibleEnd; rowIdx++) {
31
32
  if (rowIdx === 0) {
32
- rows.push(_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "#".padEnd(numColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "".padEnd(titleMaxWidth) }), _jsx(Text, { dimColor: true, children: "author".padStart(authorColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "changes".padStart(changesColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
33
+ rows.push(_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "#".padEnd(numColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "".padEnd(titleMaxWidth) }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
33
34
  continue;
34
35
  }
35
36
  const flatIndex = rowIdx - HEADER_ROWS;
@@ -40,14 +41,10 @@ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, h
40
41
  const selected = flatIndex === selectedIndex;
41
42
  const cursor = selected ? ">" : " ";
42
43
  const num = `#${pr.number}`;
43
- const title = pr.title.length > titleMaxWidth ? pr.title.slice(0, titleMaxWidth - 1) + "\u2026" : pr.title;
44
- const author = pr.author.login.length > authorColWidth
45
- ? pr.author.login.slice(0, authorColWidth - 1) + "\u2026"
46
- : pr.author.login;
47
- const changes = `+${item.additions} -${item.deletions}`;
44
+ const title = pr.title.length > titleMaxWidth ? pr.title.slice(0, titleMaxWidth - 1) + "" : pr.title;
48
45
  const ci = checksIndicator(item.checks);
49
46
  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}`));
47
+ 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, color: ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
51
48
  }
52
49
  return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: listHeight, children: rows }) }));
53
50
  }