diff --git a/index.ts b/index.ts index 83c1419..3c24dea 100644 --- a/index.ts +++ b/index.ts @@ -76,41 +76,333 @@ function ask(question: string): Promise { }); } -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 = {}; - 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 = 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"> { @@ -324,7 +616,7 @@ async function showMenu(): Promise { ? await handleCommit(false, false) : selected === "pr" ? await handlePR(false) - : await handleConfig().then(() => "done" as const); + : await handleConfig(); if (result !== "back") return; }