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.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// k0 — the icon in the menu bar.
|
|
2
|
+
//
|
|
3
|
+
// It knows nothing about sessions, transcripts or terminals: it asks the server what is waiting
|
|
4
|
+
// for you, tints the icon with the colour of the most urgent thing, and draws a short menu. All
|
|
5
|
+
// the intelligence stays in the server; this is only the face.
|
|
6
|
+
|
|
7
|
+
import AppKit
|
|
8
|
+
import UserNotifications
|
|
9
|
+
|
|
10
|
+
let port = ProcessInfo.processInfo.environment["K0_PORT"] ?? "4319"
|
|
11
|
+
// API calls go to the number, which depends on no name at all. The board in the browser opens
|
|
12
|
+
// with k0.localhost instead, so the browser learns that "k0" means this.
|
|
13
|
+
let base = "http://127.0.0.1:\(port)"
|
|
14
|
+
let boardURL = "http://k0.localhost:\(port)"
|
|
15
|
+
|
|
16
|
+
/// Where k0 keeps the things that outlive a reinstall. Mirrors `server/paths.js`.
|
|
17
|
+
let k0Home: URL = {
|
|
18
|
+
if let custom = ProcessInfo.processInfo.environment["K0_HOME"] {
|
|
19
|
+
return URL(fileURLWithPath: custom)
|
|
20
|
+
}
|
|
21
|
+
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".k0")
|
|
22
|
+
}()
|
|
23
|
+
|
|
24
|
+
// MARK: - What the server answers
|
|
25
|
+
|
|
26
|
+
struct Waiting: Decodable {
|
|
27
|
+
let id: Int
|
|
28
|
+
let title: String
|
|
29
|
+
let project: String
|
|
30
|
+
let status: String
|
|
31
|
+
let window: String?
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
struct Status: Decodable {
|
|
35
|
+
let urgent: String?
|
|
36
|
+
let waiting: [Waiting]
|
|
37
|
+
/// Which of the four modes we are in: `sleep`, `away`, `nerd`, `driving`. The server holds
|
|
38
|
+
/// it, not us: it is the same control that is on the board, and two separate copies of the
|
|
39
|
+
/// same thing would end up disagreeing.
|
|
40
|
+
///
|
|
41
|
+
/// Optional on purpose: a server older than this icon does not send the field, and with a
|
|
42
|
+
/// plain `String` the whole response would fail to decode — the icon would say "server not
|
|
43
|
+
/// responding" because of one field it could not find.
|
|
44
|
+
let mode: String?
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// The four modes, in the order of the scale: sleepiest to most awake. The words are the
|
|
48
|
+
/// server's (`server/mode.js`); the titles are what the user reads.
|
|
49
|
+
enum Modes {
|
|
50
|
+
static let all: [(String, String)] = [
|
|
51
|
+
("sleep", "Sleep"),
|
|
52
|
+
("away", "Away"),
|
|
53
|
+
("nerd", "Nerd"),
|
|
54
|
+
("driving", "Driving"),
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The same colours as the board, but saturated: in a menu bar, pastels disappear.
|
|
59
|
+
func colour(_ state: String?) -> NSColor {
|
|
60
|
+
switch state {
|
|
61
|
+
case "ASK": return NSColor(srgbRed: 0.94, green: 0.27, blue: 0.27, alpha: 1) // red
|
|
62
|
+
case "PLANNED": return NSColor(srgbRed: 0.98, green: 0.45, blue: 0.09, alpha: 1) // orange
|
|
63
|
+
case "IDLE": return NSColor(srgbRed: 0.92, green: 0.70, blue: 0.03, alpha: 1) // yellow
|
|
64
|
+
case "WORKING",
|
|
65
|
+
"PLANNING": return NSColor(srgbRed: 0.23, green: 0.51, blue: 0.96, alpha: 1) // blue
|
|
66
|
+
default: return NSColor(srgbRed: 0.61, green: 0.64, blue: 0.69, alpha: 1) // grey
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The same labels as the dashboard.
|
|
71
|
+
let label: [String: String] = [
|
|
72
|
+
"ASK": "Needs answer",
|
|
73
|
+
"PLANNED": "Needs approval",
|
|
74
|
+
"IDLE": "Your turn",
|
|
75
|
+
"WORKING": "Working",
|
|
76
|
+
"PLANNING": "Planning",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/// The notifications switch, which stays as it was left even after a restart. Switched off, the
|
|
80
|
+
/// icon still changes colour: what disappears is only the notification.
|
|
81
|
+
enum Notifications {
|
|
82
|
+
static let key = "k0.notifications"
|
|
83
|
+
|
|
84
|
+
static var enabled: Bool {
|
|
85
|
+
get {
|
|
86
|
+
if UserDefaults.standard.object(forKey: key) == nil { return true } // on by default
|
|
87
|
+
return UserDefaults.standard.bool(forKey: key)
|
|
88
|
+
}
|
|
89
|
+
set { UserDefaults.standard.set(newValue, forKey: key) }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MARK: - Cmd+V for images
|
|
94
|
+
|
|
95
|
+
/// In Terminal, Cmd+V with an image on the clipboard does nothing: Terminal swallows the
|
|
96
|
+
/// keystroke before Claude Code sees it, and an image would not go through a terminal anyway.
|
|
97
|
+
/// Inside an editor it works only because an extension is doing this same job.
|
|
98
|
+
///
|
|
99
|
+
/// The keystroke is not intercepted here — that road wants the Accessibility permission and
|
|
100
|
+
/// needs macOS to make it stick, which is far from a given. Instead this works on the clipboard,
|
|
101
|
+
/// which asks for no permission at all: **while Terminal is in front**, an image on the
|
|
102
|
+
/// clipboard is saved to a file and its path is put in its place. So your ordinary Cmd+V pastes
|
|
103
|
+
/// the path, and Claude Code recognises it and attaches it as `[Image #1]`. The moment you leave
|
|
104
|
+
/// Terminal the image goes back on the clipboard exactly as you copied it.
|
|
105
|
+
final class ImagePaste {
|
|
106
|
+
private var image: Data? // the real image, set aside
|
|
107
|
+
private var ourChange = -1 // the changeCount of the swap we made
|
|
108
|
+
private var counter = 0
|
|
109
|
+
|
|
110
|
+
private let folder = k0Home.appendingPathComponent("cache/images")
|
|
111
|
+
|
|
112
|
+
func start() {
|
|
113
|
+
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
114
|
+
clearOld()
|
|
115
|
+
Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in self.tick() }
|
|
116
|
+
NSLog("k0: image pasting active (no permission required)")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func tick() {
|
|
120
|
+
let pb = NSPasteboard.general
|
|
121
|
+
let inTerminal = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Terminal"
|
|
122
|
+
|
|
123
|
+
// The clipboard changed at somebody else's hand: read what is on it now.
|
|
124
|
+
if pb.changeCount != ourChange {
|
|
125
|
+
image = imageOnClipboard(pb)
|
|
126
|
+
ourChange = -1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
guard let png = image else { return }
|
|
130
|
+
|
|
131
|
+
if inTerminal && ourChange == -1 {
|
|
132
|
+
// You come into Terminal with an image copied: the path goes in its place.
|
|
133
|
+
counter += 1
|
|
134
|
+
let file = folder.appendingPathComponent("clipboard-\(counter).png")
|
|
135
|
+
guard (try? png.write(to: file)) != nil else { return }
|
|
136
|
+
pb.clearContents()
|
|
137
|
+
pb.setString(file.path, forType: .string)
|
|
138
|
+
ourChange = pb.changeCount
|
|
139
|
+
} else if !inTerminal && ourChange != -1 {
|
|
140
|
+
// You have left Terminal: the image goes back where it was.
|
|
141
|
+
pb.clearContents()
|
|
142
|
+
pb.setData(png, forType: .png)
|
|
143
|
+
ourChange = pb.changeCount
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Only when the clipboard really holds an image and nothing else: if there is text, it is
|
|
148
|
+
/// an ordinary paste and must not be touched.
|
|
149
|
+
private func imageOnClipboard(_ pb: NSPasteboard) -> Data? {
|
|
150
|
+
guard pb.string(forType: .string) == nil else { return nil }
|
|
151
|
+
if let png = pb.data(forType: .png) { return png }
|
|
152
|
+
guard let tiff = pb.data(forType: .tiff),
|
|
153
|
+
let rep = NSBitmapImageRep(data: tiff) else { return nil }
|
|
154
|
+
return rep.representation(using: .png, properties: [:])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Yesterday's images are of no use to anybody.
|
|
158
|
+
private func clearOld() {
|
|
159
|
+
let cutoff = Date().addingTimeInterval(-86400)
|
|
160
|
+
let f = FileManager.default
|
|
161
|
+
guard let files = try? f.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey])
|
|
162
|
+
else { return }
|
|
163
|
+
for u in files {
|
|
164
|
+
let date = (try? u.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
|
165
|
+
if let date, date < cutoff { try? f.removeItem(at: u) }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// MARK: - The app
|
|
171
|
+
|
|
172
|
+
final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
173
|
+
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
174
|
+
var seen: [Int: String] = [:] // the last known status of every card
|
|
175
|
+
var firstPass = true // no notifications on the first pass: that would be noise at startup
|
|
176
|
+
var notificationsAllowed = false
|
|
177
|
+
let images = ImagePaste()
|
|
178
|
+
|
|
179
|
+
func applicationDidFinishLaunching(_ n: Notification) {
|
|
180
|
+
item.button?.image = icon(nil)
|
|
181
|
+
item.menu = NSMenu()
|
|
182
|
+
|
|
183
|
+
let centre = UNUserNotificationCenter.current()
|
|
184
|
+
centre.delegate = self
|
|
185
|
+
centre.requestAuthorization(options: [.alert, .sound]) { ok, _ in
|
|
186
|
+
self.notificationsAllowed = ok
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
images.start()
|
|
190
|
+
watchScreens()
|
|
191
|
+
watchTerminal()
|
|
192
|
+
|
|
193
|
+
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in self.refresh() }
|
|
194
|
+
refresh()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// MARK: The screen changing under the windows
|
|
198
|
+
|
|
199
|
+
/// You plug a monitor in or out, or change resolution, and the session windows stay where
|
|
200
|
+
/// they were: out of place, or off screen entirely. This hears the news and asks the server
|
|
201
|
+
/// to put them back.
|
|
202
|
+
///
|
|
203
|
+
/// It falls to the menu bar icon because it is the only piece of k0 that sees that
|
|
204
|
+
/// notification: the server is plain Node, and to notice by itself it would have to
|
|
205
|
+
/// interrogate the screen constantly, which is a waste for something that happens twice a day.
|
|
206
|
+
func watchScreens() {
|
|
207
|
+
NotificationCenter.default.addObserver(
|
|
208
|
+
forName: NSApplication.didChangeScreenParametersNotification,
|
|
209
|
+
object: nil,
|
|
210
|
+
queue: .main
|
|
211
|
+
) { _ in self.relayoutWindows() }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// The debounce is not general caution: the notification arrives in bursts — plugging in a
|
|
215
|
+
/// monitor sends several in a row — and macOS takes a moment to settle the new arrangement
|
|
216
|
+
/// anyway. Moving on the first one would centre everything on the old screen.
|
|
217
|
+
private var screenTimer: Timer?
|
|
218
|
+
func relayoutWindows() {
|
|
219
|
+
screenTimer?.invalidate()
|
|
220
|
+
screenTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { _ in
|
|
221
|
+
guard let url = URL(string: "\(base)/api/windows/relayout") else { return }
|
|
222
|
+
var req = URLRequest(url: url)
|
|
223
|
+
req.httpMethod = "POST"
|
|
224
|
+
URLSession.shared.dataTask(with: req).resume()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: Terminal quitting
|
|
229
|
+
|
|
230
|
+
/// Shift+Enter starts a new line because the Terminal profile holds a key binding that sends
|
|
231
|
+
/// Esc+Enter (the installer writes it; the reason is over there). The point is **when** to
|
|
232
|
+
/// write it: Terminal reads those bindings only at startup, and on quitting it rewrites its
|
|
233
|
+
/// preferences from what it had in memory — erasing our entry. Written while Terminal is
|
|
234
|
+
/// running it is written for nothing; the only moment it survives is **right after Terminal
|
|
235
|
+
/// has died**, and from there until its next launch nobody touches it again.
|
|
236
|
+
///
|
|
237
|
+
/// It falls to the menu bar icon for the same reason as the screens: it is the only piece of
|
|
238
|
+
/// k0 that sees applications leave. The real work stays in `k0-board shift-enter`, so the
|
|
239
|
+
/// logic about profiles and preferences is written in one place only.
|
|
240
|
+
func watchTerminal() {
|
|
241
|
+
NSWorkspace.shared.notificationCenter.addObserver(
|
|
242
|
+
forName: NSWorkspace.didTerminateApplicationNotification,
|
|
243
|
+
object: nil,
|
|
244
|
+
queue: .main
|
|
245
|
+
) { n in
|
|
246
|
+
let app = n.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
|
|
247
|
+
guard app?.bundleIdentifier == "com.apple.Terminal" else { return }
|
|
248
|
+
self.restoreShiftEnter(in: 2)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A Mac just switched on, or k0 installed with Terminal already closed: now is the good
|
|
252
|
+
// moment, and no notification is coming to tell us.
|
|
253
|
+
if !terminalRunning { restoreShiftEnter(in: 0) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
var terminalRunning: Bool {
|
|
257
|
+
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.Terminal" }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private var shiftEnterInFlight = false
|
|
261
|
+
|
|
262
|
+
/// The wait is not general caution: Terminal, as it dies, is still flushing its preferences
|
|
263
|
+
/// to disk. Writing in the middle of that means being overwritten, which is exactly the
|
|
264
|
+
/// trouble this is running away from.
|
|
265
|
+
func restoreShiftEnter(in seconds: TimeInterval) {
|
|
266
|
+
guard !shiftEnterInFlight else { return }
|
|
267
|
+
shiftEnterInFlight = true
|
|
268
|
+
|
|
269
|
+
// <k0>/menubar/k0.app → two steps back and we are at <k0>/bin/k0-board.js.
|
|
270
|
+
let cli = Bundle.main.bundleURL
|
|
271
|
+
.deletingLastPathComponent()
|
|
272
|
+
.deletingLastPathComponent()
|
|
273
|
+
.appendingPathComponent("bin/k0-board.js")
|
|
274
|
+
|
|
275
|
+
// The installer puts the interpreter's real path in the service's environment, because
|
|
276
|
+
// under launchd the PATH is barely more than /usr/bin:/bin and a version manager's node
|
|
277
|
+
// would not be found at all.
|
|
278
|
+
let node = ProcessInfo.processInfo.environment["K0_NODE"] ?? "/usr/bin/env"
|
|
279
|
+
let args = node == "/usr/bin/env" ? ["node", cli.path, "shift-enter"] : [cli.path, "shift-enter"]
|
|
280
|
+
|
|
281
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + seconds) {
|
|
282
|
+
defer { DispatchQueue.main.async { self.shiftEnterInFlight = false } }
|
|
283
|
+
|
|
284
|
+
guard FileManager.default.isReadableFile(atPath: cli.path) else {
|
|
285
|
+
NSLog("k0: Shift+Enter, cannot find \(cli.path) — was the app moved out of k0?")
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
let p = Process()
|
|
289
|
+
p.executableURL = URL(fileURLWithPath: node)
|
|
290
|
+
p.arguments = args
|
|
291
|
+
do {
|
|
292
|
+
try p.run()
|
|
293
|
+
p.waitUntilExit()
|
|
294
|
+
NSLog("k0: Terminal quit, Shift+Enter put back in the profile (exit \(p.terminationStatus))")
|
|
295
|
+
} catch {
|
|
296
|
+
NSLog("k0: Shift+Enter not put back — \(error.localizedDescription)")
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// MARK: Icon
|
|
302
|
+
|
|
303
|
+
/// A small rounded square like a sticky note, tinted with the most urgent status.
|
|
304
|
+
func icon(_ state: String?) -> NSImage {
|
|
305
|
+
let side: CGFloat = 14
|
|
306
|
+
let img = NSImage(size: NSSize(width: side, height: side))
|
|
307
|
+
img.lockFocus()
|
|
308
|
+
let r = NSBezierPath(roundedRect: NSRect(x: 1, y: 1, width: side - 2, height: side - 2),
|
|
309
|
+
xRadius: 2.5, yRadius: 2.5)
|
|
310
|
+
if state == nil {
|
|
311
|
+
// Nothing going on: outline only, so it does not catch the eye.
|
|
312
|
+
colour(nil).setStroke()
|
|
313
|
+
r.lineWidth = 1.5
|
|
314
|
+
r.stroke()
|
|
315
|
+
} else {
|
|
316
|
+
colour(state).setFill()
|
|
317
|
+
r.fill()
|
|
318
|
+
}
|
|
319
|
+
img.unlockFocus()
|
|
320
|
+
img.isTemplate = false
|
|
321
|
+
return img
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// MARK: The refresh round
|
|
325
|
+
|
|
326
|
+
func refresh() {
|
|
327
|
+
guard let url = URL(string: "\(base)/api/status") else { return }
|
|
328
|
+
var req = URLRequest(url: url)
|
|
329
|
+
req.timeoutInterval = 3
|
|
330
|
+
URLSession.shared.dataTask(with: req) { data, _, _ in
|
|
331
|
+
let status: Status? = data.flatMap { try? JSONDecoder().decode(Status.self, from: $0) }
|
|
332
|
+
DispatchQueue.main.async { self.draw(status) }
|
|
333
|
+
}.resume()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func draw(_ status: Status?) {
|
|
337
|
+
// Server down: a dark icon and a menu that says so, instead of disappearing.
|
|
338
|
+
guard let status else {
|
|
339
|
+
item.button?.image = icon(nil)
|
|
340
|
+
item.button?.toolTip = "k0 — server not responding"
|
|
341
|
+
buildMenu(nil)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
item.button?.image = icon(status.urgent)
|
|
346
|
+
item.button?.toolTip = status.waiting.isEmpty
|
|
347
|
+
? "k0 — nothing waiting for you"
|
|
348
|
+
: "k0 — \(status.waiting.count) waiting"
|
|
349
|
+
buildMenu(status)
|
|
350
|
+
notifyNew(status)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// MARK: Menu
|
|
354
|
+
|
|
355
|
+
func buildMenu(_ status: Status?) {
|
|
356
|
+
let menu = NSMenu()
|
|
357
|
+
|
|
358
|
+
if status == nil {
|
|
359
|
+
let m = NSMenuItem(title: "Server not responding", action: nil, keyEquivalent: "")
|
|
360
|
+
m.isEnabled = false
|
|
361
|
+
menu.addItem(m)
|
|
362
|
+
} else if status!.waiting.isEmpty {
|
|
363
|
+
let m = NSMenuItem(title: "Nothing waiting for you", action: nil, keyEquivalent: "")
|
|
364
|
+
m.isEnabled = false
|
|
365
|
+
menu.addItem(m)
|
|
366
|
+
} else {
|
|
367
|
+
for w in status!.waiting {
|
|
368
|
+
let m = NSMenuItem(title: "\(w.title) · \(label[w.status] ?? w.status)",
|
|
369
|
+
action: #selector(goToSession(_:)), keyEquivalent: "")
|
|
370
|
+
m.target = self
|
|
371
|
+
m.tag = w.id
|
|
372
|
+
m.image = dot(w.status)
|
|
373
|
+
menu.addItem(m)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
menu.addItem(.separator())
|
|
378
|
+
let board = NSMenuItem(title: "Dashboard", action: #selector(openBoard), keyEquivalent: "")
|
|
379
|
+
board.target = self
|
|
380
|
+
menu.addItem(board)
|
|
381
|
+
let restart = NSMenuItem(title: "Restart", action: #selector(restartServer), keyEquivalent: "")
|
|
382
|
+
restart.target = self
|
|
383
|
+
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)
|
|
388
|
+
// The four modes sit next to these because they are the same kind of gesture — something
|
|
389
|
+
// you leave as it is — but the tick comes from the server rather than from here: over
|
|
390
|
+
// there the board changes them too, and the menu is rebuilt every two seconds anyway.
|
|
391
|
+
// With the server down the items are left without an action, so macOS greys them out by
|
|
392
|
+
// itself: `isEnabled` would not be enough, automatic menu enabling takes it straight back.
|
|
393
|
+
//
|
|
394
|
+
// All this says is where we are: whether the mode is holding all the way to the closed
|
|
395
|
+
// lid is the board's story, which has the room to write it in words.
|
|
396
|
+
menu.addItem(.separator())
|
|
397
|
+
// With no answer from the server — or with a server older than this field — nothing is
|
|
398
|
+
// ticked: guessing a mode and showing it as true would be worse than saying nothing,
|
|
399
|
+
// because that is exactly the question the menu is there to answer.
|
|
400
|
+
let current = status.flatMap { $0.mode }
|
|
401
|
+
for (key, title) in Modes.all {
|
|
402
|
+
let entry = NSMenuItem(title: title,
|
|
403
|
+
action: status == nil ? nil : #selector(chooseMode(_:)),
|
|
404
|
+
keyEquivalent: "")
|
|
405
|
+
entry.target = self
|
|
406
|
+
entry.representedObject = key
|
|
407
|
+
entry.state = current == key ? .on : .off
|
|
408
|
+
menu.addItem(entry)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
menu.addItem(.separator())
|
|
412
|
+
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
|
|
413
|
+
quitItem.target = self
|
|
414
|
+
menu.addItem(quitItem)
|
|
415
|
+
|
|
416
|
+
item.menu = menu
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func dot(_ state: String) -> NSImage {
|
|
420
|
+
let d: CGFloat = 9
|
|
421
|
+
let img = NSImage(size: NSSize(width: d, height: d))
|
|
422
|
+
img.lockFocus()
|
|
423
|
+
colour(state).setFill()
|
|
424
|
+
NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
|
|
425
|
+
img.unlockFocus()
|
|
426
|
+
return img
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// MARK: Actions
|
|
430
|
+
|
|
431
|
+
@objc func goToSession(_ sender: NSMenuItem) { focus(sender.tag) }
|
|
432
|
+
|
|
433
|
+
func focus(_ id: Int) {
|
|
434
|
+
guard let url = URL(string: "\(base)/api/card/\(id)/focus") else { return }
|
|
435
|
+
var req = URLRequest(url: url)
|
|
436
|
+
req.httpMethod = "POST"
|
|
437
|
+
URLSession.shared.dataTask(with: req).resume()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@objc func toggleNotifications(_ sender: NSMenuItem) {
|
|
441
|
+
Notifications.enabled.toggle()
|
|
442
|
+
sender.state = Notifications.enabled ? .on : .off
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// The server holds the mode, so here we only ask. The tick moves straight away so the menu
|
|
446
|
+
/// is not left mute under your finger; the real one arrives with the next round, which is at
|
|
447
|
+
/// most two seconds later.
|
|
448
|
+
///
|
|
449
|
+
/// Clicking the one already ticked does nothing: you leave a mode, you do not switch it off,
|
|
450
|
+
/// and there is always one lit.
|
|
451
|
+
@objc func chooseMode(_ sender: NSMenuItem) {
|
|
452
|
+
guard let chosen = sender.representedObject as? String, sender.state != .on else { return }
|
|
453
|
+
for entry in sender.menu?.items ?? [] where entry.representedObject is String {
|
|
454
|
+
entry.state = (entry.representedObject as? String) == chosen ? .on : .off
|
|
455
|
+
}
|
|
456
|
+
guard let url = URL(string: "\(base)/api/mode") else { return }
|
|
457
|
+
var req = URLRequest(url: url)
|
|
458
|
+
req.httpMethod = "POST"
|
|
459
|
+
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
460
|
+
req.httpBody = try? JSONSerialization.data(withJSONObject: ["mode": chosen])
|
|
461
|
+
URLSession.shared.dataTask(with: req) { _, _, _ in
|
|
462
|
+
DispatchQueue.main.async { self.refresh() }
|
|
463
|
+
}.resume()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@objc func openBoard() {
|
|
467
|
+
if let url = URL(string: boardURL) { NSWorkspace.shared.open(url) }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
@objc func restartServer() {
|
|
471
|
+
let uid = getuid()
|
|
472
|
+
let p = Process()
|
|
473
|
+
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
|
474
|
+
p.arguments = ["kickstart", "-k", "gui/\(uid)/com.k0.server"]
|
|
475
|
+
try? p.run()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@objc func quit() { NSApp.terminate(nil) }
|
|
479
|
+
|
|
480
|
+
// MARK: Notifications
|
|
481
|
+
|
|
482
|
+
/// Notifies only the MOVE into "Needs answer" or "Needs approval", and only when you are not
|
|
483
|
+
/// already looking at that very terminal.
|
|
484
|
+
func notifyNew(_ status: Status) {
|
|
485
|
+
var now: [Int: String] = [:]
|
|
486
|
+
for w in status.waiting { now[w.id] = w.status }
|
|
487
|
+
|
|
488
|
+
// The seen statuses are updated even with notifications off: switching them back on must
|
|
489
|
+
// not deliver everything that happened in the meantime all at once.
|
|
490
|
+
if !firstPass && Notifications.enabled {
|
|
491
|
+
for w in status.waiting where w.status == "ASK" || w.status == "PLANNED" {
|
|
492
|
+
guard seen[w.id] != w.status else { continue } // already notified
|
|
493
|
+
guard !amLookingAt(w.window) else { continue }
|
|
494
|
+
notify(w)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
seen = now
|
|
498
|
+
firstPass = false
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/// True when Terminal is the front application and its front window is this one.
|
|
502
|
+
func amLookingAt(_ window: String?) -> Bool {
|
|
503
|
+
guard NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Terminal" else {
|
|
504
|
+
return false
|
|
505
|
+
}
|
|
506
|
+
guard let window, let expected = Int(window) else { return false }
|
|
507
|
+
let p = Process()
|
|
508
|
+
p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
509
|
+
p.arguments = ["-e", "tell application \"Terminal\" to get id of front window"]
|
|
510
|
+
let out = Pipe()
|
|
511
|
+
p.standardOutput = out
|
|
512
|
+
p.standardError = Pipe()
|
|
513
|
+
do { try p.run() } catch { return false }
|
|
514
|
+
p.waitUntilExit()
|
|
515
|
+
let text = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
516
|
+
return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) == expected
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
func notify(_ w: Waiting) {
|
|
520
|
+
let c = UNMutableNotificationContent()
|
|
521
|
+
c.title = "k0"
|
|
522
|
+
c.body = "\(w.title) · \(label[w.status] ?? w.status)"
|
|
523
|
+
c.userInfo = ["cardId": w.id]
|
|
524
|
+
c.sound = .default
|
|
525
|
+
let r = UNNotificationRequest(identifier: "k0-\(w.id)-\(w.status)", content: c, trigger: nil)
|
|
526
|
+
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) }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
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
|
+
// Clicking the notification goes straight to that terminal.
|
|
542
|
+
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
543
|
+
didReceive response: UNNotificationResponse,
|
|
544
|
+
withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
545
|
+
if let id = response.notification.request.content.userInfo["cardId"] as? Int { focus(id) }
|
|
546
|
+
completionHandler()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Show it even when k0 is "in the foreground" (it never really is, it is an accessory).
|
|
550
|
+
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
551
|
+
willPresent notification: UNNotification,
|
|
552
|
+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
|
553
|
+
completionHandler([.banner, .sound])
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let app = NSApplication.shared
|
|
558
|
+
let delegate = K0()
|
|
559
|
+
app.delegate = delegate
|
|
560
|
+
app.setActivationPolicy(.accessory) // no Dock icon, just the menu bar
|
|
561
|
+
app.run()
|
package/menubar/build.sh
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Builds k0.app: a minimal bundle, with no Xcode project.
|
|
3
|
+
set -e
|
|
4
|
+
cd "$(dirname "$0")"
|
|
5
|
+
|
|
6
|
+
APP="k0.app"
|
|
7
|
+
BIN="$APP/Contents/MacOS/k0"
|
|
8
|
+
|
|
9
|
+
# Rebuilding changes the signature, and macOS then makes you grant the Accessibility
|
|
10
|
+
# permission all over again. So it only recompiles when the code has really changed.
|
|
11
|
+
if [ -x "$BIN" ] && [ "$BIN" -nt K0MenuBar.swift ] && [ "$BIN" -nt Info.plist ]; then
|
|
12
|
+
echo "✅ $(pwd)/$APP (already up to date)"
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
rm -rf "$APP"
|
|
17
|
+
mkdir -p "$APP/Contents/MacOS"
|
|
18
|
+
cp Info.plist "$APP/Contents/Info.plist"
|
|
19
|
+
|
|
20
|
+
# -swift-version 5: Swift 6's strict concurrency is not worth it for a 400-line app.
|
|
21
|
+
swiftc -O -swift-version 5 -o "$BIN" K0MenuBar.swift
|
|
22
|
+
|
|
23
|
+
# Ad-hoc signature: without one, macOS does not deliver the bundle's notifications.
|
|
24
|
+
codesign --force --sign - --identifier com.k0.menubar "$APP" >/dev/null 2>&1 || {
|
|
25
|
+
echo "⚠️ signing failed: notifications will use the fallback banner"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
echo "✅ $(pwd)/$APP"
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "k0-board",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A board of sticky notes that drives your Claude Code sessions",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"ai-coding",
|
|
8
|
+
"kanban",
|
|
9
|
+
"developer-tools",
|
|
10
|
+
"terminal",
|
|
11
|
+
"dashboard"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/alessio-ragni/k0-board#readme",
|
|
14
|
+
"bugs": "https://github.com/alessio-ragni/k0-board/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/alessio-ragni/k0-board.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Alessio Ragni (https://alessioragni.com)",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"k0-board": "bin/k0-board.js"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"platform",
|
|
31
|
+
"server",
|
|
32
|
+
"web",
|
|
33
|
+
"menubar",
|
|
34
|
+
"!menubar/k0.app",
|
|
35
|
+
".claude/skills",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"start": "node server/index.js",
|
|
42
|
+
"test": "node test/clean.test.mjs && node test/states.test.mjs && node test/files.test.mjs && node test/mentions.test.mjs && node test/refs.test.mjs && node test/machine.test.mjs && node test/mode.test.mjs",
|
|
43
|
+
"doctor": "node bin/k0-board.js doctor"
|
|
44
|
+
}
|
|
45
|
+
}
|