@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.
- package/.aicommit.config.example.json +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- 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
|
+
}
|