aegis-desktop 0.7.7 → 0.7.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.7.7",
4
+ "version": "0.7.8",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -29,7 +29,7 @@
29
29
  "icon": "node scripts/generate-icon.mjs",
30
30
  "dist": "npm run predist && electron-builder",
31
31
  "dist:dir": "npm run predist && electron-builder --dir",
32
- "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
32
+ "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/preset-fill.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/endpoints.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
33
33
  "test:shell": "node ../test/desktop-shell.mjs",
34
34
  "test:model": "node ../test/model-dispatch.mjs",
35
35
  "test:budget": "node ../test/budget.test.mjs",
@@ -43,7 +43,8 @@
43
43
  "test:preview-read": "node ../test/desktop-preview-read.test.mjs",
44
44
  "test:stream-policy": "node ../test/stream-policy.test.mjs",
45
45
  "test:renderer-dom": "node ../test/renderer-dom.test.mjs",
46
- "test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
46
+ "test:renderer-wiring": "node ../test/renderer-wiring.test.mjs",
47
+ "test:preset-fill": "node ../test/preset-fill.test.mjs"
47
48
  },
48
49
  "dependencies": {
49
50
  "electron": "^33.0.0",
package/renderer/app.js CHANGED
@@ -165,48 +165,53 @@ const EXPLORE_KEY = 'aegis.explore';
165
165
  // is only billable/routable through the pooled AEGIS Cloud class.
166
166
  const AUTONOMOUS_CLASS = 'aegis';
167
167
  const CUSTOM_CLASSES = new Set(['openai-compat', 'anthropic']);
168
+ // Custom classes currently holding a base URL the engine refuses (not local).
169
+ // Tracked so the UI can explain the dead class, and so a preset click may
170
+ // REPLACE the refused URL — applyCustomPreset otherwise never clobbers a
171
+ // configured endpoint, which would leave a blocked row unfixable by click.
172
+ const blockedCustomClasses = new Set();
168
173
  // Placeholders for the typed model-id field: custom endpoints enumerate
169
- // nothing, so the field has to say what a valid id looks like.
174
+ // nothing, so the field has to say what a valid id looks like. The examples
175
+ // are local-server ids — a hosted model id here would advertise a route this
176
+ // class no longer has (see the preset note below).
170
177
  const MODEL_ID_PLACEHOLDER = {
171
- 'openai-compat': 'type a model id — e.g. gpt-4o-mini',
172
- anthropic: 'type a model id — e.g. claude-sonnet-4-5',
178
+ 'openai-compat': 'type a model id — e.g. llama3.2',
179
+ anthropic: 'type a model id — e.g. claude-3-5-sonnet',
173
180
  };
174
181
  // Quick-fill presets for the two custom-endpoint classes — model id + the
175
182
  // base URL it actually lives at, since typing the right model string is only
176
- // half the problem (the wrong base URL 400s just as hard). Base URLs and
177
- // default model ids match aegis1 services/nexus_provider/catalog.py exactly:
178
- // DeepSeek is served via Anthropic-Messages transport, Gemini via
179
- // OpenAI-chat transport — that is why each shows up under the *other*
180
- // custom class from what its own name suggests.
183
+ // half the problem (the wrong base URL 400s just as hard).
184
+ //
185
+ // EVERY base URL HERE IS LOCAL, AND THAT IS THE POLICY, NOT AN OMISSION.
186
+ // These two classes are the free direct lane: aegiscode dials the base URL
187
+ // itself, so a remote one would be a model nobody bills. desktop/lib/local/
188
+ // endpoints.js refuses a non-local base URL for both of them, at the settings
189
+ // store (so the row cannot be created) and again at dispatch (so a row hand
190
+ // edited onto disk cannot be dialed). The hosted providers that used to be
191
+ // listed here — OpenAI, Anthropic, Gemini, and DeepSeek (whose live ids,
192
+ // served over DeepSeek's Anthropic-Messages transport, are `deepseek-flash`
193
+ // and `deepseek-v4-pro`) — are all in the relay's BYOK catalog, so their
194
+ // correct home is now the **Bring your own key** class, which bills the flat
195
+ // handling fee. Adding one back here would revive a route that is refused on
196
+ // save.
197
+ //
198
+ // The two transports stay distinct because wire format is not inferable from
199
+ // a local URL: `openai-compat` posts /chat/completions, `anthropic` posts the
200
+ // Messages API. A given local server answers one of them, so pick the class
201
+ // that matches what it serves. A preset's model id is what gets pinned, and
202
+ // for a local server that id is whatever *you* loaded — the ids below are the
203
+ // servers' own conventional defaults, and the field still invites your own.
181
204
  const CUSTOM_MODEL_PRESETS = {
182
205
  'openai-compat': [
183
- { label: 'OpenAI — gpt-4o-mini', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
184
- { label: 'OpenAI — gpt-4o', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' },
185
- {
186
- label: 'Gemini — 3.5 Flash',
187
- baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
188
- model: 'gemini-3.5-flash',
189
- },
206
+ { label: 'Ollama (OpenAI-compatible shim)', baseURL: 'http://127.0.0.1:11434/v1', model: 'llama3.2' },
207
+ { label: 'LM Studio', baseURL: 'http://127.0.0.1:1234/v1', model: 'local-model' },
208
+ { label: 'vLLM', baseURL: 'http://127.0.0.1:8000/v1', model: 'local-model' },
209
+ { label: 'llama.cpp', baseURL: 'http://127.0.0.1:8080/v1', model: 'local-model' },
190
210
  ],
191
211
  anthropic: [
192
- { label: 'Anthropic — Claude Sonnet 5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-sonnet-5' },
193
- { label: 'Anthropic — Claude Haiku 4.5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-haiku-4-5' },
194
- // DeepSeek's live API serves exactly two ids (verified against
195
- // GET https://api.deepseek.com/v1/models): `deepseek-flash` — the current
196
- // generation, which DeepSeek calls "Flash 4.1" — and the slow tier
197
- // `deepseek-v4-pro`, retired 2026-09-14 and now served as 4.1 too. The
198
- // preset that used to sit here, `deepseek-v4-flash`, is a *legacy alias*
199
- // DeepSeek keeps alive only for configs already carrying it, so the picker
200
- // was advertising a previous generation by its dead id. Ids and labels
201
- // match aegiscodex-dev src/models.js; the base URL is DeepSeek's
202
- // Anthropic-Messages transport (aegis1 services/nexus_provider/catalog.py
203
- // DEEPSEEK_DEFAULT_BASE), which is why these two sit under this class and
204
- // not the OpenAI-compatible one. Both ids are reasoning models: the token
205
- // budget for them is the Effort rung, never a stated number (they bill
206
- // hidden chain-of-thought against the same budget as the answer — see
207
- // budget.js).
208
- { label: 'DeepSeek — Flash 4.1', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-flash' },
209
- { label: 'DeepSeek — V4 Pro (retired → 4.1)', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-pro' },
212
+ // Anything local that speaks the Anthropic Messages API — a LiteLLM
213
+ // proxy, claude-code-router, or a gateway on your own LAN.
214
+ { label: 'LiteLLM proxy (Messages API)', baseURL: 'http://127.0.0.1:4000', model: 'local-model' },
210
215
  ],
211
216
  };
212
217
  // The in-app AEGIS key is stored in a reserved namespace the main process
@@ -422,12 +427,17 @@ function abortBranches() {
422
427
  // instead of only as pure math in stream-policy.js.
423
428
 
424
429
  /**
425
- * Set when the user asks the running turn to stop. The abort comes back as a
426
- * rejected IPC call, which does not preserve `err.name`, so this flag — not an
427
- * AbortError check — is what distinguishes a stop the user asked for from a
428
- * genuine failure.
430
+ * Turn identity, for the stop path. `turnSeq` is monotonic and never reset;
431
+ * `runningTurn` is the token of the turn in progress (null when idle) and
432
+ * `stoppedTurn` the token already asked to stop. The abort is not
433
+ * instantaneous, so a second Escape lands while the first turn still looks
434
+ * running — comparing tokens (see `stopAppliesTo`) is what keeps that second
435
+ * press from re-entering, and what stops a held key's repeats from reaching the
436
+ * *next* turn once this one has ended.
429
437
  */
430
- let userStopped = false;
438
+ let turnSeq = 0;
439
+ let runningTurn = null;
440
+ let stoppedTurn = null;
431
441
 
432
442
  /**
433
443
  * Transcript policy, created by init(): the reader's scroll veto, the
@@ -461,9 +471,16 @@ function rafPainter(paint) {
461
471
  */
462
472
  function stopPendingTurn() {
463
473
  if (!pendingSessionId) return false;
474
+ // Idempotent, and scoped to the turn that is actually running: a second press
475
+ // during teardown is the same intent (`stopAppliesTo` returns false and
476
+ // nothing is re-entered). A held key's repeats are refused earlier, by the
477
+ // `e.repeat` guard in transcript-view.js.
478
+ if (!stopAppliesTo(runningTurn, stoppedTurn)) return false;
464
479
  // Recorded before the abort lands: `send()`'s catch reads it to tell a
465
- // deliberate stop from a real error.
466
- userStopped = true;
480
+ // deliberate stop from a real error. Scoped to this one turn — a global flag
481
+ // could be left `true` by an earlier turn and make an unrelated transport
482
+ // failure look like a stop the user asked for.
483
+ stoppedTurn = runningTurn;
467
484
  try {
468
485
  models.cancel(pendingSessionId);
469
486
  } catch {
@@ -2319,8 +2336,11 @@ function captureDiffPreview(tool) {
2319
2336
  * already ran, so this is a retrospective log line, not a live spinner.
2320
2337
  */
2321
2338
  function toolActivityLabel(tool) {
2322
- const { name, args, ok } = tool || {};
2323
- const mark = ok === false ? '✗' : '✓';
2339
+ const { name, args } = tool || {};
2340
+ // The glyph is `toolMark`'s decision, not `ok === false ? '✗' : '✓'`: a call
2341
+ // whose result never arrived (aborted between call and result) used to draw
2342
+ // as ✓, claiming success for work this turn never finished.
2343
+ const mark = toolMark(tool);
2324
2344
  const a = args || {};
2325
2345
  if (name === 'task') {
2326
2346
  const kind = a.subagent_type && a.subagent_type !== 'general' ? a.subagent_type : 'general';
@@ -2571,9 +2591,25 @@ async function loadModels(cls) {
2571
2591
  // `model: "<url>"` and 400'd upstream). `needsModelId` is what turns this
2572
2592
  // into an explicit "type a model id" prompt rather than an empty picker.
2573
2593
  let needsModelId = true;
2594
+ // A row whose stored base URL is not local is refused by the engine — at
2595
+ // storage, at dispatch, and here. This is the here: without the reason the
2596
+ // class just looks armed and then fails on send, so capture it from the
2597
+ // same call that reports `needsModelId` and show it in place of the
2598
+ // endpoint line. It is recoverable by design — the text says how.
2599
+ let blockedReason = '';
2574
2600
  try {
2575
2601
  const data = await models.listModels(cls);
2576
2602
  if (data && typeof data.needsModelId === 'boolean') needsModelId = data.needsModelId;
2603
+ if (data && typeof data.blockedReason === 'string' && data.blockedReason) {
2604
+ blockedReason = data.blockedReason;
2605
+ }
2606
+ // Track the refusal against the class itself: applyCustomPreset() reads
2607
+ // this set to know that the row's stored endpoint is dead and may be
2608
+ // replaced by a click. A class that lists without a reason is cleared
2609
+ // again, so the replacement exception never widens past rows the engine
2610
+ // is actually refusing.
2611
+ if (blockedReason) blockedCustomClasses.add(cls);
2612
+ else blockedCustomClasses.delete(cls);
2577
2613
  if (data && typeof data.baseURL === 'string' && data.baseURL) {
2578
2614
  cfg = { ...cfg, baseURL: data.baseURL };
2579
2615
  }
@@ -2622,10 +2658,12 @@ async function loadModels(cls) {
2622
2658
  els.modelInput.placeholder = needsModelId
2623
2659
  ? MODEL_ID_PLACEHOLDER[cls] || 'type a model id'
2624
2660
  : 'model id';
2625
- els.modelHint.textContent = cfg.baseURL
2626
- ? `endpoint: ${cfg.baseURL} · key: ${cfg.configured ? cfg.keyMask : 'not set'}` +
2627
- (needsModelId ? ' · type a model id above' : '')
2628
- : 'Set base URL + key in Provider settings, then type a model id.';
2661
+ els.modelHint.textContent = blockedReason
2662
+ ? blockedReason
2663
+ : cfg.baseURL
2664
+ ? `endpoint: ${cfg.baseURL} · key: ${cfg.configured ? cfg.keyMask : 'not set'}` +
2665
+ (needsModelId ? ' · type a model id above' : '')
2666
+ : 'Set base URL + key in Provider settings, then type a model id.';
2629
2667
  return;
2630
2668
  }
2631
2669
 
@@ -2658,6 +2696,16 @@ async function loadModels(cls) {
2658
2696
  hint = null; // carries a link, built below
2659
2697
  } else if (!list.length) {
2660
2698
  hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
2699
+ } else if (cls === 'byok' && data && data.needsAegisKey) {
2700
+ // Enforced by this CLIENT, not by the server, so it no longer waits on
2701
+ // the server's fee.require_balance flag (off by default): the shared
2702
+ // local engine refuses a keyless BYOK send outright (401), because an
2703
+ // unattributed turn is served and billed to nobody — the fee lands on
2704
+ // user 0 as uncollected. Saying "required" here is now literal, not a
2705
+ // nudge. Checked BEFORE needsProviderKey because the account key is the
2706
+ // blocker and the provider key is not yet the question.
2707
+ hint = `${list.length} model${list.length === 1 ? '' : 's'} available — ` +
2708
+ 'an AEGIS account key is required: the BYOK handling fee is billed there.';
2661
2709
  } else if (cls === 'byok' && data && data.needsProviderKey) {
2662
2710
  // Unlike the pooled 'aegis' class, byok still shows every model here —
2663
2711
  // the catalog answers with no key at all — but none of them are
@@ -2699,6 +2747,12 @@ async function loadModels(cls) {
2699
2747
  * already-configured base URL is left alone (never silently overwritten) —
2700
2748
  * if it doesn't match what the preset expects, the hint says so instead, so
2701
2749
  * the user's own custom endpoint can't be clobbered by a stray click.
2750
+ *
2751
+ * The one exception is a BLOCKED row: when the engine refuses the stored
2752
+ * endpoint (non-local — see blockedCustomClasses), there is nothing left to
2753
+ * preserve, so the preset URL overwrites the field unconditionally. Without
2754
+ * that, the local-only policy would leave the row unfixable by click; the
2755
+ * hint then tells the user to Save so the replacement is actually stored.
2702
2756
  */
2703
2757
  function applyCustomPreset(cls, modelId) {
2704
2758
  const preset = (CUSTOM_MODEL_PRESETS[cls] || []).find((p) => p.model === modelId);
@@ -2712,14 +2766,17 @@ function applyCustomPreset(cls, modelId) {
2712
2766
  const row = els.settingsList.querySelector(`.setting-row[data-provider="${cls}"]`);
2713
2767
  const baseInput = row && row.querySelector('.setting-base');
2714
2768
  if (!baseInput) return;
2715
- const current = baseInput.value.trim();
2716
- if (!current) {
2717
- baseInput.value = preset.baseURL;
2718
- els.modelHint.textContent = `filled in — click Save in Provider settings below to store the ${preset.label} endpoint.`;
2719
- } else if (current !== preset.baseURL) {
2720
- els.modelHint.textContent =
2721
- `${preset.label} needs base URL ${preset.baseURL} — Provider settings below has ${current}. Update it there too.`;
2722
- }
2769
+
2770
+ // Which branch applies (fill / refuse-to-clobber / repair a blocked row) is
2771
+ // preset-fill.js's decision, unit-tested there. This function only writes
2772
+ // what it returns — `null` on either field means leave it as it is.
2773
+ const plan = planPresetFill({
2774
+ preset,
2775
+ current: baseInput.value.trim(),
2776
+ blocked: blockedCustomClasses.has(cls),
2777
+ });
2778
+ if (plan.baseURL !== null) baseInput.value = plan.baseURL;
2779
+ if (plan.hint !== null) els.modelHint.textContent = plan.hint;
2723
2780
  }
2724
2781
 
2725
2782
  // -------------------------------------------------------------- settings pane
@@ -2843,6 +2900,29 @@ async function loadSettings() {
2843
2900
  onRemove: () => removeSetting(provider),
2844
2901
  }));
2845
2902
  }
2903
+ // What AEGIS charges for relaying the turn, stated outright. The figure is
2904
+ // the server's own (`fee` on GET /api/v1/byok/providers); nothing is shown
2905
+ // when it publishes none, because a hardcoded client-side fee is one that
2906
+ // can drift from the ledger that actually bills. Until this line existed
2907
+ // the only place the fee was ever disclosed was the CLI's `/class byok`
2908
+ // output — a desktop user's per-turn cost figure was the vendor's rate with
2909
+ // no mention that AEGIS also collects.
2910
+ const fee = byokData && byokData.fee;
2911
+ if (fee && !fee.disabled &&
2912
+ (Number(fee.in_usd_per_1k) > 0 || Number(fee.out_usd_per_1k) > 0)) {
2913
+ const note = document.createElement('div');
2914
+ note.className = 'setting-row';
2915
+ note.id = 'byok-fee-note';
2916
+ const text = document.createElement('div');
2917
+ text.className = 'setting-name';
2918
+ const per1k = (n) => `${Number(n).toFixed(4)}`;
2919
+ text.textContent =
2920
+ `BYOK handling fee: ${per1k(fee.in_usd_per_1k)} / 1k in, ` +
2921
+ `${per1k(fee.out_usd_per_1k)} / 1k out — billed to your AEGIS account ` +
2922
+ 'on top of your own provider bill.';
2923
+ note.appendChild(text);
2924
+ els.settingsList.appendChild(note);
2925
+ }
2846
2926
  } catch {
2847
2927
  /* catalog unreachable (offline, server down) — the two custom rows above still work */
2848
2928
  }
@@ -2854,6 +2934,10 @@ async function saveSetting(provider, baseURL, key) {
2854
2934
  const cfg = { baseURL };
2855
2935
  if (key) cfg.key = key;
2856
2936
  await models.settings.set(provider, cfg);
2937
+ // Stored: whatever the engine refused is gone, so drop the blocked mark
2938
+ // and let loadModels() below re-establish it from the fresh refusal (if
2939
+ // any). Replacing a refused endpoint is exactly what unblocks the row.
2940
+ blockedCustomClasses.delete(provider);
2857
2941
  els.settingsHint.textContent = 'saved.';
2858
2942
  await loadSettings();
2859
2943
  await loadModels(els.classSelect.value);
@@ -2867,6 +2951,8 @@ async function removeSetting(provider) {
2867
2951
  els.settingsHint.textContent = 'removing…';
2868
2952
  try {
2869
2953
  await models.settings.remove(provider);
2954
+ // Nothing stored means nothing to refuse; clear the blocked mark too.
2955
+ blockedCustomClasses.delete(provider);
2870
2956
  els.settingsHint.textContent = 'removed.';
2871
2957
  await loadSettings();
2872
2958
  await loadModels(els.classSelect.value);
@@ -3102,6 +3188,11 @@ function newChat() {
3102
3188
  els.sessionsHint.textContent = '';
3103
3189
  pendingEl = null;
3104
3190
  pendingSessionId = null;
3191
+ // A new thread owns no turn: dropping the running token here means any
3192
+ // in-flight `send()` whose teardown has not run yet can no longer match
3193
+ // itself (see the `finally` guard) and cannot clear this thread's state.
3194
+ runningTurn = null;
3195
+ stoppedTurn = null;
3105
3196
  currentSessionId = null;
3106
3197
  threadMessages = [];
3107
3198
  flowCount = 0;
@@ -3155,9 +3246,12 @@ async function send() {
3155
3246
  }
3156
3247
 
3157
3248
  els.prompt.value = '';
3158
- // Clear the stop flag a previous turn may have left set, so a stale `true`
3159
- // can never make an unrelated failure look like a deliberate stop.
3160
- userStopped = false;
3249
+ // This turn's identity. Claiming it here — and clearing `stoppedTurn` —
3250
+ // replaces the old global "userStopped" flag, which any previous turn could
3251
+ // leave set and which this send could only hope to clear in time.
3252
+ const myTurn = ++turnSeq;
3253
+ runningTurn = myTurn;
3254
+ stoppedTurn = null;
3161
3255
  addMessage('user', prompt);
3162
3256
 
3163
3257
  // What this request travels with, resolved from ONE authority by budgetFor:
@@ -3306,6 +3400,15 @@ async function send() {
3306
3400
  if (est) bits.push(`~${est.input + est.output} tokens`);
3307
3401
  }
3308
3402
  if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
3403
+ // …and on the byok class, whose money that figure is. The relay never
3404
+ // returns a settled charge (nothing on that route emits one), so this is
3405
+ // always the rate table's estimate of the caller's OWN provider bill — and
3406
+ // AEGIS additionally collects a handling fee on the same turn, which this
3407
+ // response cannot price. One bit, and the number stops reading as the whole
3408
+ // cost of a BYOK turn.
3409
+ if (cls === 'byok' && turn.cost != null) {
3410
+ bits.push('your provider bill (est.) + AEGIS handling fee');
3411
+ }
3309
3412
  // …AND the running session total beside it, which is the number the CLI
3310
3413
  // prints. The per-turn figure answers "what did that call cost"; only the
3311
3414
  // rolling one answers "what has this conversation cost", and it was the
@@ -3366,8 +3469,13 @@ async function send() {
3366
3469
  // everything already streamed and answer with a red "aborted" error,
3367
3470
  // destroying the partial reply at the exact moment the user asked to keep
3368
3471
  // it. Salvage the partial turn and label it honestly instead.
3369
- if (isCancellation(err, { userStopped })) {
3370
- const text = streamedText || reasoningText || '(stopped before any output)';
3472
+ if (isCancellation(err, { userStopped: stoppedTurn === myTurn })) {
3473
+ // Which of the three outcomes this is — answer text, deliberation only,
3474
+ // or nothing — is `salvageTurn`'s decision, so the labelling cannot
3475
+ // drift from the salvage.
3476
+ const salvage = salvageTurn({ streamedText, reasoningText });
3477
+ const text = salvage.text;
3478
+ const salvageReasoning = salvage.reasoning;
3371
3479
  threadMessages.push({ role: 'assistant', content: text });
3372
3480
  // A stopped turn is a real exchange and the CLI records one: its
3373
3481
  // `appendHistory` writes a `status: 'stopped'` entry for every stopped
@@ -3382,12 +3490,13 @@ async function send() {
3382
3490
  model,
3383
3491
  prompt,
3384
3492
  reply: text,
3385
- reasoning: text === reasoningText ? '' : reasoningText,
3493
+ reasoning: salvageReasoning,
3386
3494
  });
3387
3495
  const stopBits = ['stopped by you'];
3496
+ if (salvage.kind === 'reasoning-only') stopBits.push('reasoning only');
3388
3497
  if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
3389
3498
  else {
3390
- const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
3499
+ const est = estimatedBuckets(prompt, text, salvageReasoning);
3391
3500
  if (est) stopBits.push(`~${est.input + est.output} tokens`);
3392
3501
  }
3393
3502
  const stopRollLine = fmtRoll(roll);
@@ -3400,7 +3509,7 @@ async function send() {
3400
3509
  ...ledgerFields(undefined, model, turn, {
3401
3510
  prompt,
3402
3511
  reply: text,
3403
- reasoning: text === reasoningText ? '' : reasoningText,
3512
+ reasoning: salvageReasoning,
3404
3513
  }),
3405
3514
  });
3406
3515
  } catch {
@@ -3419,7 +3528,14 @@ async function send() {
3419
3528
  }
3420
3529
  } finally {
3421
3530
  setBusy(false);
3422
- pendingSessionId = null;
3531
+ // Only this turn may clear the pending state, and only if it is still the
3532
+ // running one. Unconditionally nulling it here is what let an outgoing
3533
+ // turn's teardown disarm its successor — after which Escape during that
3534
+ // successor did nothing at all.
3535
+ if (runningTurn === myTurn) {
3536
+ runningTurn = null;
3537
+ pendingSessionId = null;
3538
+ }
3423
3539
  loadSessions();
3424
3540
  }
3425
3541
  }
@@ -444,6 +444,7 @@
444
444
  <script src="usage.js"></script>
445
445
  <script src="stream-policy.js"></script>
446
446
  <script src="transcript-view.js"></script>
447
+ <script src="preset-fill.js"></script>
447
448
  <script src="diff.js"></script>
448
449
  <script src="diffview.js"></script>
449
450
  <script src="vendor/marked.umd.js"></script>
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The decision behind clicking a Model-card preset: what to type into the
5
+ * model-id field, whether to touch the base-URL field, and what the hint
6
+ * should say. Kept out of app.js — like stream-policy.js and budget.js — so
7
+ * the rule is testable from plain Node without the Electron host. app.js only
8
+ * calls into this and performs the DOM writes.
9
+ *
10
+ * Why it exists: after the local-only endpoint policy, one branch here is
11
+ * load-bearing rather than cosmetic. A row whose stored endpoint the engine
12
+ * REFUSES (non-local — see lib/local/endpoints.js) must be repairable by
13
+ * click. The general rule is "never silently overwrite a configured base URL":
14
+ * the user's own custom endpoint must not be clobbered by a stray preset
15
+ * click, so when the field disagrees with the preset the hint says so instead
16
+ * of writing. That rule would leave a refused row unfixable forever — there is
17
+ * nothing left to preserve in a URL the engine will not dial — so `blocked`
18
+ * is the one exception and overwrites outright.
19
+ *
20
+ * The return value is a plan, not a mutation: `baseURL: null` and `hint: null`
21
+ * both mean "leave that alone", which keeps a caller from being able to write
22
+ * an unintended value by ignoring a field.
23
+ */
24
+
25
+ /** What the hint says when the preset's endpoint replaces a refused one. */
26
+ function blockedFillHint(baseURL) {
27
+ return (
28
+ `stored endpoint refused — replaced with ${baseURL}; ` +
29
+ 'click Save in Provider settings to replace the refused endpoint.'
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Plan one preset click.
35
+ *
36
+ * @param {object} input
37
+ * @param {object} input.preset a CUSTOM_MODEL_PRESETS entry ({model,baseURL,label})
38
+ * @param {string} input.current the base-URL field's current value, trimmed
39
+ * @param {boolean} input.blocked the engine refused this class's stored endpoint
40
+ * @returns {{model: string, baseURL: string|null, hint: string|null}|null}
41
+ * null when there is no preset to apply (the caller does nothing).
42
+ */
43
+ function planPresetFill(input) {
44
+ const args = input || {};
45
+ const preset = args.preset;
46
+ // No preset means no click to act on. Guarded on the model id specifically:
47
+ // the model field is what every branch is required to write.
48
+ if (!preset || !preset.model) return null;
49
+ const current = typeof args.current === 'string' ? args.current : '';
50
+
51
+ // The one case that overwrites a configured endpoint. Checked first so it
52
+ // cannot be shadowed by the !current / mismatch branches below.
53
+ if (args.blocked) {
54
+ return { model: preset.model, baseURL: preset.baseURL, hint: blockedFillHint(preset.baseURL) };
55
+ }
56
+ if (!current) {
57
+ return {
58
+ model: preset.model,
59
+ baseURL: preset.baseURL,
60
+ hint: `filled in — click Save in Provider settings below to store the ${preset.label} endpoint.`,
61
+ };
62
+ }
63
+ if (current !== preset.baseURL) {
64
+ return {
65
+ model: preset.model,
66
+ baseURL: null, // never clobber a configured endpoint
67
+ hint: `${preset.label} needs base URL ${preset.baseURL} — Provider settings below has ${current}. Update it there too.`,
68
+ };
69
+ }
70
+ // Already pointing at the preset's endpoint: nothing to write and nothing to
71
+ // say. `hint: null` rather than '' — the hint element also carries the
72
+ // endpoint/key line and the max-output suffix, so this branch must not clear
73
+ // text it did not set.
74
+ return { model: preset.model, baseURL: null, hint: null };
75
+ }
76
+
77
+ if (typeof module !== 'undefined' && module.exports) {
78
+ module.exports = { planPresetFill, blockedFillHint };
79
+ }
@@ -83,6 +83,70 @@ function isCancellation(err, opts) {
83
83
  return ABORT_SIGNATURES.some((re) => re.test(msg));
84
84
  }
85
85
 
86
+ /**
87
+ * Should a stop request reach the turn running right now?
88
+ *
89
+ * `runningTurn` is the token of the turn in progress (null when idle) and
90
+ * `stoppedTurn` is the token already asked to stop. The abort is not
91
+ * instantaneous — the transport has to unwind before `send()`'s catch runs — so
92
+ * a second press lands on a turn that is still nominally running. That second
93
+ * press is the *same* intent and must not re-enter.
94
+ *
95
+ * The two writers keep the pair consistent: app.js only ever assigns
96
+ * `stoppedTurn = runningTurn`, and claiming a new turn sets `stoppedTurn = null`
97
+ * alongside `runningTurn = myTurn`. So `stoppedTurn` is never some *other*
98
+ * turn's number, and `runningTurn !== stoppedTurn` means exactly "not already
99
+ * stopping". What keeps a finished turn's stop off its successor is that
100
+ * invariant plus the catch's `stoppedTurn === myTurn` — not a refusal here. A
101
+ * press on a live successor is legitimate and does reach it.
102
+ */
103
+ function stopAppliesTo(runningTurn, stoppedTurn) {
104
+ if (runningTurn == null) return false;
105
+ return runningTurn !== stoppedTurn;
106
+ }
107
+
108
+ /**
109
+ * What a cancelled turn leaves on screen, and what it honestly is.
110
+ *
111
+ * `streamedText || reasoningText` collapsed two different outcomes into one
112
+ * string: a turn cut off after real answer text, and a turn cut off with
113
+ * deliberation but no answer. The second is not a failure and the user should
114
+ * keep it, but folding it in as if it were the answer loses the distinction —
115
+ * so the case is named here and the caller labels it. With neither, the
116
+ * placeholder is still returned rather than an empty bubble.
117
+ */
118
+ function salvageTurn(input) {
119
+ const i = input || {};
120
+ const streamed = typeof i.streamedText === 'string' ? i.streamedText : '';
121
+ const reasoning = typeof i.reasoningText === 'string' ? i.reasoningText : '';
122
+ if (streamed) return { text: streamed, reasoning: reasoning, kind: 'answer' };
123
+ if (reasoning) return { text: reasoning, reasoning: '', kind: 'reasoning-only' };
124
+ return { text: '(stopped before any output)', reasoning: '', kind: 'empty' };
125
+ }
126
+
127
+ /**
128
+ * The status glyph for one tool-activity line.
129
+ *
130
+ * Only an explicit `true` is a ✓. A call with no `ok` at all is a call whose
131
+ * result never arrived — the tool-call-mid-abort case — and drawing it as ✓
132
+ * claimed success for work the turn never finished. It gets its own mark
133
+ * instead of ✗, because interrupted is not the same as failed.
134
+ */
135
+ function toolMark(tool) {
136
+ const t = tool || {};
137
+ if (t.ok === true) return '✓';
138
+ if (t.ok === false) return '✗';
139
+ return '⊘';
140
+ }
141
+
86
142
  if (typeof module !== 'undefined' && module.exports) {
87
- module.exports = { nearBottom, shouldFollow, isCancellation, STICK_SLOP_PX };
143
+ module.exports = {
144
+ nearBottom,
145
+ shouldFollow,
146
+ isCancellation,
147
+ stopAppliesTo,
148
+ salvageTurn,
149
+ toolMark,
150
+ STICK_SLOP_PX,
151
+ };
88
152
  }
@@ -220,6 +220,11 @@ function bindEscapeInterrupt(deps) {
220
220
 
221
221
  function handle(e) {
222
222
  if (!e || e.key !== 'Escape') return false;
223
+ // A held key is one intent, not one per repeat. Without this the auto-repeat
224
+ // events keep arriving after the interrupted turn has already ended, and
225
+ // the ones that land once the user has sent the next message stop that turn
226
+ // instead — the keyboard twin of a stuck cancel button.
227
+ if (e.repeat) return false;
223
228
  if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
224
229
  if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
225
230
  return false;