k0-board 0.1.1 → 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 +29 -1
- package/README.md +17 -4
- package/menubar/K0MenuBar.swift +105 -22
- package/menubar/build.sh +7 -1
- package/package.json +1 -1
- package/platform/linux/tray.py +42 -2
- package/platform/win32/tray.ps1 +11 -0
- package/web/board.css +29 -5
- package/web/board.js +15 -4
- package/web/index.html +1 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ 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
|
+
|
|
9
36
|
## [0.1.1]
|
|
10
37
|
|
|
11
38
|
Documentation, and how a release is made. Nothing about k0 itself changed.
|
|
@@ -65,6 +92,7 @@ here is everything that had to change for it to be somebody else's tool as well.
|
|
|
65
92
|
- The personal fixtures k0 grew up on. `test/clean.test.mjs` now fails the build if any of them
|
|
66
93
|
come back.
|
|
67
94
|
|
|
68
|
-
[Unreleased]: https://github.com/alessio-ragni/k0-board/compare/v0.1.
|
|
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
|
|
69
97
|
[0.1.1]: https://github.com/alessio-ragni/k0-board/compare/v0.1.0...v0.1.1
|
|
70
98
|
[0.1.0]: https://github.com/alessio-ragni/k0-board/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ The board lives at <http://k0.localhost:4319>.
|
|
|
108
108
|
| Keep the machine awake | ✅ | ✅ | ✅ |
|
|
109
109
|
| Keep working with the lid closed | ✅ | ❌ system setting | ❌ system setting |
|
|
110
110
|
| Tray icon and notifications | ✅ | ✅ GNOME/KDE | ✅ |
|
|
111
|
+
| Clicking a notification takes you to that terminal | ✅ needs the permission | ✅ if the desktop honours actions | ✅ |
|
|
111
112
|
|
|
112
113
|
**macOS is the only platform the author has.** Linux and Windows are written from the
|
|
113
114
|
documentation and reviewed carefully, and the logic they share with macOS is tested on all three
|
|
@@ -146,8 +147,9 @@ k0 makes **no network requests**. Not one, not even for a font.
|
|
|
146
147
|
### The board
|
|
147
148
|
|
|
148
149
|
- **+** — at the left, next to k0, and next to every repository name on the board: that one opens
|
|
149
|
-
a card with the repository already chosen.
|
|
150
|
-
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.
|
|
151
153
|
|
|
152
154
|
The repository has to be **picked from the list** (searchable, most used at the top; arrow keys
|
|
153
155
|
to move, Enter takes the highlighted row): a name typed by hand does not count. On leaving the
|
|
@@ -380,7 +382,9 @@ numbers.
|
|
|
380
382
|
|
|
381
383
|
The difference between the two is the writing. The installer uses the name the session already
|
|
382
384
|
had, or the first few words of the first prompt. `/k0-import` has Claude read the conversation and
|
|
383
|
-
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.
|
|
384
388
|
|
|
385
389
|
Imported cards are born yellow — *Your turn*: **Resume** reopens the conversation where it was,
|
|
386
390
|
**Done** clears it away. Being dead sessions they do not light the tray icon. Running it again
|
|
@@ -588,6 +592,7 @@ platform/
|
|
|
588
592
|
contract.js the line between k0 and the operating system, and what an adapter must fill in
|
|
589
593
|
index.js picks the adapter, and reports what this machine can and cannot do
|
|
590
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)
|
|
591
596
|
linux/ tmux and any emulator, systemd-inhibit, systemd user units, a GTK tray
|
|
592
597
|
win32/ PowerShell and Windows Terminal, execution state, Task Scheduler, a WinForms tray
|
|
593
598
|
shared/ what more than one of them needs: tmux, running commands, reading a process table
|
|
@@ -605,7 +610,9 @@ server/
|
|
|
605
610
|
machine.js the only one that looks at processes and memory: what it all costs, and who is costing it
|
|
606
611
|
pdf.js the document on paper, printed by a headless browser
|
|
607
612
|
web/ the two pages (html, css, js served exactly as they are)
|
|
608
|
-
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
|
|
609
616
|
files.html the file viewer — files.js, files.css
|
|
610
617
|
base.css colours, fonts and scale: the house variables, shared by both pages
|
|
611
618
|
md.js markdown laid out, written by hand because nothing here is compiled
|
|
@@ -634,6 +641,12 @@ certain), `-n` for the name, `--resume` to pick one up, and `--permission-mode p
|
|
|
634
641
|
|
|
635
642
|
Field notes the code takes for granted.
|
|
636
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.
|
|
637
650
|
- **`--dangerously-skip-permissions` switches off plan mode.** No error, no warning, and the order
|
|
638
651
|
of the two flags changes nothing — the session starts in `bypassPermissions` and there is no
|
|
639
652
|
trace of a plan. It looks like it works, which is what makes it a trap. The only way to really
|
package/menubar/K0MenuBar.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/platform/linux/tray.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/platform/win32/tray.ps1
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
561
|
-
|
|
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
|
-
${
|
|
88
|
-
|
|
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>
|