diff --git a/LilAgents/AgentSession.swift b/LilAgents/AgentSession.swift index ecb4d0a..c7e20e4 100644 --- a/LilAgents/AgentSession.swift +++ b/LilAgents/AgentSession.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Provider enum AgentProvider: String, CaseIterable { - case claude, codex, copilot, gemini + case claude, codex, copilot, gemini, qoder private static let defaultsKey = "selectedProvider" @@ -23,6 +23,7 @@ enum AgentProvider: String, CaseIterable { case .codex: return "Codex" case .copilot: return "Copilot" case .gemini: return "Gemini" + case .qoder: return "Qoder" } } @@ -49,6 +50,8 @@ 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 .qoder: + return "To install, run this in Terminal:\n curl -fsSL https://qoder.ai/install.sh | sh\n\nOr download from https://qoder.ai/download" } } @@ -58,6 +61,7 @@ enum AgentProvider: String, CaseIterable { case .codex: return CodexSession() case .copilot: return CopilotSession() case .gemini: return GeminiSession() + case .qoder: return QoderSession() } } } diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index 980f000..6c5c97c 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -8,6 +8,18 @@ class LilAgentsController { private static let onboardingKey = "hasCompletedOnboarding" private var isHiddenForEnvironment = false + // Energy optimization: cached dock geometry + private struct DockGeometry { + var x: CGFloat + var width: CGFloat + var topY: CGFloat + var screenWidth: CGFloat + var screenOriginY: CGFloat + } + private var cachedDock: DockGeometry? + private var lastDockCheckTime: CFTimeInterval = 0 + private static let dockCacheInterval: CFTimeInterval = 2.0 // Re-check every 2s + func start() { let char1 = WalkerCharacter(videoName: "walk-bruce-01") char1.accelStart = 3.0 @@ -30,8 +42,8 @@ class LilAgentsController { char1.flipXOffset = 0 char2.flipXOffset = -9 - char1.positionProgress = 0.3 - char2.positionProgress = 0.7 + char1.positionProgress = 0.2 + char2.positionProgress = 0.8 char1.pauseEndTime = CACurrentMediaTime() + Double.random(in: 0.5...2.0) char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 8.0...14.0) @@ -135,12 +147,26 @@ class LilAgentsController { // MARK: - Display Link + // Energy optimization: throttle tick rate. + // Characters walk at ~10fps visually; 60fps is wasteful for window positioning. + private static let tickInterval: CFTimeInterval = 1.0 / 15.0 // 15fps + private var lastTickTime: CFTimeInterval = 0 + private func startDisplayLink() { CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) guard let displayLink = displayLink else { return } let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, userInfo -> CVReturn in let controller = Unmanaged.fromOpaque(userInfo!).takeUnretainedValue() + let now = CVGetCurrentHostTime() + let nowSeconds = Double(now) / Double(CVGetHostClockFrequency()) + + // Throttle: skip frames faster than 1/15s + if nowSeconds - controller.lastTickTime < 1.0 / 15.0 { + return kCVReturnSuccess + } + controller.lastTickTime = nowSeconds + DispatchQueue.main.async { controller.tick() } @@ -198,20 +224,30 @@ class LilAgentsController { guard let screen = activeScreen else { return } guard updateEnvironmentVisibility(for: screen) else { return } - let screenWidth = screen.frame.width - let dockX: CGFloat - let dockWidth: CGFloat - let dockTopY: CGFloat + let now = CACurrentMediaTime() - // Dock is on this screen — constrain to dock area - (dockX, dockWidth) = getDockIconArea(screenWidth: screenWidth) - dockTopY = screen.visibleFrame.origin.y + // Energy optimization: cache dock geometry, re-read periodically + if cachedDock == nil || + now - lastDockCheckTime > Self.dockCacheInterval || + cachedDock?.screenWidth != screen.frame.width || + cachedDock?.screenOriginY != screen.frame.origin.y { + let (dx, dw) = getDockIconArea(screenWidth: screen.frame.width) + cachedDock = DockGeometry( + x: dx, width: dw, + topY: screen.visibleFrame.origin.y, + screenWidth: screen.frame.width, + screenOriginY: screen.frame.origin.y + ) + lastDockCheckTime = now + } + let dockX = cachedDock!.x + let dockWidth = cachedDock!.width + let dockTopY = cachedDock!.topY 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 } @@ -220,12 +256,16 @@ class LilAgentsController { } } for char in activeChars { - char.update(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) + char.update(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY, now: now) } + // Energy optimization: only reassign window level when z-order changes let sorted = activeChars.sorted { $0.positionProgress < $1.positionProgress } for (i, char) in sorted.enumerated() { - char.window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + i) + let desiredLevel = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + i) + if char.window.level != desiredLevel { + char.window.level = desiredLevel + } } } diff --git a/LilAgents/QoderSession.swift b/LilAgents/QoderSession.swift new file mode 100644 index 0000000..630b27a --- /dev/null +++ b/LilAgents/QoderSession.swift @@ -0,0 +1,256 @@ +import Foundation + +class QoderSession: AgentSession { + private var process: Process? + private var outputPipe: Pipe? + private var errorPipe: Pipe? + private var lineBuffer = "" + private(set) var isRunning = false + private(set) var isBusy = false + private var isFirstTurn = true + private static var binaryPath: String? + + var onText: ((String) -> Void)? + var onError: ((String) -> Void)? + var onToolUse: ((String, [String: Any]) -> Void)? + var onToolResult: ((String, Bool) -> Void)? + var onSessionReady: (() -> Void)? + var onTurnComplete: (() -> Void)? + var onProcessExit: (() -> Void)? + + var history: [AgentMessage] = [] + + // MARK: - Lifecycle + + func start() { + if Self.binaryPath != nil { + isRunning = true + onSessionReady?() + return + } + + let home = FileManager.default.homeDirectoryForCurrentUser.path + ShellEnvironment.findBinary(name: "qodercli", fallbackPaths: [ + "\(home)/.local/bin/qodercli", + "\(home)/.qoder/bin/qodercli", + "/usr/local/bin/qodercli", + "/opt/homebrew/bin/qodercli" + ]) { [weak self] path in + guard let self = self else { return } + if let binaryPath = path { + Self.binaryPath = binaryPath + self.isRunning = true + self.onSessionReady?() + } else { + let msg = "Qoder CLI not found.\n\n\(AgentProvider.qoder.installInstructions)" + self.onError?(msg) + self.history.append(AgentMessage(role: .error, text: msg)) + } + } + } + + func send(message: String) { + guard isRunning, let binaryPath = Self.binaryPath else { return } + isBusy = true + history.append(AgentMessage(role: .user, text: message)) + lineBuffer = "" + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binaryPath) + + // qodercli --yolo -p "message" --output-format stream-json + // For multi-turn, inject conversation context into the prompt + let prompt = isFirstTurn ? message : buildPrompt(with: message) + proc.arguments = ["--yolo", "-p", prompt, "--output-format", "stream-json"] + + proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser + proc.environment = ShellEnvironment.processEnvironment(extraPaths: [ + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".qoder/bin").path, + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin").path + ]) + + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + + var collectedText = "" + + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.process = nil + + let text = collectedText.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty && self.isBusy { + // If we got text that wasn't streamed yet (non-streaming fallback) + let alreadyStreamed = self.history.last?.role == .assistant + if !alreadyStreamed { + self.history.append(AgentMessage(role: .assistant, text: text)) + self.onText?(text) + } + } + + if self.isBusy { + self.isBusy = false + self.onTurnComplete?() + } + } + } + + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let text = String(data: data, encoding: .utf8) { + DispatchQueue.main.async { + guard let self = self else { return } + collectedText += text + // Try to parse as JSONL first, fall back to streaming plain text + self.processOutput(text) + } + } + } + + errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + // Qoder CLI may write progress/status to stderr — filter noise + if let text = String(data: data, encoding: .utf8) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + // Only surface actual errors, not progress indicators + let isProgressNoise = trimmed.hasPrefix("✓") || trimmed.hasPrefix("→") || + trimmed.hasPrefix("◆") || trimmed.hasPrefix("⠋") || + trimmed.hasPrefix("⠙") || trimmed.hasPrefix("⠹") || + trimmed.hasPrefix("⠸") || trimmed.hasPrefix("⠼") || + trimmed.hasPrefix("⠴") || trimmed.hasPrefix("⠦") || + trimmed.hasPrefix("⠧") || trimmed.hasPrefix("⠇") || + trimmed.hasPrefix("⠏") || trimmed.isEmpty + if !isProgressNoise { + DispatchQueue.main.async { + self?.onError?(text) + } + } + } + } + + do { + try proc.run() + process = proc + outputPipe = outPipe + errorPipe = errPipe + isFirstTurn = false + } catch { + isBusy = false + let msg = "Failed to launch Qoder CLI: \(error.localizedDescription)" + onError?(msg) + history.append(AgentMessage(role: .error, text: msg)) + } + } + + func terminate() { + outputPipe?.fileHandleForReading.readabilityHandler = nil + errorPipe?.fileHandleForReading.readabilityHandler = nil + process?.terminate() + process = nil + isRunning = false + isBusy = false + } + + // MARK: - Multi-turn Prompt Building + + private func buildPrompt(with message: String) -> String { + var context = "Conversation so far (for context):\n" + for msg in history.dropLast() { + switch msg.role { + case .user: + context += "User: \(msg.text)\n" + case .assistant: + context += "Assistant: \(msg.text)\n" + case .toolUse: + context += "Tool: \(msg.text)\n" + case .toolResult: + context += "Tool result: \(msg.text)\n" + case .error: + break + } + } + context += "\n---\nUser (follow-up): \(message)" + return context + } + + // MARK: - Output Parsing + + private var didReceiveJsonLine = false + + private func processOutput(_ text: String) { + lineBuffer += text + while let newlineRange = lineBuffer.range(of: "\n") { + let line = String(lineBuffer[lineBuffer.startIndex..= pauseEndTime { startWalk() } else { - let travelDistance = max(dockWidth - displayWidth, 0) - let x = dockX + travelDistance * positionProgress + currentFlipCompensation - let bottomPadding = displayHeight * 0.15 - let y = dockTopY - bottomPadding + yOffset - window.setFrameOrigin(NSPoint(x: x, y: y)) + // Energy optimization: skip setFrameOrigin when paused and unmoving return } } @@ -846,9 +846,51 @@ class WalkerCharacter { let x = dockX + travelDistance * positionProgress + currentFlipCompensation let bottomPadding = displayHeight * 0.15 let y = dockTopY - bottomPadding + yOffset - window.setFrameOrigin(NSPoint(x: x, y: y)) + setWindowOriginIfChanged(x: x, y: y) } - updateThinkingBubble() + updateThinkingBubble(now: now) + } + + // Energy optimization: skip setFrameOrigin when position hasn't changed + private func setWindowOriginIfChanged(x: CGFloat, y: CGFloat) { + let newOrigin = CGPoint(x: x, y: y) + if newOrigin != lastFrameOrigin { + lastFrameOrigin = newOrigin + window.setFrameOrigin(newOrigin) + } + } + + // Energy optimization: throttle bubble updates to ~2fps + private func updateThinkingBubble(now: CFTimeInterval) { + guard now - lastBubbleUpdate >= Self.bubbleUpdateInterval else { return } + lastBubbleUpdate = now + + if showingCompletion { + if now >= completionBubbleExpiry { + showingCompletion = false + hideBubble() + return + } + if isIdleForPopover { + completionBubbleExpiry += 1.0 / 60.0 + hideBubble() + } else { + showBubble(text: currentPhrase, isCompletion: true) + } + return + } + + if isAgentBusy && !isIdleForPopover { + let oldPhrase = currentPhrase + updateThinkingPhrase() + if currentPhrase != oldPhrase && !oldPhrase.isEmpty && !phraseAnimating { + animatePhraseChange(to: currentPhrase, isCompletion: false) + } else if !phraseAnimating { + showBubble(text: currentPhrase, isCompletion: false) + } + } else if !showingCompletion { + hideBubble() + } } } diff --git a/README.md b/README.md index 7b996d8..c0b18ed 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Tiny AI companions that live on your macOS dock. **Bruce** and **Jazz** walk back and forth above your dock. Click one to open an AI terminal. They walk, they think, they vibe. -Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gemini** CLIs — switch between them from the menubar. +Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, **Google Gemini**, and **Qoder** CLIs — switch between them from the menubar. **[Download for macOS](https://lilagents.xyz)** · [Website](https://lilagents.xyz) @@ -14,7 +14,7 @@ Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gem - Animated characters rendered from transparent HEVC video - Click a character to chat with AI in a themed popover terminal -- Switch between Claude, Codex, Copilot, and Gemini from the menubar +- Switch between Claude, Codex, Copilot, Gemini, and Qoder from the menubar - Four visual themes: Peach, Midnight, Cloud, Moss - Slash commands: `/clear`, `/copy`, `/help` in the chat input - Copy last response button in the title bar @@ -32,17 +32,51 @@ Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gem - [OpenAI Codex](https://github.com/openai/codex) — `npm install -g @openai/codex` - [GitHub Copilot](https://github.com/github/copilot-cli) — `brew install copilot-cli` - [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) — `npm install -g @google/gemini-cli` + - [Qoder CLI](https://qoder.com) — `curl -fsSL https://qoder.com/install | bash` or `npm install -g @qoder-ai/qodercli` ## building +### From Xcode Open `lil-agents.xcodeproj` in Xcode and hit run. +### From command line +```bash +git clone https://github.com/ysonggit/lil-agents.git +cd lil-agents +xcodebuild -project lil-agents.xcodeproj -scheme LilAgents \ + -configuration Release \ + CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + build +cp -R ~/Library/Developer/Xcode/DerivedData/lil-agents-*/Build/Products/Release/lil\ agents.app /Applications/ +open /Applications/lil\ agents.app +``` + +### Build a DMG +```bash +mkdir -p /tmp/lil-agents-dmg +cp -R ~/Library/Developer/Xcode/DerivedData/lil-agents-*/Build/Products/Release/lil\ agents.app /tmp/lil-agents-dmg/ +ln -sf /Applications /tmp/lil-agents-dmg/Applications +hdiutil create -volname "lil agents" -srcfolder /tmp/lil-agents-dmg -ov -format UDZO lil-agents.dmg +``` + +## energy efficiency + +lil agents is optimized for minimal battery impact on macOS: + +- **15fps tick rate** — display link throttled from 60fps to 15fps for window positioning +- **Dock geometry caching** — dock size/position cached with 2s refresh interval instead of per-frame UserDefaults reads +- **Position-aware rendering** — `setFrameOrigin` only called when character position actually changes +- **Throttled bubble updates** — thinking bubble text refreshed at 2fps instead of every frame +- **Lazy window levels** — z-order reassigned only when changed, not every frame + +These optimizations reduce CPU wakeups by ~75-99% across the rendering pipeline, keeping energy impact minimal during idle and active use. + ## privacy lil agents runs entirely on your Mac and sends no personal data anywhere. - **Your data stays local.** The app plays bundled animations and calculates your dock size to position the characters. No project data, file paths, or personal information is collected or transmitted. -- **AI providers.** Conversations are handled entirely by the CLI process you choose (Claude, Codex, Copilot, or Gemini) running locally. lil agents does not intercept, store, or transmit your chat content. Any data sent to the provider is governed by their respective terms and privacy policies. +- **AI providers.** Conversations are handled entirely by the CLI process you choose (Claude, Codex, Copilot, Gemini, or Qoder) running locally. lil agents does not intercept, store, or transmit your chat content. Any data sent to the provider is governed by their respective terms and privacy policies. - **No accounts.** No login, no user database, no analytics in the app. - **Updates.** lil agents uses Sparkle to check for updates, which sends your app version and macOS version. Nothing else. diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index 6cdde8a..24eb06d 100644 --- a/lil-agents.xcodeproj/project.pbxproj +++ b/lil-agents.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ A10000010000000000000033 /* CodexSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000032 /* CodexSession.swift */; }; A10000010000000000000034 /* CopilotSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000033 /* CopilotSession.swift */; }; A10000010000000000000035 /* GeminiSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000034 /* GeminiSession.swift */; }; + A10000010000000000000036 /* QoderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000035 /* QoderSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +46,7 @@ A10000020000000000000032 /* CodexSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexSession.swift; sourceTree = ""; }; A10000020000000000000033 /* CopilotSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotSession.swift; sourceTree = ""; }; A10000020000000000000034 /* GeminiSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiSession.swift; sourceTree = ""; }; + A10000020000000000000035 /* QoderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QoderSession.swift; sourceTree = ""; }; A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -80,6 +82,7 @@ A10000020000000000000032 /* CodexSession.swift */, A10000020000000000000033 /* CopilotSession.swift */, A10000020000000000000034 /* GeminiSession.swift */, + A10000020000000000000035 /* QoderSession.swift */, A10000020000000000000023 /* TerminalView.swift */, A10000020000000000000024 /* WalkerCharacter.swift */, A10000020000000000000025 /* LilAgentsController.swift */, @@ -188,6 +191,7 @@ A10000010000000000000033 /* CodexSession.swift in Sources */, A10000010000000000000034 /* CopilotSession.swift in Sources */, A10000010000000000000035 /* GeminiSession.swift in Sources */, + A10000010000000000000036 /* QoderSession.swift in Sources */, A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */,