reasonix 0.30.5 → 0.32.0

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.
@@ -19264,8 +19264,36 @@ var en = {
19264
19264
  effortHigh: "high (cheaper / faster)",
19265
19265
  webSearch: "web search",
19266
19266
  webSearchNote: "web_fetch + web_search tools",
19267
+ sectionCompute: "Compute",
19268
+ proNext: "/pro one-shot",
19269
+ proArm: "Arm for next turn",
19270
+ proArmed: "Armed \u2014 disarms after next turn",
19271
+ proNextNote: "next turn runs on deepseek-v4-pro, then auto-disarms",
19272
+ sectionBudget: "Budget",
19273
+ budgetOf: "of",
19274
+ budgetSetCap: "set a cap",
19275
+ budgetCustom: "custom",
19276
+ budgetBumpHint: "bump the cap to keep going",
19277
+ budgetClear: "Clear cap",
19278
+ budgetIdleLine: "warns at 80% \xB7 refuses past 100%",
19279
+ budgetWarnLine: "approaching cap \u2014 loop will refuse past 100%",
19280
+ budgetRefusing: "cap exhausted \u2014 next turn refused until bumped or cleared",
19281
+ sectionLoop: "Loop",
19282
+ loopIdleHint: "Auto-resubmit a prompt on a fixed interval.",
19283
+ loopCostHint: "Each iteration costs ~{cost} (last turn).",
19284
+ loopInterval: "interval",
19285
+ loopCustom: "custom",
19286
+ loopRangeError: "interval must fall in 5s..6h",
19287
+ loopPrompt: "prompt",
19288
+ loopPromptPlaceholder: "e.g. check the deploy status and report any errors",
19289
+ loopStart: "Start loop",
19290
+ loopStop: "Stop",
19291
+ loopRunning: "running",
19292
+ loopIter: "iter {iter}",
19293
+ loopFiresIn: "fires in {remaining}",
19267
19294
  sectionRuntime: "Runtime",
19268
19295
  activeModel: "active model",
19296
+ modelPricingLine: "${hit} hit \xB7 ${miss} miss \xB7 ${out} out per 1M tok",
19269
19297
  editMode: "edit mode",
19270
19298
  editModeNote: "switch from the Chat tab header",
19271
19299
  sectionLanguage: "Language",
@@ -19348,6 +19376,7 @@ var en = {
19348
19376
  tokens7d: "tokens \xB7 7d",
19349
19377
  cacheHit: "cache hit",
19350
19378
  toolCalls24h: "tool calls \xB7 24h",
19379
+ budget: "budget",
19351
19380
  currentSession: "current session",
19352
19381
  noSession: "No live session \u2014 /dashboard from inside reasonix code to attach.",
19353
19382
  promptTok: "prompt tok",
@@ -19578,6 +19607,7 @@ var en = {
19578
19607
  filterPlaceholder: "filter plans",
19579
19608
  active: "active",
19580
19609
  done: "done",
19610
+ idle: "idle",
19581
19611
  steps: "steps",
19582
19612
  pickHint: "Pick a plan on the left.",
19583
19613
  noTitle: "(no title)",
@@ -19607,16 +19637,34 @@ var en = {
19607
19637
  pullModel: "Pull {model}",
19608
19638
  indexStatus: "index status",
19609
19639
  builtStatus: "\u25CF built",
19640
+ incompatibleStatus: "\u25CF incompatible",
19610
19641
  chunks: "chunks",
19611
19642
  files: "files",
19612
19643
  dim: "dim",
19613
19644
  size: "size",
19614
19645
  lastBuild: "last build",
19646
+ builtWith: "built with",
19647
+ currentTarget: "current target",
19648
+ incompatibleHint: "This on-disk index was built for a different provider or model. Run Rebuild to replace it.",
19615
19649
  runIndexHint: "Run an index to enable semantic_search.",
19616
19650
  reIndex: "Re-index",
19617
19651
  build: "Build",
19618
19652
  rebuild: "Rebuild",
19619
19653
  stop: "Stop",
19654
+ provider: "provider",
19655
+ providerType: "service type",
19656
+ openaiCompat: "openai-compatible",
19657
+ apiUrl: "API URL",
19658
+ apiKey: "API key",
19659
+ apiKeyStoredNote: "API key is stored in ~/.reasonix/config.json \u2014 do not share that file.",
19660
+ customRequestBody: "custom request body",
19661
+ invalidCustomRequestBody: "Custom request body must be valid JSON: {error}",
19662
+ customRequestBodyMustBeObject: "Custom request body must be a JSON object.",
19663
+ saveBeforeIndex: "Save semantic settings before starting an index.",
19664
+ extraBody: "extra body",
19665
+ keepExistingKey: "leave blank to keep existing key",
19666
+ remoteProvider: "Remote embedding provider",
19667
+ remoteProviderDesc: "Configure the full OpenAI-compatible embeddings URL here. Reasonix will send requests exactly to the URL you provide.",
19620
19668
  ollama: "ollama",
19621
19669
  binary: "binary",
19622
19670
  found: "found",
@@ -19644,11 +19692,14 @@ var en = {
19644
19692
  nothingSkipped: "nothing skipped \u2014 all walked files would be indexed.",
19645
19693
  firstIncluded: "first {count} included file(s)",
19646
19694
  job: "Job",
19695
+ phaseSetup: "preparing",
19647
19696
  phaseScan: "scanning files",
19648
19697
  phaseEmbed: "embedding chunks",
19649
19698
  phaseWrite: "writing index",
19650
19699
  phaseDone: "done",
19651
19700
  phaseError: "error",
19701
+ phaseCancelled: "cancelled",
19702
+ setupFailed: "setup failed",
19652
19703
  stopping: "stopping",
19653
19704
  scanned: "scanned {count}",
19654
19705
  changed: "changed {count}",
@@ -19709,7 +19760,17 @@ var en = {
19709
19760
  accept: "Accept",
19710
19761
  reject: "Reject",
19711
19762
  arguments: "arguments",
19712
- revisePlaceholder: "What needs to change before the next step? Leave blank to just continue."
19763
+ revisePlaceholder: "What needs to change before the next step? Leave blank to just continue.",
19764
+ pickerFilter: "Filter\u2026",
19765
+ pickerEmpty: "Nothing to show.",
19766
+ pickerLoadMore: "Load more",
19767
+ pickerPick: "Open",
19768
+ pickerInstall: "Install",
19769
+ pickerUninstall: "Uninstall",
19770
+ pickerRename: "Rename\u2026",
19771
+ pickerNew: "New\u2026",
19772
+ pickerNewPlaceholder: "Name (leave blank for default)",
19773
+ viewerClose: "Close"
19713
19774
  }
19714
19775
  };
19715
19776
 
@@ -19777,8 +19838,36 @@ var zhCN = {
19777
19838
  effortHigh: "high\uFF08\u66F4\u4FBF\u5B9C / \u66F4\u5FEB\uFF09",
19778
19839
  webSearch: "\u7F51\u9875\u641C\u7D22",
19779
19840
  webSearchNote: "web_fetch + web_search \u5DE5\u5177",
19841
+ sectionCompute: "\u8BA1\u7B97",
19842
+ proNext: "/pro \u5355\u8F6E",
19843
+ proArm: "\u4E3A\u4E0B\u4E00\u8F6E\u88C5\u5907",
19844
+ proArmed: "\u5DF2\u88C5\u5907 \u2014 \u4E0B\u4E00\u8F6E\u540E\u81EA\u52A8\u89E3\u9664",
19845
+ proNextNote: "\u4E0B\u4E00\u8F6E\u4F7F\u7528 deepseek-v4-pro\uFF0C\u4E4B\u540E\u81EA\u52A8\u89E3\u9664",
19846
+ sectionBudget: "\u9884\u7B97",
19847
+ budgetOf: "/",
19848
+ budgetSetCap: "\u8BBE\u7F6E\u4E0A\u9650",
19849
+ budgetCustom: "\u81EA\u5B9A\u4E49",
19850
+ budgetBumpHint: "\u63D0\u9AD8\u4E0A\u9650\u4EE5\u7EE7\u7EED",
19851
+ budgetClear: "\u6E05\u9664\u4E0A\u9650",
19852
+ budgetIdleLine: "80% \u65F6\u63D0\u9192 \xB7 100% \u540E\u62D2\u7EDD\u6267\u884C",
19853
+ budgetWarnLine: "\u63A5\u8FD1\u4E0A\u9650 \u2014 \u8D85\u8FC7 100% \u5C06\u62D2\u7EDD\u6267\u884C",
19854
+ budgetRefusing: "\u5DF2\u8D85\u51FA\u4E0A\u9650 \u2014 \u63D0\u9AD8\u6216\u6E05\u9664\u540E\u624D\u4F1A\u7EE7\u7EED",
19855
+ sectionLoop: "\u5FAA\u73AF",
19856
+ loopIdleHint: "\u6309\u56FA\u5B9A\u95F4\u9694\u81EA\u52A8\u91CD\u65B0\u63D0\u4EA4\u4E00\u6BB5\u63D0\u793A\u8BCD\u3002",
19857
+ loopCostHint: "\u6BCF\u6B21\u8FED\u4EE3\u7EA6 {cost}\uFF08\u4E0A\u4E00\u8F6E\u6210\u672C\uFF09\u3002",
19858
+ loopInterval: "\u95F4\u9694",
19859
+ loopCustom: "\u81EA\u5B9A\u4E49",
19860
+ loopRangeError: "\u95F4\u9694\u9700\u5728 5s..6h \u4E4B\u95F4",
19861
+ loopPrompt: "\u63D0\u793A\u8BCD",
19862
+ loopPromptPlaceholder: "\u4F8B\u5982\uFF1A\u68C0\u67E5\u90E8\u7F72\u72B6\u6001\u5E76\u6C47\u62A5\u4EFB\u4F55\u9519\u8BEF",
19863
+ loopStart: "\u542F\u52A8\u5FAA\u73AF",
19864
+ loopStop: "\u505C\u6B62",
19865
+ loopRunning: "\u8FD0\u884C\u4E2D",
19866
+ loopIter: "\u7B2C {iter} \u6B21",
19867
+ loopFiresIn: "{remaining} \u540E\u89E6\u53D1",
19780
19868
  sectionRuntime: "\u8FD0\u884C\u65F6",
19781
19869
  activeModel: "\u5F53\u524D\u6A21\u578B",
19870
+ modelPricingLine: "${hit} \u547D\u4E2D \xB7 ${miss} \u672A\u547D\u4E2D \xB7 ${out} \u8F93\u51FA / 100 \u4E07 tok",
19782
19871
  editMode: "\u7F16\u8F91\u6A21\u5F0F",
19783
19872
  editModeNote: "\u5728\u5BF9\u8BDD\u6807\u7B7E\u9875\u5934\u90E8\u5207\u6362",
19784
19873
  sectionLanguage: "\u8BED\u8A00",
@@ -19861,6 +19950,7 @@ var zhCN = {
19861
19950
  tokens7d: "tokens \xB7 7 \u5929",
19862
19951
  cacheHit: "\u7F13\u5B58\u547D\u4E2D",
19863
19952
  toolCalls24h: "\u5DE5\u5177\u8C03\u7528 \xB7 24 \u5C0F\u65F6",
19953
+ budget: "\u9884\u7B97",
19864
19954
  currentSession: "\u5F53\u524D\u4F1A\u8BDD",
19865
19955
  noSession: "\u65E0\u6D3B\u8DC3\u4F1A\u8BDD \u2014 \u5728 reasonix code \u5185\u6267\u884C /dashboard \u8FDB\u884C\u8FDE\u63A5\u3002",
19866
19956
  promptTok: "\u63D0\u793A tokens",
@@ -20091,6 +20181,7 @@ var zhCN = {
20091
20181
  filterPlaceholder: "\u7B5B\u9009\u8BA1\u5212",
20092
20182
  active: "\u8FDB\u884C\u4E2D",
20093
20183
  done: "\u5DF2\u5B8C\u6210",
20184
+ idle: "\u672A\u5F00\u59CB",
20094
20185
  steps: "\u6B65\u9AA4",
20095
20186
  pickHint: "\u9009\u62E9\u5DE6\u4FA7\u7684\u8BA1\u5212\u3002",
20096
20187
  noTitle: "\uFF08\u65E0\u6807\u9898\uFF09",
@@ -20120,16 +20211,33 @@ var zhCN = {
20120
20211
  pullModel: "\u62C9\u53D6 {model}",
20121
20212
  indexStatus: "\u7D22\u5F15\u72B6\u6001",
20122
20213
  builtStatus: "\u25CF \u5DF2\u6784\u5EFA",
20214
+ incompatibleStatus: "\u25CF \u4E0D\u517C\u5BB9",
20123
20215
  chunks: "\u5206\u5757",
20124
20216
  files: "\u6587\u4EF6",
20125
20217
  dim: "\u7EF4\u5EA6",
20126
20218
  size: "\u5927\u5C0F",
20127
20219
  lastBuild: "\u4E0A\u6B21\u6784\u5EFA",
20220
+ builtWith: "\u6784\u5EFA\u6765\u6E90",
20221
+ currentTarget: "\u5F53\u524D\u76EE\u6807",
20222
+ incompatibleHint: "\u78C1\u76D8\u4E0A\u7684\u8FD9\u4E2A\u7D22\u5F15\u662F\u4E3A\u4E0D\u540C\u7684 provider \u6216 model \u6784\u5EFA\u7684\u3002\u8FD0\u884C\u201C\u5B8C\u5168\u91CD\u5EFA\u201D\u5373\u53EF\u66FF\u6362\u3002",
20128
20223
  runIndexHint: "\u8FD0\u884C\u7D22\u5F15\u4EE5\u542F\u7528 semantic_search\u3002",
20129
20224
  reIndex: "\u91CD\u5EFA\u7D22\u5F15",
20130
20225
  build: "\u6784\u5EFA",
20131
20226
  rebuild: "\u5B8C\u5168\u91CD\u5EFA",
20132
20227
  stop: "\u505C\u6B62",
20228
+ provider: "\u63D0\u4F9B\u65B9",
20229
+ providerType: "\u670D\u52A1\u7C7B\u578B",
20230
+ openaiCompat: "OpenAI-Compatible",
20231
+ apiUrl: "API URL",
20232
+ apiKey: "API Key",
20233
+ customRequestBody: "\u81EA\u5B9A\u4E49\u8BF7\u6C42\u4F53",
20234
+ invalidCustomRequestBody: "\u81EA\u5B9A\u4E49\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F\u5408\u6CD5 JSON\uFF1A{error}",
20235
+ customRequestBodyMustBeObject: "\u81EA\u5B9A\u4E49\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61\u3002",
20236
+ saveBeforeIndex: "\u8BF7\u5148\u4FDD\u5B58\u8BED\u4E49\u8BBE\u7F6E\uFF0C\u518D\u542F\u52A8\u7D22\u5F15\u3002",
20237
+ extraBody: "\u6269\u5C55\u8BF7\u6C42\u4F53",
20238
+ keepExistingKey: "\u7559\u7A7A\u5219\u4FDD\u7559\u73B0\u6709 Key",
20239
+ remoteProvider: "\u8FDC\u7A0B\u5411\u91CF\u670D\u52A1",
20240
+ remoteProviderDesc: "\u5728\u8FD9\u91CC\u914D\u7F6E OpenAI-Compatible embeddings \u7684\u5B8C\u6574 URL\u3002Reasonix \u4F1A\u4E25\u683C\u4F7F\u7528\u4F60\u63D0\u4F9B\u7684 URL \u53D1\u8D77\u8BF7\u6C42\u3002",
20133
20241
  ollama: "Ollama",
20134
20242
  binary: "\u4E8C\u8FDB\u5236",
20135
20243
  found: "\u5DF2\u627E\u5230",
@@ -20157,11 +20265,14 @@ var zhCN = {
20157
20265
  nothingSkipped: "\u65E0\u8DF3\u8FC7 \u2014 \u6240\u6709\u904D\u5386\u7684\u6587\u4EF6\u90FD\u5C06\u88AB\u7D22\u5F15\u3002",
20158
20266
  firstIncluded: "\u524D {count} \u4E2A\u5305\u542B\u7684\u6587\u4EF6",
20159
20267
  job: "\u4EFB\u52A1",
20268
+ phaseSetup: "\u521D\u59CB\u5316\u4E2D",
20160
20269
  phaseScan: "\u626B\u63CF\u6587\u4EF6",
20161
20270
  phaseEmbed: "\u5D4C\u5165\u5206\u5757",
20162
20271
  phaseWrite: "\u5199\u5165\u7D22\u5F15",
20163
20272
  phaseDone: "\u5B8C\u6210",
20164
20273
  phaseError: "\u9519\u8BEF",
20274
+ phaseCancelled: "\u5DF2\u505C\u6B62",
20275
+ setupFailed: "\u521D\u59CB\u5316\u5931\u8D25",
20165
20276
  stopping: "\u505C\u6B62\u4E2D",
20166
20277
  scanned: "\u5DF2\u626B\u63CF {count}",
20167
20278
  changed: "\u5DF2\u53D8\u66F4 {count}",
@@ -20222,7 +20333,17 @@ var zhCN = {
20222
20333
  accept: "\u63A5\u53D7",
20223
20334
  reject: "\u62D2\u7EDD",
20224
20335
  arguments: "\u53C2\u6570",
20225
- revisePlaceholder: "\u4E0B\u4E00\u6B65\u4E4B\u524D\u9700\u8981\u66F4\u6539\u4EC0\u4E48\uFF1F\u7559\u7A7A\u5219\u76F4\u63A5\u7EE7\u7EED\u3002"
20336
+ revisePlaceholder: "\u4E0B\u4E00\u6B65\u4E4B\u524D\u9700\u8981\u66F4\u6539\u4EC0\u4E48\uFF1F\u7559\u7A7A\u5219\u76F4\u63A5\u7EE7\u7EED\u3002",
20337
+ pickerFilter: "\u8FC7\u6EE4\u2026",
20338
+ pickerEmpty: "\u6682\u65E0\u5185\u5BB9\u3002",
20339
+ pickerLoadMore: "\u52A0\u8F7D\u66F4\u591A",
20340
+ pickerPick: "\u6253\u5F00",
20341
+ pickerInstall: "\u5B89\u88C5",
20342
+ pickerUninstall: "\u5378\u8F7D",
20343
+ pickerRename: "\u91CD\u547D\u540D\u2026",
20344
+ pickerNew: "\u65B0\u5EFA\u2026",
20345
+ pickerNewPlaceholder: "\u540D\u79F0\uFF08\u7559\u7A7A\u4F7F\u7528\u9ED8\u8BA4\uFF09",
20346
+ viewerClose: "\u5173\u95ED"
20226
20347
  }
20227
20348
  };
20228
20349
 
@@ -22930,6 +23051,156 @@ function CheckpointModal({ modal, onResolve }) {
22930
23051
  <//>
22931
23052
  `;
22932
23053
  }
23054
+ function PickerModal({
23055
+ modal,
23056
+ onResolve
23057
+ }) {
23058
+ useLang();
23059
+ const [selectedId, setSelectedId] = d2(modal.items[0]?.id ?? null);
23060
+ const [query2, setQuery] = d2(modal.query ?? "");
23061
+ const [renameTarget, setRenameTarget] = d2(null);
23062
+ const [renameText, setRenameText] = d2("");
23063
+ const [showNew, setShowNew] = d2(false);
23064
+ const [newText, setNewText] = d2("");
23065
+ const has = (a3) => modal.actions.includes(a3);
23066
+ const selected = modal.items.find((i3) => i3.id === selectedId) ?? null;
23067
+ const submitRefine = (next) => {
23068
+ setQuery(next);
23069
+ if (has("refine")) onResolve("picker", { action: "refine", query: next });
23070
+ };
23071
+ const startRename = (id) => {
23072
+ const item = modal.items.find((i3) => i3.id === id);
23073
+ if (!item) return;
23074
+ setRenameTarget(id);
23075
+ setRenameText(item.title);
23076
+ };
23077
+ const sendRename = () => {
23078
+ if (!renameTarget || !renameText.trim()) return;
23079
+ onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
23080
+ setRenameTarget(null);
23081
+ setRenameText("");
23082
+ };
23083
+ const sendNew = () => {
23084
+ onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
23085
+ setShowNew(false);
23086
+ setNewText("");
23087
+ };
23088
+ return html4`
23089
+ <${ModalCard}
23090
+ accent="#fcd34d"
23091
+ icon="≡"
23092
+ title=${modal.title}
23093
+ subtitle=${modal.hint}
23094
+ >
23095
+ ${has("refine") ? html4`<input
23096
+ class="modal-picker-search"
23097
+ type="search"
23098
+ placeholder=${t4("modal.pickerFilter")}
23099
+ value=${query2}
23100
+ onInput=${(e3) => submitRefine(e3.target.value)}
23101
+ />` : null}
23102
+ <div class="modal-picker-list">
23103
+ ${modal.items.length === 0 ? html4`<div class="modal-picker-empty">${t4("modal.pickerEmpty")}</div>` : modal.items.map(
23104
+ (it) => html4`
23105
+ <button
23106
+ key=${it.id}
23107
+ class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
23108
+ onClick=${() => setSelectedId(it.id)}
23109
+ onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
23110
+ >
23111
+ <span class="modal-picker-title">${it.title}</span>
23112
+ ${it.badge ? html4`<span class="modal-picker-badge">${it.badge}</span>` : null}
23113
+ ${it.subtitle ? html4`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
23114
+ ${it.meta ? html4`<span class="modal-picker-meta">${it.meta}</span>` : null}
23115
+ </button>
23116
+ `
23117
+ )}
23118
+ </div>
23119
+ ${modal.hasMore && has("load-more") ? html4`<button
23120
+ class="modal-picker-more"
23121
+ onClick=${() => onResolve("picker", { action: "load-more" })}
23122
+ >${t4("modal.pickerLoadMore")}</button>` : null}
23123
+ ${renameTarget ? html4`
23124
+ <div class="modal-picker-form">
23125
+ <input
23126
+ type="text"
23127
+ value=${renameText}
23128
+ onInput=${(e3) => setRenameText(e3.target.value)}
23129
+ />
23130
+ <div class="modal-actions">
23131
+ <button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t4("common.save")}</button>
23132
+ <button onClick=${() => setRenameTarget(null)}>${t4("common.back")}</button>
23133
+ </div>
23134
+ </div>
23135
+ ` : showNew ? html4`
23136
+ <div class="modal-picker-form">
23137
+ <input
23138
+ type="text"
23139
+ placeholder=${t4("modal.pickerNewPlaceholder")}
23140
+ value=${newText}
23141
+ onInput=${(e3) => setNewText(e3.target.value)}
23142
+ />
23143
+ <div class="modal-actions">
23144
+ <button class="primary" onClick=${sendNew}>${t4("common.add")}</button>
23145
+ <button onClick=${() => setShowNew(false)}>${t4("common.back")}</button>
23146
+ </div>
23147
+ </div>
23148
+ ` : html4`
23149
+ <div class="modal-actions">
23150
+ ${has("pick") && selected ? html4`<button
23151
+ class="primary"
23152
+ onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
23153
+ >${t4("modal.pickerPick")}</button>` : null}
23154
+ ${has("install") && selected ? html4`<button
23155
+ class="primary"
23156
+ onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
23157
+ >${t4("modal.pickerInstall")}</button>` : null}
23158
+ ${has("uninstall") && selected ? html4`<button
23159
+ onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
23160
+ >${t4("modal.pickerUninstall")}</button>` : null}
23161
+ ${has("rename") && selected ? html4`<button onClick=${() => startRename(selected.id)}>${t4("modal.pickerRename")}</button>` : null}
23162
+ ${has("delete") && selected ? html4`<button
23163
+ class="danger"
23164
+ onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
23165
+ >${t4("common.delete")}</button>` : null}
23166
+ ${has("new") ? html4`<button onClick=${() => setShowNew(true)}>${t4("modal.pickerNew")}</button>` : null}
23167
+ <button onClick=${() => onResolve("picker", { action: "cancel" })}>${t4("modal.cancel")}</button>
23168
+ </div>
23169
+ `}
23170
+ <//>
23171
+ `;
23172
+ }
23173
+ function ViewerModal({
23174
+ modal,
23175
+ onResolve
23176
+ }) {
23177
+ useLang();
23178
+ return html4`
23179
+ <${ModalCard}
23180
+ accent="#67e8f9"
23181
+ icon="◇"
23182
+ title=${modal.title}
23183
+ subtitle=${modal.meta}
23184
+ >
23185
+ ${modal.steps && modal.steps.length > 0 ? html4`
23186
+ <ol class="modal-viewer-steps">
23187
+ ${modal.steps.map(
23188
+ (s3) => html4`
23189
+ <li key=${s3.id} class=${`modal-viewer-step modal-viewer-step-${s3.status}`}>
23190
+ <span class="modal-viewer-step-mark">${s3.status === "done" ? "\u2713" : "\xB7"}</span>
23191
+ <span class="modal-viewer-step-title">${s3.title}</span>
23192
+ </li>
23193
+ `
23194
+ )}
23195
+ </ol>
23196
+ ` : null}
23197
+ ${modal.body ? html4`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>` : null}
23198
+ <div class="modal-actions">
23199
+ <button onClick=${() => onResolve("viewer", { action: "close" })}>${t4("modal.viewerClose")}</button>
23200
+ </div>
23201
+ <//>
23202
+ `;
23203
+ }
22933
23204
  function RevisionModal({ modal, onResolve }) {
22934
23205
  useLang();
22935
23206
  const riskColor = (r3) => r3 === "high" ? "#f87171" : r3 === "med" ? "#fbbf24" : r3 === "low" ? "#86efac" : "#9ca3af";
@@ -23514,7 +23785,7 @@ function ChatPanel() {
23514
23785
  </div>` : null}
23515
23786
  ${error ? html4`<div class="notice err">${error}</div>` : null}
23516
23787
 
23517
- ${modal ? modal.kind === "shell" ? html4`<${ShellModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "choice" ? html4`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "plan" ? html4`<${PlanModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "edit-review" ? html4`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "workspace" ? html4`<${WorkspaceModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "checkpoint" ? html4`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "revision" ? html4`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />` : null : null}
23788
+ ${modal ? modal.kind === "shell" ? html4`<${ShellModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "choice" ? html4`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "plan" ? html4`<${PlanModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "edit-review" ? html4`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "workspace" ? html4`<${WorkspaceModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "checkpoint" ? html4`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "revision" ? html4`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "picker" ? html4`<${PickerModal} modal=${modal} onResolve=${resolveModal} />` : modal.kind === "viewer" ? html4`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />` : null : null}
23518
23789
 
23519
23790
  <div class="chat-body">
23520
23791
  <div class="chat-main">
@@ -23599,7 +23870,7 @@ function SideRail({ stats, budgetUsd, activePlan }) {
23599
23870
  const cacheTone = cachePct >= 80 ? "ok" : cachePct >= 50 ? "" : "warn";
23600
23871
  const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
23601
23872
  const budgetPct = showBudget ? Math.min(120, stats.totalCostUsd / budgetUsd * 100) : 0;
23602
- const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
23873
+ const budgetTone2 = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
23603
23874
  const walletCurrency = stats?.balance?.[0]?.currency;
23604
23875
  return html4`
23605
23876
  <aside class="chat-rail">
@@ -23622,8 +23893,8 @@ function SideRail({ stats, budgetUsd, activePlan }) {
23622
23893
  <div class="rh">${t4("chat.railToolBudget")}</div>
23623
23894
  <div class="progress-row">
23624
23895
  <span class="lbl">${t4("chat.railSpend")}</span>
23625
- <div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
23626
- <span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
23896
+ <div class=${`progress ${budgetTone2}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
23897
+ <span class="v" style=${budgetTone2 === "err" ? "color:var(--c-err)" : budgetTone2 === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
23627
23898
  </div>
23628
23899
  </div>
23629
23900
  ` : null}
@@ -24564,6 +24835,35 @@ function MemoryPanel() {
24564
24835
  `;
24565
24836
  }
24566
24837
 
24838
+ // dashboard/src/lib/budget.ts
24839
+ function deriveBudgetState(cap, spent) {
24840
+ const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
24841
+ if (typeof cap !== "number" || cap <= 0) {
24842
+ return { kind: "off", spent: safeSpent };
24843
+ }
24844
+ const pct = safeSpent / cap * 100;
24845
+ if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
24846
+ if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
24847
+ return { kind: "running", cap, spent: safeSpent, pct };
24848
+ }
24849
+ var QUICK_CAPS_USD = [1, 5, 10, 25, 50];
24850
+ function bumpSuggestions(currentCap) {
24851
+ if (currentCap <= 0) return [];
24852
+ return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
24853
+ }
24854
+ function niceUp(n3) {
24855
+ const eps = 1e-9;
24856
+ if (n3 < 1) return Math.ceil((n3 - eps) * 10) / 10;
24857
+ if (n3 < 10) return Math.ceil((n3 - eps) * 2) / 2;
24858
+ if (n3 < 100) return Math.ceil(n3 - eps);
24859
+ return Math.ceil((n3 - eps) / 5) * 5;
24860
+ }
24861
+ function budgetTone(state) {
24862
+ if (state.kind === "exhausted") return "err";
24863
+ if (state.kind === "warn") return "warn";
24864
+ return "";
24865
+ }
24866
+
24567
24867
  // dashboard/src/lib/use-poll.ts
24568
24868
  function usePoll(path, intervalMs = 2e3) {
24569
24869
  const [data, setData] = d2(null);
@@ -24634,6 +24934,19 @@ function balanceKpi(c3) {
24634
24934
  const symbol = c3.balance.currency === "CNY" ? "\xA5" : c3.balance.currency === "USD" ? "$" : "";
24635
24935
  return kpi(t4("overview.balance"), `${symbol}${c3.balance.total}`, c3.balance.currency, "flat");
24636
24936
  }
24937
+ function budgetKpi(o3) {
24938
+ const state = deriveBudgetState(o3.budgetUsd, o3.cockpit?.currentSession?.totalCostUsd ?? null);
24939
+ if (state.kind === "off") return null;
24940
+ const tone = budgetTone(state);
24941
+ const valueColor = tone === "err" ? "color:var(--c-err)" : tone === "warn" ? "color:var(--c-warn)" : "";
24942
+ return html4`
24943
+ <div class="kpi cock-w-1">
24944
+ <div class="label">${t4("overview.budget")}</div>
24945
+ <div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
24946
+ <div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
24947
+ </div>
24948
+ `;
24949
+ }
24637
24950
  function tokens7dKpi(c3) {
24638
24951
  if (!c3.tokens7d) return kpi(t4("overview.tokens7d"), "\u2014", t4("overview.noUsageYet"), "flat");
24639
24952
  const d3 = deltaPctText(c3.tokens7d.deltaPct);
@@ -24789,6 +25102,7 @@ function OverviewPanel() {
24789
25102
  ${tokens7dKpi(c3)}
24790
25103
  ${cacheHitKpi(c3)}
24791
25104
  ${toolCallsKpi(c3)}
25105
+ ${budgetKpi(o3)}
24792
25106
 
24793
25107
  ${currentSessionBlock(c3)}
24794
25108
  ${costTrendSpark(c3)}
@@ -25001,7 +25315,7 @@ function PermissionsPanel() {
25001
25315
  function statusPill(p3) {
25002
25316
  if (p3.completionRatio >= 1) return html4`<span class="pill ok">${t4("plans.done")}</span>`;
25003
25317
  if (p3.completionRatio > 0) return html4`<span class="pill info">${t4("plans.active")}</span>`;
25004
- return html4`<span class="pill">idle</span>`;
25318
+ return html4`<span class="pill">${t4("plans.idle")}</span>`;
25005
25319
  }
25006
25320
  function PlansPanel() {
25007
25321
  useLang();
@@ -25114,13 +25428,20 @@ function PlansPanel() {
25114
25428
  function SemanticPanel() {
25115
25429
  useLang();
25116
25430
  const [data, setData] = d2(null);
25431
+ const [draft, setDraft] = d2(null);
25432
+ const [draftDirty, setDraftDirty] = d2(false);
25433
+ const draftDirtyRef = A2(false);
25117
25434
  const [error, setError] = d2(null);
25118
25435
  const [busy, setBusy] = d2(false);
25119
25436
  const [info, setInfo] = d2(null);
25120
25437
  const load = q2(async () => {
25121
25438
  try {
25122
- const r3 = await api("/semantic");
25123
- setData(r3);
25439
+ const [semantic, config] = await Promise.all([
25440
+ api("/semantic"),
25441
+ api("/semantic/config")
25442
+ ]);
25443
+ setData(semantic);
25444
+ setDraft((current) => current && draftDirtyRef.current ? current : toConfigDraft(config));
25124
25445
  } catch (err) {
25125
25446
  setError(err.message);
25126
25447
  }
@@ -25128,7 +25449,7 @@ function SemanticPanel() {
25128
25449
  y2(() => {
25129
25450
  load();
25130
25451
  const phase2 = data?.job?.phase;
25131
- const running2 = phase2 === "scan" || phase2 === "embed" || phase2 === "write";
25452
+ const running2 = isActiveSemanticPhase(phase2);
25132
25453
  const pulling2 = data?.pull?.status === "pulling";
25133
25454
  const ms = running2 || pulling2 ? 1200 : 5e3;
25134
25455
  const id = setInterval(load, ms);
@@ -25136,10 +25457,18 @@ function SemanticPanel() {
25136
25457
  }, [load, data?.job?.phase, data?.pull?.status]);
25137
25458
  const start = q2(
25138
25459
  async (rebuild) => {
25460
+ if (!draft) return;
25139
25461
  setBusy(true);
25140
25462
  setError(null);
25141
25463
  setInfo(null);
25142
25464
  try {
25465
+ const validation = validateSemanticDraft(draft);
25466
+ if (draftDirty) {
25467
+ throw new Error(t4("semantic.saveBeforeIndex"));
25468
+ }
25469
+ if (validation.error) {
25470
+ throw new Error(validation.error);
25471
+ }
25143
25472
  await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
25144
25473
  setInfo(rebuild ? t4("semantic.rebuildStarted") : t4("semantic.incrementalStarted"));
25145
25474
  await load();
@@ -25149,7 +25478,7 @@ function SemanticPanel() {
25149
25478
  setBusy(false);
25150
25479
  }
25151
25480
  },
25152
- [load]
25481
+ [draft, draftDirty, load]
25153
25482
  );
25154
25483
  const stop = q2(async () => {
25155
25484
  setBusy(true);
@@ -25169,10 +25498,11 @@ function SemanticPanel() {
25169
25498
  setError(null);
25170
25499
  setInfo(t4("semantic.startingDaemon"));
25171
25500
  try {
25172
- const r3 = await api("/semantic/ollama/start", { method: "POST", body: {} });
25173
- setInfo(
25174
- r3.ready ? t4("semantic.daemonUp") : t4("semantic.daemonTimeout")
25175
- );
25501
+ const r3 = await api("/semantic/ollama/start", {
25502
+ method: "POST",
25503
+ body: {}
25504
+ });
25505
+ setInfo(r3.ready ? t4("semantic.daemonUp") : t4("semantic.daemonTimeout"));
25176
25506
  await load();
25177
25507
  } catch (err) {
25178
25508
  setError(err.message);
@@ -25196,10 +25526,44 @@ function SemanticPanel() {
25196
25526
  },
25197
25527
  [load]
25198
25528
  );
25199
- if (!data && !error)
25529
+ const saveProviderConfig = q2(async () => {
25530
+ if (!draft) return;
25531
+ setBusy(true);
25532
+ setError(null);
25533
+ setInfo(null);
25534
+ try {
25535
+ const extraBody = semanticValidation.extraBody;
25536
+ await api("/semantic/config", {
25537
+ method: "POST",
25538
+ body: {
25539
+ provider: draft.provider,
25540
+ ollama: {
25541
+ baseUrl: draft.ollama.baseUrl,
25542
+ model: draft.ollama.model
25543
+ },
25544
+ openaiCompat: {
25545
+ baseUrl: draft.openaiCompat.baseUrl,
25546
+ apiKey: draft.openaiCompat.apiKey,
25547
+ model: draft.openaiCompat.model,
25548
+ extraBody
25549
+ }
25550
+ }
25551
+ });
25552
+ setDraftDirty(false);
25553
+ draftDirtyRef.current = false;
25554
+ setInfo(t4("semantic.savedConfig", { count: 1 }));
25555
+ await load();
25556
+ } catch (err) {
25557
+ setError(err.message);
25558
+ } finally {
25559
+ setBusy(false);
25560
+ }
25561
+ }, [draft, load]);
25562
+ if (!data && !error) {
25200
25563
  return html4`<div class="card" style="color:var(--fg-3)">${t4("common.loading")}</div>`;
25564
+ }
25201
25565
  if (error && !data) return html4`<div class="card accent-err">${error}</div>`;
25202
- if (!data) return null;
25566
+ if (!data || !draft) return null;
25203
25567
  if (!data.attached) {
25204
25568
  return html4`
25205
25569
  <div class="card" style="color:var(--fg-3)">
@@ -25210,35 +25574,168 @@ function SemanticPanel() {
25210
25574
  }
25211
25575
  const job = data.job;
25212
25576
  const phase = job?.phase;
25213
- const running = phase === "scan" || phase === "embed" || phase === "write";
25577
+ const running = isActiveSemanticPhase(phase);
25214
25578
  const pull = data.pull;
25215
25579
  const pulling = pull?.status === "pulling";
25216
- const o3 = data.ollama ?? {};
25217
- const binaryFound = o3.binaryFound === true;
25218
- const daemonRunning = o3.daemonRunning === true;
25219
- const modelPulled = o3.modelPulled === true;
25220
- const modelName = o3.modelName ?? "nomic-embed-text";
25221
- const installedModels = o3.installedModels ?? [];
25222
- const ready = binaryFound && daemonRunning && modelPulled;
25580
+ const provider = data.providerStatus?.kind ?? draft.provider;
25581
+ const ready = data.providerStatus?.ready === true;
25582
+ const isOllama = provider === "ollama";
25583
+ const ollama = data.providerStatus?.kind === "ollama" ? data.providerStatus : null;
25584
+ const remote = data.providerStatus?.kind === "openai-compat" ? data.providerStatus : null;
25585
+ const binaryFound = ollama?.binaryFound === true;
25586
+ const daemonRunning = ollama?.daemonRunning === true;
25587
+ const modelPulled = ollama?.modelPulled === true;
25588
+ const modelName = isOllama ? ollama?.modelName ?? draft.ollama.model ?? "nomic-embed-text" : draft.openaiCompat.model;
25223
25589
  const sectionH3 = (text) => html4`
25224
25590
  <h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${text}</h3>
25225
25591
  `;
25226
25592
  const idx = data.index;
25593
+ const indexReady = idx?.exists === true && idx.compatible !== false;
25594
+ const indexMismatch = idx?.exists === true && idx.compatible === false;
25595
+ const semanticValidation = validateSemanticDraft(draft);
25596
+ const semanticDraftBlocked = draftDirty || semanticValidation.error !== null;
25227
25597
  return html4`
25228
25598
  <div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
25229
25599
  <div style="display:flex;flex-direction:column;gap:10px;min-width:0">
25230
25600
  <div class="chips">
25231
- <span class=${`chip-f static ${idx?.exists ? "active" : ""}`}>
25232
- ${idx?.exists ? t4("semantic.indexBuilt") : t4("semantic.noIndex")}
25601
+ <span class=${`chip-f static ${indexReady ? "active" : ""}`}>
25602
+ ${indexReady ? t4("semantic.indexBuilt") : t4("semantic.noIndex")}
25233
25603
  </span>
25234
25604
  ${ready ? html4`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t4("semantic.ready")}</span>` : html4`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t4("semantic.setupNeeded")}</span>`}
25235
25605
  </div>
25236
- ${info ? html4`<div><span class="pill info">${info}</span></div>` : null}
25237
25606
  ${error ? html4`<div class="card accent-err">${error}</div>` : null}
25238
25607
 
25239
- ${idx?.exists ? html4`<${SemanticSearchSection} />` : null}
25608
+ <div class="card">
25609
+ <div class="card-h"><span class="title">${t4("semantic.provider")}</span></div>
25610
+ <div class="form-row">
25611
+ <span class="lbl">${t4("semantic.providerType")}</span>
25612
+ <select
25613
+ class="input mono"
25614
+ value=${draft.provider}
25615
+ onInput=${(e3) => {
25616
+ draftDirtyRef.current = true;
25617
+ setDraftDirty(true);
25618
+ setDraft({
25619
+ ...draft,
25620
+ provider: e3.target.value
25621
+ });
25622
+ }}
25623
+ >
25624
+ <option value="ollama">Ollama</option>
25625
+ <option value="openai-compat">OpenAI-Compatible</option>
25626
+ </select>
25627
+ </div>
25628
+ ${draft.provider === "ollama" ? html4`
25629
+ <div class="form-row">
25630
+ <span class="lbl">${t4("semantic.model")}</span>
25631
+ <input
25632
+ class="input mono"
25633
+ type="text"
25634
+ value=${draft.ollama.model}
25635
+ onInput=${(e3) => {
25636
+ draftDirtyRef.current = true;
25637
+ setDraftDirty(true);
25638
+ setDraft({
25639
+ ...draft,
25640
+ ollama: { ...draft.ollama, model: e3.target.value }
25641
+ });
25642
+ }}
25643
+ />
25644
+ </div>
25645
+ ` : html4`
25646
+ <div class="form-row">
25647
+ <span class="lbl">${t4("semantic.apiUrl")}</span>
25648
+ <input
25649
+ class="input mono"
25650
+ type="text"
25651
+ placeholder="https://api.openai.com/v1/embeddings"
25652
+ value=${draft.openaiCompat.baseUrl}
25653
+ onInput=${(e3) => {
25654
+ draftDirtyRef.current = true;
25655
+ setDraftDirty(true);
25656
+ setDraft({
25657
+ ...draft,
25658
+ openaiCompat: {
25659
+ ...draft.openaiCompat,
25660
+ baseUrl: e3.target.value
25661
+ }
25662
+ });
25663
+ }}
25664
+ />
25665
+ </div>
25666
+ <div class="form-row">
25667
+ <span class="lbl">${t4("semantic.apiKey")}</span>
25668
+ <input
25669
+ class="input mono"
25670
+ type="password"
25671
+ placeholder=${draft.openaiCompat.apiKeySet ? t4("semantic.keepExistingKey") : "sk-..."}
25672
+ value=${draft.openaiCompat.apiKey}
25673
+ onInput=${(e3) => {
25674
+ draftDirtyRef.current = true;
25675
+ setDraftDirty(true);
25676
+ setDraft({
25677
+ ...draft,
25678
+ openaiCompat: {
25679
+ ...draft.openaiCompat,
25680
+ apiKey: e3.target.value
25681
+ }
25682
+ });
25683
+ }}
25684
+ />
25685
+ <div style="color:var(--fg-3);font-size:12px">${t4("semantic.apiKeyStoredNote")}</div>
25686
+ </div>
25687
+ <div class="form-row">
25688
+ <span class="lbl">${t4("semantic.model")}</span>
25689
+ <input
25690
+ class="input mono"
25691
+ type="text"
25692
+ value=${draft.openaiCompat.model}
25693
+ onInput=${(e3) => {
25694
+ draftDirtyRef.current = true;
25695
+ setDraftDirty(true);
25696
+ setDraft({
25697
+ ...draft,
25698
+ openaiCompat: {
25699
+ ...draft.openaiCompat,
25700
+ model: e3.target.value
25701
+ }
25702
+ });
25703
+ }}
25704
+ />
25705
+ </div>
25706
+ <details style="margin-top:10px">
25707
+ <summary style="cursor:pointer;color:var(--fg-2);font-size:12px">${t4("semantic.customRequestBody")}</summary>
25708
+ <div class="form-row" style="margin-top:10px">
25709
+ <span class="lbl">${t4("semantic.customRequestBody")}</span>
25710
+ <textarea
25711
+ class="input mono"
25712
+ rows="6"
25713
+ value=${draft.openaiCompat.extraBodyText}
25714
+ onInput=${(e3) => {
25715
+ draftDirtyRef.current = true;
25716
+ setDraftDirty(true);
25717
+ setDraft({
25718
+ ...draft,
25719
+ openaiCompat: {
25720
+ ...draft.openaiCompat,
25721
+ extraBodyText: e3.target.value
25722
+ }
25723
+ });
25724
+ }}
25725
+ ></textarea>
25726
+ </div>
25727
+ </details>
25728
+ ${semanticValidation.error ? html4`<div style="color:var(--c-err);font-size:12px;margin-top:-2px">${semanticValidation.error}</div>` : null}
25729
+ `}
25730
+ <div style="display:flex;gap:6px;margin-top:10px">
25731
+ <button class="btn primary" disabled=${busy || semanticValidation.error !== null} onClick=${saveProviderConfig}>${t4("common.save")}</button>
25732
+ </div>
25733
+ </div>
25734
+ ${info ? html4`<div><span class="pill info">${info}</span></div>` : null}
25735
+
25736
+ ${indexReady ? html4`<${SemanticSearchSection} />` : null}
25240
25737
 
25241
- ${!binaryFound ? html4`
25738
+ ${isOllama && !binaryFound ? html4`
25242
25739
  <div class="card">
25243
25740
  <div class="card-h"><span class="title">${t4("semantic.installOllama")}</span></div>
25244
25741
  <div class="card-b" style="font-size:13px">
@@ -25251,7 +25748,7 @@ function SemanticPanel() {
25251
25748
  </div>
25252
25749
  </div>
25253
25750
  ` : null}
25254
- ${binaryFound && !daemonRunning ? html4`
25751
+ ${isOllama && binaryFound && !daemonRunning ? html4`
25255
25752
  <div class="card">
25256
25753
  <div class="card-h"><span class="title">${t4("semantic.daemon")}</span></div>
25257
25754
  <div class="card-b" style="font-size:13px">
@@ -25263,7 +25760,7 @@ function SemanticPanel() {
25263
25760
  </div>
25264
25761
  </div>
25265
25762
  ` : null}
25266
- ${daemonRunning && !modelPulled ? html4`
25763
+ ${isOllama && daemonRunning && !modelPulled ? html4`
25267
25764
  <div class="card">
25268
25765
  <div class="card-h"><span class="title">${t4("semantic.model")}</span></div>
25269
25766
  <div class="card-b" style="font-size:13px">
@@ -25283,6 +25780,14 @@ function SemanticPanel() {
25283
25780
  </div>
25284
25781
  </div>
25285
25782
  ` : null}
25783
+ ${!isOllama ? html4`
25784
+ <div class="card">
25785
+ <div class="card-h"><span class="title">${t4("semantic.remoteProvider")}</span></div>
25786
+ <div class="card-b" style="font-size:13px;color:var(--fg-2)">
25787
+ ${t4("semantic.remoteProviderDesc")}
25788
+ </div>
25789
+ </div>
25790
+ ` : null}
25286
25791
 
25287
25792
  ${job ? html4`
25288
25793
  ${sectionH3(t4("semantic.job"))}
@@ -25295,42 +25800,42 @@ function SemanticPanel() {
25295
25800
  <div class="card-h">
25296
25801
  <span class="title">${t4("semantic.indexStatus")}</span>
25297
25802
  <span class="meta">
25298
- ${idx?.exists ? html4`<span class="pill ok">${t4("semantic.builtStatus")}</span>` : html4`<span class="pill">${t4("system.none")}</span>`}
25803
+ ${idx?.exists ? idx.compatible === false ? html4`<span class="pill warn">${t4("semantic.incompatibleStatus")}</span>` : html4`<span class="pill ok">${t4("semantic.builtStatus")}</span>` : html4`<span class="pill">${t4("system.none")}</span>`}
25299
25804
  </span>
25300
25805
  </div>
25301
25806
  ${idx?.exists ? html4`
25807
+ <div class="rail-kv"><span class="k">${t4("semantic.provider")}</span><span class="v">${idx.builtWith?.provider ?? idx.provider ?? provider}</span></div>
25302
25808
  <div class="rail-kv"><span class="k">${t4("semantic.chunks")}</span><span class="v">${fmtNum(idx.chunks)}</span></div>
25303
25809
  <div class="rail-kv"><span class="k">${t4("semantic.files")}</span><span class="v">${fmtNum(idx.files)}</span></div>
25304
- <div class="rail-kv"><span class="k">${t4("semantic.model")}</span><span class="v" style="font-size:11px">${idx.model ?? modelName}</span></div>
25810
+ <div class="rail-kv"><span class="k">${t4("semantic.model")}</span><span class="v" style="font-size:11px">${idx.builtWith?.model ?? idx.model ?? modelName}</span></div>
25305
25811
  <div class="rail-kv"><span class="k">${t4("semantic.dim")}</span><span class="v">${fmtNum(idx.dim)}</span></div>
25306
25812
  <div class="rail-kv"><span class="k">${t4("semantic.size")}</span><span class="v">${fmtBytes(idx.sizeBytes)}</span></div>
25307
25813
  <div class="rail-kv"><span class="k">${t4("semantic.lastBuild")}</span><span class="v">${fmtRelativeTime(idx.lastBuiltMs ?? null)}</span></div>
25308
- ` : html4`
25309
- <div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">
25310
- ${t4("semantic.runIndexHint")}
25311
- </div>
25312
- `}
25814
+ ${idx.compatible === false ? html4`
25815
+ <div class="rail-kv"><span class="k">${t4("semantic.builtWith")}</span><span class="v" style="font-size:11px">${idx.builtWith?.provider} · ${idx.builtWith?.model}</span></div>
25816
+ <div class="rail-kv"><span class="k">${t4("semantic.currentTarget")}</span><span class="v" style="font-size:11px">${idx.current?.provider} · ${idx.current?.model}</span></div>
25817
+ <div style="color:var(--c-warn);font-size:12px;padding-top:8px">${t4("semantic.incompatibleHint")}</div>
25818
+ ` : null}
25819
+ ` : html4`<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">${t4("semantic.runIndexHint")}</div>`}
25313
25820
  <div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap">
25314
- <button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>${idx?.exists ? t4("semantic.reIndex") : t4("semantic.build")}</button>
25315
- ${idx?.exists ? html4`<button disabled=${busy || running || !ready} onClick=${() => start(true)}>${t4("semantic.rebuild")}</button>` : null}
25821
+ <button class="primary" disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(false)}>${indexReady ? t4("semantic.reIndex") : t4("semantic.build")}</button>
25822
+ ${idx?.exists ? html4`<button disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(true)}>${t4("semantic.rebuild")}</button>` : null}
25316
25823
  ${running ? html4`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t4("semantic.stop")}</button>` : null}
25317
25824
  </div>
25318
25825
  </div>
25319
25826
 
25320
25827
  <div class="card">
25321
- <div class="card-h"><span class="title">${t4("semantic.ollama")}</span></div>
25322
- <div class="rail-kv">
25323
- <span class="k">${t4("semantic.binary")}</span>
25324
- <span class="v">${binaryFound ? html4`<span class="pill ok">${t4("semantic.found")}</span>` : html4`<span class="pill err">${t4("semantic.missing")}</span>`}</span>
25325
- </div>
25326
- <div class="rail-kv">
25327
- <span class="k">${t4("semantic.daemonStatus")}</span>
25328
- <span class="v">${daemonRunning ? html4`<span class="pill ok">${t4("semantic.up")}</span>` : html4`<span class="pill warn">${t4("semantic.down")}</span>`}</span>
25329
- </div>
25330
- <div class="rail-kv">
25331
- <span class="k">${t4("semantic.model")}</span>
25332
- <span class="v">${modelPulled ? html4`<span class="pill ok">${t4("semantic.pulled")}</span>` : html4`<span class="pill warn">${t4("semantic.missing")}</span>`}</span>
25333
- </div>
25828
+ <div class="card-h"><span class="title">${isOllama ? t4("semantic.ollama") : t4("semantic.openaiCompat")}</span></div>
25829
+ ${isOllama ? html4`
25830
+ <div class="rail-kv"><span class="k">${t4("semantic.binary")}</span><span class="v">${binaryFound ? html4`<span class="pill ok">${t4("semantic.found")}</span>` : html4`<span class="pill err">${t4("semantic.missing")}</span>`}</span></div>
25831
+ <div class="rail-kv"><span class="k">${t4("semantic.daemonStatus")}</span><span class="v">${daemonRunning ? html4`<span class="pill ok">${t4("semantic.up")}</span>` : html4`<span class="pill warn">${t4("semantic.down")}</span>`}</span></div>
25832
+ <div class="rail-kv"><span class="k">${t4("semantic.model")}</span><span class="v">${modelPulled ? html4`<span class="pill ok">${t4("semantic.pulled")}</span>` : html4`<span class="pill warn">${t4("semantic.missing")}</span>`}</span></div>
25833
+ ` : html4`
25834
+ <div class="rail-kv"><span class="k">${t4("semantic.apiUrl")}</span><span class="v" style="font-size:11px;max-width:160px;overflow-wrap:anywhere;word-break:break-word;text-align:right">${remote?.baseUrl ?? draft.openaiCompat.baseUrl}</span></div>
25835
+ <div class="rail-kv"><span class="k">${t4("semantic.apiKey")}</span><span class="v">${remote?.apiKeySet ? html4`<span class="pill ok">${t4("semantic.found")}</span>` : html4`<span class="pill warn">${t4("semantic.missing")}</span>`}</span></div>
25836
+ <div class="rail-kv"><span class="k">${t4("semantic.model")}</span><span class="v" style="font-size:11px">${remote?.model ?? draft.openaiCompat.model}</span></div>
25837
+ <div class="rail-kv"><span class="k">${t4("semantic.extraBody")}</span><span class="v">${fmtNum(remote?.extraBodyKeys.length ?? 0)}</span></div>
25838
+ `}
25334
25839
  </div>
25335
25840
 
25336
25841
  <${SemanticExcludesCard} />
@@ -25338,6 +25843,44 @@ function SemanticPanel() {
25338
25843
  </div>
25339
25844
  `;
25340
25845
  }
25846
+ function toConfigDraft(config) {
25847
+ return {
25848
+ provider: config.provider,
25849
+ ollama: {
25850
+ baseUrl: config.ollama.baseUrl,
25851
+ model: config.ollama.model
25852
+ },
25853
+ openaiCompat: {
25854
+ baseUrl: config.openaiCompat.baseUrl,
25855
+ apiKey: "",
25856
+ model: config.openaiCompat.model,
25857
+ extraBodyText: JSON.stringify(config.openaiCompat.extraBody ?? {}, null, 2),
25858
+ apiKeySet: config.openaiCompat.apiKeySet
25859
+ }
25860
+ };
25861
+ }
25862
+ function validateSemanticDraft(draft) {
25863
+ if (draft.provider !== "openai-compat") {
25864
+ return { extraBody: {}, error: null };
25865
+ }
25866
+ const raw = draft.openaiCompat.extraBodyText.trim();
25867
+ if (!raw) {
25868
+ return { extraBody: {}, error: null };
25869
+ }
25870
+ let parsed;
25871
+ try {
25872
+ parsed = JSON.parse(raw);
25873
+ } catch (err) {
25874
+ return {
25875
+ extraBody: {},
25876
+ error: t4("semantic.invalidCustomRequestBody", { error: err.message })
25877
+ };
25878
+ }
25879
+ if (!isPlainObject(parsed)) {
25880
+ return { extraBody: {}, error: t4("semantic.customRequestBodyMustBeObject") };
25881
+ }
25882
+ return { extraBody: parsed, error: null };
25883
+ }
25341
25884
  function SemanticSearchSection() {
25342
25885
  useLang();
25343
25886
  const [query2, setQuery] = d2("");
@@ -25510,11 +26053,7 @@ function SemanticExcludesCard() {
25510
26053
  <div class="card-h">
25511
26054
  <span class="title">${t4("semantic.indexConfig")}</span>
25512
26055
  <span class="meta">
25513
- <a
25514
- class="mono"
25515
- style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer"
25516
- onClick=${reset}
25517
- >${t4("semantic.reset")}</a>
26056
+ <a class="mono" style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer" onClick=${reset}>${t4("semantic.reset")}</a>
25518
26057
  </span>
25519
26058
  </div>
25520
26059
  ${info ? html4`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
@@ -25546,11 +26085,7 @@ function SemanticExcludesCard() {
25546
26085
  placeholder="**/*.test.ts"
25547
26086
  />
25548
26087
 
25549
- <div
25550
- class="checkbox-row"
25551
- style="margin-top:8px;cursor:pointer"
25552
- onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}
25553
- >
26088
+ <div class="checkbox-row" style="margin-top:8px;cursor:pointer" onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}>
25554
26089
  <span class=${`box ${draft.respectGitignore ? "on" : ""}`}>${draft.respectGitignore ? "\u2713" : ""}</span>
25555
26090
  <span>${t4("semantic.respectGitignore")}</span>
25556
26091
  </div>
@@ -25570,9 +26105,7 @@ function SemanticExcludesCard() {
25570
26105
  </div>
25571
26106
 
25572
26107
  <div style="display:flex;gap:6px;margin-top:10px">
25573
- <button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}>
25574
- <span class="g">⊕</span><span>${t4("semantic.preview")}</span>
25575
- </button>
26108
+ <button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}><span class="g">⊕</span><span>${t4("semantic.preview")}</span></button>
25576
26109
  <button class="btn primary" style="flex:1" disabled=${busy} onClick=${save}>${t4("common.save")}</button>
25577
26110
  </div>
25578
26111
 
@@ -25597,19 +26130,17 @@ function ExcludesPreview({ preview }) {
25597
26130
  ].filter((k3) => (buckets[k3] || 0) > 0);
25598
26131
  return html4`
25599
26132
  <div class="excludes-preview">
25600
- <div class="summary">
25601
- ${t4("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}
25602
- </div>
26133
+ <div class="summary">${t4("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}</div>
25603
26134
  ${reasons.length === 0 ? html4`<div style="color:var(--fg-3)">${t4("semantic.nothingSkipped")}</div>` : reasons.map(
25604
26135
  (r3) => html4`
25605
- <details>
25606
- <summary><strong>${r3}: ${buckets[r3]}</strong></summary>
25607
- <ul>
25608
- ${(samples[r3] || []).map((p3) => html4`<li><code>${p3}</code></li>`)}
25609
- ${(buckets[r3] || 0) > (samples[r3] || []).length ? html4`<li style="color:var(--fg-3)">…${(buckets[r3] || 0) - (samples[r3] || []).length} more</li>` : null}
25610
- </ul>
25611
- </details>
25612
- `
26136
+ <details>
26137
+ <summary><strong>${r3}: ${buckets[r3]}</strong></summary>
26138
+ <ul>
26139
+ ${(samples[r3] || []).map((p3) => html4`<li><code>${p3}</code></li>`)}
26140
+ ${(buckets[r3] || 0) > (samples[r3] || []).length ? html4`<li style="color:var(--fg-3)">…${(buckets[r3] || 0) - (samples[r3] || []).length} more</li>` : null}
26141
+ </ul>
26142
+ </details>
26143
+ `
25613
26144
  )}
25614
26145
  ${preview.sampleIncluded?.length ? html4`
25615
26146
  <details>
@@ -25642,10 +26173,7 @@ function ChipFormRow({
25642
26173
  };
25643
26174
  return html4`
25644
26175
  <div class="form-row">
25645
- <span class="lbl">
25646
- ${label}
25647
- ${sub ? html4`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}
25648
- </span>
26176
+ <span class="lbl">${label}${sub ? html4`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}</span>
25649
26177
  <div style="display:flex;flex-wrap:wrap;gap:4px">
25650
26178
  ${value.map(
25651
26179
  (e3) => html4`
@@ -25676,22 +26204,27 @@ function ChipFormRow({
25676
26204
  function SemanticJobView({ job, running }) {
25677
26205
  useLang();
25678
26206
  const phaseLabel = {
26207
+ setup: t4("semantic.phaseSetup"),
25679
26208
  scan: t4("semantic.phaseScan"),
25680
26209
  embed: t4("semantic.phaseEmbed"),
25681
26210
  write: t4("semantic.phaseWrite"),
25682
26211
  done: t4("semantic.phaseDone"),
25683
- error: t4("semantic.phaseError")
26212
+ error: t4("semantic.phaseError"),
26213
+ cancelled: t4("semantic.phaseCancelled")
25684
26214
  }[job.phase] ?? job.phase;
25685
26215
  const total = job.chunksTotal ?? 0;
25686
26216
  const doneN = job.chunksDone ?? 0;
25687
26217
  const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
25688
- const elapsed = ((Date.now() - job.startedAt) / 1e3).toFixed(1);
26218
+ const elapsedBase = job.finishedAt ?? Date.now();
26219
+ const elapsedSeconds = (elapsedBase - job.startedAt) / 1e3;
26220
+ const elapsed = elapsedSeconds < 0.1 ? "<0.1s" : `${elapsedSeconds.toFixed(1)}s`;
26221
+ const phaseSummary = job.phase === "error" && job.lastPhase === "setup" ? t4("semantic.setupFailed") : phaseLabel;
25689
26222
  return html4`
25690
26223
  <div class="kv">
25691
26224
  <div><span class="kv-key">phase</span>
25692
- <span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
25693
- ${job.aborted ? html4`<span class="pill warn" style="margin-left: 6px;">${t4("semantic.stopping")}</span>` : null}
25694
- <span style="color:var(--fg-3);margin-left:8px">${elapsed}s</span>
26225
+ <span class=${`pill ${job.phase === "error" ? "pill-err" : job.phase === "cancelled" ? "warn" : running ? "pill-active" : "pill-dim"}`}>${phaseSummary}</span>
26226
+ ${job.aborted && running ? html4`<span class="pill warn" style="margin-left: 6px;">${t4("semantic.stopping")}</span>` : null}
26227
+ <span style="color:var(--fg-3);margin-left:8px">${elapsed}</span>
25695
26228
  </div>
25696
26229
  ${job.filesScanned !== null && job.filesScanned !== void 0 ? html4`<div><span class="kv-key">${t4("semantic.files")}</span>${t4("semantic.scanned", { count: job.filesScanned })}${job.filesChanged != null ? ` \xB7 ${t4("semantic.changed", { count: job.filesChanged })}` : ""}${job.filesSkipped ? ` \xB7 ${t4("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>` : null}
25697
26230
  ${total > 0 ? html4`
@@ -25725,6 +26258,14 @@ function SkipBucketsView({ buckets }) {
25725
26258
  const parts = order.filter(([k3]) => (buckets[k3] || 0) > 0).map(([k3, label]) => `${label}: ${buckets[k3]}`);
25726
26259
  return html4`<div><span class="kv-key">${t4("semantic.skipped")}</span>${t4("semantic.skippedFiles", { total, details: parts.join(", ") })}</div>`;
25727
26260
  }
26261
+ function isActiveSemanticPhase(phase) {
26262
+ return phase === "setup" || phase === "scan" || phase === "embed" || phase === "write";
26263
+ }
26264
+ function isPlainObject(value) {
26265
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
26266
+ const proto = Object.getPrototypeOf(value);
26267
+ return proto === Object.prototype || proto === null;
26268
+ }
25728
26269
 
25729
26270
  // dashboard/src/panels/sessions.ts
25730
26271
  function SessionsPanel() {
@@ -25828,7 +26369,314 @@ function SessionsPanel() {
25828
26369
  `;
25829
26370
  }
25830
26371
 
26372
+ // dashboard/src/lib/loop-control.ts
26373
+ var INTERVAL_PRESETS_MS = [
26374
+ { ms: 3e4, label: "30s" },
26375
+ { ms: 6e4, label: "1m" },
26376
+ { ms: 5 * 6e4, label: "5m" },
26377
+ { ms: 15 * 6e4, label: "15m" },
26378
+ { ms: 60 * 6e4, label: "1h" },
26379
+ { ms: 6 * 60 * 6e4, label: "6h" }
26380
+ ];
26381
+ var UNIT_TO_MS = {
26382
+ s: 1e3,
26383
+ m: 6e4,
26384
+ h: 60 * 6e4
26385
+ };
26386
+ var MIN_INTERVAL_MS = 5e3;
26387
+ var MAX_INTERVAL_MS = 6 * 60 * 6e4;
26388
+ function parseCustomInterval(value, unit) {
26389
+ const n3 = Number.parseFloat(value);
26390
+ if (!Number.isFinite(n3) || n3 <= 0) return null;
26391
+ const ms = Math.round(n3 * UNIT_TO_MS[unit]);
26392
+ if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
26393
+ return ms;
26394
+ }
26395
+ function formatRemaining(ms) {
26396
+ const safe = Math.max(0, Math.floor(ms / 1e3));
26397
+ const h3 = Math.floor(safe / 3600);
26398
+ const m3 = Math.floor(safe % 3600 / 60);
26399
+ const s3 = safe % 60;
26400
+ if (h3 > 0) return m3 > 0 ? `${h3}h ${m3}m` : `${h3}h`;
26401
+ if (m3 > 0) return s3 > 0 ? `${m3}m ${s3}s` : `${m3}m`;
26402
+ return `${s3}s`;
26403
+ }
26404
+
25831
26405
  // dashboard/src/panels/settings.ts
26406
+ function fmtUsd22(n3) {
26407
+ return `$${n3.toFixed(n3 < 1 ? 4 : 2)}`;
26408
+ }
26409
+ function formatPricing(p3) {
26410
+ if (!p3) return null;
26411
+ return t4("settings.modelPricingLine", {
26412
+ hit: p3.inputCacheHit.toFixed(3),
26413
+ miss: p3.inputCacheMiss.toFixed(3),
26414
+ out: p3.output.toFixed(3)
26415
+ });
26416
+ }
26417
+ function ModelRow({
26418
+ current,
26419
+ catalog,
26420
+ saving,
26421
+ onPick
26422
+ }) {
26423
+ const list2 = catalog?.models ?? null;
26424
+ const ready = list2 && list2.length > 0;
26425
+ if (!ready) {
26426
+ return html4`<code class="mono">${current ?? "\u2014"}</code>`;
26427
+ }
26428
+ const options2 = list2.includes(current) ? list2 : [current, ...list2];
26429
+ const price = catalog?.pricing[current];
26430
+ return html4`
26431
+ <span style="display:inline-flex;flex-direction:column;gap:4px">
26432
+ <select
26433
+ value=${current}
26434
+ onChange=${(e3) => {
26435
+ const next = e3.target.value;
26436
+ if (next && next !== current) onPick(next);
26437
+ }}
26438
+ disabled=${saving}
26439
+ style="font-family:var(--font-mono);min-width:200px"
26440
+ >
26441
+ ${options2.map((m3) => html4`<option key=${m3} value=${m3}>${m3}</option>`)}
26442
+ </select>
26443
+ ${price ? html4`<span style="color:var(--fg-3);font-size:11px;font-family:var(--font-mono)">${formatPricing(price)}</span>` : null}
26444
+ </span>
26445
+ `;
26446
+ }
26447
+ function BudgetGauge({ state }) {
26448
+ if (state.kind === "off") return null;
26449
+ const tone = budgetTone(state);
26450
+ const fill = Math.min(100, state.pct);
26451
+ const valueColor = tone === "err" ? "color:var(--c-err)" : tone === "warn" ? "color:var(--c-warn)" : "color:var(--fg-1)";
26452
+ return html4`
26453
+ <div style="display:flex;flex-direction:column;gap:6px">
26454
+ <div style="display:flex;justify-content:space-between;align-items:baseline;font-size:13px">
26455
+ <span style=${valueColor}>
26456
+ <strong style="font-family:var(--font-mono)">${fmtUsd22(state.spent)}</strong>
26457
+ <span style="color:var(--fg-3)"> ${t4("settings.budgetOf")} </span>
26458
+ <strong style="font-family:var(--font-mono)">${fmtUsd22(state.cap)}</strong>
26459
+ </span>
26460
+ <span style=${`font-family:var(--font-mono);font-size:11px;${valueColor}`}>${state.pct.toFixed(1)}%</span>
26461
+ </div>
26462
+ <div class=${`progress ${tone}`}><div class="progress-fill" style=${`width:${fill}%`}></div></div>
26463
+ <span style="color:var(--fg-3);font-size:11px">
26464
+ ${state.kind === "exhausted" ? t4("settings.budgetRefusing") : state.kind === "warn" ? t4("settings.budgetWarnLine") : t4("settings.budgetIdleLine")}
26465
+ </span>
26466
+ </div>
26467
+ `;
26468
+ }
26469
+ function BudgetSection({ state, saving, onSetCap, onClear }) {
26470
+ const [custom, setCustom] = d2("");
26471
+ const submitCustom = () => {
26472
+ const n3 = Number.parseFloat(custom);
26473
+ if (Number.isFinite(n3) && n3 > 0) {
26474
+ onSetCap(n3);
26475
+ setCustom("");
26476
+ }
26477
+ };
26478
+ const quickButtons = (caps) => caps.map(
26479
+ (c3) => html4`
26480
+ <button
26481
+ key=${c3}
26482
+ class="btn"
26483
+ style="font-family:var(--font-mono)"
26484
+ disabled=${saving}
26485
+ onClick=${() => onSetCap(c3)}
26486
+ >$${c3}</button>
26487
+ `
26488
+ );
26489
+ const customField = html4`
26490
+ <span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
26491
+ <span style="color:var(--fg-3);font-size:11px">${t4("settings.budgetCustom")}</span>
26492
+ <input
26493
+ type="number"
26494
+ min="0.01"
26495
+ step="0.01"
26496
+ value=${custom}
26497
+ placeholder="0.00"
26498
+ onInput=${(e3) => setCustom(e3.target.value)}
26499
+ onKeyDown=${(e3) => {
26500
+ if (e3.key === "Enter") submitCustom();
26501
+ }}
26502
+ style="width:72px;font-family:var(--font-mono)"
26503
+ disabled=${saving}
26504
+ />
26505
+ <button
26506
+ class="btn primary"
26507
+ disabled=${saving || !(Number.parseFloat(custom) > 0)}
26508
+ onClick=${submitCustom}
26509
+ >→</button>
26510
+ </span>
26511
+ `;
26512
+ return html4`
26513
+ <div class="card" style="display:flex;flex-direction:column;gap:12px">
26514
+ <${BudgetGauge} state=${state} />
26515
+
26516
+ ${state.kind === "off" ? html4`
26517
+ <div>
26518
+ <div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t4("settings.budgetSetCap")}</div>
26519
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
26520
+ ${quickButtons(QUICK_CAPS_USD)}
26521
+ ${customField}
26522
+ </div>
26523
+ </div>
26524
+ ` : state.kind === "warn" || state.kind === "exhausted" ? html4`
26525
+ <div>
26526
+ <div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t4("settings.budgetBumpHint")}</div>
26527
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
26528
+ ${bumpSuggestions(state.cap).map(
26529
+ (next) => html4`
26530
+ <button
26531
+ key=${next}
26532
+ class="btn primary"
26533
+ style="font-family:var(--font-mono)"
26534
+ disabled=${saving}
26535
+ onClick=${() => onSetCap(next)}
26536
+ >→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
26537
+ `
26538
+ )}
26539
+ ${customField}
26540
+ </div>
26541
+ <div style="margin-top:8px">
26542
+ <button class="btn" disabled=${saving} onClick=${onClear}>${t4("settings.budgetClear")}</button>
26543
+ </div>
26544
+ </div>
26545
+ ` : html4`
26546
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
26547
+ ${bumpSuggestions(state.cap).map(
26548
+ (next) => html4`
26549
+ <button
26550
+ key=${next}
26551
+ class="btn"
26552
+ style="font-family:var(--font-mono)"
26553
+ disabled=${saving}
26554
+ onClick=${() => onSetCap(next)}
26555
+ >→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
26556
+ `
26557
+ )}
26558
+ ${customField}
26559
+ <button
26560
+ class="btn"
26561
+ style="margin-left:8px"
26562
+ disabled=${saving}
26563
+ onClick=${onClear}
26564
+ >${t4("settings.budgetClear")}</button>
26565
+ </div>
26566
+ `}
26567
+ </div>
26568
+ `;
26569
+ }
26570
+ function LoopSection({
26571
+ status,
26572
+ remainingMs,
26573
+ avgIterCostUsd,
26574
+ busy,
26575
+ onStart,
26576
+ onStop
26577
+ }) {
26578
+ const [intervalMs, setIntervalMs] = d2(INTERVAL_PRESETS_MS[1].ms);
26579
+ const [prompt, setPrompt] = d2("");
26580
+ const [customValue, setCustomValue] = d2("");
26581
+ const [customUnit, setCustomUnit] = d2("m");
26582
+ if (status) {
26583
+ return html4`
26584
+ <div class="card" style="display:flex;flex-direction:column;gap:10px">
26585
+ <div style="display:flex;justify-content:space-between;align-items:baseline">
26586
+ <span style="color:var(--c-warn);font-family:var(--font-mono);font-size:11px">⟳ ${t4("settings.loopRunning")}</span>
26587
+ <span style="color:var(--fg-3);font-size:11px">
26588
+ ${t4("settings.loopIter", { iter: status.iter })} · ${t4("settings.loopFiresIn", { remaining: formatRemaining(remainingMs) })}
26589
+ </span>
26590
+ </div>
26591
+ <div style="background:var(--bg-elev-2);border:1px solid var(--bd);border-radius:var(--r);padding:8px 10px;font-family:var(--font-mono);font-size:12px;color:var(--fg-1);white-space:pre-wrap;max-height:120px;overflow-y:auto">${status.prompt}</div>
26592
+ <div>
26593
+ <button class="btn danger" disabled=${busy} onClick=${onStop}>${t4("settings.loopStop")}</button>
26594
+ </div>
26595
+ </div>
26596
+ `;
26597
+ }
26598
+ const customMs = parseCustomInterval(customValue, customUnit);
26599
+ const canStart = !busy && intervalMs > 0 && prompt.trim().length > 0;
26600
+ return html4`
26601
+ <div class="card" style="display:flex;flex-direction:column;gap:10px">
26602
+ <div style="color:var(--fg-3);font-size:11px">
26603
+ ${t4("settings.loopIdleHint")}
26604
+ ${typeof avgIterCostUsd === "number" && avgIterCostUsd > 0 ? html4` ${t4("settings.loopCostHint", { cost: `$${avgIterCostUsd.toFixed(4)}` })}` : null}
26605
+ </div>
26606
+ <div style="display:flex;flex-direction:column;gap:6px">
26607
+ <span style="color:var(--fg-3);font-size:11px">${t4("settings.loopInterval")}</span>
26608
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
26609
+ ${INTERVAL_PRESETS_MS.map(
26610
+ (p3) => html4`
26611
+ <button
26612
+ key=${p3.ms}
26613
+ class=${`btn ${intervalMs === p3.ms && customValue === "" ? "primary" : ""}`}
26614
+ style="font-family:var(--font-mono)"
26615
+ disabled=${busy}
26616
+ onClick=${() => {
26617
+ setIntervalMs(p3.ms);
26618
+ setCustomValue("");
26619
+ }}
26620
+ >${p3.label}</button>
26621
+ `
26622
+ )}
26623
+ <span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
26624
+ <span style="color:var(--fg-3);font-size:11px">${t4("settings.loopCustom")}</span>
26625
+ <input
26626
+ type="number"
26627
+ min="1"
26628
+ step="1"
26629
+ value=${customValue}
26630
+ onInput=${(e3) => {
26631
+ const raw = e3.target.value;
26632
+ setCustomValue(raw);
26633
+ const ms = parseCustomInterval(raw, customUnit);
26634
+ if (ms !== null) setIntervalMs(ms);
26635
+ }}
26636
+ style="width:64px;font-family:var(--font-mono)"
26637
+ disabled=${busy}
26638
+ />
26639
+ <select
26640
+ value=${customUnit}
26641
+ onChange=${(e3) => {
26642
+ const next = e3.target.value;
26643
+ setCustomUnit(next);
26644
+ if (customValue) {
26645
+ const ms = parseCustomInterval(customValue, next);
26646
+ if (ms !== null) setIntervalMs(ms);
26647
+ }
26648
+ }}
26649
+ disabled=${busy}
26650
+ >
26651
+ <option value="s">s</option>
26652
+ <option value="m">m</option>
26653
+ <option value="h">h</option>
26654
+ </select>
26655
+ </span>
26656
+ </div>
26657
+ ${customValue && customMs === null ? html4`<span style="color:var(--c-err);font-size:11px">${t4("settings.loopRangeError")}</span>` : null}
26658
+ </div>
26659
+ <div style="display:flex;flex-direction:column;gap:6px">
26660
+ <span style="color:var(--fg-3);font-size:11px">${t4("settings.loopPrompt")}</span>
26661
+ <textarea
26662
+ rows="3"
26663
+ placeholder=${t4("settings.loopPromptPlaceholder")}
26664
+ value=${prompt}
26665
+ onInput=${(e3) => setPrompt(e3.target.value)}
26666
+ style="width:100%;font-family:var(--font-mono);resize:vertical"
26667
+ disabled=${busy}
26668
+ ></textarea>
26669
+ </div>
26670
+ <div>
26671
+ <button
26672
+ class="btn primary"
26673
+ disabled=${!canStart}
26674
+ onClick=${() => onStart(intervalMs, prompt.trim())}
26675
+ >${t4("settings.loopStart")}</button>
26676
+ </div>
26677
+ </div>
26678
+ `;
26679
+ }
25832
26680
  function SettingsPanel() {
25833
26681
  useLang();
25834
26682
  const [data, setData] = d2(null);
@@ -25836,6 +26684,12 @@ function SettingsPanel() {
25836
26684
  const [saving, setSaving] = d2(false);
25837
26685
  const [saved, setSaved] = d2(null);
25838
26686
  const [draft, setDraft] = d2({});
26687
+ const [catalog, setCatalog] = d2(null);
26688
+ const [loopStatus, setLoopStatus] = d2(null);
26689
+ const [loopAvgCost, setLoopAvgCost] = d2(null);
26690
+ const [loopBusy, setLoopBusy] = d2(false);
26691
+ const lastStatusSyncRef = A2(0);
26692
+ const [now, setNow] = d2(() => Date.now());
25839
26693
  const load = q2(async () => {
25840
26694
  try {
25841
26695
  const r3 = await api("/settings");
@@ -25848,6 +26702,64 @@ function SettingsPanel() {
25848
26702
  y2(() => {
25849
26703
  load();
25850
26704
  }, [load]);
26705
+ y2(() => {
26706
+ api("/models").then(setCatalog).catch(() => void 0);
26707
+ }, []);
26708
+ const refreshLoop = q2(async () => {
26709
+ try {
26710
+ const r3 = await api("/loop/status");
26711
+ setLoopStatus(r3.status);
26712
+ lastStatusSyncRef.current = Date.now();
26713
+ } catch {
26714
+ }
26715
+ try {
26716
+ const r3 = await api("/overview");
26717
+ setLoopAvgCost(r3.stats?.lastTurnCostUsd ?? null);
26718
+ } catch {
26719
+ }
26720
+ }, []);
26721
+ y2(() => {
26722
+ let cancelled = false;
26723
+ refreshLoop();
26724
+ const id = setInterval(() => {
26725
+ if (!cancelled) refreshLoop();
26726
+ }, 5e3);
26727
+ return () => {
26728
+ cancelled = true;
26729
+ clearInterval(id);
26730
+ };
26731
+ }, [refreshLoop]);
26732
+ y2(() => {
26733
+ if (!loopStatus) return;
26734
+ const id = setInterval(() => setNow(Date.now()), 1e3);
26735
+ return () => clearInterval(id);
26736
+ }, [loopStatus]);
26737
+ const remainingMs = loopStatus ? Math.max(0, loopStatus.nextFireMs - (now - lastStatusSyncRef.current)) : 0;
26738
+ const startLoop = q2(
26739
+ async (intervalMs, prompt) => {
26740
+ setLoopBusy(true);
26741
+ try {
26742
+ await api("/loop/start", { method: "POST", body: { intervalMs, prompt } });
26743
+ await refreshLoop();
26744
+ } catch (err) {
26745
+ setError(err.message);
26746
+ } finally {
26747
+ setLoopBusy(false);
26748
+ }
26749
+ },
26750
+ [refreshLoop]
26751
+ );
26752
+ const stopLoop = q2(async () => {
26753
+ setLoopBusy(true);
26754
+ try {
26755
+ await api("/loop/stop", { method: "POST" });
26756
+ await refreshLoop();
26757
+ } catch (err) {
26758
+ setError(err.message);
26759
+ } finally {
26760
+ setLoopBusy(false);
26761
+ }
26762
+ }, [refreshLoop]);
25851
26763
  const save = q2(
25852
26764
  async (fields) => {
25853
26765
  setSaving(true);
@@ -25991,11 +26903,50 @@ function SettingsPanel() {
25991
26903
  )}
25992
26904
  </div>
25993
26905
 
26906
+ ${sectionH3(t4("settings.sectionCompute"))}
26907
+ <div class="card">
26908
+ ${fieldRow(
26909
+ t4("settings.proNext"),
26910
+ html4`
26911
+ <button
26912
+ class=${`btn ${v3.proNext ? "primary" : ""}`}
26913
+ onClick=${() => save({ proNext: !v3.proNext })}
26914
+ disabled=${saving}
26915
+ >${v3.proNext ? t4("settings.proArmed") : t4("settings.proArm")}</button>
26916
+ `,
26917
+ t4("settings.proNextNote")
26918
+ )}
26919
+ </div>
26920
+
26921
+ ${sectionH3(t4("settings.sectionBudget"))}
26922
+ <${BudgetSection}
26923
+ state=${deriveBudgetState(v3.budgetUsd, v3.sessionSpendUsd)}
26924
+ saving=${saving}
26925
+ onSetCap=${(usd) => save({ budgetUsd: usd })}
26926
+ onClear=${() => save({ budgetUsd: null })}
26927
+ />
26928
+
26929
+ ${sectionH3(t4("settings.sectionLoop"))}
26930
+ <${LoopSection}
26931
+ status=${loopStatus}
26932
+ remainingMs=${remainingMs}
26933
+ avgIterCostUsd=${loopAvgCost}
26934
+ busy=${loopBusy}
26935
+ onStart=${startLoop}
26936
+ onStop=${stopLoop}
26937
+ />
26938
+
25994
26939
  ${sectionH3(t4("settings.sectionRuntime"))}
25995
26940
  <div class="card">
25996
26941
  ${fieldRow(
25997
26942
  t4("settings.activeModel"),
25998
- html4`<code class="mono">${v3.model ?? "\u2014"}</code>`
26943
+ html4`<${ModelRow}
26944
+ current=${v3.model ?? "\u2014"}
26945
+ catalog=${catalog}
26946
+ saving=${saving}
26947
+ onPick=${(m3) => save({ model: m3 })}
26948
+ />`,
26949
+ t4("settings.appliesNextTurn")
25999
26950
  )}
26000
26951
  ${fieldRow(
26001
26952
  t4("settings.editMode"),