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