llm-switcher 1.6.0 → 1.6.3

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/ui.html CHANGED
@@ -551,6 +551,7 @@
551
551
 
552
552
  /* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
553
553
  .combo { position: relative; }
554
+ .combo[data-open] { z-index: 50; }
554
555
  .slot-note { font-size: 0.72rem; color: var(--faint); } /* Kept in the page when empty: a live region added later is often not read. */
555
556
  .slot-note:empty { margin-top: -0.25rem; }
556
557
  .combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
@@ -905,6 +906,10 @@
905
906
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h16M12 4v16"/><circle cx="12" cy="12" r="8"/></svg>
906
907
  Routes & Profiles<span class="ct" id="ct-routes"></span>
907
908
  </a>
909
+ <a class="nav-link" href="#/compact" data-nav="compact">
910
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 14h6m0 0v6m0-6L3 21"/><path d="M20 10h-6m0 0V4m0 6l7-7"/><circle cx="12" cy="12" r="2"/></svg>
911
+ Idle Compaction<span class="ct" id="ct-compact"></span>
912
+ </a>
908
913
  <a class="nav-link" href="#/models" data-nav="models">
909
914
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></svg>
910
915
  Model Catalog<span class="ct" id="ct-models"></span>
@@ -952,57 +957,6 @@
952
957
  <!-- VIEW 1: Routes & Profiles -->
953
958
  <div id="view-routes" class="view-panel" style="display:none;">
954
959
  <div class="sec">
955
- <!-- Idle compaction -->
956
- <div class="card">
957
- <div class="cardh">
958
- <h2 style="font-size:1.02rem; font-weight:600;">Idle compaction</h2>
959
- <span class="badge outline" id="badge-idle-compact">OFF</span>
960
- </div>
961
- <p style="margin:.2rem 0 .7rem; color:var(--muted); font-size:.86rem;">
962
- A Claude Code conversation that comes back after a pause has lost the provider's prompt
963
- cache, so its history is paid for at full price. Shorten it then, and write the
964
- compaction into the session file so the next resume starts short.
965
- </p>
966
- <div class="row">
967
- <label class="cb" style="display:flex; align-items:center; gap:.5rem;">
968
- <input type="checkbox" id="idle-compact-enabled" onchange="saveIdleCompact()">
969
- <span>Shorten a conversation that was idle too long</span>
970
- </label>
971
- </div>
972
- <div class="row">
973
- <div class="who">
974
- <div class="l">Pause that counts as losing the cache</div>
975
- <div class="meta">minutes · Anthropic 5m default, OpenAI 30m</div>
976
- </div>
977
- <div style="min-width:9rem;">
978
- <input type="number" id="idle-compact-minutes" min="1" max="1440" class="inp"
979
- style="width:100%;" onchange="saveIdleCompact()">
980
- </div>
981
- </div>
982
- <div class="row">
983
- <div class="who">
984
- <div class="l">Context it must be larger than</div>
985
- <div class="meta">KB · below this nothing is touched</div>
986
- </div>
987
- <div style="min-width:9rem;">
988
- <input type="number" id="idle-compact-kb" min="8" max="4096" class="inp"
989
- style="width:100%;" onchange="saveIdleCompact()">
990
- </div>
991
- </div>
992
- <div class="row">
993
- <div class="who">
994
- <div class="l">Model that writes the summary</div>
995
- <div class="meta">a cheaper model writes a cheaper and worse summary</div>
996
- </div>
997
- <div style="min-width:16rem; flex:1;">
998
- <input type="text" id="idle-compact-model" class="inp" style="width:100%;"
999
- placeholder="empty = the model of the conversation itself"
1000
- onchange="saveIdleCompact()">
1001
- </div>
1002
- </div>
1003
- <span id="idle-compact-status" class="meta" aria-live="polite"></span>
1004
- </div>
1005
-
1006
960
  <!-- Active Coding CLI Targets -->
1007
961
  <div class="card">
1008
962
  <div class="cardh">
@@ -1067,6 +1021,195 @@
1067
1021
  </div>
1068
1022
  </div>
1069
1023
 
1024
+ <!-- VIEW: Idle Prefix Cache & Compaction -->
1025
+ <div id="view-compact" class="view-panel" style="display:none;">
1026
+ <div class="sec">
1027
+ <!-- Overview Stats -->
1028
+ <div class="stats">
1029
+ <div class="stat">
1030
+ <div class="k">Compaction Engine</div>
1031
+ <div class="v" id="stat-compact-status" style="color:var(--muted); font-size:1.4rem;">Disabled</div>
1032
+ <div class="status" id="stat-compact-indicator" style="margin-top:0.3rem;">Prefix caching inactive</div>
1033
+ </div>
1034
+
1035
+ <div class="stat">
1036
+ <div class="k">Inactivity Pause</div>
1037
+ <div class="v" id="stat-compact-pause" style="color:var(--accent); font-size:1.4rem;">15 min</div>
1038
+ <div class="hint" style="margin-top:0.3rem;">Trigger pause threshold</div>
1039
+ </div>
1040
+
1041
+ <div class="stat">
1042
+ <div class="k">Min Size Threshold</div>
1043
+ <div class="v" id="stat-compact-size" style="color:var(--mint); font-size:1.4rem;">64 KB</div>
1044
+ <div class="hint" style="margin-top:0.3rem;">Smaller payloads bypassed</div>
1045
+ </div>
1046
+
1047
+ <div class="stat">
1048
+ <div class="k">Cache Lookback</div>
1049
+ <div class="v" id="stat-compact-lookback" style="color:var(--code); font-size:1.4rem;">72 hrs</div>
1050
+ <div class="hint" style="margin-top:0.3rem;">Storage retention window</div>
1051
+ </div>
1052
+ </div>
1053
+
1054
+ <!-- Hero Master Card -->
1055
+ <div class="card pad">
1056
+ <div class="sech">
1057
+ <div>
1058
+ <div style="display:flex; align-items:center; gap:0.6rem;">
1059
+ <h2 style="font-size:1.05rem; font-weight:600;">Idle Prefix Cache &amp; Compaction</h2>
1060
+ <span class="badge outline" id="badge-idle-compact">OFF</span>
1061
+ </div>
1062
+ <p class="hint" style="margin-top:0.35rem; max-width:54rem; line-height:1.45;">
1063
+ Shortens long conversation histories when an agent session remains inactive past a configured pause.
1064
+ The edge gateway maintains a canonical shortened prefix for resumed turns, cutting context tokens while keeping client transcripts 100% untouched.
1065
+ </p>
1066
+ </div>
1067
+ <div style="display:flex; align-items:center; gap:0.8rem;">
1068
+ <label class="switch" title="Toggle Idle Compaction">
1069
+ <input type="checkbox" id="idle-compact-enabled" onchange="saveIdleCompact()">
1070
+ <span></span>
1071
+ </label>
1072
+ </div>
1073
+ </div>
1074
+ <div id="idle-compact-status" style="margin-top:0.75rem; font-size:0.82rem; min-height:1.2rem;" aria-live="polite"></div>
1075
+ </div>
1076
+
1077
+ <!-- Configuration Section (Hidden when inactive) -->
1078
+ <div id="idle-compact-config-section" style="display:none; flex-direction:column; gap:1.25rem;">
1079
+ <!-- Card 1: Core Policy & Trigger Thresholds -->
1080
+ <div class="card">
1081
+ <div class="cardh">
1082
+ <h3 style="font-size:0.95rem; font-weight:600;">Compaction Policy &amp; Triggers</h3>
1083
+ <span class="badge ok">Gateway Invariant</span>
1084
+ </div>
1085
+
1086
+ <div class="row">
1087
+ <div class="who">
1088
+ <div class="l">Inactivity Pause Threshold</div>
1089
+ <div class="meta">Elapsed idle minutes after which the conversation prefix is compacted</div>
1090
+ </div>
1091
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1092
+ <input type="number" id="idle-compact-minutes" min="1" max="1440" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Inactivity pause threshold in minutes (1 – 1440 min)" placeholder="15">
1093
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.2rem; user-select:none;">min</span>
1094
+ </div>
1095
+ </div>
1096
+
1097
+ <div class="row">
1098
+ <div class="who">
1099
+ <div class="l">Minimum Context Payload</div>
1100
+ <div class="meta">Context threshold; conversations smaller than this are kept untouched</div>
1101
+ </div>
1102
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1103
+ <input type="number" id="idle-compact-kb" min="8" max="4096" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Minimum context payload size in kilobytes (8 – 4096 KB)" placeholder="64">
1104
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.2rem; user-select:none;">KB</span>
1105
+ </div>
1106
+ </div>
1107
+
1108
+ <div class="row" style="align-items:flex-start;">
1109
+ <div class="who" style="padding-top:0.25rem;">
1110
+ <div class="l">Summarization Model</div>
1111
+ <div class="meta">Select from discovered/profile models or enter custom ID (empty = deterministic truncation)</div>
1112
+ </div>
1113
+ <div style="min-width:18rem; flex:1; max-width:28rem; margin-left:auto; display:flex; flex-direction:column; gap:0.4rem;">
1114
+ <div class="combo" id="combo-compact-model">
1115
+ <input type="text" id="idle-compact-model" placeholder="empty = deterministic truncation" maxlength="512" onchange="saveIdleCompact()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="idle-compact-model-list" autocomplete="off" spellcheck="false" style="font-family:var(--font-mono); font-size:0.8rem;" title="Upstream model used to generate turn summary text">
1116
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show available summary models">
1117
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
1118
+ </button>
1119
+ <ul class="combo-list" id="idle-compact-model-list" role="listbox" aria-label="Available summarization models" hidden></ul>
1120
+ </div>
1121
+ <div id="compact-model-presets" style="display:flex; flex-wrap:wrap; align-items:center; gap:0.35rem;">
1122
+ <!-- Quick Presets rendered dynamically -->
1123
+ </div>
1124
+ </div>
1125
+ </div>
1126
+
1127
+ <div class="row">
1128
+ <div class="who">
1129
+ <div class="l">Shorten Codex CLI Sessions</div>
1130
+ <div class="meta">Off by default; Codex retains native turn history in local SQLite storage</div>
1131
+ </div>
1132
+ <label class="switch" title="Toggle Codex compaction" style="margin-left:auto;">
1133
+ <input type="checkbox" id="idle-compact-codex" onchange="saveIdleCompact()">
1134
+ <span></span>
1135
+ </label>
1136
+ </div>
1137
+ </div>
1138
+
1139
+ <!-- Card 2: Context Retention & Budget Controls -->
1140
+ <div class="card">
1141
+ <div class="cardh">
1142
+ <h3 style="font-size:0.95rem; font-weight:600;">Context Retention &amp; Cache Budgets</h3>
1143
+ <span class="badge accent">Turn Budgets</span>
1144
+ </div>
1145
+
1146
+ <div class="row">
1147
+ <div class="who">
1148
+ <div class="l">Verbatim Recent Turns</div>
1149
+ <div class="meta">Number of most recent turns always preserved byte-for-byte</div>
1150
+ </div>
1151
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1152
+ <input type="number" id="idle-compact-keep" min="1" max="100" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Number of most recent turns to retain verbatim (1 – 100 turns)" placeholder="6">
1153
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">turns</span>
1154
+ </div>
1155
+ </div>
1156
+
1157
+ <div class="row">
1158
+ <div class="who">
1159
+ <div class="l">Character Budget Per Earlier Turn</div>
1160
+ <div class="meta">Maximum character budget preserved per earlier conversation turn</div>
1161
+ </div>
1162
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1163
+ <input type="number" id="idle-compact-user-chars" min="128" max="24000" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Character budget limit per earlier text turn (128 – 24000 chars)" placeholder="3000">
1164
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">chars</span>
1165
+ </div>
1166
+ </div>
1167
+
1168
+ <div class="row">
1169
+ <div class="who">
1170
+ <div class="l">Maximum Summary Length</div>
1171
+ <div class="meta">Upper character limit allocated for generated turn summary blocks</div>
1172
+ </div>
1173
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1174
+ <input type="number" id="idle-compact-summary-chars" min="256" max="128000" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Upper character limit allocated for generated summary (256 – 128000 chars)" placeholder="24000">
1175
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">chars</span>
1176
+ </div>
1177
+ </div>
1178
+
1179
+ <div class="row">
1180
+ <div class="who">
1181
+ <div class="l">Cache Prefix Retention Window</div>
1182
+ <div class="meta">Duration in hours before idle cached prefixes are pruned from state disk</div>
1183
+ </div>
1184
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1185
+ <input type="number" id="idle-compact-lookback" min="1" max="168" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Duration in hours before idle cached prefixes are pruned (1 – 168 hours)" placeholder="72">
1186
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">hours</span>
1187
+ </div>
1188
+ </div>
1189
+ </div>
1190
+
1191
+ <!-- Card 3: Architecture & Safety Invariants -->
1192
+ <div class="card pad">
1193
+ <h3 style="font-size:0.95rem; font-weight:600; margin-bottom:0.75rem;">Edge Gateway Invariants &amp; Storage Safety</h3>
1194
+ <div class="sec">
1195
+ <div class="copyline">
1196
+ <code>Zero Client Mutation: Transcripts on client disk (Claude Code / Codex) are never modified.</code>
1197
+ </div>
1198
+ <div class="copyline">
1199
+ <code>Atomic CAS Cache: Compare-and-swap generation counters prevent stale compaction races.</code>
1200
+ </div>
1201
+ <div class="copyline">
1202
+ <code>Private State Storage: Cached prefixes saved under ~/.llm-switcher/state/idle-compact/ (0600 permissions).</code>
1203
+ </div>
1204
+ <div class="copyline">
1205
+ <code>Deterministic Safety: If no summary model is configured, gateway uses pure heuristic truncation without external calls.</code>
1206
+ </div>
1207
+ </div>
1208
+ </div>
1209
+ </div>
1210
+ </div>
1211
+ </div>
1212
+
1070
1213
  <!-- VIEW 2: Discovered Models Catalog -->
1071
1214
  <div id="view-models" class="view-panel" style="display:none;">
1072
1215
  <div class="sec">
@@ -1242,13 +1385,6 @@
1242
1385
  </div>
1243
1386
 
1244
1387
  <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1245
- <label class="f">Thinking Mode
1246
- <select id="p-thinking-mode">
1247
- <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1248
- <option value="native">NATIVE (strict reasoning_effort)</option>
1249
- <option value="off">OFF (never send thinking)</option>
1250
- </select>
1251
- </label>
1252
1388
  <label class="f">Routing Mode
1253
1389
  <select id="p-mode">
1254
1390
  <option value="convert">CONVERT (Bi-directional translation)</option>
@@ -1262,11 +1398,46 @@
1262
1398
  <!-- TAB 3: Models -->
1263
1399
  <div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1264
1400
  <div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
1265
- <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1401
+ <div>
1402
+ <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600; margin:0;">Model Slots Mapping</h3>
1403
+ <span class="hint" style="font-size:0.75rem;">Configure target model and individual thinking effort for each slot</span>
1404
+ </div>
1266
1405
  <div id="models-status" class="models-status" aria-live="polite"></div>
1267
1406
  </div>
1268
1407
 
1269
1408
  <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1409
+
1410
+ <!-- Global Reasoning Policy -->
1411
+ <div style="border-top:1px solid var(--line); padding-top:0.85rem; margin-top:0.35rem; display:grid; gap:0.85rem;">
1412
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1413
+ <h4 style="font-size:0.88rem; font-weight:600; margin:0;">Reasoning Mode &amp; Policy</h4>
1414
+ <span class="hint">Controls reasoning behavior across all model slots</span>
1415
+ </div>
1416
+
1417
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1418
+ <label class="f">Thinking Mode
1419
+ <select id="p-thinking-mode">
1420
+ <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1421
+ <option value="native">NATIVE (strict reasoning_effort)</option>
1422
+ <option value="off">OFF (never send thinking to any model)</option>
1423
+ </select>
1424
+ </label>
1425
+ <label class="f" id="f-thinking-effort" style="display:none;">Global Fallback Effort
1426
+ <select id="p-thinking-effort">
1427
+ <option value="auto">Auto (inherit from client tool)</option>
1428
+ <option value="low">Low (fast, light reasoning)</option>
1429
+ <option value="medium">Medium (balanced reasoning)</option>
1430
+ <option value="high">High (deep reasoning)</option>
1431
+ <option value="xhigh">Extra High (complex problem solving)</option>
1432
+ <option value="max">Max (maximum reasoning depth)</option>
1433
+ </select>
1434
+ </label>
1435
+ </div>
1436
+
1437
+ <div id="bifrost-hint" style="display:none; padding:0.6rem 0.85rem; border-radius:6px; background:rgba(59,130,246,0.08); border:1px solid rgba(59,130,246,0.25); font-size:0.82rem; color:#60a5fa; line-height:1.4;">
1438
+ ⚡ <strong>Bifrost Passthrough:</strong> Byte-for-byte routing to intact without format conversion. Thinking effort is controlled natively by your client tool.
1439
+ </div>
1440
+ </div>
1270
1441
  </div>
1271
1442
 
1272
1443
  <!-- Test Connection Box -->
@@ -1474,7 +1645,7 @@
1474
1645
  function handleRoute() {
1475
1646
  const hash = window.location.hash || '#/routes';
1476
1647
  const route = hash.replace(/^#\/?/, '').split('/')[0] || 'routes';
1477
- const validRoutes = ['routes', 'models', 'logs', 'doctor'];
1648
+ const validRoutes = ['routes', 'compact', 'models', 'logs', 'doctor'];
1478
1649
  const activeRoute = validRoutes.includes(route) ? route : 'routes';
1479
1650
 
1480
1651
  // Update active nav link
@@ -1495,6 +1666,7 @@
1495
1666
  updateHeader(activeRoute);
1496
1667
 
1497
1668
  // Lazy load view content
1669
+ if (activeRoute === 'compact') loadIdleCompact();
1498
1670
  if (activeRoute === 'models') renderCatalogView();
1499
1671
  if (activeRoute === 'logs') loadInspectorLogs();
1500
1672
  }
@@ -1512,7 +1684,6 @@
1512
1684
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1513
1685
 
1514
1686
  if (route === 'routes') {
1515
- loadIdleCompact();
1516
1687
  titleEl.textContent = 'Routes & Profiles';
1517
1688
  if (activeTargets.length > 0) {
1518
1689
  const parts = [];
@@ -1528,6 +1699,14 @@
1528
1699
  <button class="btn btn-outline" id="btn-toggle-routes" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1529
1700
  <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1530
1701
  `;
1702
+ } else if (route === 'compact') {
1703
+ loadIdleCompact();
1704
+ titleEl.textContent = 'Idle Compaction';
1705
+ subEl.textContent = 'Edge gateway prefix compaction and cache pruning for long sessions.';
1706
+ actionsEl.innerHTML = `
1707
+ <button class="btn btn-outline" onclick="loadIdleCompact()">Refresh</button>
1708
+ <button class="btn btn-primary" onclick="saveIdleCompact()">Save Configuration</button>
1709
+ `;
1531
1710
  } else if (route === 'models') {
1532
1711
  titleEl.textContent = 'Dynamic Model Catalog';
1533
1712
  subEl.textContent = 'Auto-polled models from upstream providers to guarantee zero 404/drift errors.';
@@ -1675,6 +1854,11 @@
1675
1854
  return defaultModels[slotId] || '';
1676
1855
  }
1677
1856
 
1857
+ function effortForSlot(modelEfforts, slotId) {
1858
+ if (!modelEfforts) return 'auto';
1859
+ return modelEfforts[slotId] || 'auto';
1860
+ }
1861
+
1678
1862
  function renderModelSlots(slotFmt, p) {
1679
1863
  const container = document.getElementById('slot-fields');
1680
1864
  const heading = document.getElementById('model-slot-heading');
@@ -1687,11 +1871,27 @@
1687
1871
 
1688
1872
  for (const s of cfg.slots) {
1689
1873
  const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
1874
+ const effVal = currentProfileBuffer ? effortForSlot(currentProfileBuffer.modelEfforts, s.id) : effortForSlot(p?.modelEfforts, s.id);
1690
1875
 
1691
1876
  const slotRow = document.createElement('div');
1692
- slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
1877
+ slotRow.className = 'slot-row-card';
1878
+ slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.35rem; padding:0.6rem 0.75rem; border-radius:6px; background:var(--card-bg, rgba(255,255,255,0.02)); border:1px solid var(--line);';
1693
1879
  slotRow.innerHTML = `
1694
- <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1880
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem;">
1881
+ <label for="p-${s.id}" style="font-size:0.82rem; font-weight:600; margin:0;">${escapeHtml(s.name)}</label>
1882
+ <div style="display:flex; align-items:center; gap:0.35rem;">
1883
+ <label for="p-effort-${s.id}" style="font-size:0.75rem; color:var(--muted); margin:0;">Target Effort:</label>
1884
+ <select id="p-effort-${s.id}" class="slot-effort inp" onchange="formDirty = true" style="height:26px; padding:0 0.4rem; font-size:0.78rem; border-radius:4px; min-width:6.8rem;">
1885
+ <option value="auto">Auto (Default)</option>
1886
+ <option value="none">None (0)</option>
1887
+ <option value="low">Low</option>
1888
+ <option value="medium">Medium</option>
1889
+ <option value="high">High</option>
1890
+ <option value="xhigh">Extra High</option>
1891
+ <option value="max">Max</option>
1892
+ </select>
1893
+ </div>
1894
+ </div>
1695
1895
  <div class="combo">
1696
1896
  <input type="text" id="p-${s.id}" oninput="updateWindowNotes()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1697
1897
  <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
@@ -1699,10 +1899,15 @@
1699
1899
  </button>
1700
1900
  <ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
1701
1901
  </div>
1702
- <div class="slot-note" id="p-${s.id}-note" role="status" aria-live="polite"></div>
1902
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem; min-height:1rem;">
1903
+ <div class="slot-note" id="p-${s.id}-note" role="status" aria-live="polite"></div>
1904
+ <div class="slot-bifrost-hint" id="p-bifrost-${s.id}" style="display:none; font-size:0.74rem; color:#60a5fa;">⚡ Bifrost Passthrough (native effort)</div>
1905
+ </div>
1703
1906
  `;
1704
1907
  container.appendChild(slotRow);
1705
1908
  wireCombo(slotRow.querySelector('.combo'));
1909
+ const effSelect = slotRow.querySelector('#p-effort-' + s.id);
1910
+ if (effSelect) effSelect.value = effVal || 'auto';
1706
1911
  }
1707
1912
  updateWindowNotes();
1708
1913
  }
@@ -1716,6 +1921,7 @@
1716
1921
  const cfg = getActiveSlotConfig(renderedSlotFormat);
1717
1922
  if (!currentProfileBuffer) currentProfileBuffer = {};
1718
1923
  const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
1924
+ const modelEfforts = { ...(currentProfileBuffer.modelEfforts || {}) };
1719
1925
  for (const s of cfg.slots) {
1720
1926
  const inp = document.getElementById('p-' + s.id);
1721
1927
  if (inp) {
@@ -1723,13 +1929,19 @@
1723
1929
  if (val) defaultModels[s.id] = val;
1724
1930
  else delete defaultModels[s.id];
1725
1931
  }
1932
+ const effSelect = document.getElementById('p-effort-' + s.id);
1933
+ if (effSelect) {
1934
+ const effVal = effSelect.value;
1935
+ if (effVal && effVal !== 'auto') modelEfforts[s.id] = effVal;
1936
+ else delete modelEfforts[s.id];
1937
+ }
1726
1938
  }
1727
1939
  currentProfileBuffer.defaultModels = defaultModels;
1940
+ currentProfileBuffer.modelEfforts = modelEfforts;
1728
1941
  // An old config can still carry the retired 1M flags. They are dropped on the next save
1729
1942
  // instead of being written back: the window is the model's own now.
1730
1943
  delete currentProfileBuffer.model1M;
1731
- return { defaultModels };
1732
-
1944
+ return { defaultModels, modelEfforts };
1733
1945
  }
1734
1946
 
1735
1947
  // 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
@@ -1756,6 +1968,64 @@
1756
1968
  const model = document.getElementById('p-' + s.id)?.value.trim() || '';
1757
1969
  note.textContent = model ? windowLine(model) : '';
1758
1970
  }
1971
+ syncEffortOptions();
1972
+ }
1973
+
1974
+ function syncEffortOptions() {
1975
+ const mode = document.getElementById('p-thinking-mode')?.value;
1976
+ const url = (document.getElementById('p-url')?.value || '').toLowerCase();
1977
+ const tool = document.getElementById('p-tool')?.value || 'auto';
1978
+ const isIntact = url.includes('intact') || url.includes(':20130') || url.includes(':8080') ||
1979
+ url.includes(':20142') || url.includes(':20150') || url.includes(':20152');
1980
+ const isThinkingOff = mode === 'off';
1981
+
1982
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1983
+ let anyBifrost = false;
1984
+
1985
+ for (const s of cfg.slots) {
1986
+ const inp = document.getElementById('p-' + s.id);
1987
+ const effSelect = document.getElementById('p-effort-' + s.id);
1988
+ const bifrostHint = document.getElementById('p-bifrost-' + s.id);
1989
+ if (!effSelect) continue;
1990
+
1991
+ const modelVal = (inp?.value || '').trim().toLowerCase();
1992
+ let slotIsBifrost = false;
1993
+ if (isIntact && modelVal) {
1994
+ if (tool === 'claude') slotIsBifrost = modelVal.startsWith('claude/') || modelVal.startsWith('anthropic/');
1995
+ else if (tool === 'codex') slotIsBifrost = modelVal.startsWith('codex/');
1996
+ else if (tool === 'agy') slotIsBifrost = modelVal.startsWith('antigravity/');
1997
+ else if (tool === 'auto') slotIsBifrost = modelVal.startsWith('claude/') || modelVal.startsWith('anthropic/') || modelVal.startsWith('codex/') || modelVal.startsWith('antigravity/');
1998
+ }
1999
+
2000
+ if (slotIsBifrost) {
2001
+ anyBifrost = true;
2002
+ if (bifrostHint) bifrostHint.style.display = 'block';
2003
+ effSelect.disabled = true;
2004
+ effSelect.style.opacity = '0.6';
2005
+ effSelect.title = 'Bifrost passthrough active: effort controlled natively by client tool';
2006
+ } else {
2007
+ if (bifrostHint) bifrostHint.style.display = 'none';
2008
+ if (isThinkingOff) {
2009
+ effSelect.disabled = true;
2010
+ effSelect.style.opacity = '0.5';
2011
+ effSelect.title = 'Thinking mode is OFF globally';
2012
+ } else {
2013
+ effSelect.disabled = false;
2014
+ effSelect.style.opacity = '1';
2015
+ effSelect.title = 'Target thinking effort for this model';
2016
+ }
2017
+ }
2018
+ }
2019
+
2020
+ const effortEl = document.getElementById('p-thinking-effort');
2021
+ const globalBifrostHint = document.getElementById('bifrost-hint');
2022
+ if (effortEl) {
2023
+ effortEl.disabled = isThinkingOff;
2024
+ effortEl.style.opacity = isThinkingOff ? '0.5' : '1';
2025
+ }
2026
+ if (globalBifrostHint) {
2027
+ globalBifrostHint.style.display = anyBifrost ? 'block' : 'none';
2028
+ }
1759
2029
  }
1760
2030
 
1761
2031
  // Prefetch Counts for Nav Badges
@@ -1783,6 +2053,8 @@
1783
2053
  if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1784
2054
  }
1785
2055
  } catch {}
2056
+
2057
+ loadIdleCompact();
1786
2058
  }
1787
2059
 
1788
2060
  // Load Status
@@ -1790,16 +2062,125 @@
1790
2062
  // dashboard and the terminal cannot show two different settings.
1791
2063
  function showIdleCompact(c) {
1792
2064
  c = c || {};
1793
- document.getElementById('idle-compact-enabled').checked = !!c.enabled;
1794
- document.getElementById('idle-compact-minutes').value = c.idleMinutes ?? 15;
1795
- document.getElementById('idle-compact-kb').value = Math.round((c.minBytes ?? 65536) / 1024);
1796
- document.getElementById('idle-compact-model').value = c.model || '';
2065
+ const enabled = !!c.enabled;
2066
+ const elEnabled = document.getElementById('idle-compact-enabled');
2067
+ if (elEnabled) elEnabled.checked = enabled;
2068
+ const configSec = document.getElementById('idle-compact-config-section');
2069
+ if (configSec) configSec.style.display = enabled ? 'flex' : 'none';
2070
+ const elMinutes = document.getElementById('idle-compact-minutes');
2071
+ if (elMinutes) elMinutes.value = c.idleMinutes ?? 15;
2072
+ const elKb = document.getElementById('idle-compact-kb');
2073
+ if (elKb) elKb.value = (c.minBytes ?? 65536) / 1024;
2074
+ const elModel = document.getElementById('idle-compact-model');
2075
+ if (elModel) {
2076
+ elModel.value = c.model || '';
2077
+ updateCompactModelPresets(c.model || '');
2078
+ }
2079
+ const elCodex = document.getElementById('idle-compact-codex');
2080
+ if (elCodex) elCodex.checked = !!c.codex;
2081
+ const elKeep = document.getElementById('idle-compact-keep');
2082
+ if (elKeep) elKeep.value = c.keepRecent ?? 6;
2083
+ const elUserChars = document.getElementById('idle-compact-user-chars');
2084
+ if (elUserChars) elUserChars.value = c.userChars ?? 3000;
2085
+ const elSummaryChars = document.getElementById('idle-compact-summary-chars');
2086
+ if (elSummaryChars) elSummaryChars.value = c.summaryMaxChars ?? 24000;
2087
+ const elLookback = document.getElementById('idle-compact-lookback');
2088
+ if (elLookback) elLookback.value = c.sessionLookbackHours ?? 72;
2089
+
2090
+ // Badge on hero card
1797
2091
  const badge = document.getElementById('badge-idle-compact');
1798
- badge.textContent = c.enabled ? 'ON' : 'OFF';
1799
- badge.className = 'badge ' + (c.enabled ? 'ok' : 'outline');
2092
+ if (badge) {
2093
+ badge.textContent = enabled ? 'ACTIVE' : 'OFF';
2094
+ badge.className = 'badge ' + (enabled ? 'ok' : 'outline');
2095
+ }
2096
+
2097
+ // Nav link count
2098
+ const ctCompact = document.getElementById('ct-compact');
2099
+ if (ctCompact) {
2100
+ ctCompact.textContent = enabled ? 'ON' : '';
2101
+ }
2102
+
2103
+ // Stats cards
2104
+ const statStatus = document.getElementById('stat-compact-status');
2105
+ if (statStatus) {
2106
+ statStatus.textContent = enabled ? 'Active' : 'Disabled';
2107
+ statStatus.style.color = enabled ? 'var(--ok)' : 'var(--muted)';
2108
+ }
2109
+ const statInd = document.getElementById('stat-compact-indicator');
2110
+ if (statInd) {
2111
+ statInd.textContent = enabled ? 'Prefix caching active' : 'Prefix caching inactive';
2112
+ statInd.className = 'status ' + (enabled ? 'on' : '');
2113
+ }
2114
+ const statPause = document.getElementById('stat-compact-pause');
2115
+ if (statPause) {
2116
+ statPause.textContent = (c.idleMinutes ?? 15) + ' min';
2117
+ }
2118
+ const statSize = document.getElementById('stat-compact-size');
2119
+ if (statSize) {
2120
+ statSize.textContent = ((c.minBytes ?? 65536) / 1024) + ' KB';
2121
+ }
2122
+ const statLookback = document.getElementById('stat-compact-lookback');
2123
+ if (statLookback) {
2124
+ statLookback.textContent = (c.sessionLookbackHours ?? 72) + ' hrs';
2125
+ }
2126
+ }
2127
+
2128
+ async function ensureCatalogLoaded() {
2129
+ if (cachedCatalog) return cachedCatalog;
2130
+ try {
2131
+ const res = await api('/api/catalog');
2132
+ if (res.ok) cachedCatalog = await res.json();
2133
+ } catch {}
2134
+ return cachedCatalog;
2135
+ }
2136
+
2137
+ function setCompactModel(model) {
2138
+ const inp = document.getElementById('idle-compact-model');
2139
+ if (inp) {
2140
+ inp.value = model;
2141
+ saveIdleCompact();
2142
+ updateCompactModelPresets(model);
2143
+ }
2144
+ }
2145
+
2146
+ function updateCompactModelPresets(currentModel) {
2147
+ const container = document.getElementById('compact-model-presets');
2148
+ if (!container) return;
2149
+ if (currentModel === undefined) {
2150
+ currentModel = document.getElementById('idle-compact-model')?.value.trim() || '';
2151
+ }
2152
+
2153
+ const presets = [
2154
+ { id: '', label: 'Deterministic (none)' },
2155
+ { id: 'haiku', label: 'haiku' },
2156
+ { id: 'subagent', label: 'subagent' }
2157
+ ];
2158
+
2159
+ const profiles = state.config?.profiles || {};
2160
+ let added = 0;
2161
+ for (const p of Object.values(profiles)) {
2162
+ if (p.defaultModels) {
2163
+ for (const [slot, m] of Object.entries(p.defaultModels)) {
2164
+ if (m && added < 2 && !presets.some(x => x.id === m)) {
2165
+ presets.push({ id: m, label: m.includes('/') ? m.split('/')[1] : m });
2166
+ added++;
2167
+ }
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ container.innerHTML = `<span class="hint" style="font-size:0.75rem; align-self:center; margin-right:0.2rem;">Quick Presets:</span>` +
2173
+ presets.map(p => {
2174
+ const active = (p.id === currentModel);
2175
+ const style = active
2176
+ ? 'padding:0.18rem 0.5rem; font-size:0.75rem; border-color:var(--accent); color:var(--accent); background:var(--accent-soft); font-weight:600;'
2177
+ : 'padding:0.18rem 0.5rem; font-size:0.75rem;';
2178
+ return `<button type="button" class="btn btn-outline btn-sm" style="${style}" onclick="setCompactModel('${escapeHtml(p.id)}')">${escapeHtml(p.label)}</button>`;
2179
+ }).join('');
1800
2180
  }
1801
2181
 
1802
2182
  async function loadIdleCompact() {
2183
+ ensureCatalogLoaded();
1803
2184
  try {
1804
2185
  const r = await api('/api/idle-compact');
1805
2186
  if (r.ok) showIdleCompact((await r.json()).idleCompact);
@@ -1807,14 +2188,35 @@
1807
2188
  }
1808
2189
 
1809
2190
  async function saveIdleCompact() {
2191
+ const isEnabled = document.getElementById('idle-compact-enabled').checked;
2192
+ const configSec = document.getElementById('idle-compact-config-section');
2193
+ if (configSec) configSec.style.display = isEnabled ? 'flex' : 'none';
1810
2194
  const status = document.getElementById('idle-compact-status');
2195
+ for (const input of document.querySelectorAll('[id^="idle-compact-"][type="number"]')) {
2196
+ if (!input.value || !input.checkValidity()) {
2197
+ input.reportValidity();
2198
+ if (status) {
2199
+ status.textContent = 'Not saved: enter a value within the displayed bounds.';
2200
+ status.style.color = 'var(--err)';
2201
+ }
2202
+ return;
2203
+ }
2204
+ }
1811
2205
  const patch = {
2206
+ codex: document.getElementById('idle-compact-codex').checked,
2207
+ keepRecent: Number(document.getElementById('idle-compact-keep').value),
2208
+ userChars: Number(document.getElementById('idle-compact-user-chars').value),
2209
+ summaryMaxChars: Number(document.getElementById('idle-compact-summary-chars').value),
2210
+ sessionLookbackHours: Number(document.getElementById('idle-compact-lookback').value),
1812
2211
  enabled: document.getElementById('idle-compact-enabled').checked,
1813
- idleMinutes: Number(document.getElementById('idle-compact-minutes').value) || 15,
1814
- minBytes: (Number(document.getElementById('idle-compact-kb').value) || 64) * 1024,
2212
+ idleMinutes: Number(document.getElementById('idle-compact-minutes').value),
2213
+ minBytes: Math.round(Number(document.getElementById('idle-compact-kb').value) * 1024),
1815
2214
  model: document.getElementById('idle-compact-model').value.trim()
1816
2215
  };
1817
- status.textContent = 'Saving...';
2216
+ if (status) {
2217
+ status.textContent = 'Saving...';
2218
+ status.style.color = 'var(--muted)';
2219
+ }
1818
2220
  try {
1819
2221
  const r = await api('/api/idle-compact', {
1820
2222
  method: 'POST',
@@ -1823,9 +2225,18 @@
1823
2225
  });
1824
2226
  if (!r.ok) throw new Error((await r.json()).error || r.status);
1825
2227
  showIdleCompact((await r.json()).idleCompact);
1826
- status.textContent = 'Saved. It applies to the next conversation that comes back after a pause.';
2228
+ if (status) {
2229
+ status.textContent = 'Saved. Applies to subsequent conversations that resume after a pause.';
2230
+ status.style.color = 'var(--ok)';
2231
+ setTimeout(() => { if (status) status.textContent = ''; }, 4500);
2232
+ }
2233
+ showToast('Idle compaction configuration saved');
1827
2234
  } catch (e) {
1828
- status.textContent = 'Not saved: ' + e.message;
2235
+ if (status) {
2236
+ status.textContent = 'Not saved: ' + e.message;
2237
+ status.style.color = 'var(--err)';
2238
+ }
2239
+ showToast('Failed to save: ' + e.message, true);
1829
2240
  }
1830
2241
  }
1831
2242
 
@@ -2036,6 +2447,7 @@
2036
2447
  formLoadedKey = key;
2037
2448
  formDirty = false;
2038
2449
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
2450
+ if (!currentProfileBuffer.modelEfforts) currentProfileBuffer.modelEfforts = {};
2039
2451
 
2040
2452
  document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
2041
2453
  document.getElementById('p-key').value = key;
@@ -2048,6 +2460,11 @@
2048
2460
  document.getElementById('p-outformat').value = p.outFormat || '';
2049
2461
  document.getElementById('p-mode').value = p.mode || 'convert';
2050
2462
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
2463
+ if (document.getElementById('p-thinking-effort')) {
2464
+ document.getElementById('p-thinking-effort').value = p.thinkingEffort || 'auto';
2465
+ }
2466
+ renderModelSlots(toolVal, p);
2467
+ syncEffortOptions();
2051
2468
  document.getElementById('btn-delete').style.display = 'inline-flex';
2052
2469
 
2053
2470
  const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
@@ -2056,8 +2473,6 @@
2056
2473
  if (activeTag) {
2057
2474
  activeTag.textContent = activeTargets.length ? '● Active: ' + activeTargets.join(', ') : '';
2058
2475
  }
2059
-
2060
- renderModelSlots(toolVal, p);
2061
2476
  }
2062
2477
 
2063
2478
  function initNewProfile() {
@@ -2072,7 +2487,8 @@
2072
2487
  outFormat: 'openai-chat',
2073
2488
  mode: 'convert',
2074
2489
  thinkingMode: 'auto',
2075
- defaultModels: {}
2490
+ defaultModels: {},
2491
+ modelEfforts: {}
2076
2492
  };
2077
2493
 
2078
2494
  document.getElementById('form-title').textContent = 'Add New Provider Profile';
@@ -2085,10 +2501,14 @@
2085
2501
  document.getElementById('p-outformat').value = 'openai-chat';
2086
2502
  document.getElementById('p-mode').value = 'convert';
2087
2503
  document.getElementById('p-thinking-mode').value = 'auto';
2504
+ if (document.getElementById('p-thinking-effort')) {
2505
+ document.getElementById('p-thinking-effort').value = 'auto';
2506
+ }
2507
+ renderModelSlots('auto', currentProfileBuffer);
2508
+ syncEffortOptions();
2088
2509
  document.getElementById('btn-delete').style.display = 'none';
2089
2510
  document.getElementById('active-tag').textContent = '';
2090
2511
 
2091
- renderModelSlots('auto', currentProfileBuffer);
2092
2512
  switchTab('tab-general');
2093
2513
  }
2094
2514
 
@@ -2163,6 +2583,8 @@
2163
2583
  document.getElementById('p-outformat').value = t.outFormat;
2164
2584
  document.getElementById('p-mode').value = t.mode;
2165
2585
  document.getElementById('p-thinking-mode').value = t.thinkingMode;
2586
+ document.getElementById('p-thinking-effort').value = t.thinkingEffort || 'auto';
2587
+ syncEffortOptions();
2166
2588
  currentProfileBuffer = { ...currentProfileBuffer, ...t };
2167
2589
  formDirty = true;
2168
2590
  renderModelSlots(t.tool || 'auto', t);
@@ -2218,6 +2640,13 @@
2218
2640
  // already had the key.
2219
2641
  const thinkingMode = document.getElementById('p-thinking-mode').value;
2220
2642
  if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
2643
+ const thinkingEffort = document.getElementById('p-thinking-effort')?.value;
2644
+ if (thinkingEffort && (thinkingEffort !== 'auto' || existing.thinkingEffort)) {
2645
+ profilePayload.thinkingEffort = thinkingEffort;
2646
+ }
2647
+ if (currentProfileBuffer?.modelEfforts && Object.keys(currentProfileBuffer.modelEfforts).length > 0) {
2648
+ profilePayload.modelEfforts = { ...currentProfileBuffer.modelEfforts };
2649
+ }
2221
2650
 
2222
2651
  if (keyVal === MASKED_KEY) {
2223
2652
  profilePayload.apiKey = existing.apiKey || '';
@@ -2428,7 +2857,14 @@
2428
2857
  function wireCombo(combo) {
2429
2858
  const input = combo.querySelector('input');
2430
2859
  combo.activeIndex = -1;
2431
- input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
2860
+ input.addEventListener('input', () => {
2861
+ combo.dataset.filter = '1';
2862
+ combo.activeIndex = -1;
2863
+ openCombo(combo);
2864
+ if (input.id === 'idle-compact-model') {
2865
+ updateCompactModelPresets(input.value.trim());
2866
+ }
2867
+ });
2432
2868
  input.addEventListener('keydown', onComboKeydown);
2433
2869
  input.addEventListener('blur', () => closeCombo(combo));
2434
2870
  // Keep the focus in the field while the list or the arrow is used.
@@ -2492,6 +2928,12 @@
2492
2928
  const list = combo.querySelector('.combo-list');
2493
2929
  const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
2494
2930
  const current = input.value.trim();
2931
+
2932
+ if (input.id === 'idle-compact-model') {
2933
+ renderCompactModelOptions(combo, q, current);
2934
+ return;
2935
+ }
2936
+
2495
2937
  const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
2496
2938
  combo.matches = matches;
2497
2939
  if (!loadedModels.length) {
@@ -2515,6 +2957,119 @@
2515
2957
  setComboActive(combo, combo.activeIndex);
2516
2958
  }
2517
2959
 
2960
+ function getAvailableCompactModels() {
2961
+ const items = [];
2962
+ const seen = new Set();
2963
+
2964
+ const add = (id, label, desc, category) => {
2965
+ if (!seen.has(id)) {
2966
+ seen.add(id);
2967
+ items.push({ id, label: label || id, desc: desc || '', category });
2968
+ }
2969
+ };
2970
+
2971
+ // 1. Deterministic option (empty string)
2972
+ add('', 'Deterministic Truncation (no external call)', 'Pure heuristic context truncation · Zero latency & zero tokens', 'Deterministic');
2973
+
2974
+ // 2. Profile role/slot aliases (recommended for routing)
2975
+ add('haiku', 'haiku (Claude Haiku Slot)', 'Auto-resolves to active profile Claude Haiku model', 'Slot Aliases');
2976
+ add('subagent', 'subagent (Codex Subagent Slot)', 'Auto-resolves to active profile Codex Subagent model', 'Slot Aliases');
2977
+ add('sonnet', 'sonnet (Claude Sonnet Slot)', 'Auto-resolves to active profile Claude Sonnet model', 'Slot Aliases');
2978
+ add('main', 'main (Codex Main Slot)', 'Auto-resolves to active profile Codex Main model', 'Slot Aliases');
2979
+
2980
+ // 3. Models configured in current profiles
2981
+ const profiles = state.config?.profiles || {};
2982
+ for (const [profKey, p] of Object.entries(profiles)) {
2983
+ const profName = p.name || profKey;
2984
+ if (p.defaultModels) {
2985
+ for (const [slot, m] of Object.entries(p.defaultModels)) {
2986
+ if (m) add(m, m, `${profName} · ${slot} slot`, 'Profile Models');
2987
+ }
2988
+ }
2989
+ if (Array.isArray(p.publicModels)) {
2990
+ for (const m of p.publicModels) {
2991
+ if (m) add(m, m, `${profName}`, 'Profile Models');
2992
+ }
2993
+ }
2994
+ }
2995
+
2996
+ // 4. Discovered Catalog models
2997
+ if (cachedCatalog) {
2998
+ if (Array.isArray(cachedCatalog.claude?.models)) {
2999
+ for (const m of cachedCatalog.claude.models) {
3000
+ if (m.id) add(m.id, m.id, `Claude Catalog · ${m.tier || ''}`, 'Catalog');
3001
+ }
3002
+ }
3003
+ if (Array.isArray(cachedCatalog.codex?.models)) {
3004
+ for (const m of cachedCatalog.codex.models) {
3005
+ if (m.id) add(m.id, m.id, `Codex Catalog · ${m.role || ''}`, 'Catalog');
3006
+ }
3007
+ }
3008
+ }
3009
+
3010
+ // 5. Popular lightweight models
3011
+ const popular = [
3012
+ { id: 'zencore/muse-spark-1.3-contributor-free', desc: 'ZenCore contributor fast model' },
3013
+ { id: 'zencore/exo-free', desc: 'ZenCore free pool fast model' },
3014
+ { id: 'claude-3-5-haiku-20241022', desc: 'Anthropic lightweight summarizer' },
3015
+ { id: 'gpt-4o-mini', desc: 'OpenAI low-cost summarizer' },
3016
+ { id: 'gemini-2.5-flash', desc: 'Google Gemini fast summarizer' }
3017
+ ];
3018
+ for (const p of popular) {
3019
+ add(p.id, p.id, p.desc, 'Recommended');
3020
+ }
3021
+
3022
+ return items;
3023
+ }
3024
+
3025
+ function renderCompactModelOptions(combo, q, current) {
3026
+ const input = combo.querySelector('input');
3027
+ const list = combo.querySelector('.combo-list');
3028
+ const allItems = getAvailableCompactModels();
3029
+
3030
+ const filtered = allItems.filter(item => {
3031
+ if (!q) return true;
3032
+ if (item.id === '' && ('deterministic'.includes(q) || 'none'.includes(q) || 'empty'.includes(q) || 'heuristic'.includes(q))) return true;
3033
+ return item.id.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || item.desc.toLowerCase().includes(q);
3034
+ }).slice(0, 150);
3035
+
3036
+ combo.matches = filtered.map(item => item.id);
3037
+
3038
+ if (!filtered.length) {
3039
+ list.innerHTML = '<li class="combo-empty">No matching model found. The typed model will be used directly.</li>';
3040
+ return;
3041
+ }
3042
+
3043
+ if (!(combo.activeIndex >= 0 && combo.activeIndex < filtered.length)) {
3044
+ combo.activeIndex = filtered.findIndex(item => item.id === current);
3045
+ }
3046
+
3047
+ list.innerHTML = filtered.map((item, i) => {
3048
+ const isSelected = item.id === current;
3049
+ let badgeClass = 'badge outline';
3050
+ if (item.category === 'Deterministic') badgeClass = 'badge ok';
3051
+ else if (item.category === 'Slot Aliases') badgeClass = 'badge accent';
3052
+ else if (item.category === 'Profile Models') badgeClass = 'badge';
3053
+
3054
+ const cut = item.id.lastIndexOf('/') + 1;
3055
+ const prefix = cut ? `<span class="pfx">${markMatch(item.id.slice(0, cut), q)}</span>` : '';
3056
+ const mainText = item.id ? `${prefix}${markMatch(item.id.slice(cut), q)}` : markMatch(item.label, q);
3057
+
3058
+ return `<li class="combo-opt" role="option" id="${input.id}-opt-${i}" data-i="${i}" aria-selected="${i === combo.activeIndex}" style="display:flex; flex-direction:column; gap:0.18rem; padding:0.45rem 0.6rem; border-bottom:1px solid rgba(255,255,255,0.04);" onmousedown="event.preventDefault(); chooseComboOption(this)">`
3059
+ + `<div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; width:100%;">`
3060
+ + `<span class="id" style="font-weight:600;">${mainText}</span>`
3061
+ + `<div style="display:flex; align-items:center; gap:0.4rem; flex:none;">`
3062
+ + `<span class="${badgeClass}" style="font-size:0.68rem; padding:0.1rem 0.35rem;">${escapeHtml(item.category)}</span>`
3063
+ + (isSelected ? '<span class="cur" style="font-weight:600;">active</span>' : '')
3064
+ + `</div>`
3065
+ + `</div>`
3066
+ + (item.desc && item.id ? `<div style="font-size:0.72rem; color:var(--muted); font-family:var(--font-sans);">${escapeHtml(item.desc)}</div>` : '')
3067
+ + `</li>`;
3068
+ }).join('');
3069
+
3070
+ setComboActive(combo, combo.activeIndex);
3071
+ }
3072
+
2518
3073
  function setComboActive(combo, i) {
2519
3074
  const input = combo.querySelector('input');
2520
3075
  combo.activeIndex = i;
@@ -2533,12 +3088,18 @@
2533
3088
 
2534
3089
  function chooseComboOption(li) {
2535
3090
  const combo = li.closest('.combo');
2536
- const model = combo?.matches?.[Number(li.dataset.i)];
2537
- if (!model) return;
2538
- combo.querySelector('input').value = model;
3091
+ const idx = Number(li.dataset.i);
3092
+ const model = combo?.matches?.[idx];
3093
+ if (model === undefined) return;
3094
+ const input = combo.querySelector('input');
3095
+ input.value = model;
2539
3096
  formDirty = true;
2540
3097
  closeCombo(combo);
2541
3098
  updateWindowNotes();
3099
+ if (input.id === 'idle-compact-model') {
3100
+ saveIdleCompact();
3101
+ updateCompactModelPresets(model);
3102
+ }
2542
3103
  }
2543
3104
 
2544
3105
  function onComboKeydown(e) {
@@ -2873,6 +3434,17 @@
2873
3434
  // Setup log auto-refresh
2874
3435
  toggleLogAutoRefresh();
2875
3436
 
3437
+ // Setup profile modal effort & bifrost change triggers
3438
+ document.getElementById('p-thinking-mode')?.addEventListener('change', syncEffortOptions);
3439
+ document.getElementById('p-mode')?.addEventListener('change', syncEffortOptions);
3440
+ document.getElementById('p-outformat')?.addEventListener('change', syncEffortOptions);
3441
+ document.getElementById('p-url')?.addEventListener('input', syncEffortOptions);
3442
+ document.getElementById('p-tool')?.addEventListener('change', syncEffortOptions);
3443
+
3444
+ // Wire Idle Compaction summarization model combobox
3445
+ const compactCombo = document.getElementById('combo-compact-model');
3446
+ if (compactCombo) wireCombo(compactCombo);
3447
+
2876
3448
  // Trigger initial route
2877
3449
  handleRoute();
2878
3450