k0-board 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.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
package/web/board.js ADDED
@@ -0,0 +1,777 @@
1
+ import { titleCase } from '/title.js'
2
+ import { initView, refit } from '/view.js'
3
+ import { weight, gb } from '/units.js'
4
+ import { setFavicon } from '/favicon.js'
5
+
6
+ // ── Statuses ───────────────────────────────────────────────────────
7
+ // The order is the attention priority inside a column: first whoever is waiting for you,
8
+ // then whoever is grinding away, backlog at the bottom. Change it here and the dashboard changes.
9
+ // The codes stay the database's own: only the label you see is in English.
10
+ const ORDER = ['ASK', 'PLANNED', 'IDLE', 'WORKING', 'PLANNING', 'BACKLOG', 'COMPLETED']
11
+
12
+ const LABEL = {
13
+ ASK: 'Needs answer',
14
+ PLANNED: 'Needs approval',
15
+ IDLE: 'Your turn',
16
+ WORKING: 'Working',
17
+ PLANNING: 'Planning',
18
+ BACKLOG: 'Backlog',
19
+ COMPLETED: 'Done',
20
+ }
21
+
22
+ const $ = (s) => document.querySelector(s)
23
+ const api = async (url, opts) => {
24
+ const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
25
+ const body = await r.json()
26
+ if (!r.ok) throw new Error(body.error || 'Something went wrong')
27
+ return body
28
+ }
29
+
30
+ let projects = []
31
+ let editing = null // id of the card open in the editor, null = a new one
32
+ let editingCard = null // the whole card: Start needs to know whether a session exists already
33
+ let chosenProject = null
34
+ let lastSignature = ''
35
+
36
+ // The filters are the legend itself: click one to switch it off, and it stays as you left it.
37
+ const hidden = new Set(JSON.parse(localStorage.getItem('k0-hidden') || '["COMPLETED"]'))
38
+ const saveHidden = () => localStorage.setItem('k0-hidden', JSON.stringify([...hidden]))
39
+
40
+ // The weight of each session: on to begin with, switched off from the gauge at the top.
41
+ let showLoad = localStorage.getItem('k0-load') !== '0'
42
+ let machine = null // how the computer is doing right now
43
+ let heaviest = null // the card eating the most, so only that one gets tinted
44
+
45
+ // ── Render ─────────────────────────────────────────────────────────
46
+ /** Coarse-grained time: to the second it was only noise. */
47
+ function since(ts, now) {
48
+ if (!ts) return ''
49
+ const m = Math.floor((now - ts) / 60000)
50
+ if (m < 1) return 'now'
51
+ if (m < 60) return `${m} min`
52
+ const h = Math.floor(m / 60)
53
+ if (h < 24) return `${h} h`
54
+ const d = Math.floor(h / 24)
55
+ return d === 1 ? 'yesterday' : `${d} days`
56
+ }
57
+
58
+ /** A stable tilt: it depends on the id, so a card does not dance on every refresh. */
59
+ const tilt = (id) => (((id * 37) % 5) - 2) * 0.5
60
+
61
+ function postit(card, now) {
62
+ const el = document.createElement('article')
63
+ el.className = `postit ${card.status}`
64
+ el.dataset.id = card.id
65
+ el.style.transform = `rotate(${tilt(card.id)}deg)`
66
+
67
+ const alive = card.session_id && card.session_alive && !card.completed_at
68
+ const dead = card.session_id && !card.session_alive && !card.completed_at
69
+ if (alive) el.title = 'Double-click to bring its terminal up front'
70
+
71
+ // On a finished card there is nothing left to edit: the bin takes the pencil's place.
72
+ const corner = card.completed_at
73
+ ? { icon: ICON.del, label: 'Delete', cls: 'corner del', act: () => remove(card) }
74
+ : { icon: ICON.edit, label: 'Edit', cls: 'corner', act: () => openEditor(card) }
75
+
76
+ el.innerHTML = `
77
+ <button class="${corner.cls}" title="${corner.label}" aria-label="${corner.label}">${corner.icon}</button>
78
+ <div class="head"><span class="tag">${esc(card.project_name)}</span>${gitChip(
79
+ card.git,
80
+ // A session with a worktree of its own also has files of its own: that is the directory
81
+ // to open. But only while the worktree really exists — `own` comes from the server,
82
+ // which has just read that directory's git state.
83
+ card.git?.own ? card.work_path : card.project_path,
84
+ card.id
85
+ )}${loadChip(card)}</div>
86
+ <div class="title">${esc(card.title)}</div>
87
+ ${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
88
+ ${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
89
+ <div class="foot">
90
+ ${dead ? '<span class="dead">session closed</span>' : ''}
91
+ <span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
92
+ </div>
93
+ <div class="actions"></div>`
94
+
95
+ // The only way to open the editor: clicking the card itself no longer does anything.
96
+ el.querySelector('.corner').onclick = (e) => {
97
+ e.stopPropagation()
98
+ corner.act()
99
+ }
100
+
101
+ const actions = el.querySelector('.actions')
102
+ const btn = (text, fn, title) => {
103
+ const b = document.createElement('button')
104
+ b.textContent = text
105
+ if (title) b.title = title
106
+ b.onclick = (e) => {
107
+ e.stopPropagation()
108
+ fn()
109
+ }
110
+ actions.append(b)
111
+ }
112
+
113
+ if (card.completed_at) {
114
+ btn('Reopen', () => setCompleted(card.id, false))
115
+ } else if (!card.session_id) {
116
+ btn('Start', () => start(card.id, 'start'))
117
+ } else {
118
+ if (dead) btn('Resume', () => start(card.id, 'resume'))
119
+ // An idea in the backlog cannot be "done": it never started.
120
+ btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
121
+ }
122
+
123
+ // The git mark is a link: a double click on it would open two tabs and bring the terminal to
124
+ // the front into the bargain. Double clicks do not reach it.
125
+ el.querySelector('.git')?.addEventListener('dblclick', (e) => e.stopPropagation())
126
+
127
+ // Double click: back to this session's terminal, wherever it has ended up.
128
+ el.ondblclick = (e) => {
129
+ if (!alive) return
130
+ e.preventDefault()
131
+ focusTerminal(card.id)
132
+ }
133
+ return el
134
+ }
135
+
136
+ // Few icons, all in the same stroke: the pencil opens the editor, the bin throws away, the
137
+ // "+" gives birth to a card. The same "+" as the one in the bar at the top.
138
+ const ICON = {
139
+ edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
140
+ del: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
141
+ plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
142
+ }
143
+
144
+ const esc = (s) =>
145
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
146
+
147
+ // ── The git mark ───────────────────────────────────────────────────
148
+ // One symbol, on the card and above the column: what is needed is to see at a glance whether
149
+ // anything is still outstanding. The rest — which branch, how many files, how many commits, and
150
+ // how many of those commits belong to this session — is read by hovering over it, the way the
151
+ // time at the bottom of a card already works.
152
+ const GIT_ICON = {
153
+ // Things that are nowhere yet: the filled dot editors use.
154
+ dirty: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5.5"/></svg>',
155
+ // Safe locally, but not on the remote.
156
+ push: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V6M6 12l6-6 6 6"/></svg>',
157
+ // All saved: it is there, but it has to disappear into the background.
158
+ ok: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5.5 5.5L20 7"/></svg>',
159
+ // Where there is no git there is nothing to say: the lens still leads to the files.
160
+ files: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4.2-4.2"/></svg>',
161
+ }
162
+
163
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
164
+
165
+ function gitDetail(g) {
166
+ const bits = [g.where ? `worktree ${g.where}` : g.branch || 'detached HEAD']
167
+ bits.push(g.dirty ? `${plural(g.dirty, 'file')} not committed` : 'nothing to commit')
168
+ // With no remote configured there is no point talking about pushing: there is simply no push.
169
+ if (!g.remote) bits.push('no remote')
170
+ else if (g.unpushed > 0) {
171
+ const mine = g.own ? ', all from this session' : g.mine > 0 ? `, ${g.mine} from this session` : ''
172
+ bits.push(`${plural(g.unpushed, 'commit')} not pushed${mine}`)
173
+ } else bits.push('everything pushed')
174
+ return bits.join(' · ')
175
+ }
176
+
177
+ /**
178
+ * The git mark is also the door to the files: clicking it opens the viewer in another tab, and
179
+ * the board stays where it is. Where there is no mark — a repository without git, or git that
180
+ * has not answered yet — a lens goes in its place, so every card and every column still leads
181
+ * in.
182
+ */
183
+ function gitChip(g, repo, card) {
184
+ const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${card ? `&card=${card}` : ''}`)
185
+ const open = 'click to see the files'
186
+ if (!g) return `<a class="git none" href="${to}" target="_blank" title="${open}">${GIT_ICON.files}</a>`
187
+ const kind = g.dirty > 0 ? 'dirty' : g.unpushed > 0 ? 'push' : 'ok'
188
+ const n = kind === 'dirty' ? g.dirty : kind === 'push' ? g.unpushed : ''
189
+ return `<a class="git ${kind}" href="${to}" target="_blank" title="${esc(gitDetail(g))} · ${open}">${
190
+ GIT_ICON[kind]
191
+ }${n}</a>`
192
+ }
193
+
194
+ // ── What a session weighs ────────────────────────────────────────────
195
+ /**
196
+ * How much this session eats, counting everything it has opened underneath itself. The detail —
197
+ * what is doing the eating in there — is read by hovering, and it is the answer to the real
198
+ * question: why does this terminal weigh so much.
199
+ */
200
+ function loadChip(card) {
201
+ if (!showLoad || !card.load) return ''
202
+ const l = card.load
203
+ const bits = l.top.map((t) => `${t.name} ${Math.round(t.rss / 1048576)} MB`)
204
+ if (l.cpu !== null) bits.push(`${Math.round(l.cpu * 100)}% CPU`)
205
+ bits.push(`${l.procs} process${l.procs === 1 ? '' : 'es'}`)
206
+ // Red on one only, and only while the machine is struggling: if they are all red they stop
207
+ // saying anything, and it is not true that they all need closing.
208
+ const hot = machine?.pressure !== 'ok' && card.id === heaviest
209
+ return `<span class="load${hot ? ' hot' : ''}" title="${esc(bits.join(' · '))}">${weight(l.rss)}</span>`
210
+ }
211
+
212
+ /** Just enough to compare one round with the next: if it changes, it redraws. */
213
+ const gitSig = (g) => (g ? `${g.dirty}/${g.unpushed}/${g.own ? 1 : 0}/${g.mine ?? ''}` : '')
214
+
215
+ /**
216
+ * How long this card has been where it is. Until it has ever changed status there is nothing in
217
+ * `session_event` and `status_since` is empty: then it counts from when it came into existence,
218
+ * otherwise a card just created would end up at the bottom of its group.
219
+ */
220
+ const freshness = (c) => c.status_since ?? c.created_at ?? 0
221
+
222
+ /**
223
+ * The order of attention: most urgent status first, and among equal statuses whichever entered
224
+ * it last. It holds between the cards inside a column and — applied to the leading card —
225
+ * between the columns on the board.
226
+ */
227
+ const byAttention = (a, b) =>
228
+ ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || freshness(b) - freshness(a)
229
+
230
+ function render(data) {
231
+ const visible = data.cards.filter((c) => !hidden.has(c.status))
232
+ const board = $('#board')
233
+ board.textContent = ''
234
+
235
+ const columns = data.columns
236
+ .map((col) => ({
237
+ col,
238
+ cards: visible.filter((c) => c.project_path === col.path).sort(byAttention),
239
+ }))
240
+ .filter(({ cards }) => cards.length)
241
+
242
+ // In driving mode the columns queue up by attention too: a column is worth as much as its
243
+ // leading card, and since the cards are sorted already, that first one is necessarily the most
244
+ // urgent it has. One red is enough to bring it left; when that red moves into working, the
245
+ // column slides right by itself. On a tie the alphabetical order from the server stands,
246
+ // because `sort` is stable.
247
+ //
248
+ // But ONLY there. In driving mode you see two or three columns at a time, and bringing the most
249
+ // urgent one to the left is the only way to see it without going to look for it. Full screen
250
+ // they are all in front of you already: there is nothing to bring to anybody, and a board that
251
+ // reshuffles itself while you work only loses your place. With the switch off the columns stand
252
+ // still in alphabetical order — the server's, which is not touched here.
253
+ if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
254
+
255
+ for (const { col, cards } of columns) {
256
+ const wrap = document.createElement('section')
257
+ wrap.className = 'column'
258
+ // The "+" next to the name: a new card on THIS repository, without picking it.
259
+ const add = `New card in ${esc(col.name)}`
260
+ wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>${esc(
261
+ col.name
262
+ )}<small>${cards.length}</small>${gitChip(col.git, col.path)}</h2>`
263
+ wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
264
+ for (const c of cards) wrap.append(postit(c, data.now))
265
+ board.append(wrap)
266
+ }
267
+
268
+ board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
269
+ $('#empty').style.display = columns.length ? 'none' : 'grid'
270
+ refit() // the board changed size: the view comes back inside its limits
271
+ }
272
+
273
+ /**
274
+ * The last column: the OTHER repositories, the ones with no column because they have not one
275
+ * card between them. Repeating the ones already on the left would be saying the same thing
276
+ * twice. Headings only, in the order you last used them. It is there for two things: getting
277
+ * into the files of a directory you are not working on, and giving birth to the first card
278
+ * there without having to find it in a list.
279
+ */
280
+ function repoColumn(projects, shown) {
281
+ // In alphabetical order, not by recency: these are thirty rows you scan with your eye looking
282
+ // for a name, and in a list that reorders itself you find nothing. Recency belongs in the
283
+ // repository picker, where you type; not here.
284
+ const others = projects
285
+ .filter((p) => !shown.has(p.path))
286
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
287
+ if (!others.length) return document.createDocumentFragment()
288
+
289
+ const wrap = document.createElement('section')
290
+ wrap.className = 'column repos'
291
+ wrap.innerHTML =
292
+ '<h2>Others<small>' +
293
+ others.length +
294
+ '</small></h2>' +
295
+ others
296
+ .map((p) => {
297
+ const add = `New card in ${esc(p.name)}`
298
+ return `<div class="repo" data-p="${esc(p.path)}">
299
+ <button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
300
+ <span class="name">${esc(p.name)}</span>${gitChip(p.git, p.path)}
301
+ </div>`
302
+ })
303
+ .join('')
304
+ wrap.onclick = (e) => {
305
+ const b = e.target.closest('.add')
306
+ if (b) openEditor(null, b.closest('.repo').dataset.p)
307
+ }
308
+ return wrap
309
+ }
310
+
311
+ // ── The load gauge ─────────────────────────────────────────────────
312
+ /**
313
+ * It lives outside `render`: it updates every round even when the board does not change, which
314
+ * is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
315
+ * while it says all is well, amber at the first warning, red when it is critical.
316
+ */
317
+ function renderMachine(m, cards) {
318
+ const el = $('#gauge')
319
+ if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
320
+ el.removeAttribute('hidden')
321
+ el.className = `gauge ${m.pressure}`
322
+
323
+ const ram = Math.round((m.mem.used / m.mem.total) * 100)
324
+ const cpu = m.cpu === null ? '—' : `${Math.round(m.cpu * 100)}%`
325
+ el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}`
326
+
327
+ const top = cards.find((c) => c.id === heaviest)
328
+ const lines = [
329
+ `${gb(m.mem.used)} of ${gb(m.mem.total)} in use · ${gb(m.swap.used)} on swap`,
330
+ `${m.alive} session${m.alive === 1 ? '' : 's'} running${m.outside ? `, ${m.outside} not on the dashboard` : ''}`,
331
+ ]
332
+ if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
333
+ // What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
334
+ if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
335
+ lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
336
+ el.title = lines.join('\n')
337
+ }
338
+
339
+ function renderFilters() {
340
+ $('#filters').innerHTML = ORDER.map(
341
+ (s) =>
342
+ `<button data-s="${s}" aria-pressed="${!hidden.has(s)}"><i style="background:var(--${s})"></i>${LABEL[s]}</button>`
343
+ ).join('')
344
+ }
345
+
346
+ // ── Actions ───────────────────────────────────────────────────────
347
+ function toast(msg, ms = 4000) {
348
+ const t = $('#toast')
349
+ t.textContent = msg
350
+ t.classList.add('show')
351
+ clearTimeout(toast.timer)
352
+ toast.timer = setTimeout(() => t.classList.remove('show'), ms)
353
+ }
354
+
355
+ async function start(id, mode) {
356
+ toast(mode === 'resume' ? 'Resuming the session…' : 'Opening the terminal…', 30000)
357
+ try {
358
+ const r = await api(`/api/card/${id}/${mode}`, { method: 'POST' })
359
+ if (!r.up) toast(`${r.name} was launched, but I didn't see it come up within 30s`)
360
+ else if (r.pasted) toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
361
+ else if (r.autoSent) toast(`${r.name} started on its own (without the Accessibility permission I can't leave the prompt waiting)`, 8000)
362
+ else toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
363
+ } catch (e) {
364
+ toast(`Couldn't do it: ${e.message}`, 8000)
365
+ }
366
+ refresh()
367
+ }
368
+
369
+ async function focusTerminal(id) {
370
+ const r = await api(`/api/card/${id}/focus`, { method: 'POST' })
371
+ if (!r.ok) toast(r.error)
372
+ }
373
+
374
+ /**
375
+ * The house confirmation, not the browser's: it is only ever asked before throwing a card away,
376
+ * which is the one thing there is no coming back from. "Done" is not — that one is undone with
377
+ * Reopen.
378
+ */
379
+ function ask(text) {
380
+ const dlg = $('#confirm')
381
+ return new Promise((resolve) => {
382
+ $('#confirm-text').textContent = text
383
+ const close = (answer) => {
384
+ dlg.close()
385
+ resolve(answer)
386
+ }
387
+ $('#confirm-yes').onclick = () => close(true)
388
+ $('#confirm-no').onclick = () => close(false)
389
+ dlg.onclose = () => resolve(false) // Esc is a no too; the first answer is the one that counts
390
+ dlg.showModal()
391
+ })
392
+ }
393
+
394
+ async function remove(card) {
395
+ if (!(await ask(`Delete “${card.title}”?`))) return
396
+ await api(`/api/card/${card.id}`, { method: 'DELETE' })
397
+ refresh()
398
+ }
399
+
400
+ async function setCompleted(id, completed) {
401
+ await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
402
+ refresh()
403
+ }
404
+
405
+ // ── Editor ─────────────────────────────────────────────────────────
406
+ /**
407
+ * `presetPath` is the repository of the column you pressed "+" on: it arrives already chosen and
408
+ * the cursor jumps straight to the title, which is the only thing missing.
409
+ */
410
+ function openEditor(card, presetPath = null) {
411
+ editing = card?.id ?? null
412
+ editingCard = card ?? null
413
+ // A repository typed by hand does not count: only a real path counts, taken from the list or
414
+ // handed over by the column.
415
+ chosenProject = card?.project_path ?? presetPath ?? null
416
+ $('#editor-title').textContent = card ? 'Card' : 'New card'
417
+ $('#f-project').value = chosenProject ? nameOf(chosenProject) : ''
418
+ // Session already started: the repository cannot be moved out from under Claude's feet.
419
+ $('#f-project').disabled = !!card?.session_id
420
+ $('#f-project').classList.remove('bad')
421
+ $('#f-title').value = card?.title ?? ''
422
+ $('#f-desc').value = card?.description ?? ''
423
+ $('#f-prompt').value = card?.prompt ?? ''
424
+ $('#f-delete').style.display = card ? '' : 'none'
425
+ // A live session is not restarted: there is only saving to do there.
426
+ $('#f-start').style.display = card?.session_alive ? 'none' : ''
427
+ $('#f-start').textContent = card?.session_id ? 'Resume' : 'Start'
428
+ $('#f-project-list').hidden = true
429
+ $('#editor').showModal()
430
+ ;(chosenProject ? $('#f-title') : $('#f-project')).focus()
431
+ loadProjects() // the repository order changes constantly: re-read on every opening
432
+ }
433
+
434
+ const nameOf = (p) => projects.find((x) => x.path === p)?.name ?? p
435
+
436
+ let hits = [] // the repositories shown in the list right now
437
+ let active = 0 // where you are with the arrows: the row Enter picks
438
+
439
+ function chooseProject(path) {
440
+ chosenProject = path
441
+ $('#f-project').value = nameOf(path)
442
+ $('#f-project').classList.remove('bad')
443
+ $('#f-project-list').hidden = true
444
+ }
445
+
446
+ /** With a repository already chosen the whole list is shown: that is where you change it. */
447
+ const listQuery = () => (chosenProject ? '' : $('#f-project').value)
448
+
449
+ const matching = (query) => {
450
+ const q = query.trim().toLowerCase()
451
+ return projects.filter((p) => p.name.toLowerCase().includes(q)).slice(0, 40)
452
+ }
453
+
454
+ function renderProjectList(query = '') {
455
+ hits = matching(query)
456
+ // Redrawing must not lose the highlight: it only moves if it has fallen outside.
457
+ active = Math.max(0, Math.min(active, hits.length - 1))
458
+ const list = $('#f-project-list')
459
+ list.innerHTML = hits.length
460
+ ? hits
461
+ .map(
462
+ (p, i) =>
463
+ `<div data-p="${esc(p.path)}" class="${p.path === chosenProject ? 'on' : ''}${
464
+ i === active ? ' active' : ''
465
+ }">${esc(p.name)}${p.last_used ? `<em>${since(p.last_used, Date.now())}</em>` : ''}</div>`
466
+ )
467
+ .join('')
468
+ : '<div style="color:var(--muted)">no repository with that name</div>'
469
+ list.hidden = false
470
+ }
471
+
472
+ /** On opening, the arrows start from the repository already chosen, not from the top. */
473
+ function openProjectList() {
474
+ const found = matching(listQuery()).findIndex((p) => p.path === chosenProject)
475
+ active = Math.max(0, found)
476
+ renderProjectList(listQuery())
477
+ $('#f-project-list').children[active]?.scrollIntoView({ block: 'nearest' })
478
+ }
479
+
480
+ /**
481
+ * On leaving the field: either there is a real repository, or there is nothing. If what you
482
+ * typed identifies exactly one, it takes it; otherwise the field empties, instead of sitting
483
+ * there with a name that does not exist.
484
+ */
485
+ function resolveProject() {
486
+ const el = $('#f-project')
487
+ if (chosenProject) {
488
+ el.value = nameOf(chosenProject)
489
+ return
490
+ }
491
+ const q = el.value.trim().toLowerCase()
492
+ const exact = q && projects.find((p) => p.name.toLowerCase() === q)
493
+ const found = q ? projects.filter((p) => p.name.toLowerCase().includes(q)) : []
494
+ if (exact) return chooseProject(exact.path)
495
+ if (found.length === 1) return chooseProject(found[0].path)
496
+ el.value = ''
497
+ el.classList.remove('bad')
498
+ }
499
+
500
+ /** Returns the saved card, or null if it could not be saved. */
501
+ async function save() {
502
+ if (!$('#editor-form').reportValidity()) return null
503
+ const title = titleCase($('#f-title').value).replace(/-+$/, '')
504
+ // The repository only counts if picked from the list: typed by hand it does not exist.
505
+ if (!chosenProject) {
506
+ $('#f-project').classList.add('bad')
507
+ $('#f-project').focus()
508
+ toast('Pick a repository from the list')
509
+ return null
510
+ }
511
+ if (!title) {
512
+ $('#f-title').focus()
513
+ toast('A title is required')
514
+ return null
515
+ }
516
+ const payload = {
517
+ title,
518
+ description: $('#f-desc').value.trim(),
519
+ project_path: chosenProject,
520
+ prompt: $('#f-prompt').value,
521
+ }
522
+ try {
523
+ const saved = editing
524
+ ? await api(`/api/card/${editing}`, { method: 'PATCH', body: JSON.stringify(payload) })
525
+ : await api('/api/card', { method: 'POST', body: JSON.stringify(payload) })
526
+ $('#editor').close()
527
+ refresh()
528
+ return saved
529
+ } catch (e) {
530
+ toast(e.message)
531
+ return null
532
+ }
533
+ }
534
+
535
+ /**
536
+ * What the four buttons say when you hover over them. The name of the mode on its own would
537
+ * not be enough: the part that really matters — the machine stays awake with the lid closed —
538
+ * is exactly the part that can be missing, and a control that promises something it does not
539
+ * do is worse than one that is switched off. The keys are the `reason` the server sends.
540
+ */
541
+ const MODE_STATE = {
542
+ sleep: 'Sleep — the machine may fall asleep and stop what it is doing',
543
+ full: 'Holds even with the lid closed',
544
+ battery: 'On, but sleeping is allowed again: the battery is nearly flat',
545
+ 'no-permission': 'On, but only with the lid open. Run `k0-board install` to cover the lid too',
546
+ unsupported: 'On, but this system does not let k0 control what the lid does',
547
+ }
548
+
549
+ /** What each one does: the title of the three buttons that are not the lit one. */
550
+ const MODE_WHAT = {
551
+ sleep: 'Sleep — let the machine fall asleep as it normally would',
552
+ away: 'Away — you are not here, but the machine keeps working. The screen may sleep',
553
+ nerd: 'Nerd — the machine and the screen both stay on',
554
+ driving: 'Driving — screen always on, and everything in large type',
555
+ }
556
+
557
+ /**
558
+ * The four modes, put on the page. Two separate things: which button is lit — that is what
559
+ * you asked for — and `data-partial`, which is how well it is managing.
560
+ *
561
+ * The large type stays hung on `data-driving` on the `<html>` element, which is where the
562
+ * stylesheet already looks for it: no measurement is touched here, so there are never two
563
+ * versions of one.
564
+ */
565
+ function setMode(mode, reason) {
566
+ const html = document.documentElement
567
+ const large = mode === 'driving'
568
+ // Only when it really changes: rewriting it every round would wake the layout for nothing.
569
+ if (large !== (html.dataset.driving === '')) {
570
+ if (large) html.dataset.driving = ''
571
+ else delete html.dataset.driving
572
+ }
573
+ for (const b of document.querySelectorAll('#modes button')) {
574
+ const its = b.dataset.mode === mode
575
+ b.setAttribute('aria-checked', its ? 'true' : 'false')
576
+ if (its && reason && reason !== 'full' && reason !== 'sleep') b.dataset.partial = ''
577
+ else delete b.dataset.partial
578
+ b.title = its ? (MODE_STATE[reason] ?? MODE_WHAT[mode]) : MODE_WHAT[b.dataset.mode]
579
+ }
580
+ }
581
+
582
+ // ── The loop ──────────────────────────────────────────────────────
583
+ async function refresh() {
584
+ try {
585
+ const data = await api('/api/board')
586
+ // The icon in the tab is tinted like the one in the menu bar: red if a session is waiting
587
+ // for you, blue if they are grinding away, outline if everything is still.
588
+ setFavicon(data.urgent)
589
+ // The mode is the server's to hold, because it shares it with the menu bar icon: here we
590
+ // simply obey, even when it was changed over there. The `reason` comes with it: if the lid
591
+ // is not covered, the lit button has to say so instead of pretending otherwise.
592
+
593
+ setMode(data.mode, data.reason)
594
+ machine = data.machine ?? null
595
+ // What is eating most right now: it is what tints that one red and nobody else.
596
+ heaviest =
597
+ data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
598
+ renderMachine(machine, data.cards)
599
+
600
+ // Redraw only if something really changed, so the editor and the focus stay where they are.
601
+ // The git state is inside the signature: without it, the mark would stay the one from the
602
+ // first round. The weight goes in already rounded — see `weight` — otherwise the board
603
+ // would redraw on every sample of the load.
604
+ const sig = JSON.stringify([
605
+ showLoad,
606
+ // The column order depends on it: changing mode means the board has to be rebuilt.
607
+ data.mode,
608
+ // Not `heaviest` as it is: on a calm machine the heaviest changes constantly without
609
+ // anything changing on screen, and it would redraw for nothing.
610
+ machine?.pressure !== 'ok' ? heaviest : null,
611
+ data.cards.map((c) => [
612
+ c.id,
613
+ c.status,
614
+ c.session_alive,
615
+ c.title,
616
+ c.description,
617
+ c.prompt,
618
+ c.project_path,
619
+ gitSig(c.git),
620
+ c.load ? weight(c.load.rss) : '',
621
+ ]),
622
+ data.columns.map((col) => [col.path, gitSig(col.git)]),
623
+ (data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
624
+ ])
625
+ if (sig !== lastSignature) {
626
+ lastSignature = sig
627
+ render(data)
628
+ }
629
+ } catch {
630
+ /* server down: we try again next round */
631
+ }
632
+ }
633
+
634
+ /** The repository list, re-read every time the editor opens: the order goes stale fast. */
635
+ async function loadProjects() {
636
+ try {
637
+ projects = await api('/api/projects')
638
+ if (!$('#f-project-list').hidden) renderProjectList(listQuery())
639
+ } catch {
640
+ /* an old list: better than no list */
641
+ }
642
+ }
643
+
644
+ async function boot() {
645
+ initView() // before anything else: render() assumes the view is already there
646
+ renderFilters()
647
+ await loadProjects()
648
+
649
+ $('#filters').onclick = (e) => {
650
+ const b = e.target.closest('button')
651
+ if (!b) return
652
+ const s = b.dataset.s
653
+ hidden.has(s) ? hidden.delete(s) : hidden.add(s)
654
+ saveHidden()
655
+ renderFilters()
656
+ lastSignature = ''
657
+ refresh()
658
+ }
659
+
660
+ // The gauge is always there; clicking it switches the weights on the cards off and on again,
661
+ // and the choice stays as you left it.
662
+ $('#gauge').onclick = () => {
663
+ showLoad = !showLoad
664
+ localStorage.setItem('k0-load', showLoad ? '1' : '0')
665
+ lastSignature = ''
666
+ refresh()
667
+ }
668
+
669
+ // The four buttons are one control: the click is caught on the container, so there are not
670
+ // four handlers saying the same thing. It answers straight away without waiting — a click
671
+ // has to answer — but the last word belongs to the server, which by then has talked to the
672
+ // operating system and is the only one who knows whether the lid is really covered.
673
+ $('#modes').onclick = async (e) => {
674
+ const b = e.target.closest('button[data-mode]')
675
+ if (!b) return
676
+ const mode = b.dataset.mode
677
+ // Clicking the lit one again does nothing, and that is the point: you leave a mode, you do
678
+ // not switch it off. Without this, every re-click would be a round trip for nothing.
679
+ if (b.getAttribute('aria-checked') === 'true') return
680
+ const before = $('#modes button[aria-checked="true"]')?.dataset.mode
681
+ setMode(mode, mode === 'sleep' ? 'sleep' : 'full')
682
+ try {
683
+ const res = await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode }) })
684
+ setMode(res.mode, res.reason)
685
+ if (res.reason === 'no-permission') toast(MODE_STATE['no-permission'])
686
+ if (res.reason === 'unsupported') toast(MODE_STATE.unsupported)
687
+ } catch (err) {
688
+ setMode(before, null) // it did not go through: go back instead of lying
689
+ toast(err.message)
690
+ }
691
+ }
692
+
693
+ $('#new-card').onclick = () => openEditor(null)
694
+ $('#f-close').onclick = () => $('#editor').close()
695
+ $('#editor-form').onsubmit = (e) => {
696
+ e.preventDefault()
697
+ save()
698
+ }
699
+ // Save and go: the same thing the button on the card does, without going through it.
700
+ $('#f-start').onclick = async () => {
701
+ const saved = await save()
702
+ if (!saved) return
703
+ start(saved.id, editingCard?.session_id ? 'resume' : 'start')
704
+ }
705
+ // Esc with the list open closes only the list: the dialog leaves on the second Esc.
706
+ $('#editor').oncancel = (e) => {
707
+ if ($('#f-project-list').hidden) return
708
+ e.preventDefault()
709
+ $('#f-project-list').hidden = true
710
+ }
711
+
712
+ // The title is normalised as you type. The cursor is put back by normalising the part before
713
+ // it as well: that way you can correct in the middle of a word without being thrown to the
714
+ // end of the field.
715
+ $('#f-title').oninput = (e) => {
716
+ const el = e.target
717
+ const caret = titleCase(el.value.slice(0, el.selectionStart)).length
718
+ el.value = titleCase(el.value)
719
+ el.setSelectionRange(caret, caret)
720
+ }
721
+
722
+ // It opens when you click or type in it, not as soon as the field takes focus: otherwise it
723
+ // would cover the rest of the form every time you open the editor.
724
+ $('#f-project').onclick = () => openProjectList()
725
+ $('#f-project').oninput = (e) => {
726
+ // Typing undoes the choice: only a repository picked from the list counts.
727
+ chosenProject = null
728
+ active = 0
729
+ e.target.classList.remove('bad')
730
+ renderProjectList(e.target.value)
731
+ }
732
+ $('#f-project').onkeydown = (e) => {
733
+ const list = $('#f-project-list')
734
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
735
+ e.preventDefault()
736
+ if (list.hidden) return openProjectList() // the first arrow only opens it
737
+ if (!hits.length) return
738
+ active = (active + (e.key === 'ArrowDown' ? 1 : hits.length - 1)) % hits.length
739
+ renderProjectList(listQuery())
740
+ list.children[active]?.scrollIntoView({ block: 'nearest' })
741
+ return
742
+ }
743
+ if (e.key !== 'Enter') return
744
+ e.preventDefault() // no form submit: here Enter takes the highlighted row
745
+ if (hits[active]) {
746
+ chooseProject(hits[active].path)
747
+ $('#f-title').focus()
748
+ }
749
+ }
750
+ // The delay lets the list's mousedown win, which is what picks a row.
751
+ $('#f-project').onblur = () =>
752
+ setTimeout(() => {
753
+ $('#f-project-list').hidden = true
754
+ resolveProject()
755
+ }, 150)
756
+ $('#f-project-list').onmousedown = (e) => {
757
+ const row = e.target.closest('[data-p]')
758
+ if (!row) return
759
+ chooseProject(row.dataset.p)
760
+ $('#f-title').focus()
761
+ }
762
+
763
+ $('#f-delete').onclick = async () => {
764
+ if (!editing || !(await ask(`Delete “${$('#f-title').value}”?`))) return
765
+ await api(`/api/card/${editing}`, { method: 'DELETE' })
766
+ $('#editor').close()
767
+ refresh()
768
+ }
769
+
770
+ refresh()
771
+ setInterval(refresh, 1000)
772
+
773
+ // A direct link: k0 opened with the new card already in front of you.
774
+ if (location.hash === '#new' || location.hash === '#nuovo') openEditor(null)
775
+ }
776
+
777
+ boot()