lonny-agent 0.1.9 → 0.2.2
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/.kilo/plans/1780293725888-quick-wizard.md +62 -0
- package/.lonny/plan-web-cwd-status.md +38 -0
- package/README.md +63 -10
- package/dist/agent/compaction.d.ts +5 -0
- package/dist/agent/compaction.d.ts.map +1 -1
- package/dist/agent/compaction.js +5 -0
- package/dist/agent/compaction.js.map +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +39 -4
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.js +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.js +1 -1
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +18 -2
- package/dist/agent/session.js.map +1 -1
- package/dist/config/__tests__/context-window.test.d.ts +2 -0
- package/dist/config/__tests__/context-window.test.d.ts.map +1 -0
- package/dist/config/__tests__/context-window.test.js +80 -0
- package/dist/config/__tests__/context-window.test.js.map +1 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +211 -0
- package/dist/config/index.js.map +1 -1
- package/dist/pi-tui/tui.d.ts.map +1 -1
- package/dist/pi-tui/tui.js +7 -16
- package/dist/pi-tui/tui.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +254 -0
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/fetch.test.js +11 -9
- package/dist/tools/__tests__/fetch.test.js.map +1 -1
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +205 -41
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/install_superpowers.d.ts +3 -0
- package/dist/tools/install_superpowers.d.ts.map +1 -0
- package/dist/tools/install_superpowers.js +207 -0
- package/dist/tools/install_superpowers.js.map +1 -0
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +25 -1
- package/dist/tui/index.js.map +1 -1
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +65 -0
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +9 -0
- package/dist/web/public/index.html +6 -0
- package/dist/web/public/style.css +11 -0
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -0
- package/src/agent/prompt-builder.ts +40 -4
- package/src/agent/providers/google.ts +1 -1
- package/src/agent/providers/openai.ts +1 -1
- package/src/agent/session.ts +19 -2
- package/src/config/__tests__/context-window.test.ts +94 -0
- package/src/config/index.ts +243 -0
- package/src/pi-tui/tui.ts +8 -14
- package/src/tools/__tests__/edit.test.ts +283 -0
- package/src/tools/__tests__/fetch.test.ts +12 -10
- package/src/tools/edit.ts +225 -42
- package/src/tools/registry.ts +265 -265
- package/src/tui/index.ts +23 -1
- package/src/web/index.ts +71 -0
- package/src/web/public/app.js +9 -0
- package/src/web/public/index.html +6 -0
- package/src/web/public/style.css +11 -0
- package/.lonny/fix-new-command-session-cleanup.md +0 -65
- package/.lonny/tui-autocomplete-above.md +0 -62
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { getContextWindowForModel } from '../index.js'
|
|
3
|
+
|
|
4
|
+
describe('getContextWindowForModel', () => {
|
|
5
|
+
// 精确匹配测试
|
|
6
|
+
test('exact match with known model names', () => {
|
|
7
|
+
expect(getContextWindowForModel('doubao-seed-2.0-code')).toBe(256_000)
|
|
8
|
+
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
|
|
9
|
+
expect(getContextWindowForModel('qwen3.6-plus')).toBe(1_000_000)
|
|
10
|
+
expect(getContextWindowForModel('claude-sonnet-4.6')).toBe(1_000_000)
|
|
11
|
+
expect(getContextWindowForModel('gpt-5.5')).toBe(1_000_000)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// 大小写不敏感测试
|
|
15
|
+
test('case insensitive matching', () => {
|
|
16
|
+
expect(getContextWindowForModel('DOUBAO-SEED-2.0-CODE')).toBe(256_000)
|
|
17
|
+
expect(getContextWindowForModel('DeepSeek-V4-Pro')).toBe(1_000_000)
|
|
18
|
+
expect(getContextWindowForModel('QWEN3.6-PLUS')).toBe(1_000_000)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// 模糊匹配 - 含空格的变体
|
|
22
|
+
test('fuzzy match with space variants', () => {
|
|
23
|
+
expect(getContextWindowForModel('doubao seed 2.0 code')).toBe(256_000)
|
|
24
|
+
expect(getContextWindowForModel('deepseek v4 pro')).toBe(1_000_000)
|
|
25
|
+
expect(getContextWindowForModel('qwen 3.6 plus')).toBe(1_000_000)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// 模糊匹配 - 下划线与连字符变体
|
|
29
|
+
test('fuzzy match with underscore and hyphen variants', () => {
|
|
30
|
+
expect(getContextWindowForModel('doubao_seed_2.0_code')).toBe(256_000)
|
|
31
|
+
expect(getContextWindowForModel('deepseek_v4_pro')).toBe(1_000_000)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// 模糊匹配 - 含前缀和后缀的模型名
|
|
35
|
+
test('fuzzy match with prefixed and suffixed model names', () => {
|
|
36
|
+
expect(getContextWindowForModel('my-doubao-seed-2.0-code')).toBe(256_000)
|
|
37
|
+
expect(getContextWindowForModel('deepseek-v4-pro-variant')).toBe(1_000_000)
|
|
38
|
+
expect(getContextWindowForModel('qwen3.6-plus-prod')).toBe(1_000_000)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// 模糊匹配 - 关键词匹配(不完整的模型名)
|
|
42
|
+
test('fuzzy match with keyword matching (partial model names)', () => {
|
|
43
|
+
// 百万上下文模型
|
|
44
|
+
expect(getContextWindowForModel('deepseek-v4')).toBe(1_000_000)
|
|
45
|
+
expect(getContextWindowForModel('qwen3.6')).toBe(1_000_000)
|
|
46
|
+
expect(getContextWindowForModel('qwen3.7')).toBe(1_000_000)
|
|
47
|
+
expect(getContextWindowForModel('claude opus 4.7')).toBe(1_000_000)
|
|
48
|
+
expect(getContextWindowForModel('gemini 2.5')).toBe(1_000_000)
|
|
49
|
+
|
|
50
|
+
// 256K 上下文模型
|
|
51
|
+
expect(getContextWindowForModel('doubao-seed-2.0')).toBe(256_000)
|
|
52
|
+
expect(getContextWindowForModel('kimi-k2.5')).toBe(262_144) // 实际值
|
|
53
|
+
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144) // 实际值
|
|
54
|
+
|
|
55
|
+
// 200K 上下文模型
|
|
56
|
+
expect(getContextWindowForModel('minimax')).toBe(200_000)
|
|
57
|
+
expect(getContextWindowForModel('glm-5')).toBe(200_000)
|
|
58
|
+
expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
|
|
59
|
+
expect(getContextWindowForModel('claude-haiku')).toBe(200_000)
|
|
60
|
+
|
|
61
|
+
// 160K 上下文模型
|
|
62
|
+
expect(getContextWindowForModel('deepseek-v3')).toBe(160_000)
|
|
63
|
+
expect(getContextWindowForModel('deepseek-v3.2')).toBe(160_000)
|
|
64
|
+
|
|
65
|
+
// 128K 上下文模型
|
|
66
|
+
expect(getContextWindowForModel('gpt-4o')).toBe(128_000)
|
|
67
|
+
expect(getContextWindowForModel('gpt-4')).toBe(128_000)
|
|
68
|
+
expect(getContextWindowForModel('llama-3')).toBe(128_000)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// 未知模型名 - 兜底默认值
|
|
72
|
+
test('unknown model names return default 128K', () => {
|
|
73
|
+
expect(getContextWindowForModel('unknown-model')).toBe(128_000)
|
|
74
|
+
expect(getContextWindowForModel('random-model-name')).toBe(128_000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// 用户配置中常用的模型名
|
|
78
|
+
test('common model names from user configs', () => {
|
|
79
|
+
// 豆包模型
|
|
80
|
+
expect(getContextWindowForModel('Doubao-Seed-2.0-Code')).toBe(256_000)
|
|
81
|
+
|
|
82
|
+
// DeepSeek 模型
|
|
83
|
+
expect(getContextWindowForModel('deepseek-chat')).toBe(128_000)
|
|
84
|
+
expect(getContextWindowForModel('deepseek-reasoner')).toBe(128_000)
|
|
85
|
+
|
|
86
|
+
// Kimi 模型
|
|
87
|
+
expect(getContextWindowForModel('kimi-k2.5')).toBe(262_144) // 实际值
|
|
88
|
+
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144) // 实际值
|
|
89
|
+
|
|
90
|
+
// 通义千问
|
|
91
|
+
expect(getContextWindowForModel('qwen3.6-plus')).toBe(1_000_000)
|
|
92
|
+
expect(getContextWindowForModel('qwen3.7-max')).toBe(1_000_000)
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/config/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface Config {
|
|
|
8
8
|
mode: 'code' | 'plan' | 'ask'
|
|
9
9
|
provider: 'openai' | 'anthropic' | 'google' | 'ollama'
|
|
10
10
|
model: string
|
|
11
|
+
contextWindow: number
|
|
11
12
|
cwd: string
|
|
12
13
|
autoApprove: boolean
|
|
13
14
|
thinking?: boolean
|
|
@@ -19,6 +20,243 @@ export interface Config {
|
|
|
19
20
|
tavilyApiKey?: string
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* 模型上下文窗口大小映射表(基于 2026 年最新数据)
|
|
25
|
+
* 单位:tokens
|
|
26
|
+
*/
|
|
27
|
+
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
28
|
+
// Doubao 系列
|
|
29
|
+
'doubao-seed-2.0-code': 256_000,
|
|
30
|
+
'doubao-seed-2.0-pro': 256_000,
|
|
31
|
+
'doubao-seed-2.0-lite': 256_000,
|
|
32
|
+
'doubao-seed-2.0-mini': 256_000,
|
|
33
|
+
'doubao-seed-2.0-code-preview-260215': 256_000,
|
|
34
|
+
'doubao-seed-1.8': 256_000,
|
|
35
|
+
'doubao-seed-1.6': 256_000,
|
|
36
|
+
|
|
37
|
+
// DeepSeek 系列
|
|
38
|
+
'deepseek-v4-pro': 1_000_000,
|
|
39
|
+
'deepseek-v4-flash': 256_000,
|
|
40
|
+
'deepseek-v3.2': 160_000,
|
|
41
|
+
'deepseek-r1': 128_000,
|
|
42
|
+
'deepseek-chat': 128_000,
|
|
43
|
+
'deepseek-reasoner': 128_000,
|
|
44
|
+
|
|
45
|
+
// Kimi 系列
|
|
46
|
+
'kimi-k2.5': 262_144,
|
|
47
|
+
'kimi-k2.6': 262_144,
|
|
48
|
+
'kimi-k2-thinking': 256_000,
|
|
49
|
+
|
|
50
|
+
// MiniMax 系列
|
|
51
|
+
'minimax-m2.5': 200_000,
|
|
52
|
+
'minimax-m2.7': 200_000,
|
|
53
|
+
'minimax-m2': 205_000,
|
|
54
|
+
|
|
55
|
+
// GLM 系列
|
|
56
|
+
'glm-5': 200_000,
|
|
57
|
+
'glm-5.1': 200_000,
|
|
58
|
+
'glm-4.7-flashx': 200_000,
|
|
59
|
+
'glm-4-flash': 128_000,
|
|
60
|
+
'glm-4': 128_000,
|
|
61
|
+
|
|
62
|
+
// Qwen 系列
|
|
63
|
+
'qwen3.6-plus': 1_000_000,
|
|
64
|
+
'qwen3.7-max': 1_000_000,
|
|
65
|
+
'qwen3-max': 256_000,
|
|
66
|
+
'qwen3.5-plus': 1_000_000,
|
|
67
|
+
'qwen-flash': 1_000_000,
|
|
68
|
+
|
|
69
|
+
// Claude 系列
|
|
70
|
+
'claude-opus-4.7': 1_000_000,
|
|
71
|
+
'claude-opus-4.6': 1_000_000,
|
|
72
|
+
'claude-sonnet-4.6': 1_000_000,
|
|
73
|
+
'claude-haiku-4.5': 200_000,
|
|
74
|
+
|
|
75
|
+
// GPT 系列
|
|
76
|
+
'gpt-5.5': 1_000_000,
|
|
77
|
+
'gpt-5.4': 1_000_000,
|
|
78
|
+
'gpt-5.1': 1_000_000,
|
|
79
|
+
'gpt-5.2-codex': 512_000,
|
|
80
|
+
'gpt-4.1': 1_000_000,
|
|
81
|
+
'gpt-4.1-mini': 1_000_000,
|
|
82
|
+
'gpt-4o': 128_000,
|
|
83
|
+
'gpt-4': 128_000,
|
|
84
|
+
|
|
85
|
+
// Gemini 系列
|
|
86
|
+
'gemini-2.5-pro': 1_000_000,
|
|
87
|
+
'gemini-2.5-flash': 1_000_000,
|
|
88
|
+
'gemini-3.1-pro': 1_000_000,
|
|
89
|
+
'gemini-3.1-flash': 1_000_000,
|
|
90
|
+
|
|
91
|
+
// Llama 系列
|
|
92
|
+
'llama-4-maverick': 1_000_000,
|
|
93
|
+
'llama-4-scout': 10_000_000,
|
|
94
|
+
'llama-3.1': 128_000,
|
|
95
|
+
'llama-3': 128_000,
|
|
96
|
+
|
|
97
|
+
// Mistral 系列
|
|
98
|
+
'mistral-small-3.1': 128_000,
|
|
99
|
+
'mistral-small-3.2': 128_000,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 模糊匹配规则
|
|
104
|
+
*/
|
|
105
|
+
interface FuzzyMatchRule {
|
|
106
|
+
keywords: string[]
|
|
107
|
+
windowSize: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 模糊匹配规则列表(从高优先级到低优先级)
|
|
112
|
+
*/
|
|
113
|
+
const fuzzyMatchRules: FuzzyMatchRule[] = [
|
|
114
|
+
// 首先匹配 128K 模型(防止 gpt-4 匹配到 gpt-5 规则)
|
|
115
|
+
{
|
|
116
|
+
keywords: [
|
|
117
|
+
'gpt-4o',
|
|
118
|
+
'gpt 4o',
|
|
119
|
+
'gpt-4',
|
|
120
|
+
'gpt 4',
|
|
121
|
+
'llama-3',
|
|
122
|
+
'llama 3',
|
|
123
|
+
'mistral-small',
|
|
124
|
+
'mistral small',
|
|
125
|
+
'glm-4-flash',
|
|
126
|
+
'glm 4 flash',
|
|
127
|
+
'glm-4',
|
|
128
|
+
],
|
|
129
|
+
windowSize: 128_000,
|
|
130
|
+
},
|
|
131
|
+
// 百万上下文模型 - 更高优先级(更精确的关键词)
|
|
132
|
+
{
|
|
133
|
+
keywords: [
|
|
134
|
+
'deepseek-v4',
|
|
135
|
+
'deepseek v4',
|
|
136
|
+
'qwen3.6',
|
|
137
|
+
'qwen 3.6',
|
|
138
|
+
'qwen3.7',
|
|
139
|
+
'qwen 3.7',
|
|
140
|
+
'qwen3.5',
|
|
141
|
+
'qwen 3.5',
|
|
142
|
+
'qwen-flash',
|
|
143
|
+
'qwen flash',
|
|
144
|
+
],
|
|
145
|
+
windowSize: 1_000_000,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
keywords: [
|
|
149
|
+
'claude-opus',
|
|
150
|
+
'claude sonnet',
|
|
151
|
+
'claude-sonnet',
|
|
152
|
+
'gpt-5.',
|
|
153
|
+
'gpt 5',
|
|
154
|
+
'gpt-4.1',
|
|
155
|
+
'gpt 4.1',
|
|
156
|
+
'gemini-2.5',
|
|
157
|
+
'gemini 2.5',
|
|
158
|
+
'gemini-3.1',
|
|
159
|
+
'gemini 3.1',
|
|
160
|
+
'llama-4',
|
|
161
|
+
'llama 4',
|
|
162
|
+
],
|
|
163
|
+
windowSize: 1_000_000,
|
|
164
|
+
},
|
|
165
|
+
// 256K 上下文模型
|
|
166
|
+
{
|
|
167
|
+
keywords: [
|
|
168
|
+
'doubao-seed-2.0',
|
|
169
|
+
'doubao seed 2.0',
|
|
170
|
+
'doubao-seed-1',
|
|
171
|
+
'doubao seed 1',
|
|
172
|
+
'kimi-k2',
|
|
173
|
+
'kimi k2',
|
|
174
|
+
'kimi-k2.5',
|
|
175
|
+
'kimi k2.5',
|
|
176
|
+
'kimi-k2.6',
|
|
177
|
+
'kimi k2.6',
|
|
178
|
+
'qwen3',
|
|
179
|
+
'qwen 3',
|
|
180
|
+
'qwen3-max',
|
|
181
|
+
'qwen3 max',
|
|
182
|
+
],
|
|
183
|
+
windowSize: 256_000,
|
|
184
|
+
},
|
|
185
|
+
// 200K 上下文模型
|
|
186
|
+
{
|
|
187
|
+
keywords: [
|
|
188
|
+
'minimax',
|
|
189
|
+
'glm-5',
|
|
190
|
+
'glm 5',
|
|
191
|
+
'glm-5.1',
|
|
192
|
+
'glm 5.1',
|
|
193
|
+
'glm-4.7',
|
|
194
|
+
'glm 4.7',
|
|
195
|
+
'claude-haiku',
|
|
196
|
+
'claude haiku',
|
|
197
|
+
],
|
|
198
|
+
windowSize: 200_000,
|
|
199
|
+
},
|
|
200
|
+
// 160K 上下文模型
|
|
201
|
+
{
|
|
202
|
+
keywords: ['deepseek-v3', 'deepseek v3', 'deepseek-v3.2', 'deepseek v3.2'],
|
|
203
|
+
windowSize: 160_000,
|
|
204
|
+
},
|
|
205
|
+
// 最后匹配通用关键词(deepseek)
|
|
206
|
+
{
|
|
207
|
+
keywords: ['deepseek'],
|
|
208
|
+
windowSize: 128_000,
|
|
209
|
+
},
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 根据模型名称获取上下文窗口大小(改进的模糊匹配)
|
|
214
|
+
* @param modelName 模型名称(不区分大小写)
|
|
215
|
+
* @returns 上下文窗口大小(tokens)
|
|
216
|
+
*/
|
|
217
|
+
export function getContextWindowForModel(modelName: string): number {
|
|
218
|
+
// 处理空字符串
|
|
219
|
+
if (!modelName) {
|
|
220
|
+
return 128_000
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const normalizedModelName = modelName.toLowerCase().replace(/[-_]/g, ' ')
|
|
224
|
+
|
|
225
|
+
// 1. 精确匹配(原始键名)
|
|
226
|
+
if (MODEL_CONTEXT_WINDOWS[modelName]) {
|
|
227
|
+
return MODEL_CONTEXT_WINDOWS[modelName]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2. 精确匹配(小写键名)
|
|
231
|
+
if (MODEL_CONTEXT_WINDOWS[modelName.toLowerCase()]) {
|
|
232
|
+
return MODEL_CONTEXT_WINDOWS[modelName.toLowerCase()]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3. 前缀匹配(支持部分匹配)
|
|
236
|
+
for (const [pattern, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
|
|
237
|
+
const normalizedPattern = pattern.toLowerCase().replace(/[-_]/g, ' ')
|
|
238
|
+
if (
|
|
239
|
+
normalizedModelName.includes(normalizedPattern) ||
|
|
240
|
+
normalizedPattern.includes(normalizedModelName)
|
|
241
|
+
) {
|
|
242
|
+
return size
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 4. 模糊匹配(关键词匹配)- 按照规则顺序(优先级)处理
|
|
247
|
+
for (const rule of fuzzyMatchRules) {
|
|
248
|
+
for (const keyword of rule.keywords) {
|
|
249
|
+
const normalizedKeyword = keyword.toLowerCase().replace(/[-_]/g, ' ')
|
|
250
|
+
if (normalizedModelName.includes(normalizedKeyword)) {
|
|
251
|
+
return rule.windowSize
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 5. 兜底默认值
|
|
257
|
+
return 128_000
|
|
258
|
+
}
|
|
259
|
+
|
|
22
260
|
interface JsonConfig {
|
|
23
261
|
apiKey?: string
|
|
24
262
|
baseUrl?: string
|
|
@@ -29,6 +267,7 @@ interface JsonConfig {
|
|
|
29
267
|
autoApprove?: boolean
|
|
30
268
|
enableCache?: boolean
|
|
31
269
|
strictTools?: boolean
|
|
270
|
+
contextWindow?: number
|
|
32
271
|
temperature?: number
|
|
33
272
|
maxTokens?: number
|
|
34
273
|
tavilyApiKey?: string
|
|
@@ -69,6 +308,9 @@ export function loadConfig(options?: {
|
|
|
69
308
|
// Auto-enable cache for DeepSeek models unless explicitly disabled
|
|
70
309
|
const enableCache = jsonConfig.enableCache ?? (isDeepSeekModel(model, baseUrl) || undefined)
|
|
71
310
|
|
|
311
|
+
// 根据模型名称获取上下文窗口大小,如果配置中有则优先使用配置值
|
|
312
|
+
const contextWindow = jsonConfig.contextWindow || getContextWindowForModel(model)
|
|
313
|
+
|
|
72
314
|
return {
|
|
73
315
|
apiKey: jsonConfig.apiKey || '',
|
|
74
316
|
baseUrl,
|
|
@@ -79,6 +321,7 @@ export function loadConfig(options?: {
|
|
|
79
321
|
| 'ollama',
|
|
80
322
|
mode: options?.mode || 'code',
|
|
81
323
|
model,
|
|
324
|
+
contextWindow,
|
|
82
325
|
cwd: options?.cwd || process.cwd(),
|
|
83
326
|
autoApprove: options?.autoApprove ?? jsonConfig.autoApprove ?? false,
|
|
84
327
|
thinking: jsonConfig.thinking,
|
package/src/pi-tui/tui.ts
CHANGED
|
@@ -1351,26 +1351,20 @@ export class TUI extends Container {
|
|
|
1351
1351
|
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1))
|
|
1352
1352
|
const targetCol = Math.max(0, cursorPos.col)
|
|
1353
1353
|
|
|
1354
|
-
//
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1354
|
+
// Use absolute cursor positioning (CUP) to avoid scroll artifacts.
|
|
1355
|
+
// Relative movement (\x1b[NB/\x1b[NA) with a stale baseline can
|
|
1356
|
+
// cause the terminal to scroll, inserting blank lines, especially
|
|
1357
|
+
// when the editor overlay cursor is far from the rendered content.
|
|
1358
|
+
let buffer = `\x1b[${targetRow + 1};${targetCol + 1}H`
|
|
1359
|
+
|
|
1360
|
+
if (this.showHardwareCursor) {
|
|
1361
|
+
buffer += '\x1b[?25h'
|
|
1361
1362
|
}
|
|
1362
|
-
// Move to absolute column (1-indexed)
|
|
1363
|
-
buffer += `\x1b[${targetCol + 1}G`
|
|
1364
1363
|
|
|
1365
1364
|
if (buffer) {
|
|
1366
1365
|
this.terminal.write(buffer)
|
|
1367
1366
|
}
|
|
1368
1367
|
|
|
1369
1368
|
this.hardwareCursorRow = targetRow
|
|
1370
|
-
if (this.showHardwareCursor) {
|
|
1371
|
-
this.terminal.showCursor()
|
|
1372
|
-
} else {
|
|
1373
|
-
this.terminal.hideCursor()
|
|
1374
|
-
}
|
|
1375
1369
|
}
|
|
1376
1370
|
}
|
|
@@ -86,6 +86,134 @@ describe('edit tool', () => {
|
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
+
describe('validation', () => {
|
|
90
|
+
beforeAll(() => {
|
|
91
|
+
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'test content\n')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects edit missing old_string', async () => {
|
|
95
|
+
const r = await tool().execute({
|
|
96
|
+
edits: [{ file_path: 'a.txt', new_string: 'replacement' }],
|
|
97
|
+
})
|
|
98
|
+
expect(r.success).toBe(false)
|
|
99
|
+
expect(r.error).toContain('missing')
|
|
100
|
+
expect(r.error).toContain('old_string')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('rejects edit missing new_string', async () => {
|
|
104
|
+
const r = await tool().execute({
|
|
105
|
+
edits: [{ file_path: 'a.txt', old_string: 'original' }],
|
|
106
|
+
})
|
|
107
|
+
expect(r.success).toBe(false)
|
|
108
|
+
expect(r.error).toContain('missing')
|
|
109
|
+
expect(r.error).toContain('new_string')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rejects edit missing file_path', async () => {
|
|
113
|
+
const r = await tool().execute({
|
|
114
|
+
edits: [{ old_string: 'a', new_string: 'b' }],
|
|
115
|
+
})
|
|
116
|
+
expect(r.success).toBe(false)
|
|
117
|
+
expect(r.error).toContain('missing')
|
|
118
|
+
expect(r.error).toContain('file_path')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reports all missing fields in one error', async () => {
|
|
122
|
+
const r = await tool().execute({
|
|
123
|
+
edits: [
|
|
124
|
+
{ file_path: 'a.txt', old_string: 'a', new_string: 'b' },
|
|
125
|
+
{ file_path: 'a.txt', new_string: 'c' },
|
|
126
|
+
{ file_path: 'a.txt', old_string: 'd' },
|
|
127
|
+
],
|
|
128
|
+
})
|
|
129
|
+
expect(r.success).toBe(false)
|
|
130
|
+
// Should report BOTH errors, not just the first
|
|
131
|
+
expect(r.error).toContain('2 of 3')
|
|
132
|
+
expect(r.error).toContain('edit #2')
|
|
133
|
+
expect(r.error).toContain('edit #3')
|
|
134
|
+
// Should mention not to split across edits
|
|
135
|
+
expect(r.error).toContain('COMPLETE')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('reports which fields each malformed edit has', async () => {
|
|
139
|
+
const r = await tool().execute({
|
|
140
|
+
edits: [{ file_path: 'a.txt', new_string: 'c' }],
|
|
141
|
+
})
|
|
142
|
+
expect(r.success).toBe(false)
|
|
143
|
+
expect(r.error).toContain('has: file_path, new_string')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('includes rawInput in the error message', async () => {
|
|
147
|
+
const r = await tool().execute({
|
|
148
|
+
edits: [{ file_path: 'a.txt', new_string: 'c' }],
|
|
149
|
+
})
|
|
150
|
+
expect(r.success).toBe(false)
|
|
151
|
+
expect(r.error).toContain('a.txt')
|
|
152
|
+
expect(r.error).toContain('new_string')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('auto-correction', () => {
|
|
157
|
+
it('wraps top-level file_path/old_string/new_string into edits array', async () => {
|
|
158
|
+
const r = await tool().execute({
|
|
159
|
+
file_path: 'nonexistent.ts',
|
|
160
|
+
old_string: 'x',
|
|
161
|
+
new_string: 'y',
|
|
162
|
+
})
|
|
163
|
+
// Should be auto-corrected so the error is about file not found, not about missing edits
|
|
164
|
+
expect(r.success).toBe(false)
|
|
165
|
+
expect(r.error).toContain('not found')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('wraps flat file_path + old_string (missing new_string) with default empty new_string', async () => {
|
|
169
|
+
const r = await tool().execute({
|
|
170
|
+
file_path: path.join(tmpDir, 'new.txt'),
|
|
171
|
+
old_string: '',
|
|
172
|
+
})
|
|
173
|
+
// Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
|
|
174
|
+
// This creates a file with empty content
|
|
175
|
+
expect(r.success).toBe(true)
|
|
176
|
+
expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true)
|
|
177
|
+
expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('')
|
|
178
|
+
fs.unlinkSync(path.join(tmpDir, 'new.txt'))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('handles edits passed directly as an array (not wrapped)', async () => {
|
|
182
|
+
// @ts-expect-error — runtime accepts array, TS expects object
|
|
183
|
+
const r = await tool().execute([
|
|
184
|
+
{ file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
|
|
185
|
+
])
|
|
186
|
+
expect(r.success).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('handles file_path + new_string (no old_string) at top level as create', async () => {
|
|
190
|
+
const r = await tool().execute({
|
|
191
|
+
file_path: path.join(tmpDir, 'auto-create.txt'),
|
|
192
|
+
new_string: 'auto created',
|
|
193
|
+
})
|
|
194
|
+
expect(r.success).toBe(true)
|
|
195
|
+
expect(fs.readFileSync(path.join(tmpDir, 'auto-create.txt'), 'utf8')).toBe('auto created')
|
|
196
|
+
fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('handles only file_path at top level as empty file creation', async () => {
|
|
200
|
+
const r = await tool().execute({
|
|
201
|
+
file_path: path.join(tmpDir, 'empty-file.txt'),
|
|
202
|
+
})
|
|
203
|
+
expect(r.success).toBe(true)
|
|
204
|
+
expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true)
|
|
205
|
+
expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('')
|
|
206
|
+
fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('rejects nonsensical top-level keys with helpful error', async () => {
|
|
210
|
+
const r = await tool().execute({ foo: 'bar' })
|
|
211
|
+
expect(r.success).toBe(false)
|
|
212
|
+
expect(r.error).toContain('edits')
|
|
213
|
+
expect(r.error).toContain('array')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
89
217
|
describe('batch mode', () => {
|
|
90
218
|
beforeAll(() => {
|
|
91
219
|
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n')
|
|
@@ -305,4 +433,159 @@ describe('edit tool', () => {
|
|
|
305
433
|
expect(fs.existsSync(path.join(createdDir(), 'rollback-1.ts'))).toBe(false)
|
|
306
434
|
})
|
|
307
435
|
})
|
|
436
|
+
|
|
437
|
+
describe('whitespace tolerance', () => {
|
|
438
|
+
const wsDir = () => path.join(tmpDir, 'ws-test')
|
|
439
|
+
const f = (name: string) => path.join(wsDir(), name)
|
|
440
|
+
|
|
441
|
+
beforeAll(() => {
|
|
442
|
+
fs.mkdirSync(wsDir(), { recursive: true })
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
const tool = () => createEditTool(applier, wsDir())
|
|
446
|
+
|
|
447
|
+
it('handles trailing space in file (AI omits trailing space)', async () => {
|
|
448
|
+
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
|
|
449
|
+
const r = await tool().execute({
|
|
450
|
+
edits: [
|
|
451
|
+
{ file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
452
|
+
],
|
|
453
|
+
})
|
|
454
|
+
expect(r.success).toBe(true)
|
|
455
|
+
expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
|
|
456
|
+
// Should indicate whitespace-normalized in output
|
|
457
|
+
expect(r.output).toContain('whitespace-normalized')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('handles trailing space in old_string (AI adds extra trailing space)', async () => {
|
|
461
|
+
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
|
|
462
|
+
const r = await tool().execute({
|
|
463
|
+
edits: [
|
|
464
|
+
{ file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
|
|
465
|
+
],
|
|
466
|
+
})
|
|
467
|
+
expect(r.success).toBe(true)
|
|
468
|
+
expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('handles leading space in file (AI omits leading indent)', async () => {
|
|
472
|
+
fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
|
|
473
|
+
const r = await tool().execute({
|
|
474
|
+
edits: [
|
|
475
|
+
{ file_path: 'leading.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
476
|
+
],
|
|
477
|
+
})
|
|
478
|
+
expect(r.success).toBe(true)
|
|
479
|
+
// Leading spaces in the original file become part of the matched block
|
|
480
|
+
// and are preserved in the result
|
|
481
|
+
expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('handles leading space in old_string (AI adds extra indent)', async () => {
|
|
485
|
+
fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
|
|
486
|
+
const r = await tool().execute({
|
|
487
|
+
edits: [
|
|
488
|
+
{ file_path: 'leading.txt', old_string: ' hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
489
|
+
],
|
|
490
|
+
})
|
|
491
|
+
expect(r.success).toBe(true)
|
|
492
|
+
// The file's leading spaces are part of the matched block, so they
|
|
493
|
+
// get replaced along with the rest of the matched text
|
|
494
|
+
expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('handles extra internal spaces in file (AI sends single space)', async () => {
|
|
498
|
+
fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
|
|
499
|
+
const r = await tool().execute({
|
|
500
|
+
edits: [
|
|
501
|
+
{ file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
|
|
502
|
+
],
|
|
503
|
+
})
|
|
504
|
+
expect(r.success).toBe(true)
|
|
505
|
+
expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('handles extra internal spaces in old_string (AI adds double space)', async () => {
|
|
509
|
+
fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
|
|
510
|
+
const r = await tool().execute({
|
|
511
|
+
edits: [
|
|
512
|
+
{ file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
|
|
513
|
+
],
|
|
514
|
+
})
|
|
515
|
+
expect(r.success).toBe(true)
|
|
516
|
+
expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('handles blank line with spaces matching empty blank line', async () => {
|
|
520
|
+
fs.writeFileSync(f('blank.txt'), 'foo\n \nbar\n')
|
|
521
|
+
const r = await tool().execute({
|
|
522
|
+
edits: [{ file_path: 'blank.txt', old_string: 'foo\n\nbar', new_string: 'FOO\n\nBAR' }],
|
|
523
|
+
})
|
|
524
|
+
expect(r.success).toBe(true)
|
|
525
|
+
// The blank line with spaces is part of the matched block and gets
|
|
526
|
+
// replaced entirely by the new_string's blank line
|
|
527
|
+
expect(fs.readFileSync(f('blank.txt'), 'utf8')).toBe('FOO\n\nBAR\n')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('handles mixed whitespace across multiple lines', async () => {
|
|
531
|
+
fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n')
|
|
532
|
+
const r = await tool().execute({
|
|
533
|
+
edits: [{ file_path: 'mixed.txt', old_string: 'a\nb\nc', new_string: 'A\nB\nC' }],
|
|
534
|
+
})
|
|
535
|
+
expect(r.success).toBe(true)
|
|
536
|
+
expect(fs.readFileSync(f('mixed.txt'), 'utf8')).toBe('A\nB\nC\n')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('still fails when text content differs (not just whitespace)', async () => {
|
|
540
|
+
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
|
|
541
|
+
const r = await tool().execute({
|
|
542
|
+
edits: [{ file_path: 'trailing.txt', old_string: 'hello\nmundo', new_string: 'x\ny' }],
|
|
543
|
+
})
|
|
544
|
+
expect(r.success).toBe(false)
|
|
545
|
+
expect(r.error).toContain('not found')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('still fails for completely wrong old_string', async () => {
|
|
549
|
+
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
|
|
550
|
+
const r = await tool().execute({
|
|
551
|
+
edits: [{ file_path: 'trailing.txt', old_string: 'zzz\nzzz', new_string: 'x\ny' }],
|
|
552
|
+
})
|
|
553
|
+
expect(r.success).toBe(false)
|
|
554
|
+
expect(r.error).toContain('not found')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('reports duplicate in whitespace-normalized mode', async () => {
|
|
558
|
+
fs.writeFileSync(f('duplicate.txt'), 'a \nb\na \nb\n')
|
|
559
|
+
const r = await tool().execute({
|
|
560
|
+
edits: [{ file_path: 'duplicate.txt', old_string: 'a\nb', new_string: 'X\nY' }],
|
|
561
|
+
})
|
|
562
|
+
expect(r.success).toBe(false)
|
|
563
|
+
expect(r.error).toContain('MULTIPLE times')
|
|
564
|
+
expect(r.error).toContain('whitespace-normalized')
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('prefers exact match when available', async () => {
|
|
568
|
+
fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n')
|
|
569
|
+
const r = await tool().execute({
|
|
570
|
+
edits: [
|
|
571
|
+
{ file_path: 'exact-prefer.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
|
|
572
|
+
],
|
|
573
|
+
})
|
|
574
|
+
expect(r.success).toBe(true)
|
|
575
|
+
expect(fs.readFileSync(f('exact-prefer.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
|
|
576
|
+
// Exact match should NOT have the whitespace-normalized note
|
|
577
|
+
expect(r.output).not.toContain('whitespace-normalized')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('provides context snippet when old_string not found', async () => {
|
|
581
|
+
fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
|
|
582
|
+
const r = await tool().execute({
|
|
583
|
+
edits: [{ file_path: 'trailing.txt', old_string: 'nonexistent_line_xyz', new_string: 'x' }],
|
|
584
|
+
})
|
|
585
|
+
expect(r.success).toBe(false)
|
|
586
|
+
expect(r.error).toContain('not found')
|
|
587
|
+
// Should contain a snippet with the file's actual content
|
|
588
|
+
expect(r.error).toContain('hello')
|
|
589
|
+
})
|
|
590
|
+
})
|
|
308
591
|
})
|