heartcode 0.1.8 → 0.1.12

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/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; }
@@ -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 () => {
@@ -1183,17 +1190,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
1183
1190
  function drawProviders(s) {
1184
1191
  const order = s.order
1185
1192
  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)
1193
+ // the steps: always on the page (nothing jumps when one is done), ticked off as you go
1188
1194
  put($('steps'), [
1189
1195
  ['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
1190
1196
  ['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
1191
1197
  ['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
1192
1198
  ].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
1199
  $('p-work').hidden = none
1198
1200
  fillHeight()
1199
1201
 
@@ -1241,9 +1243,9 @@ function drawProviders(s) {
1241
1243
  h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
1242
1244
  h('div', { className: 'row', style: 'gap:8px' },
1243
1245
  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 }))),
1246
+ [['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
1247
  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] }),
1248
+ 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
1249
  h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
1248
1250
  h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
1249
1251
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
@@ -1254,7 +1256,7 @@ function drawProviders(s) {
1254
1256
  // left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
1255
1257
  // again only when what it lists changes (a provider, its models, the search); a change to your
1256
1258
  // list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
1257
- const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + (MODELS[p.name]?.loading ? 'loading' : '')), modelQuery.trim(), catSort])
1259
+ const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort])
1258
1260
  // every provider's list at once: on when all are
1259
1261
  const allOn = s.providers.length > 0 && s.providers.every((p) => p.list)
1260
1262
  put($('lists-all'), [sw({ checked: allOn, 'aria-label': "load every provider's model list", onchange: (e) => act('/api/provider/list', { on: e.target.checked }, `every provider's model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load all lists'])
@@ -1479,7 +1481,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
1479
1481
  // how: text (A to Z first), cheap (lowest first), else most first
1480
1482
  const COLS = {
1481
1483
  model: { label: 'Model', how: 'text', get: (t) => t.model },
1482
- provider: { label: 'Provider', how: 'text', get: (t) => t.provider },
1484
+ provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
1483
1485
  context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
1484
1486
  abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
1485
1487
  tools: { label: 'Tools', get: (t) => can(t).tools },
@@ -1496,6 +1498,44 @@ let SORT = (() => {
1496
1498
  return []
1497
1499
  }
1498
1500
  })()
1501
+ // your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
1502
+ let RANK = (() => {
1503
+ try {
1504
+ return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
1505
+ } catch {
1506
+ return []
1507
+ }
1508
+ })()
1509
+ const ranked = () => {
1510
+ const names = [...new Set(S.order.map((t) => t.provider))]
1511
+ return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
1512
+ }
1513
+ // the ranking dialog: drag a provider to its place, then Save
1514
+ function openRanking() {
1515
+ const list = $('pr-list')
1516
+ let held = null
1517
+ put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
1518
+ list.ondragstart = (e) => (held = e.target).classList.add('dragging')
1519
+ list.ondragend = () => held?.classList.remove('dragging')
1520
+ list.ondragover = (e) => {
1521
+ e.preventDefault()
1522
+ const over = e.target.closest('li')
1523
+ if (!held || !over || over === held) return
1524
+ const r = over.getBoundingClientRect()
1525
+ list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
1526
+ }
1527
+ $('pr').showModal()
1528
+ }
1529
+ $('pr-cancel').onclick = () => $('pr').close()
1530
+ $('pr-save').onclick = () => {
1531
+ const list = [...$('pr-list').children].map((li) => li.textContent)
1532
+ RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
1533
+ try {
1534
+ localStorage.setItem('heart.rank', JSON.stringify(RANK))
1535
+ } catch {}
1536
+ $('pr').close()
1537
+ setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
1538
+ }
1499
1539
  const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
1500
1540
  function setSort(next) {
1501
1541
  SORT = next
@@ -1511,7 +1551,9 @@ function clickSort(key) {
1511
1551
  if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
1512
1552
  setSort(SORT.filter((x) => x !== at))
1513
1553
  }
1554
+ let RANKED = new Map() // provider → its place in the ranking, for one sort
1514
1555
  function sorted(order) {
1556
+ RANKED = new Map(ranked().map((n, i) => [n, i]))
1515
1557
  const cmp = (a, b, { key, dir }) => {
1516
1558
  let x = COLS[key].get(a)
1517
1559
  let y = COLS[key].get(b)
@@ -1523,7 +1565,7 @@ function sorted(order) {
1523
1565
  }
1524
1566
  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
1567
  }
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')
1568
+ 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
1569
  // The models ticked on the Your models page ("provider/model"), to remove several at once; the last
1528
1570
  // one ticked, so a Shift+click ticks the ones between
1529
1571
  const PICKED = new Set()
@@ -1594,6 +1636,7 @@ function drawMine(s) {
1594
1636
  PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
1595
1637
  PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
1596
1638
  PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
1639
+ 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
1640
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1598
1641
  SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1599
1642
  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,
@@ -1845,8 +1888,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
1845
1888
  }
1846
1889
  $('c-add').onclick = async () => {
1847
1890
  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 = ''
1891
+ if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
1849
1892
  }
1893
+ $('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
1894
+ $('c-cancel').onclick = () => $('c-form').close()
1850
1895
  function drawMcp(s) {
1851
1896
  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
1897
  s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
@@ -1873,7 +1918,27 @@ function parseSkill(text) {
1873
1918
  }
1874
1919
  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
1920
  const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
1876
- $('k-new').onclick = () => ((SEL.skills = '+new'), S && drawSkills(S))
1921
+ // a new skill: the form in a dialog (what you typed is kept if you close it)
1922
+ function newSkill() {
1923
+ const f = skillForm('+new', NEW, { isNew: true })
1924
+ const first = !S.skills.length
1925
+ put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
1926
+ 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,
1927
+ f.el,
1928
+ h('div', { className: 'actions' },
1929
+ h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
1930
+ h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1931
+ const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1932
+ if (!f.d.body.trim()) return toast('Write the prompt first', true)
1933
+ const was = SEL.skills
1934
+ SEL.skills = slug // the new one, chosen in the list once it's there
1935
+ if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
1936
+ else SEL.skills = was
1937
+ } })))
1938
+ $('k-form').showModal()
1939
+ $('k-form').querySelector('input')?.focus()
1940
+ }
1941
+ $('k-new').onclick = newSkill
1877
1942
  function skillForm(id, start, { isNew, readOnly }) {
1878
1943
  const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
1879
1944
  const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
@@ -1891,27 +1956,11 @@ function skillForm(id, start, { isNew, readOnly }) {
1891
1956
  ] }
1892
1957
  }
1893
1958
  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
1959
  const mine = s.skills.filter((k) => k.mine)
1897
1960
  const theirs = s.skills.filter((k) => !k.mine)
1898
1961
  const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
1899
1962
  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
- }
1963
+ 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
1964
  const k = x.k
1916
1965
  const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
1917
1966
  return h('div', { className: 'card' },
package/src/settings.js CHANGED
@@ -427,7 +427,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
427
427
  if (!p) throw new Error(`no provider "${name}"`)
428
428
  if (p.list === false) return { models: [], details: {}, off: true }
429
429
  const lists = readJson(LISTS, {})
430
- let saved = lists[name]?.baseUrl === p.baseUrl ? lists[name] : null
430
+ let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length ? lists[name] : null // an empty one: asked again
431
431
  if (!saved) {
432
432
  const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
433
433
  const num = (...xs) => xs.map(Number).find((x) => x > 0)