stage-tui 1.0.7
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/README.md +112 -0
- package/bin/stage +51 -0
- package/index.ts +22 -0
- package/package.json +46 -0
- package/src/ai-commit.ts +706 -0
- package/src/app.tsx +127 -0
- package/src/config-file.ts +40 -0
- package/src/config.ts +283 -0
- package/src/git-branch-name.ts +13 -0
- package/src/git-process.ts +25 -0
- package/src/git-status-parser.ts +103 -0
- package/src/git.ts +298 -0
- package/src/hooks/use-branch-dialog-controller.ts +188 -0
- package/src/hooks/use-commit-history-controller.ts +130 -0
- package/src/hooks/use-git-tui-controller.ts +310 -0
- package/src/hooks/use-git-tui-effects.ts +168 -0
- package/src/hooks/use-git-tui-keyboard.ts +293 -0
- package/src/ui/components/branch-dialog.tsx +107 -0
- package/src/ui/components/commit-dialog.tsx +68 -0
- package/src/ui/components/commit-history-dialog.tsx +87 -0
- package/src/ui/components/diff-workspace.tsx +108 -0
- package/src/ui/components/footer-bar.tsx +65 -0
- package/src/ui/components/shortcuts-dialog.tsx +53 -0
- package/src/ui/components/top-bar.tsx +36 -0
- package/src/ui/diff-style.ts +33 -0
- package/src/ui/theme.ts +151 -0
- package/src/ui/types.ts +21 -0
- package/src/ui/utils.ts +99 -0
package/src/ai-commit.ts
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { createCerebras } from "@ai-sdk/cerebras"
|
|
2
|
+
import { generateText, jsonSchema, NoObjectGeneratedError, Output } from "ai"
|
|
3
|
+
|
|
4
|
+
import type { StageConfig } from "./config"
|
|
5
|
+
import type { ChangedFile, GitClient } from "./git"
|
|
6
|
+
|
|
7
|
+
const COMMIT_TYPES = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"] as const
|
|
8
|
+
type CommitType = (typeof COMMIT_TYPES)[number]
|
|
9
|
+
const SCOPE_REGEX = /^[a-z0-9._/-]+$/
|
|
10
|
+
const MAX_RECENT_COMMIT_SUBJECTS = 6
|
|
11
|
+
|
|
12
|
+
const CONVENTIONAL_COMMIT_REGEX =
|
|
13
|
+
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: [^A-Z].+$/
|
|
14
|
+
|
|
15
|
+
const COMMIT_OUTPUT_SCHEMA = {
|
|
16
|
+
type: "object",
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
required: ["type", "description"],
|
|
19
|
+
properties: {
|
|
20
|
+
type: {
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: COMMIT_TYPES,
|
|
23
|
+
description: "Conventional commit type based on behavior impact.",
|
|
24
|
+
},
|
|
25
|
+
scope: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Optional subsystem noun such as ui, git, config, keyboard.",
|
|
28
|
+
},
|
|
29
|
+
description: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Imperative, concise summary of what changed.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
const COMMIT_OUTPUT_FLEXIBLE_SCHEMA = jsonSchema<{
|
|
37
|
+
type: CommitType
|
|
38
|
+
scope?: string
|
|
39
|
+
description: string
|
|
40
|
+
}>(COMMIT_OUTPUT_SCHEMA)
|
|
41
|
+
|
|
42
|
+
type GenerateAiCommitSummaryParams = {
|
|
43
|
+
git: GitClient
|
|
44
|
+
files: ChangedFile[]
|
|
45
|
+
selectedPaths: string[]
|
|
46
|
+
aiConfig: StageConfig["ai"]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type TextGenerationModel = Parameters<typeof generateText>[0]["model"]
|
|
50
|
+
|
|
51
|
+
export async function generateAiCommitSummary({
|
|
52
|
+
git,
|
|
53
|
+
files,
|
|
54
|
+
selectedPaths,
|
|
55
|
+
aiConfig,
|
|
56
|
+
}: GenerateAiCommitSummaryParams): Promise<string> {
|
|
57
|
+
if (!aiConfig.enabled) {
|
|
58
|
+
throw new Error("AI commit generation is disabled in config.")
|
|
59
|
+
}
|
|
60
|
+
if (aiConfig.provider !== "cerebras") {
|
|
61
|
+
throw new Error(`Unsupported AI provider: ${aiConfig.provider}`)
|
|
62
|
+
}
|
|
63
|
+
if (!aiConfig.apiKey.trim()) {
|
|
64
|
+
throw new Error("AI commit generation requires ai.api_key.")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const selected = selectedPaths.map((path) => path.trim()).filter(Boolean)
|
|
68
|
+
if (selected.length === 0) {
|
|
69
|
+
throw new Error("No files selected for AI commit.")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fileByPath = new Map(files.map((file) => [file.path, file]))
|
|
73
|
+
const context = await buildCommitContext({
|
|
74
|
+
git,
|
|
75
|
+
fileByPath,
|
|
76
|
+
selectedPaths: selected,
|
|
77
|
+
maxFiles: aiConfig.maxFiles,
|
|
78
|
+
maxCharsPerFile: aiConfig.maxCharsPerFile,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const cerebras = createCerebras({
|
|
82
|
+
apiKey: aiConfig.apiKey,
|
|
83
|
+
})
|
|
84
|
+
const model = cerebras(aiConfig.model)
|
|
85
|
+
const firstAttempt = await generateCommitDraft(model, context, aiConfig.reasoningEffort, false)
|
|
86
|
+
const firstValid = finalizeCommitSummary(firstAttempt)
|
|
87
|
+
if (firstValid) {
|
|
88
|
+
return firstValid
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const secondAttempt = await generateCommitDraft(model, context, aiConfig.reasoningEffort, true)
|
|
92
|
+
const secondValid = finalizeCommitSummary(secondAttempt)
|
|
93
|
+
if (secondValid) {
|
|
94
|
+
return secondValid
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error(
|
|
98
|
+
"AI did not return a usable conventional commit message. Try again or adjust ai.reasoning_effort / ai.max_chars_per_file.",
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type BuildCommitContextParams = {
|
|
103
|
+
git: GitClient
|
|
104
|
+
fileByPath: Map<string, ChangedFile>
|
|
105
|
+
selectedPaths: string[]
|
|
106
|
+
maxFiles: number
|
|
107
|
+
maxCharsPerFile: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function buildCommitContext({
|
|
111
|
+
git,
|
|
112
|
+
fileByPath,
|
|
113
|
+
selectedPaths,
|
|
114
|
+
maxFiles,
|
|
115
|
+
maxCharsPerFile,
|
|
116
|
+
}: BuildCommitContextParams): Promise<string> {
|
|
117
|
+
const limitedPaths = selectedPaths.slice(0, maxFiles)
|
|
118
|
+
const signals: ContextSignals = {
|
|
119
|
+
touchedFiles: limitedPaths.length,
|
|
120
|
+
newFiles: 0,
|
|
121
|
+
modifiedFiles: 0,
|
|
122
|
+
deletedFiles: 0,
|
|
123
|
+
renamedFiles: 0,
|
|
124
|
+
addedLines: 0,
|
|
125
|
+
removedLines: 0,
|
|
126
|
+
docsFiles: 0,
|
|
127
|
+
testFiles: 0,
|
|
128
|
+
configFiles: 0,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fileSummaries = limitedPaths.map((path) => {
|
|
132
|
+
const file = fileByPath.get(path)
|
|
133
|
+
const status = file ? `${file.indexStatus}${file.worktreeStatus}`.trim() || "??" : "??"
|
|
134
|
+
updateStatusSignals(signals, file)
|
|
135
|
+
updatePathCategorySignals(signals, path)
|
|
136
|
+
return { path, status }
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const snippets = await Promise.all(limitedPaths.map(async (path) => {
|
|
140
|
+
const diff = await git.diffForFile(path)
|
|
141
|
+
const diffStats = analyzeDiff(diff)
|
|
142
|
+
const behaviorCues = collectBehaviorCues(diff)
|
|
143
|
+
const condensed = condenseDiff(diff, maxCharsPerFile)
|
|
144
|
+
return {
|
|
145
|
+
path,
|
|
146
|
+
addedLines: diffStats.addedLines,
|
|
147
|
+
removedLines: diffStats.removedLines,
|
|
148
|
+
behaviorCues,
|
|
149
|
+
condensed,
|
|
150
|
+
}
|
|
151
|
+
}))
|
|
152
|
+
|
|
153
|
+
const fileLines = fileSummaries.map((entry) => {
|
|
154
|
+
const snippet = snippets.find((candidate) => candidate.path === entry.path)
|
|
155
|
+
const additions = snippet?.addedLines ?? 0
|
|
156
|
+
const deletions = snippet?.removedLines ?? 0
|
|
157
|
+
signals.addedLines += additions
|
|
158
|
+
signals.removedLines += deletions
|
|
159
|
+
return `- ${entry.status} ${entry.path} (+${additions} -${deletions})`
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const diffHighlights = snippets
|
|
163
|
+
.filter((snippet) => snippet.condensed)
|
|
164
|
+
.map((snippet) => `FILE: ${snippet.path}\n${snippet.condensed}`)
|
|
165
|
+
|
|
166
|
+
const behaviorCues = aggregateBehaviorCues(snippets.map((snippet) => snippet.behaviorCues))
|
|
167
|
+
const recentCommitSubjects = await readRecentCommitSubjects(git)
|
|
168
|
+
const existingSurfaceOnly = signals.newFiles === 0 && signals.renamedFiles === 0
|
|
169
|
+
const likelyNewSurface = signals.newFiles > 0 || signals.renamedFiles > 0
|
|
170
|
+
|
|
171
|
+
const recentCommitsSection = recentCommitSubjects.length > 0
|
|
172
|
+
? [
|
|
173
|
+
"",
|
|
174
|
+
"Recent commit subjects (style reference only):",
|
|
175
|
+
...recentCommitSubjects.map((subject) => `- ${subject}`),
|
|
176
|
+
]
|
|
177
|
+
: []
|
|
178
|
+
|
|
179
|
+
const diffSection = diffHighlights.length > 0
|
|
180
|
+
? diffHighlights.join("\n\n")
|
|
181
|
+
: "- no diff snippets were captured"
|
|
182
|
+
|
|
183
|
+
const selectedPathsSection = selectedPaths.length > limitedPaths.length
|
|
184
|
+
? `- additional_selected_files_not_shown: ${selectedPaths.length - limitedPaths.length}`
|
|
185
|
+
: "- additional_selected_files_not_shown: 0"
|
|
186
|
+
|
|
187
|
+
const lines: string[] = [
|
|
188
|
+
"Context signals:",
|
|
189
|
+
`- touched_files: ${signals.touchedFiles}`,
|
|
190
|
+
`- existing_surface_only: ${existingSurfaceOnly ? "yes" : "no"}`,
|
|
191
|
+
`- likely_new_surface: ${likelyNewSurface ? "yes" : "no"}`,
|
|
192
|
+
`- status_counts: new=${signals.newFiles} modified=${signals.modifiedFiles} deleted=${signals.deletedFiles} renamed=${signals.renamedFiles}`,
|
|
193
|
+
`- diff_line_counts: additions=${signals.addedLines} deletions=${signals.removedLines}`,
|
|
194
|
+
`- file_categories: docs=${signals.docsFiles} tests=${signals.testFiles} config=${signals.configFiles}`,
|
|
195
|
+
selectedPathsSection,
|
|
196
|
+
"- classify by behavior impact first; line counts and file counts are supporting signals",
|
|
197
|
+
"",
|
|
198
|
+
"Behavior cues:",
|
|
199
|
+
`- added_conditions: ${formatCueList(behaviorCues.addedConditions)}`,
|
|
200
|
+
`- removed_conditions: ${formatCueList(behaviorCues.removedConditions)}`,
|
|
201
|
+
`- added_guards: ${formatCueList(behaviorCues.addedGuards)}`,
|
|
202
|
+
`- removed_guards: ${formatCueList(behaviorCues.removedGuards)}`,
|
|
203
|
+
"",
|
|
204
|
+
"Changed files:",
|
|
205
|
+
fileLines.join("\n"),
|
|
206
|
+
...recentCommitsSection,
|
|
207
|
+
"",
|
|
208
|
+
"Diff highlights:",
|
|
209
|
+
diffSection,
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
return lines.join("\n")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readRecentCommitSubjects(git: GitClient): Promise<string[]> {
|
|
216
|
+
try {
|
|
217
|
+
const entries = await git.listCommits(MAX_RECENT_COMMIT_SUBJECTS)
|
|
218
|
+
return entries
|
|
219
|
+
.map((entry) => entry.subject.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.slice(0, MAX_RECENT_COMMIT_SUBJECTS)
|
|
222
|
+
} catch {
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function condenseDiff(diff: string, maxChars: number): string {
|
|
228
|
+
const lines = diff.split("\n")
|
|
229
|
+
const relevant: string[] = []
|
|
230
|
+
let changedLines = 0
|
|
231
|
+
let hunkCount = 0
|
|
232
|
+
const MAX_CHANGED_LINES = 120
|
|
233
|
+
const MAX_HUNKS = 12
|
|
234
|
+
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
if (line.startsWith("# ")) {
|
|
237
|
+
relevant.push(line)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
if (line.startsWith("@@")) {
|
|
241
|
+
hunkCount += 1
|
|
242
|
+
if (hunkCount <= MAX_HUNKS) {
|
|
243
|
+
relevant.push(line)
|
|
244
|
+
}
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (line.startsWith("+") || line.startsWith("-")) {
|
|
251
|
+
changedLines += 1
|
|
252
|
+
if (changedLines <= MAX_CHANGED_LINES) {
|
|
253
|
+
relevant.push(line)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const body = (relevant.length > 0 ? relevant.join("\n") : diff.trim()).trim()
|
|
259
|
+
if (!body) {
|
|
260
|
+
return ""
|
|
261
|
+
}
|
|
262
|
+
if (body.length <= maxChars) {
|
|
263
|
+
return body
|
|
264
|
+
}
|
|
265
|
+
return `${body.slice(0, Math.max(maxChars - 16, 1)).trimEnd()}\n...[truncated]`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function generateCommitDraft(
|
|
269
|
+
model: TextGenerationModel,
|
|
270
|
+
context: string,
|
|
271
|
+
reasoningEffort: "low" | "medium" | "high",
|
|
272
|
+
retry: boolean,
|
|
273
|
+
): Promise<CommitDraft | null> {
|
|
274
|
+
const maxOutputTokens = resolveMaxOutputTokens(reasoningEffort)
|
|
275
|
+
try {
|
|
276
|
+
const { output } = await generateText({
|
|
277
|
+
model,
|
|
278
|
+
temperature: 0,
|
|
279
|
+
maxOutputTokens,
|
|
280
|
+
providerOptions: {
|
|
281
|
+
cerebras: {
|
|
282
|
+
reasoningEffort,
|
|
283
|
+
strictJsonSchema: true,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
output: Output.object({
|
|
287
|
+
name: "conventional_commit_subject",
|
|
288
|
+
description: "Conventional commit fields for a concise git subject line.",
|
|
289
|
+
schema: COMMIT_OUTPUT_FLEXIBLE_SCHEMA,
|
|
290
|
+
}),
|
|
291
|
+
system: buildSystemPrompt(retry),
|
|
292
|
+
prompt: buildUserPrompt(context, retry),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
return normalizeCommitDraft(output)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (NoObjectGeneratedError.isInstance(error)) {
|
|
298
|
+
const fallbackText = typeof error.text === "string" ? error.text : ""
|
|
299
|
+
return normalizeCommitDraft(extractJsonObject(fallbackText))
|
|
300
|
+
}
|
|
301
|
+
throw error
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildSystemPrompt(retry: boolean): string {
|
|
306
|
+
return [
|
|
307
|
+
"You generate conventional commit subjects from git diff context.",
|
|
308
|
+
"Prioritize semantic correctness over wording novelty.",
|
|
309
|
+
"Commit type rubric:",
|
|
310
|
+
"- fix: behavior correction, bug handling, regression prevention, compatibility adjustment.",
|
|
311
|
+
"- feat: net-new user-facing capability or clearly new surface (new command/screen/endpoint/setting).",
|
|
312
|
+
"- refactor: structural changes with no behavior change.",
|
|
313
|
+
"- style: formatting-only edits.",
|
|
314
|
+
"- docs/test/build/ci/chore/perf/revert only when clearly dominant.",
|
|
315
|
+
"- if uncertain between feat and fix, choose fix.",
|
|
316
|
+
"- do not infer feat only from additions, support wording, or larger diff size.",
|
|
317
|
+
"- if changes are in existing files/flows and no new surface is explicit, avoid feat.",
|
|
318
|
+
"- adding compatibility or alternate paths for an existing workflow is usually fix.",
|
|
319
|
+
"- feat requires introducing a meaningfully new workflow/surface to users.",
|
|
320
|
+
"- when conditions/guards are added to prevent unsafe or accidental behavior, this strongly indicates fix.",
|
|
321
|
+
"Style rules:",
|
|
322
|
+
"- scope is optional and must be a lowercase noun token.",
|
|
323
|
+
"- description is imperative, specific, concise, and starts lowercase.",
|
|
324
|
+
"- description must read naturally after '<type>(<scope>):'.",
|
|
325
|
+
"- for fix, phrase the user-visible failure prevented/resolved (often with 'when' or 'on').",
|
|
326
|
+
"- for fix, prefer describing the undesired side effect being prevented.",
|
|
327
|
+
"- for fix, prefer prevent/avoid/handle over enable/add.",
|
|
328
|
+
"- prefer concrete verbs: fix/prevent/handle/enable/avoid/normalize/simplify/refine.",
|
|
329
|
+
"- prefer user-visible behavior over internal API detail names.",
|
|
330
|
+
"- avoid wording that names low-level implementation calls unless unavoidable.",
|
|
331
|
+
"- avoid vague lead verbs like support, update, improve, change.",
|
|
332
|
+
"- avoid 'enable' for fix unless the phrase also states what broken behavior is corrected.",
|
|
333
|
+
"- avoid generic phrases like 'update code' or 'improve things'.",
|
|
334
|
+
retry
|
|
335
|
+
? "Retry mode: if previous output was invalid, simplify wording while keeping meaning."
|
|
336
|
+
: "Return the best single conventional commit subject metadata for this change set.",
|
|
337
|
+
].join("\n")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildUserPrompt(context: string, retry: boolean): string {
|
|
341
|
+
const retryLine = retry
|
|
342
|
+
? "Retry constraints: keep description short and concrete; prefer simpler scope or omit scope."
|
|
343
|
+
: "Use the context below."
|
|
344
|
+
return [
|
|
345
|
+
retryLine,
|
|
346
|
+
"Output must satisfy schema and conventional commit semantics.",
|
|
347
|
+
"",
|
|
348
|
+
context,
|
|
349
|
+
].join("\n")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
type CommitDraft = {
|
|
353
|
+
type: CommitType
|
|
354
|
+
scope?: string
|
|
355
|
+
description: string
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function normalizeCommitDraft(value: unknown): CommitDraft | null {
|
|
359
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const candidate = value as Record<string, unknown>
|
|
364
|
+
const type = typeof candidate.type === "string" ? candidate.type.trim().toLowerCase() : ""
|
|
365
|
+
if (!isCommitType(type)) {
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const descriptionRaw = typeof candidate.description === "string" ? candidate.description : ""
|
|
370
|
+
const description = sanitizeDescription(descriptionRaw)
|
|
371
|
+
if (!description) {
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const scopeRaw = typeof candidate.scope === "string" ? candidate.scope : undefined
|
|
376
|
+
const scope = sanitizeScope(scopeRaw)
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
type,
|
|
380
|
+
scope,
|
|
381
|
+
description,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isCommitType(value: string): value is CommitType {
|
|
386
|
+
return COMMIT_TYPES.includes(value as CommitType)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sanitizeScope(scope: string | undefined): string | undefined {
|
|
390
|
+
if (!scope) {
|
|
391
|
+
return undefined
|
|
392
|
+
}
|
|
393
|
+
const normalized = scope.trim().toLowerCase()
|
|
394
|
+
if (!normalized) {
|
|
395
|
+
return undefined
|
|
396
|
+
}
|
|
397
|
+
if (!SCOPE_REGEX.test(normalized)) {
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
return normalized
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function sanitizeDescription(description: string): string {
|
|
404
|
+
const normalized = description
|
|
405
|
+
.trim()
|
|
406
|
+
.replace(/^["'`]+/, "")
|
|
407
|
+
.replace(/["'`]+$/, "")
|
|
408
|
+
.replace(/[‐‑–—]/g, "-")
|
|
409
|
+
.replace(/\s+/g, " ")
|
|
410
|
+
.replace(/[.]+$/, "")
|
|
411
|
+
.trim()
|
|
412
|
+
|
|
413
|
+
if (!normalized) {
|
|
414
|
+
return ""
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const first = normalized[0]
|
|
418
|
+
const loweredFirst = first ? first.toLowerCase() : ""
|
|
419
|
+
return `${loweredFirst}${normalized.slice(1)}`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function finalizeCommitSummary(draft: CommitDraft | null): string | null {
|
|
423
|
+
if (!draft) {
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const prefix = draft.scope ? `${draft.type}(${draft.scope})` : draft.type
|
|
428
|
+
const prefixWithColon = `${prefix}: `
|
|
429
|
+
const maxDescriptionLength = Math.max(72 - prefixWithColon.length, 1)
|
|
430
|
+
const compactDescription = compactDescriptionLength(draft.description, maxDescriptionLength)
|
|
431
|
+
const candidate = `${prefixWithColon}${compactDescription}`
|
|
432
|
+
if (!CONVENTIONAL_COMMIT_REGEX.test(candidate)) {
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
return candidate
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function compactDescriptionLength(description: string, maxLength: number): string {
|
|
439
|
+
if (description.length <= maxLength) {
|
|
440
|
+
return description
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const clipped = description.slice(0, maxLength).trim()
|
|
444
|
+
const lastSpace = clipped.lastIndexOf(" ")
|
|
445
|
+
const wordSafe = lastSpace >= Math.floor(maxLength * 0.6) ? clipped.slice(0, lastSpace).trim() : clipped
|
|
446
|
+
return trimTrailingConnector(wordSafe.replace(/[.]+$/, "").trim())
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function trimTrailingConnector(text: string): string {
|
|
450
|
+
const connectors = new Set(["and", "or", "to", "on", "with", "for", "of", "the", "a", "an", "via"])
|
|
451
|
+
const words = text.split(" ").filter(Boolean)
|
|
452
|
+
while (words.length > 1 && connectors.has(words[words.length - 1] ?? "")) {
|
|
453
|
+
words.pop()
|
|
454
|
+
}
|
|
455
|
+
return words.join(" ")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
type ContextSignals = {
|
|
459
|
+
touchedFiles: number
|
|
460
|
+
newFiles: number
|
|
461
|
+
modifiedFiles: number
|
|
462
|
+
deletedFiles: number
|
|
463
|
+
renamedFiles: number
|
|
464
|
+
addedLines: number
|
|
465
|
+
removedLines: number
|
|
466
|
+
docsFiles: number
|
|
467
|
+
testFiles: number
|
|
468
|
+
configFiles: number
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
type BehaviorCues = {
|
|
472
|
+
addedConditions: string[]
|
|
473
|
+
removedConditions: string[]
|
|
474
|
+
addedGuards: string[]
|
|
475
|
+
removedGuards: string[]
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function updateStatusSignals(signals: ContextSignals, file: ChangedFile | undefined): void {
|
|
479
|
+
if (!file) return
|
|
480
|
+
if (file.untracked) {
|
|
481
|
+
signals.newFiles += 1
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const statuses = [file.indexStatus, file.worktreeStatus]
|
|
486
|
+
if (statuses.includes("D")) {
|
|
487
|
+
signals.deletedFiles += 1
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
if (statuses.includes("R")) {
|
|
491
|
+
signals.renamedFiles += 1
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
if (statuses.includes("A")) {
|
|
495
|
+
signals.newFiles += 1
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
signals.modifiedFiles += 1
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function updatePathCategorySignals(signals: ContextSignals, path: string): void {
|
|
502
|
+
const normalized = path.toLowerCase()
|
|
503
|
+
if (isDocsPath(normalized)) signals.docsFiles += 1
|
|
504
|
+
if (isTestPath(normalized)) signals.testFiles += 1
|
|
505
|
+
if (isConfigPath(normalized)) signals.configFiles += 1
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isDocsPath(path: string): boolean {
|
|
509
|
+
return path.endsWith(".md") || path.endsWith(".mdx") || path.includes("/docs/") || path.startsWith("docs/")
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isTestPath(path: string): boolean {
|
|
513
|
+
return path.includes("/test/") || path.includes("/tests/") || path.includes(".test.") || path.includes(".spec.")
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isConfigPath(path: string): boolean {
|
|
517
|
+
return (
|
|
518
|
+
path.endsWith(".json")
|
|
519
|
+
|| path.endsWith(".yaml")
|
|
520
|
+
|| path.endsWith(".yml")
|
|
521
|
+
|| path.endsWith(".toml")
|
|
522
|
+
|| path.endsWith(".ini")
|
|
523
|
+
|| path.endsWith("lock")
|
|
524
|
+
|| path.endsWith(".lock")
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function analyzeDiff(diff: string): { addedLines: number; removedLines: number } {
|
|
529
|
+
let addedLines = 0
|
|
530
|
+
let removedLines = 0
|
|
531
|
+
|
|
532
|
+
for (const line of diff.split("\n")) {
|
|
533
|
+
if (line.startsWith("+++")) continue
|
|
534
|
+
if (line.startsWith("---")) continue
|
|
535
|
+
if (line.startsWith("+")) {
|
|
536
|
+
addedLines += 1
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
if (line.startsWith("-")) {
|
|
540
|
+
removedLines += 1
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return { addedLines, removedLines }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function collectBehaviorCues(diff: string): BehaviorCues {
|
|
548
|
+
const addedConditions = new Set<string>()
|
|
549
|
+
const removedConditions = new Set<string>()
|
|
550
|
+
const addedGuards = new Set<string>()
|
|
551
|
+
const removedGuards = new Set<string>()
|
|
552
|
+
|
|
553
|
+
for (const line of diff.split("\n")) {
|
|
554
|
+
if (line.startsWith("+++")
|
|
555
|
+
|| line.startsWith("---")
|
|
556
|
+
|| line.startsWith("@@")
|
|
557
|
+
|| line.startsWith("# ")) {
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const isAdded = line.startsWith("+")
|
|
562
|
+
const isRemoved = line.startsWith("-")
|
|
563
|
+
if (!isAdded && !isRemoved) {
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const content = line.slice(1).trim()
|
|
568
|
+
if (!content) {
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const condition = extractConditionCue(content)
|
|
573
|
+
if (condition) {
|
|
574
|
+
if (isAdded) {
|
|
575
|
+
addedConditions.add(condition)
|
|
576
|
+
} else {
|
|
577
|
+
removedConditions.add(condition)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const guard = extractGuardCue(content)
|
|
582
|
+
if (guard) {
|
|
583
|
+
if (isAdded) {
|
|
584
|
+
addedGuards.add(guard)
|
|
585
|
+
} else {
|
|
586
|
+
removedGuards.add(guard)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
addedConditions: Array.from(addedConditions),
|
|
593
|
+
removedConditions: Array.from(removedConditions),
|
|
594
|
+
addedGuards: Array.from(addedGuards),
|
|
595
|
+
removedGuards: Array.from(removedGuards),
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function extractConditionCue(line: string): string | null {
|
|
600
|
+
const inline = line.match(/^if\s*\((.*)\)\s*\{?$/)
|
|
601
|
+
if (inline) {
|
|
602
|
+
return normalizeCue(inline[1] ?? "")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const ternary = line.match(/^.*\?.*:.*/)
|
|
606
|
+
if (ternary) {
|
|
607
|
+
return normalizeCue("ternary-condition")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return null
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function extractGuardCue(line: string): string | null {
|
|
614
|
+
if (line.startsWith("return")) {
|
|
615
|
+
return normalizeCue(line)
|
|
616
|
+
}
|
|
617
|
+
if (line.startsWith("throw")) {
|
|
618
|
+
return normalizeCue(line)
|
|
619
|
+
}
|
|
620
|
+
if (line.includes(".preventDefault(")) {
|
|
621
|
+
return "preventDefault()"
|
|
622
|
+
}
|
|
623
|
+
if (line.includes(".stopPropagation(")) {
|
|
624
|
+
return "stopPropagation()"
|
|
625
|
+
}
|
|
626
|
+
return null
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function normalizeCue(value: string): string {
|
|
630
|
+
const compact = value.replace(/\s+/g, " ").trim()
|
|
631
|
+
if (compact.length <= 72) {
|
|
632
|
+
return compact
|
|
633
|
+
}
|
|
634
|
+
return `${compact.slice(0, 69).trimEnd()}...`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function aggregateBehaviorCues(cuesList: BehaviorCues[]): BehaviorCues {
|
|
638
|
+
const addedConditions = new Set<string>()
|
|
639
|
+
const removedConditions = new Set<string>()
|
|
640
|
+
const addedGuards = new Set<string>()
|
|
641
|
+
const removedGuards = new Set<string>()
|
|
642
|
+
|
|
643
|
+
for (const cues of cuesList) {
|
|
644
|
+
for (const value of cues.addedConditions) {
|
|
645
|
+
addedConditions.add(value)
|
|
646
|
+
}
|
|
647
|
+
for (const value of cues.removedConditions) {
|
|
648
|
+
removedConditions.add(value)
|
|
649
|
+
}
|
|
650
|
+
for (const value of cues.addedGuards) {
|
|
651
|
+
addedGuards.add(value)
|
|
652
|
+
}
|
|
653
|
+
for (const value of cues.removedGuards) {
|
|
654
|
+
removedGuards.add(value)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
addedConditions: Array.from(addedConditions),
|
|
660
|
+
removedConditions: Array.from(removedConditions),
|
|
661
|
+
addedGuards: Array.from(addedGuards),
|
|
662
|
+
removedGuards: Array.from(removedGuards),
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function formatCueList(values: string[]): string {
|
|
667
|
+
if (values.length === 0) {
|
|
668
|
+
return "none"
|
|
669
|
+
}
|
|
670
|
+
const limit = values.slice(0, 6)
|
|
671
|
+
return limit.join(" | ")
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function resolveMaxOutputTokens(reasoningEffort: "low" | "medium" | "high"): number {
|
|
675
|
+
if (reasoningEffort === "high") return 4096
|
|
676
|
+
if (reasoningEffort === "medium") return 3072
|
|
677
|
+
return 2048
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function extractJsonObject(text: string): unknown {
|
|
681
|
+
const raw = text.trim()
|
|
682
|
+
if (!raw) {
|
|
683
|
+
return null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const direct = tryParseJson(raw)
|
|
687
|
+
if (direct !== null) {
|
|
688
|
+
return direct
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const firstBrace = raw.indexOf("{")
|
|
692
|
+
const lastBrace = raw.lastIndexOf("}")
|
|
693
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
694
|
+
return tryParseJson(raw.slice(firstBrace, lastBrace + 1))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return null
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function tryParseJson(raw: string): unknown {
|
|
701
|
+
try {
|
|
702
|
+
return JSON.parse(raw)
|
|
703
|
+
} catch {
|
|
704
|
+
return null
|
|
705
|
+
}
|
|
706
|
+
}
|