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
@@ -3,12 +3,19 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box, useInput, useApp } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
6
+ import { z } from "zod";
6
7
  import { exec } from "child_process";
7
8
  import { promisify } from "util";
8
- import { findRepoRoot, getCurrentBranch, extractTicketId, getGitStatus, getStagedDiffStat, hasStagedChanges, hasUnstagedChanges, } from "../../lib/git.js";
9
+ import { findRepoRoot, findMainRepoRoot, getCurrentBranch, extractTicketId, getGitStatus, getStagedDiffStat, getStagedDiffContent, hasStagedChanges, hasUnstagedChanges, } from "../../lib/git.js";
10
+ import { fillCommitMessage } from "../../lib/ai.js";
11
+ import { getIssueTracker } from "../../lib/trackers/index.js";
12
+ import { renderTicket } from "../../lib/prompts.js";
9
13
  export const description = "Stage and commit changes";
14
+ export const options = z.object({
15
+ fill: z.boolean().optional().describe("Use AI to draft a short commit message"),
16
+ });
10
17
  const execAsync = promisify(exec);
11
- export default function Commit() {
18
+ export default function Commit({ options }) {
12
19
  const { exit } = useApp();
13
20
  const [status, setStatus] = useState("loading");
14
21
  const [message, setMessage] = useState("");
@@ -25,10 +32,9 @@ export default function Commit() {
25
32
  stageAndContinue();
26
33
  }
27
34
  else if (input === "n" || input === "N" || key.escape) {
28
- if (hasStagedChanges()) {
29
- setStatus("awaiting-message");
30
- const prefix = ticketId ? `[${ticketId}] ` : "";
31
- setCommitInput(prefix);
35
+ if (hasStagedChanges() && repoRoot && branch) {
36
+ // Respect --fill even when the user declines to stage more.
37
+ void openMessagePhase({ repoRoot, branch, ticketId });
32
38
  }
33
39
  else {
34
40
  setStatus("no-changes");
@@ -39,19 +45,59 @@ export default function Commit() {
39
45
  }
40
46
  });
41
47
  async function stageAndContinue() {
48
+ if (!repoRoot || !branch)
49
+ return;
42
50
  try {
43
- await execAsync("git add -A", { cwd: repoRoot ?? undefined });
51
+ await execAsync("git add -A", { cwd: repoRoot });
44
52
  setGitStatus(getGitStatus());
45
53
  setDiffStat(getStagedDiffStat());
46
- setStatus("awaiting-message");
47
- const prefix = ticketId ? `[${ticketId}] ` : "";
48
- setCommitInput(prefix);
54
+ await openMessagePhase({ repoRoot, branch, ticketId });
49
55
  }
50
56
  catch (e) {
51
57
  setStatus("error");
52
58
  setMessage(`Failed to stage changes: ${e}`);
53
59
  }
54
60
  }
61
+ // Routes to either the AI-fill phase or straight to the bare input.
62
+ // Takes context as args so callers in init() (where state isn't yet
63
+ // propagated from the just-fired setStates) can hand in fresh values.
64
+ async function openMessagePhase(ctx) {
65
+ const prefix = ctx.ticketId ? `[${ctx.ticketId}] ` : "";
66
+ if (options.fill) {
67
+ setStatus("filling");
68
+ setMessage("Drafting commit message with Claude...");
69
+ const drafted = await draftWithAI(ctx);
70
+ // Whether Claude succeeds or not, fall through to the input —
71
+ // the user can edit or type from scratch.
72
+ setCommitInput(drafted ?? prefix);
73
+ setStatus("awaiting-message");
74
+ return;
75
+ }
76
+ setCommitInput(prefix);
77
+ setStatus("awaiting-message");
78
+ }
79
+ async function draftWithAI(ctx) {
80
+ const diffContent = getStagedDiffContent(ctx.repoRoot);
81
+ if (!diffContent.trim())
82
+ return null;
83
+ // Pull ticket context if we can — the prompt uses it to ground the
84
+ // summary in the requested change rather than the literal diff.
85
+ let ticketContent;
86
+ const mainRoot = findMainRepoRoot();
87
+ if (ctx.ticketId && mainRoot) {
88
+ const tracker = getIssueTracker(mainRoot);
89
+ const result = await tracker.getIssue(ctx.ticketId, mainRoot);
90
+ if (result.ok) {
91
+ ticketContent = renderTicket(result.value, tracker.displayName);
92
+ }
93
+ }
94
+ return fillCommitMessage({
95
+ branch: ctx.branch,
96
+ ticketId: ctx.ticketId,
97
+ ticketContent,
98
+ diffContent,
99
+ });
100
+ }
55
101
  async function handleCommitSubmit(value) {
56
102
  const trimmed = value.trim();
57
103
  if (!trimmed) {
@@ -128,9 +174,7 @@ export default function Commit() {
128
174
  }
129
175
  else if (staged) {
130
176
  setDiffStat(getStagedDiffStat());
131
- setStatus("awaiting-message");
132
- const prefix = ticket ? `[${ticket}] ` : "";
133
- setCommitInput(prefix);
177
+ await openMessagePhase({ repoRoot: root, branch: currentBranch, ticketId: ticket });
134
178
  }
135
179
  else {
136
180
  setStatus("no-changes");
@@ -140,7 +184,7 @@ export default function Commit() {
140
184
  }
141
185
  init();
142
186
  }, []);
143
- const isLoading = status === "loading" || status === "committing" || status === "pushing";
187
+ const isLoading = status === "loading" || status === "filling" || status === "committing" || status === "pushing";
144
188
  return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDCBE Commit" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] }))] }), gitStatus && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Changes:" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: "100%", children: [gitStatus
145
189
  .split("\n")
146
190
  .slice(0, 10)
package/dist/lib/ai.d.ts CHANGED
@@ -75,7 +75,33 @@ export interface RunAgentResult {
75
75
  export declare function runAgent(prompt: string, opts?: {
76
76
  allowedTools?: string[];
77
77
  }): RunAgentResult;
78
+ /**
79
+ * Async version of runAgent. Use this from Ink renderers — spawnSync
80
+ * blocks Node's event loop, freezing the UI (no spinner animation, no
81
+ * keystroke processing) for the entire duration of Claude's generation.
82
+ * spawn() lets the loop run during the call.
83
+ */
84
+ export declare function runAgentAsync(prompt: string, opts?: {
85
+ allowedTools?: string[];
86
+ }): Promise<RunAgentResult>;
78
87
  /**
79
88
  * Clean up cached image downloads for an issue identifier on the active tracker.
80
89
  */
81
90
  export declare function cleanupImages(ticketId: string): void;
91
+ export interface FillCommitOpts {
92
+ branch: string;
93
+ ticketId: string | null;
94
+ ticketContent?: string;
95
+ diffContent: string;
96
+ }
97
+ /**
98
+ * Generate a short imperative commit message from a staged diff.
99
+ * Async so callers (the Ink dashboard, the CLI commit flow) keep the
100
+ * event loop turning during Claude's ~5–30s generation — using the sync
101
+ * runAgent here freezes the renderer.
102
+ *
103
+ * Returns the trimmed message string (no quotes, no preamble) on success,
104
+ * or null if Claude failed. Caller is responsible for ensuring the diff
105
+ * is non-empty.
106
+ */
107
+ export declare function fillCommitMessage(opts: FillCommitOpts): Promise<string | null>;
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
  }
@@ -162,24 +162,17 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
162
162
  onSubmit();
163
163
  return;
164
164
  }
165
- // Ctrl+C: cancel (preferred over Esc — vim users rely on Esc muscle memory)
166
- if (key.ctrl && input === "c") {
167
- onCancel();
168
- return;
169
- }
170
165
  // Ctrl+O: escalate to $SANTREE_EDITOR / $VISUAL / $EDITOR. On save+close
171
- // the buffer is replaced and the form is auto-submitted (matches git commit).
166
+ // the buffer is replaced and control returns to the textbox so the
167
+ // user can keep editing or submit with Ctrl+D.
172
168
  if (key.ctrl && input === "o") {
173
169
  const result = editExternally(value, "md");
174
170
  if (!result.ok)
175
171
  return;
176
- if (result.cancelled) {
177
- onCancel();
172
+ if (result.cancelled)
178
173
  return;
179
- }
180
174
  onChange(result.content);
181
175
  setCursor(result.content.length);
182
- onSubmit();
183
176
  return;
184
177
  }
185
178
  // Ctrl+V: paste clipboard image as a temp file reference.
@@ -189,6 +182,13 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
189
182
  insertAt(cursor, `![pasted image](${imagePath})`);
190
183
  return;
191
184
  }
185
+ // Ctrl+G: cancel (Emacs abort). Ctrl+C can't be used because Ink's
186
+ // exitOnCtrlC fires at the app level before useInput sees it, exiting
187
+ // the dashboard. Esc is reserved for vim muscle memory (swallowed).
188
+ if (key.ctrl && input === "g") {
189
+ onCancel();
190
+ return;
191
+ }
192
192
  // Esc: swallow without cancelling (vim users hit it constantly).
193
193
  if (key.escape)
194
194
  return;
@@ -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 {};