@hncode/hncode 0.3.3 → 0.3.5
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/LICENSE +131 -674
- package/README.md +5 -4
- package/README_CN.md +5 -4
- package/package.json +2 -2
- package/src/agent-task.js +78 -0
- package/src/agent.js +75 -45
- package/src/config.js +63 -19
- package/src/git.js +170 -1
- package/src/index.js +27 -13
- package/src/llm.js +7 -3
- package/src/mcp.js +21 -13
- package/src/plugin.js +10 -8
- package/src/term.js +84 -46
- package/src/tui.js +659 -186
- package/src/tui.js.bak_20260921_191527 +9203 -0
- package/src/update.js +35 -14
package/README.md
CHANGED
|
@@ -118,12 +118,13 @@ Special thanks to the communities behind these projects for pushing the boundari
|
|
|
118
118
|
|
|
119
119
|
## 📄 License
|
|
120
120
|
|
|
121
|
-
This project is licensed under the [
|
|
121
|
+
This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
|
|
122
122
|
|
|
123
123
|
**In short:**
|
|
124
|
-
1. ✅
|
|
125
|
-
2. ✅
|
|
126
|
-
3. ❌
|
|
124
|
+
1. ✅ Free for noncommercial use — personal projects, learning, research, hobby work, and noncommercial organizations
|
|
125
|
+
2. ✅ You may modify and share it, provided you keep the license text and the noncommercial notice with any copy
|
|
126
|
+
3. ❌ No commercial use of any kind (selling it, shipping it inside a paid product, or using it to run a commercial service)
|
|
127
|
+
4. ℹ️ This is *not* a copyleft license: your own additions do not have to be published. Commercial use requires a separate license from the author.
|
|
127
128
|
|
|
128
129
|
---
|
|
129
130
|
|
package/README_CN.md
CHANGED
|
@@ -75,12 +75,13 @@ hncode 从以下几个优秀项目中汲取了灵感:
|
|
|
75
75
|
|
|
76
76
|
## 📄 许可证
|
|
77
77
|
|
|
78
|
-
本项目的许可受 [
|
|
78
|
+
本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
|
|
79
79
|
|
|
80
80
|
**简单来说:**
|
|
81
|
-
1. ✅
|
|
82
|
-
2. ✅
|
|
83
|
-
3. ❌
|
|
81
|
+
1. ✅ 非商业用途免费——个人项目、学习、研究、业余爱好,以及非商业组织
|
|
82
|
+
2. ✅ 可以修改和分享,但任何副本都必须附带许可证全文和「不得商用」的声明
|
|
83
|
+
3. ❌ 禁止任何形式的商业使用(出售、作为付费产品的一部分、或用于运营商业服务)
|
|
84
|
+
4. ℹ️ 这**不是** copyleft 许可:你自己新增的部分无需开源。商业使用需向作者单独获取授权。
|
|
84
85
|
|
|
85
86
|
---
|
|
86
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20"
|
|
24
24
|
},
|
|
25
|
-
"license": "
|
|
25
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
26
26
|
"author": "NiceHello666",
|
|
27
27
|
"keywords": [
|
|
28
28
|
"cli",
|
package/src/agent-task.js
CHANGED
|
@@ -176,3 +176,81 @@ export function statusColor(status) {
|
|
|
176
176
|
if (status === 'completed') return 'gray';
|
|
177
177
|
return 'red';
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ---- background-task transcript card (mirrors kimi-code) --------------------
|
|
181
|
+
// A background task reports its lifecycle as a one-line card in the transcript:
|
|
182
|
+
//
|
|
183
|
+
// ● agent task completed in background (review the diff)
|
|
184
|
+
// ✗ bash task failed in background (npm test · exit 1)
|
|
185
|
+
//
|
|
186
|
+
// `phase` drives the bullet colour (started / completed / failed); `headline`
|
|
187
|
+
// and the dim `detail` carry the rest. Ported from kimi-code's
|
|
188
|
+
// formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
|
|
189
|
+
// so the two tools read the same way.
|
|
190
|
+
|
|
191
|
+
const BG_DETAIL_MAX = 240;
|
|
192
|
+
|
|
193
|
+
function bgTruncate(value) {
|
|
194
|
+
if (value === undefined || value === null) return undefined;
|
|
195
|
+
const collapsed = String(value).trim().replace(/\s+/g, ' ');
|
|
196
|
+
if (!collapsed) return undefined;
|
|
197
|
+
return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function backgroundTaskPhase(status) {
|
|
201
|
+
if (status === 'running') return 'started';
|
|
202
|
+
if (status === 'completed') return 'completed';
|
|
203
|
+
return 'failed'; // failed / timed_out / killed / lost
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function bgSubject(kind) {
|
|
207
|
+
if (kind === 'agent') return 'agent task';
|
|
208
|
+
if (kind === 'question') return 'question task';
|
|
209
|
+
return 'bash task';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bgHeadline(status, kind) {
|
|
213
|
+
const subject = bgSubject(kind);
|
|
214
|
+
switch (status) {
|
|
215
|
+
case 'running': return `${subject} started in background`;
|
|
216
|
+
case 'completed': return `${subject} completed in background`;
|
|
217
|
+
case 'failed': return `${subject} failed in background`;
|
|
218
|
+
case 'timed_out': return `${subject} timed out`;
|
|
219
|
+
case 'killed': return `${subject} stopped`;
|
|
220
|
+
case 'lost': return `${subject} lost`;
|
|
221
|
+
default: return `${subject} ${status}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function bgDetail(task) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
const description = bgTruncate(task.description);
|
|
228
|
+
if (description !== undefined) parts.push(description);
|
|
229
|
+
|
|
230
|
+
if (task.status === 'completed' || task.status === 'failed') {
|
|
231
|
+
if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
|
|
232
|
+
parts.push(`exit ${task.exitCode}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (task.status === 'killed') {
|
|
236
|
+
const reason = bgTruncate(task.stopReason);
|
|
237
|
+
parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
|
|
238
|
+
}
|
|
239
|
+
if (task.status === 'failed') {
|
|
240
|
+
const reason = bgTruncate(task.stopReason);
|
|
241
|
+
if (reason !== undefined) parts.push(reason);
|
|
242
|
+
}
|
|
243
|
+
if (task.status === 'timed_out') parts.push('timed out');
|
|
244
|
+
if (task.status === 'lost') parts.push('session restarted before completion');
|
|
245
|
+
|
|
246
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build the card payload for a task snapshot.
|
|
250
|
+
export function backgroundTaskCard(task) {
|
|
251
|
+
return {
|
|
252
|
+
phase: backgroundTaskPhase(task.status),
|
|
253
|
+
headline: bgHeadline(task.status, task.kind),
|
|
254
|
+
detail: bgDetail(task),
|
|
255
|
+
};
|
|
256
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -24,20 +24,10 @@ function hashKey(s) {
|
|
|
24
24
|
return h.toString(36) + '-' + str.length.toString(36);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// ---- completion guard --------------------------------------------------------
|
|
28
|
-
// A turn is only complete when the model ends it with an actual answer. Models
|
|
29
|
-
// do stop mid-task: the stream ends right after a reasoning block, or with an
|
|
30
|
-
// empty assistant message, and the caller would report "done" for unfinished
|
|
31
|
-
// work. When that happens we tell the model what it did and give it more steps.
|
|
32
|
-
// Bounded, so a model that insists on stopping cannot loop forever.
|
|
33
|
-
export const MAX_NUDGES = 5;
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
+ 'summary of what changed and how you verified it. Otherwise keep going with the tools right '
|
|
39
|
-
+ 'now — do not stop silently.';
|
|
40
|
-
}
|
|
28
|
+
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
29
|
+
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
30
|
+
|
|
41
31
|
|
|
42
32
|
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
43
33
|
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
@@ -199,7 +189,6 @@ export class Agent {
|
|
|
199
189
|
let toolCalls = [];
|
|
200
190
|
let emittedEnd = false;
|
|
201
191
|
let thought = '';
|
|
202
|
-
let nudges = 0;
|
|
203
192
|
// decide whether running out of steps actually left the turn unfinished.
|
|
204
193
|
let hadToolCallsOnFinalStep = false;
|
|
205
194
|
|
|
@@ -252,8 +241,6 @@ export class Agent {
|
|
|
252
241
|
// No step cap: the loop runs until the model produces a final answer, the
|
|
253
242
|
// user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
|
|
254
243
|
// kept only for callers that explicitly request a bound (0/undefined = none).
|
|
255
|
-
// Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
|
|
256
|
-
// ability to interrupt, not from a fixed tool-round budget.
|
|
257
244
|
const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
|
|
258
245
|
for (let step = 0; step < stepLimit; step++) {
|
|
259
246
|
// Process any steer messages collected during the previous step
|
|
@@ -278,36 +265,90 @@ export class Agent {
|
|
|
278
265
|
// preserved rather than simply discarded.
|
|
279
266
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
280
267
|
let usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
281
|
-
if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
268
|
+
if (this.cfg.autoCompact !== false && maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
282
269
|
const before = usedNow;
|
|
283
270
|
// SHELL HOOK: PreCompact — a chance to back up the transcript before the
|
|
284
271
|
// older half is summarized away. Never blocks (the trim must go ahead).
|
|
285
272
|
await runShellHooks(this.hookConfig, 'PreCompact',
|
|
286
273
|
{ tokens: usedNow, messages: this.messages }, this.cfg.workspace);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
274
|
+
// Tell the TUI the trim is under way. Summarizing is a full model
|
|
275
|
+
// round-trip, so without this the UI sat silent for seconds mid-turn
|
|
276
|
+
// with no sign anything was happening.
|
|
277
|
+
this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
|
|
278
|
+
//
|
|
279
|
+
// ORDER: SUMMARIZE FIRST, THEN TRIM. The summary is generated from the
|
|
280
|
+
// FULL history so the summarizing model can see what just happened — the
|
|
281
|
+
// most recent exchange is what tells it what the session is doing now.
|
|
282
|
+
// Trimming first and summarizing the leftovers summarized only the OLD
|
|
283
|
+
// part and lost the information the summary needed most.
|
|
284
|
+
//
|
|
285
|
+
// The transcript fed to the summarizer is BOUNDED: the full history can
|
|
286
|
+
// itself exceed the window, in which case the call fails and the trim
|
|
287
|
+
// would drop everything with nothing to show for it. Keep the MOST RECENT
|
|
288
|
+
// messages within the budget — they carry the live state — and clip any
|
|
289
|
+
// single oversized message.
|
|
290
|
+
const SUMMARY_INPUT_CHARS = 120_000;
|
|
291
|
+
const perMsgCap = 8_000;
|
|
292
|
+
const summaryParts = [];
|
|
293
|
+
let summaryChars = 0;
|
|
294
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
295
|
+
const m = this.messages[i];
|
|
296
|
+
const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
|
|
297
|
+
const clipped = raw.length > perMsgCap ? raw.slice(0, perMsgCap) + '\n…[clipped]' : raw;
|
|
298
|
+
const line = `${m.role}: ${clipped}`;
|
|
299
|
+
if (summaryChars + line.length > SUMMARY_INPUT_CHARS) break;
|
|
300
|
+
summaryChars += line.length;
|
|
301
|
+
summaryParts.unshift(line);
|
|
302
|
+
}
|
|
293
303
|
let summary = '';
|
|
294
|
-
if (
|
|
304
|
+
if (this.messages.length > 0) {
|
|
295
305
|
const summaryPrompt = [
|
|
296
306
|
{ role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
|
|
297
|
-
|
|
307
|
+
{ role: 'user', content: summaryParts.join('\n\n') },
|
|
298
308
|
];
|
|
299
309
|
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
|
|
300
310
|
const llm = new LLM(cfgCopy);
|
|
301
311
|
try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
|
|
302
|
-
if (summary) {
|
|
303
|
-
this.messages = [{
|
|
304
|
-
role: 'system',
|
|
305
|
-
content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
|
|
306
|
-
}, ...this.messages];
|
|
307
|
-
}
|
|
308
312
|
}
|
|
313
|
+
// NOW trim. Keep under a TOKEN budget, not a message count: sizes vary by
|
|
314
|
+
// orders of magnitude (one Read result can dwarf fifty short turns), so a
|
|
315
|
+
// fixed 20%-of-messages slice could still leave the request above the
|
|
316
|
+
// threshold or throw away far more than needed. Walk backwards until the
|
|
317
|
+
// retained tail reaches ~30% of the window; always keep at least the last
|
|
318
|
+
// two so the newest exchange survives.
|
|
319
|
+
const keepBudget = Math.max(1, Math.floor(maxCtx * 0.3));
|
|
320
|
+
let keepFrom = this.messages.length;
|
|
321
|
+
let keptTokens = 0;
|
|
322
|
+
while (keepFrom > 0) {
|
|
323
|
+
const n = this.messages.length - keepFrom; // already-kept count
|
|
324
|
+
if (n >= 2 && keptTokens >= keepBudget) break;
|
|
325
|
+
const cand = this.messages[keepFrom - 1];
|
|
326
|
+
const t = estimateMessagesTokens([cand], this.cfg) - estimateMessagesTokens([], this.cfg);
|
|
327
|
+
// Always take at least the last two messages, however big they are.
|
|
328
|
+
if (n >= 2 && keptTokens + t > keepBudget) break;
|
|
329
|
+
keptTokens += t;
|
|
330
|
+
keepFrom--;
|
|
331
|
+
}
|
|
332
|
+
// BOUNDARY FIX: never let the kept slice START on a `tool` message whose
|
|
333
|
+
// assistant(toolCalls) was dropped — most APIs reject a tool result with
|
|
334
|
+
// no preceding call. Pull the boundary back over any leading tool rows.
|
|
335
|
+
while (keepFrom > 0 && keepFrom < this.messages.length && this.messages[keepFrom].role === 'tool') {
|
|
336
|
+
keepFrom--;
|
|
337
|
+
}
|
|
338
|
+
const keep = this.messages.slice(keepFrom);
|
|
339
|
+
const droppedCount = keepFrom;
|
|
340
|
+
// Final shape: SUMMARY + the most recent messages.
|
|
341
|
+
// ALWAYS prepend a marker, even when summarization failed. Silently
|
|
342
|
+
// dropping history left the model (and the user) with no idea that context
|
|
343
|
+
// was lost; a one-line note makes the gap explicit.
|
|
344
|
+
this.messages = [{
|
|
345
|
+
role: 'system',
|
|
346
|
+
content: summary
|
|
347
|
+
? `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of the conversation so far:\n\n${summary}`
|
|
348
|
+
: `[hncode] ${droppedCount} earlier message(s) were dropped to stay within the context window, and no summary could be generated. Their detail is no longer available.`,
|
|
349
|
+
}, ...keep];
|
|
309
350
|
usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
310
|
-
this.onEvent({ type: 'compacted', before, after: usedNow, kept:
|
|
351
|
+
this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
|
|
311
352
|
}
|
|
312
353
|
// Live context gauge: report the estimated size of the request about to be
|
|
313
354
|
// sent, so the TUI's context readout is refreshed at every step rather than
|
|
@@ -363,18 +404,8 @@ export class Agent {
|
|
|
363
404
|
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
364
405
|
continue;
|
|
365
406
|
} else if (!hasText) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const why = thought.trim()
|
|
369
|
-
? 'your last output was reasoning/thinking only, with no answer text'
|
|
370
|
-
: 'your final message was empty';
|
|
371
|
-
this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
|
|
372
|
-
const last = this.messages[this.messages.length - 1];
|
|
373
|
-
const body = nudgeMessage(why);
|
|
374
|
-
if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
|
|
375
|
-
else this.messages.push({ role: 'user', content: body });
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
407
|
+
// No tool calls and no answer text: the model stopped without saying
|
|
408
|
+
// anything. End the turn.
|
|
378
409
|
break;
|
|
379
410
|
} else {
|
|
380
411
|
// The model produced its answer. A steer typed WHILE that answer was
|
|
@@ -512,7 +543,6 @@ export class Agent {
|
|
|
512
543
|
this.onEvent({
|
|
513
544
|
type: 'incomplete',
|
|
514
545
|
reason: 'the step budget was exhausted while tools were still being called',
|
|
515
|
-
usedNudges: nudges,
|
|
516
546
|
});
|
|
517
547
|
}
|
|
518
548
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
package/src/config.js
CHANGED
|
@@ -286,6 +286,12 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
286
286
|
autoUpdate: process.env.HNCODE_AUTO_UPDATE
|
|
287
287
|
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
|
|
288
288
|
: (root.auto_update === true || root.auto_update === 'true'),
|
|
289
|
+
// AUTO-COMPACTION (/auto-compact): when true, the agent summarizes older
|
|
290
|
+
// history once a request reaches 85% of the model context. Default ON. Set
|
|
291
|
+
// `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
|
|
292
|
+
autoCompact: process.env.HNCODE_AUTO_COMPACT
|
|
293
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_COMPACT)
|
|
294
|
+
: !(root.auto_compact === false || root.auto_compact === 'false'),
|
|
289
295
|
// PROMPT CACHE (/cache): explicit cache breakpoints for long stable prefixes.
|
|
290
296
|
// Default ON — it only ever adds provider-recognised markers, and an
|
|
291
297
|
// unsupported field on a non-caching gateway is filtered out in cache.js.
|
|
@@ -377,7 +383,7 @@ export function setConfigString(key, value) {
|
|
|
377
383
|
const opensMultiline = /^"""|^'''/.test(rest) && !/^"""[\s\S]*"""\s*$/.test(rest);
|
|
378
384
|
if (opensMultiline) skipping = true;
|
|
379
385
|
if (!replaced && value !== '' && value != null) {
|
|
380
|
-
out.push(...renderTomlString(key, String(value)));
|
|
386
|
+
out.push(...renderTomlString(key, String(value)).split('\n'));
|
|
381
387
|
replaced = true;
|
|
382
388
|
}
|
|
383
389
|
continue;
|
|
@@ -386,9 +392,9 @@ export function setConfigString(key, value) {
|
|
|
386
392
|
}
|
|
387
393
|
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
388
394
|
if (!replaced && value !== '' && value != null) {
|
|
389
|
-
// Insert new root-level keys BEFORE the first [table] section so they
|
|
390
|
-
//
|
|
391
|
-
const rendered = renderTomlString(key, String(value));
|
|
395
|
+
// Insert new root-level keys BEFORE the first [table] section so they are
|
|
396
|
+
// parsed as root keys, not assigned to a table.
|
|
397
|
+
const rendered = renderTomlString(key, String(value)).split('\n');
|
|
392
398
|
if (firstTableIdx >= 0) {
|
|
393
399
|
out.splice(firstTableIdx, 0, '', ...rendered);
|
|
394
400
|
} else {
|
|
@@ -403,13 +409,26 @@ export function setConfigString(key, value) {
|
|
|
403
409
|
|
|
404
410
|
// Render `key = <value>` as TOML. Uses a multi-line basic string when the value
|
|
405
411
|
// contains newlines (readable, and TOML-valid), a plain one otherwise.
|
|
412
|
+
//
|
|
413
|
+
// Layout matters: TOML DISCARDS the newline immediately after `"""`, so the
|
|
414
|
+
// value begins on the following line and the closing `"""` must butt up against
|
|
415
|
+
// the last character. The previous form (""" + \n + body + \n + """) parsed back
|
|
416
|
+
// as `value + '\n'`, and because setConfigString writes back whatever it read,
|
|
417
|
+
// every read→write cycle appended another newline (a system prompt gained a
|
|
418
|
+
// blank line per edit).
|
|
419
|
+
//
|
|
420
|
+
// key = """
|
|
421
|
+
// <value, verbatim>
|
|
422
|
+
// """
|
|
406
423
|
function renderTomlString(key, value) {
|
|
407
424
|
if (value.includes('\n')) {
|
|
408
425
|
// Inside """…""" a backslash must be escaped; quotes are fine unless tripled.
|
|
409
426
|
const body = value.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
|
|
410
|
-
|
|
427
|
+
// `"""` then a newline (discarded by the parser), then the value, then the
|
|
428
|
+
// closing delimiter on the same line as the value's last line.
|
|
429
|
+
return [`${key} = """`, ...body.split('\n')].join('\n') + '"""';
|
|
411
430
|
}
|
|
412
|
-
return
|
|
431
|
+
return `${key} = "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
413
432
|
}
|
|
414
433
|
|
|
415
434
|
// Append (or replace) a [providers.<name>] table in config.toml without
|
|
@@ -419,21 +438,30 @@ export function addProvider(name, { base_url, api_key, protocol } = {}) {
|
|
|
419
438
|
let text = '';
|
|
420
439
|
try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
|
|
421
440
|
const lines = text.split(/\r?\n/);
|
|
422
|
-
//
|
|
441
|
+
// Drop any existing [providers.<name>] block (and its key lines) so it is
|
|
442
|
+
// replaced rather than duplicated. Accepts the BARE and QUOTED header forms
|
|
443
|
+
// (this function writes the quoted one) and unescapes before comparing.
|
|
444
|
+
const unq = (s) => String(s).replace(/\\(.)/g, '$1');
|
|
445
|
+
const provRe = /^\s*\[providers\.\s*(?:"((?:[^"\\]|\\.)*)"|([^\]]+))\s*\]\s*$/;
|
|
423
446
|
const out = [];
|
|
424
447
|
let skipping = false;
|
|
425
448
|
for (const ln of lines) {
|
|
426
|
-
const m =
|
|
427
|
-
if (m) { skipping = m[1] === name; if (skipping) continue; }
|
|
449
|
+
const m = provRe.exec(ln);
|
|
450
|
+
if (m) { skipping = unq(m[1] !== undefined ? m[1] : m[2]) === name; if (skipping) continue; }
|
|
428
451
|
else if (/^\s*\[/.test(ln)) skipping = false;
|
|
429
452
|
if (!skipping) out.push(ln);
|
|
430
453
|
}
|
|
431
454
|
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
432
455
|
out.push('');
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
456
|
+
// Escape backslashes BEFORE quotes (the other order double-escapes), and quote
|
|
457
|
+
// the table key too. Raw interpolation meant a provider name or URL holding a
|
|
458
|
+
// backslash or a quote wrote invalid TOML and broke the ENTIRE config file —
|
|
459
|
+
// every later launch failed until it was repaired by hand.
|
|
460
|
+
const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
461
|
+
out.push(`[providers.${q(name)}]`);
|
|
462
|
+
if (base_url) out.push(`base_url = ${q(base_url)}`);
|
|
463
|
+
if (api_key) out.push(`api_key = ${q(api_key)}`);
|
|
464
|
+
if (protocol) out.push(`protocol = ${q(protocol)}`);
|
|
437
465
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
438
466
|
fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
|
|
439
467
|
return file;
|
|
@@ -449,15 +477,25 @@ export function removeProvider(name) {
|
|
|
449
477
|
const lines = text.split(/\r?\n/);
|
|
450
478
|
const out = [];
|
|
451
479
|
let skipping = false;
|
|
480
|
+
// Table headers may be BARE (`[providers.p]`) or QUOTED (`[providers."p"]`) —
|
|
481
|
+
// addProvider now quotes them so a name containing a quote or backslash stays
|
|
482
|
+
// valid TOML. Match both and unescape before comparing: with the quoted form
|
|
483
|
+
// the old bare-only regex captured `"p"` (quotes included), `=== name` never
|
|
484
|
+
// held, and removeProvider silently deleted nothing.
|
|
485
|
+
const unq = (s) => String(s).replace(/\\(.)/g, '$1');
|
|
486
|
+
const provRe = /^\s*\[providers\.\s*(?:"((?:[^"\\]|\\.)*)"|([^\]]+))\s*\]\s*$/;
|
|
452
487
|
// A models key is `[models."provider/modelId"]`; match the provider prefix.
|
|
453
|
-
const modelRe = /^\s*\[models
|
|
488
|
+
const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
|
|
454
489
|
for (const ln of lines) {
|
|
455
|
-
const pm =
|
|
456
|
-
if (pm) {
|
|
490
|
+
const pm = provRe.exec(ln);
|
|
491
|
+
if (pm) {
|
|
492
|
+
skipping = unq(pm[1] !== undefined ? pm[1] : pm[2]) === name;
|
|
493
|
+
if (skipping) continue;
|
|
494
|
+
}
|
|
457
495
|
const mm = modelRe.exec(ln);
|
|
458
496
|
if (mm) {
|
|
459
497
|
// The model key starts with `<provider>/`; skip it if it is this provider's.
|
|
460
|
-
const keyProvider = mm[1].split('/')[0];
|
|
498
|
+
const keyProvider = unq(mm[1]).split('/')[0];
|
|
461
499
|
skipping = keyProvider === name;
|
|
462
500
|
if (skipping) continue;
|
|
463
501
|
} else if (/^\s*\[/.test(ln)) {
|
|
@@ -484,8 +522,14 @@ export function addModel(provider, modelId, opts = {}) {
|
|
|
484
522
|
try { text = fs.readFileSync(file, 'utf8'); } catch {}
|
|
485
523
|
const key = modelKey(provider, modelId);
|
|
486
524
|
if (new RegExp(`^\\s*\\[models\\."${escapeRe(key)}"\\]`, 'm').test(text)) return file; // already there
|
|
487
|
-
|
|
488
|
-
|
|
525
|
+
// EVERY interpolated string is escaped. These were written raw, so a model id
|
|
526
|
+
// (or provider) containing a quote or backslash produced malformed TOML and
|
|
527
|
+
// the ENTIRE config file stopped parsing — every later launch then failed
|
|
528
|
+
// until the file was repaired by hand. Provider ids and model ids come from
|
|
529
|
+
// remote APIs, so they are not trusted input.
|
|
530
|
+
const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
531
|
+
const lines = [`[models.${q(key)}]`, `provider = ${q(provider)}`, `model = ${q(modelId)}`];
|
|
532
|
+
if (opts.display_name) lines.push(`display_name = ${q(opts.display_name)}`);
|
|
489
533
|
// Persist the context window discovered from /v1/models so resolveConfig can
|
|
490
534
|
// size the context bar per model (not a global 128k).
|
|
491
535
|
if (opts.contextLength) lines.push(`context_length = ${Number(opts.contextLength)}`);
|
package/src/git.js
CHANGED
|
@@ -329,4 +329,173 @@ export function statuslineInfo(cwd) {
|
|
|
329
329
|
deletions: unstaged.deletions + staged.deletions,
|
|
330
330
|
changed: unstaged.files + staged.files,
|
|
331
331
|
};
|
|
332
|
-
}
|
|
332
|
+
}
|
|
333
|
+
// ---- diff / log / push / stash / rebase -------------------------------------
|
|
334
|
+
// Thin wrappers over `git` for the TUI's slash commands. Each returns
|
|
335
|
+
// { ok, ... } and never throws, so a command handler can just print the result.
|
|
336
|
+
|
|
337
|
+
// Working-tree diff. `staged` shows the index, `path` limits to one file.
|
|
338
|
+
// `stat` returns only the summary line (cheap); otherwise the patch, bounded so a
|
|
339
|
+
// huge change cannot flood the transcript.
|
|
340
|
+
export function diffText(cwd, opts = {}) {
|
|
341
|
+
const args = ['diff'];
|
|
342
|
+
if (opts.staged) args.push('--cached');
|
|
343
|
+
const paths = opts.path ? [opts.path] : [];
|
|
344
|
+
if (opts.stat) args.push('--stat');
|
|
345
|
+
const r = runGit(paths.length ? args.concat(['--', ...paths]) : args, cwd);
|
|
346
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git diff failed', text: '' };
|
|
347
|
+
let text = r.stdout;
|
|
348
|
+
if (text.length > 200 * 1024) text = text.slice(0, 200 * 1024) + '\n... [diff truncated]';
|
|
349
|
+
return { ok: true, text, empty: !text.trim(), error: null };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Commit history, one commit per line: `<short-sha> <date> <subject>`.
|
|
353
|
+
// `count` defaults to 15; `path` limits to a file's history.
|
|
354
|
+
export function logText(cwd, opts = {}) {
|
|
355
|
+
const n = Math.max(1, Math.min(500, Number(opts.count) || 15));
|
|
356
|
+
const args = ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'];
|
|
357
|
+
if (opts.path) args.push('--', opts.path);
|
|
358
|
+
const r = runGit(args, cwd);
|
|
359
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git log failed', text: '' };
|
|
360
|
+
return { ok: true, text: r.stdout.trim(), empty: !r.stdout.trim(), error: null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Push the current (or a named) branch. This is an OUTBOUND operation, so the
|
|
364
|
+
// caller must have confirmed first. `setUpstream` adds -u when the branch has no
|
|
365
|
+
// upstream yet. `force` is opt-in only.
|
|
366
|
+
export function push(cwd, opts = {}) {
|
|
367
|
+
const branch = String(opts.branch || '').trim();
|
|
368
|
+
const remote = String(opts.remote || 'origin').trim();
|
|
369
|
+
const args = ['push'];
|
|
370
|
+
if (opts.setUpstream) args.push('-u');
|
|
371
|
+
if (opts.force) args.push('--force-with-lease'); // safer than --force
|
|
372
|
+
args.push(remote);
|
|
373
|
+
if (branch) args.push(branch);
|
|
374
|
+
const r = runGit(args, cwd, { timeoutMs: 120_000 });
|
|
375
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git push failed', output: r.stdout.trim() };
|
|
376
|
+
return { ok: true, output: (r.stdout + r.stderr).trim(), error: null };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Does the branch have an upstream? Used to decide whether push needs -u.
|
|
380
|
+
export function hasUpstream(cwd, branch) {
|
|
381
|
+
const b = branch || currentBranch(cwd);
|
|
382
|
+
if (!b) return false;
|
|
383
|
+
const r = runGit(['rev-parse', '--abbrev-ref', `${b}@{upstream}`], cwd);
|
|
384
|
+
return r.ok;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Commits the local branch is ahead/behind its upstream, e.g. "ahead 2, behind 0".
|
|
388
|
+
export function aheadBehind(cwd, branch) {
|
|
389
|
+
const b = branch || currentBranch(cwd);
|
|
390
|
+
if (!b) return '';
|
|
391
|
+
const r = runGit(['rev-list', '--left-right', '--count', `${b}...${b}@{upstream}`], cwd);
|
|
392
|
+
if (!r.ok) return '';
|
|
393
|
+
const [ahead, behind] = r.stdout.trim().split(/\s+/);
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (Number(ahead)) parts.push(`ahead ${ahead}`);
|
|
396
|
+
if (Number(behind)) parts.push(`behind ${behind}`);
|
|
397
|
+
return parts.join(', ');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- stash ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
export function stashList(cwd) {
|
|
403
|
+
const r = runGit(['stash', 'list'], cwd);
|
|
404
|
+
return r.ok ? r.stdout.split('\n').filter(Boolean) : [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function stashPush(cwd, message) {
|
|
408
|
+
const args = ['stash', 'push'];
|
|
409
|
+
if (message) args.push('-m', String(message));
|
|
410
|
+
const r = runGit(args, cwd);
|
|
411
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
412
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash failed' };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function stashPop(cwd) {
|
|
416
|
+
const r = runGit(['stash', 'pop'], cwd);
|
|
417
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
418
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash pop failed' };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- rebase -----------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
// Rebase the current branch onto `onto`. Returns the git output; on conflict the
|
|
424
|
+
// caller shows git's own message (the user resolves it outside, or with a prompt).
|
|
425
|
+
export function rebase(cwd, onto) {
|
|
426
|
+
const target = String(onto || '').trim();
|
|
427
|
+
if (!target) return { ok: false, error: 'a target branch is required' };
|
|
428
|
+
const r = runGit(['rebase', target], cwd, { timeoutMs: 120_000 });
|
|
429
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'git rebase failed' };
|
|
430
|
+
return { ok: true, output: r.stdout.trim(), error: null };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- async variants (never block the event loop) ----------------------------
|
|
434
|
+
// `runGit` uses spawnSync: on the STARTUP path that freezes the TUI before its
|
|
435
|
+
// first paint, and a cold `git` was measured at ~80-100ms per call (×4 calls).
|
|
436
|
+
// The async form below runs the same command off the main thread, so the frame
|
|
437
|
+
// draws immediately and the badge fills in when the data arrives.
|
|
438
|
+
|
|
439
|
+
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
let child;
|
|
442
|
+
const timeout = opts.timeoutMs || 30_000;
|
|
443
|
+
try {
|
|
444
|
+
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
|
+
cwd: cwd || process.cwd(),
|
|
446
|
+
windowsHide: true,
|
|
447
|
+
env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' },
|
|
448
|
+
});
|
|
449
|
+
} catch (e) {
|
|
450
|
+
resolve({ ok: false, stdout: '', stderr: '', code: null, error: e.message });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let stdout = '';
|
|
454
|
+
let stderr = '';
|
|
455
|
+
let done = false;
|
|
456
|
+
const finish = (r) => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
|
|
457
|
+
const timer = setTimeout(() => {
|
|
458
|
+
try { child.kill(); } catch {}
|
|
459
|
+
finish({ ok: false, stdout: '', stderr: '', code: null, error: 'git timed out' });
|
|
460
|
+
}, timeout);
|
|
461
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
|
+
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
|
464
|
+
child.on('close', (code) => finish({ ok: code === 0, stdout, stderr, code, error: null }));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Async twin of statuslineInfo(). Same shape, same parser — only the four git
|
|
469
|
+
// invocations are concurrent instead of blocking. Each sub-call fails soft, so a
|
|
470
|
+
// non-repo (or a missing git) yields null exactly like the sync version.
|
|
471
|
+
export async function statuslineInfoAsync(cwd) {
|
|
472
|
+
const inside = await runGitAsync(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
473
|
+
if (!inside.ok) return null;
|
|
474
|
+
|
|
475
|
+
const stat = async (args) => {
|
|
476
|
+
const r = await runGitAsync(['diff', ...args, '--shortstat'], cwd);
|
|
477
|
+
if (!r.ok) return { files: 0, insertions: 0, deletions: 0 };
|
|
478
|
+
const s = r.stdout.trim();
|
|
479
|
+
const files = /(\d+) files? changed/.exec(s);
|
|
480
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(s);
|
|
481
|
+
const del = /(\d+) deletions?\(-\)/.exec(s);
|
|
482
|
+
return {
|
|
483
|
+
files: files ? Number(files[1]) : 0,
|
|
484
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
485
|
+
deletions: del ? Number(del[1]) : 0,
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const [branchRes, unstaged, staged] = await Promise.all([
|
|
490
|
+
runGitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
|
|
491
|
+
stat([]),
|
|
492
|
+
stat(['--cached']),
|
|
493
|
+
]);
|
|
494
|
+
const branch = branchRes.ok ? branchRes.stdout.trim() : '';
|
|
495
|
+
return {
|
|
496
|
+
branch: branch || '',
|
|
497
|
+
insertions: unstaged.insertions + staged.insertions,
|
|
498
|
+
deletions: unstaged.deletions + staged.deletions,
|
|
499
|
+
changed: unstaged.files + staged.files,
|
|
500
|
+
};
|
|
501
|
+
}
|