stage-tui 1.0.8 → 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.
Files changed (62) hide show
  1. package/README.md +12 -3
  2. package/package.json +28 -20
  3. package/src/ai-commit/context-builder.ts +148 -0
  4. package/src/ai-commit/diff-budget.ts +173 -0
  5. package/src/ai-commit/diff-signals.ts +264 -0
  6. package/src/ai-commit/draft.ts +203 -0
  7. package/src/ai-commit/generate-ai-commit-summary.ts +67 -0
  8. package/src/ai-commit/index.ts +1 -0
  9. package/src/ai-commit/policy.ts +50 -0
  10. package/src/ai-commit/prompts.ts +56 -0
  11. package/src/ai-commit/tokenizer.ts +28 -0
  12. package/src/ai-commit-eval/args.ts +113 -0
  13. package/src/ai-commit-eval/config.ts +20 -0
  14. package/src/ai-commit-eval/evaluate-commit.ts +53 -0
  15. package/src/ai-commit-eval/evaluate-working-tree.ts +34 -0
  16. package/src/ai-commit-eval/output.ts +20 -0
  17. package/src/ai-commit-eval/replay-worktree.ts +74 -0
  18. package/src/ai-commit-eval/selection.ts +21 -0
  19. package/src/ai-commit-eval/types.ts +30 -0
  20. package/src/ai-commit-eval/utils.ts +34 -0
  21. package/src/ai-commit-eval.ts +62 -0
  22. package/src/app.tsx +103 -18
  23. package/src/config-file.ts +6 -2
  24. package/src/config-parser.ts +234 -0
  25. package/src/config.ts +9 -183
  26. package/src/git/client.ts +154 -0
  27. package/src/git/index.ts +8 -0
  28. package/src/git/parsers.ts +86 -0
  29. package/src/git/read-ops.ts +142 -0
  30. package/src/git/stash.ts +53 -0
  31. package/src/git/types.ts +54 -0
  32. package/src/git/write-ops.ts +135 -0
  33. package/src/git-process.ts +37 -3
  34. package/src/git-status-parser.ts +56 -24
  35. package/src/hooks/commit-history/options.ts +53 -0
  36. package/src/hooks/commit-history/use-commit-diff-loader.ts +109 -0
  37. package/src/hooks/commit-history/use-commit-files-loader.ts +118 -0
  38. package/src/hooks/git-tui-controller/tracking.ts +19 -0
  39. package/src/hooks/git-tui-keyboard/handle-dialog-keys.ts +258 -0
  40. package/src/hooks/git-tui-keyboard/handle-main-keys.ts +151 -0
  41. package/src/hooks/git-tui-keyboard/key-flags.ts +45 -0
  42. package/src/hooks/git-tui-keyboard/types.ts +62 -0
  43. package/src/hooks/selection-index.ts +14 -0
  44. package/src/hooks/use-branch-dialog-controller.ts +85 -23
  45. package/src/hooks/use-commit-history-controller.ts +202 -34
  46. package/src/hooks/use-git-tui-controller.ts +53 -63
  47. package/src/hooks/use-git-tui-effects.ts +22 -8
  48. package/src/hooks/use-git-tui-keyboard.ts +13 -283
  49. package/src/hooks/use-task-runner.ts +40 -0
  50. package/src/ui/components/branch-dialog.tsx +169 -35
  51. package/src/ui/components/commit-history/layout.ts +66 -0
  52. package/src/ui/components/commit-history-dialog.tsx +251 -43
  53. package/src/ui/components/diff-workspace.tsx +123 -26
  54. package/src/ui/components/footer-bar.tsx +30 -3
  55. package/src/ui/components/shortcuts-dialog.tsx +2 -2
  56. package/src/ui/components/splash-screen.tsx +24 -0
  57. package/src/ui/components/top-bar.tsx +17 -4
  58. package/src/ui/list-range.ts +12 -0
  59. package/src/ui/types.ts +4 -0
  60. package/src/ui/utils.ts +69 -4
  61. package/src/ai-commit.ts +0 -706
  62. package/src/git.ts +0 -298
package/README.md CHANGED
@@ -44,6 +44,14 @@ stage # use installed npm package
44
44
  stage --dev # use local checkout at STAGE_DEV_PATH
45
45
  ```
46
46
 
47
+ AI commit evals:
48
+
49
+ ```bash
50
+ bun run eval:ai
51
+ bun run eval:ai -- --path src/hooks/use-git-tui-keyboard.ts
52
+ bun run eval:ai -- --commit 56d030f072853619483abaf79c57e9104a143d9d
53
+ ```
54
+
47
55
  ## Configuration
48
56
 
49
57
  Config file path:
@@ -84,8 +92,9 @@ provider = "cerebras" # currently only supported provider
84
92
  api_key = "" # required when enabled = true
85
93
  model = "gpt-oss-120b"
86
94
  reasoning_effort = "low" # "low" | "medium" | "high"
95
+ max_input_tokens = 24000
87
96
  max_files = 32
88
- max_chars_per_file = 4000
97
+ max_tokens_per_file = 4000
89
98
  ```
90
99
 
91
100
  </details>
@@ -101,8 +110,8 @@ max_chars_per_file = 4000
101
110
  - `↑ / ↓`: move file selection
102
111
  - `r`: refresh
103
112
  - `f`: fetch
104
- - `l`: pull
105
- - `p`: push
113
+ - `p`: pull
114
+ - `ctrl+p`: push
106
115
  - `esc`: close dialog (or exit from main view)
107
116
 
108
117
  </details>
package/package.json CHANGED
@@ -1,21 +1,19 @@
1
1
  {
2
2
  "name": "stage-tui",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
+ "private": false,
4
5
  "description": "Minimalist TUI Git client",
6
+ "homepage": "https://github.com/jenslys/stage",
7
+ "bugs": {
8
+ "url": "https://github.com/jenslys/stage/issues"
9
+ },
5
10
  "author": "jenslys",
6
11
  "repository": {
7
12
  "type": "git",
8
13
  "url": "https://github.com/jenslys/stage.git"
9
14
  },
10
- "homepage": "https://github.com/jenslys/stage",
11
- "bugs": {
12
- "url": "https://github.com/jenslys/stage/issues"
13
- },
14
- "module": "index.ts",
15
- "type": "module",
16
- "private": false,
17
- "publishConfig": {
18
- "access": "public"
15
+ "bin": {
16
+ "stage": "./bin/stage"
19
17
  },
20
18
  "files": [
21
19
  "bin",
@@ -23,24 +21,34 @@
23
21
  "index.ts",
24
22
  "README.md"
25
23
  ],
26
- "bin": {
27
- "stage": "./bin/stage"
24
+ "type": "module",
25
+ "module": "index.ts",
26
+ "publishConfig": {
27
+ "access": "public"
28
28
  },
29
29
  "scripts": {
30
- "stage": "bun run index.ts"
31
- },
32
- "devDependencies": {
33
- "@types/bun": "latest",
34
- "@types/react": "^19.2.14"
35
- },
36
- "peerDependencies": {
37
- "typescript": "^5"
30
+ "stage": "bun run index.ts",
31
+ "eval:ai": "bun run src/ai-commit-eval.ts",
32
+ "lint": "oxlint --react-plugin --import-plugin --promise-plugin src index.ts",
33
+ "lint:fix": "oxlint --react-plugin --import-plugin --promise-plugin --fix src index.ts",
34
+ "format": "oxfmt --write src index.ts package.json tsconfig.json .oxlintrc.json .oxfmtrc.json",
35
+ "format:check": "oxfmt --check src index.ts package.json tsconfig.json .oxlintrc.json .oxfmtrc.json"
38
36
  },
39
37
  "dependencies": {
40
38
  "@ai-sdk/cerebras": "^2.0.34",
41
39
  "@opentui/core": "^0.1.81",
42
40
  "@opentui/react": "^0.1.81",
43
41
  "ai": "^6.0.97",
42
+ "gpt-tokenizer": "^3.4.0",
44
43
  "react": "^19.2.4"
44
+ },
45
+ "devDependencies": {
46
+ "@types/bun": "latest",
47
+ "@types/react": "^19.2.14",
48
+ "oxfmt": "0.35.0",
49
+ "oxlint": "1.50.0"
50
+ },
51
+ "peerDependencies": {
52
+ "typescript": "^5"
45
53
  }
46
54
  }
@@ -0,0 +1,148 @@
1
+ import type { ChangedFile, GitClient } from "../git"
2
+
3
+ import { buildDiffSectionWithBudget, condenseDiff, truncateToTokenBudget } from "./diff-budget"
4
+ import {
5
+ aggregateBehaviorCues,
6
+ analyzeDiff,
7
+ collectBehaviorCues,
8
+ formatCueList,
9
+ type ContextSignals,
10
+ updatePathCategorySignals,
11
+ updateStatusSignals,
12
+ } from "./diff-signals"
13
+ import { MAX_RECENT_COMMIT_SUBJECTS } from "./policy"
14
+ import type { TextTokenizer } from "./tokenizer"
15
+
16
+ type BuildCommitContextParams = {
17
+ git: GitClient
18
+ fileByPath: Map<string, ChangedFile>
19
+ selectedPaths: string[]
20
+ maxFiles: number
21
+ maxInputTokens: number
22
+ maxTokensPerFile: number
23
+ tokenizer: TextTokenizer
24
+ }
25
+
26
+ export async function buildCommitContext({
27
+ git,
28
+ fileByPath,
29
+ selectedPaths,
30
+ maxFiles,
31
+ maxInputTokens,
32
+ maxTokensPerFile,
33
+ tokenizer,
34
+ }: BuildCommitContextParams): Promise<string> {
35
+ const limitedPaths = selectedPaths.slice(0, maxFiles)
36
+ const signals: ContextSignals = {
37
+ touchedFiles: limitedPaths.length,
38
+ newFiles: 0,
39
+ modifiedFiles: 0,
40
+ deletedFiles: 0,
41
+ renamedFiles: 0,
42
+ addedLines: 0,
43
+ removedLines: 0,
44
+ docsFiles: 0,
45
+ testFiles: 0,
46
+ configFiles: 0,
47
+ }
48
+
49
+ const fileSummaries = limitedPaths.map((path) => {
50
+ const file = fileByPath.get(path)
51
+ const status = file ? `${file.indexStatus}${file.worktreeStatus}`.trim() || "??" : "??"
52
+ updateStatusSignals(signals, file)
53
+ updatePathCategorySignals(signals, path)
54
+ return { path, status }
55
+ })
56
+
57
+ const snippets = await Promise.all(
58
+ limitedPaths.map(async (path) => {
59
+ const diff = await git.diffForFile(path)
60
+ const diffStats = analyzeDiff(diff)
61
+ const behaviorCues = collectBehaviorCues(diff)
62
+ const condensed = condenseDiff(diff)
63
+ return {
64
+ path,
65
+ addedLines: diffStats.addedLines,
66
+ removedLines: diffStats.removedLines,
67
+ behaviorCues,
68
+ condensed,
69
+ }
70
+ }),
71
+ )
72
+
73
+ const fileLines = fileSummaries.map((entry) => {
74
+ const snippet = snippets.find((candidate) => candidate.path === entry.path)
75
+ const additions = snippet?.addedLines ?? 0
76
+ const deletions = snippet?.removedLines ?? 0
77
+ signals.addedLines += additions
78
+ signals.removedLines += deletions
79
+ return `- ${entry.status} ${entry.path} (+${additions} -${deletions})`
80
+ })
81
+
82
+ const behaviorCues = aggregateBehaviorCues(snippets.map((snippet) => snippet.behaviorCues))
83
+ const recentCommitSubjects = await readRecentCommitSubjects(git)
84
+ const existingSurfaceOnly = signals.newFiles === 0 && signals.renamedFiles === 0
85
+ const likelyNewSurface = signals.newFiles > 0 || signals.renamedFiles > 0
86
+
87
+ const recentCommitsSection =
88
+ recentCommitSubjects.length > 0
89
+ ? [
90
+ "",
91
+ "Recent commit subjects (style reference only):",
92
+ ...recentCommitSubjects.map((subject) => `- ${subject}`),
93
+ ]
94
+ : []
95
+
96
+ const selectedPathsSection =
97
+ selectedPaths.length > limitedPaths.length
98
+ ? `- additional_selected_files_not_shown: ${selectedPaths.length - limitedPaths.length}`
99
+ : "- additional_selected_files_not_shown: 0"
100
+
101
+ const preambleLines: string[] = [
102
+ "Context signals:",
103
+ `- touched_files: ${signals.touchedFiles}`,
104
+ `- existing_surface_only: ${existingSurfaceOnly ? "yes" : "no"}`,
105
+ `- likely_new_surface: ${likelyNewSurface ? "yes" : "no"}`,
106
+ `- status_counts: new=${signals.newFiles} modified=${signals.modifiedFiles} deleted=${signals.deletedFiles} renamed=${signals.renamedFiles}`,
107
+ `- diff_line_counts: additions=${signals.addedLines} deletions=${signals.removedLines}`,
108
+ `- file_categories: docs=${signals.docsFiles} tests=${signals.testFiles} config=${signals.configFiles}`,
109
+ selectedPathsSection,
110
+ "- classify by behavior impact first; line counts and file counts are supporting signals",
111
+ "",
112
+ "Behavior cues:",
113
+ `- added_conditions: ${formatCueList(behaviorCues.addedConditions)}`,
114
+ `- removed_conditions: ${formatCueList(behaviorCues.removedConditions)}`,
115
+ `- added_guards: ${formatCueList(behaviorCues.addedGuards)}`,
116
+ `- removed_guards: ${formatCueList(behaviorCues.removedGuards)}`,
117
+ `- added_calls: ${formatCueList(behaviorCues.addedCalls)}`,
118
+ `- removed_calls: ${formatCueList(behaviorCues.removedCalls)}`,
119
+ "",
120
+ "Changed files:",
121
+ fileLines.join("\n"),
122
+ ...recentCommitsSection,
123
+ ]
124
+
125
+ const diffSection = buildDiffSectionWithBudget({
126
+ snippets: snippets.map((snippet) => ({ path: snippet.path, body: snippet.condensed })),
127
+ preambleLines,
128
+ maxInputTokens,
129
+ maxTokensPerFile,
130
+ tokenizer,
131
+ })
132
+
133
+ const context = [...preambleLines, "", "Diff highlights:", diffSection].join("\n")
134
+
135
+ return truncateToTokenBudget(context, maxInputTokens, tokenizer, "\n...[context truncated]")
136
+ }
137
+
138
+ async function readRecentCommitSubjects(git: GitClient): Promise<string[]> {
139
+ try {
140
+ const entries = await git.listCommits(MAX_RECENT_COMMIT_SUBJECTS)
141
+ return entries
142
+ .map((entry) => entry.subject.trim())
143
+ .filter(Boolean)
144
+ .slice(0, MAX_RECENT_COMMIT_SUBJECTS)
145
+ } catch {
146
+ return []
147
+ }
148
+ }
@@ -0,0 +1,173 @@
1
+ import type { TextTokenizer } from "./tokenizer"
2
+
3
+ export function condenseDiff(diff: string): string {
4
+ const lines = diff.split("\n")
5
+ const relevant: string[] = []
6
+ let changedLines = 0
7
+ let hunkCount = 0
8
+ const maxChangedLines = 120
9
+ const maxHunks = 12
10
+
11
+ for (const line of lines) {
12
+ if (line.startsWith("# ")) {
13
+ relevant.push(line)
14
+ continue
15
+ }
16
+ if (line.startsWith("@@")) {
17
+ hunkCount += 1
18
+ if (hunkCount <= maxHunks) {
19
+ relevant.push(line)
20
+ }
21
+ continue
22
+ }
23
+ if (line.startsWith("+++") || line.startsWith("---")) {
24
+ continue
25
+ }
26
+ if (line.startsWith("+") || line.startsWith("-")) {
27
+ changedLines += 1
28
+ if (changedLines <= maxChangedLines) {
29
+ relevant.push(line)
30
+ }
31
+ }
32
+ }
33
+
34
+ const body = (relevant.length > 0 ? relevant.join("\n") : diff.trim()).trim()
35
+ return body || ""
36
+ }
37
+
38
+ export function buildDiffSectionWithBudget({
39
+ snippets,
40
+ preambleLines,
41
+ maxInputTokens,
42
+ maxTokensPerFile,
43
+ tokenizer,
44
+ }: {
45
+ snippets: Array<{ path: string; body: string }>
46
+ preambleLines: string[]
47
+ maxInputTokens: number
48
+ maxTokensPerFile: number
49
+ tokenizer: TextTokenizer
50
+ }): string {
51
+ const fallback = "- no diff snippets were captured"
52
+ const preamble = [...preambleLines, "", "Diff highlights:"].join("\n")
53
+ const availableTokens = Math.max(maxInputTokens - tokenizer.encode(preamble).length, 0)
54
+ if (availableTokens <= 0) {
55
+ return truncateToTokenBudget(fallback, 1, tokenizer)
56
+ }
57
+
58
+ const prepared = snippets
59
+ .filter((snippet) => snippet.body)
60
+ .map((snippet) => {
61
+ const header = `FILE: ${snippet.path}\n`
62
+ const body = truncateToTokenBudget(snippet.body, Math.max(maxTokensPerFile, 1), tokenizer)
63
+ return {
64
+ header,
65
+ body,
66
+ headerTokens: tokenizer.encode(header).length,
67
+ bodyTokens: tokenizer.encode(body).length,
68
+ }
69
+ })
70
+ .filter((snippet) => snippet.body)
71
+
72
+ if (prepared.length === 0) {
73
+ return truncateToTokenBudget(fallback, availableTokens, tokenizer)
74
+ }
75
+
76
+ const separator = "\n\n"
77
+ const separatorTokens = tokenizer.encode(separator).length
78
+ const minBodyTokens = 24
79
+
80
+ let includeCount = 0
81
+ let fixedCost = 0
82
+ let minimumBodyCost = 0
83
+
84
+ for (const snippet of prepared) {
85
+ const joinCost = includeCount > 0 ? separatorTokens : 0
86
+ const minBodyCost = Math.min(minBodyTokens, snippet.bodyTokens)
87
+ const nextCost = fixedCost + minimumBodyCost + joinCost + snippet.headerTokens + minBodyCost
88
+ if (nextCost > availableTokens) {
89
+ break
90
+ }
91
+ includeCount += 1
92
+ fixedCost += joinCost + snippet.headerTokens
93
+ minimumBodyCost += minBodyCost
94
+ }
95
+
96
+ if (includeCount === 0) {
97
+ const first = prepared[0]
98
+ if (!first || first.headerTokens >= availableTokens) {
99
+ return truncateToTokenBudget(fallback, availableTokens, tokenizer)
100
+ }
101
+ const bodyBudget = availableTokens - first.headerTokens
102
+ const body = truncateToTokenBudget(first.body, bodyBudget, tokenizer)
103
+ const single = `${first.header}${body}`.trimEnd()
104
+ return single || truncateToTokenBudget(fallback, availableTokens, tokenizer)
105
+ }
106
+
107
+ const included = prepared.slice(0, includeCount)
108
+ const bodyBudget = Math.max(availableTokens - fixedCost, 0)
109
+ const allocations = included.map((snippet) => Math.min(minBodyTokens, snippet.bodyTokens))
110
+ let remainingBodyTokens = Math.max(
111
+ bodyBudget - allocations.reduce((sum, value) => sum + value, 0),
112
+ 0,
113
+ )
114
+
115
+ while (remainingBodyTokens > 0) {
116
+ let progressed = false
117
+ for (let index = 0; index < included.length && remainingBodyTokens > 0; index += 1) {
118
+ const snippet = included[index]
119
+ if (!snippet) continue
120
+ if (allocations[index]! >= snippet.bodyTokens) continue
121
+ allocations[index] = allocations[index]! + 1
122
+ remainingBodyTokens -= 1
123
+ progressed = true
124
+ }
125
+
126
+ if (!progressed) {
127
+ break
128
+ }
129
+ }
130
+
131
+ const section = included
132
+ .map((snippet, index) => {
133
+ const body = truncateToTokenBudget(snippet.body, allocations[index] ?? 0, tokenizer)
134
+ return `${snippet.header}${body}`.trimEnd()
135
+ })
136
+ .filter(Boolean)
137
+ .join(separator)
138
+
139
+ if (!section) {
140
+ return truncateToTokenBudget(fallback, availableTokens, tokenizer)
141
+ }
142
+
143
+ return truncateToTokenBudget(section, availableTokens, tokenizer)
144
+ }
145
+
146
+ export function truncateToTokenBudget(
147
+ text: string,
148
+ tokenLimit: number,
149
+ tokenizer: TextTokenizer,
150
+ suffix = "\n...[truncated]",
151
+ ): string {
152
+ if (tokenLimit <= 0) {
153
+ return ""
154
+ }
155
+
156
+ const encoded = tokenizer.encode(text)
157
+ if (encoded.length <= tokenLimit) {
158
+ return text
159
+ }
160
+
161
+ const suffixTokens = tokenizer.encode(suffix).length
162
+ if (tokenLimit <= suffixTokens) {
163
+ return tokenizer.decode(encoded.slice(0, tokenLimit)).trimEnd()
164
+ }
165
+
166
+ const contentTokenLimit = tokenLimit - suffixTokens
167
+ const clipped = tokenizer.decode(encoded.slice(0, contentTokenLimit)).trimEnd()
168
+ if (!clipped) {
169
+ return tokenizer.decode(encoded.slice(0, tokenLimit)).trimEnd()
170
+ }
171
+
172
+ return `${clipped}${suffix}`
173
+ }
@@ -0,0 +1,264 @@
1
+ import type { ChangedFile } from "../git"
2
+
3
+ export type ContextSignals = {
4
+ touchedFiles: number
5
+ newFiles: number
6
+ modifiedFiles: number
7
+ deletedFiles: number
8
+ renamedFiles: number
9
+ addedLines: number
10
+ removedLines: number
11
+ docsFiles: number
12
+ testFiles: number
13
+ configFiles: number
14
+ }
15
+
16
+ export type BehaviorCues = {
17
+ addedConditions: string[]
18
+ removedConditions: string[]
19
+ addedGuards: string[]
20
+ removedGuards: string[]
21
+ addedCalls: string[]
22
+ removedCalls: string[]
23
+ }
24
+
25
+ export function updateStatusSignals(signals: ContextSignals, file: ChangedFile | undefined): void {
26
+ if (!file) return
27
+ if (file.untracked) {
28
+ signals.newFiles += 1
29
+ return
30
+ }
31
+
32
+ const statuses = [file.indexStatus, file.worktreeStatus]
33
+ if (statuses.includes("D")) {
34
+ signals.deletedFiles += 1
35
+ return
36
+ }
37
+ if (statuses.includes("R")) {
38
+ signals.renamedFiles += 1
39
+ return
40
+ }
41
+ if (statuses.includes("A")) {
42
+ signals.newFiles += 1
43
+ return
44
+ }
45
+ signals.modifiedFiles += 1
46
+ }
47
+
48
+ export function updatePathCategorySignals(signals: ContextSignals, path: string): void {
49
+ const normalized = path.toLowerCase()
50
+ if (isDocsPath(normalized)) signals.docsFiles += 1
51
+ if (isTestPath(normalized)) signals.testFiles += 1
52
+ if (isConfigPath(normalized)) signals.configFiles += 1
53
+ }
54
+
55
+ export function analyzeDiff(diff: string): { addedLines: number; removedLines: number } {
56
+ let addedLines = 0
57
+ let removedLines = 0
58
+
59
+ for (const line of diff.split("\n")) {
60
+ if (line.startsWith("+++") || line.startsWith("---")) continue
61
+ if (line.startsWith("+")) {
62
+ addedLines += 1
63
+ continue
64
+ }
65
+ if (line.startsWith("-")) {
66
+ removedLines += 1
67
+ }
68
+ }
69
+
70
+ return { addedLines, removedLines }
71
+ }
72
+
73
+ export function collectBehaviorCues(diff: string): BehaviorCues {
74
+ const addedConditions = new Set<string>()
75
+ const removedConditions = new Set<string>()
76
+ const addedGuards = new Set<string>()
77
+ const removedGuards = new Set<string>()
78
+ const addedCalls = new Set<string>()
79
+ const removedCalls = new Set<string>()
80
+
81
+ for (const line of diff.split("\n")) {
82
+ if (
83
+ line.startsWith("+++") ||
84
+ line.startsWith("---") ||
85
+ line.startsWith("@@") ||
86
+ line.startsWith("# ")
87
+ ) {
88
+ continue
89
+ }
90
+
91
+ const isAdded = line.startsWith("+")
92
+ const isRemoved = line.startsWith("-")
93
+ if (!isAdded && !isRemoved) {
94
+ continue
95
+ }
96
+
97
+ const content = line.slice(1).trim()
98
+ if (!content) {
99
+ continue
100
+ }
101
+
102
+ const condition = extractConditionCue(content)
103
+ if (condition) {
104
+ if (isAdded) {
105
+ addedConditions.add(condition)
106
+ } else {
107
+ removedConditions.add(condition)
108
+ }
109
+ }
110
+
111
+ const guard = extractGuardCue(content)
112
+ if (guard) {
113
+ if (isAdded) {
114
+ addedGuards.add(guard)
115
+ } else {
116
+ removedGuards.add(guard)
117
+ }
118
+ }
119
+
120
+ const call = extractCallCue(content)
121
+ if (call) {
122
+ if (isAdded) {
123
+ addedCalls.add(call)
124
+ } else {
125
+ removedCalls.add(call)
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ addedConditions: Array.from(addedConditions),
132
+ removedConditions: Array.from(removedConditions),
133
+ addedGuards: Array.from(addedGuards),
134
+ removedGuards: Array.from(removedGuards),
135
+ addedCalls: Array.from(addedCalls),
136
+ removedCalls: Array.from(removedCalls),
137
+ }
138
+ }
139
+
140
+ export function aggregateBehaviorCues(cuesList: BehaviorCues[]): BehaviorCues {
141
+ const addedConditions = new Set<string>()
142
+ const removedConditions = new Set<string>()
143
+ const addedGuards = new Set<string>()
144
+ const removedGuards = new Set<string>()
145
+ const addedCalls = new Set<string>()
146
+ const removedCalls = new Set<string>()
147
+
148
+ for (const cues of cuesList) {
149
+ for (const value of cues.addedConditions) {
150
+ addedConditions.add(value)
151
+ }
152
+ for (const value of cues.removedConditions) {
153
+ removedConditions.add(value)
154
+ }
155
+ for (const value of cues.addedGuards) {
156
+ addedGuards.add(value)
157
+ }
158
+ for (const value of cues.removedGuards) {
159
+ removedGuards.add(value)
160
+ }
161
+ for (const value of cues.addedCalls) {
162
+ addedCalls.add(value)
163
+ }
164
+ for (const value of cues.removedCalls) {
165
+ removedCalls.add(value)
166
+ }
167
+ }
168
+
169
+ return {
170
+ addedConditions: Array.from(addedConditions),
171
+ removedConditions: Array.from(removedConditions),
172
+ addedGuards: Array.from(addedGuards),
173
+ removedGuards: Array.from(removedGuards),
174
+ addedCalls: Array.from(addedCalls),
175
+ removedCalls: Array.from(removedCalls),
176
+ }
177
+ }
178
+
179
+ export function formatCueList(values: string[]): string {
180
+ if (values.length === 0) {
181
+ return "none"
182
+ }
183
+ return values.slice(0, 6).join(" | ")
184
+ }
185
+
186
+ function isDocsPath(path: string): boolean {
187
+ return (
188
+ path.endsWith(".md") ||
189
+ path.endsWith(".mdx") ||
190
+ path.includes("/docs/") ||
191
+ path.startsWith("docs/")
192
+ )
193
+ }
194
+
195
+ function isTestPath(path: string): boolean {
196
+ return (
197
+ path.includes("/test/") ||
198
+ path.includes("/tests/") ||
199
+ path.includes(".test.") ||
200
+ path.includes(".spec.")
201
+ )
202
+ }
203
+
204
+ function isConfigPath(path: string): boolean {
205
+ return (
206
+ path.endsWith(".json") ||
207
+ path.endsWith(".yaml") ||
208
+ path.endsWith(".yml") ||
209
+ path.endsWith(".toml") ||
210
+ path.endsWith(".ini") ||
211
+ path.endsWith("lock") ||
212
+ path.endsWith(".lock")
213
+ )
214
+ }
215
+
216
+ function extractConditionCue(line: string): string | null {
217
+ const inline = line.match(/^if\s*\((.*)\)\s*\{?$/)
218
+ if (inline) {
219
+ return normalizeCue(inline[1] ?? "")
220
+ }
221
+
222
+ if (/^.*\?.*:.*/.test(line)) {
223
+ return normalizeCue("ternary-condition")
224
+ }
225
+
226
+ return null
227
+ }
228
+
229
+ function extractGuardCue(line: string): string | null {
230
+ if (line.startsWith("return") || line.startsWith("throw")) {
231
+ return normalizeCue(line)
232
+ }
233
+ if (line.includes(".preventDefault(")) {
234
+ return "preventDefault()"
235
+ }
236
+ if (line.includes(".stopPropagation(")) {
237
+ return "stopPropagation()"
238
+ }
239
+ return null
240
+ }
241
+
242
+ function extractCallCue(line: string): string | null {
243
+ const match = line.match(/([A-Za-z_$][A-Za-z0-9_$.]*)\(([^()]*)\)/)
244
+ if (!match) {
245
+ return null
246
+ }
247
+
248
+ const callee = (match[1] ?? "").trim()
249
+ if (!callee) {
250
+ return null
251
+ }
252
+
253
+ const args = (match[2] ?? "").replace(/\s+/g, " ").trim()
254
+ const compactArgs = args.length > 40 ? `${args.slice(0, 37).trimEnd()}...` : args
255
+ return normalizeCue(`${callee}(${compactArgs})`)
256
+ }
257
+
258
+ function normalizeCue(value: string): string {
259
+ const compact = value.replace(/\s+/g, " ").trim()
260
+ if (compact.length <= 72) {
261
+ return compact
262
+ }
263
+ return `${compact.slice(0, 69).trimEnd()}...`
264
+ }