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.
- package/dist/commands/dashboard.js +228 -67
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/helpers/squirrel.d.ts +2 -0
- package/dist/commands/helpers/squirrel.js +12 -0
- package/dist/commands/worktree/commit.d.ts +9 -1
- package/dist/commands/worktree/commit.js +58 -14
- package/dist/lib/ai.d.ts +26 -0
- package/dist/lib/ai.js +53 -0
- package/dist/lib/claude-todos.d.ts +37 -0
- package/dist/lib/claude-todos.js +98 -0
- package/dist/lib/dashboard/DetailPanel.js +99 -9
- package/dist/lib/dashboard/IssueList.js +2 -0
- package/dist/lib/dashboard/MultilineTextArea.js +24 -11
- package/dist/lib/dashboard/Overlays.d.ts +5 -0
- package/dist/lib/dashboard/Overlays.js +76 -3
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
- package/dist/lib/dashboard/ReviewList.js +12 -15
- package/dist/lib/dashboard/data.js +158 -7
- package/dist/lib/dashboard/types.d.ts +45 -10
- package/dist/lib/dashboard/types.js +40 -7
- package/dist/lib/diff-parse.d.ts +25 -0
- package/dist/lib/diff-parse.js +60 -0
- package/dist/lib/git.d.ts +22 -0
- package/dist/lib/git.js +41 -0
- package/dist/lib/github.d.ts +6 -0
- package/dist/lib/github.js +29 -0
- package/dist/lib/open-url.d.ts +10 -0
- package/dist/lib/open-url.js +20 -0
- package/dist/lib/squirrel-loader.d.ts +9 -0
- package/dist/lib/squirrel-loader.js +322 -0
- package/dist/lib/trackers/index.d.ts +13 -0
- package/dist/lib/trackers/index.js +19 -0
- package/package.json +1 -1
- 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
|
-
|
|
30
|
-
|
|
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
|
|
51
|
+
await execAsync("git add -A", { cwd: repoRoot });
|
|
44
52
|
setGitStatus(getGitStatus());
|
|
45
53
|
setDiffStat(getStagedDiffStat());
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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) {
|
|
@@ -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
|
|
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, ``);
|
|
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
|
-
|
|
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 {};
|