@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/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 [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
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. ✅ You can freely use, modify, and share this code
125
- 2. ✅ Must retain author attribution (NiceHello666) and link back to this repository
126
- 3. ❌ Cannot be used for commercial purposes (e.g., selling, as part of paid products)
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
- 本项目的许可受 [GNU General Public License v3.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
78
+ 本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
79
79
 
80
80
  **简单来说:**
81
- 1. ✅ 你可以自由使用、修改和分享此代码
82
- 2. ✅ 必须保留原作者署名(NiceHello666)并链接回此仓库
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",
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": "GPL-3.0-only",
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
- export function nudgeMessage(why) {
36
- return `[hncode] Your turn ended without a final answer: ${why}. `
37
- + 'This usually means the task is not finished. If the work IS complete, reply with a short '
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
- const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
288
- const keep = this.messages.slice(-keepN);
289
- const dropped = this.messages.slice(0, this.messages.length - keepN);
290
- this.messages = keep;
291
- // Ask the model to summarize what was trimmed so far, then prepend it
292
- // as a system message so the model retains the conversation arc.
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 (dropped.length > 0) {
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
- ...dropped,
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: keepN });
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
- if (nudges < MAX_NUDGES) {
367
- nudges++;
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
- // are parsed as root keys, not assigned to a table.
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
- return [`${key} = """`, ...body.split('\n'), '"""'];
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 [`${key} = "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`];
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
- // drop any existing [providers.<name>] block (and its key lines) so we replace it
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 = /^\s*\[providers\.([^\]]+)\]\s*$/.exec(ln);
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
- out.push(`[providers.${name}]`);
434
- if (base_url) out.push(`base_url = "${String(base_url).replace(/"/g, '\\"')}"`);
435
- if (api_key) out.push(`api_key = "${String(api_key).replace(/"/g, '\\"')}"`);
436
- if (protocol) out.push(`protocol = "${String(protocol).replace(/"/g, '\\"')}"`);
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\."([^"]+)"\]\s*$/;
488
+ const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
454
489
  for (const ln of lines) {
455
- const pm = /^\s*\[providers\.([^\]]+)\]\s*$/.exec(ln);
456
- if (pm) { skipping = pm[1] === name; if (skipping) continue; }
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
- const lines = [`[models."${key}"]`, `provider = "${provider}"`, `model = "${modelId}"`];
488
- if (opts.display_name) lines.push(`display_name = "${String(opts.display_name).replace(/"/g, '\\"')}"`);
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
+ }