Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions LilAgents/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
// MARK: - Provider

enum AgentProvider: String, CaseIterable {
case claude, codex, copilot, gemini
case claude, codex, copilot, gemini, live

private static let defaultsKey = "selectedProvider"

Expand All @@ -23,11 +23,15 @@ enum AgentProvider: String, CaseIterable {
case .codex: return "Codex"
case .copilot: return "Copilot"
case .gemini: return "Gemini"
case .live: return "Live Session"
}
}

var inputPlaceholder: String {
"Ask \(displayName)..."
switch self {
case .live: return "Send a note to the session..."
default: return "Ask \(displayName)..."
}
}

/// Returns provider name styled per theme format.
Expand All @@ -49,17 +53,32 @@ enum AgentProvider: String, CaseIterable {
return "To install, run this in Terminal:\n brew install copilot-cli\n\nOr: npm install -g @github/copilot-cli"
case .gemini:
return "To install, run this in Terminal:\n npm install -g @google/gemini-cli\n\nThen authenticate:\n gemini auth"
case .live:
return "To use Live Session, install the bridge hook.\nSee: hooks/lil-agents-bridge.mjs in the project."
}
}

/// Whether this provider requires session selection before use.
var requiresSessionPicker: Bool {
self == .live && Self.selectedLiveSession == nil
}

/// The currently selected live session (set via menu bar).
static var selectedLiveSession: LiveSession.DiscoveredSession?

func createSession() -> any AgentSession {
switch self {
case .claude: return ClaudeSession()
case .codex: return CodexSession()
case .copilot: return CopilotSession()
case .gemini: return GeminiSession()
case .live: fatalError("Use createLiveSession(sessionId:projectName:) instead")
}
}

func createLiveSession(sessionId: String, projectName: String) -> LiveSession {
LiveSession(sessionId: sessionId, projectName: projectName)
}
}

// MARK: - Title Format
Expand Down
94 changes: 89 additions & 5 deletions LilAgents/LilAgentsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Provider submenu
let providerItem = NSMenuItem(title: "Provider", action: nil, keyEquivalent: "")
let providerMenu = NSMenu()
providerMenu.delegate = self
for (i, provider) in AgentProvider.allCases.enumerated() {
let item = NSMenuItem(title: provider.displayName, action: #selector(switchProvider(_:)), keyEquivalent: "")
item.tag = i
item.state = provider == AgentProvider.current ? .on : .off
providerMenu.addItem(item)
if provider == .live {
// Live Session gets a submenu with discovered sessions
let liveItem = NSMenuItem(title: provider.displayName, action: nil, keyEquivalent: "")
liveItem.tag = i
liveItem.state = provider == AgentProvider.current ? .on : .off
let liveMenu = NSMenu()
liveMenu.delegate = self
liveItem.submenu = liveMenu
providerMenu.addItem(liveItem)
} else {
let item = NSMenuItem(title: provider.displayName, action: #selector(switchProvider(_:)), keyEquivalent: "")
item.tag = i
item.state = provider == AgentProvider.current ? .on : .off
providerMenu.addItem(item)
}
}
providerItem.submenu = providerMenu
menu.addItem(providerItem)
Expand Down Expand Up @@ -226,4 +238,76 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

extension AppDelegate: NSMenuDelegate {}
extension AppDelegate: NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
// Check if this is the Live Session submenu
if let parentMenu = statusItem?.menu?.item(withTitle: "Provider")?.submenu {
for item in parentMenu.items where item.title == "Live Session" && item.submenu === menu {
rebuildLiveSessionMenu(menu)
return
}
}
}

private func rebuildLiveSessionMenu(_ menu: NSMenu) {
menu.removeAllItems()

let sessions = LiveSession.discoverSessions()
let selectedId = AgentProvider.selectedLiveSession?.id

if sessions.isEmpty {
let emptyItem = NSMenuItem(title: "No active sessions", action: nil, keyEquivalent: "")
emptyItem.isEnabled = false
menu.addItem(emptyItem)
} else {
for (i, session) in sessions.enumerated() {
let title = "\(session.projectName) — \(session.age)"
let item = NSMenuItem(title: title, action: #selector(selectLiveSession(_:)), keyEquivalent: "")
item.tag = i
item.state = session.id == selectedId ? .on : .off
item.representedObject = session.id
item.toolTip = session.cwd
menu.addItem(item)
}
}

menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Refresh", action: #selector(refreshLiveSessions(_:)), keyEquivalent: ""))
}

@objc func selectLiveSession(_ sender: NSMenuItem) {
let sessions = LiveSession.discoverSessions()
guard sender.tag < sessions.count else { return }
let chosen = sessions[sender.tag]

// Set provider to .live and store selected session
AgentProvider.current = .live
AgentProvider.selectedLiveSession = chosen

// Update provider menu checkmarks
if let providerMenu = statusItem?.menu?.item(withTitle: "Provider")?.submenu {
let liveIdx = AgentProvider.allCases.firstIndex(of: .live)!
for item in providerMenu.items {
item.state = item.tag == liveIdx ? .on : .off
}
}

// Terminate existing sessions and reconnect with new live session
controller?.characters.forEach { char in
char.session?.terminate()
char.session = nil
if char.isIdleForPopover {
char.closePopover()
}
char.popoverWindow?.orderOut(nil)
char.popoverWindow = nil
char.terminalView = nil
char.thinkingBubbleWindow?.orderOut(nil)
char.thinkingBubbleWindow = nil
}
}

@objc func refreshLiveSessions(_ sender: NSMenuItem) {
// The menu will rebuild on next open via menuNeedsUpdate
}
}
20 changes: 8 additions & 12 deletions LilAgents/LilAgentsController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ class LilAgentsController {
char1.fullSpeedStart = 3.75
char1.decelStart = 8.0
char1.walkStop = 8.5
char1.walkAmountRange = 0.4...0.65
char1.walkAmountRange = 0.5...0.9

let char2 = WalkerCharacter(videoName: "walk-jazz-01")
char2.accelStart = 3.9
char2.fullSpeedStart = 4.5
char2.decelStart = 8.0
char2.walkStop = 8.75
char2.walkAmountRange = 0.35...0.6
char2.walkAmountRange = 0.5...0.9
char1.yOffset = -3
char2.yOffset = -7
char1.characterColor = NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0)
Expand All @@ -34,7 +34,7 @@ class LilAgentsController {
char2.positionProgress = 0.7

char1.pauseEndTime = CACurrentMediaTime() + Double.random(in: 0.5...2.0)
char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 8.0...14.0)
char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 2.0...5.0)

char1.setup()
char2.setup()
Expand Down Expand Up @@ -203,22 +203,18 @@ class LilAgentsController {
let dockWidth: CGFloat
let dockTopY: CGFloat

// Dock is on this screen — constrain to dock area
(dockX, dockWidth) = getDockIconArea(screenWidth: screenWidth)
// Let characters roam the full screen width with small padding
let padding: CGFloat = 20
dockX = screen.frame.origin.x + padding
dockWidth = screenWidth - padding * 2
dockTopY = screen.visibleFrame.origin.y

updateDebugLine(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY)

let activeChars = characters.filter { $0.window.isVisible && $0.isManuallyVisible }

let now = CACurrentMediaTime()
let anyWalking = activeChars.contains { $0.isWalking }
for char in activeChars {
if char.isIdleForPopover { continue }
if char.isPaused && now >= char.pauseEndTime && anyWalking {
char.pauseEndTime = now + Double.random(in: 5.0...10.0)
}
}
// Allow both characters to walk simultaneously — no blocking
for char in activeChars {
char.update(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY)
}
Expand Down
Loading