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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user