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,76 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box, useApp } from "ink";
4
+ import { z } from "zod";
5
+ import { spawn } from "child_process";
6
+ import { findRepoRoot, getCurrentBranch, getBaseBranch, getDiffTool } from "../../lib/git.js";
7
+ import { run } from "../../lib/exec.js";
8
+ export const description = "View worktree diff against its base branch (uses delta if installed)";
9
+ export const options = z.object({
10
+ staged: z.boolean().optional().describe("Show only staged changes"),
11
+ unstaged: z.boolean().optional().describe("Show only unstaged changes (working tree vs index)"),
12
+ commits: z.boolean().optional().describe("Show only committed changes (base...HEAD)"),
13
+ base: z.string().optional().describe("Override base branch"),
14
+ });
15
+ export default function Diff({ options: opts }) {
16
+ const [status, setStatus] = useState({ state: "running" });
17
+ const { exit } = useApp();
18
+ useEffect(() => {
19
+ const repoRoot = findRepoRoot();
20
+ if (!repoRoot) {
21
+ setStatus({ state: "error", message: "Not inside a git repository" });
22
+ return;
23
+ }
24
+ const branch = getCurrentBranch();
25
+ if (!branch) {
26
+ setStatus({ state: "error", message: "Could not determine current branch" });
27
+ return;
28
+ }
29
+ const baseBranch = opts.base ?? getBaseBranch(branch);
30
+ // Use merge-base (not base tip) so upstream-only changes are excluded —
31
+ // matches GitHub PR diff semantics. Falls back to baseBranch if merge-base
32
+ // can't be resolved (e.g. unrelated histories).
33
+ const mergeBase = run(`git -C "${repoRoot}" merge-base "${baseBranch}" HEAD`) ?? baseBranch;
34
+ // Resolve diff range based on flags. Defaults to merge-base..working-tree
35
+ // (everything on this branch including uncommitted work, branch-only).
36
+ // Honor SANTREE_DIFF_TOOL by overriding core.pager just for this invocation
37
+ // — `-c` config takes precedence over the user's global git config.
38
+ const tool = getDiffTool();
39
+ const args = ["-C", repoRoot];
40
+ if (tool) {
41
+ args.push("-c", `core.pager=${tool}`);
42
+ }
43
+ args.push("diff");
44
+ if (opts.staged) {
45
+ args.push("--staged");
46
+ }
47
+ else if (opts.unstaged) {
48
+ // working tree vs index — no extra arg
49
+ }
50
+ else if (opts.commits) {
51
+ args.push(`${mergeBase}..HEAD`);
52
+ }
53
+ else {
54
+ args.push(mergeBase);
55
+ }
56
+ const child = spawn("git", args, { stdio: "inherit" });
57
+ child.on("error", (err) => {
58
+ setStatus({ state: "error", message: err.message });
59
+ exit();
60
+ });
61
+ child.on("close", (code) => {
62
+ setStatus({ state: "done", exitCode: code ?? 0 });
63
+ exit();
64
+ });
65
+ return () => {
66
+ if (!child.killed)
67
+ child.kill();
68
+ };
69
+ }, []);
70
+ if (status.state === "error") {
71
+ return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["\u2717 ", status.message] }) }));
72
+ }
73
+ // While running: render nothing so git/delta own the terminal.
74
+ // On done: render nothing — git's output already filled the screen.
75
+ return null;
76
+ }
package/dist/lib/ai.d.ts CHANGED
@@ -37,8 +37,18 @@ export declare function fetchAndRenderPR(branch: string): Promise<string | null>
37
37
  */
38
38
  export declare function fetchAndRenderDiff(branch: string): Promise<string>;
39
39
  /**
40
- * Check if claude CLI is available on PATH.
41
- * Returns "claude" or null if not installed.
40
+ * Resolve the path to the Claude CLI binary, preferring cmux's bundled copy
41
+ * when running inside cmux. Falls back to PATH lookup, then to Anthropic's
42
+ * standard installer location (`~/.claude/local/claude`). Returns null if
43
+ * none of those resolve.
44
+ *
45
+ * Used by every santree code path that needs to invoke or report the Claude
46
+ * binary — version display, doctor checks, and interactive launches.
47
+ */
48
+ export declare function resolveClaudeBinary(): string | null;
49
+ /**
50
+ * @deprecated Use `resolveClaudeBinary()` directly. Kept as an alias because
51
+ * existing call sites pass the return value straight to spawn args.
42
52
  */
43
53
  export declare function resolveAgentBinary(): string | null;
44
54
  /**
package/dist/lib/ai.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execSync, spawn, spawnSync } from "child_process";
2
- import { writeFileSync } from "fs";
2
+ import { existsSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { tmpdir } from "os";
4
+ import { homedir, tmpdir } from "os";
5
+ import { getMultiplexer } from "./multiplexer/index.js";
5
6
  import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot, getBaseBranch, } from "./git.js";
6
7
  import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
7
8
  import { getTicketContent, cleanupImages } from "./linear.js";
@@ -105,17 +106,47 @@ export async function fetchAndRenderDiff(branch) {
105
106
  });
106
107
  }
107
108
  /**
108
- * Check if claude CLI is available on PATH.
109
- * Returns "claude" or null if not installed.
109
+ * cmux ships its own Claude CLI shim wired to the active cmux workspace. When
110
+ * we run inside cmux, the system `claude` (if any) talks to a different
111
+ * session — confusing for the user. See manaflow-ai/cmux#2048.
110
112
  */
111
- export function resolveAgentBinary() {
113
+ const CMUX_CLAUDE_PATH = "/Applications/cmux.app/Contents/Resources/bin/claude";
114
+ /**
115
+ * Resolve the path to the Claude CLI binary, preferring cmux's bundled copy
116
+ * when running inside cmux. Falls back to PATH lookup, then to Anthropic's
117
+ * standard installer location (`~/.claude/local/claude`). Returns null if
118
+ * none of those resolve.
119
+ *
120
+ * Used by every santree code path that needs to invoke or report the Claude
121
+ * binary — version display, doctor checks, and interactive launches.
122
+ */
123
+ export function resolveClaudeBinary() {
124
+ // Inside cmux, the bundled binary is the only one wired to the active
125
+ // workspace. Always prefer it when present.
126
+ if (getMultiplexer().kind === "cmux" && existsSync(CMUX_CLAUDE_PATH)) {
127
+ return CMUX_CLAUDE_PATH;
128
+ }
129
+ // PATH lookup
112
130
  try {
113
131
  execSync("which claude", { stdio: "ignore" });
114
132
  return "claude";
115
133
  }
116
134
  catch {
117
- return null;
135
+ // fall through
118
136
  }
137
+ // Anthropic installer location — Ink renders may not inherit the user's
138
+ // shell PATH, so check this explicitly.
139
+ const localClaude = join(homedir(), ".claude", "local", "claude");
140
+ if (existsSync(localClaude))
141
+ return localClaude;
142
+ return null;
143
+ }
144
+ /**
145
+ * @deprecated Use `resolveClaudeBinary()` directly. Kept as an alias because
146
+ * existing call sites pass the return value straight to spawn args.
147
+ */
148
+ export function resolveAgentBinary() {
149
+ return resolveClaudeBinary();
119
150
  }
120
151
  // Conservative limit: 200KB leaves room for env vars within macOS 256KB ARG_MAX
121
152
  const ARG_MAX_SAFE = 200 * 1024;
@@ -143,12 +174,7 @@ export function launchAgent(prompt, opts) {
143
174
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
144
175
  }
145
176
  const args = [];
146
- if (process.env.SANTREE_SKIP_PERMISSIONS) {
147
- args.push("--dangerously-skip-permissions");
148
- }
149
- if (opts?.planMode) {
150
- args.push("--permission-mode", "plan");
151
- }
177
+ args.push("--permission-mode", opts?.planMode ? "plan" : "auto");
152
178
  if (opts?.sessionId) {
153
179
  if (opts.resume) {
154
180
  args.push("--resume", opts.sessionId);
@@ -170,9 +196,17 @@ export function runAgent(prompt, opts) {
170
196
  if (!bin) {
171
197
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
172
198
  }
173
- const skipPerms = process.env.SANTREE_SKIP_PERMISSIONS ? ["--dangerously-skip-permissions"] : [];
174
199
  const toolArgs = opts?.allowedTools?.length ? ["--allowedTools", ...opts.allowedTools] : [];
175
- const result = spawnSync(bin, [...skipPerms, ...toolArgs, "-p", "--output-format", "text", "--", promptArg(prompt)], {
200
+ const result = spawnSync(bin, [
201
+ "--permission-mode",
202
+ "auto",
203
+ ...toolArgs,
204
+ "-p",
205
+ "--output-format",
206
+ "text",
207
+ "--",
208
+ promptArg(prompt),
209
+ ], {
176
210
  encoding: "utf-8",
177
211
  maxBuffer: 10 * 1024 * 1024,
178
212
  });
@@ -7,5 +7,14 @@ interface Props {
7
7
  creatingForTicket: string | null;
8
8
  creationLogs: string;
9
9
  }
10
+ export type IssueActionItem = {
11
+ key: string;
12
+ label: string;
13
+ color: string;
14
+ };
15
+ /** Returns the context-sensitive action key list for the selected issue.
16
+ * Lifted out of the panel so the dashboard can render it on the same row as
17
+ * the global command bar (so left- and right-pane key hints align). */
18
+ export declare function buildIssueActions(di: DashboardIssue): IssueActionItem[];
10
19
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
11
20
  export {};
@@ -49,10 +49,12 @@ function fileColor(xy) {
49
49
  return "gray";
50
50
  return "yellow";
51
51
  }
52
- function buildActions(di) {
52
+ /** Returns the context-sensitive action key list for the selected issue.
53
+ * Lifted out of the panel so the dashboard can render it on the same row as
54
+ * the global command bar (so left- and right-pane key hints align). */
55
+ export function buildIssueActions(di) {
53
56
  const { worktree, pr, issue } = di;
54
57
  const items = [];
55
- // Work/Resume
56
58
  if (worktree?.sessionId) {
57
59
  items.push({ key: "↵", label: "Resume", color: "cyan" });
58
60
  }
@@ -63,15 +65,15 @@ function buildActions(di) {
63
65
  else {
64
66
  items.push({ key: "w", label: "Work", color: "cyan" });
65
67
  }
66
- // Editor
67
68
  if (worktree) {
68
69
  items.push({ key: "e", label: "Editor", color: "cyan" });
69
70
  }
70
- // Commit
71
71
  if (worktree?.dirty) {
72
72
  items.push({ key: "C", label: "Commit", color: "cyan" });
73
73
  }
74
- // PR actions
74
+ if (worktree) {
75
+ items.push({ key: "v", label: "View diff", color: "cyan" });
76
+ }
75
77
  if (worktree && !pr) {
76
78
  items.push({ key: "c", label: "Create PR", color: "cyan" });
77
79
  }
@@ -79,17 +81,26 @@ function buildActions(di) {
79
81
  items.push({ key: "f", label: "Fix PR", color: "cyan" });
80
82
  items.push({ key: "r", label: "Review", color: "cyan" });
81
83
  }
82
- // Links
83
84
  if (issue.url) {
84
85
  items.push({ key: "o", label: "Linear", color: "gray" });
85
86
  }
86
87
  if (pr)
87
88
  items.push({ key: "p", label: "Open PR", color: "gray" });
88
- // Destructive
89
89
  if (worktree) {
90
90
  items.push({ key: "d", label: "Remove", color: "red" });
91
91
  }
92
- return [items];
92
+ return items;
93
+ }
94
+ /** Section title with a colored leading icon and a bold name. Kept consistent
95
+ * across all sections so the eye can immediately find the next block. */
96
+ function sectionHeader(icon, label, iconColor = "cyan") {
97
+ return {
98
+ text: "",
99
+ segments: [
100
+ { text: `${icon} `, color: iconColor, bold: true },
101
+ { text: label, bold: true },
102
+ ],
103
+ };
93
104
  }
94
105
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }) {
95
106
  // Show creation logs when selected issue is being created
@@ -106,116 +117,227 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
106
117
  const { issue: li, worktree, pr } = issue;
107
118
  const lines = [];
108
119
  const rule = "─".repeat(width);
109
- // ── Hero: identifier + title ──────────────────────────────────────
120
+ const ruleLine = { text: rule, dim: true };
121
+ // ── Hero: identifier + title, then a status pill row ───────────────
110
122
  lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
111
- const meta = [];
112
- meta.push(li.state.name);
113
- meta.push(li.priorityLabel);
114
- if (li.labels.length > 0)
115
- meta.push(li.labels.join(", "));
116
- lines.push({ text: meta.join(" · "), color: stateColor(li.state.type) });
123
+ const sc = stateColor(li.state.type);
124
+ const heroSegs = [
125
+ { text: "● ", color: sc },
126
+ { text: li.state.name, color: sc },
127
+ { text: " · ", dim: true },
128
+ { text: li.priorityLabel },
129
+ ];
130
+ if (li.labels.length > 0) {
131
+ heroSegs.push({ text: " · ", dim: true });
132
+ heroSegs.push({ text: li.labels.join(", "), dim: true });
133
+ }
134
+ lines.push({ text: "", segments: heroSegs });
117
135
  // ── Description ───────────────────────────────────────────────────
118
136
  if (li.description) {
119
- lines.push({ text: rule, dim: true });
120
137
  lines.push({ text: "" });
121
138
  for (const dLine of li.description.trimEnd().split("\n")) {
122
139
  lines.push({ text: dLine });
123
140
  }
124
- lines.push({ text: "" });
125
141
  }
126
- // ── Worktree (enhanced) ───────────────────────────────────────────
127
- lines.push({ text: rule, dim: true });
128
- lines.push({ text: "WORKTREE", dim: true });
142
+ // ── Worktree ──────────────────────────────────────────────────────
143
+ lines.push(ruleLine);
129
144
  if (worktree) {
145
+ // Header carries a quick status badge (clean / dirty) so the user can tell
146
+ // at a glance without reading further.
147
+ const dirty = worktree.dirty;
148
+ lines.push({
149
+ text: "",
150
+ segments: [
151
+ { text: "⎇ ", color: "cyan", bold: true },
152
+ { text: "Worktree", bold: true },
153
+ { text: " " },
154
+ {
155
+ text: dirty ? "● dirty" : "✓ clean",
156
+ color: dirty ? "yellow" : "green",
157
+ },
158
+ ],
159
+ });
130
160
  lines.push({ text: ` ${worktree.branch}` });
131
161
  lines.push({ text: ` ${worktree.path}`, dim: true });
132
- const gs = parseGitStatus(worktree.gitStatus);
133
- const statusParts = [];
134
- if (gs.staged > 0)
135
- statusParts.push(`+${gs.staged} staged`);
136
- if (gs.unstaged > 0)
137
- statusParts.push(`~${gs.unstaged} unstaged`);
138
- if (gs.untracked > 0)
139
- statusParts.push(`?${gs.untracked} untracked`);
140
- if (worktree.commitsAhead > 0)
141
- statusParts.push(`+${worktree.commitsAhead} ahead`);
142
- if (statusParts.length > 0) {
143
- lines.push({
144
- text: ` ${statusParts.join(" ")}`,
145
- color: worktree.dirty ? "yellow" : "green",
146
- });
147
- }
148
- else {
149
- lines.push({ text: " ✓ clean", color: "green" });
150
- }
151
- // Show individual files (up to 8)
152
- const maxFiles = 8;
153
- for (let i = 0; i < Math.min(gs.files.length, maxFiles); i++) {
154
- const f = gs.files[i];
155
- lines.push({ text: ` ${f.xy} ${f.file}`, color: fileColor(f.xy) });
156
- }
157
- if (gs.files.length > maxFiles) {
158
- lines.push({ text: ` +${gs.files.length - maxFiles} more`, dim: true });
159
- }
160
- if (worktree.sessionId) {
161
- lines.push({ text: ` session: ${worktree.sessionId}`, color: "cyan" });
162
+ // Single metric row: files / +ins / -dels / commits ahead.
163
+ const ds = worktree.diffStats;
164
+ if (ds && (ds.insertions > 0 || ds.deletions > 0 || ds.filesChanged > 0)) {
165
+ const segs = [{ text: " " }];
166
+ if (ds.filesChanged > 0) {
167
+ segs.push({
168
+ text: `${ds.filesChanged} file${ds.filesChanged === 1 ? "" : "s"}`,
169
+ });
170
+ }
171
+ if (ds.insertions > 0) {
172
+ if (segs.length > 1)
173
+ segs.push({ text: " " });
174
+ segs.push({ text: `+${ds.insertions}`, color: "green" });
175
+ }
176
+ if (ds.deletions > 0) {
177
+ if (segs.length > 1)
178
+ segs.push({ text: " " });
179
+ segs.push({ text: `−${ds.deletions}`, color: "red" });
180
+ }
181
+ if (worktree.commitsAhead > 0) {
182
+ if (segs.length > 1)
183
+ segs.push({ text: " " });
184
+ segs.push({ text: `↑ ${worktree.commitsAhead}`, color: "cyan" });
185
+ }
186
+ lines.push({ text: "", segments: segs });
162
187
  }
163
- else {
164
- lines.push({ text: " session: none", color: "red" });
188
+ // Per-status counts only when there's something dirty — when the tree is
189
+ // clean the badge in the section header already says so.
190
+ const gs = parseGitStatus(worktree.gitStatus);
191
+ if (dirty) {
192
+ const statusSegs = [{ text: " " }];
193
+ if (gs.staged > 0) {
194
+ if (statusSegs.length > 1)
195
+ statusSegs.push({ text: " " });
196
+ statusSegs.push({ text: `+${gs.staged} staged`, color: "green" });
197
+ }
198
+ if (gs.unstaged > 0) {
199
+ if (statusSegs.length > 1)
200
+ statusSegs.push({ text: " " });
201
+ statusSegs.push({ text: `~${gs.unstaged} unstaged`, color: "yellow" });
202
+ }
203
+ if (gs.untracked > 0) {
204
+ if (statusSegs.length > 1)
205
+ statusSegs.push({ text: " " });
206
+ statusSegs.push({ text: `?${gs.untracked} untracked`, color: "gray" });
207
+ }
208
+ if (statusSegs.length > 1) {
209
+ lines.push({ text: "", segments: statusSegs });
210
+ }
211
+ // Show individual files (up to 8)
212
+ const maxFiles = 8;
213
+ for (let i = 0; i < Math.min(gs.files.length, maxFiles); i++) {
214
+ const f = gs.files[i];
215
+ lines.push({ text: ` ${f.xy} ${f.file}`, color: fileColor(f.xy) });
216
+ }
217
+ if (gs.files.length > maxFiles) {
218
+ lines.push({ text: ` +${gs.files.length - maxFiles} more`, dim: true });
219
+ }
165
220
  }
221
+ // Session state — single line, color reflects state.
166
222
  if (worktree.sessionState === "waiting") {
167
223
  const msg = worktree.sessionMessage
168
224
  ? `NEEDS INPUT: ${worktree.sessionMessage}`
169
225
  : "NEEDS INPUT";
170
- lines.push({ text: ` ${msg}`, color: "red" });
226
+ lines.push({
227
+ text: "",
228
+ segments: [
229
+ { text: " ◆ ", color: "red" },
230
+ { text: msg, color: "red", bold: true },
231
+ ],
232
+ });
233
+ }
234
+ else if (worktree.sessionState === "active") {
235
+ lines.push({
236
+ text: "",
237
+ segments: [
238
+ { text: " ◆ ", color: "green" },
239
+ { text: "session active", color: "green" },
240
+ ],
241
+ });
171
242
  }
172
243
  else if (worktree.sessionState === "idle") {
173
- lines.push({ text: " session idle (waiting for prompt)", color: "yellow" });
244
+ lines.push({
245
+ text: "",
246
+ segments: [
247
+ { text: " ◆ ", color: "yellow" },
248
+ { text: "session idle", color: "yellow" },
249
+ { text: " (waiting for prompt)", dim: true },
250
+ ],
251
+ });
174
252
  }
175
- else if (worktree.sessionState === "active") {
176
- lines.push({ text: " session active (working)", color: "green" });
253
+ else if (worktree.sessionId) {
254
+ lines.push({
255
+ text: "",
256
+ segments: [
257
+ { text: " ◇ ", color: "cyan" },
258
+ { text: "session ", dim: true },
259
+ { text: worktree.sessionId.slice(0, 8), color: "cyan" },
260
+ ],
261
+ });
262
+ }
263
+ else {
264
+ lines.push({
265
+ text: "",
266
+ segments: [
267
+ { text: " ◇ ", dim: true },
268
+ { text: "no session", dim: true },
269
+ ],
270
+ });
177
271
  }
178
272
  }
179
273
  else {
180
- lines.push({ text: "", dim: true });
274
+ lines.push(sectionHeader("", "Worktree"));
275
+ lines.push({ text: " no worktree for this ticket", dim: true });
181
276
  }
182
277
  // ── Pull Request ──────────────────────────────────────────────────
183
278
  const { checks, reviews } = issue;
184
- lines.push({ text: rule, dim: true });
185
- lines.push({ text: "PULL REQUEST", dim: true });
279
+ lines.push(ruleLine);
186
280
  if (pr) {
187
- const sc = pr.state === "MERGED" ? "magenta" : pr.state === "OPEN" ? "green" : "red";
188
- const draft = pr.isDraft ? " draft" : "";
189
- lines.push({ text: ` #${pr.number} ${pr.state}${draft}`, color: sc });
281
+ const prColor = pr.state === "MERGED" ? "magenta" : pr.state === "OPEN" ? "green" : "red";
282
+ const draft = pr.isDraft ? " · draft" : "";
283
+ lines.push({
284
+ text: "",
285
+ segments: [
286
+ { text: "◉ ", color: "cyan", bold: true },
287
+ { text: "Pull Request", bold: true },
288
+ { text: " " },
289
+ { text: `#${pr.number}`, color: prColor, bold: true },
290
+ { text: " " },
291
+ { text: pr.state, color: prColor },
292
+ { text: draft, dim: true },
293
+ ],
294
+ });
190
295
  if (pr.url) {
191
296
  lines.push({ text: ` ${pr.url}`, dim: true });
192
297
  }
193
298
  }
194
299
  else {
195
- lines.push({ text: "", dim: true });
300
+ lines.push(sectionHeader("", "Pull Request"));
301
+ lines.push({ text: " no PR yet", dim: true });
196
302
  }
197
303
  // ── Checks ────────────────────────────────────────────────────────
198
304
  if (checks && checks.length > 0) {
199
- const passCount = checks.filter((c) => c.bucket === "pass").length;
200
- lines.push({ text: rule, dim: true });
201
- lines.push({ text: `CHECKS ${passCount}/${checks.length} passing`, dim: true });
202
- for (const check of checks) {
203
- if (check.bucket === "pass") {
204
- lines.push({ text: ` ✓ ${check.name}`, color: "green" });
205
- }
206
- else if (check.bucket === "fail") {
207
- const desc = check.description ? ` — ${check.description}` : "";
208
- lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
209
- }
210
- else {
211
- lines.push({ text: ` ${check.name} (pending)`, color: "yellow" });
212
- }
305
+ const passing = checks.filter((c) => c.bucket === "pass");
306
+ const failing = checks.filter((c) => c.bucket === "fail");
307
+ const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
308
+ const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
309
+ lines.push(ruleLine);
310
+ const headerSegs = [
311
+ { text: "✓ ", color: "cyan", bold: true },
312
+ { text: "Checks", bold: true },
313
+ { text: " " },
314
+ { text: `${passing.length}/${checks.length} passing`, color: headerColor },
315
+ ];
316
+ if (failing.length > 0) {
317
+ headerSegs.push({ text: " · ", dim: true });
318
+ headerSegs.push({ text: `${failing.length} failing`, color: "red" });
319
+ }
320
+ if (pending.length > 0) {
321
+ headerSegs.push({ text: " · ", dim: true });
322
+ headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
323
+ }
324
+ lines.push({ text: "", segments: headerSegs });
325
+ // Order: failing first (most important), then pending, then passing.
326
+ for (const check of failing) {
327
+ const desc = check.description ? ` — ${check.description}` : "";
328
+ lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
329
+ }
330
+ for (const check of pending) {
331
+ lines.push({ text: ` ● ${check.name}`, color: "yellow" });
332
+ }
333
+ for (const check of passing) {
334
+ lines.push({ text: ` ✓ ${check.name}`, color: "green" });
213
335
  }
214
336
  }
215
337
  // ── Reviews ───────────────────────────────────────────────────────
216
338
  if (reviews && reviews.length > 0) {
217
- lines.push({ text: rule, dim: true });
218
- lines.push({ text: "REVIEWS", dim: true });
339
+ lines.push(ruleLine);
340
+ lines.push(sectionHeader("", "Reviews"));
219
341
  for (const review of reviews) {
220
342
  const author = review.author.login;
221
343
  const rc = review.state === "APPROVED"
@@ -223,18 +345,18 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
223
345
  : review.state === "CHANGES_REQUESTED"
224
346
  ? "red"
225
347
  : "yellow";
226
- lines.push({ text: ` ${author} ${review.state}`, color: rc });
348
+ lines.push({
349
+ text: "",
350
+ segments: [{ text: ` ${author}` }, { text: " " }, { text: review.state, color: rc }],
351
+ });
227
352
  }
228
353
  }
229
- // ── Build actions footer ──────────────────────────────────────────
230
- const actionRows = buildActions(issue);
231
- // +1 for the separator line
232
- const actionsHeight = actionRows.length + 1;
233
- const scrollableHeight = height - actionsHeight;
234
- // ── Render scrollable content ─────────────────────────────────────
354
+ // Action footer is rendered by the dashboard one row outside the panel,
355
+ // alongside the global command bar, so left- and right-pane key hints sit
356
+ // on the same row. The panel itself uses its full height for content.
235
357
  const totalLines = lines.length;
236
- const canScroll = totalLines > scrollableHeight;
237
- const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
358
+ const canScroll = totalLines > height;
359
+ const contentRows = canScroll ? height - 2 : height;
238
360
  const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
239
361
  const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
240
362
  let scrollArrow = null;
@@ -243,5 +365,29 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
243
365
  const atBottom = clampedOffset + contentRows >= totalLines;
244
366
  scrollArrow = atTop ? "↓ scroll" : atBottom ? "↑ scroll" : "↑↓ scroll";
245
367
  }
246
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: 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 }) }), actionRows.map((row, i) => (_jsx(Box, { children: row.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))) }, `a-${i}`)))] }));
368
+ // Pre-truncate to keep long URLs/paths/descriptions from wrapping into the
369
+ // row below — Ink's Text wrap is unreliable at the box's right edge and was
370
+ // causing content to bleed into the next line and shift everything down.
371
+ const clamp = (s) => (s.length > width ? s.slice(0, Math.max(0, width - 1)) + "…" : s);
372
+ const clampSegments = (segs) => {
373
+ let remaining = width;
374
+ const out = [];
375
+ for (const seg of segs) {
376
+ if (remaining <= 0)
377
+ break;
378
+ if (seg.text.length <= remaining) {
379
+ out.push(seg);
380
+ remaining -= seg.text.length;
381
+ }
382
+ else {
383
+ out.push({
384
+ ...seg,
385
+ text: seg.text.slice(0, Math.max(0, remaining - 1)) + "…",
386
+ });
387
+ remaining = 0;
388
+ }
389
+ }
390
+ return out;
391
+ };
392
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, 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 }) }))] }));
247
393
  }