From 57af72664d64edd404be211bb058183fe1618c2a Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 11 Jun 2026 00:38:12 +0800 Subject: [PATCH] feat(cli): add interactive platform selection for PR creation --- index.ts | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++--- src/pr.ts | 30 ++++++++----- 2 files changed, 144 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index ced6d0c..0595acb 100644 --- a/index.ts +++ b/index.ts @@ -25,10 +25,12 @@ import { getBranchCommits, getBranchDiff, detectPlatform, + getRemoteHostname, checkCLI, checkAuth, createPR, } from "./src/pr"; +import type { Platform } from "./src/pr"; import { PR_SYSTEM_PROMPT, buildPRPrompt } from "./src/prompt"; import { generatePRMessage } from "./src/ai"; @@ -413,6 +415,119 @@ async function showMenu(): Promise { }); } +async function selectPlatform(hostname: string): Promise { + const options = [ + { platform: "github" as Platform, label: "GitHub", desc: "gh CLI" }, + { platform: "gitea" as Platform, label: "Gitea", desc: "tea CLI" }, + ]; + let cursor = 0; + + const headerLines = 4; + + process.stdout.write(`\n Remote: ${CYAN}${hostname}${RESET} — could not auto-detect platform.\n`); + process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`); + + const totalLines = headerLines + options.length; + + function render() { + for (let i = 0; i < options.length; i++) { + process.stdout.write("\x1b[2K\r"); + const opt = options[i]!; + const pointer = i === cursor ? `${CYAN}❯${RESET} ` : " "; + const dot = i === cursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`; + const name = i === cursor ? `${BOLD}${opt.label}${RESET}` : opt.label; + const desc = i === cursor ? opt.desc : `${DIM}${opt.desc}${RESET}`; + process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 10 - opt.label.length))}${desc}\n`); + } + process.stdout.write(`\x1b[${options.length}A`); + } + + function clearMenu() { + process.stdout.write(`\x1b[${headerLines}A`); + for (let i = 0; i < totalLines; i++) { + process.stdout.write("\r\x1b[2K\n"); + } + process.stdout.write(`\x1b[${totalLines}A`); + } + + if (!process.stdin.isTTY) { + console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); + process.exit(1); + } + + const savedRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdout.write("\x1b[?25l"); + + render(); + + return new Promise((resolve) => { + let escapeBuf = ""; + + function handleSeq(seq: string) { + if (seq === "\x1b[A" || seq === "\x1bOA") { + if (cursor > 0) { + cursor--; + render(); + } + } else if (seq === "\x1b[B" || seq === "\x1bOB") { + if (cursor < options.length - 1) { + cursor++; + render(); + } + } + } + + process.stdin.on("data", (data: Buffer) => { + const key = data.toString(); + + if (key === "\x03") { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + clearMenu(); + process.stdout.write("\x1b[?25h"); + resolve(null); + return; + } + + if (key === "\x1b" || key.startsWith("\x1b[")) { + escapeBuf = key; + if (key.length >= 3) { + handleSeq(key); + escapeBuf = ""; + } + return; + } + + if (escapeBuf) { + escapeBuf += key; + if (/^[A-Za-z~]$/.test(key)) { + handleSeq(escapeBuf); + escapeBuf = ""; + } else if (escapeBuf.length > 8) { + escapeBuf = ""; + } + return; + } + + if (key === " " || key === "\r") { + const selected = options[cursor]!; + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + + clearMenu(); + process.stdout.write("\x1b[?25h"); + + resolve(selected.platform); + return; + } + }); + }); +} + async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { const config = await loadConfig(); @@ -549,16 +664,19 @@ async function handlePR(draft: boolean): Promise { process.exit(1); } - const platform = await detectPlatform(); + let platform = await detectPlatform(); if (!platform) { - console.error( - ` ${RED}Error: Could not detect GitHub or Gitea from origin remote URL.${RESET}`, - ); - process.exit(1); + const hostname = (await getRemoteHostname()) || "unknown"; + const chosen = await selectPlatform(hostname); + if (!chosen) { + console.log(" Aborted."); + process.exit(0); + } + platform = chosen; } const platformLabel = platform === "github" ? "GitHub" : "Gitea"; - console.log(` Remote platform: ${CYAN}${platformLabel}${RESET}`); + console.log(` Using: ${CYAN}${platformLabel}${RESET}`); const cliError = checkCLI(platform); if (cliError) { diff --git a/src/pr.ts b/src/pr.ts index 88026e5..20e867f 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -51,30 +51,40 @@ export async function getBranchDiff(base: string): Promise { } } +function parseRemoteHostname(url: string): string | null { + const hostname = url + .trim() + .toLowerCase() + .replace(/^(https?:\/\/|ssh:\/\/|git:\/\/)/, "") + .replace(/^[^@]+@/, "") + .split(/[:/]/)[0]; + return hostname || null; +} + export async function detectPlatform(): Promise { try { const url = await Bun.$`git remote get-url origin`.quiet().text(); - const trimmed = url.trim().toLowerCase(); - - const hostname = trimmed - .replace(/^(https?:\/\/|ssh:\/\/|git:\/\/)/, "") - .replace(/^[^@]+@/, "") - .split(/[:/]/)[0]; + const hostname = parseRemoteHostname(url); if (!hostname) return null; if (hostname === "github.com") return "github"; if (hostname.includes("gitea")) return "gitea"; - - if (Bun.which("tea")) return "gitea"; - if (Bun.which("gh")) return "github"; - return null; } catch { return null; } } +export async function getRemoteHostname(): Promise { + try { + const url = await Bun.$`git remote get-url origin`.quiet().text(); + return parseRemoteHostname(url); + } catch { + return null; + } +} + export function checkCLI(platform: Platform): string | null { const bin = platform === "github" ? "gh" : "tea"; const path = Bun.which(bin);