feat(config): add interactive config editor and GitLab PR support (#4)
Build / bun-build (push) Successful in 32s

Revamp the configuration UI with an interactive editor that supports inline text editing, navigation, and field validation, replacing the previous sequential prompts. Add GitLab pull request creation support via the `glab` CLI, and extend back navigation to all interactive menus for a consistent user experience.

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-06-12 09:00:28 +08:00
parent c8626ff69a
commit 55db09c973
8 changed files with 536 additions and 131 deletions
+29 -8
View File
@@ -2,11 +2,17 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const LEFT = "\x1b[D";
const ALT_UP = "\x1bOA";
const ALT_DOWN = "\x1bOB";
const ALT_LEFT = "\x1bOD";
const SPACE = " ";
const ENTER = "\r";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK;
export interface Choice<T> {
label: string;
@@ -19,6 +25,7 @@ interface BasePromptOptions {
title: string;
subtitle?: string;
cancelMessage?: string;
allowBack?: boolean;
}
interface SinglePromptOptions<T> extends BasePromptOptions {
@@ -55,11 +62,13 @@ function padLabel(label: string, width: number) {
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
}
function controls(mode: "single" | "multi") {
function controls(mode: "single" | "multi", showBackHint = true) {
if (mode === "single") {
return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`;
const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`;
}
return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`;
const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`;
}
function renderPrompt(lines: string[], previousLines: number) {
@@ -89,6 +98,9 @@ function clearPrompt(lines: number) {
function normalizeKey(key: string, escapeBuf: string) {
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) {
return { action: "back", escapeBuf: "" };
}
if (key === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
@@ -101,6 +113,9 @@ function normalizeKey(key: string, escapeBuf: string) {
const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) {
return { action: "back", escapeBuf: "" };
}
return {
action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
@@ -126,7 +141,7 @@ function createLines<T>(
];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
lines.push(` ${controls(mode)}`, "");
lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!;
@@ -156,7 +171,7 @@ function ensureTTY(title: string) {
export async function selectOne<T>(
options: SinglePromptOptions<T>,
): Promise<T | null> {
): Promise<T | null | PromptBack> {
ensureTTY(options.title);
let cursor = 0;
@@ -178,7 +193,7 @@ export async function selectOne<T>(
render();
return new Promise((resolve) => {
const finish = (value: T | null) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
@@ -195,6 +210,9 @@ export async function selectOne<T>(
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) {
cursor--;
render();
@@ -212,7 +230,7 @@ export async function selectOne<T>(
export async function selectMany<T>(
options: MultiPromptOptions<T>,
): Promise<T[] | null> {
): Promise<T[] | null | PromptBack> {
ensureTTY(options.title);
const items: Choice<T | null>[] = options.selectAllLabel
@@ -267,7 +285,7 @@ export async function selectMany<T>(
render();
return new Promise((resolve) => {
const finish = (value: T[] | null) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
@@ -284,6 +302,9 @@ export async function selectMany<T>(
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) {
cursor--;
render();
+44 -37
View File
@@ -1,4 +1,4 @@
export type Platform = "github" | "gitea";
export type Platform = "github" | "gitea" | "gitlab";
export async function getDefaultBranch(): Promise<string> {
try {
@@ -93,16 +93,23 @@ function parseRemoteHostname(url: string): string | null {
return hostname || null;
}
export function detectPlatformFromHostname(hostname: string | null): Platform | null {
if (!hostname) return null;
if (hostname === "github.com") return "github";
if (hostname === "gitlab.com" || hostname.includes("gitlab")) {
return "gitlab";
}
if (hostname.includes("gitea")) return "gitea";
return null;
}
export async function detectPlatform(): Promise<Platform | null> {
try {
const url = await Bun.$`git remote get-url origin`.quiet().text();
const hostname = parseRemoteHostname(url);
if (!hostname) return null;
if (hostname === "github.com") return "github";
if (hostname.includes("gitea")) return "gitea";
return null;
return detectPlatformFromHostname(hostname);
} catch {
return null;
}
@@ -117,37 +124,6 @@ export async function getRemoteHostname(): Promise<string | null> {
}
}
export function checkCLI(platform: Platform): string | null {
const bin = platform === "github" ? "gh" : "tea";
const path = Bun.which(bin);
if (!path) {
if (platform === "github") {
return "GitHub CLI (gh) not found. Install: brew install gh";
}
return "Gitea CLI (tea) not found. Install from: https://gitea.com/gitea/tea";
}
return null;
}
export async function checkAuth(platform: Platform): Promise<string | null> {
if (platform === "github") {
try {
await Bun.$`gh auth status`.quiet();
return null;
} catch {
return "Not authenticated with GitHub CLI. Run: gh auth login";
}
}
try {
const result = await Bun.$`tea logins list`.quiet().text();
if (result.trim()) return null;
return "Not authenticated with Gitea CLI. Run: tea login add";
} catch {
return "Not authenticated with Gitea CLI. Run: tea login add";
}
}
export async function createPR(
platform: Platform,
title: string,
@@ -186,6 +162,37 @@ export async function createPR(
return match?.[1] ?? stdout.trim();
}
if (platform === "gitlab") {
const args = [
"mr",
"create",
"--title",
title,
"--description",
body,
"--target-branch",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["glab", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [
"pulls",
"create",
+4 -2
View File
@@ -1,11 +1,12 @@
import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { selectMany } from "./menu";
import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu";
export async function selectFiles(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
): Promise<string[]> {
): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) {
@@ -30,6 +31,7 @@ export async function selectFiles(
});
if (selected === null) process.exit(1);
if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(