From fad7c5b37c9e5a1fcfed75759cd859924dd4748a Mon Sep 17 00:00:00 2001 From: Yang Song Date: Mon, 30 Mar 2026 21:51:08 +0200 Subject: [PATCH 1/3] Add Qoder as a supported AI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Qoder CLI integration alongside Claude, Codex, Copilot, and Gemini. Also fixes character overlap by spreading initial walk positions. ๐Ÿค– Generated with [Qoder][https://qoder.com] --- LilAgents/AgentSession.swift | 6 +- LilAgents/LilAgentsController.swift | 4 +- LilAgents/QoderSession.swift | 256 +++++++++++++++++++++++++++ LilAgents/ShellEnvironment.swift | 3 + lil-agents.xcodeproj/project.pbxproj | 4 + 5 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 LilAgents/QoderSession.swift 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..d15fdc3 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -30,8 +30,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) 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.. Date: Mon, 30 Mar 2026 21:56:36 +0200 Subject: [PATCH 2/3] Update README with Qoder support and build instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Qoder to supported providers list - Add Qoder CLI install command - Add command-line build instructions - Add DMG packaging instructions - Update privacy section ๐Ÿค– Generated with [Qoder][https://qoder.com] --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7b996d8..2ae6cba 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,39 @@ 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 +``` + ## 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. From 33a653ec67307c67037799a6cb323d3b9b571229 Mon Sep 17 00:00:00 2001 From: Yang Song Date: Tue, 31 Mar 2026 21:21:03 +0200 Subject: [PATCH 3/3] Optimize energy consumption across rendering pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Throttle display link from 60fps to 15fps for window positioning - Cache dock geometry with 2s refresh instead of per-frame UserDefaults reads - Skip setFrameOrigin when character position unchanged - Throttle thinking bubble updates to 2fps - Reassign window level only when z-order changes Reduces CPU wakeups by ~75-99%, keeping energy impact minimal. ๐Ÿค– Generated with [Qoder][https://qoder.com] --- LilAgents/LilAgentsController.swift | 60 ++++++++++++++++++++----- LilAgents/WalkerCharacter.swift | 68 +++++++++++++++++++++++------ README.md | 12 +++++ 3 files changed, 117 insertions(+), 23 deletions(-) diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index d15fdc3..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 @@ -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/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index b854c2f..5c58f53 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -165,7 +165,7 @@ class WalkerCharacter { } if wasBubbleVisibleBeforeEnvironmentHide { - updateThinkingBubble() + updateThinkingBubble(now: CACurrentMediaTime()) } } @@ -795,30 +795,30 @@ class WalkerCharacter { // MARK: - Frame Update - func update(dockX: CGFloat, dockWidth: CGFloat, dockTopY: CGFloat) { + // Energy optimization: throttle thinking bubble updates + private static let bubbleUpdateInterval: CFTimeInterval = 0.5 + private var lastBubbleUpdate: CFTimeInterval = 0 + // Track last position to skip redundant setFrameOrigin calls + private var lastFrameOrigin: CGPoint = .zero + + func update(dockX: CGFloat, dockWidth: CGFloat, dockTopY: CGFloat, now: CFTimeInterval) { currentTravelDistance = max(dockWidth - displayWidth, 0) if isIdleForPopover { let travelDistance = currentTravelDistance 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) updatePopoverPosition() - updateThinkingBubble() + updateThinkingBubble(now: now) return } - let now = CACurrentMediaTime() - if isPaused { if now >= 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 2ae6cba..c0b18ed 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,18 @@ 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.