heartcode 0.0.0-stage → 0.1.0

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