feat(config): add interactive editor with inline editing and navigation
Build / bun-build (push) Successful in 9m25s
Build / bun-build (push) Successful in 9m25s
This commit is contained in:
@@ -76,41 +76,333 @@ function ask(question: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfig() {
|
type ConfigKey = keyof Config;
|
||||||
const config = await loadConfig();
|
|
||||||
|
|
||||||
console.log(`\n ${BOLD}Current configuration:${RESET}`);
|
interface ConfigField {
|
||||||
console.log(
|
key: ConfigKey;
|
||||||
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`,
|
label: string;
|
||||||
);
|
format: (config: Config) => string;
|
||||||
console.log(` API Base: ${config.apiBase}`);
|
initialEditValue: (config: Config) => string;
|
||||||
console.log(` Model: ${config.model}`);
|
parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
|
||||||
console.log(` Max Tokens: ${config.maxTokens}`);
|
}
|
||||||
console.log(` Temperature: ${config.temperature}`);
|
|
||||||
|
|
||||||
console.log(
|
const CONFIG_FIELDS: ConfigField[] = [
|
||||||
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`,
|
{
|
||||||
);
|
key: "apiKey",
|
||||||
|
label: "API Key",
|
||||||
const apiKey = await ask(" API Key: ");
|
format: (config) =>
|
||||||
const apiBase = await ask(` API Base [${config.apiBase}]: `);
|
config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`,
|
||||||
const model = await ask(` Model [${config.model}]: `);
|
initialEditValue: () => "",
|
||||||
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
|
parse: (value) => ({ value }),
|
||||||
const temperature = await ask(` Temperature [${config.temperature}]: `);
|
},
|
||||||
|
{
|
||||||
const updates: Partial<Config> = {};
|
key: "apiBase",
|
||||||
if (apiKey) updates.apiKey = apiKey;
|
label: "API Base",
|
||||||
if (apiBase) updates.apiBase = apiBase;
|
format: (config) => config.apiBase,
|
||||||
if (model) updates.model = model;
|
initialEditValue: (config) => config.apiBase,
|
||||||
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
|
parse: (value) => ({ value }),
|
||||||
if (temperature) updates.temperature = parseFloat(temperature);
|
},
|
||||||
|
{
|
||||||
if (Object.keys(updates).length > 0) {
|
key: "model",
|
||||||
await saveConfig(updates);
|
label: "Model",
|
||||||
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
|
format: (config) => config.model,
|
||||||
} else {
|
initialEditValue: (config) => config.model,
|
||||||
console.log("\n No changes.");
|
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 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function visibleLength(value: string) {
|
||||||
|
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLine() {
|
||||||
|
process.stdout.write("\r\x1b[2K");
|
||||||
|
}
|
||||||
|
|
||||||
|
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"> {
|
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
||||||
@@ -324,7 +616,7 @@ async function showMenu(): Promise<void> {
|
|||||||
? await handleCommit(false, false)
|
? await handleCommit(false, false)
|
||||||
: selected === "pr"
|
: selected === "pr"
|
||||||
? await handlePR(false)
|
? await handlePR(false)
|
||||||
: await handleConfig().then(() => "done" as const);
|
: await handleConfig();
|
||||||
|
|
||||||
if (result !== "back") return;
|
if (result !== "back") return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user