@senad-d/branchme 0.1.1

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.
@@ -0,0 +1,275 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import {
4
+ BRANCH_STATUS_TOOL_NAME,
5
+ CHANGE_BRANCH_TOOL_NAME,
6
+ CREATE_BRANCH_TOOL_NAME,
7
+ PULL_REQUEST_TOOL_NAME,
8
+ PUSH_BRANCH_TOOL_NAME,
9
+ } from "../constants.ts";
10
+ import {
11
+ changeExistingLocalBranch,
12
+ createLocalBranch,
13
+ getBranchStatus,
14
+ getGitRoot,
15
+ getLocalBranchCommit,
16
+ localBranchExists,
17
+ pushCurrentBranch,
18
+ validateBranchName,
19
+ withRepositoryMutationQueue,
20
+ } from "../git.ts";
21
+ import {
22
+ createGitHubPullRequest,
23
+ ensureGitHubBranchExists,
24
+ redactSecrets,
25
+ repositoryLabel,
26
+ resolveGitHubRepository,
27
+ resolveGitHubToken,
28
+ validatePullRequestBranchRef,
29
+ } from "../github.ts";
30
+ import type { BranchStatusDetails, ChangeBranchDetails, PullRequestDetails } from "../types.ts";
31
+
32
+ const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
33
+
34
+ const CreateBranchParametersSchema = Type.Object(
35
+ {
36
+ branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
37
+ },
38
+ { additionalProperties: false },
39
+ );
40
+
41
+ const ChangeBranchParametersSchema = Type.Object(
42
+ {
43
+ branchName: Type.String({ minLength: 1, description: "Name of the existing local branch to switch to." }),
44
+ },
45
+ { additionalProperties: false },
46
+ );
47
+
48
+ const PullRequestParametersSchema = Type.Object(
49
+ {
50
+ headBranch: Type.String({ minLength: 1, description: "Existing local branch containing the pull request changes." }),
51
+ baseBranch: Type.String({ minLength: 1, description: "Existing local target branch for the pull request." }),
52
+ title: Type.String({ minLength: 1, description: "Pull request title." }),
53
+ body: Type.String({ description: "Pull request body. Pass an empty string only when intentionally blank." }),
54
+ draft: Type.Boolean({ description: "Whether to create the pull request as a draft." }),
55
+ },
56
+ { additionalProperties: false },
57
+ );
58
+
59
+ // Exported so tests and advanced embedders can inject environment/fetch behavior without mutating globals.
60
+ export interface BranchMeToolOptions {
61
+ env?: NodeJS.ProcessEnv;
62
+ fetchImpl?: typeof fetch;
63
+ }
64
+
65
+ function repositoryText(details: BranchStatusDetails): string {
66
+ return details.githubRepository ? repositoryLabel(details.githubRepository) : "not resolved";
67
+ }
68
+
69
+ export function formatBranchStatus(details: BranchStatusDetails): string {
70
+ const branch = details.detached ? "detached HEAD" : details.currentBranch ?? "unknown branch";
71
+ const tree = details.hasChanges ? "dirty" : "clean";
72
+ const upstream = details.upstream ? `upstream ${details.upstream}` : "no upstream";
73
+ const counts = details.ahead === null || details.behind === null ? "ahead/behind unavailable" : `ahead ${details.ahead}, behind ${details.behind}`;
74
+ const warning = details.warnings?.length ? `; warning: ${details.warnings.join("; ")}` : "";
75
+ return `BranchMe status: ${branch}; ${tree}; ${upstream}; ${counts}; GitHub ${repositoryText(details)}${warning}.`;
76
+ }
77
+
78
+ export function formatChangeBranch(details: ChangeBranchDetails): string {
79
+ const previous = details.previousDetached ? "detached HEAD" : details.previousBranch ?? "unknown branch";
80
+ return `Changed branch from ${previous} to ${details.currentBranch}.`;
81
+ }
82
+
83
+ export function formatPullRequest(details: PullRequestDetails): string {
84
+ return `Created pull request #${details.number} (${details.state}) for ${repositoryLabel(details.repository)}: ${details.url}`;
85
+ }
86
+
87
+ async function validateLocalPullRequestBranchName(
88
+ pi: Pick<ExtensionAPI, "exec">,
89
+ ctx: { cwd: string },
90
+ branchName: string,
91
+ field: "headBranch" | "baseBranch",
92
+ signal?: AbortSignal,
93
+ ): Promise<void> {
94
+ // Keep PR-specific safety rules and local branch semantics on one path before existence checks.
95
+ validatePullRequestBranchRef(branchName, field);
96
+ try {
97
+ await validateBranchName(pi, ctx, branchName, signal);
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ throw new Error(`${field} is not a valid local branch name: ${redactSecrets(message)}.`);
101
+ }
102
+ }
103
+
104
+ async function requireExistingValidatedLocalPullRequestBranch(
105
+ pi: Pick<ExtensionAPI, "exec">,
106
+ ctx: { cwd: string },
107
+ branchName: string,
108
+ field: "headBranch" | "baseBranch",
109
+ signal?: AbortSignal,
110
+ ): Promise<void> {
111
+ if (await localBranchExists(pi, ctx, branchName, signal)) return;
112
+ throw new Error(`${field} local branch '${redactSecrets(branchName)}' does not exist.`);
113
+ }
114
+
115
+ function shortCommit(commit: string): string {
116
+ return commit.slice(0, 12);
117
+ }
118
+
119
+ async function requireGitHubHeadMatchesLocalBranch(
120
+ pi: Pick<ExtensionAPI, "exec">,
121
+ ctx: { cwd: string },
122
+ branchName: string,
123
+ githubCommitSha: string,
124
+ signal?: AbortSignal,
125
+ ): Promise<void> {
126
+ const localCommit = await getLocalBranchCommit(pi, ctx, branchName, signal);
127
+ if (localCommit.toLowerCase() === githubCommitSha.toLowerCase()) return;
128
+ throw new Error(
129
+ `headBranch local branch '${redactSecrets(branchName)}' points to ${shortCommit(localCommit)}, but GitHub has ${shortCommit(githubCommitSha)}. Run push_branch and wait for it to complete before calling pull_request, then retry.`,
130
+ );
131
+ }
132
+
133
+ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "exec">, options: BranchMeToolOptions = {}): void {
134
+ pi.registerTool({
135
+ name: BRANCH_STATUS_TOOL_NAME,
136
+ label: "Branch Status",
137
+ description: "branch_status inspects the current git repository, branch, upstream, dirty state, ahead/behind counts, and GitHub repository if available. branch_status is read-only.",
138
+ promptSnippet: "branch_status: inspect current-repository git branch status without mutating files or git state",
139
+ promptGuidelines: [
140
+ "Use branch_status before change_branch, create_branch, push_branch, or pull_request when the user asks about the current branch state.",
141
+ "Use branch_status only for read-only inspection; branch_status never creates branches, pushes, commits, stages, or edits files.",
142
+ ],
143
+ parameters: EmptyParametersSchema,
144
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
145
+ const details = await getBranchStatus(pi, ctx, signal);
146
+
147
+ try {
148
+ details.githubRepository = await resolveGitHubRepository(pi, ctx, signal, options.env);
149
+ } catch {
150
+ // Repository resolution is optional for branch_status; mutation tools fail closed.
151
+ }
152
+
153
+ return {
154
+ content: [{ type: "text", text: formatBranchStatus(details) }],
155
+ details,
156
+ };
157
+ },
158
+ });
159
+
160
+ pi.registerTool({
161
+ name: CREATE_BRANCH_TOOL_NAME,
162
+ label: "Create Branch",
163
+ description: "create_branch creates and checks out a new local branch from the current HEAD only. create_branch does not accept a base ref and never stages, commits, pushes, or edits files.",
164
+ promptSnippet: "create_branch: create and checkout a new branch from current HEAD using an explicit branchName",
165
+ promptGuidelines: [
166
+ "Use create_branch only when the user explicitly wants a new branch from current HEAD.",
167
+ "Use create_branch with only branchName; create_branch never accepts or infers baseRef, commits, stages, pushes, or edits files.",
168
+ ],
169
+ parameters: CreateBranchParametersSchema,
170
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
171
+ const details = await createLocalBranch(pi, ctx, params.branchName, signal);
172
+ return {
173
+ content: [
174
+ {
175
+ type: "text",
176
+ text: `Created and checked out branch ${details.newBranch} from ${details.previousBranch}.`,
177
+ },
178
+ ],
179
+ details,
180
+ };
181
+ },
182
+ });
183
+
184
+ pi.registerTool({
185
+ name: CHANGE_BRANCH_TOOL_NAME,
186
+ label: "Change Branch",
187
+ description: "change_branch switches to an existing local branch in the current repository only. change_branch rejects dirty worktrees and never creates branches, forces checkout, stashes, stages, commits, pushes, or edits files directly.",
188
+ promptSnippet: "change_branch: switch to an existing local branch with branchName after a clean-worktree preflight",
189
+ promptGuidelines: [
190
+ "Use change_branch only when the user explicitly wants to switch to an existing local branch in the current repository.",
191
+ "Use change_branch with only branchName; change_branch never accepts baseRef, force, stash, discard, create, owner, repo, or path inputs.",
192
+ "Use change_branch only on a clean working tree; change_branch rejects dirty worktrees and never stages, commits, pushes, stashes, or force-switches.",
193
+ ],
194
+ parameters: ChangeBranchParametersSchema,
195
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
196
+ const details = await changeExistingLocalBranch(pi, ctx, params.branchName, signal);
197
+ return {
198
+ content: [{ type: "text", text: formatChangeBranch(details) }],
199
+ details,
200
+ };
201
+ },
202
+ });
203
+
204
+ pi.registerTool({
205
+ name: PUSH_BRANCH_TOOL_NAME,
206
+ label: "Push Branch",
207
+ description: "push_branch pushes the current branch to its configured upstream remote with an explicit refspec. If the current branch has no upstream, push_branch publishes it to origin with --set-upstream. push_branch never commits, stages, or edits files.",
208
+ promptSnippet: "push_branch: push or publish the current branch only with an explicit target, without committing or staging",
209
+ promptGuidelines: [
210
+ "Use push_branch only after commits already exist; push_branch never commits, stages, or edits files.",
211
+ "Use push_branch to push only the current branch; push_branch does not accept a branchName parameter.",
212
+ "Use push_branch by itself before pull_request; wait for push_branch to complete before creating a pull_request.",
213
+ ],
214
+ parameters: EmptyParametersSchema,
215
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
216
+ const details = await pushCurrentBranch(pi, ctx, signal);
217
+ const action = details.mode === "publish" ? "Published" : "Pushed";
218
+ return {
219
+ content: [{ type: "text", text: `${action} current branch ${details.currentBranch}.` }],
220
+ details,
221
+ };
222
+ },
223
+ });
224
+
225
+ pi.registerTool({
226
+ name: PULL_REQUEST_TOOL_NAME,
227
+ label: "Pull Request",
228
+ description: "pull_request creates a GitHub pull request in the resolved current repository. pull_request requires headBranch and baseBranch to exist as safe local branch names, requires headBranch to match the GitHub-visible branch commit, and requires baseBranch to be visible on GitHub. Owner-prefixed refs, owner, and repo are never accepted as inputs.",
229
+ promptSnippet: "pull_request: create a GitHub pull request in the current repository with all PR fields explicit",
230
+ promptGuidelines: [
231
+ "Use pull_request only when the user provides explicit headBranch, baseBranch, title, body, and draft values.",
232
+ "Use pull_request only with existing local branches for headBranch and baseBranch; headBranch must match the GitHub-visible branch commit.",
233
+ "Do not call push_branch and pull_request in the same tool batch; call pull_request only after push_branch has completed.",
234
+ "Use pull_request only for the resolved current repository; pull_request never accepts owner, repo, or owner-prefixed branch refs.",
235
+ "Use pull_request with GITHUB_TOKEN or GH_TOKEN from the process environment or local .env fallback; pull_request must not expose token values.",
236
+ ],
237
+ parameters: PullRequestParametersSchema,
238
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
239
+ const repoRoot = await getGitRoot(pi, ctx, signal);
240
+ const rootCtx = { cwd: repoRoot };
241
+
242
+ return withRepositoryMutationQueue(repoRoot, async () => {
243
+ await validateLocalPullRequestBranchName(pi, rootCtx, params.headBranch, "headBranch", signal);
244
+ await validateLocalPullRequestBranchName(pi, rootCtx, params.baseBranch, "baseBranch", signal);
245
+ await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.headBranch, "headBranch", signal);
246
+ await requireExistingValidatedLocalPullRequestBranch(pi, rootCtx, params.baseBranch, "baseBranch", signal);
247
+ const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
248
+ const token = (await resolveGitHubToken(options.env, { cwd: repoRoot, signal })).token;
249
+
250
+ try {
251
+ const headBranch = await ensureGitHubBranchExists(repository, params.headBranch, "headBranch", token, {
252
+ fetchImpl: options.fetchImpl,
253
+ signal,
254
+ });
255
+ await requireGitHubHeadMatchesLocalBranch(pi, rootCtx, params.headBranch, headBranch.commitSha, signal);
256
+ await ensureGitHubBranchExists(repository, params.baseBranch, "baseBranch", token, {
257
+ fetchImpl: options.fetchImpl,
258
+ signal,
259
+ });
260
+ const details = await createGitHubPullRequest(repository, params, token, {
261
+ fetchImpl: options.fetchImpl,
262
+ signal,
263
+ });
264
+ return {
265
+ content: [{ type: "text", text: formatPullRequest(details) }],
266
+ details,
267
+ };
268
+ } catch (error) {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ throw new Error(redactSecrets(message, [token]));
271
+ }
272
+ });
273
+ },
274
+ });
275
+ }
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ export interface GitHubRepository {
2
+ owner: string;
3
+ repo: string;
4
+ }
5
+
6
+ export interface CurrentBranchInfo {
7
+ currentBranch: string | null;
8
+ detached: boolean;
9
+ }
10
+
11
+ export interface AheadBehindCount {
12
+ ahead: number | null;
13
+ behind: number | null;
14
+ }
15
+
16
+ export interface BranchStatusDetails {
17
+ repoRoot: string;
18
+ currentBranch: string | null;
19
+ detached: boolean;
20
+ upstream: string | null;
21
+ hasChanges: boolean;
22
+ ahead: number | null;
23
+ behind: number | null;
24
+ warnings?: string[];
25
+ githubRepository?: GitHubRepository;
26
+ }
27
+
28
+ export interface CreateBranchDetails {
29
+ repoRoot: string;
30
+ previousBranch: string;
31
+ newBranch: string;
32
+ }
33
+
34
+ export interface ChangeBranchDetails {
35
+ repoRoot: string;
36
+ previousBranch: string | null;
37
+ previousDetached: boolean;
38
+ currentBranch: string;
39
+ hasChangesBeforeSwitch: false;
40
+ }
41
+
42
+ export interface PushBranchDetails {
43
+ repoRoot: string;
44
+ currentBranch: string;
45
+ upstream: string | null;
46
+ mode: "push" | "publish";
47
+ remote: string;
48
+ remoteRef: string;
49
+ refspec: string;
50
+ output: string;
51
+ }
52
+
53
+ export interface PullRequestDetails {
54
+ repository: GitHubRepository;
55
+ number: number;
56
+ url: string;
57
+ state: string;
58
+ head: string;
59
+ base: string;
60
+ draft: boolean;
61
+ }
62
+
63
+ export interface PullRequestInput {
64
+ headBranch: string;
65
+ baseBranch: string;
66
+ title: string;
67
+ body: string;
68
+ draft: boolean;
69
+ }
70
+
71
+ export interface GitExecResult {
72
+ stdout: string;
73
+ stderr: string;
74
+ code: number;
75
+ killed: boolean;
76
+ }
@@ -0,0 +1,345 @@
1
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ export interface BranchMePanelData {
4
+ currentBranch: string | null;
5
+ detached: boolean;
6
+ githubRepository: string | null;
7
+ tokenSource: string | null;
8
+ statusNote?: string;
9
+ repositoryWarning?: string;
10
+ tokenWarning?: string;
11
+ }
12
+
13
+ interface PanelTheme {
14
+ fg(color: string, value: string): string;
15
+ bold(value: string): string;
16
+ }
17
+
18
+ type PanelCell =
19
+ | string
20
+ | {
21
+ text: string;
22
+ role?: string;
23
+ bold?: boolean;
24
+ preserveLeading?: boolean;
25
+ };
26
+
27
+ export type BranchMePanelSection = "status" | "workflow";
28
+
29
+ const NARROW_MIN_WIDTH = 24;
30
+ const WIDE_MIN_WIDTH = 72;
31
+ const MAX_PANEL_WIDTH = 96;
32
+ const MAX_PANEL_HEIGHT = 14;
33
+ const BODY_HEIGHT = 7;
34
+ const PANEL_SECTIONS: BranchMePanelSection[] = ["status", "workflow"];
35
+ const ANSI_RESET = /\u001b\[0m/gu;
36
+
37
+ function truncateVisible(value: string, width: number, ellipsis = "…", pad = false): string {
38
+ const truncated = truncateToWidth(value, width, ellipsis, pad);
39
+ return value.includes("\u001b") ? truncated : truncated.replace(ANSI_RESET, "");
40
+ }
41
+
42
+ function sanitize(value: string): string {
43
+ return value.replace(/[\u0000-\u001f\u007f\u009b]/gu, " ").replace(/\s+/gu, " ").trim();
44
+ }
45
+
46
+ function clip(value: string, width: number): string {
47
+ if (width <= 0) return "";
48
+ return truncateVisible(sanitize(value), width);
49
+ }
50
+
51
+ function pad(value: string, width: number): string {
52
+ return truncateVisible(sanitize(value), Math.max(0, width), "…", true);
53
+ }
54
+
55
+ function sanitizeLayout(value: string): string {
56
+ return value.replace(/[\u0000-\u001f\u007f\u009b]/gu, " ");
57
+ }
58
+
59
+ function clipLayout(value: string, width: number): string {
60
+ if (width <= 0) return "";
61
+ return truncateVisible(sanitizeLayout(value), width);
62
+ }
63
+
64
+ function padLayout(value: string, width: number): string {
65
+ return truncateVisible(sanitizeLayout(value), Math.max(0, width), "…", true);
66
+ }
67
+
68
+ function style(theme: PanelTheme | undefined, role: string, value: string): string {
69
+ return theme ? theme.fg(role, value) : value;
70
+ }
71
+
72
+ function formatCell(cell: PanelCell, width: number, theme?: PanelTheme): string {
73
+ const text = typeof cell === "string" ? cell : cell.text;
74
+ const rendered = typeof cell === "string" || !cell.preserveLeading ? pad(text, width) : padLayout(text, width);
75
+ if (typeof cell === "string" || !theme || !cell.role) return rendered;
76
+
77
+ const styled = cell.bold ? theme.bold(rendered) : rendered;
78
+ return theme.fg(cell.role, styled);
79
+ }
80
+
81
+ function layoutCell(text: string, role?: string, bold = false): PanelCell {
82
+ return { text, role, bold, preserveLeading: true };
83
+ }
84
+
85
+ function heading(text: string): PanelCell {
86
+ return layoutCell(` ${text}`, "accent", true);
87
+ }
88
+
89
+ function category(text: string, selected: boolean): PanelCell {
90
+ return {
91
+ text: `${selected ? "▶" : " "} ${text}`,
92
+ role: selected ? "accent" : "dim",
93
+ bold: selected,
94
+ preserveLeading: true,
95
+ };
96
+ }
97
+
98
+ function border(theme: PanelTheme | undefined, value: string): string {
99
+ return style(theme, "accent", value);
100
+ }
101
+
102
+ function horizontal(width: number, left: string, fill: string, right: string, theme?: PanelTheme): string {
103
+ if (width <= 0) return "";
104
+ if (width === 1) return border(theme, fill);
105
+ return border(theme, `${left}${fill.repeat(Math.max(0, width - 2))}${right}`);
106
+ }
107
+
108
+ function titleBorder(width: number, scope: string, theme?: PanelTheme): string {
109
+ if (width < 3) return horizontal(width, "╭", "─", "╮", theme);
110
+
111
+ const title = " BranchMe ";
112
+ const scopeText = ` ${scope} `;
113
+ const innerWidth = width - 2;
114
+ const showScope = title.length + scopeText.length <= innerWidth;
115
+ const visibleTitle = title.length <= innerWidth ? title : " BranchMe";
116
+ const visibleScope = showScope ? scopeText : "";
117
+ const fillWidth = Math.max(0, innerWidth - visibleTitle.length - visibleScope.length);
118
+ return border(theme, `╭${visibleTitle}${"─".repeat(fillWidth)}${visibleScope}╮`);
119
+ }
120
+
121
+ function framedLine(content: PanelCell, width: number, theme?: PanelTheme): string {
122
+ const innerWidth = Math.max(0, width - 2);
123
+ return `${border(theme, "│")}${formatCell(content, innerWidth, theme)}${border(theme, "│")}`;
124
+ }
125
+
126
+ function panelWarnings(data: BranchMePanelData): string[] {
127
+ return [data.statusNote, data.repositoryWarning, data.tokenWarning].filter((warning): warning is string => Boolean(warning));
128
+ }
129
+
130
+ function firstPanelWarning(data: BranchMePanelData): string | null {
131
+ return panelWarnings(data)[0] ?? null;
132
+ }
133
+
134
+ function statusValue(data: BranchMePanelData): { branch: string; repository: string; token: string } {
135
+ return {
136
+ branch: data.detached ? "detached HEAD" : data.currentBranch ?? "unknown",
137
+ repository: data.repositoryWarning ? `warning: ${data.repositoryWarning}` : data.githubRepository ?? "not resolved",
138
+ token: data.tokenWarning ? `warning: ${data.tokenWarning}` : getTokenLabel(data.tokenSource),
139
+ };
140
+ }
141
+
142
+ function statusDetailRow(label: string, value: string): PanelCell {
143
+ return layoutCell(` ${label.padEnd(19, " ")}${sanitize(value)}`);
144
+ }
145
+
146
+ function workflowDetailRow(tool: string, description: string): PanelCell {
147
+ return layoutCell(` ${tool.padEnd(15, " ")}-> ${sanitize(description)}`);
148
+ }
149
+
150
+ function sectionTitle(section: BranchMePanelSection): string {
151
+ switch (section) {
152
+ case "status":
153
+ return "STATUS";
154
+ case "workflow":
155
+ return "WORKFLOW";
156
+ }
157
+ }
158
+
159
+ function sectionLabel(section: BranchMePanelSection): string {
160
+ const title = sectionTitle(section).toLowerCase();
161
+ return title.charAt(0).toUpperCase() + title.slice(1);
162
+ }
163
+
164
+ function normalizeSection(section: BranchMePanelSection | undefined): BranchMePanelSection {
165
+ return section && PANEL_SECTIONS.includes(section) ? section : "status";
166
+ }
167
+
168
+ function sectionIndex(section: BranchMePanelSection): number {
169
+ return PANEL_SECTIONS.indexOf(section);
170
+ }
171
+
172
+ function sectionFooter(section: BranchMePanelSection, data: BranchMePanelData): string {
173
+ const warning = firstPanelWarning(data);
174
+ if (warning) return `warning • ${warning}`;
175
+
176
+ switch (section) {
177
+ case "status":
178
+ return "status • current repository only • tools perform actions";
179
+ case "workflow":
180
+ return "workflow • inspect → change/create → push → PR";
181
+ }
182
+ }
183
+
184
+ function sectionRows(section: BranchMePanelSection, data: BranchMePanelData): PanelCell[] {
185
+ const values = statusValue(data);
186
+
187
+ switch (section) {
188
+ case "status":
189
+ return [
190
+ heading("STATUS"),
191
+ statusDetailRow("Current branch:", values.branch),
192
+ statusDetailRow("GitHub repository:", values.repository),
193
+ statusDetailRow("GitHub token:", values.token),
194
+ ];
195
+ case "workflow":
196
+ return [
197
+ heading("WORKFLOW"),
198
+ workflowDetailRow("branch_status", "inspect"),
199
+ workflowDetailRow("change_branch", "existing local"),
200
+ workflowDetailRow("create_branch", "from HEAD"),
201
+ workflowDetailRow("push_branch", "current branch"),
202
+ workflowDetailRow("pull_request", "after push"),
203
+ ];
204
+ }
205
+ }
206
+
207
+ function fitLines(lines: string[], width: number): string[] {
208
+ return lines.map((line) => truncateVisible(line, Math.max(0, width), ""));
209
+ }
210
+
211
+ function renderTiny(data: BranchMePanelData, width: number): string[] {
212
+ const values = statusValue(data);
213
+ const warning = firstPanelWarning(data);
214
+ return fitLines(
215
+ [
216
+ clip("BranchMe", width),
217
+ clip(`branch: ${values.branch}`, width),
218
+ clip(`repo: ${values.repository}`, width),
219
+ clip(warning ? `warning: ${warning}` : "q quit", width),
220
+ ],
221
+ width,
222
+ );
223
+ }
224
+
225
+ function renderNarrow(data: BranchMePanelData, width: number, selectedSection: BranchMePanelSection, theme?: PanelTheme): string[] {
226
+ const section = normalizeSection(selectedSection);
227
+ const rows = sectionRows(section, data).slice(0, BODY_HEIGHT);
228
+ const counter = `${sectionIndex(section) + 1}/${PANEL_SECTIONS.length}`;
229
+ const lines = [
230
+ titleBorder(width, sectionLabel(section), theme),
231
+ framedLine("current repo only • informational", width, theme),
232
+ framedLine(layoutCell(" ↑↓ section • q quit • /branchme help"), width, theme),
233
+ horizontal(width, "├", "─", "┤", theme),
234
+ ];
235
+
236
+ for (let index = 0; index < BODY_HEIGHT; index += 1) {
237
+ const row = rows[index] ?? "";
238
+ lines.push(framedLine(row, width, theme));
239
+ }
240
+
241
+ lines.push(
242
+ horizontal(width, "├", "─", "┤", theme),
243
+ framedLine(layoutCell(` ${counter} • ${sectionFooter(section, data)}`), width, theme),
244
+ horizontal(width, "╰", "─", "╯", theme),
245
+ );
246
+
247
+ return fitLines(lines.slice(0, MAX_PANEL_HEIGHT), width);
248
+ }
249
+
250
+ function paneSeparator(width: number, leftPaneWidth: number, rightPaneWidth: number, middle: string, theme?: PanelTheme): string {
251
+ return border(theme, `├${"─".repeat(leftPaneWidth)}${middle}${"─".repeat(rightPaneWidth)}┤`);
252
+ }
253
+
254
+ function paneLine(left: PanelCell, right: PanelCell, leftPaneWidth: number, rightPaneWidth: number, theme?: PanelTheme): string {
255
+ return `${border(theme, "│")}${formatCell(left, leftPaneWidth, theme)}${border(theme, "│")}${formatCell(right, rightPaneWidth, theme)}${border(theme, "│")}`;
256
+ }
257
+
258
+ function renderWide(data: BranchMePanelData, width: number, selectedSection: BranchMePanelSection, theme?: PanelTheme): string[] {
259
+ const section = normalizeSection(selectedSection);
260
+ const leftPaneWidth = Math.min(22, Math.max(16, Math.floor(width * 0.27)));
261
+ const rightPaneWidth = Math.max(10, width - leftPaneWidth - 3);
262
+ const rightRows = sectionRows(section, data).slice(0, BODY_HEIGHT);
263
+ const leftRows = PANEL_SECTIONS.map((candidate) => category(sectionLabel(candidate), candidate === section));
264
+
265
+ const lines = [
266
+ titleBorder(width, sectionLabel(section), theme),
267
+ framedLine(layoutCell(" ↑↓ section • q quit • /branchme help"), width, theme),
268
+ paneSeparator(width, leftPaneWidth, rightPaneWidth, "┬", theme),
269
+ ];
270
+
271
+ for (let index = 0; index < BODY_HEIGHT; index += 1) {
272
+ lines.push(paneLine(leftRows[index] ?? "", rightRows[index] ?? "", leftPaneWidth, rightPaneWidth, theme));
273
+ }
274
+
275
+ lines.push(
276
+ paneSeparator(width, leftPaneWidth, rightPaneWidth, "┴", theme),
277
+ framedLine(layoutCell(` ${sectionIndex(section) + 1}/${PANEL_SECTIONS.length} • ${sectionFooter(section, data)}`), width, theme),
278
+ horizontal(width, "╰", "─", "╯", theme),
279
+ );
280
+
281
+ return fitLines(lines.slice(0, MAX_PANEL_HEIGHT), width);
282
+ }
283
+
284
+ export function getTokenLabel(tokenSource: string | null): string {
285
+ return tokenSource ? "present" : "not set";
286
+ }
287
+
288
+ export function renderBranchMePanelLines(
289
+ data: BranchMePanelData,
290
+ width: number,
291
+ theme?: PanelTheme,
292
+ selectedSection: BranchMePanelSection = "status",
293
+ ): string[] {
294
+ const normalizedWidth = Math.max(0, width);
295
+ if (normalizedWidth < NARROW_MIN_WIDTH) return renderTiny(data, normalizedWidth);
296
+
297
+ const panelWidth = Math.min(normalizedWidth, MAX_PANEL_WIDTH);
298
+ const section = normalizeSection(selectedSection);
299
+ if (panelWidth >= WIDE_MIN_WIDTH) return renderWide(data, panelWidth, section, theme);
300
+ return renderNarrow(data, panelWidth, section, theme);
301
+ }
302
+
303
+ export class BranchMePanel {
304
+ private readonly data: BranchMePanelData;
305
+ private readonly theme: PanelTheme | undefined;
306
+ private readonly onClose: () => void;
307
+ private readonly onChange: () => void;
308
+ private selectedSection: BranchMePanelSection = "status";
309
+
310
+ constructor(data: BranchMePanelData, theme: PanelTheme | undefined, onClose: () => void, onChange: () => void = () => {}) {
311
+ this.data = data;
312
+ this.theme = theme;
313
+ this.onClose = onClose;
314
+ this.onChange = onChange;
315
+ }
316
+
317
+ render(width: number): string[] {
318
+ return renderBranchMePanelLines(this.data, width, this.theme, this.selectedSection);
319
+ }
320
+
321
+ handleInput(data: string): void {
322
+ if (data === "q" || data === "Q" || matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
323
+ this.onClose();
324
+ return;
325
+ }
326
+
327
+ if (data === "k" || data === "K" || matchesKey(data, Key.up)) {
328
+ this.moveSelection(-1);
329
+ return;
330
+ }
331
+
332
+ if (data === "j" || data === "J" || matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
333
+ this.moveSelection(1);
334
+ }
335
+ }
336
+
337
+ invalidate(): void {}
338
+
339
+ private moveSelection(delta: number): void {
340
+ const currentIndex = sectionIndex(this.selectedSection);
341
+ const nextIndex = (currentIndex + delta + PANEL_SECTIONS.length) % PANEL_SECTIONS.length;
342
+ this.selectedSection = PANEL_SECTIONS[nextIndex] ?? "status";
343
+ this.onChange();
344
+ }
345
+ }