Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab/menubar: complete-state + Terminal theming + restore/restart/tile

Menu Bar app:
- ClaudeSession adds a `complete` state ("turn done, idle") between working and stale; icon/dot/menu colors shift to green=working / slate=complete / amber=awaiting / gray=stale.
- Restore-threads submenu opens N fresh Terminal windows running `claude -r <id>` for the most-recently-modified sessions on disk that aren't already live (recovery hatch for when Terminal.app dies).
- Restart-all-active SIGTERMs every live session and respawns each in a fresh Terminal with --resume (warns first since it interrupts in-flight work).
- Auto-tile + Tile-now pack open Terminal windows into a square-ish grid with font size scaled to fit; "Near"/"Far" text-size radio shrinks the font for close-to-screen viewing.
- Theme-by-status pushes per-state background color + custom-title to each Terminal tab via a single batched osascript pass (memoized so it only fires when state/subject actually changes).
- New ClaudeHistoryReader.swift reads ~/.claude history JSONLs to source the "restore last N" picks.

Bash hooks + install:
- claude-prompt-log.sh derives a 4–8-word `summary` from the prompt and writes it both into active-prompts/<id>.json and as an OSC 0 title to the controlling TTY (so the Terminal title reflects the current task even when theme-by-status is off).
- claude-stop.sh prefers the new sinebells `all-done-waltz.mp3` when present, falling back to the legacy chime.
- install.sh copies any *.mp3 in slab/sounds/ alongside the .wav set so the waltz phrase is installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+710 -18
+24 -1
slab/bin/claude-prompt-log.sh
··· 39 39 tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ') 40 40 ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) 41 41 42 + # 4–8 word summary used as the live Terminal title and the menubar's 43 + # short subject. We collapse whitespace, take the first 7 words, and 44 + # cap at 48 chars so it fits in a window-title bar. 45 + summary=$(echo "$input" | jq -r '.prompt // ""' \ 46 + | tr '\n\r\t' ' ' \ 47 + | awk '{ 48 + gsub(/^ +| +$/, ""); 49 + n = (NF > 7) ? 7 : NF; 50 + out = ""; 51 + for (i = 1; i <= n; i++) out = (i == 1 ? $i : out " " $i); 52 + if (length(out) > 48) out = substr(out, 1, 45) "…"; 53 + print out; 54 + }') 55 + 42 56 echo "$input" | jq -c \ 43 57 --arg sid "$session_id" \ 44 58 --arg tty "$tty" \ 45 59 --arg pid "$claude_pid" \ 46 60 --arg ts "$ts" \ 47 - '{session_id: $sid, cwd: .cwd, subject: (.prompt | tostring | .[0:140]), tty: $tty, claude_pid: ($pid | tonumber? // 0), updated: $ts, state: "working"}' \ 61 + --arg sum "$summary" \ 62 + '{session_id: $sid, cwd: .cwd, subject: (.prompt | tostring | .[0:140]), summary: $sum, tty: $tty, claude_pid: ($pid | tonumber? // 0), updated: $ts, state: "working"}' \ 48 63 > "$ACTIVE_DIR/$session_id" 2>/dev/null 64 + 65 + # Live terminal title: write OSC 0 ("set window + icon name") direct 66 + # to the controlling TTY. Terminal.app picks it up unless the 67 + # menubar's "Theme by status" has set a custom title — that wins, 68 + # and reads the same `summary` field from active-prompts. 69 + if [[ -n "$tty" && -e "/dev/$tty" && -n "$summary" ]]; then 70 + printf '\033]0;%s\007' "$summary" > "/dev/$tty" 2>/dev/null 71 + fi 49 72 50 73 # User responded — clear any awaiting marker for this session. 51 74 rm -f "$AWAITING_DIR/$session_id"
+8 -1
slab/bin/claude-stop.sh
··· 117 117 tired_stinger 118 118 "$SLAB_BIN/claude-sleep" now 119 119 else 120 - /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null & 120 + # Lid open + everything settled → sinebells waltz phrase. Falls back 121 + # to the legacy chime if the rendered file is missing (e.g. fresh 122 + # checkout that hasn't run waltz.mjs yet). 123 + if [[ -f "$CH/all-done-waltz.mp3" ]]; then 124 + /usr/bin/afplay "$CH/all-done-waltz.mp3" 2>/dev/null & 125 + else 126 + /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null & 127 + fi 121 128 fi 122 129 else 123 130 max=8
+5
slab/install.sh
··· 74 74 # ------------ sounds ------------ 75 75 say "copying sounds to $SLAB_HOME/sounds" 76 76 cp -f "$SLAB_REPO/sounds/"*.wav "$SLAB_HOME/sounds/" 77 + # Pre-rendered waltz phrases (e.g. all-done-waltz.mp3 from recap/bin/waltz.mjs). 78 + shopt -s nullglob 79 + mp3s=("$SLAB_REPO/sounds/"*.mp3) 80 + (( ${#mp3s[@]} > 0 )) && cp -f "${mp3s[@]}" "$SLAB_HOME/sounds/" 81 + shopt -u nullglob 77 82 78 83 # ------------ python venv (numpy + sounddevice for reactive listener) ------------ 79 84 if [[ ! -x "$SLAB_HOME/venv/bin/python3" ]]; then
+362
slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift
··· 4 4 private var statusItem: NSStatusItem! 5 5 private var refreshTimer: Timer? 6 6 private var animTimer: Timer? 7 + /// `sessionId → "<state>|<subject>"` of the last theme/title we pushed 8 + /// to Terminal, so the per-tick refresh only fires osascript when 9 + /// something actually changed. 10 + private var lastTerminalDecor: [String: String] = [:] 7 11 private var rainbowPhase: CGFloat = 0 8 12 private var rotationPhase: CGFloat = 0 9 13 private var mailTickCount = 0 ··· 50 54 } 51 55 52 56 statusItem.menu = MenuBuilder.build(state: state, mailStatus: mailStatus, target: self) 57 + 58 + applyTerminalDecor() 53 59 } 54 60 55 61 private func updateIcon() { ··· 214 220 guard let host = sender.representedObject as? String else { return } 215 221 let script = "tell application \"Terminal\" to do script \"ssh \(host)\"" 216 222 ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) 223 + } 224 + 225 + /// Open N fresh Terminal windows, each resuming one of the most-recently- 226 + /// modified Claude sessions on disk that isn't already live. Built for 227 + /// the "Terminal.app crashed and took every Claude with it" case. 228 + @objc func restoreRecentThreads(_ sender: NSMenuItem) { 229 + guard let n = sender.representedObject as? Int, n > 0 else { return } 230 + let liveIds = Set(state.claudeSessions.map { $0.sessionId }) 231 + let tile = state.autoTile 232 + let near = state.nearText 233 + // Snapshot screen geometry on main; NSScreen reads off-main can 234 + // return nil on first hit and were the silent failure mode for the 235 + // first auto-tile pass. 236 + let geom = tile ? Self.screenGeom() : nil 237 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in 238 + let entries = ClaudeHistoryReader.recent(limit: n, excluding: liveIds) 239 + if entries.isEmpty { 240 + DispatchQueue.main.async { 241 + self?.notify(title: "slab", subtitle: "Restore threads", body: "No restorable Claude sessions found.") 242 + } 243 + return 244 + } 245 + let layout = geom.flatMap { Self.computeTileLayout(count: entries.count, geom: $0, near: near) } 246 + for (i, entry) in entries.enumerated() { 247 + let cell = layout?.cellAt(index: i) 248 + Self.openTerminalRunningClaude( 249 + cwd: entry.cwd, 250 + sessionId: entry.sessionId, 251 + bounds: cell?.bounds, 252 + fontSize: layout?.fontSize 253 + ) 254 + } 255 + } 256 + } 257 + 258 + /// Stop every live Claude session, then relaunch each in a fresh Terminal 259 + /// window with `--resume`. Confirms first because it kills in-flight work. 260 + @objc func restartAllActive() { 261 + let sessions = state.claudeSessions 262 + if sessions.isEmpty { return } 263 + let alert = NSAlert() 264 + alert.messageText = "Restart all active Claude sessions?" 265 + alert.informativeText = "This will stop \(sessions.count) running session\(sessions.count == 1 ? "" : "s") and relaunch each in a fresh Terminal window. In-flight work will be interrupted." 266 + alert.alertStyle = .warning 267 + alert.addButton(withTitle: "Restart All") 268 + alert.addButton(withTitle: "Cancel") 269 + NSApp.activate(ignoringOtherApps: true) 270 + guard alert.runModal() == .alertFirstButtonReturn else { return } 271 + 272 + // Snapshot what we need before SIGTERM races the active-prompts 273 + // janitor — once the pid dies, the marker file gets reaped. 274 + let payloads = sessions.map { (sid: $0.sessionId, cwd: $0.cwd, pid: $0.claudePid) } 275 + let geom = state.autoTile ? Self.screenGeom() : nil 276 + let layout = geom.flatMap { Self.computeTileLayout(count: payloads.count, geom: $0, near: state.nearText) } 277 + DispatchQueue.global(qos: .userInitiated).async { 278 + for p in payloads where p.pid > 0 { 279 + kill(pid_t(p.pid), SIGTERM) 280 + } 281 + // Brief grace period so the old TTY/process tree tears down 282 + // before the resumed session tries to grab the keyring etc. 283 + Thread.sleep(forTimeInterval: 0.5) 284 + for (i, p) in payloads.enumerated() where !p.cwd.isEmpty { 285 + let cell = layout?.cellAt(index: i) 286 + Self.openTerminalRunningClaude( 287 + cwd: p.cwd, 288 + sessionId: p.sid, 289 + bounds: cell?.bounds, 290 + fontSize: layout?.fontSize 291 + ) 292 + } 293 + } 294 + } 295 + 296 + @objc func toggleAutoTile() { 297 + let path = Paths.autoTileFlag 298 + let fm = FileManager.default 299 + if fm.fileExists(atPath: path) { 300 + try? fm.removeItem(atPath: path) 301 + } else { 302 + let dir = (path as NSString).deletingLastPathComponent 303 + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) 304 + fm.createFile(atPath: path, contents: nil) 305 + } 306 + refresh() 307 + } 308 + 309 + @objc func setTextNear() { 310 + let path = Paths.nearTextFlag 311 + let dir = (path as NSString).deletingLastPathComponent 312 + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) 313 + FileManager.default.createFile(atPath: path, contents: nil) 314 + refresh() 315 + tileNow() 316 + } 317 + 318 + @objc func setTextFar() { 319 + try? FileManager.default.removeItem(atPath: Paths.nearTextFlag) 320 + refresh() 321 + tileNow() 322 + } 323 + 324 + @objc func toggleThemeByStatus() { 325 + let path = Paths.themeByStatusFlag 326 + let fm = FileManager.default 327 + if fm.fileExists(atPath: path) { 328 + try? fm.removeItem(atPath: path) 329 + // Forget what we pushed so re-enabling later starts from a 330 + // clean slate (no "we already themed this session" memory 331 + // surviving across an off→on cycle). 332 + lastTerminalDecor.removeAll() 333 + } else { 334 + let dir = (path as NSString).deletingLastPathComponent 335 + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) 336 + fm.createFile(atPath: path, contents: nil) 337 + } 338 + refresh() 339 + applyTerminalDecor() 340 + } 341 + 342 + /// Push the per-status Terminal profile + custom title to each window 343 + /// matching a live Claude session. Called from `refresh()`, but only 344 + /// emits an osascript when something actually changed since the last 345 + /// pass (state flipped, subject moved, or new session appeared). 346 + private func applyTerminalDecor() { 347 + guard state.themeByStatus else { return } 348 + // {R, G, B} in AppleScript color space (each component 0–65535). 349 + // Picked to read at a glance on dark monospace text without 350 + // requiring a profile swap. We avoid `set current settings` because 351 + // applying a profile resets the tab's font + the window's pixel 352 + // size to the profile default, causing a brief reframe-flicker 353 + // even when we save/restore around it. 354 + struct Assignment { let tty: String; let bg: (Int, Int, Int)?; let title: String } 355 + var changes: [Assignment] = [] 356 + var seen = Set<String>() 357 + for s in state.claudeSessions where !s.tty.isEmpty { 358 + seen.insert(s.sessionId) 359 + let bg: (Int, Int, Int)? 360 + let glyph: String 361 + switch s.state { 362 + // Working = green (active/healthy), complete = calm slate 363 + // (turn done, idle), awaiting = amber (needs you to continue), 364 + // stale = no bg change. RGBs are 0–65535 AppleScript colorspace, 365 + // dark enough that the profile's default light text still reads. 366 + case .working: bg = (1500, 14000, 4000); glyph = "● working" // deep forest green 367 + case .complete: bg = (5000, 7000, 12000); glyph = "✓ complete" // muted slate 368 + case .awaiting: bg = (32000, 18000, 1500); glyph = "◉ awaiting" // warm amber — attention! 369 + case .stale: bg = nil; glyph = "○ stale" // leave bg alone 370 + } 371 + let title = "\(glyph) · \(s.titleString)" 372 + let bgKey = bg.map { "\($0.0),\($0.1),\($0.2)" } ?? "-" 373 + let key = "\(bgKey)|\(title)" 374 + if lastTerminalDecor[s.sessionId] == key { continue } 375 + lastTerminalDecor[s.sessionId] = key 376 + changes.append(Assignment(tty: s.tty, bg: bg, title: title)) 377 + } 378 + // Reap entries for sessions that disappeared since last tick — they 379 + // either died or got reaped by the janitor; either way our memo is 380 + // stale. 381 + for sid in lastTerminalDecor.keys where !seen.contains(sid) { 382 + lastTerminalDecor.removeValue(forKey: sid) 383 + } 384 + if changes.isEmpty { return } 385 + 386 + // One osascript pass that walks every window×tab once and applies 387 + // every change in this batch. We only touch `background color` and 388 + // `custom title` — neither resets font or window size, so there's 389 + // nothing to save/restore and nothing to flicker. 390 + var lines = [ 391 + "tell application \"Terminal\"", 392 + " repeat with w in windows", 393 + " repeat with t in tabs of w", 394 + " try", 395 + " set ttyName to tty of t", 396 + ] 397 + for a in changes { 398 + let escTty = a.tty.replacingOccurrences(of: "\"", with: "\\\"") 399 + let escTitle = a.title 400 + .replacingOccurrences(of: "\\", with: "\\\\") 401 + .replacingOccurrences(of: "\"", with: "\\\"") 402 + lines.append(" if ttyName ends with \"\(escTty)\" then") 403 + if let bg = a.bg { 404 + lines.append(" set background color of t to {\(bg.0), \(bg.1), \(bg.2)}") 405 + } 406 + lines.append(" set custom title of t to \"\(escTitle)\"") 407 + lines.append(" end if") 408 + } 409 + lines.append(contentsOf: [ 410 + " end try", 411 + " end repeat", 412 + " end repeat", 413 + "end tell", 414 + ]) 415 + let script = lines.joined(separator: "\n") 416 + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) 417 + } 418 + 419 + /// Spawn a new Terminal.app window that cd's into `cwd` and resumes the 420 + /// given session. Optionally sets bounds + font size so the caller can 421 + /// tile the windows. Single-quoted shell args are escaped so a path with 422 + /// apostrophes can't break out. 423 + private static func openTerminalRunningClaude( 424 + cwd: String, 425 + sessionId: String, 426 + bounds: (left: Int, top: Int, right: Int, bottom: Int)? = nil, 427 + fontSize: Int? = nil 428 + ) { 429 + let safeCwd = cwd.replacingOccurrences(of: "'", with: "'\\''") 430 + let safeSid = sessionId.replacingOccurrences(of: "'", with: "'\\''") 431 + let shellCmd = "cd '\(safeCwd)' && claude -r '\(safeSid)'" 432 + let escapedCmd = shellCmd.replacingOccurrences(of: "\\", with: "\\\\") 433 + .replacingOccurrences(of: "\"", with: "\\\"") 434 + 435 + var lines = ["tell application \"Terminal\"", " activate", " do script \"\(escapedCmd)\""] 436 + // Order matters: setting font size makes Terminal snap the window to 437 + // the nearest whole row/col grid, which can shrink it. So we set 438 + // bounds, then font, then bounds again — the second `set bounds` 439 + // forces the pixel size we actually want, even if Terminal would 440 + // rather round it. 441 + if let b = bounds { 442 + lines.append(" set bounds of front window to {\(b.left), \(b.top), \(b.right), \(b.bottom)}") 443 + } 444 + if let fs = fontSize { 445 + lines.append(" set font size of selected tab of front window to \(fs)") 446 + } 447 + if let b = bounds { 448 + lines.append(" set bounds of front window to {\(b.left), \(b.top), \(b.right), \(b.bottom)}") 449 + } 450 + lines.append("end tell") 451 + let script = lines.joined(separator: "\n") 452 + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) 453 + } 454 + 455 + // MARK: - Auto tile layout 456 + 457 + /// One cell in the tiled grid, in AppleScript's top-left-origin pixel 458 + /// coordinates (Terminal.app's `bounds` property uses this space). 459 + /// Per-side gutter between tiled windows (and at the edge of the visible 460 + /// frame). Total inter-window gap is 2*gutter; total edge gap = gutter. 461 + static let tileGutter = 2 462 + 463 + struct TileLayout { 464 + let cols: Int 465 + let rows: Int 466 + let cellWidth: Int 467 + let cellHeight: Int 468 + let originX: Int // visible-frame left edge 469 + let originY: Int // visible-frame top edge (below menu bar), AS coords 470 + let fontSize: Int 471 + 472 + struct Cell { 473 + let bounds: (left: Int, top: Int, right: Int, bottom: Int) 474 + } 475 + 476 + func cellAt(index: Int) -> Cell { 477 + let row = index / cols 478 + let col = index % cols 479 + let left = originX + col * cellWidth + tileGutter 480 + let top = originY + row * cellHeight + tileGutter 481 + let right = originX + (col + 1) * cellWidth - tileGutter 482 + let bottom = originY + (row + 1) * cellHeight - tileGutter 483 + return Cell(bounds: (left, top, right, bottom)) 484 + } 485 + } 486 + 487 + /// Snapshot of the main display's geometry, in AppleScript's top-left- 488 + /// origin pixel coordinates. Captured on the main thread so layout math 489 + /// can run safely off-main. 490 + struct ScreenGeom { 491 + let originX: Int // visible-frame left edge (AS coords) 492 + let originY: Int // visible-frame top edge (below menu bar, AS coords) 493 + let width: Int 494 + let height: Int 495 + } 496 + 497 + static func screenGeom() -> ScreenGeom? { 498 + guard let screen = NSScreen.main else { return nil } 499 + let visible = screen.visibleFrame 500 + let fullHeight = screen.frame.height 501 + let asTopOfVisible = Int(fullHeight - (visible.origin.y + visible.size.height)) 502 + return ScreenGeom( 503 + originX: Int(visible.origin.x), 504 + originY: asTopOfVisible, 505 + width: Int(visible.size.width), 506 + height: Int(visible.size.height) 507 + ) 508 + } 509 + 510 + /// Pack `count` windows into the most-square grid that fits the main 511 + /// display's visible frame. Font size scales down for denser grids so 512 + /// even N=10 stays legible. 513 + private static func computeTileLayout(count: Int, geom: ScreenGeom, near: Bool = false) -> TileLayout? { 514 + guard count > 0 else { return nil } 515 + let cols = max(1, Int(ceil(Double(count).squareRoot()))) 516 + let rows = max(1, Int(ceil(Double(count) / Double(cols)))) 517 + let cellW = geom.width / cols 518 + let cellH = geom.height / rows 519 + // Inner pixels available per window after the gutter is taken out. 520 + let innerW = max(1, cellW - 2 * tileGutter) 521 + let innerH = max(1, cellH - 2 * tileGutter) 522 + 523 + // Pick the largest font that fits ~24 rows × ~80 cols of monospace 524 + // in the inner area. A char cell is roughly fontSize * 0.6 wide and 525 + // fontSize * 1.2 tall in Menlo/SF Mono. Enlarging is fine — for low 526 + // N the cells are huge, so the font scales up rather than us 527 + // wasting empty pixels at a fixed default. 528 + let fontByH = Double(innerH) / (24.0 * 1.2) 529 + let fontByW = Double(innerW) / (80.0 * 0.6) 530 + let raw = min(fontByH, fontByW) 531 + // Near = "I'm sitting close, give me density" → 60% of the 532 + // legible-from-typical-distance size, floored at 6pt. 533 + let scaled = near ? raw * 0.6 : raw 534 + let fontSize = max(near ? 6 : 8, Int(scaled.rounded())) 535 + 536 + return TileLayout( 537 + cols: cols, 538 + rows: rows, 539 + cellWidth: cellW, 540 + cellHeight: cellH, 541 + originX: geom.originX, 542 + originY: geom.originY, 543 + fontSize: fontSize 544 + ) 545 + } 546 + 547 + /// Tile every currently-open Terminal.app window into the same grid the 548 + /// auto-tile path uses. Independent of the auto-tile flag — this is the 549 + /// "I forgot to enable it" / "I want to re-pack what's open" button. 550 + @objc func tileNow() { 551 + guard let geom = Self.screenGeom() else { return } 552 + let near = state.nearText 553 + DispatchQueue.global(qos: .userInitiated).async { 554 + // Ask Terminal for its window count first so we can size the 555 + // grid to what's actually on screen. 556 + let countOut = ShellRunner.run( 557 + "/usr/bin/osascript", 558 + args: ["-e", "tell application \"Terminal\" to count windows"], 559 + timeout: 5 560 + ).output.trimmingCharacters(in: .whitespacesAndNewlines) 561 + guard let n = Int(countOut), n > 0 else { return } 562 + guard let layout = Self.computeTileLayout(count: n, geom: geom, near: near) else { return } 563 + 564 + var lines: [String] = ["tell application \"Terminal\"", " activate"] 565 + for i in 0..<n { 566 + let cell = layout.cellAt(index: i) 567 + let asIndex = i + 1 // AppleScript is 1-based 568 + let boundsLine = " set bounds of window \(asIndex) to {\(cell.bounds.left), \(cell.bounds.top), \(cell.bounds.right), \(cell.bounds.bottom)}" 569 + // bounds → font → bounds: same pattern as the restore path 570 + // so Terminal's row/col snap can't shrink the cell. 571 + lines.append(boundsLine) 572 + lines.append(" set font size of selected tab of window \(asIndex) to \(layout.fontSize)") 573 + lines.append(boundsLine) 574 + } 575 + lines.append("end tell") 576 + let script = lines.joined(separator: "\n") 577 + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) 578 + } 217 579 } 218 580 219 581 private func syncMail(account: String?) {
+133
slab/menubar-swift/Sources/SlabMenubar/ClaudeHistoryReader.swift
··· 1 + import Foundation 2 + 3 + /// One restorable Claude session discovered on disk. 4 + struct ClaudeHistoryEntry { 5 + let sessionId: String 6 + let cwd: String 7 + let mtime: Date 8 + let subject: String 9 + 10 + var cwdLabel: String { 11 + let url = URL(fileURLWithPath: cwd) 12 + return url.lastPathComponent.isEmpty ? cwd : url.lastPathComponent 13 + } 14 + 15 + var shortSubject: String { 16 + let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) 17 + if trimmed.count <= 60 { return trimmed } 18 + let idx = trimmed.index(trimmed.startIndex, offsetBy: 57) 19 + return trimmed[..<idx] + "…" 20 + } 21 + } 22 + 23 + /// Walks `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl` and returns the 24 + /// most-recently-modified sessions. Used by the menubar's "Restore threads" 25 + /// submenu so a Terminal.app crash that wipes every Claude tab can be 26 + /// recovered with one click. 27 + enum ClaudeHistoryReader { 28 + /// Return the `limit` most-recently-modified sessions, excluding any 29 + /// session id present in `excludeSessionIds` (typically the live ones 30 + /// listed in `~/.local/share/slab/state/active-prompts/`). 31 + static func recent(limit: Int, excluding excludeSessionIds: Set<String> = []) -> [ClaudeHistoryEntry] { 32 + let fm = FileManager.default 33 + let projectsDir = "\(Paths.home)/.claude/projects" 34 + guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsDir) else { 35 + return [] 36 + } 37 + 38 + // Pass 1: collect (path, mtime, sessionId) for every JSONL across all 39 + // project dirs, skipping anything already live. We only need the 40 + // metadata at this stage so directory walks stay cheap. 41 + struct Candidate { 42 + let path: String 43 + let sessionId: String 44 + let mtime: Date 45 + } 46 + var candidates: [Candidate] = [] 47 + for dir in projectDirs where !dir.hasPrefix(".") { 48 + let dirPath = "\(projectsDir)/\(dir)" 49 + guard let files = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } 50 + for file in files where file.hasSuffix(".jsonl") { 51 + let sessionId = String(file.dropLast(".jsonl".count)) 52 + if excludeSessionIds.contains(sessionId) { continue } 53 + let path = "\(dirPath)/\(file)" 54 + let mtime = (try? fm.attributesOfItem(atPath: path)[.modificationDate] as? Date) ?? .distantPast 55 + candidates.append(Candidate(path: path, sessionId: sessionId, mtime: mtime)) 56 + } 57 + } 58 + 59 + // Pass 2: sort by mtime desc, then read the first user message of the 60 + // top `limit` files for subject/cwd. Avoid parsing thousands of files 61 + // when the user only asked for 10. 62 + candidates.sort { $0.mtime > $1.mtime } 63 + var entries: [ClaudeHistoryEntry] = [] 64 + for c in candidates { 65 + if entries.count >= limit { break } 66 + guard let (cwd, subject) = firstUserMessage(path: c.path) else { continue } 67 + // Empty cwd would mean we can't `cd` to relaunch — drop it. 68 + if cwd.isEmpty { continue } 69 + entries.append(ClaudeHistoryEntry( 70 + sessionId: c.sessionId, 71 + cwd: cwd, 72 + mtime: c.mtime, 73 + subject: subject 74 + )) 75 + } 76 + return entries 77 + } 78 + 79 + /// Stream the first `type:"user"` line of a JSONL and return its 80 + /// `(cwd, content)` pair. We read line-by-line to avoid pulling 81 + /// multi-megabyte conversations into memory. 82 + private static func firstUserMessage(path: String) -> (String, String)? { 83 + guard let handle = FileHandle(forReadingAtPath: path) else { return nil } 84 + defer { try? handle.close() } 85 + 86 + var buffer = Data() 87 + var fallbackCwd = "" 88 + // Read in 64KB chunks; almost every session has a user message in the 89 + // first chunk. Cap total read so a pathological file can't hang us. 90 + let chunkSize = 64 * 1024 91 + let maxRead = 1 * 1024 * 1024 92 + var totalRead = 0 93 + 94 + while totalRead < maxRead { 95 + let chunk = handle.readData(ofLength: chunkSize) 96 + if chunk.isEmpty { break } 97 + totalRead += chunk.count 98 + buffer.append(chunk) 99 + 100 + while let nlIdx = buffer.firstIndex(of: 0x0A) { 101 + let lineData = buffer[..<nlIdx] 102 + buffer = buffer[(nlIdx + 1)...] 103 + if lineData.isEmpty { continue } 104 + guard let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue } 105 + 106 + if fallbackCwd.isEmpty, let c = obj["cwd"] as? String, !c.isEmpty { 107 + fallbackCwd = c 108 + } 109 + if (obj["type"] as? String) != "user" { continue } 110 + let cwd = (obj["cwd"] as? String) ?? fallbackCwd 111 + let subject = extractContent(from: obj["message"]) ?? "(no subject)" 112 + return (cwd, subject) 113 + } 114 + } 115 + return nil 116 + } 117 + 118 + /// `message.content` is either a plain string or an array of content 119 + /// blocks (`{type:"text",text:"…"}`, tool results, etc). We only want 120 + /// the first chunk of human-readable text for the menu label. 121 + private static func extractContent(from message: Any?) -> String? { 122 + guard let msg = message as? [String: Any] else { return nil } 123 + if let s = msg["content"] as? String { return s } 124 + if let arr = msg["content"] as? [[String: Any]] { 125 + for block in arr { 126 + if (block["type"] as? String) == "text", let t = block["text"] as? String { 127 + return t 128 + } 129 + } 130 + } 131 + return nil 132 + } 133 + }
+82 -8
slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift
··· 1 1 import Foundation 2 2 3 3 struct ClaudeSession { 4 - enum State { case working, awaiting, stale } 4 + /// `working` — Claude is actively running a turn. 5 + /// `complete` — Stop fired ("turn complete"); session is alive but idle, 6 + /// waiting for the next user prompt. Calm, no attention needed. 7 + /// `awaiting` — Notification fired; Claude paused mid-task and needs the 8 + /// user (permission prompt, idle-on-input). Attention! 9 + /// `stale` — claude_pid is gone; about to be reaped. 10 + enum State { case working, complete, awaiting, stale } 5 11 6 12 let sessionId: String 7 13 let cwd: String 8 14 let subject: String 15 + /// 4–8 word topic summary written by `claude-prompt-log.sh` for use as 16 + /// a live Terminal title. Empty for older active-prompts files; callers 17 + /// fall back to `shortSubject`. 18 + let summary: String 9 19 let tty: String 10 20 let claudePid: Int 11 21 let updated: Date ··· 19 29 return trimmed[..<idx] + "…" 20 30 } 21 31 32 + /// Prefer the hook's pre-truncated summary; fall back to the longer 33 + /// subject when the hook hasn't populated it yet. 34 + var titleString: String { 35 + let s = summary.trimmingCharacters(in: .whitespacesAndNewlines) 36 + return s.isEmpty ? shortSubject : s 37 + } 38 + 22 39 var cwdLabel: String { 23 40 let url = URL(fileURLWithPath: cwd) 24 41 return url.lastPathComponent.isEmpty ? cwd : url.lastPathComponent ··· 32 49 let awaitingDir = Paths.awaitingPromptsDir 33 50 34 51 guard let names = try? fm.contentsOfDirectory(atPath: activeDir) else { 52 + // No active dir means no awaiting can be valid either — sweep 53 + // any orphan awaiting files so they don't surface later. 54 + reapOrphanAwaiting(awaitingDir: awaitingDir, validIds: []) 35 55 return [] 36 56 } 37 57 ··· 45 65 } 46 66 } 47 67 68 + // Reap markers whose claude_pid is observably dead (terminal closed 69 + // mid-session, no Stop hook fired). Pid liveness is checked BEFORE 70 + // the awaiting branch so a closed tab carrying a stale awaiting 71 + // marker doesn't keep pulsing red until another session's Stop hook 72 + // gets around to running the janitor. 48 73 var sessions: [ClaudeSession] = [] 74 + var liveIds: Set<String> = [] 49 75 for name in names where !name.hasPrefix(".") { 50 76 let path = "\(activeDir)/\(name)" 51 - guard let session = parse(path: path, fallbackId: name) else { continue } 77 + guard let session = parse(path: path, fallbackId: name) else { 78 + // Unparseable file with a real name: leave it alone — could 79 + // be an in-flight write from claude-prompt-log.sh. 80 + continue 81 + } 52 82 var s = session 83 + 84 + if s.claudePid > 0 && !pidAlive(s.claudePid) { 85 + // Dead pid → reap active marker and any matching awaiting 86 + // marker. Best-effort: ignore unlink errors (concurrent 87 + // janitor in claude-stop.sh may have beaten us here). 88 + try? fm.removeItem(atPath: path) 89 + try? fm.removeItem(atPath: "\(awaitingDir)/\(s.sessionId)") 90 + continue 91 + } 92 + 53 93 if let msg = awaitingMessages[s.sessionId] { 54 - s.state = .awaiting 94 + // claude-stop.sh writes the literal string "turn complete" 95 + // (idle-after-turn). claude-notify.sh writes the actual 96 + // permission/notification message. Distinguish so the user 97 + // sees a calm cue for "done with the turn" vs a louder cue 98 + // for "i need you to continue". 99 + if msg.lowercased().hasPrefix("turn complete") { 100 + s.state = .complete 101 + } else { 102 + s.state = .awaiting 103 + } 55 104 s.awaitingMessage = msg 56 - } else if pidAlive(s.claudePid) || s.claudePid == 0 { 105 + } else { 57 106 s.state = .working 58 - } else { 59 - s.state = .stale 60 107 } 61 108 sessions.append(s) 109 + liveIds.insert(s.sessionId) 62 110 } 63 111 112 + reapOrphanAwaiting(awaitingDir: awaitingDir, validIds: liveIds) 113 + 114 + // Sort: attention-needed first (awaiting), then complete, then 115 + // working, all most-recent first within each band. 64 116 return sessions.sorted { a, b in 65 - if a.state == .awaiting && b.state != .awaiting { return true } 66 - if a.state != .awaiting && b.state == .awaiting { return false } 117 + func rank(_ st: ClaudeSession.State) -> Int { 118 + switch st { 119 + case .awaiting: return 0 120 + case .complete: return 1 121 + case .working: return 2 122 + case .stale: return 3 123 + } 124 + } 125 + let ra = rank(a.state), rb = rank(b.state) 126 + if ra != rb { return ra < rb } 67 127 return a.updated > b.updated 68 128 } 69 129 } 70 130 131 + /// Drop awaiting-prompts entries that no longer have a live active 132 + /// counterpart. Mirrors the orphan sweep in claude-stop.sh so the 133 + /// menubar can self-heal without waiting on another session's Stop. 134 + private static func reapOrphanAwaiting(awaitingDir: String, validIds: Set<String>) { 135 + let fm = FileManager.default 136 + guard let names = try? fm.contentsOfDirectory(atPath: awaitingDir) else { return } 137 + for name in names where !name.hasPrefix(".") { 138 + if validIds.contains(name) { continue } 139 + try? fm.removeItem(atPath: "\(awaitingDir)/\(name)") 140 + } 141 + } 142 + 71 143 private static func parse(path: String, fallbackId: String) -> ClaudeSession? { 72 144 guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } 73 145 ··· 78 150 sessionId: fallbackId, 79 151 cwd: "", 80 152 subject: "(no subject)", 153 + summary: "", 81 154 tty: "", 82 155 claudePid: 0, 83 156 updated: mtime, ··· 100 173 sessionId: (obj["session_id"] as? String) ?? fallbackId, 101 174 cwd: (obj["cwd"] as? String) ?? "", 102 175 subject: (obj["subject"] as? String) ?? "(no subject)", 176 + summary: (obj["summary"] as? String) ?? "", 103 177 tty: (obj["tty"] as? String) ?? "", 104 178 claudePid: (obj["claude_pid"] as? Int) ?? 0, 105 179 updated: updated,
+9 -6
slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift
··· 180 180 path.stroke() 181 181 } 182 182 183 - /// Per-session edge color. Working = steady cyan-teal. Awaiting = 184 - /// pulsing warm red, the loud "look at me" state. Stale = slow gray 185 - /// blink, "thread is dead but the marker's still on disk." 183 + /// Per-session edge color. Working = steady green ("active and healthy"). 184 + /// Complete = soft slate (turn done, idle — quiet). Awaiting = pulsing 185 + /// amber, the loud "look at me, continue" state. Stale = slow gray blink, 186 + /// "thread is dead but the marker's still on disk." 186 187 private static func sessionColor(_ state: ClaudeSession.State, phase: CGFloat) -> NSColor { 187 188 switch state { 188 189 case .working: 189 - return NSColor(deviceHue: 0.50, saturation: 0.60, brightness: 0.82, alpha: 1.0) 190 + return NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1.0) 191 + case .complete: 192 + return NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1.0) 190 193 case .awaiting: 191 194 // Pulse brightness at 2 Hz so paused threads visibly throb 192 - // among the steady cyan working ones. 195 + // among the steady green working ones. 193 196 let pulse = 0.5 + 0.5 * cos(phase * .pi * 4) 194 197 let brightness = 0.70 + 0.30 * pulse 195 - return NSColor(deviceHue: 0.0, saturation: 0.92, brightness: brightness, alpha: 1.0) 198 + return NSColor(deviceHue: 0.10, saturation: 0.95, brightness: brightness, alpha: 1.0) 196 199 case .stale: 197 200 let blink = 0.5 + 0.5 * cos(phase * .pi * 2) 198 201 return NSColor(deviceWhite: 0.28 + 0.32 * blink, alpha: 1.0)
+69 -2
slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift
··· 98 98 } 99 99 menu.addItem(info(header)) 100 100 101 + menu.addItem(buildRestoreSubmenu(state: state, target: target)) 102 + 103 + if !sessions.isEmpty { 104 + let restartAll = item( 105 + "Restart all active (\(sessions.count))", 106 + selector: #selector(AppDelegate.restartAllActive), 107 + target: target 108 + ) 109 + menu.addItem(restartAll) 110 + } 111 + 101 112 if sessions.isEmpty { return } 102 113 103 114 for s in sessions { ··· 105 116 switch s.state { 106 117 case .awaiting: dot = "◉" 107 118 case .working: dot = "●" 119 + case .complete: dot = "✓" 108 120 case .stale: dot = "○" 109 121 } 110 122 let tail = s.cwdLabel.isEmpty ? "" : " · \(s.cwdLabel)" ··· 122 134 } 123 135 } 124 136 137 + /// "Restore threads" submenu — recovery hatch for the case where 138 + /// Terminal.app dies and takes every Claude tab with it. Each item opens 139 + /// N fresh Terminal windows running `claude -r <session-id>` for the 140 + /// most-recently-modified sessions on disk that aren't already live. 141 + private static func buildRestoreSubmenu(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { 142 + let parent = NSMenuItem(title: "Restore threads", action: nil, keyEquivalent: "") 143 + let sub = NSMenu() 144 + 145 + let tile = item("Auto tile windows", selector: #selector(AppDelegate.toggleAutoTile), target: target) 146 + tile.state = state.autoTile ? .on : .off 147 + sub.addItem(tile) 148 + sub.addItem(item("Tile now", selector: #selector(AppDelegate.tileNow), target: target)) 149 + 150 + // Text size — radio pair so the active mode is visible at a glance. 151 + // Near = denser (close to screen), Far = auto-fit "suitable" size. 152 + let textParent = NSMenuItem(title: "Text size", action: nil, keyEquivalent: "") 153 + let textSub = NSMenu() 154 + let farItem = NSMenuItem(title: "Far (suitable)", action: #selector(AppDelegate.setTextFar), keyEquivalent: "") 155 + farItem.target = target 156 + farItem.state = state.nearText ? .off : .on 157 + textSub.addItem(farItem) 158 + let nearItem = NSMenuItem(title: "Near (small)", action: #selector(AppDelegate.setTextNear), keyEquivalent: "") 159 + nearItem.target = target 160 + nearItem.state = state.nearText ? .on : .off 161 + textSub.addItem(nearItem) 162 + textParent.submenu = textSub 163 + sub.addItem(textParent) 164 + 165 + let theme = item("Theme by status", selector: #selector(AppDelegate.toggleThemeByStatus), target: target) 166 + theme.state = state.themeByStatus ? .on : .off 167 + theme.toolTip = "Re-skin Terminal windows by Claude state — Ocean for working, Red Sands for awaiting, custom title shows the subject" 168 + sub.addItem(theme) 169 + 170 + sub.addItem(.separator()) 171 + 172 + for n in 1...10 { 173 + let entry = NSMenuItem( 174 + title: "Restore last \(n)", 175 + action: #selector(AppDelegate.restoreRecentThreads(_:)), 176 + keyEquivalent: "" 177 + ) 178 + entry.target = target 179 + entry.representedObject = n 180 + entry.isEnabled = true 181 + sub.addItem(entry) 182 + } 183 + parent.submenu = sub 184 + return parent 185 + } 186 + 125 187 private static func coloredTitle(for s: ClaudeSession, dot: String, tail: String) -> NSAttributedString { 126 188 let full = "\(dot) \(s.shortSubject)\(tail)" 127 189 let attr = NSMutableAttributedString(string: full) 128 190 let dotColor: NSColor 129 191 switch s.state { 130 - case .working: dotColor = NSColor(deviceHue: 0.50, saturation: 0.60, brightness: 0.82, alpha: 1.0) 131 - case .awaiting: dotColor = NSColor(deviceHue: 0.0, saturation: 0.92, brightness: 0.92, alpha: 1.0) 192 + // Working = green (active/healthy), complete = soft slate (calm), 193 + // awaiting = warm amber (needs you), stale = gray. 194 + case .working: dotColor = NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1.0) 195 + case .complete: dotColor = NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1.0) 196 + case .awaiting: dotColor = NSColor(deviceHue: 0.10, saturation: 0.95, brightness: 0.95, alpha: 1.0) 132 197 case .stale: dotColor = NSColor(deviceWhite: 0.55, alpha: 1.0) 133 198 } 134 199 // Color the status dot strong, the rest of the line in the dot's ··· 147 212 switch s.state { 148 213 case .awaiting: 149 214 parts.append("awaiting: \(s.awaitingMessage ?? "input")") 215 + case .complete: 216 + parts.append("turn complete (idle)") 150 217 case .working: 151 218 parts.append("working") 152 219 case .stale:
+12
slab/menubar-swift/Sources/SlabMenubar/Paths.swift
··· 33 33 /// chimes and stops ambient instead of starting it. Toggled from the 34 34 /// menubar's "Mute ambient sonification" item. 35 35 static var muteFlag: String { "\(slabHome)/state/muted" } 36 + /// When this file exists, restored / restarted Claude windows are 37 + /// auto-tiled across the main display in a grid sized by the window 38 + /// count, with the Terminal font scaled so no cell is too cramped. 39 + static var autoTileFlag: String { "\(slabHome)/state/auto-tile" } 40 + /// When this file exists, the tile font is shrunk well below the 41 + /// "comfortable from typical distance" size — for sitting close to the 42 + /// screen and wanting more content per pane. 43 + static var nearTextFlag: String { "\(slabHome)/state/tile-near" } 44 + /// When this file exists, each Terminal window matching a live Claude 45 + /// session is re-themed by status (working/awaiting), so a wall of 46 + /// terminals reads as a status display at a glance. 47 + static var themeByStatusFlag: String { "\(slabHome)/state/theme-by-status" } 36 48 } 37 49 38 50 enum Tools {
+6
slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift
··· 7 7 var activeSubagents: Int = 0 8 8 var ambientActive: Bool = false 9 9 var muted: Bool = false 10 + var autoTile: Bool = false 11 + var nearText: Bool = false 12 + var themeByStatus: Bool = false 10 13 var tailnetPeers: [TailnetPeer] = [] 11 14 var claudeSessions: [ClaudeSession] = [] 12 15 ··· 34 37 s.activeSubagents = countFiles(in: Paths.activeSubagentsDir) 35 38 s.ambientActive = FileManager.default.fileExists(atPath: Paths.ambientFlag) 36 39 s.muted = FileManager.default.fileExists(atPath: Paths.muteFlag) 40 + s.autoTile = FileManager.default.fileExists(atPath: Paths.autoTileFlag) 41 + s.nearText = FileManager.default.fileExists(atPath: Paths.nearTextFlag) 42 + s.themeByStatus = FileManager.default.fileExists(atPath: Paths.themeByStatusFlag) 37 43 s.tailnetPeers = TailnetPeer.query() 38 44 s.claudeSessions = ClaudeSessionReader.active() 39 45 return s
slab/sounds/all-done-waltz.mp3

This is a binary file and will not be displayed.