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
+386 -81
View File
@@ -13,7 +13,8 @@ import {
commit,
} from "./src/git";
import { selectFiles } from "./src/selector";
import { selectOne } from "./src/menu";
import { BACK, selectOne } from "./src/menu";
import type { PromptBack } from "./src/menu";
import { collectProjectContext } from "./src/context";
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
import { generateCommitMessage } from "./src/ai";
@@ -29,8 +30,6 @@ import {
getBranchDiff,
detectPlatform,
getRemoteHostname,
checkCLI,
checkAuth,
createPR,
} from "./src/pr";
import type { Platform } from "./src/pr";
@@ -77,41 +76,333 @@ function ask(question: string): Promise<string> {
});
}
async function handleConfig() {
const config = await loadConfig();
type ConfigKey = keyof Config;
console.log(`\n ${BOLD}Current configuration:${RESET}`);
console.log(
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`,
);
console.log(` API Base: ${config.apiBase}`);
console.log(` Model: ${config.model}`);
console.log(` Max Tokens: ${config.maxTokens}`);
console.log(` Temperature: ${config.temperature}`);
interface ConfigField {
key: ConfigKey;
label: string;
format: (config: Config) => string;
initialEditValue: (config: Config) => string;
parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
}
console.log(
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`,
);
const CONFIG_FIELDS: ConfigField[] = [
{
key: "apiKey",
label: "API Key",
format: (config) =>
config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`,
initialEditValue: () => "",
parse: (value) => ({ value }),
},
{
key: "apiBase",
label: "API Base",
format: (config) => config.apiBase,
initialEditValue: (config) => config.apiBase,
parse: (value) => ({ value }),
},
{
key: "model",
label: "Model",
format: (config) => config.model,
initialEditValue: (config) => config.model,
parse: (value) => ({ value }),
},
{
key: "maxTokens",
label: "Max Tokens",
format: (config) => String(config.maxTokens),
initialEditValue: (config) => String(config.maxTokens),
parse: (value) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { error: "Max Tokens must be a positive integer." };
}
return { value: parsed };
},
},
{
key: "temperature",
label: "Temperature",
format: (config) => String(config.temperature),
initialEditValue: (config) => String(config.temperature),
parse: (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: "Temperature must be a finite number." };
}
return { value: parsed };
},
},
];
const apiKey = await ask(" API Key: ");
const apiBase = await ask(` API Base [${config.apiBase}]: `);
const model = await ask(` Model [${config.model}]: `);
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
const temperature = await ask(` Temperature [${config.temperature}]: `);
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
const updates: Partial<Config> = {};
if (apiKey) updates.apiKey = apiKey;
if (apiBase) updates.apiBase = apiBase;
if (model) updates.model = model;
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
if (temperature) updates.temperature = parseFloat(temperature);
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
if (Object.keys(updates).length > 0) {
await saveConfig(updates);
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
} else {
console.log("\n No changes.");
function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
function renderConfigPage(
config: Config,
cursor: number,
previousLines: number,
status: string | null,
editState: { buffer: string; cursor: number } | null,
) {
if (previousLines > 0) {
for (let i = 0; i < previousLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(previousLines);
}
const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2;
const lines = [
"",
` ${BOLD}Configuration${RESET}`,
editState
? ` ${DIM}editing · enter save · esc cancel · ctrl+c cancel${RESET}`
: ` ${DIM}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET}`,
"",
];
let activeValueOffset = 0;
for (let i = 0; i < CONFIG_FIELDS.length; i++) {
const field = CONFIG_FIELDS[i]!;
const active = i === cursor;
const pointer = active ? `${CYAN}${RESET}` : " ";
const marker = active ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const label = active ? `${BOLD}${field.label}${RESET}` : field.label;
const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label)));
const value = active && editState ? editState.buffer : field.format(config);
if (active && editState) {
activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`);
}
lines.push(` ${pointer} ${marker} ${label}${padding}${value}`);
}
if (status) {
lines.push("", ` ${status}`);
}
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
if (editState) {
moveUp(lines.length - (4 + cursor));
const column = activeValueOffset + editState.cursor;
process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`);
} else {
moveUp(lines.length);
}
return lines.length;
}
async function handleConfig(): Promise<"done" | "back"> {
if (process.stdin.isTTY !== true) {
console.error(` ${RED}Error: Configuration requires a TTY.${RESET}`);
process.exit(1);
}
let config = await loadConfig();
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
let status: string | null = null;
let editState: { buffer: string; cursor: number } | null = null;
let renderedCursorRow = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
const render = () => {
moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0;
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
};
render();
return new Promise((resolve) => {
const finish = (value: "done" | "back") => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(renderedLines);
process.stdout.write("\x1b[?25h");
resolve(value);
};
const saveEdit = async () => {
if (!editState) return;
const field = CONFIG_FIELDS[cursor]!;
const value = editState.buffer.trim();
editState = null;
if (value === "") {
status = `${DIM}No changes.${RESET}`;
} else {
const parsed = field.parse(value);
if ("error" in parsed) {
status = `${RED}${parsed.error}${RESET}`;
} else {
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
config = await loadConfig();
status = `${GREEN}${field.label} saved.${RESET}`;
}
}
render();
};
const onData = (data: Buffer) => {
const key = data.toString();
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 ESC = "\x1b";
const RIGHT = "\x1b[C";
const ALT_RIGHT = "\x1bOC";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
if (editState) {
if (key === CTRL_C || key === ESC) {
editState = null;
status = `${DIM}No changes.${RESET}`;
render();
return;
}
if (key === ENTER) {
void saveEdit();
return;
}
if (key === "\x01") {
editState.cursor = 0;
render();
return;
}
if (key === "\x05") {
editState.cursor = editState.buffer.length;
render();
return;
}
if (key === "\x0b") {
editState.buffer = editState.buffer.slice(0, editState.cursor);
render();
return;
}
if (key === "\x15") {
editState.buffer = editState.buffer.slice(editState.cursor);
editState.cursor = 0;
render();
return;
}
if (key === BACKSPACE) {
if (editState.cursor > 0) {
editState.buffer =
editState.buffer.slice(0, editState.cursor - 1) +
editState.buffer.slice(editState.cursor);
editState.cursor--;
render();
}
return;
}
if (key === LEFT || key === ALT_LEFT) {
if (editState.cursor > 0) editState.cursor--;
render();
return;
}
if (key === RIGHT || key === ALT_RIGHT) {
if (editState.cursor < editState.buffer.length) editState.cursor++;
render();
return;
}
if (key.startsWith("\x1b[")) {
if (key === "\x1b[H" || key === "\x1b[1~") {
editState.cursor = 0;
} else if (key === "\x1b[F" || key === "\x1b[4~") {
editState.cursor = editState.buffer.length;
} else if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) {
editState.buffer =
editState.buffer.slice(0, editState.cursor) +
editState.buffer.slice(editState.cursor + 1);
}
render();
return;
}
if (key >= " " && key !== "\x7f") {
editState.buffer =
editState.buffer.slice(0, editState.cursor) +
key +
editState.buffer.slice(editState.cursor);
editState.cursor += key.length;
render();
}
return;
}
const action = (() => {
if (key === UP || key === ALT_UP) return "up";
if (key === DOWN || key === ALT_DOWN) return "down";
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return "back";
if (key === SPACE) return "edit";
if (key === CTRL_C) return "cancel";
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
return null;
}
if (escapeBuf) {
const next = escapeBuf + key;
escapeBuf = /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next;
if (next === UP || next === ALT_UP) return "up";
if (next === DOWN || next === ALT_DOWN) return "down";
if (next === LEFT || next === ALT_LEFT) return "back";
}
return null;
})();
if (action === "cancel") return finish("done");
if (action === "back") return finish("back");
if (action === "up" && cursor > 0) {
cursor--;
status = null;
render();
} else if (action === "down" && cursor < CONFIG_FIELDS.length - 1) {
cursor++;
status = null;
render();
} else if (action === "edit") {
const value = CONFIG_FIELDS[cursor]!.initialEditValue(config);
editState = { buffer: value, cursor: value.length };
status = null;
render();
}
};
process.stdin.on("data", onData);
});
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
@@ -306,22 +597,34 @@ async function showMenu(): Promise<void> {
process.exit(1);
}
const selected = await selectOne({
title: "gai",
subtitle: "Choose a workflow",
items: MENU_ACTIONS.map((action) => ({
label: action.label,
value: action.key,
description: action.description,
})),
});
while (true) {
const selected = await selectOne({
title: "gai",
subtitle: "Choose a workflow",
allowBack: false,
items: MENU_ACTIONS.map((action) => ({
label: action.label,
value: action.key,
description: action.description,
})),
});
if (selected === "commit") await handleCommit(false, false);
if (selected === "pr") await handlePR(false);
if (selected === "config") await handleConfig();
if (selected === null || selected === BACK) return;
const result =
selected === "commit"
? await handleCommit(false, false)
: selected === "pr"
? await handlePR(false)
: await handleConfig();
if (result !== "back") return;
}
}
async function selectPlatform(hostname: string): Promise<Platform | null> {
async function selectPlatform(
hostname: string,
): Promise<Platform | null | PromptBack> {
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
process.exit(1);
@@ -333,20 +636,15 @@ async function selectPlatform(hostname: string): Promise<Platform | null> {
items: [
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
{ label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" },
],
});
}
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
async function handleCommit(
autoMode: boolean,
dryRun: boolean,
): Promise<"done" | "back"> {
if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1);
@@ -356,8 +654,8 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(" Nothing to commit.");
return;
console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`);
return "done";
}
if (unstagedFiles.length > 0) {
@@ -368,6 +666,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
);
} else {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return "back";
if (selected.length > 0) {
await stageFiles(selected);
console.log(
@@ -379,8 +678,17 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const diff = await getStagedDiff();
if (!diff) {
console.log(" No staged changes to commit.");
return;
console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`);
return "done";
}
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
const MAX_DIFF_SIZE = 15000;
@@ -418,7 +726,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
console.log(` ${GREEN}${message}${RESET}`);
await copyToClipboard(message);
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`);
return;
return "done";
}
const action = await confirmCommit(message);
@@ -456,9 +764,11 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
: ` Aborted. Message: ${message}`,
);
}
return "done";
}
async function handlePR(draft: boolean): Promise<void> {
async function handlePR(draft: boolean): Promise<"done" | "back"> {
const config = await loadConfig();
if (!config.apiKey) {
@@ -477,6 +787,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (chosen === BACK) return "back";
if (!chosen) {
console.log(" Aborted.");
process.exit(0);
@@ -484,21 +795,10 @@ async function handlePR(draft: boolean): Promise<void> {
platform = chosen;
}
const platformLabel = platform === "github" ? "GitHub" : "Gitea";
const platformLabel =
platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
console.log(` Using: ${CYAN}${platformLabel}${RESET}`);
const cliError = checkCLI(platform);
if (cliError) {
console.error(` ${RED}Error: ${cliError}${RESET}`);
process.exit(1);
}
const authError = await checkAuth(platform);
if (authError) {
console.error(` ${RED}Error: ${authError}${RESET}`);
process.exit(1);
}
const baseBranch = await getDefaultBranch();
const branchName = await getBranchName();
@@ -516,10 +816,13 @@ async function handlePR(draft: boolean): Promise<void> {
const commits = await getBranchCommits(baseBranch);
if (commits.length === 0) {
console.error(
` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`,
);
process.exit(1);
const choice = await selectOne({
title: "No commits to compare",
subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
items: [{ label: "Back", value: "back" as const }],
});
if (choice === null) process.exit(0);
return "done";
}
console.log(
@@ -535,7 +838,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (answer.toLowerCase() === "n") {
console.log(" Aborted.");
return;
return "done";
}
console.log(` Pushing ${CYAN}${branchName}${RESET}...`);
@@ -604,7 +907,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (lower === "n") {
console.log(" Aborted.");
return;
return "done";
}
if (lower === "e") {
@@ -612,7 +915,7 @@ async function handlePR(draft: boolean): Promise<void> {
const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) {
console.log(" Aborted.");
return;
return "done";
}
title = newTitle;
body = newBody;
@@ -630,6 +933,8 @@ async function handlePR(draft: boolean): Promise<void> {
);
process.exit(1);
}
return "done";
}
async function main() {