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
135 changes: 135 additions & 0 deletions LilAgents/CharacterPack.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import AppKit

struct CharacterConfig {
let videoName: String
let displayName: String
let accelStart: CFTimeInterval
let fullSpeedStart: CFTimeInterval
let decelStart: CFTimeInterval
let walkStop: CFTimeInterval
let walkAmountRange: ClosedRange<CGFloat>
let yOffset: CGFloat
let flipXOffset: CGFloat
let characterColor: NSColor
let initialPosition: CGFloat
let initialPauseRange: ClosedRange<Double>
}

struct CharacterPack {
let id: String
let name: String
let characters: [CharacterConfig]
let thinkingPhrases: [String]
let completionPhrases: [String]
let onboardingGreeting: String
let onboardingWelcome: String

// MARK: - Presets

static let `default` = CharacterPack(
id: "default",
name: "Bruce & Jazz",
characters: [
CharacterConfig(
videoName: "walk-bruce-01", displayName: "Bruce",
accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5,
walkAmountRange: 0.4...0.65, yOffset: -3, flipXOffset: 0,
characterColor: NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0),
initialPosition: 0.3, initialPauseRange: 0.5...2.0
),
CharacterConfig(
videoName: "walk-jazz-01", displayName: "Jazz",
accelStart: 3.9, fullSpeedStart: 4.5, decelStart: 8.0, walkStop: 8.75,
walkAmountRange: 0.35...0.6, yOffset: -7, flipXOffset: -9,
characterColor: NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0),
initialPosition: 0.7, initialPauseRange: 8.0...14.0
),
],
thinkingPhrases: [
"hmm...", "thinking...", "one sec...", "ok hold on",
"let me check", "working on it", "almost...", "bear with me",
"on it!", "gimme a sec", "brb", "processing...",
"hang tight", "just a moment", "figuring it out",
"crunching...", "reading...", "looking..."
],
completionPhrases: [
"done!", "all set!", "ready!", "here you go", "got it!",
"finished!", "ta-da!", "voila!"
],
onboardingGreeting: "hi!",
onboardingWelcome: """
hey! we're bruce and jazz — your lil dock agents.

click either of us to open a Claude AI chat. we'll walk around while you work and let you know when Claude's thinking.

check the menu bar icon (top right) for themes, sounds, and more options.

click anywhere outside to dismiss, then click us again to start chatting.
"""
)

static let droids = CharacterPack(
id: "droids",
name: "Droids",
characters: [
CharacterConfig(
videoName: "walk-r2d2-01", displayName: "R2-Do2",
accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5,
walkAmountRange: 0.4...0.65, yOffset: 30, flipXOffset: 0,
characterColor: NSColor(red: 0.2, green: 0.5, blue: 0.85, alpha: 1.0),
initialPosition: 0.2, initialPauseRange: 0.5...2.0
),
CharacterConfig(
videoName: "walk-c3po-01", displayName: "C-3POa",
accelStart: 3.9, fullSpeedStart: 4.5, decelStart: 8.0, walkStop: 8.75,
walkAmountRange: 0.35...0.6, yOffset: 30, flipXOffset: -9,
characterColor: NSColor(red: 0.85, green: 0.72, blue: 0.2, alpha: 1.0),
initialPosition: 0.5, initialPauseRange: 8.0...14.0
),
CharacterConfig(
videoName: "walk-bb8-01", displayName: "BB-Gr8",
accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5,
walkAmountRange: 0.45...0.7, yOffset: 30, flipXOffset: 0,
characterColor: NSColor(red: 0.92, green: 0.5, blue: 0.15, alpha: 1.0),
initialPosition: 0.8, initialPauseRange: 4.0...8.0
),
],
thinkingPhrases: [
"boop beep...", "searching feelings...", "consulting the Force...", "hold on...",
"recalculating hyperspace...", "scanning...", "almost there...", "patience you must have",
"on it, master!", "standby...", "computing...", "processing...",
"stay on target...", "one moment...", "the Force is strong...",
"beep bwoop...", "accessing archives...", "calculating odds..."
],
completionPhrases: [
"the Force is with you!", "mission complete!", "ready, master!", "these are the droids!",
"done it is!", "I have a good feeling!", "roger roger!", "may the Force..."
],
onboardingGreeting: "beep boop!",
onboardingWelcome: """
greetings! we're R2-Do2, C-3POa, and BB-Gr8 — your lil dock droids.

click any of us to open a Claude AI chat. we'll patrol the dock while you work and let you know when the Force is computing.

check the menu bar icon (top right) for themes, sounds, and more options.

click anywhere outside to dismiss, then click us again to start chatting. may the Force be with you!
"""
)

static let allPacks: [CharacterPack] = [.default, .droids]
static var current: CharacterPack = .default

// MARK: - Persistence

private static let userDefaultsKey = "selectedCharacterPack"

static func loadSaved() -> CharacterPack {
let id = UserDefaults.standard.string(forKey: userDefaultsKey) ?? "default"
return allPacks.first { $0.id == id } ?? .default
}

static func saveCurrent() {
UserDefaults.standard.set(current.id, forKey: userDefaultsKey)
}
}
60 changes: 36 additions & 24 deletions LilAgents/LilAgentsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Menu Bar

func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if statusItem == nil {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
}
if let button = statusItem?.button {
button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "figure.walk", accessibilityDescription: "lil agents")
}

let menu = NSMenu()

let char1Item = NSMenuItem(title: "Bruce", action: #selector(toggleChar1), keyEquivalent: "1")
char1Item.state = .on
menu.addItem(char1Item)

let char2Item = NSMenuItem(title: "Jazz", action: #selector(toggleChar2), keyEquivalent: "2")
char2Item.state = .on
menu.addItem(char2Item)
// Dynamic character toggle items
for (i, config) in CharacterPack.current.characters.enumerated() {
let item = NSMenuItem(title: config.displayName, action: #selector(toggleCharacter(_:)), keyEquivalent: "\(i + 1)")
item.tag = i
item.state = .on
menu.addItem(item)
}

menu.addItem(NSMenuItem.separator())

Expand All @@ -63,6 +65,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
themeItem.submenu = themeMenu
menu.addItem(themeItem)

// Character pack submenu
let packItem = NSMenuItem(title: "Characters", action: nil, keyEquivalent: "")
let packMenu = NSMenu()
for (i, pack) in CharacterPack.allPacks.enumerated() {
let item = NSMenuItem(title: pack.name, action: #selector(switchPack(_:)), keyEquivalent: "")
item.tag = i
item.state = pack.id == CharacterPack.current.id ? .on : .off
packMenu.addItem(item)
}
packItem.submenu = packMenu
menu.addItem(packItem)

// Display submenu
let displayItem = NSMenuItem(title: "Display", action: nil, keyEquivalent: "")
let displayMenu = NSMenu()
Expand Down Expand Up @@ -130,6 +144,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

@objc func switchPack(_ sender: NSMenuItem) {
let idx = sender.tag
guard idx < CharacterPack.allPacks.count else { return }

controller?.switchPack(CharacterPack.allPacks[idx])

// Rebuild menu to reflect new character names
setupMenuBar()
}

@objc func switchDisplay(_ sender: NSMenuItem) {
let idx = sender.tag
controller?.pinnedScreenIndex = idx
Expand All @@ -141,22 +165,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

@objc func toggleChar1(_ sender: NSMenuItem) {
guard let chars = controller?.characters, chars.count > 0 else { return }
let char = chars[0]
if char.window.isVisible {
char.window.orderOut(nil)
char.queuePlayer.pause()
sender.state = .off
} else {
char.window.orderFrontRegardless()
sender.state = .on
}
}

@objc func toggleChar2(_ sender: NSMenuItem) {
guard let chars = controller?.characters, chars.count > 1 else { return }
let char = chars[1]
@objc func toggleCharacter(_ sender: NSMenuItem) {
let idx = sender.tag
guard let chars = controller?.characters, idx < chars.count else { return }
let char = chars[idx]
if char.window.isVisible {
char.window.orderOut(nil)
char.queuePlayer.pause()
Expand Down
79 changes: 39 additions & 40 deletions LilAgents/LilAgentsController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,8 @@ class LilAgentsController {
private static let onboardingKey = "hasCompletedOnboarding"

func start() {
let char1 = WalkerCharacter(videoName: "walk-bruce-01")
char1.accelStart = 3.0
char1.fullSpeedStart = 3.75
char1.decelStart = 8.0
char1.walkStop = 8.5
char1.walkAmountRange = 0.4...0.65

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
char1.yOffset = -3
char2.yOffset = -7
char1.characterColor = NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0)
char2.characterColor = NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0)

char1.flipXOffset = 0
char2.flipXOffset = -9

char1.positionProgress = 0.3
char2.positionProgress = 0.7

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

char1.setup()
char2.setup()

characters = [char1, char2]
characters.forEach { $0.controller = self }
CharacterPack.current = CharacterPack.loadSaved()
loadCharacters(from: CharacterPack.current)

setupDebugLine()
startDisplayLink()
Expand All @@ -49,16 +19,45 @@ class LilAgentsController {
}
}

func loadCharacters(from pack: CharacterPack) {
for config in pack.characters {
let char = WalkerCharacter(videoName: config.videoName)
char.applyConfig(config)
char.setup()
characters.append(char)
}
characters.forEach { $0.controller = self }
}

func switchPack(_ pack: CharacterPack) {
characters.forEach { char in
char.claudeSession?.terminate()
char.claudeSession = nil
char.popoverWindow?.orderOut(nil)
char.popoverWindow = nil
char.terminalView = nil
char.thinkingBubbleWindow?.orderOut(nil)
char.thinkingBubbleWindow = nil
char.window.orderOut(nil)
}
characters.removeAll()

CharacterPack.current = pack
CharacterPack.saveCurrent()

loadCharacters(from: pack)
}

private func triggerOnboarding() {
guard let bruce = characters.first else { return }
bruce.isOnboarding = true
// Show "hi!" bubble after a short delay so the character is visible first
guard let first = characters.first else { return }
let greeting = CharacterPack.current.onboardingGreeting
first.isOnboarding = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
bruce.currentPhrase = "hi!"
bruce.showingCompletion = true
bruce.completionBubbleExpiry = CACurrentMediaTime() + 600 // stays until clicked
bruce.showBubble(text: "hi!", isCompletion: true)
bruce.playCompletionSound()
first.currentPhrase = greeting
first.showingCompletion = true
first.completionBubbleExpiry = CACurrentMediaTime() + 600
first.showBubble(text: greeting, isCompletion: true)
first.playCompletionSound()
}
}

Expand Down
39 changes: 18 additions & 21 deletions LilAgents/WalkerCharacter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AppKit

class WalkerCharacter {
let videoName: String
var displayName: String = ""
var window: NSWindow!
var playerLayer: AVPlayerLayer!
var queuePlayer: AVQueuePlayer!
Expand Down Expand Up @@ -59,6 +60,20 @@ class WalkerCharacter {
self.videoName = videoName
}

func applyConfig(_ config: CharacterConfig) {
displayName = config.displayName
accelStart = config.accelStart
fullSpeedStart = config.fullSpeedStart
decelStart = config.decelStart
walkStop = config.walkStop
walkAmountRange = config.walkAmountRange
yOffset = config.yOffset
flipXOffset = config.flipXOffset
characterColor = config.characterColor
positionProgress = config.initialPosition
pauseEndTime = CACurrentMediaTime() + Double.random(in: config.initialPauseRange)
}

// MARK: - Setup

func setup() {
Expand Down Expand Up @@ -136,15 +151,7 @@ class WalkerCharacter {
// Show static welcome message instead of Claude terminal
terminalView?.inputField.isEditable = false
terminalView?.inputField.placeholderString = ""
let welcome = """
hey! we're bruce and jazz — your lil dock agents.

click either of us to open a Claude AI chat. we'll walk around while you work and let you know when Claude's thinking.

check the menu bar icon (top right) for themes, sounds, and more options.

click anywhere outside to dismiss, then click us again to start chatting.
"""
let welcome = CharacterPack.current.onboardingWelcome
terminalView?.appendStreamingText(welcome)
terminalView?.endStreaming()

Expand Down Expand Up @@ -393,18 +400,8 @@ class WalkerCharacter {

// MARK: - Thinking Bubble

private static let thinkingPhrases = [
"hmm...", "thinking...", "one sec...", "ok hold on",
"let me check", "working on it", "almost...", "bear with me",
"on it!", "gimme a sec", "brb", "processing...",
"hang tight", "just a moment", "figuring it out",
"crunching...", "reading...", "looking..."
]

private static let completionPhrases = [
"done!", "all set!", "ready!", "here you go", "got it!",
"finished!", "ta-da!", "voila!"
]
private static var thinkingPhrases: [String] { CharacterPack.current.thinkingPhrases }
private static var completionPhrases: [String] { CharacterPack.current.completionPhrases }

private var lastPhraseUpdate: CFTimeInterval = 0
var currentPhrase = ""
Expand Down
Binary file added LilAgents/walk-bb8-01.mov
Binary file not shown.
Binary file added LilAgents/walk-c3po-01.mov
Binary file not shown.
Binary file added LilAgents/walk-r2d2-01.mov
Binary file not shown.
Loading