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.
- package/README.md +12 -3
- package/package.json +28 -20
- package/src/ai-commit/context-builder.ts +148 -0
- package/src/ai-commit/diff-budget.ts +173 -0
- package/src/ai-commit/diff-signals.ts +264 -0
- package/src/ai-commit/draft.ts +203 -0
- package/src/ai-commit/generate-ai-commit-summary.ts +67 -0
- package/src/ai-commit/index.ts +1 -0
- package/src/ai-commit/policy.ts +50 -0
- package/src/ai-commit/prompts.ts +56 -0
- package/src/ai-commit/tokenizer.ts +28 -0
- package/src/ai-commit-eval/args.ts +113 -0
- package/src/ai-commit-eval/config.ts +20 -0
- package/src/ai-commit-eval/evaluate-commit.ts +53 -0
- package/src/ai-commit-eval/evaluate-working-tree.ts +34 -0
- package/src/ai-commit-eval/output.ts +20 -0
- package/src/ai-commit-eval/replay-worktree.ts +74 -0
- package/src/ai-commit-eval/selection.ts +21 -0
- package/src/ai-commit-eval/types.ts +30 -0
- package/src/ai-commit-eval/utils.ts +34 -0
- package/src/ai-commit-eval.ts +62 -0
- package/src/app.tsx +103 -18
- package/src/config-file.ts +6 -2
- package/src/config-parser.ts +234 -0
- package/src/config.ts +9 -183
- package/src/git/client.ts +154 -0
- package/src/git/index.ts +8 -0
- package/src/git/parsers.ts +86 -0
- package/src/git/read-ops.ts +142 -0
- package/src/git/stash.ts +53 -0
- package/src/git/types.ts +54 -0
- package/src/git/write-ops.ts +135 -0
- package/src/git-process.ts +37 -3
- package/src/git-status-parser.ts +56 -24
- package/src/hooks/commit-history/options.ts +53 -0
- package/src/hooks/commit-history/use-commit-diff-loader.ts +109 -0
- package/src/hooks/commit-history/use-commit-files-loader.ts +118 -0
- package/src/hooks/git-tui-controller/tracking.ts +19 -0
- package/src/hooks/git-tui-keyboard/handle-dialog-keys.ts +258 -0
- package/src/hooks/git-tui-keyboard/handle-main-keys.ts +151 -0
- package/src/hooks/git-tui-keyboard/key-flags.ts +45 -0
- package/src/hooks/git-tui-keyboard/types.ts +62 -0
- package/src/hooks/selection-index.ts +14 -0
- package/src/hooks/use-branch-dialog-controller.ts +85 -23
- package/src/hooks/use-commit-history-controller.ts +202 -34
- package/src/hooks/use-git-tui-controller.ts +53 -63
- package/src/hooks/use-git-tui-effects.ts +22 -8
- package/src/hooks/use-git-tui-keyboard.ts +13 -283
- package/src/hooks/use-task-runner.ts +40 -0
- package/src/ui/components/branch-dialog.tsx +169 -35
- package/src/ui/components/commit-history/layout.ts +66 -0
- package/src/ui/components/commit-history-dialog.tsx +251 -43
- package/src/ui/components/diff-workspace.tsx +123 -26
- package/src/ui/components/footer-bar.tsx +30 -3
- package/src/ui/components/shortcuts-dialog.tsx +2 -2
- package/src/ui/components/splash-screen.tsx +24 -0
- package/src/ui/components/top-bar.tsx +17 -4
- package/src/ui/list-range.ts +12 -0
- package/src/ui/types.ts +4 -0
- package/src/ui/utils.ts +69 -4
- package/src/ai-commit.ts +0 -706
- 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
|
-
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
"
|
|
11
|
-
|
|
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
|
-
"
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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
|
+
}
|