heartcode 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1618 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>HeartCode Settings</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f5f6f8; --panel: #ffffff; --panel2: #f9fafb; --text: #1f2328; --muted: #57606a; --faint: #8c959f; --line: #dde2e8;
10
+ --brand: #1a7f37; --brand-soft: #e6f4ea; --on-brand: #ffffff; --accent: #0969da; --accent-soft: #e8f1fd; --ok: #1a7f37; --ok-soft: #e6f4ea; --err: #cf222e; --err-soft: #fdecee;
11
+ --input: #ffffff; --hover: #eef1f5; --on-accent: #ffffff;
12
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ --radius: 12px;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #0d0f13; --panel: #15181e; --panel2: #1a1d24; --text: #e6e8eb; --muted: #9aa0aa; --faint: #6b7280; --line: #272c36;
18
+ --brand: #9ece6a; --brand-soft: #1e2a18; --on-brand: #0d0f13; --accent: #7aa2f7; --accent-soft: #1b2438; --ok: #9ece6a; --ok-soft: #1e2a18; --err: #f7768e; --err-soft: #3a1c22;
19
+ --input: #0f1116; --hover: #1d212a; --on-accent: #0d0f13;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
24
+ a { color: var(--accent); }
25
+ button, input, select, textarea { font: inherit; color: inherit; }
26
+ [hidden] { display: none !important; }
27
+
28
+ /* the app: pages on the left, one page at a time on the right, using the whole width */
29
+ .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
30
+ aside { border-right: 1px solid var(--line); padding: 20px 12px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--panel); display: flex; flex-direction: column; }
31
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; padding: 0 10px 20px; }
32
+ .brand span { color: var(--brand); font-size: 18px; }
33
+ nav a { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; margin-bottom: 2px; }
34
+ nav a:hover { background: var(--hover); color: var(--text); }
35
+ nav a.on { background: var(--brand-soft); color: var(--text); font-weight: 600; }
36
+ nav a .ic { width: 18px; text-align: center; opacity: .8; }
37
+ nav a .count { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: normal; }
38
+ .using { margin: auto 4px 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; font-size: 12.5px; background: var(--panel2); }
39
+ .using .k { color: var(--faint); display: block; }
40
+ .using b { display: block; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 2px 0; }
41
+ main { padding: 32px 40px 64px; width: 100%; max-width: 1640px; min-width: 0; }
42
+ .page { display: none; }
43
+ .page.on { display: block; }
44
+ .head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
45
+ h1 { font-size: 24px; margin: 0 0 4px; }
46
+ .lead { color: var(--muted); margin: 0; max-width: 760px; }
47
+ h2 { font-size: 15px; margin: 0 0 4px; }
48
+ h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
49
+ .sub { color: var(--muted); margin: 0 0 14px; font-size: 13px; }
50
+
51
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; }
52
+ .stack { display: flex; flex-direction: column; gap: 18px; }
53
+ /* two columns: the work on the left, a panel on the right */
54
+ .split { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 24px; align-items: start; }
55
+ .split > .side { position: sticky; top: 24px; }
56
+ /* cards that fill the width, side by side */
57
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); gap: 18px; align-items: start; }
58
+ .grid .wide { grid-column: 1 / -1; }
59
+ /* two columns of cards, each a stack (no gaps where one card is shorter) */
60
+ .cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: stretch; }
61
+ .cols > .stack { display: flex; flex-direction: column; }
62
+ .cols > .stack > .card:last-child { flex: 1; } /* both columns end at the same line */
63
+ /* two or three choices in one control (Command | URL) */
64
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; height: 38px; }
65
+ .seg label { display: flex; align-items: center; padding: 0 14px; cursor: pointer; color: var(--muted); }
66
+ .seg label + label { border-left: 1px solid var(--line); }
67
+ .seg label.on { background: var(--accent-soft); color: var(--text); font-weight: 600; }
68
+ .seg input { position: absolute; opacity: 0; width: 0; }
69
+ /* a list on the left, what's chosen on the right */
70
+ .md { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 20px; align-items: start; }
71
+ .md .list { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; position: sticky; top: 24px; max-height: calc(100vh - 170px); display: flex; flex-direction: column; }
72
+ .md .list .top { padding: 12px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; }
73
+ .md .list .items { overflow-y: auto; }
74
+ .md .group { padding: 10px 14px 4px; font-size: 11px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .05em; }
75
+ .md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px 14px; border-left: 3px solid transparent; cursor: pointer; }
76
+ .md .it:hover { background: var(--hover); }
77
+ .md .it.on { background: var(--accent-soft); border-left-color: var(--accent); }
78
+ .md .it .t { display: flex; gap: 6px; align-items: center; font-weight: 600; font-size: 13px; word-break: break-all; }
79
+ .md .it .d { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
80
+ .md .detail { min-width: 0; }
81
+ .md.solo { grid-template-columns: minmax(0, 1fr); } /* nothing to list: the panel gets the width */
82
+ .md.solo .list { display: none; }
83
+ /* a long list (the prompts): list and editor fill the screen's height, like an editor app */
84
+ /* a list (or tabs) and its editor fill the screen below the page's title, like an editor app;
85
+ the height is set from where it starts (fillHeight) */
86
+ .md.fill { min-height: 460px; align-items: stretch; }
87
+ .md.fill:not(.tabs) .list { max-height: none; height: 100%; position: static; }
88
+ .md.fill.tabs { grid-template-rows: auto minmax(0, 1fr); }
89
+ .md.fill .detail { min-height: 0; }
90
+ .md.fill .detail > .card { height: 100%; display: flex; flex-direction: column; overflow: auto; }
91
+ .md.fill .detail > .card > textarea.editor { flex: 1; height: auto !important; min-height: 140px; }
92
+ /* a few items: tabs above the panel instead of a mostly empty column */
93
+ .md.tabs { grid-template-columns: minmax(0, 1fr); gap: 14px; }
94
+ .md.tabs .list { position: static; max-height: none; background: none; border: 0; overflow: visible; }
95
+ .md.tabs .items { display: flex; flex-wrap: wrap; gap: 8px; overflow: visible; }
96
+ .md.tabs .group { display: none; }
97
+ .md.tabs .it { width: auto; border: 1px solid var(--line); border-radius: 999px; padding: 7px 16px; background: var(--panel); }
98
+ .md.tabs .it.on { border-color: var(--accent); background: var(--accent-soft); }
99
+ .md.tabs .it .d { display: none; }
100
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); flex: none; }
101
+
102
+ label.f { display: block; font-size: 12.5px; font-weight: 600; margin: 14px 0 6px; }
103
+ .hint { color: var(--faint); font-size: 12.5px; margin-top: 6px; }
104
+ input[type=text], input[type=password], input[type=number], input[type=search], select, textarea {
105
+ width: 100%; padding: 8px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--input);
106
+ }
107
+ input[type=text], input[type=password], input[type=number], input[type=search], select { height: 38px; }
108
+ textarea { font-family: var(--mono); font-size: 12.5px; resize: vertical; tab-size: 2; line-height: 1.55; display: block; }
109
+ textarea.editor { min-height: 96px; overflow-y: auto; } /* grows with its text (grow()), up to most of the screen */
110
+ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
111
+ button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; white-space: nowrap; }
112
+ button:hover:not(:disabled) { border-color: var(--accent); }
113
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
114
+ button.primary:hover:not(:disabled) { filter: brightness(1.08); }
115
+ button.danger { color: var(--err); }
116
+ button.small { padding: 3px 10px; font-size: 12.5px; }
117
+ button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
118
+ button:disabled { opacity: .4; cursor: default; }
119
+ .row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
120
+ .row > .grow { flex: 1; min-width: 180px; }
121
+ .actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
122
+ .actions .spacer { flex: 1; }
123
+ .mono { font-family: var(--mono); font-size: 12.5px; }
124
+ .muted { color: var(--muted); }
125
+ .tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 10px; background: var(--hover); color: var(--muted); }
126
+ .tag.brand { background: var(--brand); color: var(--on-brand); }
127
+ .tag.ok { background: var(--ok-soft); color: var(--ok); }
128
+ .tag.err { background: var(--err-soft); color: var(--err); }
129
+ .empty { text-align: center; padding: 26px 16px; color: var(--muted); }
130
+ .empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 4px; }
131
+
132
+ /* first start: the steps, then adding a provider */
133
+ .steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; }
134
+ .step { display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--muted); font-size: 13px; }
135
+ .step .num { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; background: var(--hover); color: var(--muted); flex: none; }
136
+ .step b { display: block; color: var(--text); }
137
+ .step.done .num { background: var(--ok-soft); color: var(--ok); }
138
+ .step.now { border-color: var(--brand); }
139
+ .step.now .num { background: var(--brand); color: var(--on-brand); }
140
+ .presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 10px; }
141
+ .preset { text-align: left; padding: 12px 14px; border-radius: 10px; }
142
+ .preset b { display: block; }
143
+ .preset span { color: var(--muted); font-size: 12px; }
144
+ .preset.on { border-color: var(--accent); background: var(--accent-soft); }
145
+
146
+ /* Providers & models: every provider's models on the left (pick one), yours in order on the right;
147
+ both fill the screen's height and scroll inside */
148
+ .work { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } /* the catalog and your models, half and half */
149
+ .work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
150
+ .cat-top { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; align-items: center; }
151
+ .cat-top input { flex: 1; min-width: 0; }
152
+ .cat-top button { flex: none; }
153
+ .prov-h { cursor: pointer; }
154
+ .prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; align-self: flex-start; margin-top: 1px; }
155
+ .prov-h .fold:hover { color: var(--text); }
156
+ section.collapsed .prov-h .fold { transform: rotate(-90deg); }
157
+ section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
158
+ .cat-list { flex: 1; overflow-y: auto; }
159
+ .prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
160
+ .prov-h .who { flex: 1; min-width: 200px; }
161
+ .prov-h .name { font-weight: 700; font-size: 15px; }
162
+ .prov-h .meta { font-size: 12px; color: var(--muted); }
163
+ .prov-h .meta .mono { font-size: 12px; }
164
+ .rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
165
+ .mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; }
166
+ .mrow:hover { background: var(--hover); }
167
+ .mrow.flash { animation: flash 1.6s ease-out; }
168
+ @keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
169
+ .mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
170
+ .mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
171
+ .mrow.taken .id { color: var(--muted); }
172
+ .mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
173
+ .mwrap .mrow { border-bottom: 0; }
174
+ .mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
175
+ .mwrap .forget:hover { color: var(--err); }
176
+ .prov-h .listsw { margin-top: 8px; width: fit-content; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
177
+ .prov-h .listsw .sw { margin-top: 0; }
178
+ .log-opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; }
179
+ .log-opts .opt { border-bottom: 0; padding-bottom: 0; }
180
+ @media (max-width: 860px) { .log-opts { grid-template-columns: 1fr; } }
181
+ .log-days { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
182
+ .log-days input { width: 90px; }
183
+ .log-days .muted { font-size: 12.5px; flex: 1; min-width: 220px; }
184
+ .reqs-card { margin-top: 18px; padding: 0; }
185
+ .reqs-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 16px 18px; border-bottom: 1px solid var(--line); }
186
+ .reqs { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: 420px; }
187
+ .req-list { max-height: 70vh; overflow-y: auto; border-right: 1px solid var(--line); }
188
+ .req-detail { max-height: 70vh; overflow-y: auto; padding: 16px 18px; min-width: 0; }
189
+ .rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; background: none; padding: 10px 14px; cursor: pointer; }
190
+ .rq:hover { background: var(--hover); }
191
+ .rq.on { background: var(--accent-soft); border-left-color: var(--accent); }
192
+ .rq.failed { border-left-color: var(--err); }
193
+ .rq.failed.on { background: var(--err-soft); }
194
+ .rq .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
195
+ .rq .m { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196
+ .rq .l2 { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-top: 3px; }
197
+ .rq .why { color: var(--err); font-size: 12px; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
198
+ .kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 8px 0 14px; }
199
+ .kv dt { color: var(--muted); }
200
+ .kv dd { margin: 0; overflow-wrap: anywhere; }
201
+ .req-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 6px 0 12px; max-height: 360px; overflow: auto; }
202
+ .req-detail details { margin: 4px 0; }
203
+ .req-detail summary { cursor: pointer; font-size: 13px; color: var(--muted); }
204
+ .req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
205
+ @media (max-width: 860px) { .reqs { grid-template-columns: 1fr; } .req-list { border-right: 0; max-height: 50vh; } }
206
+ .cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
207
+ .byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
208
+ .byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
209
+ /* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
210
+ .olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
211
+ .olist .test .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
212
+ .olist .test .spin { flex: none; }
213
+ .olist .test button { flex: none; }
214
+ .olist .test.ok { color: var(--ok); }
215
+ .olist .test.warn { color: var(--accent); }
216
+ 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; }
217
+ dialog#ms::backdrop { background: rgba(0, 0, 0, .55); }
218
+ dialog#ms h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
219
+ dialog#ms .opt:last-of-type { border-bottom: 0; }
220
+ dialog#ms .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
221
+ .olist .test.failed { color: var(--err); }
222
+ .olist .test.testing, .olist .test.waiting { color: var(--accent); }
223
+ .olist .test button { padding: 0 7px; font-size: 11px; }
224
+ .spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
225
+ @keyframes spin { to { transform: rotate(360deg); } }
226
+ .mine-top { padding: 16px 18px 6px; }
227
+ .mine-title { display: flex; align-items: center; justify-content: space-between; gap: 6px 8px; flex-wrap: wrap; }
228
+ .mine-title h2 { margin: 0 0 6px; flex: 1; white-space: nowrap; }
229
+ .mine-title button { white-space: nowrap; }
230
+ /* your models as a table: a header, then one line a model */
231
+ .ohead, .olist li.in { display: grid; grid-template-columns: 14px 30px minmax(0, 1.7fr) minmax(0, 0.6fr) minmax(0, 1.5fr) 26px; align-items: center; column-gap: 8px; padding: 7px 16px; }
232
+ .ohead { font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding-top: 8px; padding-bottom: 8px; }
233
+ .olist { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
234
+ .olist li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; }
235
+ .olist li.in:first-child { background: var(--brand-soft); }
236
+ .olist li.next { border-bottom: 0; background: none; color: var(--faint); font-size: 12.5px; text-align: center; padding: 14px 10px; }
237
+ .olist li.in { cursor: grab; user-select: none; transition: transform .16s ease, box-shadow .16s ease; position: relative; }
238
+ .olist.sorting li.in { transition: transform .16s ease; }
239
+ .olist li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
240
+ .olist .grip { touch-action: none; }
241
+ body.sorting, body.sorting * { cursor: grabbing !important; }
242
+ /* Your models: a table, one row a model */
243
+ .mt-card { padding: 0; }
244
+ .mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
245
+ .mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
246
+ .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; }
247
+ .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
248
+ .mt-scroll { overflow-x: auto; }
249
+ .mt { min-width: 1090px; }
250
+ .mt-h, .mtable li { display: grid; grid-template-columns: 16px 28px minmax(150px, 3fr) minmax(70px, 1.1fr) 66px 66px 42px 60px 50px 54px 60px minmax(166px, 1.5fr) minmax(120px, 1.3fr) 28px; align-items: center; column-gap: 6px; padding: 8px 16px; }
251
+ .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
252
+ .mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
253
+ .mt-h button:hover { color: var(--text); }
254
+ .mt-h button.on { color: var(--brand); font-weight: 700; }
255
+ .mt-h .num, .mtable .num { text-align: right; }
256
+ .mtable { list-style: none; margin: 0; padding: 0; }
257
+ .mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; }
258
+ .mtable li:last-child { border-bottom: 0; }
259
+ .mtable li.in { user-select: none; transition: transform .16s ease, box-shadow .16s ease; }
260
+ .mtable.drag li.in { cursor: grab; }
261
+ .mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
262
+ .mtable .grip { color: var(--faint); touch-action: none; }
263
+ .mtable:not(.drag) .grip { visibility: hidden; }
264
+ .mtable .n { font-weight: 800; color: var(--brand); }
265
+ .mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }
266
+ .mtable .p { color: var(--muted); overflow-wrap: anywhere; }
267
+ .mtable .yes { color: var(--ok); }
268
+ .mtable .no { color: var(--faint); }
269
+ .mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
270
+ .mtable .src.warn { color: var(--accent); }
271
+ .mtable button { padding: 1px 7px; font-size: 11.5px; }
272
+ .mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
273
+ .mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
274
+ .mtable .try.ok { color: var(--ok); }
275
+ .mtable .try.bad { color: var(--err); }
276
+ .mtable .try.going { color: var(--accent); }
277
+ .mt-empty { padding: 28px 18px; color: var(--muted); }
278
+ .olist .grip { cursor: grab; color: var(--faint); padding: 0 2px; user-select: none; }
279
+ .olist .n { font-weight: 800; color: var(--brand); min-width: 24px; }
280
+ .olist .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
281
+ .olist .p { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
282
+ .olist .p.use { color: var(--brand); font-weight: 600; }
283
+ .olist button { padding: 2px 8px; font-size: 12px; }
284
+ .olist button.danger { padding: 1px 6px; }
285
+ .mine-rules { border-top: 1px solid var(--line); padding: 4px 18px 16px; }
286
+ .mine-rules .hint { min-height: 2.9em; } /* the hints differ in length: the next setting stays put */
287
+ .mine-empty { padding: 30px 22px; text-align: center; color: var(--muted); }
288
+ .mine-empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 6px; }
289
+
290
+ /* switches */
291
+ .opt { display: flex; gap: 14px; align-items: flex-start; padding: 14px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
292
+ .opt:last-child { border-bottom: 0; padding-bottom: 0; }
293
+ .opt:first-child { padding-top: 0; }
294
+ .opt .t { flex: 1; min-width: 0; }
295
+ .opt b { display: block; }
296
+ .opt span { color: var(--muted); font-size: 13px; }
297
+ .sw { position: relative; width: 38px; height: 22px; flex: none; margin-top: 2px; }
298
+ .sw input { opacity: 0; width: 0; height: 0; position: absolute; }
299
+ .sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line); transition: background .15s; }
300
+ .sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: transform .15s; }
301
+ .sw input:checked + i { background: var(--ok); }
302
+ .sw input:checked + i::after { transform: translateX(16px); }
303
+ .sw input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
304
+
305
+ /* choices as cards (permissions, colors) */
306
+ .choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
307
+ .choice { position: relative; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; background: var(--panel); }
308
+ .choice:hover { border-color: var(--accent); }
309
+ .choice.on { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
310
+ .choice b { display: block; }
311
+ .choice span { color: var(--muted); font-size: 12.5px; }
312
+ .choice input { position: absolute; opacity: 0; }
313
+ .swatch { display: flex; gap: 3px; margin-bottom: 8px; }
314
+ .swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid var(--line); }
315
+
316
+ /* a model's tooltip: shown at once on hover (or keyboard focus), next to the row */
317
+ #tip { position: fixed; z-index: 30; max-width: 380px; padding: 12px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 10px 30px rgba(0, 0, 0, .28); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .08s; }
318
+ #tip.show { opacity: 1; }
319
+ #tip .tid { font-family: var(--mono); font-weight: 700; font-size: 13px; word-break: break-all; }
320
+ #tip .tname { color: var(--muted); margin-top: 1px; }
321
+ #tip dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 10px 0 0; }
322
+ #tip dt { color: var(--faint); }
323
+ #tip dd { margin: 0; }
324
+ #tip .tdesc { color: var(--muted); margin-top: 10px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
325
+ #tip .thint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); color: var(--accent); font-weight: 600; }
326
+ #toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); padding: 10px 18px; border-radius: 10px; background: var(--text); color: var(--bg); opacity: 0; transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
327
+ #toast.show { opacity: 1; }
328
+ #toast.err { background: var(--err); color: #fff; }
329
+
330
+ @media (max-width: 1180px) {
331
+ .split { grid-template-columns: 1fr; }
332
+ .work { grid-template-columns: 1fr; height: auto !important; }
333
+ .work .mine { order: -1; }
334
+ .cat-list { max-height: 520px; }
335
+ .split > .side { position: static; order: -1; }
336
+ .md { grid-template-columns: 280px minmax(0, 1fr); }
337
+ }
338
+ @media (max-width: 860px) {
339
+ .app { grid-template-columns: minmax(0, 1fr); }
340
+ aside { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 12px 16px 8px; }
341
+ .brand { padding: 0 0 8px; }
342
+ nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }
343
+ nav a { flex: none; margin: 0; }
344
+ nav a .count, .using { display: none; }
345
+ main { padding: 20px 16px 60px; }
346
+ .steps { grid-template-columns: 1fr; }
347
+ .choices { grid-template-columns: 1fr; }
348
+ .grid, .cols { grid-template-columns: 1fr; }
349
+ .md { grid-template-columns: 1fr; }
350
+ .md .list { position: static; max-height: 300px; }
351
+ .md.fill .list { height: auto !important; max-height: 300px; }
352
+ .md.fill .detail > .card { height: auto; }
353
+ .md.fill .detail > .card > textarea.editor { min-height: 260px; }
354
+ .prov-head, .prov-body { padding-left: 14px; padding-right: 14px; }
355
+ }
356
+ </style>
357
+ </head>
358
+ <body>
359
+ <div class="app">
360
+ <aside>
361
+ <div class="brand"><span>♥</span> HeartCode settings</div>
362
+ <nav id="nav">
363
+ <a href="#providers" data-page="providers"><span class="ic">◉</span> Providers &amp; models <span class="count" id="n-providers"></span></a>
364
+ <a href="#models" data-page="models"><span class="ic">☰</span> Your models <span class="count" id="n-mine"></span></a>
365
+ <a href="#general" data-page="general"><span class="ic">⚙</span> General</a>
366
+ <a href="#logging" data-page="logging"><span class="ic">▤</span> Logging <span class="count" id="n-failed"></span></a>
367
+ <a href="#prompts" data-page="prompts"><span class="ic">✎</span> System prompt</a>
368
+ <a href="#mcp" data-page="mcp"><span class="ic">⧉</span> MCP servers <span class="count" id="n-mcp"></span></a>
369
+ <a href="#skills" data-page="skills"><span class="ic">✦</span> Skills <span class="count" id="n-skills"></span></a>
370
+ <a href="#files" data-page="files"><span class="ic">≡</span> Advanced files</a>
371
+ </nav>
372
+ <div class="using" id="using"></div>
373
+ </aside>
374
+
375
+ <main>
376
+ <!-- Providers & models -->
377
+ <section class="page" id="page-providers">
378
+ <div class="head">
379
+ <div><h1>Providers &amp; models</h1><p class="lead">A provider is a service with models (OpenRouter, Groq, Ollama on this machine…). Add one, then choose which of its models HeartCode uses.</p></div>
380
+ <button class="primary" id="p-open">+ Add a provider</button>
381
+ </div>
382
+ <div class="steps" id="steps"></div>
383
+ <div class="stack">
384
+ <div class="card" id="p-form" hidden>
385
+ <h2 id="p-title">Add a provider</h2>
386
+ <p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
387
+ <div class="presets" id="presets"></div>
388
+ <div class="row" style="margin-top:6px">
389
+ <div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
390
+ <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>
391
+ </div>
392
+ <label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
393
+ <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
394
+ <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
395
+ <div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
396
+ </div>
397
+ <div class="work" id="p-work">
398
+ <div class="card catalog">
399
+ <div class="cat-top"><input type="search" id="m-search" placeholder="Search all models…"><button class="small" id="fold-all" type="button">Collapse all</button><button class="small" id="unfold-all" type="button">Expand all</button></div>
400
+ <div class="cat-list" id="p-list"></div>
401
+ </div>
402
+ <div class="card mine" id="inuse"></div>
403
+ </div>
404
+ </div>
405
+ </section>
406
+
407
+ <!-- Your models: all at once, sortable -->
408
+ <section class="page" id="page-models">
409
+ <div class="head"><div><h1>Your models</h1><p class="lead">Every model in your list, with what it can do. Click a column to sort by it, then more columns to break ties; click again to flip, a third time to drop it. Save the order you see, or drag a row: that saves the order you see with your change, and you go on from there.</p></div></div>
410
+ <div class="card mt-card" id="mt"></div>
411
+ </section>
412
+
413
+ <!-- Logging -->
414
+ <section class="page" id="page-logging">
415
+ <div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div></div>
416
+ <div class="card">
417
+ <div class="log-opts">
418
+ <div>
419
+ <h2>The run's log</h2>
420
+ <p class="sub">Everything each run does, as text: requests, replies, retries, tools, commands, what you typed.</p>
421
+ <div id="l-run"></div>
422
+ </div>
423
+ <div>
424
+ <h2>Requests</h2>
425
+ <p class="sub">Each request to a model and its reply or error: which failed, which worked, and which fallback answered.</p>
426
+ <div id="l-req"></div>
427
+ <div class="row" style="align-items:center;gap:10px;margin-top:12px">
428
+ <label class="f" for="l-detail" style="margin:0">Keep</label>
429
+ <select id="l-detail" style="flex:1">
430
+ <option value="full">Everything: what was sent and what came back</option>
431
+ <option value="summary">Only a summary: model, result, error, timings, tokens</option>
432
+ </select>
433
+ </div>
434
+ <div class="hint">Everything can make big files on long conversations. API keys are never written.</div>
435
+ </div>
436
+ </div>
437
+ <div class="log-days">
438
+ <label for="l-days"><b>Keep logs for</b></label>
439
+ <input type="number" id="l-days" min="1" max="3650" step="1" value="30">
440
+ <span>days</span>
441
+ <span class="muted">Older ones (both kinds) are deleted when HeartCode starts, and right away when you change this. 30 days unless you change it.</span>
442
+ </div>
443
+ </div>
444
+ <div class="card reqs-card">
445
+ <div class="reqs-top">
446
+ <h2 style="margin:0">Requests logged</h2>
447
+ <div class="seg" id="l-filter">
448
+ <label class="on"><input type="radio" name="l-filter" value="all" checked>All</label><label><input type="radio" name="l-filter" value="failed">Failed</label><label><input type="radio" name="l-filter" value="fallback">Fallbacks</label>
449
+ </div>
450
+ </div>
451
+ <div class="reqs">
452
+ <div class="req-list" id="l-list"></div>
453
+ <div class="req-detail" id="l-detail-pane"><div class="empty">Click a request to see it in full.</div></div>
454
+ </div>
455
+ </div>
456
+ </section>
457
+
458
+ <!-- General -->
459
+ <section class="page" id="page-general">
460
+ <div class="head"><div><h1>General</h1><p class="lead">How HeartCode works for you. Saved for next time; a running HeartCode takes changes right away.</p></div></div>
461
+ <div class="cols">
462
+ <div class="stack">
463
+ <div class="card">
464
+ <h2>What can HeartCode do without asking?</h2>
465
+ <p class="sub">Shift+Tab in HeartCode switches between these too (and to plan mode).</p>
466
+ <div class="choices" id="g-perm"></div>
467
+ </div>
468
+ <div class="card">
469
+ <h2>Colors</h2>
470
+ <p class="sub">Your own colors on top of a theme: theme.json in <a href="#files">Advanced files</a>.</p>
471
+ <div class="choices" id="g-theme"></div>
472
+ </div>
473
+ <div class="card">
474
+ <h2>Thinking and context</h2>
475
+ <div class="row">
476
+ <div class="grow"><label class="f" for="g-effort">How hard a thinking model thinks</label><select id="g-effort"></select></div>
477
+ <div class="grow"><label class="f" for="g-context">Context window (tokens)</label><input type="number" id="g-context" min="1" placeholder="as the model says"></div>
478
+ </div>
479
+ <div class="hint">Leave the context window empty unless HeartCode gets it wrong for your model.</div>
480
+ </div>
481
+ </div>
482
+ <div class="stack">
483
+ <div class="card">
484
+ <h2>Features</h2>
485
+ <p class="sub">Switch each one on or off.</p>
486
+ <div id="g-toggles"></div>
487
+ </div>
488
+ <div class="card">
489
+ <h2>Voice input</h2>
490
+ <p class="sub">Ctrl+R in HeartCode. Empty: whisper-cli on this machine. Or a provider's transcription API: <span class="mono">{ "provider": "groq", "model": "whisper-large-v3" }</span>; add <span class="mono">"language": "auto"</span> for other languages.</p>
491
+ <textarea id="v-text" spellcheck="false" rows="3" placeholder="(whisper-cli on this machine)"></textarea>
492
+ <div class="actions"><button class="primary" id="v-save">Save voice</button></div>
493
+ </div>
494
+ </div>
495
+ </div>
496
+ </section>
497
+
498
+ <!-- System prompt and the other prompts -->
499
+ <section class="page" id="page-prompts">
500
+ <div class="head"><div><h1>System prompt</h1><p class="lead">What HeartCode tells the model. Change a prompt and save: a running HeartCode uses yours from the next message. <span class="mono">{{names}}</span> are filled in by HeartCode; Reset puts HeartCode's back.</p></div></div>
501
+ <div class="md">
502
+ <div class="list"><div class="top"><input type="search" id="r-search" placeholder="Search prompts…"></div><div class="items" id="r-items"></div></div>
503
+ <div class="detail" id="r-detail"></div>
504
+ </div>
505
+ </section>
506
+
507
+ <!-- MCP servers -->
508
+ <section class="page" id="page-mcp">
509
+ <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>
510
+ <div class="stack">
511
+ <div class="card">
512
+ <h2>Add a server</h2>
513
+ <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>
514
+ <div class="row">
515
+ <div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
516
+ <div><label class="f">Kind</label><div class="seg" id="c-kind">
517
+ <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>
518
+ </div></div>
519
+ <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>
520
+ <button class="primary" id="c-add">Add server</button>
521
+ </div>
522
+ </div>
523
+ <div class="card" id="c-list"></div>
524
+ <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>
525
+ </div>
526
+ </section>
527
+
528
+ <!-- Skills -->
529
+ <section class="page" id="page-skills">
530
+ <div class="head"><div><h1>Skills</h1><p class="lead">Ready-made prompts. You type one as <span class="mono">/name</span>, or the model uses it when it fits.</p></div><button class="primary" id="k-new">+ New skill</button></div>
531
+ <div class="md">
532
+ <div class="list"><div class="items" id="k-items"></div></div>
533
+ <div class="detail" id="k-detail"></div>
534
+ </div>
535
+ </section>
536
+
537
+ <!-- Advanced files -->
538
+ <section class="page" id="page-files">
539
+ <div class="head"><div><h1>Advanced files</h1><p class="lead">The rest of HeartCode's settings, as the files they're kept in (<span class="mono" id="f-home"></span>). A JSON file that doesn't parse isn't saved; save one empty to delete it.</p></div></div>
540
+ <div class="md">
541
+ <div class="list"><div class="items" id="f-items"></div></div>
542
+ <div class="detail" id="f-detail"></div>
543
+ </div>
544
+ </section>
545
+ </main>
546
+ </div>
547
+ <dialog id="ms" aria-labelledby="ms-title">
548
+ <form>
549
+ <h2 id="ms-title"></h2>
550
+ <p class="sub" id="ms-from"></p>
551
+ <label class="f" for="ms-context">Context window (tokens)</label>
552
+ <div class="row" style="gap:8px;align-items:center"><input type="number" id="ms-context" min="512" step="1" style="flex:1"><button class="small" id="ms-measure" type="button">Measure</button></div>
553
+ <div class="hint">Empty: as found (models.dev, the server, or what HeartCode learns). Measure asks the server.</div>
554
+ <label class="opt"><div class="t"><b>Tool calls</b><span>Reads, edits and runs things itself. Off: HeartCode guides it step by step.</span></div><span class="sw"><input type="checkbox" id="ms-tools"><i></i></span></label>
555
+ <label class="opt"><div class="t"><b>Thinking</b><span>Thinks before it answers (the effort setting applies).</span></div><span class="sw"><input type="checkbox" id="ms-thinking"><i></i></span></label>
556
+ <label class="opt"><div class="t"><b>Images</b><span>Reads screenshots and pictures you send.</span></div><span class="sw"><input type="checkbox" id="ms-images"><i></i></span></label>
557
+ <label class="opt"><div class="t"><b>Streaming</b><span>Its reply shows as it's written. Off: all at once at the end.</span></div><span class="sw"><input type="checkbox" id="ms-stream"><i></i></span></label>
558
+ <div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
559
+ </form>
560
+ </dialog>
561
+ <div id="toast" role="status"></div>
562
+ <div id="tip" role="tooltip"></div>
563
+ <script>
564
+ const token = new URLSearchParams(location.search).get('t')
565
+ const $ = (id) => document.getElementById(id)
566
+ const h = (tag, props = {}, ...kids) => {
567
+ const el = document.createElement(tag)
568
+ for (const [k, v] of Object.entries(props)) k.startsWith('on') ? el.addEventListener(k.slice(2), v) : k in el ? (el[k] = v) : el.setAttribute(k, v)
569
+ el.append(...kids.flat(Infinity).filter((x) => x != null && x !== false))
570
+ return el
571
+ }
572
+ // an element's content, without the null/false parts (left out on purpose)
573
+ const put = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter((x) => x != null && x !== false))
574
+ let S = null
575
+ let toastTimer
576
+ function toast(text, err = false) {
577
+ const t = $('toast')
578
+ t.textContent = text
579
+ t.className = 'show' + (err ? ' err' : '')
580
+ clearTimeout(toastTimer)
581
+ toastTimer = setTimeout(() => (t.className = ''), err ? 6000 : 2400)
582
+ }
583
+ async function api(path, body) {
584
+ const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) })
585
+ const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
586
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
587
+ return data
588
+ }
589
+ async function act(path, body, done) {
590
+ try {
591
+ const r = await api(path, body)
592
+ if (r.providers) render(r)
593
+ if (done) toast(done)
594
+ return r
595
+ } catch (e) {
596
+ toast(e.message, true)
597
+ return null
598
+ }
599
+ }
600
+ const sw = (props) => h('label', { className: 'sw' }, h('input', { type: 'checkbox', ...props }), h('i'))
601
+
602
+ // pages: one at a time, from the address (#providers…)
603
+ const PAGES = ['providers', 'models', 'general', 'logging', 'prompts', 'mcp', 'skills', 'files']
604
+ const page = () => (PAGES.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'providers')
605
+ function showPage() {
606
+ for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
607
+ for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
608
+ if (S) drawUsing(S)
609
+ scrollTo(0, 0)
610
+ fillHeight()
611
+ document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
612
+ }
613
+ addEventListener('hashchange', showPage)
614
+
615
+ // A list on the left, the chosen one on the right (prompts, skills, files). Unsaved text in the
616
+ // editor is kept per item while you look at others, marked with a dot in the list.
617
+ const DRAFTS = { prompts: {}, skills: {}, files: {} }
618
+ const SEL = { prompts: 'agent/system', skills: null, files: null }
619
+ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
620
+ const all = groups.flatMap((g) => g.items)
621
+ if (!all.some((x) => x.id === SEL[kind]) && SEL[kind] !== '+new') SEL[kind] = all[0]?.id ?? null
622
+ // the list: built when its items change; otherwise each item is updated in place (the chosen
623
+ // one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
624
+ 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]
625
+ const shape = JSON.stringify(groups.map((g) => [g.title, g.items.map((x) => x.id)]))
626
+ if (itemsEl.dataset.shape !== shape) {
627
+ itemsEl.dataset.shape = shape
628
+ put(itemsEl, groups.map((g) => [
629
+ g.title && g.items.length ? h('div', { className: 'group', textContent: g.title }) : null,
630
+ g.items.map((x) => h('button', { className: 'it', 'data-id': x.id })),
631
+ ]))
632
+ }
633
+ const byId = new Map(all.map((x) => [x.id, x]))
634
+ for (const b of itemsEl.querySelectorAll('.it')) {
635
+ const x = byId.get(b.dataset.id)
636
+ b.className = 'it' + (x.id === SEL[kind] ? ' on' : '')
637
+ put(b, inside(x))
638
+ b.onclick = () => ((SEL[kind] = x.id), masterDetail(kind, itemsEl, detailEl, groups, draw))
639
+ }
640
+ itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
641
+ itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
642
+ put(detailEl, draw(SEL[kind] === '+new' ? '+new' : all.find((x) => x.id === SEL[kind])))
643
+ itemsEl.closest('.md').classList.add('fill')
644
+ fillHeight()
645
+ }
646
+ // an editor for one item: its text (or your unsaved changes to it)
647
+ // A text box as tall as its text (at least a few lines, at most most of the screen): no empty box.
648
+ function grow(ta, min = 96) {
649
+ const fit = () => {
650
+ ta.style.height = 'auto'
651
+ ta.style.height = `${Math.min(Math.max(ta.scrollHeight + 2, min), Math.max(min, innerHeight * 0.72))}px`
652
+ }
653
+ ta.addEventListener('input', fit)
654
+ requestAnimationFrame(fit) // once it's on the page
655
+ return ta
656
+ }
657
+ // the list-and-editor pages: down to the bottom of the screen (from wherever their title ends)
658
+ function fillHeight() {
659
+ requestAnimationFrame(() => {
660
+ const room = (el) => Math.max(460, innerHeight - el.getBoundingClientRect().top - window.scrollY - 28)
661
+ for (const md of document.querySelectorAll('.page.on .md.fill')) md.style.height = innerWidth > 860 ? `${room(md)}px` : ''
662
+ // the models: as tall as what's in them, at most to the bottom of the screen (then they scroll)
663
+ // (measured from the content's own height: the layout isn't touched, so nothing scrolls back)
664
+ for (const w of document.querySelectorAll('.page.on .work')) {
665
+ const natural = Math.max(...[...w.children].map((card) => [...card.children].reduce((n, c) => n + c.scrollHeight, 2)))
666
+ w.style.height = innerWidth > 860 ? `${Math.min(natural, room(w))}px` : ''
667
+ }
668
+ })
669
+ }
670
+ addEventListener('resize', () => (document.querySelectorAll('textarea').forEach((t) => t.dispatchEvent(new Event('input'))), fillHeight()))
671
+ function editor(kind, id, saved, props = {}) {
672
+ const ta = grow(h('textarea', { className: 'editor', spellcheck: false, ...props, value: DRAFTS[kind][id] ?? saved }))
673
+ ta.addEventListener('input', () => {
674
+ if (ta.value === saved) delete DRAFTS[kind][id]
675
+ else DRAFTS[kind][id] = ta.value
676
+ })
677
+ return ta
678
+ }
679
+ const saved = (kind, id) => delete DRAFTS[kind][id]
680
+ // saves one item: its draft goes first (so the page drawn after shows it saved), and comes back if saving fails
681
+ async function saveItem(kind, id, path, body, done) {
682
+ const d = DRAFTS[kind][id]
683
+ delete DRAFTS[kind][id]
684
+ const r = await act(path, body, done)
685
+ if (!r && d !== undefined) DRAFTS[kind][id] = d
686
+ if (!r) render(S)
687
+ return r
688
+ }
689
+
690
+ // --- Providers & models ---
691
+ const PRESETS = [
692
+ ['openrouter', 'OpenRouter', 'https://openrouter.ai/api/v1', 'hundreds of models'],
693
+ ['groq', 'Groq', 'https://api.groq.com/openai/v1', 'very fast'],
694
+ ['openai', 'OpenAI', 'https://api.openai.com/v1', 'GPT models'],
695
+ ['gemini', 'Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai', 'Google'],
696
+ ['mistral', 'Mistral', 'https://api.mistral.ai/v1', 'Mistral, Codestral'],
697
+ ['deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', 'DeepSeek models'],
698
+ ['together', 'Together', 'https://api.together.xyz/v1', 'open models'],
699
+ ['ollama', 'Ollama', 'http://localhost:11434/v1', 'on this machine'],
700
+ ['lmstudio', 'LM Studio', 'http://localhost:1234/v1', 'on this machine'],
701
+ ]
702
+ function drawPresets() {
703
+ put($('presets'), PRESETS.map(([name, label, url, what]) => h('button', { type: 'button', className: 'preset' + ($('p-url').value === url ? ' on' : ''), onclick: () => {
704
+ if (!editing && (!$('p-name').value || PRESETS.some(([n]) => n === $('p-name').value))) $('p-name').value = name
705
+ $('p-url').value = url
706
+ drawPresets()
707
+ $('p-key').focus()
708
+ } }, h('b', { textContent: label }), h('span', { textContent: what }))))
709
+ }
710
+ $('p-url').oninput = drawPresets
711
+
712
+ const MODELS = {} // provider → { list } | { error } | { loading }
713
+ const filters = {}
714
+ function loadModels(name, again = false) {
715
+ if (MODELS[name] && !again) return
716
+ MODELS[name] = { loading: true }
717
+ api('/api/models', { name }).then(({ models, details }) => (MODELS[name] = { list: models, details: details ?? {} }), (e) => (MODELS[name] = { error: e.message })).then(() => S && drawProviders(S))
718
+ }
719
+ const useModel = (provider, model) => act('/api/model', { provider, model }, `HeartCode now uses ${provider} · ${model}`)
720
+ const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
721
+
722
+ let editing = null
723
+ let formOpen = false
724
+ function openForm(p = null) {
725
+ editing = p
726
+ formOpen = true
727
+ $('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
728
+ $('p-name').value = p?.name ?? ''
729
+ $('p-url').value = p?.baseUrl ?? ''
730
+ $('p-key').value = ''
731
+ $('p-key').placeholder = p?.key ? 'empty: keep the key it has' : 'paste it here (none for a server on this machine, like Ollama)'
732
+ $('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
733
+ $('p-save').textContent = p ? 'Save changes' : 'Add provider'
734
+ drawPresets()
735
+ drawProviders(S)
736
+ $('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
737
+ ;(p ? $('p-url') : $('p-name')).focus()
738
+ }
739
+ function closeForm() {
740
+ editing = null
741
+ formOpen = false
742
+ drawProviders(S)
743
+ }
744
+ $('p-open').onclick = () => openForm()
745
+ $('p-cancel').onclick = closeForm
746
+ $('p-save').onclick = async () => {
747
+ const name = $('p-name').value.trim()
748
+ const k = $('p-key').value.trim()
749
+ const was = editing
750
+ const r = await act('/api/provider', { name, baseUrl: $('p-url').value, apiKey: k, keepKey: Boolean(was?.key && !k), was: was?.name })
751
+ if (!r) return
752
+ const added = r.providers.find((p) => p.name === name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''))
753
+ if (!added) return
754
+ if (was) delete MODELS[was.name]
755
+ loadModels(added.name, true) // a new one, or a new URL or key: its models (again)
756
+ toast(was ? `saved ${added.name}` : `added ${added.name}: its models are loading`)
757
+ closeForm()
758
+ document.querySelector(`[data-provider="${added.name}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
759
+ }
760
+ for (const id of ['c-name', 'c-what']) $(id).addEventListener('keydown', (e) => e.key === 'Enter' && $('c-add').click())
761
+ for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown', (e) => e.key === 'Enter' && $('p-save').click())
762
+
763
+ // model names in a natural order: "glm-5.2" before "glm-5.10", case doesn't matter
764
+ const byName = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
765
+ let modelQuery = ''
766
+ // Providers folded on the page (the browser remembers them). Typing a search opens them all, until
767
+ // you fold or unfold one (or all) again.
768
+ let searchOpen = false
769
+ const FOLDED = new Set((() => {
770
+ try {
771
+ return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
772
+ } catch {
773
+ return []
774
+ }
775
+ })())
776
+ function applyFolds() {
777
+ try {
778
+ localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
779
+ } catch {}
780
+ for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
781
+ const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
782
+ sec.classList.toggle('collapsed', folded)
783
+ sec.querySelector('.fold')?.setAttribute('aria-expanded', String(!folded))
784
+ }
785
+ }
786
+ // A fold by hand during a search starts from what's shown (the ones it opened stay open).
787
+ function settle() {
788
+ if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
789
+ searchOpen = false
790
+ }
791
+ const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), applyFolds())
792
+ $('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), applyFolds())
793
+ $('unfold-all').onclick = () => (settle(), FOLDED.clear(), applyFolds())
794
+ $('m-search').oninput = (e) => ((modelQuery = e.target.value), (searchOpen = true), S && drawProviders(S), applyFolds())
795
+ // A model's tooltip: its full name and what's known about it (from the provider, HeartCode's price
796
+ // list and its own test), and what a click does. Shown on hover and on keyboard focus.
797
+ const kTok = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
798
+ const usd = (x) => (x === 0 ? 'free' : `$${x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3).replace(/0$/, '') : x.toFixed(2)}`)
799
+ // What a model is, under it in your list, in the two lines its card keeps for it (at most two
800
+ // lines of text, its button on the right): where that comes from, and Edit (or Set it up, for one
801
+ // models.dev doesn't know). Replaced in place when it changes (streamed).
802
+ const FROM = { 'models.dev': 'from models.dev', you: 'set by you', tested: 'tested' }
803
+ function testLine(t) {
804
+ const x = t.tested
805
+ const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
806
+ const [cls, text, action] =
807
+ !x ? ['none', 'looking it up in models.dev…', null]
808
+ : x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Retry all
809
+ : ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
810
+ const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
811
+ return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
812
+ }
813
+
814
+ // Test: one short message to the model, to see that it really answers (the key, access to it, its
815
+ // name, the server). The result stays under it until the page is reloaded.
816
+ const TRIES = {} // "provider/model" → { going } | { ok, ms, reply } | { ok: false, error }
817
+ const keyOf = (t) => `${t.provider}/${t.model}`
818
+ // The Works? cell: the last result (cut short, all of it on hover) and Test.
819
+ function triedCell(t) {
820
+ const r = TRIES[keyOf(t)]
821
+ const secs = r?.ms != null ? ` · ${(r.ms / 1000).toFixed(1)}s` : ''
822
+ const [cls, text, about] = !r ? ['', 'not tested', 'send it a short message to see that it really answers (your key, access to it, its name)']
823
+ : r.going ? ['going', 'testing…', 'sending it a short message']
824
+ : r.ok ? ['ok', `✓ ${(r.ms / 1000).toFixed(1)}s`, `works: it answered "${r.reply}"${secs}`]
825
+ : ['bad', `✗ ${r.status ?? (/nothing/.test(r.error) ? 'empty' : /within/.test(r.error) ? 'no answer' : 'failed')}`, `doesn't work: ${r.error}${secs}`] // the reason on hover
826
+ return h('div', { className: 'try ' + cls },
827
+ h('span', { className: 'msg', textContent: text, title: about }),
828
+ h('button', { className: 'small', textContent: 'Test', disabled: Boolean(r?.going), title: 'send it a short message to see that it really answers', 'aria-label': `test ${t.model}`, onclick: () => tryModel(t) }))
829
+ }
830
+ function showTried(t) {
831
+ document.querySelector(`#mt li[data-key="${CSS.escape(keyOf(t))}"] .try`)?.replaceWith(triedCell(t))
832
+ if (SORT.some((x) => x.key === 'works') && !TRIES[keyOf(t)]?.going) drawMine(S) // sorted by it: its place may change
833
+ }
834
+ async function tryModel(t) {
835
+ TRIES[keyOf(t)] = { going: true }
836
+ showTried(t)
837
+ const r = (await act('/api/try', { provider: t.provider, model: t.model })) ?? { ok: false, error: 'the settings page did not answer' }
838
+ TRIES[keyOf(t)] = r
839
+ showTried(t)
840
+ return r
841
+ }
842
+ let testingAll = false
843
+ async function tryAll() {
844
+ if (testingAll) return
845
+ testingAll = true
846
+ const list = [...(S?.order ?? [])]
847
+ let good = 0
848
+ toast(`testing ${list.length} model${list.length === 1 ? '' : 's'}…`)
849
+ // several at once (6), but at most 2 per provider: more would trip its rate limit and show
850
+ // working models as failing
851
+ for (const t of list) TRIES[keyOf(t)] = { going: true }
852
+ list.forEach(showTried)
853
+ const queue = [...list]
854
+ const busy = {}
855
+ const worker = async () => {
856
+ while (queue.length) {
857
+ const i = queue.findIndex((t) => (busy[t.provider] ?? 0) < 2)
858
+ if (i === -1) {
859
+ await new Promise((r) => setTimeout(r, 100)) // every waiting one's provider is busy
860
+ continue
861
+ }
862
+ const [t] = queue.splice(i, 1)
863
+ busy[t.provider] = (busy[t.provider] ?? 0) + 1
864
+ try {
865
+ if ((await tryModel(t)).ok) good++
866
+ } finally {
867
+ busy[t.provider]--
868
+ }
869
+ }
870
+ }
871
+ try {
872
+ await Promise.all(Array.from({ length: Math.min(6, list.length) }, worker))
873
+ } finally {
874
+ testingAll = false
875
+ }
876
+ toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
877
+ }
878
+
879
+ // models.dev looked up again: one model (its Retry), or all of yours (Retry all)
880
+ const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
881
+ async function lookUp(t) {
882
+ toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
883
+ const r = await act('/api/models-dev', t ? { provider: t.provider, model: t.model } : {})
884
+ if (r) toast(t ? (r.found ? `${t.model}: found in models.dev` : `${t.model}: still not in models.dev`) : `models.dev: ${r.found} of ${r.of} found`)
885
+ }
886
+
887
+ // The model's setup: a window over the page (nothing on it moves). Its context window (empty: as
888
+ // found; Measure asks the server) and what it can do; saved above what models.dev says.
889
+ let setupFor = null
890
+ function openSetup(t) {
891
+ setupFor = t
892
+ const x = t.tested ?? {}
893
+ $('ms-title').textContent = t.model
894
+ $('ms-from').textContent = {
895
+ assumed: "models.dev doesn't know this model. Tell HeartCode what it can do; until then it assumes tool calls, streaming and a ~128k window.",
896
+ 'models.dev': 'From models.dev. Change anything that isn\'t right for how you reach it.',
897
+ you: 'Set by you.',
898
+ tested: 'From an earlier test. Change anything that isn\'t right.',
899
+ }[x.source] ?? ''
900
+ $('ms-context').value = x.set?.context ?? ''
901
+ $('ms-context').placeholder = x.context ? `${x.context.toLocaleString()}${x.sure === false ? ' (assumed)' : ''}` : 'tokens'
902
+ $('ms-tools').checked = x.mode ? x.mode === 'tools' : true
903
+ $('ms-thinking').checked = Boolean(x.thinking)
904
+ $('ms-images').checked = Boolean(x.vision)
905
+ $('ms-stream').checked = x.stream !== false
906
+ $('ms').showModal()
907
+ }
908
+ $('ms-save').onclick = async (e) => {
909
+ e.preventDefault()
910
+ const t = setupFor
911
+ const raw = $('ms-context').value.trim()
912
+ const r = await act('/api/model-setup', { provider: t.provider, model: t.model, context: raw ? Number(raw) : null, tools: $('ms-tools').checked, thinking: $('ms-thinking').checked, images: $('ms-images').checked, stream: $('ms-stream').checked }, `${t.model}: saved`)
913
+ if (r) $('ms').close()
914
+ }
915
+ $('ms-cancel').onclick = (e) => (e.preventDefault(), $('ms').close())
916
+ $('ms').querySelector('form').onsubmit = (e) => (e.preventDefault(), $('ms-save').click()) // Enter: save
917
+ // Measure: one request far too big for any model; the server's refusal states its window
918
+ $('ms-measure').onclick = async (e) => {
919
+ e.preventDefault()
920
+ const t = setupFor
921
+ if (!confirm(`Measure ${t.model}'s context window?\n\nHeartCode sends it one request of about 2.2 million tokens (4 MB). Servers refuse it with their limit in the error, and a refused request isn't charged. If a server does take that much, it's stopped at once, but that request may be charged.`)) return
922
+ const b = $('ms-measure')
923
+ Object.assign(b, { disabled: true, textContent: 'measuring…' })
924
+ try {
925
+ const r = await api('/api/measure', { provider: t.provider, model: t.model })
926
+ render(r)
927
+ $('ms-context').value = r.measured.limit ?? r.measured.over
928
+ toast(r.measured.limit ? `${t.model}: ${r.measured.limit.toLocaleString()} tokens, said by the server` : `${t.model} took over ${r.measured.over.toLocaleString()} tokens`)
929
+ } catch (x) {
930
+ toast(x.message, true)
931
+ } finally {
932
+ Object.assign(b, { disabled: false, textContent: 'Measure' })
933
+ }
934
+ }
935
+ // short enough for the two lines a card keeps for it (guided: no tool calls, HeartCode guides it)
936
+ const testedText = (t) => `${t.mode === 'tools' ? 'tool calls' : 'guided'}${t.context ? ` · ${t.sure === false ? '~' : ''}${kTok(t.context)} context` : ''}${t.thinking ? ' · thinks' : ''}${t.vision ? ' · images' : ''}`
937
+ // an error without the request's address in it ("500 from http://…/chat/completions: …" → "500: …")
938
+ const shortError = (e) => {
939
+ const x = String(e ?? '').replace(/ from https?:\/\/\S+?:(?=\s|$)/, ':').replace(/\{\s*"error"\s*:\s*\{[^{}]*?"message"\s*:\s*"((?:[^"\\]|\\.)*)"[\s\S]*$/, '$1').trim() // a JSON error: its message
940
+ return x.length > 120 ? x.slice(0, 119) + '…' : x
941
+ }
942
+ function withTip(el, content) {
943
+ const show = () => {
944
+ if (document.body.classList.contains('sorting')) return // not while a card is being moved
945
+ const tip = $('tip')
946
+ put(tip, content())
947
+ tip.classList.add('show')
948
+ const r = el.getBoundingClientRect()
949
+ const t = tip.getBoundingClientRect()
950
+ const below = r.bottom + 8 + t.height < innerHeight
951
+ tip.style.top = `${below ? r.bottom + 6 : Math.max(8, r.top - t.height - 6)}px`
952
+ tip.style.left = `${Math.min(Math.max(8, r.left), innerWidth - t.width - 8)}px`
953
+ }
954
+ const hide = () => $('tip').classList.remove('show')
955
+ el.addEventListener('mouseenter', show)
956
+ el.addEventListener('focus', show)
957
+ el.addEventListener('mouseleave', hide)
958
+ el.addEventListener('blur', hide)
959
+ el.addEventListener('click', hide)
960
+ return el
961
+ }
962
+ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
963
+
964
+ function drawProviders(s) {
965
+ const order = s.order
966
+ const none = !s.providers.length
967
+ // the steps: always on the page (nothing jumps when one is done), ticked off as you go; with
968
+ // no provider yet, the form is open (the only way in: no other button)
969
+ put($('steps'), [
970
+ ['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
971
+ ['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
972
+ ['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
973
+ ].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))))
974
+ const showForm = formOpen || none
975
+ $('p-form').hidden = !showForm
976
+ $('p-cancel').hidden = none // nothing to go back to
977
+ $('p-open').hidden = showForm
978
+ $('p-work').hidden = none
979
+ fillHeight()
980
+
981
+ // right: your models, in order (#1 is the one in use); drag or ↑ ↓ to reorder
982
+ const side = innerWidth > 860 ? 'on the left' : 'below' // where the models are (a phone: under this)
983
+ const save = (list, done) => saveOrder(list, done)
984
+ const move = (i, j) => {
985
+ if (i === j) return
986
+ const list = [...order]
987
+ const [t] = list.splice(i, 1)
988
+ list.splice(j, 0, t)
989
+ save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
990
+ }
991
+ put($('inuse'),
992
+ h('div', { className: 'mine-top' },
993
+ h('div', { className: 'mine-title' },
994
+ h('h2', { textContent: 'Your models' }),
995
+ h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
996
+ h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
997
+ h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
998
+ h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
999
+ order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
1000
+ order.length ? h('ol', { className: 'olist' }, order.map((t, i) => h('li', {
1001
+ className: 'in', 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
1002
+ onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
1003
+ },
1004
+ h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
1005
+ h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
1006
+ h('span', { className: 'm', textContent: t.model, title: t.model }),
1007
+ h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
1008
+ testLine(t),
1009
+ h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) })),
1010
+ ), h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` }))
1011
+ : h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
1012
+ order.length > 1 ? h('div', { className: 'mine-rules' },
1013
+ h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
1014
+ h('div', { className: 'row', style: 'gap:8px' },
1015
+ h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
1016
+ [['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 }))),
1017
+ 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),
1018
+ 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] }),
1019
+ h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
1020
+ h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
1021
+ [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
1022
+
1023
+ sortable($('inuse').querySelector('.olist'), move)
1024
+
1025
+ // left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
1026
+ // again only when what it lists changes (a provider, its models, the search); a change to your
1027
+ // list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
1028
+ 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()])
1029
+ if (shape !== catalogShape) (buildCatalog(s), applyFolds())
1030
+ catalogShape = shape
1031
+ markCatalog()
1032
+ $('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models, or type a model name…`
1033
+ $('n-providers').textContent = s.providers.length || ''
1034
+ }
1035
+ // Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
1036
+ // others move aside as it goes; let go and it's saved, already in its place (no jump when the
1037
+ // page redraws from what's saved).
1038
+ function sortable(ol, onMove) {
1039
+ if (!ol) return
1040
+ ol.addEventListener('pointerdown', (e) => {
1041
+ const li = e.target.closest('li.in')
1042
+ if (!li || e.button !== 0 || e.target.closest('button, a, input, select, textarea')) return
1043
+ if (e.pointerType !== 'mouse' && !e.target.closest('.grip')) return // a finger scrolls the list, except on the grip
1044
+ const items = [...ol.querySelectorAll('li.in')]
1045
+ if (items.length < 2) return
1046
+ const from = items.indexOf(li)
1047
+ const rects = items.map((x) => x.getBoundingClientRect())
1048
+ const step = rects[1].top - rects[0].top // a card and the space after it
1049
+ const centers = rects.map((r) => r.top + r.height / 2)
1050
+ const startY = e.clientY
1051
+ let to = from
1052
+ let moving = false
1053
+ const shift = () => items.forEach((x, k) => {
1054
+ if (x === li) return
1055
+ const by = from < to && k > from && k <= to ? -step : from > to && k >= to && k < from ? step : 0
1056
+ x.style.transform = by ? `translateY(${by}px)` : ''
1057
+ })
1058
+ const onMovePointer = (ev) => {
1059
+ const dy = ev.clientY - startY
1060
+ if (!moving) {
1061
+ if (Math.abs(dy) < 4) return
1062
+ moving = true
1063
+ li.setPointerCapture(e.pointerId)
1064
+ li.classList.add('lifted')
1065
+ ol.classList.add('sorting')
1066
+ document.body.classList.add('sorting')
1067
+ $('tip').classList.remove('show')
1068
+ getSelection()?.removeAllRanges()
1069
+ }
1070
+ const y = Math.max(rects[0].top - rects[from].top, Math.min(rects.at(-1).bottom - rects[from].bottom, dy)) // within the list
1071
+ li.style.transform = `translateY(${y}px)`
1072
+ const mid = centers[from] + y
1073
+ to = items.filter((x, k) => k !== from && centers[k] < mid).length
1074
+ shift()
1075
+ }
1076
+ const done = () => {
1077
+ removeEventListener('pointermove', onMovePointer)
1078
+ removeEventListener('pointerup', done)
1079
+ removeEventListener('pointercancel', done)
1080
+ if (!moving) return
1081
+ document.body.classList.remove('sorting')
1082
+ ol.classList.remove('sorting')
1083
+ li.classList.remove('lifted')
1084
+ for (const x of items) {
1085
+ x.style.transition = 'none' // into place at once: the cards are where they look
1086
+ x.style.transform = ''
1087
+ }
1088
+ if (to !== from) items[to].insertAdjacentElement(to > from ? 'afterend' : 'beforebegin', li)
1089
+ ol.querySelectorAll('li.in').forEach((x, k) => (x.querySelector('.n').textContent = `#${k + 1}`))
1090
+ requestAnimationFrame(() => items.forEach((x) => (x.style.transition = '')))
1091
+ onMove(from, to)
1092
+ }
1093
+ addEventListener('pointermove', onMovePointer)
1094
+ addEventListener('pointerup', done)
1095
+ addEventListener('pointercancel', done)
1096
+ })
1097
+ }
1098
+
1099
+ // --- the models on the left: rows that read your list when clicked or hovered (S.order, now) ---
1100
+ let catalogShape = null
1101
+ const placeOf = (provider, id) => {
1102
+ const i = S.order.findIndex((t) => t.provider === provider && t.model === id)
1103
+ return i < 0 ? undefined : i
1104
+ }
1105
+ // A model's row on the left: scrolled to and lit up for a moment (one just added by name).
1106
+ function showRow(provider, id, said) {
1107
+ toast(said)
1108
+ const row = $('p-list').querySelector(`.mrow[data-provider="${CSS.escape(provider)}"][data-model="${CSS.escape(id)}"]`)
1109
+ if (!row) return
1110
+ row.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
1111
+ row.classList.remove('flash')
1112
+ void row.offsetWidth // restart the animation
1113
+ row.classList.add('flash')
1114
+ }
1115
+ // Off your list, the one in use too: the next one becomes #1, or none is left (asked first, then).
1116
+ const removeMine = (provider, id) => {
1117
+ const rest = S.order.filter((t) => t.provider !== provider || t.model !== id)
1118
+ if (!rest.length && !confirm(`Take off ${id}? It's your only model: HeartCode then has none to use until you choose one.`)) return
1119
+ return saveOrder(rest, !rest.length ? `${id} removed: no model chosen now` : placeOf(provider, id) === 0 ? `${id} removed: HeartCode now uses ${rest[0].provider} · ${rest[0].model}` : `${id} removed`)
1120
+ }
1121
+ const addModel = (provider, id) => {
1122
+ const order = S.order
1123
+ return order.length ? saveOrder([...order, { provider, model: id }], `${id} added as #${order.length + 1}`) : useModel(provider, id)
1124
+ }
1125
+ function rowTail(provider, id) {
1126
+ const at = placeOf(provider, id)
1127
+ return at === 0 ? h('span', { className: 'tag brand', textContent: '#1 in use' }) : at > 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
1128
+ }
1129
+ function buildCatalog(s) {
1130
+ const q = modelQuery.trim()
1131
+ // a name no provider lists (anywhere): offered under each provider, to use it anyway
1132
+ const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
1133
+ put($('p-list'), s.providers.map((p) => {
1134
+ if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
1135
+ else loadModels(p.name, MODELS[p.name]?.off)
1136
+ const m = MODELS[p.name] ?? { loading: true }
1137
+ // and the models added by name (and any of yours it doesn't list), marked as yours
1138
+ const listed = new Set(m.list ?? [])
1139
+ const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
1140
+ const ids = [...listed, ...yours].sort(byName).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
1141
+ const row = (id, own) =>
1142
+ h('button', { className: 'mrow', 'data-provider': p.name, 'data-model': id, onclick: () => (placeOf(p.name, id) === undefined ? addModel(p.name, id) : removeMine(p.name, id)) },
1143
+ h('span', { className: 'id', textContent: id }),
1144
+ own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
1145
+ rowTail(p.name, id))
1146
+ // one you added: ✕ forgets it (it leaves your list too)
1147
+ const item = (id) => !p.added.includes(id) ? row(id) : h('div', { className: 'mwrap' }, row(id),
1148
+ h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
1149
+ if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
1150
+ if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
1151
+ act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
1152
+ } }))
1153
+ const typed = !anyMatch ? [row(q, true)] : []
1154
+ if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
1155
+ // a model its list leaves out (some servers don't list them all): by its exact name
1156
+ const name = h('input', { type: 'text', placeholder: m.off ? 'List off: a model\'s exact name' : 'Custom model: its exact name', 'aria-label': `add a model to ${p.name} by name` })
1157
+ const go = () => {
1158
+ const id = name.value.trim()
1159
+ if (!id) return name.focus()
1160
+ name.value = ''
1161
+ // only under this provider, with its others: you choose whether to use it (click it, like any)
1162
+ if ([...(MODELS[p.name]?.list ?? []), ...p.added].includes(id)) return showRow(p.name, id, `${id} is in ${p.name}'s models already`)
1163
+ act('/api/provider/added', { name: p.name, model: id }).then((r) => r && showRow(p.name, id, `${id} added to ${p.name}'s models: click it to use it`))
1164
+ }
1165
+ name.addEventListener('keydown', (e) => e.key === 'Enter' && go())
1166
+ return h('section', { 'data-provider': p.name },
1167
+ // the header folds it (a click on it, or its arrow; not on its buttons and switch)
1168
+ h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
1169
+ h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
1170
+ h('div', { className: 'who' },
1171
+ h('span', { className: 'name', textContent: p.name }), ' ',
1172
+ m.off ? h('span', { className: 'tag', textContent: 'list off' }) : m.loading ? h('span', { className: 'tag', textContent: 'loading…' }) : m.error ? h('span', { className: 'tag err', textContent: "can't list its models", title: m.error }) : h('span', { className: 'tag ok', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
1173
+ ' ', h('span', { className: 'tag inlist' }),
1174
+ h('div', { className: 'meta' }, h('span', { className: 'mono', textContent: p.baseUrl }), ' · key ', h('span', { className: 'mono', textContent: p.key || 'none' })),
1175
+ withTip(h('label', { className: 'listsw' }, sw({ checked: p.list, 'aria-label': `load ${p.name}'s model list`, onchange: (e) => act('/api/provider/list', { name: p.name, on: e.target.checked }, `${p.name}: model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load its model list'),
1176
+ () => 'On: its models are loaded from the provider. Off: never asked for; only the models you add by name show.')),
1177
+ h('button', { className: 'small', textContent: 'Edit', onclick: () => openForm(p) }),
1178
+ h('button', { className: 'small danger', textContent: 'Delete', onclick: () => confirm(`Delete the provider "${p.name}"? Its models leave your list too.`) && act('/api/provider/delete', { name: p.name }, `deleted ${p.name}`) })),
1179
+ h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go })),
1180
+ // what's listed first; loading and errors under it (the models already shown stay where they are)
1181
+ typed.length || ids.length ? h('div', { className: 'rows' }, typed, ids.map(item)) : null,
1182
+ m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: 'Or add a model by its exact name, above.' })) : null,
1183
+ m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
1184
+ }))
1185
+ }
1186
+ // --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
1187
+ // for the ones before it), the sort kept in this browser ---
1188
+ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.tools !== false, thinking: Boolean(t.tested ? t.tested.thinking : t.known?.thinking), images: Boolean(t.tested ? t.tested.vision : t.known?.images) })
1189
+ // how: text (A to Z first), cheap (lowest first), else most first
1190
+ const COLS = {
1191
+ model: { label: 'Model', how: 'text', get: (t) => t.model },
1192
+ provider: { label: 'Provider', how: 'text', get: (t) => t.provider },
1193
+ context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
1194
+ abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
1195
+ tools: { label: 'Tools', get: (t) => can(t).tools },
1196
+ thinking: { label: 'Thinking', get: (t) => can(t).thinking },
1197
+ images: { label: 'Images', get: (t) => can(t).images },
1198
+ input: { label: 'In $/M', how: 'cheap', get: (t) => t.price?.input ?? null },
1199
+ output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
1200
+ 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
1201
+ }
1202
+ let SORT = (() => {
1203
+ try {
1204
+ return JSON.parse(localStorage.getItem('heart.sort') ?? '[]').filter((x) => COLS[x?.key])
1205
+ } catch {
1206
+ return []
1207
+ }
1208
+ })()
1209
+ const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
1210
+ function setSort(next) {
1211
+ SORT = next
1212
+ try {
1213
+ localStorage.setItem('heart.sort', JSON.stringify(SORT))
1214
+ } catch {}
1215
+ drawMine(S)
1216
+ }
1217
+ // a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
1218
+ function clickSort(key) {
1219
+ const at = SORT.find((x) => x.key === key)
1220
+ if (!at) return setSort([...SORT, { key, dir: firstDir(key) }])
1221
+ if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
1222
+ setSort(SORT.filter((x) => x !== at))
1223
+ }
1224
+ function sorted(order) {
1225
+ const cmp = (a, b, { key, dir }) => {
1226
+ let x = COLS[key].get(a)
1227
+ let y = COLS[key].get(b)
1228
+ if (x === y) return 0
1229
+ if (x == null) return 1 // unknown (no price): last, either way
1230
+ if (y == null) return -1
1231
+ const r = typeof x === 'string' ? byName(x, y) : Number(x) - Number(y)
1232
+ return dir === 'up' ? r : -r
1233
+ }
1234
+ 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)
1235
+ }
1236
+ 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')
1237
+ function drawMine(s) {
1238
+ const order = s.order
1239
+ $('n-mine').textContent = order.length || ''
1240
+ const view = sorted(order)
1241
+ const same = view.every((t, i) => t === order[i])
1242
+ // the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
1243
+ const keep = (list, moved) => {
1244
+ SORT = []
1245
+ try {
1246
+ localStorage.setItem('heart.sort', '[]')
1247
+ } catch {}
1248
+ saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
1249
+ }
1250
+ // a row dragged (in the order shown, sorted or not)
1251
+ const move = (i, j) => {
1252
+ if (i === j) return
1253
+ const list = [...view]
1254
+ const [t] = list.splice(i, 1)
1255
+ list.splice(j, 0, t)
1256
+ keep(list, true)
1257
+ }
1258
+ const yes = (v) => h('span', { className: v ? 'yes' : 'no', textContent: v ? '✓' : '–' })
1259
+ const src = (t) => {
1260
+ const x = t.tested
1261
+ if (!x) return h('div', { className: 'src', textContent: 'looking it up…' })
1262
+ if (x.source === 'assumed') return h('div', { className: 'src warn', title: "models.dev doesn't know it: Retry, or set up what it can do" }, 'not found', retry(t), h('button', { className: 'small', textContent: 'Set up', onclick: () => openSetup(t) }))
1263
+ return h('div', { className: 'src' }, Object.keys(x.set ?? {}).length ? 'set by you' : x.source === 'models.dev' ? 'models.dev' : FROM[x.source], h('button', { className: 'small', textContent: 'Edit', onclick: () => openSetup(t) }))
1264
+ }
1265
+ const head = (key, num) => {
1266
+ const at = SORT.findIndex((x) => x.key === key)
1267
+ return h('button', { type: 'button', className: (at >= 0 ? 'on ' : '') + (num ? 'num' : ''), title: `sort by ${COLS[key].label.toLowerCase()}`, onclick: () => clickSort(key) },
1268
+ COLS[key].label, at >= 0 ? ` ${SORT[at].dir === 'up' ? '↑' : '↓'}${SORT.length > 1 ? at + 1 : ''}` : '')
1269
+ }
1270
+ put($('mt'),
1271
+ h('div', { className: 'mt-top' },
1272
+ h('div', { className: 'sorts' }, SORT.length
1273
+ ? ['Sorted by ', SORT.map(({ key, dir }, k) => h('span', { className: 'chip' }, `${k + 1}. ${COLS[key].label}: ${dirText(key, dir)}`, h('button', { type: 'button', textContent: '✕', 'aria-label': `stop sorting by ${COLS[key].label}`, onclick: () => setSort(SORT.filter((x) => x.key !== key)) })))]
1274
+ : order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
1275
+ SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1276
+ SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1277
+ order.length ? h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', onclick: () => lookUp() }) : null,
1278
+ order.length ? h('button', { className: 'small', textContent: 'Test all', title: 'send each a short message, several at once (2 per provider at most), to see which really answer', onclick: () => tryAll() }) : null),
1279
+ order.length
1280
+ ? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
1281
+ h('div', { className: 'mt-h' }, h('span'), h('span', { textContent: '#' }), head('model'), head('provider'), head('context', true), head('abilities', true), head('tools'), head('thinking'), head('images'), head('input', true), head('output', true), h('span', { textContent: 'From' }), head('works'), h('span')),
1282
+ h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }, view.map((t) => {
1283
+ const c = can(t)
1284
+ const ctx = COLS.context.get(t)
1285
+ return h('li', { className: 'in', 'data-key': `${t.provider}/${t.model}` },
1286
+ h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
1287
+ h('span', { className: 'n', textContent: `#${order.indexOf(t) + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
1288
+ h('span', { className: 'm', textContent: t.model }),
1289
+ h('span', { className: 'p', textContent: t.provider }),
1290
+ h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
1291
+ h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
1292
+ yes(c.tools), yes(c.thinking), yes(c.images),
1293
+ h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
1294
+ h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
1295
+ src(t),
1296
+ triedCell(t),
1297
+ h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
1298
+ }))))
1299
+ : h('div', { className: 'mt-empty' }, 'No models in your list yet. ', h('a', { href: '#providers', textContent: 'Choose some under Providers & models' }), '.'))
1300
+ sortable($('mt').querySelector('.mtable'), move)
1301
+ }
1302
+
1303
+ // your list changed: the rows and counts it touches, in place
1304
+ function markCatalog() {
1305
+ for (const r of $('p-list').querySelectorAll('.mrow')) {
1306
+ const at = placeOf(r.dataset.provider, r.dataset.model)
1307
+ r.classList.toggle('taken', at !== undefined)
1308
+ r.lastElementChild.replaceWith(rowTail(r.dataset.provider, r.dataset.model))
1309
+ }
1310
+ for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
1311
+ const n = S.order.filter((t) => t.provider === sec.dataset.provider).length
1312
+ const tag = sec.querySelector('.inlist')
1313
+ tag.hidden = !n
1314
+ tag.textContent = `${n} in your list${S.order[0]?.provider === sec.dataset.provider ? ' · #1' : ''}`
1315
+ }
1316
+ }
1317
+
1318
+ function drawUsing(s) {
1319
+ const o = s.order
1320
+ $('using').hidden = page() === 'providers' // there it's on the page itself
1321
+ put($('using'), o.length ? [h('span', { className: 'k', textContent: 'HeartCode uses' }), h('b', { textContent: o[0].model }), h('span', { className: 'k', textContent: `${o[0].provider}${o.length > 1 ? ` · ${o.length - 1} fallback${o.length > 2 ? 's' : ''}` : ''}` })] : [h('span', { className: 'k', textContent: 'No model chosen yet' }), h('a', { href: '#providers', textContent: 'Choose one' })])
1322
+ }
1323
+
1324
+ // --- General ---
1325
+ const PERM_INFO = { all: ['Everything', 'Edits files and runs commands without asking'], commands: ['Edits only', 'Edits files; asks before running commands'], ask: ['Ask first', 'Asks before every edit and command'] }
1326
+ const THEME_INFO = { dark: ["Your terminal's background", ['#1f2328', '#d4d4d8', '#ff6b9d', '#7aa2f7']], light: ['White background', ['#fafbfc', '#24292f', '#d6336c', '#0969da']], midnight: ['Deep blue background', ['#1a1b26', '#c0caf5', '#ff7ab2', '#7aa2f7']], mono: ['No colors: bold and dim', ['#000000', '#bbbbbb', '#ffffff', '#777777']] }
1327
+ const TOGGLE_INFO = { autoCompact: 'Compact long conversations', director: 'Let the model set things up', format: 'Format files after edits', lsp: 'Language server checks' }
1328
+ function choices(id, list, cur, name) {
1329
+ put($(id), list.map(([v, title, desc, colors]) => h('label', { className: 'choice' + (v === cur ? ' on' : '') },
1330
+ h('input', { type: 'radio', name: id, checked: v === cur, onchange: () => act('/api/setting', { name, value: v }, `${title}: saved`) }),
1331
+ colors ? h('div', { className: 'swatch' }, colors.map((c) => h('i', { style: `background:${c}` }))) : null,
1332
+ h('b', { textContent: title }), h('span', { textContent: desc }))))
1333
+ }
1334
+ function drawGeneral(s) {
1335
+ choices('g-perm', ['all', 'commands', 'ask'].map((p) => [p, ...PERM_INFO[p]]), s.settings.permissions, 'permissions')
1336
+ choices('g-theme', s.choices.theme.map((t) => [t, t[0].toUpperCase() + t.slice(1), ...(THEME_INFO[t] ?? ['', null])]), s.settings.theme, 'theme')
1337
+ put($('g-effort'), s.choices.effort.map((e) => h('option', { value: e, textContent: { default: "The model's own setting", low: 'Low: quick, less thinking', medium: 'Medium', high: 'High: thinks the longest' }[e] ?? e })))
1338
+ $('g-effort').value = s.settings.effort
1339
+ if (document.activeElement !== $('g-context')) $('g-context').value = s.settings.context ?? ''
1340
+ put($('g-toggles'), Object.entries(s.choices.toggles).map(([k, d]) => h('label', { className: 'opt' },
1341
+ h('div', { className: 't' }, h('b', { textContent: TOGGLE_INFO[k] ?? k }), h('span', { textContent: d[0].toUpperCase() + d.slice(1) })),
1342
+ sw({ checked: s.settings[k], onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${TOGGLE_INFO[k] ?? k}: ${e.target.checked ? 'on' : 'off'}`) }))))
1343
+ if (document.activeElement !== $('v-text')) $('v-text').value = s.voice
1344
+ }
1345
+ $('g-effort').onchange = (e) => act('/api/setting', { name: 'effort', value: e.target.value }, 'effort saved')
1346
+ $('g-context').onchange = (e) => act('/api/setting', { name: 'context', value: e.target.value.trim() ? Number(e.target.value) : null }, 'context window saved')
1347
+ $('v-save').onclick = () => act('/api/voice', { text: $('v-text').value }, 'voice saved')
1348
+
1349
+ // --- Logging: the two switches, how much of each request; the requests logged (live) ---
1350
+ const logSwitch = (s, k, title, about) => h('label', { className: 'opt' },
1351
+ h('div', { className: 't' }, h('b', { textContent: title }), h('span', { textContent: about })),
1352
+ sw({ checked: s.settings[k], 'aria-label': title, onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${title}: ${e.target.checked ? 'on' : 'off'}`) }))
1353
+ function drawLogging(s) {
1354
+ put($('l-run'), logSwitch(s, 'log', 'Keep a log of every run', `From now on: a folder per run in ${s.logs.runs}, a file per tab (tab-1.log, tab-2.log…).`))
1355
+ put($('l-req'), logSwitch(s, 'logRequests', 'Log every request and reply', `Right away: a file per request in ${s.logs.requests}, a folder per run.`))
1356
+ $('l-detail').value = s.settings.logDetail
1357
+ if (document.activeElement !== $('l-days')) $('l-days').value = s.settings.logDays
1358
+ $('l-detail').disabled = !s.settings.logRequests
1359
+ }
1360
+ $('l-days').onchange = (e) => {
1361
+ const days = Number(e.target.value)
1362
+ if (!Number.isInteger(days) || days < 1) return toast('a whole number of days, 1 or more', true), (e.target.value = S.settings.logDays)
1363
+ act('/api/setting', { name: 'logDays', value: days }, `logs kept for ${days} day${days === 1 ? '' : 's'}; older ones deleted`)
1364
+ }
1365
+ $('l-detail').onchange = (e) => act('/api/setting', { name: 'logDetail', value: e.target.value }, 'saved: from the next request')
1366
+
1367
+ let REQS = null // the requests logged, newest first
1368
+ let reqOpen = null // the one shown in full: "run/file"
1369
+ const reqFilter = () => document.querySelector('input[name="l-filter"]:checked').value
1370
+ const reqShown = (r) => ({ all: true, failed: r.result === 'failed', fallback: r.place > 1 })[reqFilter()]
1371
+ for (const r of document.querySelectorAll('input[name="l-filter"]')) r.onchange = () => {
1372
+ for (const c of document.querySelectorAll('#l-filter label')) c.classList.toggle('on', c.querySelector('input').checked)
1373
+ drawReqs()
1374
+ }
1375
+ const when = (iso) => {
1376
+ const d = new Date(iso)
1377
+ const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
1378
+ return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}`
1379
+ }
1380
+ const secs = (ms) => (ms == null ? '' : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
1381
+ const tokensOf = (u) => (u ? `${u.prompt_tokens ?? '?'} in · ${u.completion_tokens ?? '?'} out` : '')
1382
+ // what went wrong, in the server's words when it said some (an error's JSON body), else the message
1383
+ const whyOf = (r) => {
1384
+ try {
1385
+ const e = JSON.parse(r.errorBody).error
1386
+ if (e) return `${r.status ? `${r.status}: ` : ''}${typeof e === 'string' ? e : e.message ?? JSON.stringify(e)}`
1387
+ } catch {}
1388
+ return r.error
1389
+ }
1390
+ const resultTag = (r) => h('span', { className: `tag ${r.result === 'ok' ? 'ok' : r.result === 'failed' ? 'err' : ''}`, textContent: r.result === 'ok' ? 'ok' : r.result === 'failed' ? `failed${r.status ? ` ${r.status}` : ''}` : 'stopped' })
1391
+ const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : r.of > 1 ? h('span', { className: 'tag', textContent: `#1 of ${r.of}` }) : null)
1392
+ function reqRow(r) {
1393
+ const key = `${r.run}/${r.file}`
1394
+ return h('button', { className: `rq ${r.result}${reqOpen === key ? ' on' : ''}`, 'data-key': key, onclick: () => openReq(r) },
1395
+ h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model }), placeTag(r)),
1396
+ h('div', { className: 'l2' },
1397
+ h('span', { textContent: when(r.at) }), h('span', { textContent: r.provider ?? '' }), h('span', { textContent: secs(r.ms) }),
1398
+ r.usage ? h('span', { textContent: tokensOf(r.usage) }) : null,
1399
+ r.purpose && r.purpose !== 'chat' ? h('span', { textContent: r.purpose === 'test' ? 'model test' : r.purpose }) : null,
1400
+ r.tab ? h('span', { textContent: `tab ${r.tab}` }) : null),
1401
+ r.result === 'failed' && r.error ? h('div', { className: 'why', textContent: whyOf(r), title: r.error }) : null)
1402
+ }
1403
+ function drawReqs() {
1404
+ if (!REQS) return put($('l-list'), h('div', { className: 'empty', textContent: 'Loading…' }))
1405
+ const shown = REQS.filter(reqShown)
1406
+ const failed = REQS.filter((r) => r.result === 'failed').length
1407
+ $('n-failed').textContent = failed ? `${failed} failed` : ''
1408
+ put($('l-list'), shown.length ? shown.map(reqRow) : h('div', { className: 'empty', textContent: REQS.length ? 'None of these.' : S?.settings.logRequests ? 'Nothing yet: requests show here as HeartCode sends them.' : 'Nothing logged. Switch on "Log every request and reply" above.' }))
1409
+ }
1410
+ async function openReq(r) {
1411
+ reqOpen = `${r.run}/${r.file}`
1412
+ for (const b of $('l-list').querySelectorAll('.rq')) b.classList.toggle('on', b.dataset.key === reqOpen)
1413
+ let d
1414
+ try {
1415
+ d = await api('/api/request', { run: r.run, file: r.file })
1416
+ } catch (e) {
1417
+ return put($('l-detail-pane'), h('div', { className: 'empty', textContent: `Can't read it: ${e.message}` }))
1418
+ }
1419
+ const row = (k, v) => (v === undefined || v === null || v === '' ? null : [h('dt', { textContent: k }), h('dd', { textContent: String(v) })])
1420
+ const pre = (text) => h('pre', { textContent: typeof text === 'string' ? text : JSON.stringify(text, null, 2) })
1421
+ const msgs = d.request?.messages ?? []
1422
+ put($('l-detail-pane'),
1423
+ h('div', { className: 'l1', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' }, resultTag(d), h('b', { className: 'mono', textContent: d.model }), placeTag(d)),
1424
+ h('dl', { className: 'kv' },
1425
+ row('When', new Date(d.at).toLocaleString()), row('Provider', d.provider), row('Address', d.url),
1426
+ row('Where in your list', d.of > 1 ? `#${d.place} of ${d.of}${d.place > 1 ? ' (a fallback: the ones before it failed)' : ''}` : null),
1427
+ row('For', d.purpose === 'test' ? 'testing the model' : d.purpose === 'btw' ? 'a /btw question' : 'the conversation'), row('Tab', d.tab),
1428
+ row('Took', secs(d.ms)), row('First token after', secs(d.firstTokenMs)), row('Tokens', tokensOf(d.usage)), row('Finished because', d.finishReason),
1429
+ row('HTTP status', d.status), row('File', `${S?.logs.requests ?? ''}/${r.run}/${r.file}`)),
1430
+ d.error ? [h('h3', { textContent: 'Error' }), pre(whyOf(d))] : null,
1431
+ d.errorBody && d.errorBody !== d.error ? h('details', {}, h('summary', { textContent: 'The whole error' }), pre(`${d.error}\n\n${d.errorBody}`)) : null,
1432
+ d.request === undefined && d.requestChars !== undefined ? h('p', { className: 'hint', textContent: `Saved as a summary: ${d.messages} messages, ${d.requestChars} characters sent; ${d.replyChars} characters and ${d.toolCalls} tool calls back. Choose "Everything" above to keep what was sent and what came back.` }) : null,
1433
+ d.reply ? [h('h3', { textContent: 'Reply' }),
1434
+ d.reply.toolCalls?.length ? h('div', {}, h('div', { className: 'muted', textContent: `${d.reply.toolCalls.length} tool call${d.reply.toolCalls.length > 1 ? 's' : ''}` }), pre(d.reply.toolCalls.map((t) => `${t.function?.name}(${t.function?.arguments ?? ''})`).join('\n'))) : null,
1435
+ d.reply.content ? pre(d.reply.content) : null,
1436
+ d.reply.reasoning ? h('details', {}, h('summary', { textContent: 'Thinking' }), pre(d.reply.reasoning)) : null] : null,
1437
+ d.request ? [h('h3', { textContent: `Sent: ${msgs.length} message${msgs.length === 1 ? '' : 's'}${d.request.tools?.length ? `, ${d.request.tools.length} tools` : ''}` }),
1438
+ msgs.map((m, i) => h('details', { open: i === msgs.length - 1 }, h('summary', { textContent: `${m.role}${m.tool_calls ? ' (tool calls)' : ''}${m.name ? ` · ${m.name}` : ''}` }), pre(m.content ?? m.tool_calls ?? ''))),
1439
+ h('details', {}, h('summary', { textContent: 'The whole request (JSON)' }), pre(d.request))] : null)
1440
+ }
1441
+
1442
+ // --- prompts: a list, and the chosen one to edit ---
1443
+ let promptQuery = ''
1444
+ $('r-search').oninput = (e) => ((promptQuery = e.target.value.toLowerCase()), S && drawPrompts(S))
1445
+ function drawPrompts(s) {
1446
+ const match = (p) => !promptQuery || `${p.id} ${p.title ?? ''} ${p.note}`.toLowerCase().includes(promptQuery)
1447
+ const item = (p) => ({ id: p.id, title: p.title ? p.title.replace(/^System prompt /, 'System ') : p.id.split('/')[1], sub: p.title ? p.id : p.note.split('\n')[0], badge: p.changed ? h('span', { className: 'tag brand', textContent: 'yours' }) : null, p })
1448
+ const groups = [{ title: 'System prompts', items: s.prompts.filter((p) => p.title && match(p)).map(item) }]
1449
+ for (const file of ['agent', 'guided', 'compact', 'probe']) groups.push({ title: { agent: 'Tools, notes and replies', guided: 'Guided mode (models without tool calls)', compact: 'Summaries', probe: 'Model tests' }[file], items: s.prompts.filter((p) => !p.title && p.id.startsWith(file + '/') && match(p)).map(item) })
1450
+ masterDetail('prompts', $('r-items'), $('r-detail'), groups, (x) => {
1451
+ if (!x) return h('div', { className: 'card empty', textContent: 'No prompt matches.' })
1452
+ const p = x.p
1453
+ const ta = editor('prompts', p.id, p.text)
1454
+ return h('div', { className: 'card' },
1455
+ h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', textContent: p.title ?? p.id }), p.changed ? h('span', { className: 'tag brand', textContent: 'yours' }) : h('span', { className: 'tag', textContent: "HeartCode's" })),
1456
+ h('div', { className: 'mono muted', style: 'margin:2px 0 10px', textContent: p.id }),
1457
+ p.note ? h('p', { className: 'sub', textContent: p.note, style: 'white-space:pre-wrap' }) : null,
1458
+ ta,
1459
+ h('div', { className: 'actions' },
1460
+ h('button', { className: 'primary', textContent: 'Save', onclick: () => saveItem('prompts', p.id, '/api/prompt', { id: p.id, text: ta.value }, 'saved: used from the next message') }),
1461
+ DRAFTS.prompts[p.id] !== undefined ? h('button', { className: 'ghost', textContent: 'Undo my changes', onclick: () => (saved('prompts', p.id), drawPrompts(S)) }) : null,
1462
+ h('span', { className: 'spacer' }),
1463
+ p.changed ? h('button', { textContent: "Reset to HeartCode's", onclick: () => confirm("Put HeartCode's prompt back? Yours is deleted.") && saveItem('prompts', p.id, '/api/prompt', { id: p.id, text: null }, "HeartCode's prompt again") }) : null))
1464
+ })
1465
+ }
1466
+
1467
+ // --- MCP ---
1468
+ const mcpKind = () => document.querySelector('input[name="c-kind"]:checked').value
1469
+ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange = () => {
1470
+ for (const c of document.querySelectorAll('#c-kind label')) c.classList.toggle('on', c.querySelector('input').checked)
1471
+ $('c-what-label').textContent = mcpKind() === 'url' ? 'URL' : 'Command'
1472
+ $('c-what').placeholder = mcpKind() === 'url' ? 'https://mcp.example.com/mcp' : 'npx -y @modelcontextprotocol/server-github'
1473
+ }
1474
+ $('c-add').onclick = async () => {
1475
+ 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`)
1476
+ if (r) $('c-name').value = $('c-what').value = ''
1477
+ }
1478
+ function drawMcp(s) {
1479
+ 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.' }),
1480
+ s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
1481
+ h('div', { className: 't' }, h('b', {}, m.name, ' ', h('span', { className: 'tag', textContent: m.from })), m.description ? h('span', { textContent: m.description }) : null, h('div', { className: 'mono muted', style: 'word-break:break-all;margin-top:3px', textContent: m.what })),
1482
+ m.from === 'yours' ? h('button', { className: 'small danger', textContent: 'Remove', onclick: () => confirm(`Remove the MCP server ${m.name}?`) && act('/api/mcp/remove', { name: m.name }, `removed ${m.name}`) }) : null,
1483
+ sw({ checked: m.on, 'aria-label': `${m.name} on`, onchange: (e) => act('/api/mcp/toggle', { name: m.name, on: e.target.checked }, `${m.name}: ${e.target.checked ? 'on' : 'off'}`) }))) : h('div', { className: 'empty', textContent: 'No MCP servers.' }))
1484
+ const f = s.files.find((x) => x.name === 'mcp.json')
1485
+ const ta = editor('files', 'mcp.json', f.text, { placeholder: f.example })
1486
+ put($('c-file'), ta, h('div', { className: 'actions' }, h('button', { className: 'primary', textContent: 'Save mcp.json', onclick: () => saveItem('files', 'mcp.json', '/api/file', { name: 'mcp.json', text: ta.value }, 'saved mcp.json: the servers start from the next message') })))
1487
+ $('n-mcp').textContent = s.mcp.filter((m) => m.on).length || ''
1488
+ }
1489
+
1490
+ // --- skills: a form (name, who uses it, what it's for, the prompt); SKILL.md is made from it ---
1491
+ const USES = [['both', 'You and the model', 'type /name, or the model uses it when it fits'], ['me', 'Only you', 'only when you type /name'], ['model', 'Only the model', "when it fits; you don't type it"]]
1492
+ function parseSkill(text) {
1493
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
1494
+ const out = { description: '', use: 'both', extra: [], body: (m ? m[2] : text).replace(/^\s*\n/, '') }
1495
+ for (const line of (m?.[1] ?? '').split('\n')) {
1496
+ const kv = /^([\w-]+):\s*(.*)$/.exec(line.trim())
1497
+ if (kv && (kv[1] === 'description' || kv[1] === 'use')) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '')
1498
+ else if (line.trim()) out.extra.push(line) // the rest (name, argument-hint…) stays as it was
1499
+ }
1500
+ return out
1501
+ }
1502
+ 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`
1503
+ const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
1504
+ $('k-new').onclick = () => ((SEL.skills = '+new'), S && drawSkills(S))
1505
+ function skillForm(id, start, { isNew, readOnly }) {
1506
+ const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
1507
+ const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
1508
+ const field = (key, props) => h('input', { type: 'text', value: d[key], readOnly, oninput: (e) => ((d[key] = e.target.value), touch()), ...props })
1509
+ const uses = h('div', { className: 'choices' }, USES.map(([v, t, sub]) => h('label', { className: 'choice' + (d.use === v ? ' on' : '') },
1510
+ h('input', { type: 'radio', name: `use-${id}`, checked: d.use === v, disabled: readOnly, onchange: () => ((d.use = v), touch(), uses.querySelectorAll('.choice').forEach((c) => c.classList.toggle('on', c.querySelector('input').checked))) }),
1511
+ h('b', { textContent: t }), h('span', { textContent: sub }))))
1512
+ const body = grow(h('textarea', { className: 'editor', spellcheck: false, readOnly, value: d.body, placeholder: 'What the model should do. $ARGUMENTS is what you type after /name: "/review the login page" gives "the login page".', oninput: (e) => ((d.body = e.target.value), touch()) }), 220)
1513
+ return { d, el: [
1514
+ h('div', { className: 'row' },
1515
+ isNew ? h('div', { className: 'grow' }, h('label', { className: 'f', textContent: "Name: you'll type /name" }), field('name', { placeholder: 'review' })) : null,
1516
+ h('div', { className: 'grow', style: 'flex:3' }, h('label', { className: 'f', textContent: 'What it does (the model reads this to know when to use it)' }), field('description', { placeholder: 'Reviews the changes for bugs and unclear code' }))),
1517
+ h('label', { className: 'f', textContent: 'Who uses it' }), uses,
1518
+ h('label', { className: 'f', textContent: 'The prompt' }), body,
1519
+ ] }
1520
+ }
1521
+ function drawSkills(s) {
1522
+ if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
1523
+ $('k-new').hidden = SEL.skills === '+new'
1524
+ const mine = s.skills.filter((k) => k.mine)
1525
+ const theirs = s.skills.filter((k) => !k.mine)
1526
+ const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
1527
+ masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
1528
+ if (x === '+new') {
1529
+ const f = skillForm('+new', NEW, { isNew: true })
1530
+ const first = !s.skills.length
1531
+ return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
1532
+ 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,
1533
+ f.el,
1534
+ h('div', { className: 'actions' },
1535
+ h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1536
+ const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1537
+ if (!f.d.body.trim()) return toast('Write the prompt first', true)
1538
+ SEL.skills = slug // the new one, chosen in the list once it's there
1539
+ if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
1540
+ } }),
1541
+ first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
1542
+ }
1543
+ const k = x.k
1544
+ const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
1545
+ return h('div', { className: 'card' },
1546
+ h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', className: 'mono', textContent: '/' + k.name }), k.mine ? null : h('span', { className: 'tag', textContent: 'from a project: read only' })),
1547
+ f.el,
1548
+ k.mine ? h('div', { className: 'actions' },
1549
+ h('button', { className: 'primary', textContent: 'Save', onclick: () => saveItem('skills', k.name, '/api/skill', { name: k.name, was: k.name, text: skillText(f.d) }, `saved /${k.name}`) }),
1550
+ h('span', { className: 'spacer' }),
1551
+ h('button', { className: 'danger', textContent: 'Delete', onclick: () => confirm(`Delete the skill /${k.name}?`) && saveItem('skills', k.name, '/api/skill/delete', { name: k.name }, `deleted /${k.name}`) })) : null)
1552
+ })
1553
+ $('n-skills').textContent = s.skills.length || ''
1554
+ }
1555
+
1556
+ // --- advanced files ---
1557
+ function drawFiles(s) {
1558
+ $('f-home').textContent = s.home
1559
+ const files = s.files.filter((f) => f.name !== 'mcp.json')
1560
+ masterDetail('files', $('f-items'), $('f-detail'), [{ items: files.map((f) => ({ id: f.name, title: f.name, sub: f.about, badge: f.text.trim() ? null : h('span', { className: 'tag', textContent: 'none yet' }), f })) }], (x) => {
1561
+ if (!x) return null
1562
+ const f = x.f
1563
+ const ta = editor('files', f.name, f.text, { placeholder: f.example })
1564
+ return h('div', { className: 'card' },
1565
+ h('div', { className: 'row', style: 'justify-content:space-between;align-items:center' }, h('h2', { style: 'margin:0', className: 'mono', textContent: f.name }), f.text.trim() ? null : h('span', { className: 'tag', textContent: 'none yet' })),
1566
+ h('p', { className: 'sub', style: 'margin:6px 0 12px', textContent: f.about }),
1567
+ !f.text.trim() && DRAFTS.files[f.name] === undefined ? h('div', { className: 'hint', style: 'margin:-4px 0 10px' }, 'Empty: the example is shown in grey. ', h('button', { className: 'small', textContent: 'Start from the example', onclick: () => ((DRAFTS.files[f.name] = f.example + '\n'), drawFiles(S)) })) : null,
1568
+ ta,
1569
+ h('div', { className: 'actions' }, h('button', { className: 'primary', textContent: `Save ${f.name}`, onclick: () => saveItem('files', f.name, '/api/file', { name: f.name, text: ta.value }, ta.value.trim() ? `saved ${f.name}` : `${f.name} deleted`) })))
1570
+ })
1571
+ }
1572
+
1573
+ function render(s) {
1574
+ S = s
1575
+ drawProviders(s)
1576
+ drawMine(s)
1577
+ drawUsing(s)
1578
+ drawGeneral(s)
1579
+ drawLogging(s)
1580
+ if (REQS) drawReqs()
1581
+ drawPrompts(s)
1582
+ drawMcp(s)
1583
+ drawSkills(s)
1584
+ drawFiles(s)
1585
+ }
1586
+
1587
+ showPage()
1588
+ drawPresets()
1589
+ if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link HeartCode printed' }), h('p', { className: 'lead', textContent: 'It has the key to this page: /settings in HeartCode, or heart settings.' }))
1590
+ else {
1591
+ api('/api/state').then(render, (e) => toast(e.message, true))
1592
+ api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => ((REQS = []), drawReqs()))
1593
+ }
1594
+ // A model's test, as it goes: the server streams each change; only its line in your list changes.
1595
+ if (token) {
1596
+ const events = new EventSource(`/api/events?t=${token}`)
1597
+ // a request logged: at the top of the list, nothing else redrawn
1598
+ events.addEventListener('request', (e) => {
1599
+ const r = JSON.parse(e.data)
1600
+ if (!REQS) return
1601
+ REQS.unshift(r)
1602
+ if (REQS.length === 1 || !reqShown(r)) return drawReqs()
1603
+ $('l-list').prepend(reqRow(r))
1604
+ const failed = REQS.filter((x) => x.result === 'failed').length
1605
+ $('n-failed').textContent = failed ? `${failed} failed` : ''
1606
+ })
1607
+ events.addEventListener('test', (e) => {
1608
+ const d = JSON.parse(e.data)
1609
+ const t = S?.order.find((x) => `${x.provider}/${x.model}` === d.key)
1610
+ if (!t) return
1611
+ Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
1612
+ document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
1613
+ drawMine(S)
1614
+ })
1615
+ }
1616
+ </script>
1617
+ </body>
1618
+ </html>