Compare commits
4 Commits
e1354e8651
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| d0506381f5 | |||
| 14df49b110 | |||
| 962b76d20f | |||
| 12e71a0af7 |
@@ -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",
|
||||||
|
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: ");
|
function visibleLength(value: string) {
|
||||||
const apiBase = await ask(` API Base [${config.apiBase}]: `);
|
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||||
const model = await ask(` Model [${config.model}]: `);
|
}
|
||||||
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
|
|
||||||
const temperature = await ask(` Temperature [${config.temperature}]: `);
|
|
||||||
|
|
||||||
const updates: Partial<Config> = {};
|
function clearLine() {
|
||||||
if (apiKey) updates.apiKey = apiKey;
|
process.stdout.write("\r\x1b[2K");
|
||||||
if (apiBase) updates.apiBase = apiBase;
|
}
|
||||||
if (model) updates.model = model;
|
|
||||||
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
|
|
||||||
if (temperature) updates.temperature = parseFloat(temperature);
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
function moveUp(lines: number) {
|
||||||
await saveConfig(updates);
|
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||||
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
|
}
|
||||||
} else {
|
|
||||||
console.log("\n No changes.");
|
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;
|
||||||
}
|
}
|
||||||
@@ -353,15 +645,6 @@ async function handleCommit(
|
|||||||
autoMode: boolean,
|
autoMode: boolean,
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
): Promise<"done" | "back"> {
|
): Promise<"done" | "back"> {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await isGitRepo())) {
|
if (!(await isGitRepo())) {
|
||||||
console.error(` ${RED}Error: Not a git repository.${RESET}`);
|
console.error(` ${RED}Error: Not a git repository.${RESET}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -371,7 +654,7 @@ async function handleCommit(
|
|||||||
const unstagedFiles = await getUnstagedFiles();
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
|
||||||
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
||||||
console.log(" Nothing to commit.");
|
console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`);
|
||||||
return "done";
|
return "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,10 +678,19 @@ async function handleCommit(
|
|||||||
|
|
||||||
const diff = await getStagedDiff();
|
const diff = await getStagedDiff();
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
console.log(" No staged changes to commit.");
|
console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`);
|
||||||
return "done";
|
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;
|
const MAX_DIFF_SIZE = 15000;
|
||||||
const truncatedDiff =
|
const truncatedDiff =
|
||||||
diff.length > MAX_DIFF_SIZE
|
diff.length > MAX_DIFF_SIZE
|
||||||
@@ -524,10 +816,13 @@ async function handlePR(draft: boolean): Promise<"done" | "back"> {
|
|||||||
const commits = await getBranchCommits(baseBranch);
|
const commits = await getBranchCommits(baseBranch);
|
||||||
|
|
||||||
if (commits.length === 0) {
|
if (commits.length === 0) {
|
||||||
console.error(
|
const choice = await selectOne({
|
||||||
` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`,
|
title: "No commits to compare",
|
||||||
);
|
subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
|
||||||
process.exit(1);
|
items: [{ label: "Back", value: "back" as const }],
|
||||||
|
});
|
||||||
|
if (choice === null) process.exit(0);
|
||||||
|
return "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { test, expect, describe } from "bun:test";
|
||||||
|
|
||||||
|
async function run(command: string[], cwd: string, env: Record<string, string> = {}) {
|
||||||
|
const proc = Bun.spawn(command, {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH ?? "",
|
||||||
|
HOME: env.HOME ?? process.env.HOME ?? "",
|
||||||
|
...env,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
proc.exited,
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { exitCode, stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("commit command", () => {
|
||||||
|
test("clean repository exits without requiring API key", async () => {
|
||||||
|
const repo = mkdtempSync(join(tmpdir(), "gai-clean-repo-"));
|
||||||
|
const home = mkdtempSync(join(tmpdir(), "gai-empty-home-"));
|
||||||
|
|
||||||
|
const init = await run(["git", "init"], repo, { HOME: home });
|
||||||
|
expect(init.exitCode).toBe(0);
|
||||||
|
|
||||||
|
const result = await run(
|
||||||
|
["bun", "run", join(import.meta.dir, "..", "index.ts"), "commit"],
|
||||||
|
repo,
|
||||||
|
{
|
||||||
|
HOME: home,
|
||||||
|
GAI_API_KEY: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toContain("Nothing to commit");
|
||||||
|
expect(result.stdout).toContain("No staged or unstaged changes");
|
||||||
|
expect(result.stderr).not.toContain("API key not set");
|
||||||
|
expect(result.stderr).not.toContain("requires a TTY");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user