Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:34:32 +03:00
commit 53b4f60da1
158 changed files with 29442 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
import SwiftUI
import CoreGraphics
// Hex color helpers shared by the Mac app and the iPhone app.
// MARK: - Color from hex string
extension Color {
init(hex: String) {
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
}
// MARK: - CGColor from hex string
func cgColorFromHex(_ hex: String) -> CGColor? {
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard let val = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((val >> 16) & 0xFF) / 255
let g = CGFloat((val >> 8) & 0xFF) / 255
let b = CGFloat( val & 0xFF) / 255
return CGColor(red: r, green: g, blue: b, alpha: 1)
}
extension CGColor {
static func from(_ hex: String) -> CGColor {
cgColorFromHex(hex) ?? CGColor(gray: 0.5, alpha: 1)
}
}
#if !os(macOS)
// CGColor.white / .black / .clear only exist on macOS; BotEngine uses them.
extension CGColor {
static var white: CGColor { CGColor(gray: 1, alpha: 1) }
static var black: CGColor { CGColor(gray: 0, alpha: 1) }
static var clear: CGColor { CGColor(gray: 0, alpha: 0) }
}
#endif
@@ -0,0 +1,301 @@
import Foundation
// MARK: - Types
struct DiffLine: Equatable {
enum Kind: Equatable { case context, added, removed }
var kind: Kind
var text: String
var origLine: Int // 1-based; -1 for pure adds
var newLine: Int // 1-based; -1 for pure removes
}
struct DiffHunk: Equatable {
var origStart: Int
var newStart: Int
var lines: [DiffLine]
}
struct FileDiff: Equatable {
var id: Int = 0 // stable identifier assigned by AppState.appendSessionDiff
var path: String
var added: Int
var removed: Int
var hunks: [DiffHunk]
var tooLarge: Bool
var isNewFile: Bool // true when produced by DiffEngine.fromNew (Write tool)
var name: String { URL(fileURLWithPath: path).lastPathComponent }
static let maxBytes = 200 * 1024
static let maxLines = 4000
}
// MARK: - DiffEngine
enum DiffEngine {
// MARK: Public API
static func fromEdit(old: String, new: String, path: String) -> FileDiff {
// Size guard
if old.utf8.count + new.utf8.count > FileDiff.maxBytes {
return countFallback(old: old, new: new, path: path, tooLarge: true)
}
let oldLines = splitLines(old)
let newLines = splitLines(new)
if oldLines.count + newLines.count > FileDiff.maxLines {
return countFallback(old: old, new: new, path: path, tooLarge: true)
}
// LCS is O(m*n) — bail out before quadratic blowup
if oldLines.count * newLines.count > 1_000_000 {
return countFallback(old: old, new: new, path: path, tooLarge: true)
}
let flat = buildDiffLines(oldLines: oldLines, newLines: newLines)
let hunks = buildHunks(from: flat, context: 3)
let added = flat.filter { $0.kind == .added }.count
let removed = flat.filter { $0.kind == .removed }.count
return FileDiff(path: path, added: added, removed: removed, hunks: hunks, tooLarge: false, isNewFile: false)
}
static func fromNew(content: String, path: String) -> FileDiff {
// Size guard (same limits as fromEdit)
if content.utf8.count > FileDiff.maxBytes {
let lineCount = content.components(separatedBy: "\n").count
return FileDiff(path: path, added: lineCount, removed: 0, hunks: [], tooLarge: true, isNewFile: true)
}
let lines = splitLines(content)
if lines.count > FileDiff.maxLines {
return FileDiff(path: path, added: lines.count, removed: 0, hunks: [], tooLarge: true, isNewFile: true)
}
let diffLines = lines.enumerated().map { (i, text) in
DiffLine(kind: .added, text: text, origLine: -1, newLine: i + 1)
}
let hunk = diffLines.isEmpty ? nil : DiffHunk(origStart: 0, newStart: 1, lines: diffLines)
return FileDiff(
path: path,
added: diffLines.count,
removed: 0,
hunks: hunk.map { [$0] } ?? [],
tooLarge: false,
isNewFile: true
)
}
// MARK: - Line splitting
private static func splitLines(_ text: String) -> [String] {
// Normalize CRLF → LF
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
var parts = normalized.components(separatedBy: "\n")
// Drop trailing empty element that results from a trailing newline
if parts.last == "" { parts.removeLast() }
return parts
}
// MARK: - LCS-based diff
private static func buildDiffLines(oldLines: [String], newLines: [String]) -> [DiffLine] {
let m = oldLines.count
let n = newLines.count
// Build LCS DP table
// dp[i][j] = LCS length of oldLines[0..<i] and newLines[0..<j]
var dp = [[Int]](repeating: [Int](repeating: 0, count: n + 1), count: m + 1)
for i in 1...max(1, m) {
for j in 1...max(1, n) {
guard i <= m && j <= n else { continue }
if oldLines[i - 1] == newLines[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// Backtrack to get matching pairs
var matches: [(Int, Int)] = [] // (oldIdx 0-based, newIdx 0-based)
var i = m, j = n
while i > 0 && j > 0 {
if oldLines[i - 1] == newLines[j - 1] {
matches.append((i - 1, j - 1))
i -= 1; j -= 1
} else if dp[i - 1][j] >= dp[i][j - 1] {
i -= 1
} else {
j -= 1
}
}
matches.reverse()
// Build flat diff from LCS pairs
var result: [DiffLine] = []
var prevOld = -1
var prevNew = -1
for (oi, ni) in matches {
// Removed lines between last match and this old match
for k in (prevOld + 1)..<oi {
result.append(DiffLine(kind: .removed, text: oldLines[k],
origLine: k + 1, newLine: -1))
}
// Added lines between last match and this new match
for k in (prevNew + 1)..<ni {
result.append(DiffLine(kind: .added, text: newLines[k],
origLine: -1, newLine: k + 1))
}
// Context line (the match)
result.append(DiffLine(kind: .context, text: oldLines[oi],
origLine: oi + 1, newLine: ni + 1))
prevOld = oi
prevNew = ni
}
// Remaining removes
for k in (prevOld + 1)..<m {
result.append(DiffLine(kind: .removed, text: oldLines[k],
origLine: k + 1, newLine: -1))
}
// Remaining adds
for k in (prevNew + 1)..<n {
result.append(DiffLine(kind: .added, text: newLines[k],
origLine: -1, newLine: k + 1))
}
return result
}
// MARK: - Hunk building
private static func buildHunks(from lines: [DiffLine], context: Int) -> [DiffHunk] {
guard !lines.isEmpty else { return [] }
// Find indices of changed lines
var changedIndices: [Int] = []
for (i, line) in lines.enumerated() {
if line.kind != .context { changedIndices.append(i) }
}
guard !changedIndices.isEmpty else { return [] }
// Expand ±context around each changed line
let ranges: [(Int, Int)] = changedIndices.map {
(max(0, $0 - context), min(lines.count - 1, $0 + context))
}
// Merge overlapping ranges
var merged: [(Int, Int)] = []
for r in ranges {
if let last = merged.last, r.0 <= last.1 + 1 {
merged[merged.count - 1] = (last.0, max(last.1, r.1))
} else {
merged.append(r)
}
}
// Build hunks
var hunks: [DiffHunk] = []
for (start, end) in merged {
let hunkLines = Array(lines[start...end])
let origStart = hunkLines.first(where: { $0.origLine > 0 })?.origLine ?? 1
let newStart = hunkLines.first(where: { $0.newLine > 0 })?.newLine ?? 1
hunks.append(DiffHunk(origStart: origStart, newStart: newStart, lines: hunkLines))
}
return hunks
}
// MARK: - Count fallback (tooLarge)
private static func countFallback(old: String, new: String, path: String, tooLarge: Bool) -> FileDiff {
let oldLines = old.components(separatedBy: "\n")
let newLines = new.components(separatedBy: "\n")
let oldSet = Set(oldLines)
let newSet = Set(newLines)
let added = newLines.filter { !$0.isEmpty && !oldSet.contains($0) }.count
let removed = oldLines.filter { !$0.isEmpty && !newSet.contains($0) }.count
return FileDiff(path: path, added: added, removed: removed, hunks: [], tooLarge: tooLarge, isNewFile: false)
}
// MARK: - toOneLine
/// Converts a possibly multi-line, markdown-formatted string to a single line of plain text.
/// Uses only the first non-empty paragraph (stops at blank line, horizontal rule, or table row).
/// Strips `**`, `__`, backticks, leading `#`, and leading bullet markers.
static func toOneLine(_ text: String, maxChars: Int = 200) -> String {
let lines = text.components(separatedBy: "\n")
// Split into paragraphs. Separators: blank line, HR (3+ repeated -/*/_ chars), table row (starts with |).
var paragraphs: [[String]] = []
var current: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isHR = trimmed.count >= 3 && (trimmed.allSatisfy { $0 == "-" } ||
trimmed.allSatisfy { $0 == "*" } ||
trimmed.allSatisfy { $0 == "_" })
let isSep = trimmed.isEmpty || isHR || trimmed.hasPrefix("|")
if isSep {
if !current.isEmpty { paragraphs.append(current); current = [] }
} else {
current.append(line)
}
}
if !current.isEmpty { paragraphs.append(current) }
// Find first paragraph that yields non-empty text after cleaning.
for paraLines in paragraphs {
var s = paraLines.joined(separator: "\n")
s = s.replacingOccurrences(of: "**", with: "")
s = s.replacingOccurrences(of: "__", with: "")
s = s.replacingOccurrences(of: "`", with: "")
let processed: [String] = s.components(separatedBy: "\n").compactMap { line in
var l = line
while l.hasPrefix("#") { l = String(l.dropFirst()) }
l = l.trimmingCharacters(in: .whitespaces)
// Strip leading bullet markers: -, *, •, or N. (ordered list)
if l.hasPrefix("- ") || l.hasPrefix("* ") || l.hasPrefix("• ") {
l = String(l.dropFirst(2))
} else if let m = l.range(of: #"^\d+\.\s+"#, options: .regularExpression) {
l = String(l[m.upperBound...])
}
let trimmed = l.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
}
let joined = processed.joined(separator: " ")
let collapsed = joined.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
.joined(separator: " ")
if !collapsed.isEmpty { return String(collapsed.prefix(maxChars)) }
}
return ""
}
}
// MARK: - String diff step encoding
public extension String {
/// Private-use character used as the diff step marker prefix.
static let diffStepMarker = "\u{E001}"
/// True if this step string encodes a file diff.
var isDiffStep: Bool { hasPrefix(Self.diffStepMarker) }
/// Parses a diff step string.
/// Format: `"\u{E001}<filename>\t<added>:<removed>:<diffId>"`
func parseDiffStep() -> (filename: String, added: Int, removed: Int, diffId: Int)? {
guard isDiffStep else { return nil }
let body = String(dropFirst()) // drop the marker character
guard let tabIdx = body.firstIndex(of: "\t") else { return nil }
let filename = String(body[body.startIndex..<tabIdx])
let rest = String(body[body.index(after: tabIdx)...])
let parts = rest.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3,
let added = Int(parts[0]),
let removed = Int(parts[1]),
let diffId = Int(parts[2]) else { return nil }
return (filename, added, removed, diffId)
}
/// Creates a diff step string from its components.
static func makeDiffStep(filename: String, added: Int, removed: Int, diffId: Int) -> String {
"\(diffStepMarker)\(filename)\t\(added):\(removed):\(diffId)"
}
}
@@ -0,0 +1,42 @@
import Foundation
/// Resting island dimensions, using a physical notch only when the screen has one.
struct IslandScreenGeometry {
static let fallbackNotchWidth: CGFloat = 184
private static let noNotchWidth: CGFloat = 80
private static let noNotchHeight: CGFloat = 24
let hasNotch: Bool
let width: CGFloat
let height: CGFloat
init(screenWidth: CGFloat, safeAreaTop: CGFloat,
auxiliaryLeftWidth: CGFloat?, auxiliaryRightWidth: CGFloat?,
menuBarHeight: CGFloat) {
hasNotch = safeAreaTop > 0
if hasNotch {
if let left = auxiliaryLeftWidth, let right = auxiliaryRightWidth {
let measuredWidth = screenWidth - left - right
width = measuredWidth > 0 && measuredWidth < screenWidth
? measuredWidth : Self.fallbackNotchWidth
} else {
width = Self.fallbackNotchWidth
}
height = safeAreaTop
} else {
width = Self.noNotchWidth
height = min(Self.noNotchHeight, menuBarHeight)
}
}
}
/// Shared by the compact view and the greeting's collapse destination.
struct IslandRestingLayout {
let width: CGFloat
let height: CGFloat
var botDiameter: CGFloat { min(20, max(0, height - 6)) }
var botCenterY: CGFloat { height / 2 }
var miniGridScale: CGFloat { min(1, max(0, height - 4) / 28) }
var miniGridCenterX: CGFloat { width - 40 }
}
@@ -0,0 +1,230 @@
import Foundation
// MARK: - Island Mode
enum IslandMode: String, CaseIterable {
case hidden, compact, expanded
}
// MARK: - Island View
enum IslandView: String, CaseIterable {
case overview, empty, approval, question, error, finished
case confused, upload, uploading, choose, mail, prompt
case searching, result, note, settings, greeting, wardrobe
}
// MARK: - Bot State
enum BotState: String, CaseIterable {
case idle, working, thinking, searching
case approval, question, error, finished
case ratelimit, sleeping, dizzy
}
// MARK: - Bot Emote
enum BotEmote: String, CaseIterable {
case love, surprised, proud, wink, yawn, happy, annoyed
}
// MARK: - Approval info (pending PermissionRequest from Claude Code)
struct ApprovalInfo: Sendable {
var sessionId: String
var tool: String
var command: String
/// tool_input serialized to JSON with sortedKeys, "" if absent — used to match PostToolUse.
var inputKey: String
/// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex".
var pillId: String
}
// MARK: - Pill badge (shown on pill edge when non-focused task has an alert)
enum PillBadge { case approval, finished, error }
// MARK: - Agent Task
struct AgentTask: Identifiable, Equatable {
var id: String
var name: String
var color: String // hex
var state: BotState
var stepIndex: Int = 0
var steps: [String]
var source: AgentSource
var isIntegration: Bool = false // true for persistent integration pills
var emote: BotEmote? = nil
var miniEye: EyeShape? = nil
var pillBadge: PillBadge? = nil // alert badge shown on pill when not focused
var sessionCwd: String? = nil // last known working directory (Claude Code sessions)
var finalLine: String? = nil // last assistant message shown as static text after Stop
}
enum AgentSource: Equatable {
case claudeCode
case n8n
case agent // third-party agent via coucou_agent field
}
// MARK: - Chat provider
enum ChatProvider: String, CaseIterable, Codable {
case anthropic = "anthropic"
case google = "google"
case openai = "openai"
case ollama = "ollama"
case lmstudio = "lmstudio"
var displayName: String {
switch self {
case .anthropic: "Anthropic"
case .google: "Google"
case .openai: "OpenAI"
case .ollama: "Ollama"
case .lmstudio: "LM Studio"
}
}
var accentHex: String {
switch self {
case .anthropic: "#E07950"
case .google: "#4285F4"
case .openai: "#10A37F"
case .ollama: "#FACC15"
case .lmstudio: "#A3E635"
}
}
var defaultModel: String {
switch self {
case .anthropic: "claude-sonnet-4-6"
case .google: "gemini-2.0-flash"
case .openai: "gpt-4o"
case .ollama: "llama3.2"
case .lmstudio: "local-model"
}
}
var keychainKey: String {
switch self {
case .anthropic: "anthropic-api-key"
case .google: "google-api-key"
case .openai: "openai-api-key"
case .ollama: ""
case .lmstudio: ""
}
}
var isLocal: Bool {
self == .ollama || self == .lmstudio
}
var pillID: String {
switch self {
case .anthropic: "ai_anthropic"
case .google: "ai_google"
case .openai: "ai_openai"
case .ollama: "ai_ollama"
case .lmstudio: "ai_lmstudio"
}
}
init?(pillID: String) {
switch pillID {
case "ai_anthropic": self = .anthropic
case "ai_google": self = .google
case "ai_openai": self = .openai
case "ai_ollama": self = .ollama
case "ai_lmstudio": self = .lmstudio
default: return nil
}
}
}
// MARK: - View dimensions (from VIEWS in prototype)
struct ViewLayout {
let height: CGFloat
let botX: CGFloat
let botY: CGFloat? // nil = auto-centered
let botDiameter: CGFloat
let agentMode: AgentLayoutMode
}
enum AgentLayoutMode {
case none, grid, pills, column
}
// MARK: - Constants (from NW, NH, EW in prototype)
enum IslandConst {
static let notchWidth: CGFloat = IslandScreenGeometry.fallbackNotchWidth
static let notchHeight: CGFloat = 32
static let expandedWidth: CGFloat = 640
static let earRadius: CGFloat = 14
static let roundedCorner: CGFloat = 14 // hidden/peek/compact
static let expandedCorner: CGFloat = 22
static let viewLayouts: [IslandView: ViewLayout] = [
// Home is the reference: height 150
.overview: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .pills),
// All non-chat views match home height (150) — law
.empty: ViewLayout(height: 160, botX: 70, botY: nil, botDiameter: 62, agentMode: .none),
.approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column),
.finished: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column),
.confused: ViewLayout(height: 160, botX: 76, botY: nil, botDiameter: 66, agentMode: .column),
.upload: ViewLayout(height: 176, botX: 140, botY: 104, botDiameter: 62, agentMode: .column),
.uploading: ViewLayout(height: 176, botX: 46, botY: 118, botDiameter: 20, agentMode: .none),
.choose: ViewLayout(height: 176, botX: 60, botY: 101, botDiameter: 52, agentMode: .column),
.mail: ViewLayout(height: 240, botX: 56, botY: nil, botDiameter: 46, agentMode: .column),
.prompt: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column),
.searching: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column),
.result: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column),
.note: ViewLayout(height: 160, botX: 60, botY: nil, botDiameter: 50, agentMode: .column),
.settings: ViewLayout(height: 160, botX: 54, botY: nil, botDiameter: 46, agentMode: .none),
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
.wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none),
]
// Project colors — keyed by lowercase display name or slug
static let projectColors: [String: String] = [
"korus": "#FF5A4E",
"sbe hub": "#2EC4A0",
"morning ai brief": "#F29B38",
"publication ig": "#7C5CFF",
"ig post": "#7C5CFF",
"louisraille.fr": "#38BDF8",
"louisraille": "#38BDF8",
"notch buddy": "#EC4899",
"notch-buddy": "#EC4899",
"notchbuddy": "#EC4899",
]
static let fallbackColors = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]
/// Returns the fixed project color for a display name, or a stable fallback.
static func colorForProject(_ name: String) -> String {
let key = name.lowercased().trimmingCharacters(in: .whitespaces)
if let c = projectColors[key] { return c }
// partial match (e.g. "korus-api" → "korus")
for (k, c) in projectColors where key.hasPrefix(k) || key.contains(k) { return c }
return fallbackColors[abs(name.hashValue) % fallbackColors.count]
}
// State card wash colors (radial gradient from bottom)
static let washColors: [IslandView: String] = [
.approval: "rgba(245,165,36,0.42)",
.question: "rgba(34,211,238,0.38)",
.error: "rgba(244,80,94,0.55)",
.finished: "rgba(52,211,153,0.5)",
.confused: "rgba(244,114,182,0.55)",
.searching: "rgba(99,102,241,0.5)",
.result: "rgba(52,211,153,0.22)",
.prompt: "rgba(99,102,241,0.22)",
]
}
@@ -0,0 +1,69 @@
import Foundation
// What the iPhone's Live Activity (Lock Screen and Dynamic Island) shows:
// the most urgent agent session, while the Mac is locked. The Mac builds it,
// the relay forwards it as the Live Activity's "content-state", ActivityKit
// decodes it on the iPhone. It passes through the relay and Apple's push
// service, so it holds no project name, step text, command or path: only the
// agent, its state and counts.
struct MochiActivityState: Codable, Hashable, Sendable {
var pillId: String
var agent: String // "VS Code", "Codex"…
var color: String // agent color, hex
var state: String // BotState raw value
var statusText: String // "working · 3/7", "waiting for your OK"…
var tone: String // waiting, question, error, working, done, idle
var stepIndex: Int
var stepCount: Int
var others: Int // other sessions still going
/// When Mochi left for the iPhone (Unix seconds): the iPhone counts the
/// time from it, live. Optional so older pushes still decode.
var since: Int? = nil
/// The fingerprint of the command waiting for your OK (an opaque hash,
/// never the command): the Lock Screen's Allow and Deny answer this one.
var approval: String? = nil
var botState: BotState { BotState(rawValue: state) ?? .idle }
var isActive: Bool { ["waiting", "question", "working", "error"].contains(tone) }
/// Lower = more urgent, same order as the iPhone list.
static func urgency(state: BotState, waitingForOK: Bool, hasQuestion: Bool) -> Int {
if waitingForOK || state == .approval { return 0 }
if hasQuestion || state == .question { return 1 }
switch state {
case .error, .ratelimit, .dizzy: return 2
case .working, .thinking, .searching: return 3
case .finished: return 4
default: return 5
}
}
static func tone(urgency: Int) -> String {
["waiting", "question", "error", "working", "done"].indices.contains(urgency)
? ["waiting", "question", "error", "working", "done"][urgency] : "idle"
}
static func statusText(state: BotState, urgency: Int, stepIndex: Int, stepCount: Int) -> String {
switch urgency {
case 0: return "waiting for your OK"
case 1: return "has a question"
default: break
}
switch state {
case .working, .thinking, .searching:
return stepCount == 0 ? "working" : "working · \(min(stepIndex + 1, stepCount))/\(stepCount)"
case .finished: return "✓ done"
case .error: return "error"
case .ratelimit: return "rate limited"
case .sleeping: return "asleep"
case .dizzy: return "dizzy"
default: return "idle"
}
}
static let placeholder = MochiActivityState(
pillId: "integration_claude", agent: "VS Code", color: "#4A86E8",
state: "working", statusText: "working · 3/7", tone: "working",
stepIndex: 2, stepCount: 7, others: 1)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import SwiftUI
/// Mochi in a fixed pose: one frame of BotEngine, no animation, no timer.
/// For surfaces that can't animate (widgets, lists on the iPhone). The Mac
/// island keeps its animated BotCanvasView / MiniBotCanvasView.
struct MochiStill: View {
var state: BotState = .idle
/// Overrides the state's eyes (e.g. .closed for a sleeping agent).
var eye: EyeShape? = nil
/// Body color; white by default, like the mockups.
var bodyHex: String = "#FFFFFF"
var showBadge: Bool = true
/// Where he looks and how his eyes are, for a still that isn't always the same.
var pose: MochiPose = .neutral
var body: some View {
Canvas { context, size in
let engine = BotEngine()
engine.isMini = true
engine.bodyColor = cgColorFromHex(bodyHex)
engine.state = state
engine.cfg = BotStates[state]!
engine.yaw = pose.yaw
engine.pitch = pose.pitch
engine.tilt = pose.tilt
engine.open = pose.open
if let eye = eye ?? pose.eye {
engine.permanentEye = eye
engine.eyeOverride = eye
engine.eyeOverrideUntil = .greatestFiniteMagnitude
}
engine.draw(context: context, size: size)
if showBadge, let badge = engine.cfg.badge {
engine.badge = badge
engine.badgeS = 1
engine.drawHandsAndExtras(context: context, size: size)
}
}
.aspectRatio(1, contentMode: .fit)
}
}
/// One frame of the little life the Mac's mini Mochi have: looking around,
/// blinking, a happy squint. Widgets can't animate, so each refresh of the
/// timeline picks a new pose per Mochi; the system cross-fades between them.
struct MochiPose: Equatable, Sendable {
var yaw: CGFloat = 0
var pitch: CGFloat = 0
var tilt: CGFloat = 0
var open: CGFloat = 1
var eye: EyeShape? = nil
static let neutral = MochiPose()
static let idlePoses: [MochiPose] = [
MochiPose(),
MochiPose(yaw: -0.45),
MochiPose(yaw: 0.45),
MochiPose(yaw: -0.3, pitch: -0.25),
MochiPose(yaw: 0.35, pitch: 0.2, tilt: 0.08),
MochiPose(pitch: -0.3, tilt: -0.06),
MochiPose(eye: .closed), // blink
MochiPose(yaw: 0.2, eye: .happy),
MochiPose(yaw: -0.5, tilt: -0.1),
MochiPose(yaw: 0.15, pitch: 0.25),
]
/// A pose that differs per Mochi and per tick, the same every time for a
/// given pair (so a widget redraw doesn't jump).
static func idle(id: String, tick: Int) -> MochiPose {
var hash: UInt64 = 5381
for byte in id.utf8 { hash = (hash &* 33) &+ UInt64(byte) }
hash = (hash &* 31) &+ UInt64(truncatingIfNeeded: tick &* 7919)
hash ^= hash >> 13
return idlePoses[Int(hash % UInt64(idlePoses.count))]
}
}
extension Color {
/// Background for a white Mochi on an agent's color. Very light colors
/// (VS Code's is near-white) would hide him, so they get a dark gray tile.
static func mochiTile(hex: String) -> Color {
guard let c = cgColorFromHex(hex), let comps = c.components, comps.count >= 3 else {
return Color(hex: hex)
}
let luminance = 0.2126 * comps[0] + 0.7152 * comps[1] + 0.0722 * comps[2]
return luminance > 0.7 ? Color(white: 0.32) : Color(hex: hex)
}
}
@@ -0,0 +1,89 @@
import Foundation
enum Outfit: String, CaseIterable {
case auto, none, partyHat, beanie, crown, sunglasses, roundGlasses,
bow, scarf, witchHat, pumpkin, santaHat, bunnyEars
var displayName: String {
switch self {
case .auto: return "Auto (seasons)"
case .none: return "None"
case .partyHat: return "Party hat"
case .beanie: return "Beanie"
case .crown: return "Crown"
case .sunglasses: return "Sunglasses"
case .roundGlasses: return "Round glasses"
case .bow: return "Bow"
case .scarf: return "Scarf"
case .witchHat: return "Witch hat"
case .pumpkin: return "Pumpkin"
case .santaHat: return "Santa hat"
case .bunnyEars: return "Bunny ears"
}
}
// Returns the seasonal outfit for the given date (user's local calendar).
// Priority: partyHat > santaHat > witchHat > bunnyEars > sunglasses > none
static func seasonal(for date: Date, calendar: Calendar) -> Outfit {
let day = calendar.component(.day, from: date)
let month = calendar.component(.month, from: date)
let year = calendar.component(.year, from: date)
// Dec 31 – Jan 2 → party hat
if (month == 12 && day == 31) || (month == 1 && day <= 2) { return .partyHat }
// Dec 1–26 → santa hat
if month == 12 && day <= 26 { return .santaHat }
// Oct 1 – Nov 1 → witch hat
if month == 10 || (month == 11 && day == 1) { return .witchHat }
// Easter −2 / +1 → bunny ears (Meeus/Jones/Butcher algorithm)
let (eMonth, eDay) = easterDate(year: year)
let eComps = DateComponents(year: year, month: eMonth, day: eDay)
if let easterDate = calendar.date(from: eComps) {
let delta = calendar.dateComponents([.day], from: easterDate, to: date).day ?? 0
if delta >= -2 && delta <= 1 { return .bunnyEars }
}
// Jun 21 – Aug 31 → sunglasses
if (month == 6 && day >= 21) || month == 7 || month == 8 { return .sunglasses }
return .none
}
// If selection == .auto → seasonal; otherwise selection itself.
static func resolved(selection: Outfit, date: Date, calendar: Calendar) -> Outfit {
selection == .auto ? seasonal(for: date, calendar: calendar) : selection
}
// UserDefaults key "mochiOutfit", default "auto", unknown value → .auto
static var stored: Outfit {
get {
let raw = UserDefaults.standard.string(forKey: "mochiOutfit") ?? "auto"
return Outfit(rawValue: raw) ?? .auto
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "mochiOutfit")
}
}
}
// Meeus/Jones/Butcher algorithm — returns (month, day) of Easter Sunday for the given year.
private func easterDate(year: Int) -> (Int, Int) {
let a = year % 19
let b = year / 100
let c = year % 100
let d = b / 4
let e = b % 4
let f = (b + 8) / 25
let g = (b - f + 1) / 3
let h = (19 * a + b - d - g + 15) % 30
let i = c / 4
let k = c % 4
let l = (32 + 2 * e + 2 * i - h - k) % 7
let m = (a + 11 * h + 22 * l) / 451
let month = (h + l - 7 * m + 114) / 31
let day = (h + l - 7 * m + 114) % 31 + 1
return (month, day)
}
@@ -0,0 +1,108 @@
import Foundation
// MARK: - Pill category
enum PillCategory: String, CaseIterable {
case workspace
case agent
case ai
case service
var title: String {
switch self {
case .workspace: return "Where you code"
case .agent: return "Agents"
case .ai: return "AI for the chat"
case .service: return "Services"
}
}
}
// MARK: - Pill definition
struct PillDefinition {
let id: String
let name: String
let color: String
let category: PillCategory
/// Label shown next to the task name in the idle card header.
let subtitle: String
let source: AgentSource
var comingSoon: Bool = false
var githubOnly: Bool = false
/// Label shown in the active-session card header (workspace/agent pills only).
var sessionSubtitle: String {
switch id {
case "integration_claude": return "Claude Code"
case "agent_cursor": return "Cursor"
case "agent_codex": return "Codex"
default: return "Agent"
}
}
}
// MARK: - Catalog
enum PillCatalog {
// All declared pills in display order.
static let all: [PillDefinition] = [
// ── Where you code ───────────────────────────────────────────────────
.init(id: "integration_claude", name: "VS Code", color: "#F5F6F8",
category: .workspace, subtitle: "Integration", source: .claudeCode),
.init(id: "agent_cursor", name: "Cursor", color: "#C0C4CC",
category: .workspace, subtitle: "Integration", source: .agent),
.init(id: "agent_antigravity", name: "Antigravity", color: "#E879F9",
category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true),
.init(id: "agent_codex", name: "Codex", color: "#2DD4BF",
category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true),
// ── Agents ───────────────────────────────────────────────────────────
.init(id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8",
category: .agent, subtitle: "Agent", source: .agent, githubOnly: true),
// ── AI for the chat ──────────────────────────────────────────────────
.init(id: "ai_anthropic", name: "Anthropic", color: ChatProvider.anthropic.accentHex,
category: .ai, subtitle: "Chat", source: .n8n),
.init(id: "ai_google", name: "Google AI", color: ChatProvider.google.accentHex,
category: .ai, subtitle: "Chat", source: .n8n),
.init(id: "ai_openai", name: "OpenAI", color: ChatProvider.openai.accentHex,
category: .ai, subtitle: "Chat", source: .n8n),
.init(id: "ai_ollama", name: "Ollama", color: ChatProvider.ollama.accentHex,
category: .ai, subtitle: "Chat", source: .n8n),
.init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex,
category: .ai, subtitle: "Chat", source: .n8n),
// ── Services ─────────────────────────────────────────────────────────
.init(id: "integration_resend", name: "Resend", color: "#22C55E",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_n8n", name: "n8n", color: "#F29B38",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_vercel", name: "Vercel", color: "#7C5CFF",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_github", name: "GitHub", color: "#F4505E",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_notion", name: "Notion", color: "#8C8C8C",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_calcom", name: "Cal.com", color: "#C9956A",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_stripe", name: "Stripe", color: "#0570DE",
category: .service, subtitle: "Integration", source: .n8n),
.init(id: "integration_music", name: "Apple Music", color: "#FA2D48",
category: .service, subtitle: "Integration", source: .n8n, githubOnly: true),
]
/// Pills available in the current build target.
static var available: [PillDefinition] {
#if APPSTORE
all.filter { !$0.githubOnly }
#else
all
#endif
}
/// Default ID for the always-on main workspace pill.
static let defaultMainPillId = "integration_claude"
/// Looks up a definition by task ID (nil if not in catalog).
static func definition(for id: String) -> PillDefinition? {
all.first { $0.id == id }
}
}
@@ -0,0 +1,64 @@
import CryptoKit
import Foundation
// A question Claude Code asks (AskUserQuestion), as the iPhone gets it: the
// questions with their choices. The Mac writes it, encrypted, in the session
// record; the iPhone answers with an `Answer` record carrying the same
// fingerprint and the labels picked. The Mac only accepts labels that are
// among the choices of the question still waiting.
struct QuestionPayload: Codable, Equatable, Sendable {
struct Option: Codable, Equatable, Sendable {
var label: String
var description: String
}
struct Item: Codable, Equatable, Sendable {
var question: String
var header: String
var options: [Option]
var multiSelect: Bool
}
var items: [Item]
/// Stable identifier of this exact question.
var fingerprint: String {
let raw = items.map { item in
([item.question, item.multiSelect ? "multi" : "single"] + item.options.map(\.label)).joined(separator: "\u{1F}")
}.joined(separator: "\u{1E}")
return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined()
}
var json: String {
guard let data = try? JSONEncoder().encode(self) else { return "" }
return String(decoding: data, as: UTF8.self)
}
static func decode(_ json: String) -> QuestionPayload? {
guard !json.isEmpty else { return nil }
return try? JSONDecoder().decode(QuestionPayload.self, from: Data(json.utf8))
}
/// The picks sent back: one list of labels per question.
static func encodeSelections(_ selections: [[String]]) -> String {
guard let data = try? JSONEncoder().encode(selections) else { return "[]" }
return String(decoding: data, as: UTF8.self)
}
static func decodeSelections(_ json: String) -> [[String]]? {
try? JSONDecoder().decode([[String]].self, from: Data(json.utf8))
}
/// True when every pick is one of that question's choices, with one pick
/// per single-choice question.
func accepts(_ selections: [[String]]) -> Bool {
guard selections.count == items.count else { return false }
for (item, picks) in zip(items, selections) {
let labels = Set(item.options.map(\.label))
guard !picks.isEmpty, picks.allSatisfy(labels.contains) else { return false }
if !item.multiSelect && picks.count != 1 { return false }
}
return true
}
}
@@ -0,0 +1,81 @@
import Foundation
// A service seen up close, for the iPhone: figures, lists and what can be
// done from there. When a service's screen opens, the iPhone asks the Mac for
// it (a `ServiceAction` record of kind "refresh"); the Mac reads the service's
// API with the key in its Keychain and writes a `ServiceDetail` record,
// encrypted. An action (redeploy, re-run, merge…) is the same kind of record:
// the Mac runs it only if the iPhone asked for one it offered on that item,
// then writes the detail again with the result. No key ever leaves the Mac.
struct ServiceActionDef: Codable, Equatable, Sendable, Hashable {
/// What the Mac does, e.g. "vercel.redeploy", "github.merge".
var kind: String
var title: String
var symbol: String
/// The thing it acts on (deployment id, "owner/repo#12"…).
var target: String
var destructive: Bool = false
/// Asked before doing it, when it can't be undone.
var confirm: String? = nil
}
struct DetailStat: Codable, Equatable, Sendable {
var label: String
var value: String
var tone: ServiceTone = .idle
}
struct DetailItem: Codable, Equatable, Sendable {
var title: String
var subtitle: String = ""
var tone: ServiceTone = .idle
var date: Date? = nil
var url: String? = nil
/// A short tag on the right ("Production", "Draft", "Active"…).
var badge: String? = nil
var actions: [ServiceActionDef] = []
}
struct DetailSection: Codable, Equatable, Sendable {
var title: String
var items: [DetailItem]
var footer: String? = nil
}
struct ServiceActionResult: Codable, Equatable, Sendable {
var title: String
var ok: Bool
var message: String
var date: Date
}
struct ServiceDetail: Codable, Equatable, Sendable {
var pillId: String
var stats: [DetailStat] = []
var sections: [DetailSection] = []
var fetchedAt: Date
/// Why the API couldn't be read (no key, 401…).
var error: String? = nil
var lastAction: ServiceActionResult? = nil
static let recordType = "ServiceDetail"
static let requestType = "ServiceAction"
static let refreshKind = "refresh"
static func recordName(for pillId: String) -> String { "detail-\(pillId)" }
static func pillId(fromRecordName name: String) -> String? {
name.hasPrefix("detail-") ? String(name.dropFirst("detail-".count)) : nil
}
/// The action offered on one of the items, if the Mac offered it.
func offered(kind: String, target: String) -> ServiceActionDef? {
for section in sections {
for item in section.items {
if let action = item.actions.first(where: { $0.kind == kind && $0.target == target }) { return action }
}
}
return nil
}
}
@@ -0,0 +1,67 @@
import Foundation
// What the iPhone shows for one service Mochi (GitHub, Stripe, Vercel, Resend,
// Cal.com, n8n, Notion): its state dot, why it has that color, and the latest
// items the Mac fetched. The Mac builds it from the data its pollers already
// keep (ServicePublisher) and writes it to the private iCloud zone, encrypted;
// the iPhone only reads it. No API key ever leaves the Mac.
enum ServiceTone: String, Codable, Sendable {
case ok, warning, error, info, idle
}
struct ServiceItem: Codable, Equatable, Sendable {
var title: String
var detail: String = ""
var tone: ServiceTone = .idle
var date: Date? = nil
var url: String? = nil
}
struct ServiceSection: Codable, Equatable, Sendable {
var title: String
var items: [ServiceItem]
}
struct ServiceSnapshot: Codable, Equatable, Sendable {
var pillId: String
var tone: ServiceTone
/// One line under the name ("Balance 1 240,00 EUR", "2 pull requests").
var headline: String
/// Why the dot has its color ("CI failing on coucou · main").
var reason: String
var sections: [ServiceSection]
var updatedAt: Date
static let recordType = "Service"
static func recordName(for pillId: String) -> String { "service-\(pillId)" }
static func pillId(fromRecordName name: String) -> String? {
name.hasPrefix("service-") ? String(name.dropFirst("service-".count)) : nil
}
/// Equal apart from the date: nothing to write again.
func sameContent(as other: ServiceSnapshot) -> Bool {
var a = self, b = other
a.updatedAt = .distantPast
b.updatedAt = .distantPast
return a == b
}
}
extension PillCatalog {
/// The service Mochi the iPhone shows, in this order.
static let phoneServices = [
"integration_github", "integration_stripe", "integration_vercel", "integration_resend",
"integration_calcom", "integration_n8n", "integration_notion",
]
/// Agent sessions (VS Code, Cursor, Codex…), as opposed to services and chat providers.
static func isSession(_ id: String) -> Bool {
switch definition(for: id)?.category {
case .service, .ai: false
default: true
}
}
}
@@ -0,0 +1,78 @@
import Foundation
// The last turn of an agent session, for the iPhone: the prompt you sent, what
// the agent did (commands, reads, searches, edits with their diffs) and its
// final answer. The Mac builds it from the hook events it already receives
// (TurnRecorder) and writes it, encrypted, to the private iCloud zone. Only
// the latest turn per session is kept.
struct TurnAction: Codable, Equatable, Sendable {
var tool: String // "Bash", "Edit", "Read"…
var summary: String // the command, file or pattern
var output: String = "" // what a command printed (trimmed)
var failed: Bool = false
var date: Date
/// Index in `files` when this action changed a file.
var fileIndex: Int? = nil
}
struct TurnDiffLine: Codable, Equatable, Sendable {
enum Kind: String, Codable, Sendable { case context, added, removed, gap }
var kind: Kind
var text: String
}
struct TurnFile: Codable, Equatable, Sendable {
var path: String
var added: Int
var removed: Int
var isNew: Bool
var lines: [TurnDiffLine]
/// Some lines were left out to keep the turn small.
var truncated: Bool = false
var name: String { URL(fileURLWithPath: path).lastPathComponent }
}
struct TurnSnapshot: Codable, Equatable, Sendable {
var pillId: String
var sessionId: String
var project: String
var prompt: String
var actions: [TurnAction]
var files: [TurnFile]
var finalMessage: String
var startedAt: Date
var endedAt: Date?
static let recordType = "Turn"
static func recordName(for pillId: String) -> String { "turn-\(pillId)" }
static func pillId(fromRecordName name: String) -> String? {
name.hasPrefix("turn-") ? String(name.dropFirst("turn-".count)) : nil
}
}
extension TurnFile {
/// The diff lines of a FileDiff (DiffEngine), hunks separated by a gap line.
init(diff: FileDiff, maxLines: Int) {
var lines: [TurnDiffLine] = []
var truncated = diff.tooLarge
for (index, hunk) in diff.hunks.enumerated() {
if index > 0 { lines.append(TurnDiffLine(kind: .gap, text: "")) }
for line in hunk.lines {
guard lines.count < maxLines else { truncated = true; break }
let kind: TurnDiffLine.Kind
switch line.kind {
case .added: kind = .added
case .removed: kind = .removed
case .context: kind = .context
}
lines.append(TurnDiffLine(kind: kind, text: String(line.text.prefix(400))))
}
}
self.init(path: diff.path, added: diff.added, removed: diff.removed,
isNew: diff.isNewFile, lines: lines, truncated: truncated)
}
}