diff --git a/packaging/mac-client/Sources/Lisa/MenuBarController.swift b/packaging/mac-client/Sources/Lisa/MenuBarController.swift index 6ac58b9..3c4df5d 100644 --- a/packaging/mac-client/Sources/Lisa/MenuBarController.swift +++ b/packaging/mac-client/Sources/Lisa/MenuBarController.swift @@ -93,9 +93,9 @@ final class MenuBarController: NSObject, NSPopoverDelegate { private func refreshOnce() async { // Two parallel requests — ping (mood + desire + idle message) and - // sessions (Claude Code active). Either failing means "offline". + // sessions (ALL observed agents). Either failing means "offline". let pingURL = URL(string: "http://localhost:5757/api/island/ping")! - let sessURL = URL(string: "http://localhost:5757/api/claude/sessions")! + let sessURL = URL(string: "http://localhost:5757/api/agents/sessions")! let cfg = URLRequest.CachePolicy.reloadIgnoringLocalCacheData async let pingResult: PingDTO? = await Self.fetch(PingDTO.self, url: pingURL, cachePolicy: cfg) @@ -379,7 +379,7 @@ final class MenuBarController: NSObject, NSPopoverDelegate { v.alignment = .leading v.spacing = 6 v.translatesAutoresizingMaskIntoConstraints = false - let t = NSTextField(labelWithString: "CLAUDE CODE · \(sessions.count) ACTIVE") + let t = NSTextField(labelWithString: "AGENTS · \(sessions.count) ACTIVE") t.font = .systemFont(ofSize: 10, weight: .semibold) t.textColor = .tertiaryLabelColor v.addArrangedSubview(t) @@ -387,18 +387,105 @@ final class MenuBarController: NSObject, NSPopoverDelegate { let row = NSStackView() row.orientation = .horizontal row.spacing = 6 - let orange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1) - let red = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1) - if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: orange)) } - if working > 0 { row.addArrangedSubview(chip("\(working) working", color: orange.withAlphaComponent(0.72))) } - if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: red)) } + if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: Self.stateOrange)) } + if working > 0 { row.addArrangedSubview(chip("\(working) working", color: Self.stateOrange.withAlphaComponent(0.72))) } + if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: Self.stateRed)) } if waiting == 0 && working == 0 && errors == 0 { row.addArrangedSubview(chip("idle", color: .systemGray)) } v.addArrangedSubview(row) + + // Per-agent rows, attention-sorted (error > waiting > working), capped. + let rank: [String: Int] = ["error": 0, "waiting": 1, "working": 2] + let sorted = sessions.sorted { (rank[$0.state] ?? 9) < (rank[$1.state] ?? 9) } + let cap = 6 + for s in sorted.prefix(cap) { v.addArrangedSubview(agentRow(s, inner: inner)) } + if sorted.count > cap { + let more = NSTextField(labelWithString: "+\(sorted.count - cap) more") + more.font = .systemFont(ofSize: 10) + more.textColor = .tertiaryLabelColor + v.addArrangedSubview(more) + } return v } + private static let stateOrange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1) + private static let stateRed = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1) + + /// One agent's row: ● state-dot · kind · branch/project label, with the + /// structural activity line beneath. Mirrors the GUI roster row. + private func agentRow(_ s: SessionDTO, inner: CGFloat) -> NSView { + let dotColor: NSColor = s.state == "error" ? Self.stateRed + : (s.state == "waiting" || s.state == "working") ? Self.stateOrange : .systemGray + + let dot = NSView() + dot.wantsLayer = true + dot.layer?.backgroundColor = dotColor.cgColor + dot.layer?.cornerRadius = 3.5 + dot.translatesAutoresizingMaskIntoConstraints = false + dot.widthAnchor.constraint(equalToConstant: 7).isActive = true + dot.heightAnchor.constraint(equalToConstant: 7).isActive = true + + let kind = NSTextField(labelWithString: (s.agent ?? "agent")) + kind.font = .systemFont(ofSize: 9, weight: .semibold) + kind.textColor = .tertiaryLabelColor + + let label = NSTextField(labelWithString: oneLine(agentLabel(s), 40)) + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = .labelColor + label.lineBreakMode = .byTruncatingTail + + let top = NSStackView(views: [dot, kind, label]) + top.orientation = .horizontal + top.spacing = 6 + top.alignment = .centerY + + let col = NSStackView(views: [top]) + col.orientation = .vertical + col.alignment = .leading + col.spacing = 1 + + let act = agentActivity(s) + if !act.isEmpty { + let actLabel = NSTextField(labelWithString: oneLine(act, 52)) + actLabel.font = .systemFont(ofSize: 10) + actLabel.textColor = .secondaryLabelColor + actLabel.lineBreakMode = .byTruncatingTail + col.addArrangedSubview(actLabel) + } + return col + } + + /// Branch label (strip "claude/"), else project. Mirrors rosterLabel. + private func agentLabel(_ s: SessionDTO) -> String { + if let b = s.activity?.gitBranch, !b.isEmpty { + return b.hasPrefix("claude/") ? String(b.dropFirst("claude/".count)) : b + } + return s.project ?? "agent" + } + + /// One-line structural activity. Mirrors agent-roster.ts formatActivity. + private func agentActivity(_ s: SessionDTO) -> String { + guard let a = s.activity else { return "" } + if let p = a.pendingPermission, !p.isEmpty { return "⚠ wants to run " + p } + var bits: [String] = [] + if let e = a.lastError, !e.isEmpty { bits.append("✗ " + e) } + var prog: [String] = [] + if let n = a.turnCount, n > 0 { prog.append("turn \(n)") } + if let tk = a.tokens { + let tot = (tk.input ?? 0) + (tk.output ?? 0) + if tot > 0 { prog.append(tot >= 1000 ? "\(Int((Double(tot) / 1000).rounded()))k tok" : "\(tot) tok") } + } + if !prog.isEmpty { bits.append(prog.joined(separator: " ")) } + if let c = a.lastCommandName, !c.isEmpty { bits.append("$ " + c) } + let tool = a.lastTools?.last ?? "" + let file = (a.filesTouched?.last).map { String($0.split(separator: "/").last ?? "") } ?? "" + if !tool.isEmpty && !file.isEmpty { bits.append(tool + " " + file) } + else if !tool.isEmpty { bits.append(tool) } + else if !file.isEmpty { bits.append(file) } + return bits.joined(separator: " · ") + } + /// A small rounded status chip (tinted background + colored label). private func chip(_ text: String, color: NSColor) -> NSView { let label = NSTextField(labelWithString: text) @@ -499,6 +586,23 @@ final class MenuBarController: NSObject, NSPopoverDelegate { } private struct SessionDTO: Decodable { let state: String + var agent: String? + var project: String? + var activity: ActivityDTO? + } + private struct ActivityDTO: Decodable { + var gitBranch: String? + var turnCount: Int? + var tokens: TokensDTO? + var lastTools: [String]? + var filesTouched: [String]? + var lastCommandName: String? + var lastError: String? + var pendingPermission: String? + } + private struct TokensDTO: Decodable { + var input: Int? + var output: Int? } private struct PingDTO: Decodable { let online: Bool? diff --git a/src/web/agent-roster.test.ts b/src/web/agent-roster.test.ts index 1881833..8645919 100644 --- a/src/web/agent-roster.test.ts +++ b/src/web/agent-roster.test.ts @@ -1,6 +1,6 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { mergeAgentSession, aggregateAgentState, rosterLabel, type RosterSession } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity, type RosterSession } from "./agent-roster.js"; const NOW = 1_700_000_000_000; const WINDOW = 30 * 60_000; @@ -57,7 +57,7 @@ describe("source-injection safety (island injects these verbatim)", () => { // island.ts embeds `${mergeAgentSession}` into the page; the browser eval's // the function source. Assert each is self-contained: its source eval's to a // working function with no external references. - for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel]) { + for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity]) { test(`${fn.name} source eval's to a working function`, () => { // eslint-disable-next-line @typescript-eslint/no-implied-eval const rebuilt = new Function(`return (${fn.toString()})`)() as (...a: unknown[]) => unknown; @@ -66,6 +66,8 @@ describe("source-injection safety (island injects these verbatim)", () => { assert.equal(rebuilt([s({ state: "error" })], NOW, WINDOW), "error"); } else if (fn === rosterLabel) { assert.equal(rebuilt(s({ project: "p" })), "p"); + } else if (fn === formatActivity) { + assert.equal(rebuilt(s({ activity: { pendingPermission: "bash" } })), "⚠ wants to run bash"); } else { assert.equal((rebuilt([], s(), NOW, WINDOW) as RosterSession[]).length, 1); } @@ -73,6 +75,34 @@ describe("source-injection safety (island injects these verbatim)", () => { } }); +describe("formatActivity", () => { + test("no activity → empty", () => { + assert.equal(formatActivity(s({ activity: undefined })), ""); + }); + test("pendingPermission wins over everything", () => { + assert.equal( + formatActivity(s({ activity: { pendingPermission: "Bash", lastError: "x", turnCount: 5 } })), + "⚠ wants to run Bash", + ); + }); + test("error · progress · cmd · tool file, in order", () => { + const out = formatActivity(s({ activity: { + lastError: "ENOENT", + turnCount: 12, + tokens: { input: 1200, output: 800 }, + lastCommandName: "npm", + lastTools: ["Read", "Edit"], + filesTouched: ["/a/b/foo.ts"], + } })); + assert.equal(out, "✗ ENOENT · turn 12 2k tok · $ npm · Edit foo.ts"); + }); + test("tokens under 1000 shown raw; only-tools / only-files handled", () => { + assert.equal(formatActivity(s({ activity: { turnCount: 1, tokens: { input: 300, output: 100 } } })), "turn 1 400 tok"); + assert.equal(formatActivity(s({ activity: { lastTools: ["Grep"] } })), "Grep"); + assert.equal(formatActivity(s({ activity: { filesTouched: ["/x/y/bar.py"] } })), "bar.py"); + }); +}); + describe("rosterLabel", () => { test("prefers the git branch, stripping the claude/ prefix", () => { assert.equal(rosterLabel(s({ activity: { gitBranch: "claude/fix-sentry-build-upload" } })), "fix-sentry-build-upload"); diff --git a/src/web/agent-roster.ts b/src/web/agent-roster.ts index 7bbc6dd..e0839fd 100644 --- a/src/web/agent-roster.ts +++ b/src/web/agent-roster.ts @@ -83,3 +83,52 @@ export function rosterLabel(s: RosterSession): string { } return s.project; } + +/** + * One-line structural summary of a session's Tier-2 activity — what it's *doing* + * and how far along, never conversation content: a pending-permission warning, + * else "✗ error · turn N · Mk tok · $ cmd · Tool file". Returns "" when there's + * no activity. Pure + self-contained (island injects this source; the + * injection-safety test guards it — no imports, no named const-arrows). + */ +export function formatActivity(s: RosterSession): string { + const a = s.activity as + | { + pendingPermission?: unknown; + lastError?: unknown; + turnCount?: unknown; + tokens?: { input?: unknown; output?: unknown }; + lastCommandName?: unknown; + lastTools?: unknown; + filesTouched?: unknown; + } + | undefined; + if (!a || typeof a !== "object") return ""; + if (typeof a.pendingPermission === "string" && a.pendingPermission) { + return "⚠ wants to run " + a.pendingPermission; + } + const bits: string[] = []; + if (typeof a.lastError === "string" && a.lastError) bits.push("✗ " + a.lastError); + + const prog: string[] = []; + if (typeof a.turnCount === "number" && a.turnCount > 0) prog.push("turn " + a.turnCount); + if (a.tokens && typeof a.tokens === "object") { + const tin = typeof a.tokens.input === "number" ? a.tokens.input : 0; + const tout = typeof a.tokens.output === "number" ? a.tokens.output : 0; + const tot = tin + tout; + if (tot > 0) prog.push(tot >= 1000 ? Math.round(tot / 1000) + "k tok" : tot + " tok"); + } + if (prog.length) bits.push(prog.join(" ")); + + if (typeof a.lastCommandName === "string" && a.lastCommandName) bits.push("$ " + a.lastCommandName); + + const tools = Array.isArray(a.lastTools) ? (a.lastTools as unknown[]) : []; + const tool = tools.length ? String(tools[tools.length - 1]) : ""; + const files = Array.isArray(a.filesTouched) ? (a.filesTouched as unknown[]) : []; + const file = files.length ? String(files[files.length - 1]).split("/").pop() || "" : ""; + if (tool && file) bits.push(tool + " " + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + + return bits.join(" · "); +} diff --git a/src/web/island.ts b/src/web/island.ts index 7085a4c..9477b87 100644 --- a/src/web/island.ts +++ b/src/web/island.ts @@ -13,7 +13,7 @@ * so the browser runs the exact unit-tested code instead of a drifting copy. */ -import { mergeAgentSession, aggregateAgentState, rosterLabel } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity } from "./agent-roster.js"; export const ISLAND_HTML = ` @@ -562,7 +562,7 @@ export const ISLAND_HTML = `