heartcode 0.1.9 → 0.1.13

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/src/config.js CHANGED
@@ -72,9 +72,22 @@ function fromFile() {
72
72
  // cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
73
73
  // flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
74
74
  // only (cfg.adhoc), unless saved.
75
- // The model name that means your list: #1, then the fallbacks when one fails (also: no model given).
76
- // Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
77
- export const DEFAULT_MODEL = 'default'
75
+ // The model name that means your list: #1, then the next when one fails (also: no model given;
76
+ // "default" is its old name). Any other model given for a run (-m, HEART_MODEL, a URL of its own)
77
+ // is that model alone: fixed (--fallback-model adds others after it).
78
+ export const AUTO_MODEL = 'auto'
79
+ export const isAuto = (m) => m === AUTO_MODEL || m === 'default'
80
+
81
+ // A model as given (-m, --fallback-model, /model): "provider/model" (as opencode writes it; split at
82
+ // the first "/", provider names have none), the older "provider model", or a model of `current`.
83
+ export function parseModel(v, providers, current) {
84
+ v = String(v).trim()
85
+ const spaced = /^(\S+)\s+(.+)$/.exec(v)
86
+ if (spaced && providers[spaced[1]]) return { provider: spaced[1], model: spaced[2] }
87
+ const i = v.indexOf('/')
88
+ if (i > 0 && providers[v.slice(0, i)]) return { provider: v.slice(0, i), model: v.slice(i + 1) }
89
+ return { provider: current, model: v }
90
+ }
78
91
 
79
92
  // providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
80
93
  export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
@@ -98,15 +111,61 @@ export function loadConfig(flags = {}) {
98
111
  cfg.providers[cfg.provider].apiKey = apiKey
99
112
  }
100
113
  const model = flags.model || process.env.HEART_MODEL
101
- if (model && model !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
114
+ if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
115
+ synced(cfg, readJson(CONFIG_FILE, {}))
102
116
  return cfg
103
117
  }
104
118
 
105
- export function saveConfig(cfg) {
119
+ // What a cfg stands for in config.json (the run-only provider and model left out).
120
+ function saved(cfg) {
106
121
  const providers = byName(cfg.providers)
107
122
  if (cfg.adhoc) delete providers[cfg.adhoc]
108
123
  const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
109
124
  const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
110
125
  for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
111
- writeJson(CONFIG_FILE, out)
126
+ return JSON.parse(JSON.stringify(out))
127
+ }
128
+
129
+ // Several HeartCodes (and the settings page in each) keep a copy of the settings. A save writes only
130
+ // what this one changed since it last read or wrote the file, onto the file as it is now, and
131
+ // takeIn brings in what the others changed: no copy undoes another's change with an old value.
132
+ // file: config.json as last read or written here; mine: what this run's cfg stood for then (a -m
133
+ // model for this run is no change of its own, so a save doesn't write it as #1).
134
+ let file = null
135
+ let mine = null
136
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
137
+ const keysOf = (...objs) => [...new Set(objs.flatMap((o) => Object.keys(o ?? {})))]
138
+ const synced = (cfg, disk) => ((file = disk), (mine = saved(cfg)))
139
+
140
+ export function saveConfig(cfg) {
141
+ const out = saved(cfg)
142
+ const was = mine ?? {}
143
+ const disk = readJson(CONFIG_FILE, {})
144
+ for (const k of keysOf(out, was)) {
145
+ if (k === 'providers') {
146
+ // provider by provider: a key changed here and a provider added elsewhere both stay
147
+ const ps = { ...disk.providers }
148
+ for (const n of keysOf(out.providers, was.providers)) if (!same(out.providers?.[n], was.providers?.[n])) out.providers?.[n] === undefined ? delete ps[n] : (ps[n] = out.providers[n])
149
+ disk.providers = byName(ps)
150
+ } else if (!same(out[k], was[k])) out[k] === undefined ? delete disk[k] : (disk[k] = out[k])
151
+ }
152
+ writeJson(CONFIG_FILE, disk)
153
+ synced(cfg, disk)
154
+ }
155
+
156
+ // What others changed in config.json since this run last read or wrote it, into cfg (the run-only
157
+ // provider stays). The keys that changed: a running HeartCode applies them (the model list…).
158
+ // what a changed key of config.json means to a running HeartCode (applySettings)
159
+ export const whatOf = (k) => (['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
160
+ export function takeIn(cfg) {
161
+ const disk = readJson(CONFIG_FILE, null)
162
+ if (!disk || same(disk, file)) return []
163
+ const keys = keysOf(disk, file).filter((k) => (k === 'providers' || k === 'provider' || k === 'model' || SETTINGS.includes(k)) && !same(disk[k], file?.[k]))
164
+ for (const k of keys) {
165
+ const value = k === 'providers' ? { ...byName(disk.providers), ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : disk[k]
166
+ value === undefined ? delete cfg[k] : (cfg[k] = value)
167
+ }
168
+ file = disk
169
+ mine = saved(cfg)
170
+ return keys
112
171
  }
package/src/guided.js CHANGED
@@ -225,14 +225,8 @@ class Guided {
225
225
 
226
226
  // `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
227
227
  // room: tokens of context to keep free for the reply (only for fitting the context; never sent).
228
- async query(question, opts = {}, tries = 0) {
229
- try {
230
- return await this.ask1(question, opts, tries)
231
- } catch (e) {
232
- // too large for the model: strat.context has dropped to its limit, so the context shrinks
233
- if (!e.inputLimit || tries >= 3) throw e
234
- return this.query(question, opts, tries + 1)
235
- }
228
+ query(question, opts = {}) {
229
+ return this.ask1(question, opts)
236
230
  }
237
231
 
238
232
  async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
package/src/headless.js CHANGED
@@ -150,6 +150,7 @@ export async function runHeadless(app, opts) {
150
150
  said = false
151
151
  current = { ctl: new AbortController() }
152
152
  app.signal = current.ctl.signal
153
+ app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
153
154
  let answer = ''
154
155
  let error = null
155
156
  try {
@@ -162,6 +163,8 @@ export async function runHeadless(app, opts) {
162
163
  log('error', error, e.stack)
163
164
  } finally {
164
165
  app.save()
166
+ app.working = false
167
+ app.afterLine?.()
165
168
  }
166
169
  const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
167
170
  current = null
package/src/settings.html CHANGED
@@ -228,11 +228,15 @@
228
228
  .olist .test button { flex: none; }
229
229
  .olist .test.ok { color: var(--ok); }
230
230
  .olist .test.warn { color: var(--accent); }
231
- dialog#ms { width: min(480px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
232
- dialog#ms::backdrop { background: rgba(0, 0, 0, .55); }
233
- dialog#ms h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
231
+ dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
232
+ dialog::backdrop { background: rgba(0, 0, 0, .55); }
233
+ dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
234
234
  dialog#ms .opt:last-of-type { border-bottom: 0; }
235
- dialog#ms .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
235
+ #pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
236
+ #pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
237
+ #pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
238
+ #pr-list li.dragging { opacity: .4; }
239
+ dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
236
240
  .olist .test.failed { color: var(--err); }
237
241
  .olist .test.testing, .olist .test.waiting { color: var(--accent); }
238
242
  .olist .test button { padding: 0 7px; font-size: 11px; }
@@ -258,7 +262,7 @@
258
262
  /* Your models: a table, one row a model */
259
263
  .mt-card { padding: 0; }
260
264
  .mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
261
- .mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
265
+ .mt-top .sorts { flex: 1 1 320px; min-width: 0; color: var(--muted); font-size: 13px; } /* too narrow beside the buttons: a line of its own */
262
266
  .mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
263
267
  .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
264
268
  .mt-scroll { overflow-x: auto; }
@@ -431,19 +435,6 @@
431
435
  </div>
432
436
  <div class="steps" id="steps"></div>
433
437
  <div class="stack">
434
- <div class="card" id="p-form" hidden>
435
- <h2 id="p-title">Add a provider</h2>
436
- <p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
437
- <div class="presets" id="presets"></div>
438
- <div class="row" style="margin-top:6px">
439
- <div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
440
- <div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
441
- </div>
442
- <label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
443
- <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
444
- <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
445
- <div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
446
- </div>
447
438
  <div class="work" id="p-work" hidden>
448
439
  <div class="card catalog">
449
440
  <div class="cat-top">
@@ -565,20 +556,8 @@
565
556
 
566
557
  <!-- MCP servers -->
567
558
  <section class="page" id="page-mcp">
568
- <div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div></div>
559
+ <div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div><button class="primary" id="c-open">+ Add a server</button></div>
569
560
  <div class="stack">
570
- <div class="card">
571
- <h2>Add a server</h2>
572
- <p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
573
- <div class="row">
574
- <div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
575
- <div><label class="f">Kind</label><div class="seg" id="c-kind">
576
- <label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
577
- </div></div>
578
- <div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
579
- <button class="primary" id="c-add">Add server</button>
580
- </div>
581
- </div>
582
561
  <div class="card" id="c-list"></div>
583
562
  <details class="card" id="c-raw"><summary style="cursor:pointer;font-weight:600">Edit mcp.json directly</summary><p class="sub" style="margin-top:10px">For what the form doesn't have: environment variables (<span class="mono">"env"</span>), headers, and so on.</p><div id="c-file"></div></details>
584
563
  </div>
@@ -617,6 +596,40 @@
617
596
  <div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
618
597
  </form>
619
598
  </dialog>
599
+ <dialog id="p-form" aria-labelledby="p-title">
600
+ <h2 id="p-title">Add a provider</h2>
601
+ <p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
602
+ <div class="presets" id="presets"></div>
603
+ <div class="row" style="margin-top:6px">
604
+ <div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
605
+ <div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
606
+ </div>
607
+ <label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
608
+ <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
609
+ <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
610
+ <div class="actions"><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
611
+ </dialog>
612
+ <dialog id="c-form" aria-labelledby="c-title">
613
+ <h2 id="c-title">Add a server</h2>
614
+ <p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
615
+ <div class="row">
616
+ <div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
617
+ <div><label class="f">Kind</label><div class="seg" id="c-kind">
618
+ <label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
619
+ </div></div>
620
+ <div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
621
+ </div>
622
+ <div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
623
+ </dialog>
624
+ <dialog id="k-form" aria-labelledby="k-title"></dialog>
625
+ <dialog id="pr" aria-labelledby="pr-title">
626
+ <form>
627
+ <h2 id="pr-title">Provider ranking</h2>
628
+ <p class="sub">Drag the providers into order, best first. Your models are then sorted by it.</p>
629
+ <ol id="pr-list"></ol>
630
+ <div class="actions"><button id="pr-cancel" type="button">Cancel</button><button class="primary" id="pr-save" type="button">Save</button></div>
631
+ </form>
632
+ </dialog>
620
633
  <div id="toast" role="status"></div>
621
634
  <div id="tip" role="tooltip"></div>
622
635
  <script>
@@ -766,7 +779,7 @@ const DRAFTS = { prompts: {}, skills: {}, files: {} }
766
779
  const SEL = { prompts: 'agent/system', skills: null, files: null }
767
780
  function masterDetail(kind, itemsEl, detailEl, groups, draw) {
768
781
  const all = groups.flatMap((g) => g.items)
769
- if (!all.some((x) => x.id === SEL[kind]) && SEL[kind] !== '+new') SEL[kind] = all[0]?.id ?? null
782
+ if (!all.some((x) => x.id === SEL[kind])) SEL[kind] = all[0]?.id ?? null
770
783
  // the list: built when its items change; otherwise each item is updated in place (the chosen
771
784
  // one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
772
785
  const inside = (x) => [h('div', { className: 't' }, DRAFTS[kind][x.id] !== undefined ? h('span', { className: 'dot', title: 'not saved' }) : null, x.title, x.badge ?? null), x.sub ? h('div', { className: 'd', textContent: x.sub }) : null]
@@ -787,7 +800,7 @@ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
787
800
  }
788
801
  itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
789
802
  itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
790
- put(detailEl, draw(SEL[kind] === '+new' ? '+new' : all.find((x) => x.id === SEL[kind])))
803
+ put(detailEl, draw(all.find((x) => x.id === SEL[kind])))
791
804
  itemsEl.closest('.md').classList.add('fill')
792
805
  fillHeight()
793
806
  }
@@ -879,10 +892,8 @@ const useModel = (provider, model) => act('/api/model', { provider, model }, `He
879
892
  const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
880
893
 
881
894
  let editing = null
882
- let formOpen = false
883
895
  function openForm(p = null) {
884
896
  editing = p
885
- formOpen = true
886
897
  $('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
887
898
  $('p-name').value = p?.name ?? ''
888
899
  $('p-url').value = p?.baseUrl ?? ''
@@ -891,15 +902,11 @@ function openForm(p = null) {
891
902
  $('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
892
903
  $('p-save').textContent = p ? 'Save changes' : 'Add provider'
893
904
  drawPresets()
894
- drawProviders(S)
895
- $('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
905
+ $('p-form').showModal()
896
906
  ;(p ? $('p-url') : $('p-name')).focus()
897
907
  }
898
- function closeForm() {
899
- editing = null
900
- formOpen = false
901
- drawProviders(S)
902
- }
908
+ const closeForm = () => $('p-form').close()
909
+ $('p-form').onclose = () => (editing = null)
903
910
  $('p-open').onclick = () => openForm()
904
911
  $('p-cancel').onclick = closeForm
905
912
  $('p-save').onclick = async () => {
@@ -923,36 +930,21 @@ for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown',
923
930
  const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare // one collator: thousands of names sort fast
924
931
  let modelQuery = ''
925
932
  // the models under each provider: by name, or by what its list says they cost (cheapest first; no price: last)
926
- let catSort = (() => {
927
- try {
928
- return localStorage.getItem('heart.catSort') === 'price' ? 'price' : 'name'
929
- } catch {
930
- return 'name'
931
- }
932
- })()
933
- $('m-sort').value = catSort
933
+ // The page's own choices (sorts, folds, the provider ranking), kept in ~/.heart (the browser's
934
+ // storage isn't: the page's address has a new port each time)
935
+ const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
936
+ let catSort = 'name'
934
937
  $('m-sort').onchange = (e) => {
935
938
  catSort = e.target.value
936
- try {
937
- localStorage.setItem('heart.catSort', catSort)
938
- } catch {}
939
+ savePref('catSort', catSort)
939
940
  if (S) drawProviders(S)
940
941
  }
941
942
  const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) : Infinity)
942
- // Providers folded on the page (the browser remembers them). Typing a search opens them all, until
943
- // you fold or unfold one (or all) again.
943
+ // Providers folded on the page (kept: savePref). Typing a search opens them all, until you fold or
944
+ // unfold one (or all) again.
944
945
  let searchOpen = false
945
- const FOLDED = new Set((() => {
946
- try {
947
- return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
948
- } catch {
949
- return []
950
- }
951
- })())
946
+ const FOLDED = new Set()
952
947
  function applyFolds() {
953
- try {
954
- localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
955
- } catch {}
956
948
  for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
957
949
  const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
958
950
  sec.classList.toggle('collapsed', folded)
@@ -965,9 +957,10 @@ function settle() {
965
957
  if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
966
958
  searchOpen = false
967
959
  }
968
- const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), applyFolds())
969
- $('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), applyFolds())
970
- $('unfold-all').onclick = () => (settle(), FOLDED.clear(), applyFolds())
960
+ const folds = () => (applyFolds(), savePref('folded', [...FOLDED])) // a fold changed: shown and kept
961
+ const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), folds())
962
+ $('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), folds())
963
+ $('unfold-all').onclick = () => (settle(), FOLDED.clear(), folds())
971
964
  // every model shown on the left (the search's matches, when searching) added to the end of your list
972
965
  $('add-all').onclick = () => {
973
966
  if (!S) return
@@ -1183,17 +1176,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
1183
1176
  function drawProviders(s) {
1184
1177
  const order = s.order
1185
1178
  const none = !s.providers.length
1186
- // the steps: always on the page (nothing jumps when one is done), ticked off as you go; with
1187
- // no provider yet, the form is open (the only way in: no other button)
1179
+ // the steps: always on the page (nothing jumps when one is done), ticked off as you go
1188
1180
  put($('steps'), [
1189
1181
  ['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
1190
1182
  ['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
1191
1183
  ['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
1192
1184
  ].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
1193
- const showForm = formOpen || none
1194
- $('p-form').hidden = !showForm
1195
- $('p-cancel').hidden = none // nothing to go back to
1196
- $('p-open').hidden = showForm
1197
1185
  $('p-work').hidden = none
1198
1186
  fillHeight()
1199
1187
 
@@ -1241,9 +1229,9 @@ function drawProviders(s) {
1241
1229
  h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
1242
1230
  h('div', { className: 'row', style: 'gap:8px' },
1243
1231
  h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
1244
- [['failure', 'at its first failure'], ['retries', 'after some tries of the same model'], ['any', 'at any error']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
1232
+ [['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
1245
1233
  s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
1246
- h('div', { className: 'hint', textContent: { failure: 'A rate limit, server error, timeout or lost connection moves on at once, and so does a model it can\'t use (a wrong key, no access, "model not found"). Other errors, like a bad request, stop.', retries: 'The same model is tried again (with a short wait) that many times first.', any: 'Also errors like "model not found" or a bad request move on.' }[s.rotation.on] }),
1234
+ h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
1247
1235
  h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
1248
1236
  h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
1249
1237
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
@@ -1479,7 +1467,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
1479
1467
  // how: text (A to Z first), cheap (lowest first), else most first
1480
1468
  const COLS = {
1481
1469
  model: { label: 'Model', how: 'text', get: (t) => t.model },
1482
- provider: { label: 'Provider', how: 'text', get: (t) => t.provider },
1470
+ provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
1483
1471
  context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
1484
1472
  abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
1485
1473
  tools: { label: 'Tools', get: (t) => can(t).tools },
@@ -1489,19 +1477,41 @@ const COLS = {
1489
1477
  output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
1490
1478
  works: { label: 'Works?', get: (t) => { const r = TRIES[keyOf(t)]; return !r || r.going ? 1 : r.ok ? 2 : 0 } }, // tested and working first; not tested; not working
1491
1479
  }
1492
- let SORT = (() => {
1493
- try {
1494
- return JSON.parse(localStorage.getItem('heart.sort') ?? '[]').filter((x) => COLS[x?.key])
1495
- } catch {
1496
- return []
1480
+ let SORT = []
1481
+ // your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
1482
+ let RANK = []
1483
+ const ranked = () => {
1484
+ const names = [...new Set(S.order.map((t) => t.provider))]
1485
+ return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
1486
+ }
1487
+ // the ranking dialog: drag a provider to its place, then Save
1488
+ function openRanking() {
1489
+ const list = $('pr-list')
1490
+ let held = null
1491
+ put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
1492
+ list.ondragstart = (e) => (held = e.target).classList.add('dragging')
1493
+ list.ondragend = () => held?.classList.remove('dragging')
1494
+ list.ondragover = (e) => {
1495
+ e.preventDefault()
1496
+ const over = e.target.closest('li')
1497
+ if (!held || !over || over === held) return
1498
+ const r = over.getBoundingClientRect()
1499
+ list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
1497
1500
  }
1498
- })()
1501
+ $('pr').showModal()
1502
+ }
1503
+ $('pr-cancel').onclick = () => $('pr').close()
1504
+ $('pr-save').onclick = () => {
1505
+ const list = [...$('pr-list').children].map((li) => li.textContent)
1506
+ RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
1507
+ savePref('rank', RANK)
1508
+ $('pr').close()
1509
+ setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
1510
+ }
1499
1511
  const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
1500
1512
  function setSort(next) {
1501
1513
  SORT = next
1502
- try {
1503
- localStorage.setItem('heart.sort', JSON.stringify(SORT))
1504
- } catch {}
1514
+ savePref('sort', SORT)
1505
1515
  drawMine(S)
1506
1516
  }
1507
1517
  // a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
@@ -1511,7 +1521,9 @@ function clickSort(key) {
1511
1521
  if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
1512
1522
  setSort(SORT.filter((x) => x !== at))
1513
1523
  }
1524
+ let RANKED = new Map() // provider → its place in the ranking, for one sort
1514
1525
  function sorted(order) {
1526
+ RANKED = new Map(ranked().map((n, i) => [n, i]))
1515
1527
  const cmp = (a, b, { key, dir }) => {
1516
1528
  let x = COLS[key].get(a)
1517
1529
  let y = COLS[key].get(b)
@@ -1523,7 +1535,7 @@ function sorted(order) {
1523
1535
  }
1524
1536
  return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
1525
1537
  }
1526
- const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1538
+ const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your ranking' : 'your ranking, reversed') : key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1527
1539
  // The models ticked on the Your models page ("provider/model"), to remove several at once; the last
1528
1540
  // one ticked, so a Shift+click ticks the ones between
1529
1541
  const PICKED = new Set()
@@ -1552,13 +1564,10 @@ function drawMine(s) {
1552
1564
  what === 'works' ? null : h('button', { type: 'button', className: 'again', textContent: '↻', title: `Test these ${n} again`, 'aria-label': `test the ${what} ones again`, disabled: testingAll, onclick: () => tryAll(models) }))
1553
1565
  })
1554
1566
 
1555
- const same = view.every((t, i) => t === order[i])
1556
1567
  // the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
1557
1568
  const keep = (list, moved) => {
1558
1569
  SORT = []
1559
- try {
1560
- localStorage.setItem('heart.sort', '[]')
1561
- } catch {}
1570
+ savePref('sort', SORT)
1562
1571
  return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
1563
1572
  }
1564
1573
  // a row dragged (in the order shown, sorted or not)
@@ -1594,8 +1603,10 @@ function drawMine(s) {
1594
1603
  PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
1595
1604
  PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
1596
1605
  PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
1606
+ PICKED.size ? h('button', { className: 'small', textContent: 'Invert selection', title: 'tick the ones not ticked, untick the ones ticked', onclick: () => (view.forEach((t) => (PICKED.has(keyOf(t)) ? PICKED.delete(keyOf(t)) : PICKED.add(keyOf(t)))), (lastPicked = null), drawMine(S)) }) : null,
1607
+ order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
1597
1608
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1598
- SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1609
+ order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
1599
1610
  order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
1600
1611
  order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
1601
1612
  order.length
@@ -1845,8 +1856,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
1845
1856
  }
1846
1857
  $('c-add').onclick = async () => {
1847
1858
  const r = await act('/api/mcp/add', { name: $('c-name').value, [mcpKind()]: $('c-what').value }, `added ${$('c-name').value.trim()}: it starts from the next message`)
1848
- if (r) $('c-name').value = $('c-what').value = ''
1859
+ if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
1849
1860
  }
1861
+ $('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
1862
+ $('c-cancel').onclick = () => $('c-form').close()
1850
1863
  function drawMcp(s) {
1851
1864
  put($('c-list'), h('h2', { textContent: 'Servers' }), h('p', { className: 'sub', textContent: 'Built in, and yours. Switch one off to keep it without using it.' }),
1852
1865
  s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
@@ -1873,7 +1886,27 @@ function parseSkill(text) {
1873
1886
  }
1874
1887
  const skillText = (k) => `---\n${k.description.trim() ? `description: ${k.description.trim().replace(/\n/g, ' ')}\n` : ''}use: ${k.use}\n${k.extra.map((l) => l + '\n').join('')}---\n\n${k.body.trim()}\n`
1875
1888
  const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
1876
- $('k-new').onclick = () => ((SEL.skills = '+new'), S && drawSkills(S))
1889
+ // a new skill: the form in a dialog (what you typed is kept if you close it)
1890
+ function newSkill() {
1891
+ const f = skillForm('+new', NEW, { isNew: true })
1892
+ const first = !S.skills.length
1893
+ put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
1894
+ first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
1895
+ f.el,
1896
+ h('div', { className: 'actions' },
1897
+ h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
1898
+ h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1899
+ const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1900
+ if (!f.d.body.trim()) return toast('Write the prompt first', true)
1901
+ const was = SEL.skills
1902
+ SEL.skills = slug // the new one, chosen in the list once it's there
1903
+ if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
1904
+ else SEL.skills = was
1905
+ } })))
1906
+ $('k-form').showModal()
1907
+ $('k-form').querySelector('input')?.focus()
1908
+ }
1909
+ $('k-new').onclick = newSkill
1877
1910
  function skillForm(id, start, { isNew, readOnly }) {
1878
1911
  const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
1879
1912
  const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
@@ -1891,27 +1924,11 @@ function skillForm(id, start, { isNew, readOnly }) {
1891
1924
  ] }
1892
1925
  }
1893
1926
  function drawSkills(s) {
1894
- if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
1895
- $('k-new').hidden = SEL.skills === '+new'
1896
1927
  const mine = s.skills.filter((k) => k.mine)
1897
1928
  const theirs = s.skills.filter((k) => !k.mine)
1898
1929
  const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
1899
1930
  masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
1900
- if (x === '+new') {
1901
- const f = skillForm('+new', NEW, { isNew: true })
1902
- const first = !s.skills.length
1903
- return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
1904
- first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
1905
- f.el,
1906
- h('div', { className: 'actions' },
1907
- h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1908
- const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1909
- if (!f.d.body.trim()) return toast('Write the prompt first', true)
1910
- SEL.skills = slug // the new one, chosen in the list once it's there
1911
- if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
1912
- } }),
1913
- first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
1914
- }
1931
+ if (!x) return h('div', { className: 'card' }, h('h2', { textContent: 'No skills yet' }), h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Make one with + New skill, or bring them from Claude Code and others with /import in HeartCode.' }))
1915
1932
  const k = x.k
1916
1933
  const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
1917
1934
  return h('div', { className: 'card' },
@@ -1943,9 +1960,20 @@ function drawFiles(s) {
1943
1960
  }
1944
1961
 
1945
1962
  function render(s) {
1963
+ if (s.prefs) {
1964
+ // the page's choices, as kept (also changed on another page: shown here too)
1965
+ const p = s.prefs
1966
+ catSort = p.catSort === 'price' ? 'price' : 'name'
1967
+ $('m-sort').value = catSort
1968
+ FOLDED.clear()
1969
+ for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
1970
+ SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
1971
+ RANK = Array.isArray(p.rank) ? p.rank.filter((x) => typeof x === 'string') : []
1972
+ }
1946
1973
  S = s
1947
1974
  for (const [key, r] of Object.entries(TRIES)) if (!r.going && placeOf(...key.split(/\/(.*)/s)) === undefined) delete TRIES[key] // off your list: its result too
1948
1975
  drawProviders(s)
1976
+ applyFolds()
1949
1977
  drawMine(s)
1950
1978
  drawUsing(s)
1951
1979
  drawGeneral(s)
@@ -1963,6 +1991,7 @@ if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link Heart
1963
1991
  else {
1964
1992
  job('state', { where: ['page'], what: 'Loading your settings', detail: 'providers, models, logging, prompts, MCP servers, skills' })
1965
1993
  api('/api/state').then(render, (e) => toast(e.message, true)).finally(() => job('state'))
1994
+
1966
1995
  .then(() => api('/api/tests', {}))
1967
1996
  .then(({ results, run }) => (Object.assign(TRIES, results), showRun(run)), (e) => toast(`the tests so far: ${e.message}`, true)) // and one going
1968
1997
  job('requests', { where: ['requests'], what: 'Loading the request log', detail: 'every request HeartCode logged' })
@@ -1972,6 +2001,15 @@ else {
1972
2001
  if (token) {
1973
2002
  const events = new EventSource(`/api/events?t=${token}`)
1974
2003
  // a request logged: at the top of the list, nothing else redrawn
2004
+ // the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
2005
+ // when it closes (what's typed in it stays)
2006
+ let later = null
2007
+ events.addEventListener('state', (e) => {
2008
+ const s = JSON.parse(e.data)
2009
+ if (!document.querySelector('dialog[open]')) return render(s)
2010
+ later = s
2011
+ })
2012
+ for (const d of document.querySelectorAll('dialog')) d.addEventListener('close', () => later && !document.querySelector('dialog[open]') && (render(later), (later = null)))
1975
2013
  events.addEventListener('request', (e) => {
1976
2014
  const r = JSON.parse(e.data)
1977
2015
  if (page() === 'logging') drawDisk() // the logs grew