santree 0.5.6 → 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 +210 -33
  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 +14 -1
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +75 -2
  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 -5
  21. package/dist/lib/dashboard/types.js +40 -0
  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
package/dist/lib/ai.js CHANGED
@@ -226,6 +226,36 @@ export function runAgent(prompt, opts) {
226
226
  output: result.stdout?.trim() ?? "",
227
227
  };
228
228
  }
229
+ /**
230
+ * Async version of runAgent. Use this from Ink renderers — spawnSync
231
+ * blocks Node's event loop, freezing the UI (no spinner animation, no
232
+ * keystroke processing) for the entire duration of Claude's generation.
233
+ * spawn() lets the loop run during the call.
234
+ */
235
+ export function runAgentAsync(prompt, opts) {
236
+ const bin = resolveAgentBinary();
237
+ if (!bin) {
238
+ return Promise.reject(new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code"));
239
+ }
240
+ const toolArgs = opts?.allowedTools?.length ? ["--allowedTools", ...opts.allowedTools] : [];
241
+ const args = [
242
+ "--permission-mode",
243
+ "auto",
244
+ ...toolArgs,
245
+ "-p",
246
+ "--output-format",
247
+ "text",
248
+ "--",
249
+ promptArg(prompt),
250
+ ];
251
+ return new Promise((resolve) => {
252
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
253
+ let stdout = "";
254
+ child.stdout?.on("data", (chunk) => (stdout += chunk.toString("utf-8")));
255
+ child.on("error", () => resolve({ success: false, output: "" }));
256
+ child.on("close", (code) => resolve({ success: code === 0, output: stdout.trim() }));
257
+ });
258
+ }
229
259
  /**
230
260
  * Clean up cached image downloads for an issue identifier on the active tracker.
231
261
  */
@@ -233,3 +263,26 @@ export function cleanupImages(ticketId) {
233
263
  const repoRoot = findMainRepoRoot();
234
264
  getIssueTracker(repoRoot).cleanupCache(ticketId);
235
265
  }
266
+ /**
267
+ * Generate a short imperative commit message from a staged diff.
268
+ * Async so callers (the Ink dashboard, the CLI commit flow) keep the
269
+ * event loop turning during Claude's ~5–30s generation — using the sync
270
+ * runAgent here freezes the renderer.
271
+ *
272
+ * Returns the trimmed message string (no quotes, no preamble) on success,
273
+ * or null if Claude failed. Caller is responsible for ensuring the diff
274
+ * is non-empty.
275
+ */
276
+ export async function fillCommitMessage(opts) {
277
+ const prompt = renderPrompt("fill-commit", {
278
+ branch_name: opts.branch,
279
+ ticket_id: opts.ticketId ?? "",
280
+ ticket_content: opts.ticketContent,
281
+ diff_content: opts.diffContent,
282
+ });
283
+ const result = await runAgentAsync(prompt);
284
+ if (!result.success)
285
+ return null;
286
+ // Trim quotes/whitespace; Claude occasionally wraps despite instructions.
287
+ return result.output.trim().replace(/^["'`]|["'`]$/g, "");
288
+ }
@@ -0,0 +1,37 @@
1
+ export type ClaudeTodoStatus = "pending" | "in_progress" | "completed";
2
+ export interface ClaudeTodo {
3
+ id: string;
4
+ content: string;
5
+ status: ClaudeTodoStatus;
6
+ }
7
+ /** Read the main-agent todo list for a Claude Code session.
8
+ *
9
+ * Claude Code persists `TodoWrite` state to
10
+ * `~/.claude/todos/<sessionId>-agent-<agentId>.json`. The file with
11
+ * `agentId === sessionId` is the user-visible list; sub-agent files
12
+ * (different agentId) are noise and ignored here.
13
+ *
14
+ * Returns null when the file is missing, empty, or unparseable. The
15
+ * dashboard treats a null/empty result as "hide the section" so a
16
+ * stray malformed file never blocks rendering. */
17
+ export declare function readMainAgentTodos(sessionId: string): ClaudeTodo[] | null;
18
+ /** Locate the cwd from which a Claude Code session is resumable.
19
+ *
20
+ * Claude stores transcripts at
21
+ * `~/.claude/projects/<encodedCwd>/<sessionId>.jsonl`, where `encodedCwd`
22
+ * replaces every `/` and `.` with `-`. `claude --resume <id>` is cwd-scoped:
23
+ * a session created at the worktree root is NOT resumable from a
24
+ * subdirectory like `backend/canary`, even though the file exists somewhere
25
+ * under `~/.claude/projects/`. The dashboard's tmux send-keys flow has no
26
+ * control over where the user's shell init / direnv leaves the window's
27
+ * cwd, so we resolve the original launch cwd here and prepend a `cd` to
28
+ * the resume command.
29
+ *
30
+ * Returns the real path of the cwd where the session is resumable —
31
+ * constrained to the worktree subtree so we never recommend `cd`-ing
32
+ * outside it. Returns null when the file isn't found anywhere matching
33
+ * the worktree (the file was deleted, or the session was created in a
34
+ * cwd we can't reconstruct). The encoding is lossy (`-` could come from
35
+ * `/` or `.`), so we verify candidates against real filesystem paths
36
+ * under `worktreeRoot` rather than guessing. */
37
+ export declare function findClaudeSessionCwd(worktreeRoot: string, sessionId: string): string | null;
@@ -0,0 +1,98 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ /** Read the main-agent todo list for a Claude Code session.
5
+ *
6
+ * Claude Code persists `TodoWrite` state to
7
+ * `~/.claude/todos/<sessionId>-agent-<agentId>.json`. The file with
8
+ * `agentId === sessionId` is the user-visible list; sub-agent files
9
+ * (different agentId) are noise and ignored here.
10
+ *
11
+ * Returns null when the file is missing, empty, or unparseable. The
12
+ * dashboard treats a null/empty result as "hide the section" so a
13
+ * stray malformed file never blocks rendering. */
14
+ export function readMainAgentTodos(sessionId) {
15
+ const file = path.join(os.homedir(), ".claude", "todos", `${sessionId}-agent-${sessionId}.json`);
16
+ let raw;
17
+ try {
18
+ raw = fs.readFileSync(file, "utf-8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(raw);
25
+ if (!Array.isArray(parsed))
26
+ return null;
27
+ const out = [];
28
+ for (const item of parsed) {
29
+ if (!item || typeof item !== "object")
30
+ continue;
31
+ const { id, content, status } = item;
32
+ if (typeof id !== "string" || typeof content !== "string")
33
+ continue;
34
+ if (status !== "pending" && status !== "in_progress" && status !== "completed")
35
+ continue;
36
+ out.push({ id, content, status });
37
+ }
38
+ return out.length > 0 ? out : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function encodeCwd(cwd) {
45
+ return cwd.replace(/[/.]/g, "-");
46
+ }
47
+ /** Locate the cwd from which a Claude Code session is resumable.
48
+ *
49
+ * Claude stores transcripts at
50
+ * `~/.claude/projects/<encodedCwd>/<sessionId>.jsonl`, where `encodedCwd`
51
+ * replaces every `/` and `.` with `-`. `claude --resume <id>` is cwd-scoped:
52
+ * a session created at the worktree root is NOT resumable from a
53
+ * subdirectory like `backend/canary`, even though the file exists somewhere
54
+ * under `~/.claude/projects/`. The dashboard's tmux send-keys flow has no
55
+ * control over where the user's shell init / direnv leaves the window's
56
+ * cwd, so we resolve the original launch cwd here and prepend a `cd` to
57
+ * the resume command.
58
+ *
59
+ * Returns the real path of the cwd where the session is resumable —
60
+ * constrained to the worktree subtree so we never recommend `cd`-ing
61
+ * outside it. Returns null when the file isn't found anywhere matching
62
+ * the worktree (the file was deleted, or the session was created in a
63
+ * cwd we can't reconstruct). The encoding is lossy (`-` could come from
64
+ * `/` or `.`), so we verify candidates against real filesystem paths
65
+ * under `worktreeRoot` rather than guessing. */
66
+ export function findClaudeSessionCwd(worktreeRoot, sessionId) {
67
+ const projectsRoot = path.join(os.homedir(), ".claude", "projects");
68
+ const wtEncoded = encodeCwd(worktreeRoot);
69
+ // Fast path: session was created at the worktree root itself.
70
+ if (fs.existsSync(path.join(projectsRoot, wtEncoded, `${sessionId}.jsonl`))) {
71
+ return worktreeRoot;
72
+ }
73
+ // Slow path: session was created in a subdir of the worktree (e.g.
74
+ // project conventions auto-cd into `backend/canary` via direnv).
75
+ let dirs;
76
+ try {
77
+ dirs = fs.readdirSync(projectsRoot);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ const prefix = `${wtEncoded}-`;
83
+ for (const dir of dirs) {
84
+ if (!dir.startsWith(prefix))
85
+ continue;
86
+ if (!fs.existsSync(path.join(projectsRoot, dir, `${sessionId}.jsonl`)))
87
+ continue;
88
+ // Decode the suffix back to a real path under the worktree. The
89
+ // encoding is lossy, so we verify candidates against the filesystem
90
+ // rather than guessing — only return a path that actually exists.
91
+ const suffix = dir.slice(prefix.length);
92
+ const candidate = path.join(worktreeRoot, ...suffix.split("-"));
93
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
94
+ return candidate;
95
+ }
96
+ }
97
+ return null;
98
+ }
@@ -10,6 +10,8 @@ function stateColor(type) {
10
10
  return "gray";
11
11
  case "orphaned":
12
12
  return "gray";
13
+ case "main":
14
+ return "magenta";
13
15
  default:
14
16
  return "yellow";
15
17
  }
@@ -58,6 +60,19 @@ function fileColor(xy) {
58
60
  export function buildIssueActions(di, trackerName) {
59
61
  const { worktree, pr, issue } = di;
60
62
  const items = [];
63
+ // The synthetic "Main repo" row is special: no PR/Switch/Resume/Remove,
64
+ // no work-launching (you're already on it). Only commit / diff /
65
+ // editor — the actions that make sense for "I have changes in main and
66
+ // want to review or land them".
67
+ if (issue.state.type === "main") {
68
+ if (worktree) {
69
+ items.push({ key: "e", label: "Editor", color: "cyan" });
70
+ if (worktree.dirty)
71
+ items.push({ key: "C", label: "Commit", color: "cyan" });
72
+ items.push({ key: "v", label: "View diff", color: "cyan" });
73
+ }
74
+ return items;
75
+ }
61
76
  if (worktree?.sessionId) {
62
77
  items.push({ key: "↵", label: "Resume", color: "cyan" });
63
78
  }
@@ -164,19 +179,21 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
164
179
  lines.push({ text: ` ${worktree.path}`, dim: true });
165
180
  // Single metric row: files / +ins / -dels / commits ahead.
166
181
  const ds = worktree.diffStats;
167
- if (ds && (ds.insertions > 0 || ds.deletions > 0 || ds.filesChanged > 0)) {
182
+ const behind = worktree.commitsBehind ?? 0;
183
+ const hasDiff = ds && (ds.insertions > 0 || ds.deletions > 0 || ds.filesChanged > 0);
184
+ if (hasDiff || worktree.commitsAhead > 0 || behind > 0) {
168
185
  const segs = [{ text: " " }];
169
- if (ds.filesChanged > 0) {
186
+ if (ds && ds.filesChanged > 0) {
170
187
  segs.push({
171
188
  text: `${ds.filesChanged} file${ds.filesChanged === 1 ? "" : "s"}`,
172
189
  });
173
190
  }
174
- if (ds.insertions > 0) {
191
+ if (ds && ds.insertions > 0) {
175
192
  if (segs.length > 1)
176
193
  segs.push({ text: " " });
177
194
  segs.push({ text: `+${ds.insertions}`, color: "green" });
178
195
  }
179
- if (ds.deletions > 0) {
196
+ if (ds && ds.deletions > 0) {
180
197
  if (segs.length > 1)
181
198
  segs.push({ text: " " });
182
199
  segs.push({ text: `−${ds.deletions}`, color: "red" });
@@ -186,6 +203,11 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
186
203
  segs.push({ text: " " });
187
204
  segs.push({ text: `↑ ${worktree.commitsAhead}`, color: "cyan" });
188
205
  }
206
+ if (behind > 0) {
207
+ if (segs.length > 1)
208
+ segs.push({ text: " " });
209
+ segs.push({ text: `↓ ${behind} behind`, color: "yellow" });
210
+ }
189
211
  lines.push({ text: "", segments: segs });
190
212
  }
191
213
  // Per-status counts only when there's something dirty — when the tree is
@@ -277,10 +299,78 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
277
299
  lines.push(sectionHeader("⎇", "Worktree"));
278
300
  lines.push({ text: " no worktree for this ticket", dim: true });
279
301
  }
302
+ // ── Claude tasks ──────────────────────────────────────────────────
303
+ // Reads `~/.claude/todos/<sessionId>-agent-<sessionId>.json` (main-agent
304
+ // list only — sub-agent todos are noise). Section is hidden when the
305
+ // session has no todos or has exited; the header shows done/total at a
306
+ // glance. Up to 6 rows are rendered before collapsing into "+ N more".
307
+ const todos = worktree?.claudeTodos ?? null;
308
+ if (todos && todos.length > 0) {
309
+ const completed = todos.filter((t) => t.status === "completed").length;
310
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
311
+ lines.push(ruleLine);
312
+ const headerSegs = [
313
+ { text: "⎈ ", color: "cyan", bold: true },
314
+ { text: "Tasks", bold: true },
315
+ { text: " " },
316
+ {
317
+ text: `${completed}/${todos.length}`,
318
+ color: completed === todos.length ? "green" : "cyan",
319
+ },
320
+ ];
321
+ if (inProgress > 0) {
322
+ headerSegs.push({ text: " · ", dim: true });
323
+ headerSegs.push({ text: `${inProgress} in progress`, color: "yellow" });
324
+ }
325
+ lines.push({ text: "", segments: headerSegs });
326
+ const maxRows = 6;
327
+ // Surface in-progress first so the active task is always visible even
328
+ // when the list is long; pending next; completed last (most likely to
329
+ // be elided when truncating).
330
+ const ordered = [
331
+ ...todos.filter((t) => t.status === "in_progress"),
332
+ ...todos.filter((t) => t.status === "pending"),
333
+ ...todos.filter((t) => t.status === "completed"),
334
+ ];
335
+ for (const t of ordered.slice(0, maxRows)) {
336
+ if (t.status === "in_progress") {
337
+ lines.push({
338
+ text: "",
339
+ segments: [
340
+ { text: " ◐ ", color: "yellow", bold: true },
341
+ { text: t.content, color: "yellow" },
342
+ ],
343
+ });
344
+ }
345
+ else if (t.status === "completed") {
346
+ lines.push({
347
+ text: "",
348
+ segments: [
349
+ { text: " ✓ ", color: "green" },
350
+ { text: t.content, dim: true },
351
+ ],
352
+ });
353
+ }
354
+ else {
355
+ lines.push({
356
+ text: "",
357
+ segments: [{ text: " ◯ ", dim: true }, { text: t.content }],
358
+ });
359
+ }
360
+ }
361
+ if (ordered.length > maxRows) {
362
+ lines.push({ text: ` + ${ordered.length - maxRows} more`, dim: true });
363
+ }
364
+ }
280
365
  // ── Pull Request ──────────────────────────────────────────────────
366
+ // Skip PR/Checks/Reviews sections entirely for the synthetic main row
367
+ // — those concepts don't apply to "the user's main checkout".
368
+ const isMain = li.state.type === "main";
281
369
  const { checks, reviews } = issue;
282
- lines.push(ruleLine);
283
- if (pr) {
370
+ if (!isMain) {
371
+ lines.push(ruleLine);
372
+ }
373
+ if (!isMain && pr) {
284
374
  const prColor = pr.state === "MERGED" ? "magenta" : pr.state === "OPEN" ? "green" : "red";
285
375
  const draft = pr.isDraft ? " · draft" : "";
286
376
  lines.push({
@@ -299,12 +389,12 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
299
389
  lines.push({ text: ` ${pr.url}`, dim: true });
300
390
  }
301
391
  }
302
- else {
392
+ else if (!isMain) {
303
393
  lines.push(sectionHeader("◉", "Pull Request"));
304
394
  lines.push({ text: " no PR yet", dim: true });
305
395
  }
306
396
  // ── Checks ────────────────────────────────────────────────────────
307
- if (checks && checks.length > 0) {
397
+ if (!isMain && checks && checks.length > 0) {
308
398
  const passing = checks.filter((c) => c.bucket === "pass");
309
399
  const failing = checks.filter((c) => c.bucket === "fail");
310
400
  const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
@@ -338,7 +428,7 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
338
428
  }
339
429
  }
340
430
  // ── Reviews ───────────────────────────────────────────────────────
341
- if (reviews && reviews.length > 0) {
431
+ if (!isMain && reviews && reviews.length > 0) {
342
432
  lines.push(ruleLine);
343
433
  lines.push(sectionHeader("★", "Reviews"));
344
434
  for (const review of reviews) {
@@ -17,6 +17,8 @@ function stateColor(type, name) {
17
17
  return "gray";
18
18
  case "orphaned":
19
19
  return "gray";
20
+ case "main":
21
+ return "magenta";
20
22
  default:
21
23
  return "yellow";
22
24
  }
@@ -297,7 +297,20 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
297
297
  return;
298
298
  if (!input)
299
299
  return;
300
- insertAt(cursor, input.replace(/\r\n?/g, "\n"));
300
+ // Strip OSC sequences (terminal-side responses to OSC 11/52 etc.
301
+ // queries) — they leak into stdin while a refresh is querying
302
+ // the background color and would otherwise type themselves into
303
+ // the buffer. Pattern: anything starting with `]` followed by a
304
+ // number, semicolon, payload, then BEL or ST. We strip both the
305
+ // fully-formed OSC `\x1b]…\x07` and the bracket-only fragment
306
+ // that arrives when Ink consumed the leading ESC as a separate
307
+ // keypress (which it does for almost all OSC responses).
308
+ let cleaned = input
309
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
310
+ .replace(/^\][0-9]+;[^\x07]*\x07?/, "");
311
+ if (!cleaned)
312
+ return;
313
+ insertAt(cursor, cleaned.replace(/\r\n?/g, "\n"));
301
314
  }, { isActive: focus });
302
315
  const innerWidth = Math.max(1, (width ?? 80) - 4);
303
316
  const rows = buildVisualRows(value, innerWidth);
@@ -25,4 +25,9 @@ interface PrCreateOverlayProps {
25
25
  dispatch: React.Dispatch<DashboardAction>;
26
26
  }
27
27
  export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
28
+ interface HelpOverlayProps {
29
+ width: number;
30
+ height: number;
31
+ }
32
+ export declare function HelpOverlay({ width, height }: HelpOverlayProps): import("react/jsx-runtime").JSX.Element;
28
33
  export {};
@@ -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,7 +18,7 @@ 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
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 ?? "")
@@ -27,3 +26,77 @@ export function PrCreateOverlay({ width, height, branch, ticketId, phase, error,
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 {};