k0-board 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.2]
10
+
11
+ Two things that were in the way every day: a field nobody needed, and a notification that opened
12
+ the wrong application.
13
+
14
+ ### Fixed
15
+
16
+ - **Clicking a notification opens that session's terminal**, on all three platforms. On macOS it
17
+ used to open **Script Editor** with a file dialog: when the Mac would not deliver k0's own
18
+ notifications, k0 fell back to posting one through `osascript`, and a banner posted that way
19
+ belongs to Script Editor rather than to k0 — no card, no click, wrong application. The fallback
20
+ is gone. When macOS refuses the permission, k0 now says so in the menu bar and takes you to the
21
+ Notifications pane — and switch it on there and the menu says so on the next pass, a couple of
22
+ seconds later, instead of leaving you looking at a message that reads as stuck. It writes what
23
+ the answer was in its log, and asks again rather than giving up on the first refusal. On Linux
24
+ the notification carries an action, on Windows the balloon finally has a click.
25
+
26
+ ### Changed
27
+
28
+ - **A new card asks for three things**: the repository, a title, and what Claude should do when it
29
+ starts — which can be left empty and typed straight into Claude Code. The *Description* field is
30
+ gone: it never went anywhere except onto the note. Imported sessions keep theirs, which is the
31
+ one place it says something, and `/k0-import` still writes it.
32
+ - **A note is never taller than it is wide.** Long text used to push a card down the column until
33
+ it stopped looking like a note. The repository, the title, the age and the buttons always show;
34
+ the text in the middle is what gets clipped.
35
+
36
+ ## [0.1.1]
37
+
38
+ Documentation, and how a release is made. Nothing about k0 itself changed.
39
+
40
+ ### Changed
41
+
42
+ - The README says what the npm package is, how to keep it up to date, and what each command
43
+ does.
44
+ - Releases are published by GitHub Actions through npm's **trusted publishing**, with a signed
45
+ provenance statement and no publishing token kept anywhere — not in the repository secrets,
46
+ not on a laptop. A token that can publish is a token that can be stolen.
47
+
9
48
  ## [0.1.0]
10
49
 
11
50
  The first public release. k0 existed for a while as one person's tool before this; what changed
@@ -53,5 +92,7 @@ here is everything that had to change for it to be somebody else's tool as well.
53
92
  - The personal fixtures k0 grew up on. `test/clean.test.mjs` now fails the build if any of them
54
93
  come back.
55
94
 
56
- [Unreleased]: https://github.com/alessio-ragni/k0-board/compare/v0.1.0...HEAD
95
+ [Unreleased]: https://github.com/alessio-ragni/k0-board/compare/v0.1.2...HEAD
96
+ [0.1.2]: https://github.com/alessio-ragni/k0-board/compare/v0.1.1...v0.1.2
97
+ [0.1.1]: https://github.com/alessio-ragni/k0-board/compare/v0.1.0...v0.1.1
57
98
  [0.1.0]: https://github.com/alessio-ragni/k0-board/releases/tag/v0.1.0
package/README.md CHANGED
@@ -19,6 +19,7 @@ doing, and it goes on saying so while you are looking somewhere else.
19
19
  ![Four columns of paper sticky notes — yellow, blue, amber and green — pinned on a dark wall, each note tilted at its own angle, with a row of small coloured status pills along the top bar.](https://raw.githubusercontent.com/alessio-ragni/k0-board/main/docs/board.jpg)
20
20
 
21
21
  [![Tests](https://github.com/alessio-ragni/k0-board/actions/workflows/ci.yml/badge.svg)](https://github.com/alessio-ragni/k0-board/actions/workflows/ci.yml)
22
+ [![npm](https://img.shields.io/npm/v/k0-board)](https://www.npmjs.com/package/k0-board)
22
23
  [![License: MIT](https://img.shields.io/badge/License-MIT-informational)](LICENSE)
23
24
 
24
25
  **Contents** · [Install](#install) · [How it is used](#how-it-is-used) ·
@@ -41,6 +42,36 @@ npx k0-board
41
42
  That is the whole thing. It tells you everything it is about to change, asks, and only then does
42
43
  it. `k0-board uninstall` undoes all of it.
43
44
 
45
+ k0 is published on npm as [`k0-board`](https://www.npmjs.com/package/k0-board): one package, no
46
+ dependencies, nothing to compile. `npx` fetches it, runs the installer once and leaves nothing
47
+ behind — what stays on the machine is the copy the installer puts in `~/.k0/app`, not an npx
48
+ cache that could be swept away under a running service. Installing it globally with
49
+ `npm install -g k0-board` works too, if you would rather have the command on your PATH.
50
+
51
+ **To update it**, run the installer again with `npx k0-board@latest`: it replaces the copy and
52
+ restarts the service. Your board is never in the way — it lives in `~/.k0/k0.db`, outside
53
+ everything the installer touches, and `uninstall` leaves it there as well.
54
+
55
+ Every release is published by GitHub Actions with a signed **provenance statement**: proof, which
56
+ `npm audit signatures` will check for you, that the tarball was built from a particular commit of
57
+ this repository rather than uploaded by somebody who happened to have a password. (0.1.0 predates
58
+ that and went out from a laptop.)
59
+
60
+ ### The commands
61
+
62
+ | | |
63
+ |---|---|
64
+ | `k0-board install` | install and start it — this is what `npx k0-board` runs |
65
+ | `k0-board uninstall` | undo everything install did; your board stays |
66
+ | `k0-board start` | run the server in the foreground, installing nothing |
67
+ | `k0-board restart` | restart the service |
68
+ | `k0-board status` | is it running? exits 0 if it is, 1 if it is not |
69
+ | `k0-board doctor` | what this machine can and cannot do, and why |
70
+
71
+ `--yes` does not ask. `--from-source` points the service at the directory you are in instead of
72
+ copying it, which is what you want while working on k0 itself. `--no-<name>` skips one optional
73
+ change — `--no-lid-sleep`, `--no-shift-enter`. `K0_PORT` moves the board off 4319.
74
+
44
75
  From a clone, if you would rather look first:
45
76
 
46
77
  ```bash
@@ -77,6 +108,7 @@ The board lives at <http://k0.localhost:4319>.
77
108
  | Keep the machine awake | ✅ | ✅ | ✅ |
78
109
  | Keep working with the lid closed | ✅ | ❌ system setting | ❌ system setting |
79
110
  | Tray icon and notifications | ✅ | ✅ GNOME/KDE | ✅ |
111
+ | Clicking a notification takes you to that terminal | ✅ needs the permission | ✅ if the desktop honours actions | ✅ |
80
112
 
81
113
  **macOS is the only platform the author has.** Linux and Windows are written from the
82
114
  documentation and reviewed carefully, and the logic they share with macOS is tested on all three
@@ -115,8 +147,9 @@ k0 makes **no network requests**. Not one, not even for a font.
115
147
  ### The board
116
148
 
117
149
  - **+** — at the left, next to k0, and next to every repository name on the board: that one opens
118
- a card with the repository already chosen. First the repository, then a title, then what Claude
119
- should do when it starts. The prompt can be left empty.
150
+ a card with the repository already chosen. Three fields and no more: the repository, a title,
151
+ and what Claude should do when it starts. The prompt can be left empty — nothing stops you from
152
+ starting the session and typing straight into Claude Code.
120
153
 
121
154
  The repository has to be **picked from the list** (searchable, most used at the top; arrow keys
122
155
  to move, Enter takes the highlighted row): a name typed by hand does not count. On leaving the
@@ -349,7 +382,9 @@ numbers.
349
382
 
350
383
  The difference between the two is the writing. The installer uses the name the session already
351
384
  had, or the first few words of the first prompt. `/k0-import` has Claude read the conversation and
352
- write a title and a description in the language it was held in.
385
+ write a title and a description in the language it was held in. That description is the line in
386
+ italics under the title on the note, and importing is the only thing that writes one: a card you
387
+ write yourself has a title and a prompt, which is all it needs.
353
388
 
354
389
  Imported cards are born yellow — *Your turn*: **Resume** reopens the conversation where it was,
355
390
  **Done** clears it away. Being dead sessions they do not light the tray icon. Running it again
@@ -557,6 +592,7 @@ platform/
557
592
  contract.js the line between k0 and the operating system, and what an adapter must fill in
558
593
  index.js picks the adapter, and reports what this machine can and cannot do
559
594
  darwin/ AppleScript and Terminal.app, pmset and caffeinate, launchd, the Swift icon
595
+ (menubar/, which is also what posts the notifications and reads the permission)
560
596
  linux/ tmux and any emulator, systemd-inhibit, systemd user units, a GTK tray
561
597
  win32/ PowerShell and Windows Terminal, execution state, Task Scheduler, a WinForms tray
562
598
  shared/ what more than one of them needs: tmux, running commands, reading a process table
@@ -574,7 +610,9 @@ server/
574
610
  machine.js the only one that looks at processes and memory: what it all costs, and who is costing it
575
611
  pdf.js the document on paper, printed by a headless browser
576
612
  web/ the two pages (html, css, js served exactly as they are)
577
- index.html the board — board.js, board.css, view.js
613
+ index.html the board — board.js, board.css, view.js. A note is never taller than it is
614
+ wide: the title, the age and the buttons always show, the text in the middle is
615
+ what gets clipped
578
616
  files.html the file viewer — files.js, files.css
579
617
  base.css colours, fonts and scale: the house variables, shared by both pages
580
618
  md.js markdown laid out, written by hand because nothing here is compiled
@@ -603,6 +641,12 @@ certain), `-n` for the name, `--resume` to pick one up, and `--permission-mode p
603
641
 
604
642
  Field notes the code takes for granted.
605
643
 
644
+ - **A notification posted by `osascript` belongs to Script Editor.** `display notification` is the
645
+ one-line way to put a banner on screen from a script, and it works — but the banner is not
646
+ yours: it carries nothing, it has no delegate, and clicking it opens Script Editor with its file
647
+ dialog. It was in here as a fallback for when macOS would not deliver k0's own notifications,
648
+ and every click on it went to the wrong application. There is now one way to notify, and where
649
+ macOS refuses it the menu says so and takes you to the Notifications pane instead of pretending.
606
650
  - **`--dangerously-skip-permissions` switches off plan mode.** No error, no warning, and the order
607
651
  of the two flags changes nothing — the session starts in `bypassPermissions` and there is no
608
652
  trace of a plan. It looks like it works, which is what makes it a trap. The only way to really
@@ -173,27 +173,93 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
173
173
  let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
174
174
  var seen: [Int: String] = [:] // the last known status of every card
175
175
  var firstPass = true // no notifications on the first pass: that would be noise at startup
176
- var notificationsAllowed = false
176
+ /// What macOS currently thinks of this app's notifications. **Asked again and again, never
177
+ /// remembered from the one answer at launch**: `requestAuthorization` replies asynchronously,
178
+ /// this app starts at login — before the notification service is necessarily up — and a
179
+ /// boolean captured in that callback would then say "no" for the rest of the day, with
180
+ /// nothing anywhere to say why.
181
+ var notificationStatus: UNAuthorizationStatus = .notDetermined
182
+ private var loggedStatus: UNAuthorizationStatus?
183
+ private var ticksBeforeAsking = 0
177
184
  let images = ImagePaste()
178
185
 
179
186
  func applicationDidFinishLaunching(_ n: Notification) {
180
187
  item.button?.image = icon(nil)
181
188
  item.menu = NSMenu()
182
189
 
183
- let centre = UNUserNotificationCenter.current()
184
- centre.delegate = self
185
- centre.requestAuthorization(options: [.alert, .sound]) { ok, _ in
186
- self.notificationsAllowed = ok
187
- }
190
+ UNUserNotificationCenter.current().delegate = self
188
191
 
189
192
  images.start()
190
193
  watchScreens()
191
194
  watchTerminal()
192
195
 
193
- Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in self.refresh() }
196
+ Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
197
+ self.refresh()
198
+ self.readNotificationPermission()
199
+ }
194
200
  refresh()
201
+ readNotificationPermission()
202
+ }
203
+
204
+ /// Reads the permission on **every** pass, two seconds apart, along with everything else.
205
+ ///
206
+ /// It rides the same beat as the rest on purpose. Somebody who has just switched k0 on in
207
+ /// System Settings comes straight back to this menu to see it change, and a menu that still
208
+ /// says "blocked" reads as broken — they switch it off again, or give up. Half a minute is
209
+ /// long enough for that; two seconds is not. The read is one call to a service already
210
+ /// listening, next to an HTTP request that happens anyway.
211
+ ///
212
+ /// Asking is the part that is kept slow: the request is fired only where the permission has
213
+ /// never been asked for, and at most once every half minute, because a request that fails
214
+ /// leaves the status exactly as it was and would otherwise be retried on every pass.
215
+ ///
216
+ /// The log line is the point of the rest. A permission that was refused and one that was
217
+ /// never asked for are both perfectly silent, and until this was written down the only visible
218
+ /// symptom was a notification that did nothing. `launchd` sends it to
219
+ /// `~/.k0/logs/com.k0.menubar.err.log`.
220
+ func readNotificationPermission() {
221
+ let centre = UNUserNotificationCenter.current()
222
+ centre.getNotificationSettings { settings in
223
+ DispatchQueue.main.async {
224
+ self.record(settings.authorizationStatus)
225
+ guard settings.authorizationStatus == .notDetermined else { return }
226
+ guard self.ticksBeforeAsking <= 0 else {
227
+ self.ticksBeforeAsking -= 1
228
+ return
229
+ }
230
+ self.ticksBeforeAsking = 15 // half a minute, at one read every two seconds
231
+ centre.requestAuthorization(options: [.alert, .sound]) { granted, error in
232
+ if let error {
233
+ NSLog("k0: macOS refused the notification permission: \(error.localizedDescription)")
234
+ } else if !granted {
235
+ NSLog("k0: the notification permission was declined")
236
+ }
237
+ // Whatever the answer, the next read is two seconds away and will report it.
238
+ }
239
+ }
240
+ }
195
241
  }
196
242
 
243
+ private func record(_ status: UNAuthorizationStatus) {
244
+ notificationStatus = status
245
+ guard loggedStatus != status else { return }
246
+ loggedStatus = status
247
+ NSLog("k0: notifications are \(Self.name(status))")
248
+ }
249
+
250
+ private static func name(_ status: UNAuthorizationStatus) -> String {
251
+ switch status {
252
+ case .authorized: return "allowed"
253
+ case .provisional: return "allowed quietly"
254
+ case .denied: return "denied"
255
+ case .notDetermined: return "not granted — macOS has not registered this app"
256
+ default: return "in an unknown state (\(status.rawValue))"
257
+ }
258
+ }
259
+
260
+ /// True only when a notification posted now would actually be delivered.
261
+ var canNotify: Bool { notificationStatus == .authorized || notificationStatus == .provisional }
262
+
197
263
  // MARK: The screen changing under the windows
198
264
 
199
265
  /// You plug a monitor in or out, or change resolution, and the session windows stay where
@@ -381,10 +447,21 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
381
447
  let restart = NSMenuItem(title: "Restart", action: #selector(restartServer), keyEquivalent: "")
382
448
  restart.target = self
383
449
  menu.addItem(restart)
384
- let notifications = NSMenuItem(title: "Notifications", action: #selector(toggleNotifications), keyEquivalent: "")
385
- notifications.target = self
386
- notifications.state = Notifications.enabled ? .on : .off
387
- menu.addItem(notifications)
450
+ // macOS has the last word here, and when it is saying no the switch below would be a lie:
451
+ // ticked, and nothing ever arrives. So the item stops pretending and takes you to the one
452
+ // place where that can be undone.
453
+ if canNotify {
454
+ let notifications = NSMenuItem(title: "Notifications", action: #selector(toggleNotifications), keyEquivalent: "")
455
+ notifications.target = self
456
+ notifications.state = Notifications.enabled ? .on : .off
457
+ menu.addItem(notifications)
458
+ } else {
459
+ let blocked = NSMenuItem(title: "Notifications blocked by macOS…",
460
+ action: #selector(openNotificationSettings),
461
+ keyEquivalent: "")
462
+ blocked.target = self
463
+ menu.addItem(blocked)
464
+ }
388
465
  // The four modes sit next to these because they are the same kind of gesture — something
389
466
  // you leave as it is — but the tick comes from the server rather than from here: over
390
467
  // there the board changes them too, and the menu is rebuilt every two seconds anyway.
@@ -442,6 +519,14 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
442
519
  sender.state = Notifications.enabled ? .on : .off
443
520
  }
444
521
 
522
+ /// Straight to the Notifications pane. The colour of the dot in the menu bar still tells you
523
+ /// what is waiting — what is missing without this permission is only being told without
524
+ /// looking.
525
+ @objc func openNotificationSettings() {
526
+ guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { return }
527
+ NSWorkspace.shared.open(url)
528
+ }
529
+
445
530
  /// The server holds the mode, so here we only ask. The tick moves straight away so the menu
446
531
  /// is not left mute under your finger; the real one arrives with the next round, which is at
447
532
  /// most two seconds later.
@@ -516,7 +601,15 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
516
601
  return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) == expected
517
602
  }
518
603
 
604
+ /// There is one way to notify and no other. There used to be a second one — `osascript -e
605
+ /// 'display notification'` — for when the Mac would not deliver this bundle's own. It is gone:
606
+ /// a banner posted by `osascript` **belongs to Script Editor**, so it carries no card, has no
607
+ /// delegate, and clicking it opened Script Editor's file dialog instead of the terminal. A
608
+ /// notification you cannot click is worse than none, because you go and click it.
609
+ ///
610
+ /// When macOS will not deliver ours, the menu says so — see `buildMenu`.
519
611
  func notify(_ w: Waiting) {
612
+ guard canNotify else { return }
520
613
  let c = UNMutableNotificationContent()
521
614
  c.title = "k0"
522
615
  c.body = "\(w.title) · \(label[w.status] ?? w.status)"
@@ -524,20 +617,10 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
524
617
  c.sound = .default
525
618
  let r = UNNotificationRequest(identifier: "k0-\(w.id)-\(w.status)", content: c, trigger: nil)
526
619
  UNUserNotificationCenter.current().add(r) { error in
527
- // If the Mac will not deliver this bundle's notifications, fall back to the system
528
- // banner: it looks the same, but it is not clickable.
529
- if error != nil || !self.notificationsAllowed { self.fallbackNotify(w) }
620
+ if let error { NSLog("k0: the notification was not delivered: \(error.localizedDescription)") }
530
621
  }
531
622
  }
532
623
 
533
- func fallbackNotify(_ w: Waiting) {
534
- let text = "\(w.title) · \(label[w.status] ?? w.status)"
535
- let p = Process()
536
- p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
537
- p.arguments = ["-e", "display notification \"\(text)\" with title \"k0\""]
538
- try? p.run()
539
- }
540
-
541
624
  // Clicking the notification goes straight to that terminal.
542
625
  func userNotificationCenter(_ center: UNUserNotificationCenter,
543
626
  didReceive response: UNNotificationResponse,
package/menubar/build.sh CHANGED
@@ -22,7 +22,13 @@ swiftc -O -swift-version 5 -o "$BIN" K0MenuBar.swift
22
22
 
23
23
  # Ad-hoc signature: without one, macOS does not deliver the bundle's notifications.
24
24
  codesign --force --sign - --identifier com.k0.menubar "$APP" >/dev/null 2>&1 || {
25
- echo "⚠️ signing failed: notifications will use the fallback banner"
25
+ echo "⚠️ signing failed: macOS will not deliver this app's notifications"
26
26
  }
27
27
 
28
+ # And a signature is not enough on its own: macOS only grants the notification permission to an
29
+ # app it knows about. The bundle never passes through Finder — launchd starts it — so nothing
30
+ # else would ever register it, and the permission would be refused with no explanation.
31
+ LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
32
+ if [ -x "$LSREGISTER" ]; then "$LSREGISTER" -f "$APP" >/dev/null 2>&1 || true; fi
33
+
28
34
  echo "✅ $(pwd)/$APP"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k0-board",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A board of sticky notes that drives your Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -41,6 +41,18 @@ except (ImportError, ValueError) as err: # pragma: no cover - depends on the de
41
41
  print(f"k0: no tray support on this desktop ({err}); the board still works", file=sys.stderr)
42
42
  raise SystemExit(0)
43
43
 
44
+ # libnotify is what makes a notification clickable: `notify-send` can only shout, and a
45
+ # notification you cannot click is one that tells you a session is waiting and then leaves you to
46
+ # find its terminal yourself. Where it is missing we still shout — see `notify`.
47
+ try: # pragma: no cover - depends on the desktop
48
+ gi.require_version("Notify", "0.7")
49
+ from gi.repository import Notify
50
+
51
+ if not Notify.init("k0"):
52
+ Notify = None
53
+ except (ImportError, ValueError, GLib.Error):
54
+ Notify = None
55
+
44
56
 
45
57
  PORT = os.environ.get("K0_PORT", "4319")
46
58
  BASE = f"http://127.0.0.1:{PORT}"
@@ -130,7 +142,35 @@ def api(path, payload=None):
130
142
  return None
131
143
 
132
144
 
133
- def notify(title, body):
145
+ # Notifications still waiting to be clicked. They are held here for one reason: a Notify object
146
+ # that Python garbage-collects takes its action callback with it, and the click then does nothing.
147
+ _shown = []
148
+
149
+
150
+ def _forget(notification):
151
+ if notification in _shown:
152
+ _shown.remove(notification)
153
+
154
+
155
+ def notify(card_id, title, body):
156
+ """Say that a session is waiting, and let the click take you to it.
157
+
158
+ The action is named `default`, which is what a notification daemon calls the click on the
159
+ banner itself rather than on a button. Not every daemon honours actions; where it does not,
160
+ or where libnotify is missing altogether, this falls back to `notify-send`, which still says
161
+ what happened — the terminal is then one click away in the menu instead of none.
162
+ """
163
+ if Notify is not None:
164
+ try:
165
+ note = Notify.Notification.new(title, body, None)
166
+ note.add_action("default", "Open", lambda *_: api(f"/api/card/{card_id}/focus", {}))
167
+ note.connect("closed", _forget)
168
+ _shown.append(note)
169
+ if note.show():
170
+ return
171
+ _forget(note)
172
+ except (GLib.Error, TypeError):
173
+ pass
134
174
  try:
135
175
  subprocess.Popen(["notify-send", "-a", "k0", title, body])
136
176
  except OSError:
@@ -177,7 +217,7 @@ class Tray:
177
217
  if not self.first_pass:
178
218
  for card in waiting:
179
219
  if (card["id"], card["status"]) not in self.seen:
180
- notify(LABELS.get(card["status"], "k0"), card.get("title", ""))
220
+ notify(card["id"], LABELS.get(card["status"], "k0"), card.get("title", ""))
181
221
  self.seen = now
182
222
  self.first_pass = False
183
223
 
@@ -74,6 +74,16 @@ $notify.Visible = $true
74
74
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
75
75
  $notify.ContextMenuStrip = $menu
76
76
 
77
+ # Clicking the balloon brings that session's terminal up front, which is the whole point of being
78
+ # told. Windows shows one balloon at a time and replaces it rather than queueing, so the card the
79
+ # click belongs to is always the last one announced — there is nothing else it could be.
80
+ $script:LastNotified = $null
81
+ $notify.Add_BalloonTipClicked({
82
+ if ($null -ne $script:LastNotified) {
83
+ Invoke-K0 "/api/card/$($script:LastNotified)/focus" @{} | Out-Null
84
+ }
85
+ }) | Out-Null
86
+
77
87
  function Add-K0Item($Text, $Action, [bool]$Enabled = $true) {
78
88
  $item = New-Object System.Windows.Forms.ToolStripMenuItem
79
89
  $item.Text = $Text
@@ -124,6 +134,7 @@ function Show-K0Notifications($Waiting) {
124
134
  foreach ($card in $Waiting) {
125
135
  $key = "$($card.id):$($card.status)"
126
136
  if (-not $script:Seen.ContainsKey($key)) {
137
+ $script:LastNotified = $card.id
127
138
  $notify.BalloonTipTitle = $Labels[$card.status]
128
139
  $notify.BalloonTipText = $card.title
129
140
  $notify.ShowBalloonTip(4000)
package/web/board.css CHANGED
@@ -180,7 +180,10 @@ body {
180
180
  transform-origin: 0 0;
181
181
  will-change: transform;
182
182
  }
183
- .column { flex: 0 0 268px; }
183
+ /* A column is exactly one card wide. The number lives in a variable because the card reads it
184
+ too: a post-it is never taller than it is wide. */
185
+ :root { --card-w: 268px; }
186
+ .column { flex: 0 0 var(--card-w); }
184
187
  .column > h2 {
185
188
  margin: 0 0 14px 4px;
186
189
  font: 600 1.5rem/1 var(--hand);
@@ -278,6 +281,10 @@ body {
278
281
  }
279
282
 
280
283
  /* ── Cards ───────────────────────────────────────────────────────────── */
284
+ /* A square, at most. Text that keeps coming does not push the card taller — a post-it that grows
285
+ with what is written on it stops being a post-it and becomes a page. Everything but the body is
286
+ `flex: none`, so the repository, the title, the age and the buttons are always there to read;
287
+ the body is what gives way. */
281
288
  .postit {
282
289
  background: var(--BACKLOG);
283
290
  border-radius: 2px;
@@ -287,6 +294,16 @@ body {
287
294
  cursor: default;
288
295
  transition: transform .12s ease, box-shadow .12s ease;
289
296
  position: relative;
297
+ display: flex;
298
+ flex-direction: column;
299
+ max-height: var(--card-w);
300
+ overflow: hidden;
301
+ }
302
+ .postit > * { flex: none; }
303
+ .postit .body {
304
+ flex: 1 1 auto;
305
+ min-height: 0;
306
+ overflow: hidden;
290
307
  }
291
308
  .postit:hover { box-shadow: 3px 6px 14px rgba(0, 0, 0, .24); z-index: 2; }
292
309
  .postit.ASK { background: var(--ASK); }
@@ -321,6 +338,12 @@ body {
321
338
  font-size: .94rem;
322
339
  letter-spacing: 0;
323
340
  word-break: break-word;
341
+ /* Two lines and an ellipsis. A session name is a name: if it does not fit in two lines of
342
+ monospace it was never going to be read at a glance anyway. */
343
+ display: -webkit-box;
344
+ -webkit-line-clamp: 2;
345
+ -webkit-box-orient: vertical;
346
+ overflow: hidden;
324
347
  }
325
348
  /* What the card is about. On imported sessions it is the only thing that says what happened in
326
349
  there, so it sits right under the title — and in italics, so it is not mistaken for the prompt,
@@ -331,7 +354,7 @@ body {
331
354
  color: rgba(0, 0, 0, .56);
332
355
  margin-top: 6px;
333
356
  display: -webkit-box;
334
- -webkit-line-clamp: 4;
357
+ -webkit-line-clamp: 2;
335
358
  -webkit-box-orient: vertical;
336
359
  overflow: hidden;
337
360
  }
@@ -342,7 +365,7 @@ body {
342
365
  /* The prompt's line breaks stay line breaks: a list has to read like a list. */
343
366
  white-space: pre-wrap;
344
367
  display: -webkit-box;
345
- -webkit-line-clamp: 4;
368
+ -webkit-line-clamp: 3;
346
369
  -webkit-box-orient: vertical;
347
370
  overflow: hidden;
348
371
  }
@@ -557,5 +580,6 @@ html[data-driving] .postit .corner svg { width: 20px; height: 20px; }
557
580
  html[data-driving] .zoom button { width: 40px; height: 40px; }
558
581
  html[data-driving] .zoom button svg { width: 22px; height: 22px; }
559
582
 
560
- /* The column is in pixels too: with the text at 22 the cards would choke inside it. */
561
- html[data-driving] .column { flex: 0 0 340px; }
583
+ /* The card is in pixels too: with the text at 22 it would choke inside 268. And since the card is
584
+ never taller than it is wide, raising this raises the ceiling with it. */
585
+ html[data-driving] { --card-w: 340px; }
package/web/board.js CHANGED
@@ -84,8 +84,17 @@ function postit(card, now) {
84
84
  card.id
85
85
  )}${loadChip(card)}</div>
86
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>` : ''}
87
+ ${
88
+ // Description and prompt travel together in one box, and that box is the only part of the
89
+ // card allowed to shrink: whatever the text does, the repository, the title, the age and
90
+ // the buttons stay where they are.
91
+ card.description || card.prompt
92
+ ? `<div class="body">
93
+ ${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
94
+ ${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
95
+ </div>`
96
+ : ''
97
+ }
89
98
  <div class="foot">
90
99
  ${dead ? '<span class="dead">session closed</span>' : ''}
91
100
  <span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
@@ -419,7 +428,6 @@ function openEditor(card, presetPath = null) {
419
428
  $('#f-project').disabled = !!card?.session_id
420
429
  $('#f-project').classList.remove('bad')
421
430
  $('#f-title').value = card?.title ?? ''
422
- $('#f-desc').value = card?.description ?? ''
423
431
  $('#f-prompt').value = card?.prompt ?? ''
424
432
  $('#f-delete').style.display = card ? '' : 'none'
425
433
  // A live session is not restarted: there is only saving to do there.
@@ -513,9 +521,12 @@ async function save() {
513
521
  toast('A title is required')
514
522
  return null
515
523
  }
524
+ // No description here on purpose: a card written by hand does not need one. The field only
525
+ // ever mattered for an imported session, where it is the one line saying what happened in
526
+ // there — and that one is written by the import. Leaving the key out of the payload is what
527
+ // keeps it: `patchCard` writes only the keys it is given.
516
528
  const payload = {
517
529
  title,
518
- description: $('#f-desc').value.trim(),
519
530
  project_path: chosenProject,
520
531
  prompt: $('#f-prompt').value,
521
532
  }
package/web/index.html CHANGED
@@ -80,12 +80,8 @@
80
80
  <small>this is also the session name</small>
81
81
  </label>
82
82
 
83
- <label>Description
84
- <textarea id="f-desc" rows="2" placeholder="a line or two about this session"></textarea>
85
- </label>
86
-
87
83
  <label>What Claude should do when it starts
88
- <textarea id="f-prompt" rows="6" placeholder="you can leave this empty"></textarea>
84
+ <textarea id="f-prompt" rows="6" placeholder="you can leave this empty and type straight into Claude Code"></textarea>
89
85
  </label>
90
86
 
91
87
  <footer>