santree 0.4.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.
@@ -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
  }
@@ -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 {};