@hifullmoon/aicommit 1.4.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
@@ -0,0 +1,95 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { normalizeCommitPolicy, validateCommitPolicyConfig } from './policy.js';
5
+ import { fileExists } from './utils.js';
6
+
7
+ export const TEAM_POLICY_KIND = 'aicommit-team-policy';
8
+ export const TEAM_POLICY_VERSION = 1;
9
+ export const TEAM_POLICY_FILENAME = '.aicommit.policy.json';
10
+ const MAX_TEAM_POLICY_BYTES = 64 * 1024;
11
+
12
+ export const TEAM_POLICY_TEMPLATE = Object.freeze({
13
+ kind: TEAM_POLICY_KIND,
14
+ version: TEAM_POLICY_VERSION,
15
+ language: 'en',
16
+ commitPolicy: Object.freeze({
17
+ version: 1,
18
+ types: Object.freeze(['feat', 'fix', 'docs', 'refactor', 'test', 'chore']),
19
+ scope: Object.freeze({ mode: 'optional', values: Object.freeze([]) }),
20
+ subject: Object.freeze({ maxLength: 72 }),
21
+ body: Object.freeze({ mode: 'optional', maxLines: 8 }),
22
+ breakingChange: 'allow',
23
+ language: 'inherit',
24
+ }),
25
+ });
26
+
27
+ function object(value) {
28
+ return value && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+
31
+ function assertExactKeys(value, expected, path) {
32
+ if (!object(value)) throw new Error(`${path} must be an object.`);
33
+ const expectedSet = new Set(expected);
34
+ const unknown = Object.keys(value).filter((key) => !expectedSet.has(key));
35
+ const missing = expected.filter((key) => !Object.hasOwn(value, key));
36
+ if (unknown.length)
37
+ throw new Error(`${path} contains unknown properties: ${unknown.join(', ')}.`);
38
+ if (missing.length)
39
+ throw new Error(`${path} is missing required properties: ${missing.join(', ')}.`);
40
+ }
41
+
42
+ export function validateTeamPolicyDocument(value) {
43
+ assertExactKeys(value, ['kind', 'version', 'language', 'commitPolicy'], 'Team policy');
44
+ if (value.kind !== TEAM_POLICY_KIND) {
45
+ throw new Error(`Team policy kind must be "${TEAM_POLICY_KIND}".`);
46
+ }
47
+ if (value.version !== TEAM_POLICY_VERSION) {
48
+ throw new Error(`Team policy version must be ${TEAM_POLICY_VERSION}.`);
49
+ }
50
+ if (!['zh', 'en'].includes(value.language)) {
51
+ throw new Error('Team policy language must be zh or en.');
52
+ }
53
+
54
+ assertExactKeys(
55
+ value.commitPolicy,
56
+ ['version', 'types', 'scope', 'subject', 'body', 'breakingChange', 'language'],
57
+ 'Team policy commitPolicy',
58
+ );
59
+ assertExactKeys(value.commitPolicy.scope, ['mode', 'values'], 'Team policy commitPolicy.scope');
60
+ assertExactKeys(value.commitPolicy.subject, ['maxLength'], 'Team policy commitPolicy.subject');
61
+ assertExactKeys(value.commitPolicy.body, ['mode', 'maxLines'], 'Team policy commitPolicy.body');
62
+ validateCommitPolicyConfig(value.commitPolicy);
63
+ return value;
64
+ }
65
+
66
+ export function renderTeamPolicyTemplate() {
67
+ return `${JSON.stringify(TEAM_POLICY_TEMPLATE, null, 2)}\n`;
68
+ }
69
+
70
+ export async function readTeamPolicy(projectRoot) {
71
+ const path = join(projectRoot, TEAM_POLICY_FILENAME);
72
+ if (!(await fileExists(path))) return null;
73
+ const info = await lstat(path);
74
+ if (!info.isFile() || info.isSymbolicLink()) {
75
+ throw new Error(`Failed to read team policy ${path}: expected a regular non-symlinked file.`);
76
+ }
77
+ if (info.size > MAX_TEAM_POLICY_BYTES) {
78
+ throw new Error(`Failed to read team policy ${path}: file exceeds 64 KiB.`);
79
+ }
80
+
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(await readFile(path, 'utf8'));
84
+ validateTeamPolicyDocument(parsed);
85
+ } catch (err) {
86
+ throw new Error(`Failed to parse team policy ${path}: ${err.message}`, { cause: err });
87
+ }
88
+ const normalized = normalizeCommitPolicy(parsed.commitPolicy, parsed.language);
89
+ delete normalized.effectiveLanguage;
90
+ return {
91
+ path,
92
+ language: parsed.language,
93
+ commitPolicy: normalized,
94
+ };
95
+ }
package/src/trust.js ADDED
@@ -0,0 +1,31 @@
1
+ const BEGIN = 'BEGIN_AICOMMIT_UNTRUSTED_JSON';
2
+ const END = 'END_AICOMMIT_UNTRUSTED_JSON';
3
+
4
+ export function encodeUntrustedData(kind, content) {
5
+ if (typeof kind !== 'string' || !/^[a-z][a-z0-9_]*$/.test(kind)) {
6
+ throw new Error('Untrusted data kind must be a lowercase identifier.');
7
+ }
8
+ return [
9
+ BEGIN,
10
+ JSON.stringify({ kind, untrusted: true, content: String(content ?? '') }),
11
+ END,
12
+ ].join('\n');
13
+ }
14
+
15
+ export function decodeUntrustedData(block) {
16
+ const lines = String(block).split('\n');
17
+ if (lines.length !== 3 || lines[0] !== BEGIN || lines[2] !== END) {
18
+ throw new Error('Invalid untrusted data envelope.');
19
+ }
20
+ const value = JSON.parse(lines[1]);
21
+ if (
22
+ !value ||
23
+ typeof value !== 'object' ||
24
+ value.untrusted !== true ||
25
+ typeof value.kind !== 'string' ||
26
+ typeof value.content !== 'string'
27
+ ) {
28
+ throw new Error('Invalid untrusted data payload.');
29
+ }
30
+ return value;
31
+ }
package/src/ui.js ADDED
@@ -0,0 +1,488 @@
1
+ import readline from 'node:readline';
2
+
3
+ import chalk from 'chalk';
4
+ import boxen from 'boxen';
5
+ import { createPrompt, useEffect, useKeypress, useRef, useState } from '@inquirer/core';
6
+ import confirm from '@inquirer/confirm';
7
+ import select from '@inquirer/select';
8
+ import checkbox from '@inquirer/checkbox';
9
+ import editor from '@inquirer/editor';
10
+ import wrapAnsi from 'wrap-ansi';
11
+
12
+ import { sanitizeTerminalText } from './utils.js';
13
+
14
+ // Shared status → color/icon maps (A/M/D/R/C/T from git, '?' = untracked)
15
+ export const statusColor = {
16
+ A: chalk.green, // Added
17
+ M: chalk.yellow, // Modified
18
+ D: chalk.red, // Deleted
19
+ R: chalk.cyan, // Renamed
20
+ C: chalk.magenta, // Copied
21
+ T: chalk.blue, // Type changed
22
+ '?': chalk.green, // Untracked
23
+ };
24
+ export const statusIcon = { A: '+', M: '~', D: '-', R: '→', C: '©', T: 'Δ', '?': '+' };
25
+
26
+ export function highlightMessage(msg) {
27
+ return sanitizeTerminalText(msg).replace(
28
+ /^(\w[\w-]*)([!:])(\s*)/,
29
+ (_, type, punct, rest) => chalk.cyan.bold(type) + chalk.yellow(punct) + rest,
30
+ );
31
+ }
32
+
33
+ export function displayMessage(message) {
34
+ const colored = sanitizeTerminalText(message).split('\n').map(highlightMessage).join('\n');
35
+ console.log(
36
+ boxen(colored, {
37
+ title: 'Suggested commit message',
38
+ titleAlignment: 'center',
39
+ padding: { top: 1, right: 2, bottom: 1, left: 2 },
40
+ margin: { top: 1, left: 2 },
41
+ borderColor: 'cyan',
42
+ borderStyle: 'round',
43
+ }),
44
+ );
45
+ }
46
+
47
+ // Reasoning is provider-generated, untrusted terminal text. Strip escape and
48
+ // control sequences before display, then preserve both its beginning and end
49
+ // when applying the configured terminal-output cap.
50
+ export function formatReasoningForTerminal(reasoning, maxChars = 12000) {
51
+ const cleaned = sanitizeTerminalText(reasoning).trim();
52
+ if (cleaned.length <= maxChars) return cleaned;
53
+
54
+ const marker = `\n\n... (reasoning truncated — ${cleaned.length} chars total) ...\n\n`;
55
+ if (maxChars <= marker.length) return marker.slice(0, maxChars);
56
+ const side = Math.max(0, Math.floor((maxChars - marker.length) / 2));
57
+ return cleaned.slice(0, side) + marker + cleaned.slice(-side);
58
+ }
59
+
60
+ // Keep the default reasoning view compact, while measuring wrapped terminal
61
+ // lines instead of source lines. This means a long paragraph still occupies
62
+ // exactly two visible preview rows (including wide CJK characters).
63
+ export function getReasoningView(
64
+ reasoning,
65
+ {
66
+ maxChars = 12000,
67
+ expanded = false,
68
+ columns = process.stdout.columns || 80,
69
+ maxExpandedLines = Infinity,
70
+ offset = 0,
71
+ } = {},
72
+ ) {
73
+ const text = formatReasoningForTerminal(reasoning, maxChars);
74
+ if (!text) {
75
+ return { text: '', totalLines: 0, truncated: false, startLine: 0, endLine: 0 };
76
+ }
77
+
78
+ const contentWidth = Math.max(24, Math.min(columns - 6, 120));
79
+ const lines = wrapAnsi(text, contentWidth, {
80
+ hard: true,
81
+ trim: false,
82
+ wordWrap: true,
83
+ }).split('\n');
84
+ const previewLines = lines.filter((line) => line.trim());
85
+ // During generation the useful context is at the tail, so the compact view
86
+ // follows the newest two lines like other code-oriented CLIs.
87
+ const pageSize = Number.isFinite(maxExpandedLines)
88
+ ? Math.max(1, Math.floor(maxExpandedLines))
89
+ : lines.length;
90
+ const maxOffset = Math.max(0, lines.length - pageSize);
91
+ const pageOffset = expanded ? Math.min(Math.max(0, Math.floor(offset)), maxOffset) : 0;
92
+ const visibleLines = expanded
93
+ ? lines.slice(pageOffset, pageOffset + pageSize)
94
+ : previewLines.slice(-2);
95
+
96
+ return {
97
+ text: visibleLines.join('\n'),
98
+ totalLines: lines.length,
99
+ truncated: !expanded && previewLines.length > visibleLines.length,
100
+ startLine: expanded
101
+ ? pageOffset + 1
102
+ : Math.max(1, previewLines.length - visibleLines.length + 1),
103
+ endLine: expanded ? pageOffset + visibleLines.length : previewLines.length,
104
+ };
105
+ }
106
+
107
+ export function formatReasoningPanel(reasoning, options = {}) {
108
+ const { expanded = false } = options;
109
+ const view = getReasoningView(reasoning, options);
110
+
111
+ if (!view.text) {
112
+ return chalk.dim(' ◇ Thinking unavailable');
113
+ }
114
+
115
+ const page =
116
+ expanded && view.totalLines > view.text.split('\n').length
117
+ ? ` · ${view.startLine}-${view.endLine}/${view.totalLines} · PgUp/PgDn`
118
+ : '';
119
+ const state = expanded
120
+ ? `Ctrl+O collapse${page}`
121
+ : `Ctrl+O expand${view.truncated ? ` · ${view.totalLines} lines` : ''}`;
122
+ const body = view.text
123
+ .split('\n')
124
+ .map((line) => ` ${chalk.dim(line)}`)
125
+ .join('\n');
126
+ return `${chalk.yellow(' ◇ Thinking')} ${chalk.dim(`[${state}]`)}\n${body}`;
127
+ }
128
+
129
+ const CTRL_O_RELEASE_MS = 180;
130
+
131
+ function useCtrlOToggleGate() {
132
+ const gate = useRef({ locked: false, timer: null });
133
+ useEffect(() => () => clearTimeout(gate.current.timer), []);
134
+ return gate;
135
+ }
136
+
137
+ // Terminal keypress events do not expose key-up. Treat a run of Ctrl+O repeat
138
+ // events as one hold: only the first toggles, and the gate re-opens shortly
139
+ // after key repeat stops.
140
+ function acceptCtrlOToggle(gate) {
141
+ clearTimeout(gate.current.timer);
142
+ gate.current.timer = setTimeout(() => {
143
+ gate.current.locked = false;
144
+ gate.current.timer = null;
145
+ }, CTRL_O_RELEASE_MS);
146
+ if (gate.current.locked) return false;
147
+ gate.current.locked = true;
148
+ return true;
149
+ }
150
+
151
+ function expandedLineCapacity(reservedRows) {
152
+ return Math.max(4, (process.stdout.rows || 24) - reservedRows);
153
+ }
154
+
155
+ const reasoningStreamPrompt = createPrompt((config, done) => {
156
+ const [text, setText] = useState(config.initialText || '');
157
+ const [expanded, setExpanded] = useState(false);
158
+ const [reasoningOffset, setReasoningOffset] = useState(0);
159
+ const [status, setStatus] = useState('streaming');
160
+ const ctrlOGate = useCtrlOToggleGate();
161
+ const pageSize = expandedLineCapacity(4);
162
+ const completeView = getReasoningView(text, { maxChars: config.maxChars, expanded: true });
163
+ const maxOffset = Math.max(0, completeView.totalLines - pageSize);
164
+
165
+ config.controller.update = setText;
166
+ config.controller.finish = () => {
167
+ setStatus('done');
168
+ done(text);
169
+ };
170
+
171
+ useKeypress((key, rl) => {
172
+ if (key.ctrl && key.name === 'o') {
173
+ rl.clearLine(0);
174
+ if (acceptCtrlOToggle(ctrlOGate)) {
175
+ if (!expanded) setReasoningOffset(0);
176
+ setExpanded(!expanded);
177
+ }
178
+ return;
179
+ }
180
+ if (expanded && key.name === 'pageup') {
181
+ rl.clearLine(0);
182
+ setReasoningOffset(Math.max(0, reasoningOffset - pageSize));
183
+ return;
184
+ }
185
+ if (expanded && key.name === 'pagedown') {
186
+ rl.clearLine(0);
187
+ setReasoningOffset(Math.min(maxOffset, reasoningOffset + pageSize));
188
+ return;
189
+ }
190
+ if (expanded && key.name === 'home') {
191
+ rl.clearLine(0);
192
+ setReasoningOffset(0);
193
+ return;
194
+ }
195
+ if (expanded && key.name === 'end') {
196
+ rl.clearLine(0);
197
+ setReasoningOffset(maxOffset);
198
+ }
199
+ });
200
+
201
+ if (status === 'done') return '';
202
+ const panel = formatReasoningPanel(text, {
203
+ maxChars: config.maxChars,
204
+ expanded,
205
+ maxExpandedLines: pageSize,
206
+ offset: reasoningOffset,
207
+ });
208
+ return `${panel}\n${chalk.dim(' Thinking… Ctrl+C cancel')}\x1B[?25l`;
209
+ });
210
+
211
+ // Start a redraw-safe live reasoning view. Updates are batched to one frame
212
+ // every ~32ms so token-sized SSE chunks do not cause hundreds of terminal
213
+ // redraws per second. The complete text remains available to the subsequent
214
+ // review prompt after this temporary view is cleared.
215
+ export function startReasoningStream(maxChars = 12000, initialText = '') {
216
+ const controller = {};
217
+ let accumulated = String(initialText || '');
218
+ let timer = null;
219
+ let stopped = false;
220
+
221
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
222
+ return {
223
+ append(chunk) {
224
+ accumulated += String(chunk || '');
225
+ },
226
+ async stop() {
227
+ stopped = true;
228
+ return accumulated;
229
+ },
230
+ };
231
+ }
232
+
233
+ const prompt = reasoningStreamPrompt(
234
+ { controller, maxChars, initialText: accumulated },
235
+ { clearPromptOnDone: true },
236
+ );
237
+
238
+ const flush = () => {
239
+ timer = null;
240
+ controller.update?.(accumulated);
241
+ };
242
+
243
+ return {
244
+ append(chunk) {
245
+ if (stopped || !chunk) return;
246
+ accumulated += String(chunk);
247
+ if (!timer) timer = setTimeout(flush, 32);
248
+ },
249
+ async stop() {
250
+ if (stopped) return accumulated;
251
+ stopped = true;
252
+ if (timer) clearTimeout(timer);
253
+ flush();
254
+ controller.finish?.();
255
+ await prompt;
256
+ return accumulated;
257
+ },
258
+ };
259
+ }
260
+
261
+ function normalizeSelectChoices(choices) {
262
+ return choices.map((choice) => {
263
+ if (typeof choice === 'string') {
264
+ return { name: choice, short: choice, value: choice, disabled: false };
265
+ }
266
+ const name = choice.name ?? String(choice.value);
267
+ return {
268
+ ...choice,
269
+ name,
270
+ short: choice.short ?? name,
271
+ disabled: choice.disabled ?? false,
272
+ };
273
+ });
274
+ }
275
+
276
+ function nextSelectableChoice(choices, active, offset, loop) {
277
+ let next = active;
278
+ for (let seen = 0; seen < choices.length; seen++) {
279
+ const candidate = next + offset;
280
+ if (!loop && (candidate < 0 || candidate >= choices.length)) return active;
281
+ next = (candidate + choices.length) % choices.length;
282
+ if (!choices[next].disabled) return next;
283
+ }
284
+ return active;
285
+ }
286
+
287
+ // A small select prompt dedicated to the reasoning review screen. Keeping the
288
+ // panel inside Inquirer's render cycle lets Ctrl+O redraw it without corrupting
289
+ // the choice cursor or leaving stale terminal rows behind.
290
+ const reasoningSelect = createPrompt((config, done) => {
291
+ const choices = normalizeSelectChoices(config.choices);
292
+ const first = choices.findIndex((choice) => !choice.disabled);
293
+ if (first === -1) throw new Error('No selectable choices.');
294
+
295
+ const [active, setActive] = useState(first);
296
+ const [expanded, setExpanded] = useState(false);
297
+ const [reasoningOffset, setReasoningOffset] = useState(0);
298
+ const [status, setStatus] = useState('idle');
299
+ const ctrlOGate = useCtrlOToggleGate();
300
+ const selected = choices[active];
301
+ const loop = config.loop !== false;
302
+ const pageSize = expandedLineCapacity(choices.length + 8);
303
+ const completeView = getReasoningView(config.reasoning.text, {
304
+ maxChars: config.reasoning.maxChars,
305
+ expanded: true,
306
+ columns: config.reasoning.columns,
307
+ });
308
+ const maxOffset = Math.max(0, completeView.totalLines - pageSize);
309
+
310
+ useKeypress((key, rl) => {
311
+ if (key.ctrl && key.name === 'o') {
312
+ rl.clearLine(0);
313
+ if (acceptCtrlOToggle(ctrlOGate)) {
314
+ if (!expanded) setReasoningOffset(0);
315
+ setExpanded(!expanded);
316
+ }
317
+ return;
318
+ }
319
+ if (expanded && key.name === 'pageup') {
320
+ rl.clearLine(0);
321
+ setReasoningOffset(Math.max(0, reasoningOffset - pageSize));
322
+ return;
323
+ }
324
+ if (expanded && key.name === 'pagedown') {
325
+ rl.clearLine(0);
326
+ setReasoningOffset(Math.min(maxOffset, reasoningOffset + pageSize));
327
+ return;
328
+ }
329
+ if (expanded && key.name === 'home') {
330
+ rl.clearLine(0);
331
+ setReasoningOffset(0);
332
+ return;
333
+ }
334
+ if (expanded && key.name === 'end') {
335
+ rl.clearLine(0);
336
+ setReasoningOffset(maxOffset);
337
+ return;
338
+ }
339
+ if (key.name === 'up' || key.name === 'k') {
340
+ rl.clearLine(0);
341
+ setActive(nextSelectableChoice(choices, active, -1, loop));
342
+ return;
343
+ }
344
+ if (key.name === 'down' || key.name === 'j') {
345
+ rl.clearLine(0);
346
+ setActive(nextSelectableChoice(choices, active, 1, loop));
347
+ return;
348
+ }
349
+ if (key.name === 'return' || key.name === 'enter') {
350
+ setStatus('done');
351
+ done(selected.value);
352
+ }
353
+ });
354
+
355
+ if (status === 'done') {
356
+ return `${chalk.green('?')} ${config.message} ${chalk.cyan(selected.short)}`;
357
+ }
358
+
359
+ const panel = formatReasoningPanel(config.reasoning.text, {
360
+ maxChars: config.reasoning.maxChars,
361
+ expanded,
362
+ columns: config.reasoning.columns,
363
+ maxExpandedLines: pageSize,
364
+ offset: reasoningOffset,
365
+ });
366
+ const rows = choices
367
+ .map((choice, index) => {
368
+ if (choice.disabled) return chalk.dim(` - ${choice.name}`);
369
+ return index === active ? chalk.cyan(` ❯ ${choice.name}`) : ` ${choice.name}`;
370
+ })
371
+ .join('\n');
372
+ const description = selected.description ? `\n ${chalk.cyan(selected.description)}` : '';
373
+ const pageHelp = expanded && maxOffset > 0 ? ' · PgUp/PgDn reasoning' : '';
374
+ const help = chalk.dim(` ↑↓/j k navigate · Enter select · Ctrl+O reasoning${pageHelp}`);
375
+
376
+ return `${panel}\n\n${chalk.green('?')} ${config.message}\n${rows}${description}\n${help}\x1B[?25l`;
377
+ });
378
+
379
+ // Vim-friendly prompts: wraps @inquirer prompts with j/k → arrow key
380
+ // remapping. We intercept keypress events BEFORE @inquirer sees them and
381
+ // translate j→down, k→up by emitting synthetic arrow-key events.
382
+ // @inquirer ignores the original j/k characters, so only the arrow keys
383
+ // take effect.
384
+ async function withVimKeys(promptFn, options) {
385
+ // Ensure keypress events are enabled on stdin (idempotent)
386
+ readline.emitKeypressEvents(process.stdin);
387
+
388
+ let inTranslate = false; // guard against re-entering our own synthetic emits
389
+
390
+ const interceptor = (_str, key) => {
391
+ if (inTranslate || !key) return;
392
+ if (key.name === 'j') {
393
+ inTranslate = true;
394
+ process.stdin.emit('keypress', undefined, {
395
+ name: 'down',
396
+ sequence: '\x1B\x5B\x42',
397
+ ctrl: false,
398
+ meta: false,
399
+ shift: false,
400
+ });
401
+ inTranslate = false;
402
+ } else if (key.name === 'k') {
403
+ inTranslate = true;
404
+ process.stdin.emit('keypress', undefined, {
405
+ name: 'up',
406
+ sequence: '\x1B\x5B\x41',
407
+ ctrl: false,
408
+ meta: false,
409
+ shift: false,
410
+ });
411
+ inTranslate = false;
412
+ }
413
+ };
414
+
415
+ // prependListener ensures we run BEFORE @inquirer's own keypress handler
416
+ process.stdin.prependListener('keypress', interceptor);
417
+
418
+ try {
419
+ return await promptFn(options);
420
+ } finally {
421
+ process.stdin.removeListener('keypress', interceptor);
422
+ }
423
+ }
424
+
425
+ export async function vimSelect(options, reasoning) {
426
+ if (reasoning) {
427
+ return reasoningSelect({ ...options, reasoning });
428
+ }
429
+ return withVimKeys(select, options);
430
+ }
431
+
432
+ export async function vimCheckbox(options) {
433
+ return withVimKeys(checkbox, options);
434
+ }
435
+
436
+ export async function confirmAction(message, reasoning) {
437
+ displayMessage(message);
438
+
439
+ const action = await vimSelect(
440
+ {
441
+ message: 'What would you like to do?',
442
+ choices: [
443
+ {
444
+ name: 'Use this message',
445
+ value: 'use',
446
+ description: 'Commit with the suggested message',
447
+ },
448
+ {
449
+ name: 'Regenerate',
450
+ value: 'regenerate',
451
+ description: 'Ask AI to generate a different message',
452
+ },
453
+ {
454
+ name: 'Edit message',
455
+ value: 'edit',
456
+ description: 'Modify the message before committing',
457
+ },
458
+ { name: 'Cancel', value: 'cancel', description: 'Abort the commit' },
459
+ ],
460
+ },
461
+ reasoning,
462
+ );
463
+
464
+ return action;
465
+ }
466
+
467
+ export async function editMessage(message) {
468
+ // `postfix` is the temp file's extension (not a help text) — ".md" gives
469
+ // the editor markdown highlighting; guidance belongs in `message`.
470
+ const edited = await editor({
471
+ message: 'Edit your commit message (save and close to continue, leave empty to cancel)',
472
+ default: message,
473
+ postfix: '.md',
474
+ waitForUseInput: false,
475
+ });
476
+
477
+ const cleaned = sanitizeTerminalText(edited).trim();
478
+ if (!cleaned) return null;
479
+
480
+ displayMessage(cleaned);
481
+ const reconfirm = await confirm({
482
+ message: 'Commit with this edited message?',
483
+ default: true,
484
+ theme: { prefix: { idle: chalk.dim('?'), done: chalk.green('?') } },
485
+ });
486
+
487
+ return reconfirm ? cleaned : null;
488
+ }