From 1ea180387ce26bce9c0c721f92713cfc5d0fcc66 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 11 Jun 2026 18:12:12 +0800 Subject: [PATCH] refactor(cli): extract interactive menu into reusable module (#3) Extract duplicate menu rendering logic from `index.ts` into a new `src/menu.ts` module, providing generic `selectOne` and `selectMany` functions. This reduces code duplication, improves maintainability, and adds consistent UI controls display across the commit flow and platform selection. Reviewed-on: https://git.catpl.top/Mplan/gai/pulls/3 --- README.md | 21 ++-- index.ts | 235 ++++-------------------------------- src/menu.ts | 307 ++++++++++++++++++++++++++++++++++++++++++++++++ src/pr.ts | 4 +- src/selector.ts | 173 ++++----------------------- 5 files changed, 364 insertions(+), 376 deletions(-) create mode 100644 src/menu.ts diff --git a/README.md b/README.md index e48e3d1..d082db7 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,12 @@ gai --version Show version $ gai gai + Choose a workflow + ↑/↓ navigate · enter/space select · ctrl+c cancel - ↑/↓ navigate, space/enter select - - ❯ ◉ commit Generate AI commit message + ❯ ● commit Generate AI commit message + ○ pr Create a PR with AI-generated title + ○ config Configure API settings ``` ### Commit Flow @@ -74,14 +76,13 @@ $ gai commit Staged files (will be included): ✓ src/git.ts (modified) - Unstaged files: - 1. src/ai.ts (modified) - 2. src/newfile.ts (new) - Select files to stage: - ❯ ◉ Select all - ○ src/ai.ts (modified) - ◉ src/newfile.ts (new) + 2 unstaged files available + ↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel + + ❯ □ Select all + □ src/ai.ts modified + ■ src/newfile.ts new Generating commit message... diff --git a/index.ts b/index.ts index 0595acb..5cd8cd2 100644 --- a/index.ts +++ b/index.ts @@ -13,6 +13,7 @@ import { commit, } from "./src/git"; import { selectFiles } from "./src/selector"; +import { selectOne } from "./src/menu"; import { collectProjectContext } from "./src/context"; import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; import { generateCommitMessage } from "./src/ai"; @@ -286,7 +287,7 @@ function printCommitResult( } interface MenuAction { - key: string; + key: "commit" | "pr" | "config"; label: string; description: string; } @@ -298,233 +299,39 @@ const MENU_ACTIONS: MenuAction[] = [ ]; async function showMenu(): Promise { - const actions = MENU_ACTIONS; - let cursor = 0; - - const headerLines = 4; - - process.stdout.write(`\n ${BOLD}gai${RESET}\n`); - process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`); - - const totalLines = headerLines + actions.length; - - function render() { - for (let i = 0; i < actions.length; i++) { - process.stdout.write("\x1b[2K\r"); - const a = actions[i]!; - const pointer = i === cursor ? `${CYAN}❯${RESET} ` : " "; - const dot = i === cursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`; - const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label; - const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`; - process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`); - } - process.stdout.write(`\x1b[${actions.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: Interactive menu 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 < actions.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(); - 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 = actions[cursor]!; - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - - clearMenu(); - process.stdout.write("\x1b[?25h"); - - if (selected.key === "commit") { - handleCommit(false, false).then(resolve); - } else if (selected.key === "pr") { - handlePR(false).then(resolve); - } else if (selected.key === "config") { - handleConfig().then(resolve); - } else { - resolve(); - } - return; - } - }); + const selected = await selectOne({ + title: "gai", + subtitle: "Choose a workflow", + 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(); } 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; - } - }); + return selectOne({ + title: "Select remote platform", + subtitle: `Remote ${hostname} could not be auto-detected`, + items: [ + { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, + { label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" }, + ], }); } diff --git a/src/menu.ts b/src/menu.ts new file mode 100644 index 0000000..11fe07a --- /dev/null +++ b/src/menu.ts @@ -0,0 +1,307 @@ +import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; + +const UP = "\x1b[A"; +const DOWN = "\x1b[B"; +const ALT_UP = "\x1bOA"; +const ALT_DOWN = "\x1bOB"; +const SPACE = " "; +const ENTER = "\r"; +const CTRL_C = "\x03"; + +export interface Choice { + label: string; + value: T; + description?: string; + selected?: boolean; +} + +interface BasePromptOptions { + title: string; + subtitle?: string; + cancelMessage?: string; +} + +interface SinglePromptOptions extends BasePromptOptions { + items: Choice[]; +} + +interface MultiPromptOptions extends BasePromptOptions { + items: Choice[]; + selectAllLabel?: string; + doneLabel?: string; +} + +function hideCursor() { + process.stdout.write("\x1b[?25l"); +} + +function showCursor() { + process.stdout.write("\x1b[?25h"); +} + +function moveUp(lines: number) { + if (lines > 0) process.stdout.write(`\x1b[${lines}A`); +} + +function clearLine() { + process.stdout.write("\r\x1b[2K"); +} + +function visibleLength(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, "").length; +} + +function padLabel(label: string, width: number) { + return label + " ".repeat(Math.max(1, width - visibleLength(label))); +} + +function controls(mode: "single" | "multi") { + if (mode === "single") { + return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`; + } + return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`; +} + +function renderPrompt(lines: string[], previousLines: number) { + if (previousLines > 0) { + for (let i = 0; i < previousLines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(previousLines); + } + + for (const line of lines) { + process.stdout.write(`${line}\n`); + } + moveUp(lines.length); + return lines.length; +} + +function clearPrompt(lines: number) { + for (let i = 0; i < lines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(lines); +} + +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 === SPACE) return { action: "space", escapeBuf: "" }; + if (key === ENTER) return { action: "enter", escapeBuf: "" }; + if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; + + if (key === "\x1b" || key.startsWith("\x1b[")) { + return { action: null, escapeBuf: key }; + } + + if (escapeBuf) { + const next = escapeBuf + key; + if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; + if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; + return { + action: null, + escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, + }; + } + + return { action: null, escapeBuf: "" }; +} + +function createLines( + options: BasePromptOptions & { items: Choice[] }, + mode: "single" | "multi", + cursor: number, +) { + const labelWidth = Math.max( + ...options.items.map((item) => visibleLength(item.label)), + 0, + ) + 2; + + const lines = [ + "", + ` ${BOLD}${options.title}${RESET}`, + ]; + + if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); + lines.push(` ${controls(mode)}`, ""); + + for (let i = 0; i < options.items.length; i++) { + const item = options.items[i]!; + const active = i === cursor; + const pointer = active ? `${CYAN}❯${RESET}` : " "; + const marker = mode === "single" + ? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}` + : item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`; + const label = active ? `${BOLD}${item.label}${RESET}` : item.label; + const description = item.description + ? active ? item.description : `${DIM}${item.description}${RESET}` + : ""; + + lines.push( + ` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`, + ); + } + + return lines; +} + +function ensureTTY(title: string) { + if (process.stdin.isTTY !== true) { + throw new Error(`${title} requires a TTY.`); + } +} + +export async function selectOne( + options: SinglePromptOptions, +): Promise { + ensureTTY(options.title); + + let cursor = 0; + let renderedLines = 0; + let escapeBuf = ""; + const wasRaw = process.stdin.isRaw; + + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + hideCursor(); + + const render = () => { + renderedLines = renderPrompt( + createLines(options, "single", cursor), + renderedLines, + ); + }; + + render(); + + return new Promise((resolve) => { + const finish = (value: T | null) => { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + clearPrompt(renderedLines); + showCursor(); + if (value === null && options.cancelMessage) { + process.stdout.write(` ${options.cancelMessage}\n`); + } + resolve(value); + }; + + const onData = (data: Buffer) => { + const result = normalizeKey(data.toString(), escapeBuf); + escapeBuf = result.escapeBuf; + + if (result.action === "cancel") return finish(null); + if (result.action === "up" && cursor > 0) { + cursor--; + render(); + } else if (result.action === "down" && cursor < options.items.length - 1) { + cursor++; + render(); + } else if (result.action === "space" || result.action === "enter") { + finish(options.items[cursor]!.value); + } + }; + + process.stdin.on("data", onData); + }); +} + +export async function selectMany( + options: MultiPromptOptions, +): Promise { + ensureTTY(options.title); + + const items: Choice[] = options.selectAllLabel + ? [ + { label: options.selectAllLabel, value: null, selected: false }, + ...options.items.map((item) => ({ ...item })), + ] + : options.items.map((item) => ({ ...item })); + + let cursor = 0; + let renderedLines = 0; + let escapeBuf = ""; + const wasRaw = process.stdin.isRaw; + + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + hideCursor(); + + const syncSelectAll = () => { + if (!options.selectAllLabel) return; + items[0]!.selected = items.slice(1).every((item) => item.selected); + }; + + const toggle = (index: number) => { + const item = items[index]!; + item.selected = !item.selected; + + if (options.selectAllLabel && index === 0) { + for (let i = 1; i < items.length; i++) { + items[i]!.selected = item.selected; + } + } else { + syncSelectAll(); + } + }; + + const render = () => { + renderedLines = renderPrompt( + createLines( + { + title: options.title, + subtitle: options.subtitle, + items, + }, + "multi", + cursor, + ), + renderedLines, + ); + }; + + render(); + + return new Promise((resolve) => { + const finish = (value: T[] | null) => { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + clearPrompt(renderedLines); + showCursor(); + if (value === null && options.cancelMessage) { + process.stdout.write(` ${options.cancelMessage}\n`); + } + resolve(value); + }; + + const onData = (data: Buffer) => { + const result = normalizeKey(data.toString(), escapeBuf); + escapeBuf = result.escapeBuf; + + if (result.action === "cancel") return finish(null); + if (result.action === "up" && cursor > 0) { + cursor--; + render(); + } else if (result.action === "down" && cursor < items.length - 1) { + cursor++; + render(); + } else if (result.action === "space") { + toggle(cursor); + render(); + } else if (result.action === "enter") { + finish( + items + .filter((item) => item.selected && item.value !== null) + .map((item) => item.value as T), + ); + } + }; + + process.stdin.on("data", onData); + }); +} diff --git a/src/pr.ts b/src/pr.ts index 20e867f..aba9bb7 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -151,7 +151,7 @@ export async function createPR( } const match = stdout.match(/(https?:\/\/[^\s]+)/); - return match ? match[1] : stdout.trim(); + return match?.[1] ?? stdout.trim(); } const args = [ @@ -180,5 +180,5 @@ export async function createPR( } const match = stdout.match(/(https?:\/\/[^\s]+)/); - return match ? match[1] : stdout.trim(); + return match?.[1] ?? stdout.trim(); } diff --git a/src/selector.ts b/src/selector.ts index 026c80e..9702cc5 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,37 +1,6 @@ import type { FileEntry } from "./types"; -import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal"; - -const UP = "\x1b[A"; -const DOWN = "\x1b[B"; -const SPACE = " "; -const ENTER = "\r"; -const CTRL_C = "\x03"; - -function hideCursor() { - process.stdout.write("\x1b[?25l"); -} - -function showCursor() { - process.stdout.write("\x1b[?25h"); -} - -function moveUp(n: number) { - process.stdout.write(`\x1b[${n}A`); -} - -function moveDown(n: number) { - process.stdout.write(`\x1b[${n}B`); -} - -function clearLine() { - process.stdout.write("\x1b[2K\r"); -} - -interface SelectItem { - label: string; - path: string; - selected: boolean; -} +import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; +import { selectMany } from "./menu"; export async function selectFiles( stagedFiles: FileEntry[], @@ -46,123 +15,27 @@ export async function selectFiles( } } - const items: SelectItem[] = [ - { label: "Select all", path: "__all__", selected: false }, - ...unstagedFiles.map((f) => ({ - label: `${f.path} (${f.label})`, - path: f.path, - selected: false, + if (process.stdin.isTTY !== true) return []; + + const selected = await selectMany({ + title: "Select files to stage", + subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, + selectAllLabel: "Select all", + cancelMessage: "Aborted.", + items: unstagedFiles.map((f) => ({ + label: f.path, + value: f.path, + description: f.label, })), - ]; - - let cursor = 0; - - process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`); - process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`); - - const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0); - - function render() { - for (let i = 0; i < items.length; i++) { - process.stdout.write("\x1b[2K\r"); - const item = items[i]!; - const isAll = i === 0; - const cursor_ = i === cursor ? `${CYAN}❯${RESET} ` : " "; - const checkbox = item.selected ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`; - - if (isAll) { - process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`); - } else { - process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`); - } - } - moveUp(items.length); - } - - function toggleItem(index: number) { - const item = items[index]!; - item.selected = !item.selected; - - if (index === 0) { - for (let i = 1; i < items.length; i++) { - items[i]!.selected = item.selected; - } - } else { - const allSelected = items.slice(1).every((it) => it.selected); - items[0]!.selected = allSelected; - } - } - - return new Promise((resolve) => { - if (process.stdin.isTTY !== true) { - resolve([]); - return; - } - - const wasRaw = process.stdin.isRaw; - if (wasRaw !== true) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - hideCursor(); - - render(); - - const onData = (data: Buffer) => { - const key = data.toString(); - - if (key === CTRL_C) { - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - showCursor(); - for (let i = 0; i < items.length; i++) { - process.stdout.write("\x1b[2K\n"); - } - moveUp(items.length); - process.stdout.write(`\n Aborted.\n`); - process.exit(1); - } - - if (key === UP) { - if (cursor > 0) { - cursor--; - render(); - } - } else if (key === DOWN) { - if (cursor < items.length - 1) { - cursor++; - render(); - } - } else if (key === SPACE) { - toggleItem(cursor); - render(); - } else if (key === ENTER) { - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - - for (let i = 0; i < items.length; i++) { - process.stdout.write("\x1b[2K\n"); - } - moveUp(items.length); - - const selected = items - .slice(1) - .filter((it) => it.selected) - .map((it) => it.path); - - if (selected.length > 0) { - process.stdout.write( - ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, - ); - } - - showCursor(); - resolve(selected); - } - }; - - process.stdin.on("data", onData); }); + + if (selected === null) process.exit(1); + + if (selected.length > 0) { + process.stdout.write( + ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, + ); + } + + return selected; }