@synmux/claude-commit 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts DELETED
@@ -1,238 +0,0 @@
1
- /**
2
- * Shared types for claude-commit.
3
- */
4
-
5
- /**
6
- * Which models to use for each stage of the pipeline.
7
- *
8
- * A bare name (`sonnet`, `haiku`, a full `claude-*` id) runs through the
9
- * Claude Agent SDK. An `ollama:`-prefixed name runs against a local or
10
- * self-hosted Ollama server instead, with everything after the prefix taken
11
- * as the Ollama model name verbatim - tag included, so
12
- * `ollama:ornith-1.5:35b` means the model `ornith-1.5:35b`. The two stages
13
- * are resolved independently, so mixing providers is normal.
14
- */
15
- export interface ModelConfig {
16
- /** Model used to read diffs and write summaries. Defaults to `sonnet`. */
17
- summary: string;
18
- /** Model used to turn summaries into the final commit message. Defaults to `sonnet`. */
19
- final: string;
20
- }
21
-
22
- /** Settings for the Ollama backend, used only by `ollama:`-prefixed models. */
23
- export interface OllamaConfig {
24
- /**
25
- * Base URL of the Ollama server. Defaults to `$OLLAMA_HOST`, falling back
26
- * to `http://localhost:11434`. A bare `host:port` (Ollama's own
27
- * convention for that variable) is given an `http://` scheme.
28
- */
29
- host: string;
30
- /**
31
- * Context window requested for every Ollama call (`options.num_ctx`) and
32
- * used to size diff chunks: a token count, or `"auto"` (the default) to
33
- * take the window Ollama itself chooses for the model on this machine.
34
- *
35
- * The window is always sent explicitly, never left to the server: a
36
- * prompt over it is truncated *silently* - HTTP 200, oldest content
37
- * dropped, no flag on the response - and a summary written from half a
38
- * diff is worse than an error, so cco pins the number it sized its chunks
39
- * against and cross-checks the response's token counts.
40
- *
41
- * `"auto"` asks Ollama rather than guessing: the model is preloaded with
42
- * no `num_ctx`, which makes the server pick from its VRAM tiers (4k / 32k
43
- * / 256k, capped at the model's trained maximum), and the choice is read
44
- * back from `/api/ps`. That is the largest window the server believes
45
- * this machine can run, resolved once per model per run. A number pins
46
- * the window instead - lower it when memory is tight (memory scales with
47
- * it, multiplied by `OLLAMA_NUM_PARALLEL`), or raise it past the tier if
48
- * you know better than the server does.
49
- */
50
- context: number | "auto";
51
- /**
52
- * How long the server keeps the model loaded after a request: a duration
53
- * string (`"10m"`), seconds as a number, `0` to unload immediately, or a
54
- * negative value to pin it. `null` leaves the server's own default (which
55
- * is itself 5 minutes unless `OLLAMA_KEEP_ALIVE` says otherwise).
56
- */
57
- keepAlive: string | number | null;
58
- }
59
-
60
- /** Fully-resolved configuration after merging defaults, file config and CLI flags. */
61
- export interface Config {
62
- /** Format the subject line as a Conventional Commit (`type(scope): description`). */
63
- conventionalCommits: boolean;
64
- /** Prefix the subject line with a gitmoji. */
65
- gitmoji: boolean;
66
- /** Produce a multi-line commit (subject + body) instead of a single subject line. */
67
- multiline: boolean;
68
- /**
69
- * Template for the first line. `{message}` is replaced with the generated
70
- * subject. Useful for ticket prefixes, e.g. `"[PROJ-123] {message}"`.
71
- */
72
- template: string | null;
73
- /** Extra instructions appended to the standard prompt. */
74
- customPrompt: string | null;
75
- /**
76
- * Default to interactive mode (the `-i` selection TUI) on every run, without
77
- * needing to pass `-i`. Override for a single run with `--no-interactive`.
78
- * When there is no interactive terminal (a pipe, CI, etc.) this is ignored and
79
- * cco falls back to the non-interactive flow rather than failing.
80
- */
81
- interactive: boolean;
82
- /** How many candidate messages to generate in interactive mode. */
83
- interactiveCount: number;
84
- /**
85
- * Sampling temperature for the final model when generating interactive
86
- * options, to encourage variety between candidates. `null` leaves the model
87
- * at its default. Only applied in interactive mode.
88
- */
89
- interactiveTemperature: number | null;
90
- /**
91
- * Name of the progress spinner animation: any spinner from the cli-spinners
92
- * set bundled with ora (e.g. `"dots"`, `"moon"`, `"material"`). Unknown
93
- * names are ignored and the default is used instead.
94
- */
95
- spinner: string;
96
- /** Models for each pipeline stage. */
97
- models: ModelConfig;
98
- /**
99
- * Approximate maximum number of tokens of diff to send to the summary model
100
- * in a single request. Diffs larger than this are split across requests.
101
- */
102
- maxChunkTokens: number;
103
- /** Approximate characters-per-token ratio used for chunk-size estimation. */
104
- charsPerToken: number;
105
- /**
106
- * Replace runs of armored/encoded diff lines (age/gpg armor, base64 blobs,
107
- * git binary patch bodies) with a one-line `[... lines omitted]` marker
108
- * before summarizing. Ciphertext is unreadable to the model and tokenizes
109
- * at roughly one token per character, so skipping it makes commits in
110
- * encrypted-file repos (e.g. chezmoi with age) fast and cheap without
111
- * losing anything a summary could actually use.
112
- */
113
- skipArmored: boolean;
114
- /**
115
- * Gitignore-style patterns for paths whose changes matter less than the
116
- * rest of the commit: generated docs, lockfiles, vendored snapshots, build
117
- * output. Diff sections under these paths are summarised separately and
118
- * briefly, and the final model is told to describe the other changes in
119
- * the subject line and to mention these only after them. When every
120
- * changed file matches, the changes are described normally - there is
121
- * nothing else for them to yield to. A pattern containing `/` matches a
122
- * path or any ancestor directory; a bare pattern matches any path segment
123
- * (see `src/paths.ts`).
124
- */
125
- lowPriorityPaths: string[];
126
- /**
127
- * Gitignore-style patterns - the same language as `lowPriorityPaths` - for
128
- * paths whose changes should not be read at all: vendored dependency
129
- * trees, generated clients, bulk data fixtures. Matching diff sections are
130
- * dropped before anything else looks at the diff, so they cost no tokens
131
- * and cannot influence the message.
132
- *
133
- * The files are still committed; this governs only what the model reads.
134
- * When every changed file matches, there is nothing left to describe and
135
- * the run stops with an error naming the directive - unlike
136
- * `lowPriorityPaths`, which promotes its partition in that case, because
137
- * "this matters less" can degrade gracefully and "do not look at this"
138
- * cannot.
139
- */
140
- ignore: string[];
141
- /** Settings for the Ollama backend (`ollama:`-prefixed models). */
142
- ollama: OllamaConfig;
143
- /**
144
- * Allow API credentials from the environment (`ANTHROPIC_API_KEY` /
145
- * `ANTHROPIC_AUTH_TOKEN`) to be used, billing pay-as-you-go instead of the
146
- * Claude subscription. When false (the default) those variables are
147
- * stripped from the environment passed to the Claude Agent SDK subprocess,
148
- * so an exported key can never silently switch billing.
149
- */
150
- allowApiKey: boolean;
151
- }
152
-
153
- /** Partial config as it may appear in a config file or be produced by flags. */
154
- export type PartialConfig = {
155
- [K in keyof Config]?: K extends "models"
156
- ? Partial<ModelConfig>
157
- : K extends "ollama"
158
- ? Partial<OllamaConfig>
159
- : Config[K];
160
- };
161
-
162
- /**
163
- * How much weight a slice of the diff carries in the commit message.
164
- * `primary` changes define the commit; `low` changes - those under the
165
- * configured `lowPriorityPaths` - are summarised briefly and mentioned only
166
- * after the primary ones.
167
- */
168
- export type ChangePriority = "primary" | "low";
169
-
170
- /** The summary of one diff chunk, tagged with the priority of the partition it came from. */
171
- export interface DiffSummary {
172
- priority: ChangePriority;
173
- text: string;
174
- }
175
-
176
- /** Result of a single model invocation. */
177
- export interface ModelResult {
178
- /** The text the model produced. */
179
- text: string;
180
- /** Cost of the call in USD, if reported. */
181
- costUsd: number;
182
- /** The model that actually served the request, if reported. */
183
- model?: string;
184
- /** Parsed structured output, when a JSON-schema `outputFormat` was requested. */
185
- structured?: unknown;
186
- }
187
-
188
- /** A staged change as seen by `git`. */
189
- export interface FileChange {
190
- /** Status code from `git diff --name-status` (e.g. `A`, `M`, `D`, `R100`). */
191
- status: string;
192
- /** Path of the file (the destination path for renames). */
193
- path: string;
194
- }
195
-
196
- /**
197
- * One prompt to one model, whichever provider serves it.
198
- *
199
- * `model` carries the provider: bare names go to Claude, `ollama:`-prefixed
200
- * ones to Ollama (see {@link ModelConfig}). Some options only apply to one
201
- * provider - `allowApiKey` gates Claude credentials, `ollama` supplies the
202
- * host and context window - and each is simply ignored by the other.
203
- */
204
- export interface RunPromptOptions {
205
- /** Model string: an alias (`sonnet`), a full id, or `ollama:<name>[:<tag>]`. */
206
- model: string;
207
- /** Full custom system prompt. */
208
- system: string;
209
- /** Receives assistant text as it streams in (enables partial messages). */
210
- onText?: (delta: string) => void;
211
- /** Abort the in-flight request. */
212
- abortController?: AbortController;
213
- /** Receives the underlying CLI's stderr (for `--verbose`). Claude only. */
214
- onStderr?: (data: string) => void;
215
- /**
216
- * Sampling temperature. Used to add variety when generating several
217
- * interactive options. Models that don't accept a temperature override
218
- * will reject the request, so the caller should be prepared to retry
219
- * without it.
220
- */
221
- temperature?: number;
222
- /**
223
- * Request a structured JSON response matching this schema. The parsed object
224
- * is returned on {@link ModelResult.structured}. Models that don't support
225
- * structured outputs will reject the request or return unparseable content,
226
- * so the caller should be prepared to retry without it.
227
- */
228
- outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
229
- /**
230
- * Allow API credentials from the environment to reach the Claude Agent SDK
231
- * subprocess. Defaults to false: `ANTHROPIC_API_KEY` /
232
- * `ANTHROPIC_AUTH_TOKEN` are stripped so the run is billed to the Claude
233
- * subscription. Has no meaning for Ollama, which takes no credential.
234
- */
235
- allowApiKey?: boolean;
236
- /** Ollama host and context settings; required for an `ollama:` model. */
237
- ollama?: OllamaConfig;
238
- }
package/src/ui/editor.ts DELETED
@@ -1,89 +0,0 @@
1
- /**
2
- * Launching the user's `$EDITOR` to tweak a commit message, plus a tiny
3
- * yes/no/edit confirmation prompt for non-interactive runs in a TTY.
4
- */
5
- import { spawn } from "node:child_process";
6
- import { readFile, unlink, writeFile } from "node:fs/promises";
7
- import { randomUUID } from "node:crypto";
8
- import { join } from "node:path";
9
- import { tmpdir } from "node:os";
10
- import { createInterface } from "node:readline";
11
- import { ClaudeCommitError } from "../errors";
12
-
13
- /** Resolve the editor command, mirroring git's lookup order. */
14
- function resolveEditor(): string {
15
- return (
16
- process.env.GIT_EDITOR || process.env.VISUAL || process.env.EDITOR || "vi"
17
- );
18
- }
19
-
20
- /** Open `initial` in the user's editor and return the saved contents. */
21
- export async function editInEditor(initial: string): Promise<string> {
22
- const editor = resolveEditor();
23
- // Unpredictable name + exclusive create (`wx`) + owner-only perms (0o600)
24
- // so the temp file can't be pre-created as a symlink or read by other users.
25
- const file = join(tmpdir(), `claude-commit-edit-${randomUUID()}.txt`);
26
- try {
27
- await writeFile(file, initial, { mode: 0o600, flag: "wx" });
28
- } catch (err) {
29
- throw new ClaudeCommitError(
30
- `Could not create a temporary file to edit the message: ${(err as Error).message}`,
31
- );
32
- }
33
- try {
34
- await runEditor(editor, file);
35
- const edited = await readFile(file, "utf8");
36
- // Drop trailing whitespace/newline noise editors tend to add.
37
- return edited.replace(/\s+$/, "");
38
- } catch (err) {
39
- if (err instanceof ClaudeCommitError) throw err;
40
- throw new ClaudeCommitError(
41
- `Editing the commit message failed (${editor}): ${(err as Error).message}`,
42
- );
43
- } finally {
44
- await unlink(file).catch(() => {});
45
- }
46
- }
47
-
48
- function runEditor(editor: string, file: string): Promise<void> {
49
- return new Promise((resolvePromise, reject) => {
50
- // $EDITOR / $GIT_EDITOR are shell command lines that may contain flags,
51
- // spaces in the program path, or quoted arguments (e.g. "code --wait" or
52
- // "/path/with spaces/editor"). Run them through the shell exactly as git
53
- // does rather than naively splitting on whitespace. The file path is our
54
- // own (tmpdir + UUID), so double-quoting it is safe.
55
- const child = spawn(`${editor} "${file}"`, {
56
- stdio: "inherit",
57
- shell: true,
58
- });
59
- child.on("error", reject);
60
- child.on("exit", (code) => {
61
- if (code === 0 || code === null) resolvePromise();
62
- else reject(new Error(`Editor exited with code ${code}`));
63
- });
64
- });
65
- }
66
-
67
- export type ConfirmChoice = "yes" | "no" | "edit";
68
-
69
- /** Prompt for [Y]es / [n]o / [e]dit. Requires an interactive stdin. */
70
- export async function confirmCommit(): Promise<ConfirmChoice> {
71
- const rl = createInterface({ input: process.stdin, output: process.stderr });
72
- try {
73
- for (;;) {
74
- const answer = (
75
- await new Promise<string>((res) =>
76
- rl.question("Commit this message? [Y/n/e] ", res),
77
- )
78
- )
79
- .trim()
80
- .toLowerCase();
81
- if (answer === "" || answer === "y" || answer === "yes") return "yes";
82
- if (answer === "n" || answer === "no") return "no";
83
- if (answer === "e" || answer === "edit") return "edit";
84
- process.stderr.write("Please answer y, n, or e.\n");
85
- }
86
- } finally {
87
- rl.close();
88
- }
89
- }
@@ -1,313 +0,0 @@
1
- /**
2
- * Interactive mode: generate several candidate messages, then let the user pick
3
- * one (and optionally edit it) before committing.
4
- *
5
- * The picker is an OpenTUI selection screen. All key handling is driven through
6
- * the renderer's global key handler so there is a single source of truth for
7
- * navigation. If the TUI cannot be initialized for any reason, we transparently
8
- * fall back to a plain readline prompt.
9
- */
10
- import { createInterface } from "node:readline";
11
- import { commit } from "../git";
12
- import { generateCommit } from "../generate";
13
- import { Spinner } from "./spinner";
14
- import { editInEditor } from "./editor";
15
- import { color } from "./colors";
16
- import type { Config } from "../types";
17
-
18
- export interface InteractiveOptions {
19
- verbose: boolean;
20
- abortController: AbortController;
21
- }
22
-
23
- type Selection =
24
- { action: "commit" | "edit"; index: number } | { action: "cancel" };
25
-
26
- /** Run the full interactive flow. Returns a process exit code. */
27
- export async function runInteractive(
28
- diff: string,
29
- config: Config,
30
- opts: InteractiveOptions,
31
- ): Promise<number> {
32
- const count = Math.max(1, config.interactiveCount);
33
- const spinner = new Spinner(process.stderr.isTTY, config.spinner);
34
- spinner.start(`Generating ${count} option${count === 1 ? "" : "s"}`);
35
- let result;
36
- try {
37
- result = await generateCommit(diff, config, {
38
- count,
39
- progress: { onPhase: (label) => spinner.update(label) },
40
- abortController: opts.abortController,
41
- });
42
- } catch (err) {
43
- spinner.stop();
44
- throw err;
45
- }
46
- spinner.stop();
47
-
48
- const messages = result.messages;
49
-
50
- let selection: Selection;
51
- try {
52
- selection = await selectWithTui(messages);
53
- } catch {
54
- // TUI failed to initialize (unusual terminal, etc.) - degrade gracefully.
55
- selection = await selectWithReadline(messages);
56
- }
57
-
58
- if (selection.action === "cancel") {
59
- process.stderr.write("Aborted. Nothing was committed.\n");
60
- return 1;
61
- }
62
-
63
- let message = messages[selection.index]!;
64
- if (selection.action === "edit") {
65
- message = await editInEditor(message);
66
- if (message.trim() === "") {
67
- process.stderr.write("Aborted: empty commit message.\n");
68
- return 1;
69
- }
70
- }
71
-
72
- await commit(message);
73
- process.stderr.write(
74
- `${color("32", "✔")} Committed\n${color("90", firstLine(message))}\n`,
75
- );
76
- if (opts.verbose) {
77
- process.stderr.write(
78
- color("90", `cost $${result.costUsd.toFixed(4)}`) + "\n",
79
- );
80
- }
81
- return 0;
82
- }
83
-
84
- /**
85
- * Rows a single candidate occupies in the picker: the subject line plus a
86
- * one-line body preview (`showDescription`), with OpenTUI's default
87
- * `itemSpacing` of 0. Mirrors SelectRenderable's own line accounting.
88
- */
89
- const PICKER_LINES_PER_ITEM = 2;
90
-
91
- /**
92
- * Rows reserved for the non-picker chrome when capping its height: root padding
93
- * (2), the header line (1) and the gap below it (1).
94
- */
95
- const PICKER_CHROME_ROWS = 4;
96
-
97
- /**
98
- * The picker's height, sized to its content (two rows per candidate) but capped
99
- * to the terminal so a large `interactiveCount` still fits; past the cap the list
100
- * scrolls internally (see `showScrollIndicator` in {@link buildPickerScene}). An
101
- * explicit height keeps the picker compact - only as tall as the options need -
102
- * rather than stretching to fill the screen.
103
- */
104
- export function pickerHeight(count: number, terminalRows: number): number {
105
- const wanted = Math.max(1, count) * PICKER_LINES_PER_ITEM;
106
- const cap = Math.max(
107
- PICKER_LINES_PER_ITEM,
108
- terminalRows - PICKER_CHROME_ROWS,
109
- );
110
- return Math.min(wanted, cap);
111
- }
112
-
113
- /** The `@opentui/core` module, however it is obtained (dynamic import or test). */
114
- type TuiModule = typeof import("@opentui/core");
115
- /** The renderer object returned by `createCliRenderer` (and the headless test renderer). */
116
- type TuiRenderer = Awaited<ReturnType<TuiModule["createCliRenderer"]>>;
117
-
118
- /** The renderables the caller wires key handling to after building the scene. */
119
- export interface PickerScene {
120
- root: InstanceType<TuiModule["BoxRenderable"]>;
121
- select: InstanceType<TuiModule["SelectRenderable"]>;
122
- }
123
-
124
- /**
125
- * Build the picker's renderable tree: a header line above the candidate list.
126
- * Extracted from {@link selectWithTui} so the layout can be rendered under
127
- * OpenTUI's headless test renderer and asserted on. The caller adds `root` to
128
- * the renderer and wires key handling to the returned `select`.
129
- */
130
- export function buildPickerScene(
131
- renderer: TuiRenderer,
132
- tui: TuiModule,
133
- messages: string[],
134
- terminalRows: number,
135
- ): PickerScene {
136
- const { BoxRenderable, TextRenderable, SelectRenderable } = tui;
137
-
138
- const root = new BoxRenderable(renderer, {
139
- flexDirection: "column",
140
- width: "100%",
141
- height: "100%",
142
- padding: 1,
143
- gap: 1,
144
- });
145
-
146
- const header = new TextRenderable(renderer, {
147
- content:
148
- "Pick a commit message ↑/↓ select · ⏎ commit · e edit · q cancel",
149
- });
150
-
151
- // A compact, content-sized list of the candidate messages. flexShrink:0 keeps
152
- // it at its full height; showScrollIndicator covers more options than fit.
153
- const select = new SelectRenderable(renderer, {
154
- height: pickerHeight(messages.length, terminalRows),
155
- flexShrink: 0,
156
- showScrollIndicator: true,
157
- options: messages.map((message, index) => ({
158
- name: firstLine(message),
159
- description: bodyPreview(message),
160
- value: index,
161
- })),
162
- selectedIndex: 0,
163
- showDescription: true,
164
- wrapSelection: true,
165
- // A calm slate highlight (OpenTUI's own default background) with soft
166
- // near-white text - the previous bright cyan/black fill was too harsh.
167
- // The default description greys (#888888 / #CCCCCC) read fine on the slate,
168
- // so they are left untouched.
169
- selectedBackgroundColor: "#334455",
170
- selectedTextColor: "#e6edf3",
171
- });
172
-
173
- root.add(header);
174
- root.add(select);
175
-
176
- return { root, select };
177
- }
178
-
179
- /** The OpenTUI selection screen. Resolves with the user's choice. */
180
- async function selectWithTui(messages: string[]): Promise<Selection> {
181
- const tui = await import("@opentui/core");
182
- const renderer = await tui.createCliRenderer({ exitOnCtrlC: false });
183
-
184
- return await new Promise<Selection>((resolve, reject) => {
185
- let settled = false;
186
- let onKey: (key: { name?: string; ctrl?: boolean }) => void = () => {};
187
-
188
- const cleanup = () => {
189
- try {
190
- renderer.keyInput.off("keypress", onKey);
191
- } catch {
192
- /* ignore */
193
- }
194
- try {
195
- renderer.destroy();
196
- } catch {
197
- /* ignore */
198
- }
199
- };
200
- const finish = (sel: Selection) => {
201
- if (settled) return;
202
- settled = true;
203
- cleanup();
204
- resolve(sel);
205
- };
206
-
207
- try {
208
- const terminalRows = process.stdout.rows ?? 24;
209
- const { root, select } = buildPickerScene(
210
- renderer,
211
- tui,
212
- messages,
213
- terminalRows,
214
- );
215
- renderer.root.add(root);
216
-
217
- onKey = (key) => {
218
- if (!key) return; // some terminals can emit empty/unknown key events
219
- try {
220
- switch (key.name) {
221
- case "up":
222
- case "k":
223
- select.moveUp();
224
- renderer.requestRender();
225
- break;
226
- case "down":
227
- case "j":
228
- select.moveDown();
229
- renderer.requestRender();
230
- break;
231
- case "return":
232
- case "enter":
233
- finish({ action: "commit", index: select.getSelectedIndex() });
234
- break;
235
- case "e":
236
- finish({ action: "edit", index: select.getSelectedIndex() });
237
- break;
238
- case "q":
239
- case "escape":
240
- finish({ action: "cancel" });
241
- break;
242
- case "c":
243
- if (key.ctrl) finish({ action: "cancel" });
244
- break;
245
- }
246
- } catch {
247
- // A renderable method threw unexpectedly. Rather than let the error
248
- // escape the key handler and leave the terminal stuck in raw mode,
249
- // cancel cleanly - finish() restores the terminal via cleanup().
250
- finish({ action: "cancel" });
251
- }
252
- };
253
-
254
- renderer.keyInput.on("keypress", onKey);
255
- renderer.start();
256
- renderer.requestRender();
257
- } catch (err) {
258
- cleanup();
259
- reject(err);
260
- }
261
- });
262
- }
263
-
264
- /** Plain-prompt fallback when the TUI is unavailable. */
265
- async function selectWithReadline(messages: string[]): Promise<Selection> {
266
- process.stderr.write("\nCandidate commit messages:\n");
267
- messages.forEach((m, i) =>
268
- process.stderr.write(` ${i + 1}. ${firstLine(m)}\n`),
269
- );
270
-
271
- const rl = createInterface({ input: process.stdin, output: process.stderr });
272
- try {
273
- for (;;) {
274
- const answer = (
275
- await new Promise<string>((res) =>
276
- rl.question(
277
- `Choose 1-${messages.length}, "e N" to edit, or q to quit: `,
278
- res,
279
- ),
280
- )
281
- )
282
- .trim()
283
- .toLowerCase();
284
-
285
- if (answer === "q" || answer === "") return { action: "cancel" };
286
-
287
- const editMatch = answer.match(/^e\s*(\d+)$/);
288
- if (editMatch) {
289
- const index = parseInt(editMatch[1]!, 10) - 1;
290
- if (index >= 0 && index < messages.length)
291
- return { action: "edit", index };
292
- }
293
-
294
- const choice = parseInt(answer, 10);
295
- if (choice >= 1 && choice <= messages.length) {
296
- return { action: "commit", index: choice - 1 };
297
- }
298
- process.stderr.write("Invalid choice.\n");
299
- }
300
- } finally {
301
- rl.close();
302
- }
303
- }
304
-
305
- function firstLine(text: string): string {
306
- return text.split("\n", 1)[0] ?? text;
307
- }
308
-
309
- /** A short, single-line preview of a message body (everything after the subject). */
310
- function bodyPreview(text: string): string {
311
- const rest = text.split("\n").slice(1).join(" ").replace(/\s+/g, " ").trim();
312
- return rest.length > 120 ? rest.slice(0, 117) + "…" : rest;
313
- }