diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7c3230..200bec1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### English +- Add AiWork (formerly DTCoder), both the GUI and the CLI/TUI. It installs no hooks: CodeIsland connects to the Agentix daemon's Unix socket (`~/.agentix/run/*/agent.sock`), subscribes to `sessions.watch`, and maps 20 `stream.*` events onto session cards, with `sessions.get` / `agent.stats` / `sessions.list` filling in title, working directory and live status. GUI and CLI are independent toggles in Hooks settings, and an idle conversation is swept shortly after its last activity because the daemon keeps every session `active` forever and never emits a close event + +### 中文 +- 新增 AiWork(原 AiWork),GUI 与 CLI/TUI 都支持。它不需要安装任何 hook:CodeIsland 直接连 Agentix 守护进程的 Unix socket(`~/.agentix/run/*/agent.sock`)订阅 `sessions.watch`,把 20 个 `stream.*` 事件映射成会话卡片,再用 `sessions.get` / `agent.stats` / `sessions.list` 补齐标题、工作目录和实时状态。GUI 与 CLI 在 Hooks 设置里是两个独立开关;空闲会话会在最后一次活动后不久被清掉——守护进程把每个会话永久标记为 `active`,从不发送关闭事件 + ## [v1.0.33] - 2026-09-01 ### English diff --git a/README.md b/README.md index 8eab0c56..7a637741 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ CodeIsland lives in your MacBook's notch area and shows you what your AI coding agents are doing — in real time. No more switching windows to check if Claude is waiting for approval or if Codex finished its task. -It connects to **14 AI coding tools** via Unix socket IPC, displaying session status, tool calls, permission requests, and more — all in a compact, pixel-art styled panel. +It connects to **16 AI coding tools** via Unix socket IPC, displaying session status, tool calls, permission requests, and more — all in a compact, pixel-art styled panel. ## Features - **Notch-native UI** — Expands from the MacBook notch, collapses when idle -- **15 AI tools supported** — Claude Code, Codex, Grok CLI, Gemini CLI, Cursor, Copilot, Trae/Traecli, Qoder, Factory, CodeBuddy, OpenCode, Kimi Code CLI, Cline, Pi / Oh My Pi, DeepSeek Harness +- **16 AI tools supported** — Claude Code, Codex, Grok CLI, Gemini CLI, Cursor, Copilot, Trae/Traecli, Qoder, Factory, CodeBuddy, OpenCode, Kimi Code CLI, Cline, Pi / Oh My Pi, DeepSeek Harness, AiWork - **Live status tracking** — See active sessions, tool calls, and AI responses in real time - **Permission management** — Approve/deny tool permissions directly from the panel - **Question answering** — Respond to agent questions without leaving your current app @@ -58,6 +58,7 @@ It connects to **14 AI coding tools** via Unix socket IPC, displaying session st | | Cline | 5 | VSCode | Full | | | Pi / Oh My Pi | 8 | Terminal | Full | | | DeepSeek Harness | 9 | Terminal | Full | +| | AiWork | 20 | IDE/Terminal | Full | ## Installation diff --git a/README.zh-CN.md b/README.zh-CN.md index 5ba551b6..42f0d1a1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,12 +21,12 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的工作状态。不用再频繁切窗口去看 Claude 是否在等审批、Codex 是否完成了任务。 -它通过 Unix socket IPC 连接 **14 种 AI 编码工具**,在刘海面板中展示会话状态、工具调用、权限请求等信息——全部呈现在一个紧凑的像素风面板中。 +它通过 Unix socket IPC 连接 **16 种 AI 编码工具**,在刘海面板中展示会话状态、工具调用、权限请求等信息——全部呈现在一个紧凑的像素风面板中。 ## 功能特性 - **刘海原生 UI** — 从 MacBook 刘海处展开,空闲时自动收起 -- **支持 15 种 AI 工具** — Claude Code、Codex、Grok CLI、Gemini CLI、Cursor、Copilot、Trae/Traecli、Qoder、Factory、CodeBuddy、OpenCode、Kimi Code CLI、Cline、Pi / Oh My Pi、DeepSeek Harness +- **支持 16 种 AI 工具** — Claude Code、Codex、Grok CLI、Gemini CLI、Cursor、Copilot、Trae/Traecli、Qoder、Factory、CodeBuddy、OpenCode、Kimi Code CLI、Cline、Pi / Oh My Pi、DeepSeek Harness、AiWork - **实时状态追踪** — 查看活跃会话、工具调用和 AI 回复 - **权限管理** — 直接在面板上审批/拒绝工具权限请求 - **问题回答** — 无需离开当前应用即可回答 Agent 的问题 @@ -58,6 +58,7 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 | | Cline | 5 | VSCode | 完整 | | | Pi / Oh My Pi | 8 | 终端 | 完整 | | | DeepSeek Harness | 9 | 终端 | 完整 | +| | AiWork | 20 | IDE/终端 | 完整 | ## 安装 diff --git a/Sources/CodeIsland/AiWorkView.swift b/Sources/CodeIsland/AiWorkView.swift new file mode 100644 index 00000000..d9d6695e --- /dev/null +++ b/Sources/CodeIsland/AiWorkView.swift @@ -0,0 +1,348 @@ +import SwiftUI + +/// AiWorkBot — shared mascot for AiWork GUI and CLI (formerly DTCoder). +/// +/// Built to the same spec as the other CodeIsland mascots (Clawd / CursorBot / +/// OpBot): a little creature whose "body" is the brand mark, with eyes, legs, a +/// ground shadow, and three animated scenes — sleep (breathing + floating z's), +/// work (typing at a keyboard, bouncing, blinking) and alert (startle jump + +/// `!` + pulsing glow). All motion is a pure function of the MascotTimeline +/// instant via MascotMotion, so frames re-render statelessly. +/// +/// The mark itself is the official AiWork / AiWork icon: two interlocking rounded +/// panels — a lime→teal green panel (the face) nested with a cyan→blue panel +/// (lower-right), separated by a dark stepped seam. +struct AiWorkView: View { + let status: MascotAgentStatus + var size: CGFloat = 27 + @State private var alive = false + @Environment(\.mascotAnimationsActive) private var animationsActive + @Environment(\.mascotAnimationEpoch) private var animationEpoch + + // ── Official AiWork mark palette ── + private static let greenLt = Color(red: 0.44, green: 0.85, blue: 0.30) // lime + private static let greenDk = Color(red: 0.13, green: 0.66, blue: 0.40) // teal-green + private static let cyanLt = Color(red: 0.32, green: 0.82, blue: 0.99) // light cyan + private static let cyanDk = Color(red: 0.12, green: 0.52, blue: 0.97) // deep blue + private static let seamC = Color(red: 0.04, green: 0.05, blue: 0.08) // interlock gap + private static let eyeC = Color.white + private static let alertC = Color(red: 1.0, green: 0.42, blue: 0.12) // warm attention + private static let kbBase = Color(red: 0.10, green: 0.12, blue: 0.16) + private static let kbKey = Color(red: 0.24, green: 0.28, blue: 0.34) + private static let kbHi = Color.white + + // ── Mark geometry (SVG-ish units shared across scenes) ── + private static let panelW: CGFloat = 6.0 + private static let panelH: CGFloat = 6.0 + private static let corner: CGFloat = 1.45 + private static let greenX: CGFloat = 3.2 + private static let greenY: CGFloat = 5.0 + private static let cyanX: CGFloat = 5.9 // green offset by +2.7 both axes + private static let cyanY: CGFloat = 7.7 + private static let centerX: CGFloat = 7.55 // union centre — squash pivot + private static let centerY: CGFloat = 9.35 + // Eyes sit on the green panel's top band (clear of the interlock seam). + private static let eyeLX: CGFloat = 4.35 + private static let eyeRX: CGFloat = 6.85 + private static let eyeW: CGFloat = 1.2 + private static let eyeY: CGFloat = 5.75 + private static let eyeH: CGFloat = 1.15 + + var body: some View { + ZStack { + switch status { + case .idle: sleepScene + case .processing, .running: workScene + case .waitingApproval, .waitingQuestion: alertScene + } + } + .frame(width: size, height: size) + .clipped() + .onAppear { alive = true } + .onChange(of: status) { + alive = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { alive = true } + } + } + + // ── Coordinate helper: maps SVG units to view points ── + private struct V { + let ox: CGFloat, oy: CGFloat, s: CGFloat + let y0: CGFloat + init(_ sz: CGSize, svgW: CGFloat = 16, svgH: CGFloat = 14, svgY0: CGFloat = 3) { + s = min(sz.width / svgW, sz.height / svgH) + ox = (sz.width - svgW * s) / 2 + oy = (sz.height - svgH * s) / 2 + y0 = svgY0 + } + func r(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, dy: CGFloat = 0) -> CGRect { + CGRect(x: ox + x * s, y: oy + (y - y0 + dy) * s, width: w * s, height: h * s) + } + } + + private func lerp(_ keyframes: [(CGFloat, CGFloat)], at pct: CGFloat) -> CGFloat { + guard let first = keyframes.first else { return 0 } + if pct <= first.0 { return first.1 } + for i in 1.. Path { + Path(roundedRect: rect, cornerRadius: max(0, min(r, min(rect.width, rect.height) / 2))) + } + + private func grad(_ rect: CGRect, _ a: Color, _ b: Color) -> GraphicsContext.Shading { + .linearGradient( + Gradient(colors: [a, b]), + startPoint: CGPoint(x: rect.minX, y: rect.minY), + endPoint: CGPoint(x: rect.maxX, y: rect.maxY) + ) + } + + // ── Draw the interlocking two-panel AiWork mark (the body/face) ── + private func drawMark(_ c: GraphicsContext, v: V, dy: CGFloat, + sqx: CGFloat = 1, sqy: CGFloat = 1, + seam: CGFloat = 0.55, + eyeScale: CGFloat = 1.0, + eyeColor: Color = Self.eyeC, + caret: CGFloat = 0) { + // Scope squash/stretch to the mark by drawing it into a transformed copy. + var g = c + if sqx != 1 || sqy != 1 { + let px = v.ox + Self.centerX * v.s + let py = v.oy + (Self.centerY - v.y0 + dy) * v.s + g.translateBy(x: px, y: py) + g.scaleBy(x: sqx, y: sqy) + g.translateBy(x: -px, y: -py) + } + + // Green panel (upper-left) — the face. + let greenRect = v.r(Self.greenX, Self.greenY, Self.panelW, Self.panelH, dy: dy) + g.fill(rr(greenRect, r: Self.corner * v.s), with: grad(greenRect, Self.greenLt, Self.greenDk)) + let gHi = v.r(Self.greenX + 0.85, Self.greenY + 0.5, Self.panelW - 1.7, 0.7, dy: dy) + g.fill(rr(gHi, r: 0.35 * v.s), with: .color(.white.opacity(0.22))) + + // Dark interlock gap: carves green's lower-right and rings cyan's + // upper-left, reproducing the icon's stepped seam without any clipping. + let gapRect = v.r(Self.cyanX - seam, Self.cyanY - seam, + Self.panelW + 2 * seam, Self.panelH + 2 * seam, dy: dy) + g.fill(rr(gapRect, r: (Self.corner + seam) * v.s), with: .color(Self.seamC)) + + // Cyan panel (lower-right). + let cyanRect = v.r(Self.cyanX, Self.cyanY, Self.panelW, Self.panelH, dy: dy) + g.fill(rr(cyanRect, r: Self.corner * v.s), with: grad(cyanRect, Self.cyanLt, Self.cyanDk)) + let cHi = v.r(Self.cyanX + 0.85, Self.cyanY + 0.5, Self.panelW - 1.7, 0.6, dy: dy) + g.fill(rr(cHi, r: 0.3 * v.s), with: .color(.white.opacity(0.16))) + + // Typing caret inside the cyan panel — a nod to the coding agent. + if caret > 0.01 { + let cr = v.r(Self.cyanX + Self.panelW * 0.5 - 0.28, Self.cyanY + 1.0, 0.55, 1.7, dy: dy) + g.fill(rr(cr, r: 0.2 * v.s), with: .color(.white.opacity(0.85 * caret))) + } + + // Eyes on the green face. + let eh = Self.eyeH * eyeScale + let ey = Self.eyeY + (Self.eyeH - eh) / 2 + g.fill(rr(v.r(Self.eyeLX, ey, Self.eyeW, max(0.28, eh), dy: dy), r: 0.55 * v.s), + with: .color(eyeColor)) + g.fill(rr(v.r(Self.eyeRX, ey, Self.eyeW, max(0.28, eh), dy: dy), r: 0.55 * v.s), + with: .color(eyeColor)) + } + + private func drawShadow(_ c: GraphicsContext, v: V, y: CGFloat, + width: CGFloat, opacity: Double) { + c.fill(rr(v.r(Self.centerX - width / 2, y, width, 1.0), r: 0.5 * v.s), + with: .color(.black.opacity(opacity))) + } + + private func drawLegs(_ c: GraphicsContext, v: V, dy: CGFloat = 0) { + let d = dy * 0.35 + // Left leg under the green column, right leg under the lower cyan panel — + // the stepped stance echoes the interlocking mark. + c.fill(rr(v.r(Self.greenX + 0.7, Self.greenY + Self.panelH - 0.2, 1.15, 1.7, dy: d), r: 0.4 * v.s), + with: .color(Self.greenDk)) + c.fill(rr(v.r(Self.cyanX + Self.panelW - 1.85, Self.cyanY + Self.panelH - 0.2, 1.15, 1.7, dy: d), r: 0.4 * v.s), + with: .color(Self.cyanDk)) + } + + // ━━━━━━ SLEEP ━━━━━━ + private var sleepScene: some View { + ZStack { + MascotTimeline(interval: 0.12) { t in + sleepCanvas(t: t) + } + MascotTimeline(interval: 0.12) { t in + floatingZs(t: t) + } + } + } + + private func floatingZs(t: Double) -> some View { + ZStack { + ForEach(0..<3, id: \.self) { i in + let ci = Double(i) + let cycle = 2.8 + ci * 0.3 + let delay = ci * 0.9 + let phase = max(0, ((t - delay).truncatingRemainder(dividingBy: cycle)) / cycle) + let fontSize = max(6, size * CGFloat(0.18 + phase * 0.10)) + let baseOp = 0.7 - ci * 0.1 + let opacity = phase < 0.8 ? baseOp : (1.0 - phase) * 3.5 * baseOp + Text("z") + .font(.system(size: fontSize, weight: .black, design: .monospaced)) + .foregroundStyle(.white.opacity(opacity)) + .offset(x: size * CGFloat(0.15 + ci * 0.08), + y: -size * CGFloat(0.15 + phase * 0.38)) + } + } + } + + private func sleepCanvas(t: Double) -> some View { + // Two incommensurate drift periods — the float never quite repeats, and + // AiWorkBot's rhythm differs from its neighbours so rows don't sync (#15). + let float = sin(t * 2 * .pi / 4.12) * 0.55 + sin(t * 2 * .pi / 6.53) * 0.28 + let breathe = MascotMotion.breathe(t, period: 4.6) + + return Canvas { c, sz in + let v = V(sz, svgW: 15, svgH: 12, svgY0: 4) + drawShadow(c, v: v, y: 15.2, width: 7 + abs(float) * 0.3, opacity: 0.2 + Double(breathe) * 0.05) + drawLegs(c, v: v, dy: float) + drawMark(c, v: v, dy: float, + sqy: 1 - breathe * 0.03, + seam: 0.5, + eyeScale: 0.16, + eyeColor: Self.eyeC.opacity(0.5)) + } + } + + // ━━━━━━ WORK ━━━━━━ + private var workScene: some View { + MascotTimeline(interval: 0.03) { t in + workCanvas(t: t) + } + } + + private func workCanvas(t: Double) -> some View { + // Work pause: every ~11s the bounce settles for a beat — reading output, + // not hammering keys nonstop (#15). + let workPause = MascotMotion.quirk(t, cycle: 10.7, duration: 1.2, seed: 0xD7C1) + let bounce = sin(t * 2 * .pi / 0.4) * 0.9 * (1 - workPause) + + sin(t * 2 * .pi / 2.8) * 0.28 * workPause + let blink = max(0.12, MascotMotion.blink(t, seed: 0xD7C2)) + let seam = 0.48 + 0.12 * (0.5 + 0.5 * sin(t * 2 * .pi / 1.7)) + let caret = max(0.0, sin(t * 2 * .pi / 0.9)) + let land = max(0, bounce) + let keyPhase = Int(t / 0.1) % 6 + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 14, svgY0: 3) + + drawShadow(c, v: v, y: 16.0, width: 9 - abs(bounce) * 0.3, + opacity: max(0.1, 0.32 - Double(abs(bounce)) * 0.03)) + + drawLegs(c, v: v, dy: bounce) + + // Keyboard + c.fill(Path(v.r(0.5, 13, 14, 3)), with: .color(Self.kbBase)) + for row in 0..<2 { + let ky = 13.5 + CGFloat(row) * 1.2 + for col in 0..<6 { + let kx = 1.0 + CGFloat(col) * 2.3 + c.fill(Path(v.r(kx, ky, 1.7, 0.7)), with: .color(Self.kbKey)) + } + } + let fCol = keyPhase % 6 + let fRow = keyPhase / 3 + c.fill(Path(v.r(1.0 + CGFloat(fCol) * 2.3, 13.5 + CGFloat(fRow) * 1.2, 1.7, 0.7)), + with: .color(Self.kbHi.opacity(0.9))) + + drawMark(c, v: v, dy: bounce, + sqx: 1 + land * 0.02, sqy: 1 - land * 0.02, + seam: seam, + eyeScale: blink, + caret: caret) + } + } + + // ━━━━━━ ALERT ━━━━━━ + private var alertScene: some View { + // Gate the pulsing glow on the animation state so the repeatForever + // CAAnimation can't pin a core across display wake (#225). + let glowActive = alive && animationsActive + return ZStack { + Circle() + .fill(Self.alertC.opacity(glowActive ? 0.14 : 0)) + .frame(width: size * 0.82) + .blur(radius: size * 0.05) + .animation( + animationsActive + ? .easeInOut(duration: 0.5).repeatForever(autoreverses: true) + : .default, + value: glowActive + ) + .id(animationEpoch) + + MascotTimeline(interval: 0.03) { t in + alertCanvas(t: t) + } + } + } + + private func alertCanvas(t: Double) -> some View { + let cycle = t.truncatingRemainder(dividingBy: 3.5) + let pct = cycle / 3.5 + + let jumpY = lerp([ + (0, 0), (0.03, 0), (0.10, -1), (0.15, 1.5), + (0.175, -8), (0.20, -8), (0.25, 1.5), + (0.275, -6), (0.30, -6), (0.35, 1.0), + (0.375, -4), (0.40, -4), (0.45, 0.8), + (0.475, -2), (0.50, -2), (0.55, 0.3), + (0.62, 0), (1.0, 0), + ], at: pct) + + let shakeX: CGFloat = (pct > 0.15 && pct < 0.55) ? sin(pct * 80) * 0.6 : 0 + + let bangOp = lerp([ + (0, 0), (0.03, 1), (0.10, 1), (0.55, 1), (0.62, 0), (1.0, 0), + ], at: pct) + let bangScale = lerp([ + (0, 0.3), (0.03, 1.3), (0.10, 1.0), (0.55, 1.0), (0.62, 0.6), (1.0, 0.6), + ], at: pct) + + // Panels pop apart on the startle; eyes widen then flash warm. + let seam = 0.55 + 0.5 * bangOp + let sqx: CGFloat = jumpY > 0.5 ? 1 + jumpY * 0.03 : 1 + let sqy: CGFloat = jumpY > 0.5 ? 1 - jumpY * 0.025 : 1 + let eyeScale: CGFloat = (pct > 0.03 && pct < 0.15) ? 1.3 : 1.0 + let eyeColor: Color = (pct > 0.03 && pct < 0.55 && sin(pct * 25) > 0) ? Self.alertC : Self.eyeC + + return Canvas { c, sz in + let v = V(sz, svgW: 16, svgH: 14, svgY0: 3) + + drawShadow(c, v: v, y: 16.0, width: 9 * (1.0 - abs(min(0, jumpY)) * 0.04), + opacity: max(0.08, 0.4 - Double(abs(min(0, jumpY))) * 0.04)) + + drawLegs(c, v: v, dy: jumpY) + + c.translateBy(x: shakeX * v.s, y: 0) + drawMark(c, v: v, dy: jumpY, sqx: sqx, sqy: sqy, + seam: seam, eyeScale: eyeScale, eyeColor: eyeColor) + c.translateBy(x: -shakeX * v.s, y: 0) + + if bangOp > 0.01 { + let bw: CGFloat = 2 * bangScale + let bx: CGFloat = 12.9 + let by: CGFloat = 3.2 + jumpY * 0.15 + c.fill(rr(v.r(bx, by, bw, 3.2 * bangScale, dy: 0), r: 0.4 * v.s), + with: .color(Self.alertC.opacity(bangOp))) + c.fill(rr(v.r(bx, by + 3.7 * bangScale, bw, 1.3 * bangScale, dy: 0), r: 0.4 * v.s), + with: .color(Self.alertC.opacity(bangOp))) + } + } + } +} diff --git a/Sources/CodeIsland/AppDelegate.swift b/Sources/CodeIsland/AppDelegate.swift index b100c445..4cc41178 100644 --- a/Sources/CodeIsland/AppDelegate.swift +++ b/Sources/CodeIsland/AppDelegate.swift @@ -59,6 +59,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { appState.startSessionDiscovery() appState.startCodexAppServerWatcher() + appState.startAiWorkWatcher() RemoteManager.shared.startup() // Buddy bridge (opt-in): mirrors the Dynamic Island onto the companion @@ -169,6 +170,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { RemoteManager.shared.shutdown() hookServer?.stop() appState.stopCodexAppServerWatcher() + appState.stopAiWorkWatcher() appState.stopSessionDiscovery() } diff --git a/Sources/CodeIsland/AppState+AiWorkWatch.swift b/Sources/CodeIsland/AppState+AiWorkWatch.swift new file mode 100644 index 00000000..e328dacc --- /dev/null +++ b/Sources/CodeIsland/AppState+AiWorkWatch.swift @@ -0,0 +1,893 @@ +import Foundation +import AppKit +import CodeIslandCore + +extension AppState { + /// Session ID prefix applied to AiWork/Agentix sessions surfaced via + /// `sessions.watch`. Keeps the namespace disjoint from hook-driven sources. + static let aiworkSessionPrefix = "aiwork:" + + // MARK: - Public lifecycle + + /// Start discovering Agentix daemons and maintaining `sessions.watch` + /// subscriptions. Idempotent. No-ops when both AiWork GUI and CLI monitoring + /// are disabled in Hooks settings. + func startAiWorkWatcher() { + guard ConfigInstaller.isAnyAiWorkMonitoringEnabled() else { + stopAiWorkWatcher() + return + } + if aiworkWatchReconnectTimer != nil { return } + + // Periodic rediscovery covers daemon restarts and late launches + // (AiWork GUI / `aiwork tui` bring the coder daemon up on demand). + let timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.reconcileAiWorkWatchClients() + } + } + // Tolerate a bit of AppKit run-loop jitter so a busy main thread + // doesn't stall rediscovery for many seconds. + timer.tolerance = 1.0 + aiworkWatchReconnectTimer = timer + + reconcileAiWorkWatchClients() + } + + func stopAiWorkWatcher() { + aiworkWatchReconnectTimer?.invalidate() + aiworkWatchReconnectTimer = nil + + let agents = Array(aiworkWatchClients.keys) + for agentId in agents { + stopAiWorkWatchClient(agentId: agentId) + } + aiworkHydratedSessionIds.removeAll() + } + + // MARK: - Client lifecycle + + func reconcileAiWorkWatchClients() { + guard ConfigInstaller.isAnyAiWorkMonitoringEnabled() else { + let agents = Array(aiworkWatchClients.keys) + for agentId in agents { + stopAiWorkWatchClient(agentId: agentId) + } + return + } + let ready = AiWorkWatchClient.discoverReadyDaemons() + let readyIds = Set(ready.map(\.agentId)) + + // Drop clients whose daemon disappeared. + for agentId in aiworkWatchClients.keys where !readyIds.contains(agentId) { + stopAiWorkWatchClient(agentId: agentId) + } + + // Start clients for newly ready daemons. + for daemon in ready where aiworkWatchClients[daemon.agentId] == nil { + startAiWorkWatchClient(agentId: daemon.agentId, socketPath: daemon.socketPath) + } + + // Drop sessions whose surface was toggled off in Hooks settings. + pruneDisabledAiWorkSessions() + + // Stream events can miss stream.completed / tool_result (watch gap, + // client exit mid-tool). Daemon agent.stats is source of truth for + // "is a turn actually running" — correct stuck .running/.processing. + reconcileAiWorkLiveStatuses() + } + + /// Remove local AiWork sessions whose Hooks toggle is off. + func pruneDisabledAiWorkSessions() { + let stale = sessions.compactMap { key, snapshot -> String? in + guard key.hasPrefix(AppState.aiworkSessionPrefix) else { return nil } + let source = snapshot.source + if source == "aiwork" || source == "aiwork-cli" { + return ConfigInstaller.isEnabled(source: source) ? nil : key + } + // Legacy / unknown: resolve from client_type. + let resolved = AiWorkStatusMapper.codeIslandSource( + clientType: snapshot.aiworkClientType, + daemonSessionId: snapshot.providerSessionId + ) + return ConfigInstaller.isEnabled(source: resolved) ? nil : key + } + guard !stale.isEmpty else { return } + for id in stale { + if let daemonId = sessions[id]?.providerSessionId + ?? id.dropPrefix(AppState.aiworkSessionPrefix) { + aiworkHydratedSessionIds.remove(daemonId) + } + sessions.removeValue(forKey: id) + } + refreshDerivedState() + } + + /// Drop every local session for one Hooks surface (`aiwork` or `aiwork-cli`). + func removeAiWorkSessions(source: String) { + let stale = sessions.compactMap { key, snapshot -> String? in + guard key.hasPrefix(AppState.aiworkSessionPrefix) else { return nil } + if snapshot.source == source { return key } + let resolved = AiWorkStatusMapper.codeIslandSource( + clientType: snapshot.aiworkClientType, + daemonSessionId: snapshot.providerSessionId + ) + return resolved == source ? key : nil + } + for id in stale { + if let daemonId = sessions[id]?.providerSessionId + ?? id.dropPrefix(AppState.aiworkSessionPrefix) { + aiworkHydratedSessionIds.remove(daemonId) + } + sessions.removeValue(forKey: id) + } + if !stale.isEmpty { + refreshDerivedState() + } + } + + /// Force-idle local AiWork sessions that the daemon no longer lists as busy. + /// + /// `sessions.watch` is lossy across reconnects and mid-tool client exits: a + /// `stream.tool_call` can leave CodeIsland at `.running` forever while the + /// daemon has already returned to `phase=idle` / `active_requests=0`. + func reconcileAiWorkLiveStatuses() { + let stuck = sessions.compactMap { + key, snapshot -> (key: String, daemonId: String, lastActivity: Date)? in + guard key.hasPrefix(AppState.aiworkSessionPrefix), snapshot.status != .idle else { + return nil + } + let daemonId = snapshot.providerSessionId + ?? String(key.dropFirst(AppState.aiworkSessionPrefix.count)) + return (key, daemonId, snapshot.lastActivity) + } + guard !stuck.isEmpty else { return } + guard !aiworkReconcileInFlight else { return } + + // The busy set has to come from the daemon that actually owns the session. + // Querying one arbitrary socket (dictionary order over aiworkWatchClients) + // meant a turn genuinely running on daemon B was absent from daemon A's + // stats and got force-idled after the grace window — so group the stuck + // sessions per agent and ask each daemon about its own. + var sockets: [String: String] = [:] + for (agentId, client) in aiworkWatchClients { + sockets[agentId] = client.socketPath + } + for daemon in AiWorkWatchClient.discoverReadyDaemons() + where sockets[daemon.agentId] == nil { + sockets[daemon.agentId] = daemon.socketPath + } + // An id with no agent segment can only be attributed when there is exactly + // one daemon to attribute it to. + let soleAgent = sockets.count == 1 ? sockets.keys.first : nil + var groups: [String: [(key: String, daemonId: String, lastActivity: Date)]] = [:] + for entry in stuck { + let owner = AiWorkWatchClient.agentId(fromDaemonSessionId: entry.daemonId) ?? soleAgent + guard let owner, sockets[owner] != nil else { continue } + groups[owner, default: []].append(entry) + } + guard !groups.isEmpty else { return } + + aiworkReconcileInFlight = true + Task.detached(priority: .utility) { [weak self] in + var results: [AiWorkBusyReconcileResult] = [] + for (agentId, entries) in groups { + guard let socketPath = sockets[agentId] else { continue } + // nil = stats unreachable for this daemon — leave its sessions alone. + guard let busy = AiWorkStatusMapper.fetchBusyDaemonSessionIds( + socketPath: socketPath + ) else { continue } + results.append(AiWorkBusyReconcileResult(sessions: entries, busyDaemonIds: busy)) + } + guard let self else { return } + await self.finishAiWorkBusyReconcile(results: results) + } + } + + /// One daemon's answer: the sessions it owns, and the ids it reports as busy. + struct AiWorkBusyReconcileResult: Sendable { + let sessions: [(key: String, daemonId: String, lastActivity: Date)] + let busyDaemonIds: Set + } + + /// MainActor tail of `reconcileAiWorkLiveStatuses`: releases the in-flight + /// guard and applies each daemon's own busy set to its own sessions. + func finishAiWorkBusyReconcile(results: [AiWorkBusyReconcileResult]) { + aiworkReconcileInFlight = false + let now = Date() + for result in results { + applyAiWorkBusyReconcile( + localSessions: result.sessions, + busyDaemonIds: result.busyDaemonIds, + now: now + ) + } + } + + /// Pure decision + apply seam for tests. + func applyAiWorkBusyReconcile( + localSessions: [(key: String, daemonId: String, lastActivity: Date)], + busyDaemonIds: Set, + now: Date, + grace: TimeInterval = 8 + ) { + var changed = false + for (key, daemonId, lastActivity) in localSessions { + guard AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: daemonId, + lastActivity: lastActivity, + busyDaemonIds: busyDaemonIds, + now: now, + grace: grace + ) else { continue } + guard var snapshot = sessions[key], snapshot.status != .idle else { continue } + // Re-check against the CURRENT activity stamp: `lastActivity` in + // localSessions was snapshotted before the agent.stats round trip, so a + // turn that started inside that window would otherwise be judged on + // pre-turn activity and flashed back to idle. + if -snapshot.lastActivity.timeIntervalSinceNow <= grace { continue } + snapshot.status = .idle + snapshot.currentTool = nil + snapshot.toolDescription = nil + sessions[key] = snapshot + changed = true + } + if changed { + refreshDerivedState() + } + } + + private func startAiWorkWatchClient(agentId: String, socketPath: String) { + guard aiworkWatchClients[agentId] == nil else { return } + + let client = AiWorkWatchClient(socketPath: socketPath) + client.onFrame = { [weak self] frame in + Task { @MainActor in + self?.handleAiWorkFrame(frame, agentId: agentId) + } + } + client.onExit = { [weak self] in + Task { @MainActor in + self?.aiworkWatchClients.removeValue(forKey: agentId) + self?.removeAiWorkSessions(agentId: agentId) + } + } + + do { + try client.start() + try client.watchSessions(watchAckId: "codeisland-\(agentId)") + } catch { + client.stop() + return + } + + aiworkWatchClients[agentId] = client + backfillAiWorkActiveSessions(agentId: agentId, socketPath: socketPath) + } + + private func stopAiWorkWatchClient(agentId: String) { + if let client = aiworkWatchClients.removeValue(forKey: agentId) { + client.stop() + } + removeAiWorkSessions(agentId: agentId) + } + + func removeAiWorkSessions(agentId: String? = nil) { + let stale: [String] + if let agentId { + // Daemon session ids look like `acp:coder:…` / `cli:coder:…`. + // Match either the agent segment or any session we tagged with this + // agent via providerSessionId when the prefix is ambiguous. + stale = sessions.compactMap { key, snapshot in + guard key.hasPrefix(AppState.aiworkSessionPrefix) else { return nil } + let daemonId = snapshot.providerSessionId ?? String(key.dropFirst(AppState.aiworkSessionPrefix.count)) + let parts = daemonId.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + // channel:agent:uuid → parts[1] == agent + if parts.count >= 2, parts[1] == agentId { return key } + // Fallback: agent id embedded anywhere in the daemon session id. + if daemonId.contains(":\(agentId):") { return key } + return nil + } + } else { + stale = sessions.keys.filter { $0.hasPrefix(AppState.aiworkSessionPrefix) } + } + for id in stale { + sessions.removeValue(forKey: id) + if let daemonId = id.dropPrefix(AppState.aiworkSessionPrefix) { + aiworkHydratedSessionIds.remove(daemonId) + } + } + if !stale.isEmpty { + refreshDerivedState() + } + } + + // MARK: - Frame handling + + func handleAiWorkFrame(_ frame: AiWorkFrame, agentId: String) { + switch frame.kind { + case .event(let name, _): + handleAiWorkStreamEvent(name: name, frame: frame, agentId: agentId) + case .response, .unknown: + // Watch stream is event-driven; unary responses arrive on a + // separate short-lived connection used for backfill / hydrate. + break + } + } + + func handleAiWorkStreamEvent(name: String, frame: AiWorkFrame, agentId: String) { + guard let daemonSessionId = frame.sessionId, !daemonSessionId.isEmpty else { return } + + let mappedStatus = AiWorkStatusMapper.status(forEventName: name) + // Metadata-only events (e.g. session_info_changed) still update title/cwd. + let isMetadataEvent = name == "stream.session_info_changed" + || name == "stream.session_mode_changed" + || name == "stream.session_config_changed" + guard mappedStatus != nil || isMetadataEvent || frame.dataObject?["session"] != nil else { + return + } + + let sessionId = AppState.aiworkSessionPrefix + daemonSessionId + let isNew = sessions[sessionId] == nil + // A status-less event for a conversation we are not already tracking is + // history, not activity. Creating a card from one would surface long- + // finished conversations (backfill deliberately skips idle entries for the + // same reason), so only an event that maps to a status may open a card. + if mappedStatus == nil, isNew { return } + var snapshot = sessions[sessionId] ?? SessionSnapshot(startTime: Date()) + snapshot.providerSessionId = daemonSessionId + AppState.applyAiWorkAppIdentity(&snapshot) + + AppState.applyAiWorkSessionMetadata(&snapshot, data: frame.dataObject) + + let source = AiWorkStatusMapper.codeIslandSource( + clientType: snapshot.aiworkClientType, + daemonSessionId: daemonSessionId + ) + guard ConfigInstaller.isEnabled(source: source) else { + // `source` is only a guess until sessions.get returns client_type: a + // modern `aiwork tui` session also carries the `acp:` prefix, so it + // reads as GUI here. Dropping it now would mean hydrate never runs, the + // real client_type is never learned, and a "CLI on / GUI off" setup + // would silently lose every TUI session forever. Hydrate first and let + // the post-hydrate guard — which sees the real client_type — decide. + if snapshot.aiworkClientType == nil, + ConfigInstaller.isAnyAiWorkMonitoringEnabled() { + hydrateAiWorkSessionIfNeeded(daemonSessionId: daemonSessionId, agentId: agentId) + return + } + // Surface toggled off — drop any leftover card for this session. + if sessions[sessionId] != nil { + sessions.removeValue(forKey: sessionId) + aiworkHydratedSessionIds.remove(daemonSessionId) + refreshDerivedState() + } + return + } + snapshot.source = source + + if let newStatus = mappedStatus { + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: name, + data: frame.dataObject, + status: newStatus + ) + } + + // Only a status-bearing event counts as activity: refreshing this on every + // metadata event would keep resetting the idle sweep's grace window, so a + // finished conversation's card would never be collected. + if mappedStatus != nil { + snapshot.lastActivity = Date() + } + sessions[sessionId] = snapshot + + if AiWorkStatusMapper.isTerminalEvent(name) { + enqueueCompletion(sessionId) + } + + // Sound: reuse the event names the hook-driven sources emit so the existing + // per-event toggles in Sound settings apply unchanged. Deliberately narrow — + // one sound per turn boundary, never per streaming delta. + switch name { + case "stream.started": + SoundManager.shared.handleEvent(isNew ? "SessionStart" : "UserPromptSubmit") + case "stream.approval_required", "stream.plan_confirmation_required": + SoundManager.shared.handleEvent("PermissionRequest") + case "stream.question_required": + SoundManager.shared.handleEvent("PermissionRequest") + case "stream.completed": + SoundManager.shared.handleEvent("Stop") + case "stream.failed": + SoundManager.shared.handleEvent("PostToolUseFailure") + default: + break + } + + refreshDerivedState() + + // Title/cwd/client_type often arrive only via sessions.get — + // hydrate when we first see the session or when those fields are missing. + if isNew + || snapshot.sessionTitle == nil + || snapshot.cwd == nil + || snapshot.aiworkClientType == nil { + hydrateAiWorkSessionIfNeeded( + daemonSessionId: daemonSessionId, + agentId: agentId + ) + } + } + + /// Pure reducer used by live events and unit tests. + static func applyAiWorkStreamEvent( + _ snapshot: inout SessionSnapshot, + eventName: String, + data: [String: AnyCodableLike]?, + status: AgentStatus + ) { + snapshot.status = status + switch eventName { + case "stream.started": + // Mirror Claude UserPromptSubmit: keep the user turn in recentMessages + // so idle cards still show `>` / `$` chat lines after completion. + if let prompt = aiworkUserPrompt(from: data), !prompt.isEmpty { + snapshot.lastUserPrompt = prompt + let last = snapshot.recentMessages.last + if last?.isUser != true || last?.text != prompt { + snapshot.addRecentMessage(ChatMessage(isUser: true, text: prompt)) + } + } + snapshot.lastAssistantMessage = nil + snapshot.currentTool = nil + snapshot.toolDescription = nil + + case "stream.tool_call": + snapshot.currentTool = AiWorkStatusMapper.toolName(from: data) + snapshot.toolDescription = AiWorkStatusMapper.toolDescription(from: data) + if let tool = snapshot.currentTool { + snapshot.recordTool( + tool, + description: snapshot.toolDescription, + success: true, + agentType: nil, + maxHistory: 20 + ) + } + + case "stream.progress", + "stream.thinking_delta", + "stream.text_delta", + "stream.ack", + "stream.fallback_started": + snapshot.currentTool = AiWorkStatusMapper.progressLabel(from: data, eventName: eventName) + if let text = AiWorkStatusMapper.progressText(from: data) { + if eventName == "stream.text_delta" || eventName == "stream.thinking_delta" { + // Deltas are tiny fragments. Putting each one into toolDescription + // (a) looks like noise and (b) re-triggers MorphText's blur on every + // token so the `$` row stays permanently blurred while streaming. + // Accumulate into lastAssistantMessage and show a trailing preview. + let existing = snapshot.lastAssistantMessage ?? "" + let merged = existing.isEmpty ? text : (existing + text) + snapshot.lastAssistantMessage = String(merged.suffix(800)) + let preview = String(merged.suffix(200)) + .trimmingCharacters(in: .whitespacesAndNewlines) + snapshot.toolDescription = preview.isEmpty + ? snapshot.currentTool + : AiWorkStatusMapper.truncate(preview, max: 160) + } else { + snapshot.toolDescription = text + } + } else if snapshot.toolDescription == nil { + snapshot.toolDescription = snapshot.currentTool + } + + case "stream.tool_result": + // Brief linger: keep the tool name but mark completion via description + // until the next progress/thinking event arrives. + if snapshot.currentTool == nil { + snapshot.currentTool = AiWorkStatusMapper.toolName(from: data) + } + snapshot.toolDescription = AiWorkStatusMapper.toolDescription(from: data) + ?? snapshot.currentTool + snapshot.status = .processing + + case "stream.approval_required", + "stream.plan_confirmation_required": + snapshot.currentTool = AiWorkStatusMapper.toolName(from: data) + ?? data?["command"]?.asString + ?? "approval" + snapshot.toolDescription = data?["command"]?.asString + ?? data?["reason"]?.asString + ?? snapshot.toolDescription + + case "stream.question_required": + snapshot.currentTool = "question" + snapshot.toolDescription = data?["message"]?.asString + ?? AiWorkStatusMapper.progressText(from: data) + ?? "waiting for answer" + + case "stream.approval_resolved", + "stream.plan_confirmation_resolved", + "stream.question_resolved": + snapshot.currentTool = "working" + snapshot.toolDescription = nil + + case "stream.completed", + "stream.failed", + "stream.aborted": + finalizeAiWorkTurnMessages(&snapshot, data: data, eventName: eventName) + snapshot.currentTool = nil + snapshot.toolDescription = nil + + default: + break + } + } + + /// Pull the observer-facing user prompt from `stream.started` (daemon + /// `display_text`) or common prompt aliases. + static func aiworkUserPrompt(from data: [String: AnyCodableLike]?) -> String? { + guard let data else { return nil } + let candidates = [ + data["display_text"]?.asString, + data["prompt"]?.asString, + data["text"]?.asString, + data["message"]?.asString, + data["user_message"]?.asString, + data["session"]?.asObject?["title"]?.asString + ] + for raw in candidates { + guard let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + continue + } + return AiWorkStatusMapper.truncate(text, max: 200) + } + return nil + } + + /// Mirror Claude `Stop`: keep chat preview after the turn goes idle. + /// Localized stand-in when a turn ends with no assistant text of its own. + /// Routed through L10n so this cannot regress to hardcoded Chinese literals — + /// v1.0.33 (#322) shipped exactly that fix for `reply_complete_placeholder`, + /// and L10nTests asserts these values are never bracketed. + static func aiworkTerminalPlaceholder(eventName: String) -> String { + switch eventName { + case "stream.failed": return L10n.shared["reply_failed_placeholder"] + case "stream.aborted": return L10n.shared["reply_aborted_placeholder"] + default: return L10n.shared["reply_complete_placeholder"] + } + } + + static func finalizeAiWorkTurnMessages( + _ snapshot: inout SessionSnapshot, + data: [String: AnyCodableLike]?, + eventName: String + ) { + let noticeSummary = data?["terminal_notice"]?.asObject?["summary"]?.asString + let finalText = data?["final_text"]?.asString + ?? data?["text"]?.asString + ?? noticeSummary + ?? snapshot.lastAssistantMessage + + if let msg = finalText?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { + let clipped = AiWorkStatusMapper.truncate(msg, max: 200) + snapshot.lastAssistantMessage = String(msg.suffix(800)) + let last = snapshot.recentMessages.last + if last?.isUser == true || last?.text != clipped { + snapshot.addRecentMessage(ChatMessage(isUser: false, text: clipped)) + } + } else if snapshot.recentMessages.last?.isUser == true { + let placeholder = AppState.aiworkTerminalPlaceholder(eventName: eventName) + snapshot.addRecentMessage(ChatMessage(isUser: false, text: placeholder)) + } + + // Late attach / missed stream.started: still leave a visible idle preview. + if snapshot.recentMessages.isEmpty { + if let prompt = snapshot.lastUserPrompt + ?? snapshot.sessionTitle + ?? aiworkUserPrompt(from: data) { + snapshot.lastUserPrompt = snapshot.lastUserPrompt ?? prompt + snapshot.addRecentMessage(ChatMessage(isUser: true, text: prompt)) + let placeholder = AppState.aiworkTerminalPlaceholder(eventName: eventName) + snapshot.addRecentMessage(ChatMessage(isUser: false, text: placeholder)) + } + } + } + + static func applyAiWorkSessionMetadata( + _ snapshot: inout SessionSnapshot, + data: [String: AnyCodableLike]? + ) { + guard let data else { return } + let sessionObj = data["session"]?.asObject + + if let cwd = sessionObj?["cwd"]?.asString ?? data["cwd"]?.asString, !cwd.isEmpty { + snapshot.cwd = cwd + } + if let title = sessionObj?["title"]?.asString ?? data["title"]?.asString, !title.isEmpty { + snapshot.sessionTitle = title + } + if let model = sessionObj?["model"]?.asString ?? data["model"]?.asString, !model.isEmpty { + snapshot.model = model + } + if let clientType = sessionObj?["client_type"]?.asString + ?? data["client_type"]?.asString, + !clientType.isEmpty { + snapshot.aiworkClientType = clientType + } + if let prompt = data["prompt"]?.asString + ?? data["user_prompt"]?.asString + ?? sessionObj?["last_user_prompt"]?.asString, + !prompt.isEmpty { + snapshot.lastUserPrompt = String(prompt.prefix(240)) + } + if let signal = sessionObj?["readable_summary"]?.asObject?["signal"]?.asString, + !signal.isEmpty, + snapshot.toolDescription == nil, + snapshot.status != .idle { + snapshot.toolDescription = AiWorkStatusMapper.truncate(signal, max: 160) + } + } + + // MARK: - Hydrate / backfill + + /// Fetch `sessions.get` so the notch can show the real conversation title + /// and the correct GUI/TUI client label (`client_type` is get-only). + func hydrateAiWorkSessionIfNeeded(daemonSessionId: String, agentId: String) { + let key = AppState.aiworkSessionPrefix + daemonSessionId + let snap = sessions[key] + if snap?.sessionTitle != nil, snap?.aiworkClientType != nil { + return + } + // In-flight / already attempted — avoid spamming sessions.get on every event. + if aiworkHydratedSessionIds.contains(daemonSessionId) { return } + aiworkHydratedSessionIds.insert(daemonSessionId) + + let socketPath = aiworkWatchClients[agentId]?.socketPath + ?? AiWorkWatchClient.discoverReadyDaemons() + .first(where: { $0.agentId == agentId })? + .socketPath + guard let socketPath else { + aiworkHydratedSessionIds.remove(daemonSessionId) + return + } + + // `self` is bound once up front rather than reached through `self?.` inside + // nested MainActor.run closures — that pattern is a captured-var-in- + // concurrent-code warning today and an error under the Swift 6 language mode. + Task.detached(priority: .utility) { [weak self] in + let frame = AiWorkWatchClient.unaryCall( + socketPath: socketPath, + method: "sessions.get", + params: ["sessionId": daemonSessionId], + timeoutSeconds: 5 + ) + guard let self else { return } + guard let frame, case .response(_, true) = frame.kind else { + await self.releaseAiWorkHydrateMarker(daemonSessionId) + return + } + guard let entry = frame.dataObject?["session"]?.asObject ?? frame.dataObject else { + await self.releaseAiWorkHydrateMarker(daemonSessionId) + return + } + await self.applyAiWorkHydrate(key: key, daemonSessionId: daemonSessionId, entry: entry) + } + } + + /// Let a later event retry `sessions.get` after a failed hydrate. + func releaseAiWorkHydrateMarker(_ daemonSessionId: String) { + aiworkHydratedSessionIds.remove(daemonSessionId) + } + + /// MainActor tail of `hydrateAiWorkSessionIfNeeded`. + func applyAiWorkHydrate( + key: String, + daemonSessionId: String, + entry: [String: AnyCodableLike] + ) { + var snapshot = sessions[key] ?? SessionSnapshot(startTime: Date()) + snapshot.providerSessionId = daemonSessionId + AppState.applyAiWorkAppIdentity(&snapshot) + AppState.applyAiWorkListEntry( + &snapshot, + daemonSessionId: daemonSessionId, + entry: entry, + preserveLiveStatus: true + ) + guard ConfigInstaller.isEnabled(source: snapshot.source) else { + sessions.removeValue(forKey: key) + aiworkHydratedSessionIds.remove(daemonSessionId) + refreshDerivedState() + return + } + sessions[key] = snapshot + refreshDerivedState() + } + + /// On connect, pull currently-busy sessions so the notch isn't empty until + /// the next live event. Idle historical sessions are intentionally skipped. + /// + /// Both RPCs below block their calling thread (`unaryCall` waits on a + /// DispatchGroup), so they must never run on this MainActor-isolated type: a + /// daemon that accepts the connection but never answers would otherwise freeze + /// the UI for the full timeout. Same off-main shape as + /// `hydrateAiWorkSessionIfNeeded` — only the apply steps hop back to main. + func backfillAiWorkActiveSessions(agentId: String, socketPath: String) { + Task.detached(priority: .utility) { [weak self] in + // Prefer agent.stats active_request_details when present; fall back to + // sessions.list filtered by AiWorkStatusMapper.isActiveListEntry. + let stats = AiWorkWatchClient.unaryCall( + socketPath: socketPath, + method: "agent.stats", + params: [String: Any]() + ) + var statsRows: [AnyCodableLike] = [] + if let stats, case .response(_, true) = stats.kind, + case .array(let rows) = stats.dataObject?["active_request_details"] { + statsRows = rows + } + guard let self else { return } + if await self.applyAiWorkStatsBackfill(agentId: agentId, rows: statsRows) { + return + } + + let list = AiWorkWatchClient.unaryCall( + socketPath: socketPath, + method: "sessions.list", + params: ["limit": 50] + ) + guard let list, case .response(_, true) = list.kind, + case .array(let listRows) = list.dataObject?["sessions"] else { + return + } + await self.applyAiWorkListBackfill(agentId: agentId, rows: listRows) + } + } + + /// Apply `agent.stats` rows. Returns true when at least one card was written, + /// in which case `sessions.list` is not consulted. + @discardableResult + func applyAiWorkStatsBackfill(agentId: String, rows: [AnyCodableLike]) -> Bool { + guard !rows.isEmpty else { return false } + var applied = false + for row in rows { + guard let obj = row.asObject else { continue } + let sid = obj["session_id"]?.asString + ?? obj["sessionId"]?.asString + guard let sid, !sid.isEmpty else { continue } + let sessionId = AppState.aiworkSessionPrefix + sid + var snapshot = sessions[sessionId] ?? SessionSnapshot(startTime: Date()) + snapshot.providerSessionId = sid + AppState.applyAiWorkAppIdentity(&snapshot) + // Prefer client_type from the stats row when present; otherwise + // keep existing / fall back via session id until hydrate. + if let clientType = obj["client_type"]?.asString, !clientType.isEmpty { + snapshot.aiworkClientType = clientType + } + let source = AiWorkStatusMapper.codeIslandSource( + clientType: snapshot.aiworkClientType, + daemonSessionId: sid + ) + guard ConfigInstaller.isEnabled(source: source) else { continue } + snapshot.source = source + if let cwd = obj["cwd"]?.asString, !cwd.isEmpty { + snapshot.cwd = cwd + } + let phase = obj["phase"]?.asString + switch phase { + case "tool_execution": + snapshot.status = .running + snapshot.currentTool = "tool" + default: + snapshot.status = .processing + snapshot.currentTool = AiWorkStatusMapper.compactPhaseLabel(phase ?? "llm_streaming") + } + if let label = obj["phase_label"]?.asString, !label.isEmpty { + snapshot.toolDescription = label + } else if let phase { + snapshot.toolDescription = AiWorkStatusMapper.compactPhaseLabel(phase) + } + snapshot.lastActivity = Date() + sessions[sessionId] = snapshot + applied = true + hydrateAiWorkSessionIfNeeded(daemonSessionId: sid, agentId: agentId) + } + if applied { + refreshDerivedState() + } + return applied + } + + /// Apply `sessions.list` rows, filtered to active entries owned by `agentId`. + func applyAiWorkListBackfill(agentId: String, rows: [AnyCodableLike]) { + var applied = false + for row in rows { + guard let entry = row.asObject else { continue } + guard AiWorkStatusMapper.isActiveListEntry(entry) else { continue } + guard let sid = entry["session_id"]?.asString, !sid.isEmpty else { continue } + // Keep backfill scoped to this agent when the id is embedded. + if !sid.contains(":\(agentId):"), !sid.hasSuffix(":\(agentId)"), sid != agentId { + // Still accept ids like `acp:coder:uuid`. + let parts = sid.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + if parts.count >= 2, parts[1] != agentId { continue } + } + + let sessionId = AppState.aiworkSessionPrefix + sid + var snapshot = sessions[sessionId] ?? SessionSnapshot(startTime: Date()) + AppState.applyAiWorkListEntry( + &snapshot, + daemonSessionId: sid, + entry: entry, + preserveLiveStatus: false + ) + guard ConfigInstaller.isEnabled(source: snapshot.source) else { continue } + sessions[sessionId] = snapshot + applied = true + aiworkHydratedSessionIds.insert(sid) + } + if applied { + refreshDerivedState() + } + } + + /// Tag AiWork sessions with the IDE bundle so TerminalBadge can show the + /// app icon + "AiWork" / "AiWork CLI" label (same pattern as Cursor). + /// Bundle id did not change with the AiWork → AiWork rename. + static let aiworkAppBundleId = "com.alipay.dtcoder.ide" + + static func applyAiWorkAppIdentity(_ snapshot: inout SessionSnapshot) { + snapshot.termBundleId = aiworkAppBundleId + snapshot.termApp = "AiWork" + } + + /// Test seam / shared apply for list/get projections. + static func applyAiWorkListEntry( + _ snapshot: inout SessionSnapshot, + daemonSessionId: String, + entry: [String: AnyCodableLike], + preserveLiveStatus: Bool = false + ) { + snapshot.providerSessionId = daemonSessionId + applyAiWorkAppIdentity(&snapshot) + if let cwd = entry["cwd"]?.asString, !cwd.isEmpty { + snapshot.cwd = cwd + } + if let title = entry["title"]?.asString, !title.isEmpty { + snapshot.sessionTitle = title + } + if let model = entry["model"]?.asString, !model.isEmpty { + snapshot.model = model + } + if let clientType = entry["client_type"]?.asString, !clientType.isEmpty { + snapshot.aiworkClientType = clientType + } + snapshot.source = AiWorkStatusMapper.codeIslandSource( + clientType: snapshot.aiworkClientType, + daemonSessionId: daemonSessionId + ) + if !preserveLiveStatus || snapshot.status == .idle { + snapshot.status = AiWorkStatusMapper.agentStatus(fromListEntry: entry) + } + if snapshot.toolDescription == nil, + let signal = entry["readable_summary"]?.asObject?["signal"]?.asString, + !signal.isEmpty, + snapshot.status != .idle { + snapshot.toolDescription = AiWorkStatusMapper.truncate(signal, max: 160) + if snapshot.currentTool == nil { + let phase = entry["readable_summary"]?.asObject?["phase"]?.asString + snapshot.currentTool = AiWorkStatusMapper.compactPhaseLabel(phase ?? "working") + } + } + snapshot.lastActivity = Date() + } +} + +private extension String { + func dropPrefix(_ prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 82e24ec3..b88870a2 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -188,6 +188,20 @@ final class AppState { @ObservationIgnored var closedCodexAppThreads: [String: Date] = [:] + /// Per-agent AiWork/Agentix daemon watch clients (`sessions.watch`). + /// Keyed by agent id (e.g. `"coder"`). See AppState+AiWorkWatch. + @ObservationIgnored + var aiworkWatchClients: [String: AiWorkWatchClient] = [:] + /// Periodic reconnect / rediscovery timer for Agentix daemons. + @ObservationIgnored + var aiworkWatchReconnectTimer: Timer? + /// Daemon session ids that already received a `sessions.get` title/cwd hydrate. + @ObservationIgnored + var aiworkHydratedSessionIds: Set = [] + /// Guards overlapping `agent.stats` reconciles from the 3s rediscovery timer. + @ObservationIgnored + var aiworkReconcileInFlight = false + /// Computed: first item in permission queue (backward compat for UI reads) var pendingPermission: PermissionRequest? { permissionQueue.first } /// Computed: first item in question queue @@ -231,7 +245,13 @@ final class AppState { /// Local-transcript token usage shown in the session-list footer. /// Refreshed lazily on panel expansion (no resident timer, no API calls). var claudeUsage: ClaudeUsageScanner.Snapshot? - private var usageScanInFlight = false + /// Process-wide, not per-instance: the scan reads one shared history + /// (`~/.claude`), so two concurrent runs are always duplicate work. Production + /// has a single AppState and never noticed, but anything constructing several — + /// the test bundle does — otherwise starts N cold scans at once on the shared + /// `.utility` cooperative pool and starves every other detached probe on it + /// (git-branch resolution among them). + private static var usageScanInFlight = false /// Incremental parse state — round-trips through each detached scan so /// growing transcripts are only read past their last consumed offset. private var usageFileCache = ClaudeUsageScanner.FileCache() @@ -507,6 +527,27 @@ final class AppState { } } + // 4b. AiWork sessions come from the Agentix daemon's persistent session + // store, which keeps every conversation as status:"active" forever and + // never emits a close/delete event. A finished turn or an exited TUI/GUI + // conversation just goes idle, so without this it would sit here for the + // full 10-min no-monitor timeout (Section 4) — completed eval bursts pile + // up in the notch. Backfill already treats idle AiWork entries as + // historical and skips them, so mirror that: drop an idle AiWork session + // shortly after its last activity. Live turns bump lastActivity on every + // stream event, so an in-use conversation is never swept mid-thought and + // re-appears the moment the next turn starts. + let aiworkIdleGrace: TimeInterval = 60 + for (key, session) in sessions + where key.hasPrefix(AppState.aiworkSessionPrefix) + && session.status == .idle { + guard -session.lastActivity.timeIntervalSinceNow > aiworkIdleGrace else { continue } + if let daemonId = session.providerSessionId { + aiworkHydratedSessionIds.remove(daemonId) + } + removeSession(key) + } + // 5. Reclaim memory for abandoned tool_use_id cache entries. prunePendingToolUses() @@ -1024,7 +1065,7 @@ final class AppState { return .expand } - private func enqueueCompletion(_ sessionId: String) { + func enqueueCompletion(_ sessionId: String) { switch Self.completionStyle() { case .off: // Panel stays compact — status indicators still update, but no @@ -1053,17 +1094,20 @@ final class AppState { /// on the first expansion. func refreshClaudeUsageIfStale() { guard UserDefaults.standard.bool(forKey: SettingsKey.showUsageStats) else { return } - guard !usageScanInFlight else { return } + guard !Self.usageScanInFlight else { return } if let scannedAt = claudeUsage?.scannedAt, Date().timeIntervalSince(scannedAt) < 120 { return } - usageScanInFlight = true + Self.usageScanInFlight = true let cacheCopy = usageFileCache Task.detached(priority: .utility) { var cache = cacheCopy let snapshot = ClaudeUsageScanner.scan(cache: &cache) + // Bound to a `let` before the hop: capturing the `var` in the + // concurrently-executing closure is an error under Swift 6. + let scannedCache = cache await MainActor.run { [weak self] in self?.claudeUsage = snapshot - self?.usageFileCache = cache - self?.usageScanInFlight = false + self?.usageFileCache = scannedCache + AppState.usageScanInFlight = false } } } diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 078b0c61..293141b9 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit import CodeIslandCore import Yams @@ -1025,6 +1026,11 @@ struct ConfigInstaller { /// Check if a specific CLI's hooks are installed static func isInstalled(source: String) -> Bool { + if source == "aiwork" || source == "aiwork-cli" { + // No hooks to install — "activated" means monitoring is enabled and + // the matching AiWork surface is present on this machine. + return isEnabled(source: source) && cliExists(source: source) + } if source == "opencode" { return isOpencodePluginInstalled(fm: FileManager.default) } if source == "pi" { return isPiExtensionInstalled(fm: FileManager.default) } if source == "omp" { return isOmpExtensionInstalled(fm: FileManager.default) } @@ -1043,6 +1049,8 @@ struct ConfigInstaller { /// Check if CLI directory exists (tool is installed on this machine) static func cliExists(source: String) -> Bool { + if source == "aiwork" { return aiworkGuiIsPresent() } + if source == "aiwork-cli" { return aiworkCliIsPresent() } if source == "opencode" { return FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/opencode") } if source == "pi" { return FileManager.default.fileExists(atPath: piAgentDir) } if source == "omp" { return FileManager.default.fileExists(atPath: ompAgentDir) } @@ -1088,6 +1096,51 @@ struct ConfigInstaller { return FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.dsh") } + /// AiWork GUI (formerly DTCoder). Bundle id is still `com.alipay.dtcoder.ide`. + static func aiworkGuiIsPresent(fileManager fm: FileManager = .default) -> Bool { + if NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.alipay.dtcoder.ide") != nil { + return true + } + let names = ["AiWork.app", "DTCoder.app"] + let roots = [NSHomeDirectory() + "/Applications", "/Applications"] + return names.contains { name in + roots.contains { root in + fm.fileExists(atPath: (root as NSString).appendingPathComponent(name)) + } + } + } + + /// AiWork CLI (`aiwork` / legacy `aiwork` on PATH, or Agentix state). + static func aiworkCliIsPresent(fileManager fm: FileManager = .default) -> Bool { + let pathDirs = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + let binaries = ["aiwork", "aiwork"] + for dir in pathDirs { + for binary in binaries where fm.isExecutableFile(atPath: (dir as NSString).appendingPathComponent(binary)) { + return true + } + } + // TUI brings up the Agentix daemon under ~/.agentix even without the GUI app. + return fm.fileExists(atPath: NSHomeDirectory() + "/.agentix/run") + } + + /// Either GUI or CLI/TUI is available. + static func aiworkIsPresent(fileManager fm: FileManager = .default) -> Bool { + aiworkGuiIsPresent(fileManager: fm) || aiworkCliIsPresent(fileManager: fm) + } + + /// Monitoring is on for GUI and/or CLI. + static func isAnyAiWorkMonitoringEnabled() -> Bool { + isEnabled(source: "aiwork") || isEnabled(source: "aiwork-cli") + } + + static var aiworkDisplayConfigPath: String { "~/.agentix/run/coder/agent.sock" } + static var aiworkCliDisplayConfigPath: String { "aiwork tui → agent.sock" } + static var aiworkFullConfigPath: String { + NSHomeDirectory() + "/.agentix/run/coder/agent.sock" + } + // Keep backward compat static func isCodexInstalled() -> Bool { isInstalled(source: "codex") } @@ -1106,6 +1159,10 @@ struct ConfigInstaller { if enabled { installHookScript(fm: fm) installBridgeBinary(fm: fm) + if source == "aiwork" || source == "aiwork-cli" { + // Daemon session watch — no hook files; AppState starts the watcher. + return true + } if source == "opencode" { return installOpencodePlugin(fm: fm) } @@ -1130,7 +1187,9 @@ struct ConfigInstaller { return isHooksInstalled(for: cli, fm: fm) } } else { - if source == "opencode" { + if source == "aiwork" || source == "aiwork-cli" { + return true + } else if source == "opencode" { uninstallOpencodePlugin(fm: fm) } else if source == "pi" { uninstallPiExtension(fm: fm) diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 749b3809..de4a4676 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -394,6 +394,8 @@ final class L10n: ObservableObject { "scroll_hidden": "more below", "lines": "lines", "reply_complete_placeholder": "Reply complete", + "reply_failed_placeholder": "Reply failed", + "reply_aborted_placeholder": "Reply interrupted", ] private static let de: [String: String] = en.merging([ @@ -743,6 +745,8 @@ final class L10n: ObservableObject { "scroll_hidden": "weitere unten", "lines": "Zeilen", "reply_complete_placeholder": "Antwort abgeschlossen", + "reply_failed_placeholder": "Antwort fehlgeschlagen", + "reply_aborted_placeholder": "Antwort abgebrochen", ]) { _, localized in localized } private static let zh: [String: String] = [ @@ -1096,6 +1100,8 @@ final class L10n: ObservableObject { "scroll_hidden": "个未显示", "lines": "行", "reply_complete_placeholder": "回复完成", + "reply_failed_placeholder": "回复失败", + "reply_aborted_placeholder": "回复已中断", ] private static let zhHant: [String: String] = [ @@ -1449,6 +1455,8 @@ final class L10n: ObservableObject { "scroll_hidden": "個未顯示", "lines": "行", "reply_complete_placeholder": "回覆完成", + "reply_failed_placeholder": "回覆失敗", + "reply_aborted_placeholder": "回覆已中斷", ] private static let ja: [String: String] = [ @@ -1802,6 +1810,8 @@ final class L10n: ObservableObject { "scroll_hidden": "下にさらにあります", "lines": "行", "reply_complete_placeholder": "返信完了", + "reply_failed_placeholder": "返信失敗", + "reply_aborted_placeholder": "返信中断", ] private static let ko: [String: String] = [ @@ -2155,6 +2165,8 @@ final class L10n: ObservableObject { "scroll_hidden": "아래에 더 있음", "lines": "줄", "reply_complete_placeholder": "답변 완료", + "reply_failed_placeholder": "답변 실패", + "reply_aborted_placeholder": "답변 중단", ] private static let tr: [String: String] = [ @@ -2508,5 +2520,7 @@ final class L10n: ObservableObject { "scroll_hidden": "aşağıda", "lines": "satır", "reply_complete_placeholder": "Yanıt tamamlandı", + "reply_failed_placeholder": "Yanıt başarısız", + "reply_aborted_placeholder": "Yanıt kesildi", ] } diff --git a/Sources/CodeIsland/MascotView.swift b/Sources/CodeIsland/MascotView.swift index 57b5df34..3fe0e67a 100644 --- a/Sources/CodeIsland/MascotView.swift +++ b/Sources/CodeIsland/MascotView.swift @@ -69,6 +69,8 @@ struct MascotView: View { PiView(status: status, size: size) case "cline": ClineView(status: status, size: size) + case "aiwork", "aiwork-cli": + AiWorkView(status: status, size: size) default: ClawdView(status: status, size: size) } diff --git a/Sources/CodeIsland/NotchAnimation.swift b/Sources/CodeIsland/NotchAnimation.swift index abde6fff..b09793ad 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/CodeIsland/NotchAnimation.swift @@ -41,21 +41,44 @@ extension AnyTransition { // MARK: - MorphText — blur morph on text change /// Text that briefly blurs when its content changes, creating a smooth "morph" effect. +/// +/// Opt in with `streamsRapidly` for sources that push token-level deltas (AiWork / +/// Agentix `stream.text_delta` into the `$` row): those updates swap text +/// immediately instead of morphing, because each change would otherwise restart the +/// blur cycle and leave the label permanently unreadable. Every other caller keeps +/// the original morph behaviour — this is deliberately not a global change. struct MorphText: View { let text: String var font: Font = .system(size: 12) var color: Color = .white var lineLimit: Int? = 1 + /// True only for sources in `SessionSnapshot.rapidStreamingSources`. + var streamsRapidly: Bool = false @State private var displayed: String @State private var blur: CGFloat = 0 @State private var generation = 0 + /// Monotonic (`systemUptime`), not wall-clock: an NTP correction or a + /// sleep/wake that steps the clock backward would make the elapsed test + /// negative and pin the label into no-morph mode. Same posture as + /// MascotAnimationGate's wake-drift handling (#225). + @State private var lastChangeAt: TimeInterval = -.greatestFiniteMagnitude - init(text: String, font: Font = .system(size: 12), color: Color = .white, lineLimit: Int? = 1) { + /// Updates arriving within this window are treated as a stream, not a morph. + private static let streamWindow: TimeInterval = 0.22 + + init( + text: String, + font: Font = .system(size: 12), + color: Color = .white, + lineLimit: Int? = 1, + streamsRapidly: Bool = false + ) { self.text = text self.font = font self.color = color self.lineLimit = lineLimit + self.streamsRapidly = streamsRapidly _displayed = State(initialValue: text) } @@ -69,8 +92,22 @@ struct MorphText: View { .compositingGroup() .onChange(of: text) { _, newText in guard newText != displayed else { return } + let now = ProcessInfo.processInfo.systemUptime + let streaming = streamsRapidly + && (now - lastChangeAt < Self.streamWindow || blur > 0.01) + lastChangeAt = now generation += 1 let gen = generation + + if streaming { + // Keep the label sharp while content is still arriving. + displayed = newText + if blur != 0 { + withAnimation(.easeOut(duration: 0.12)) { blur = 0 } + } + return + } + withAnimation(.easeOut(duration: 0.1)) { blur = 1 } Task { @MainActor in try? await Task.sleep(for: .milliseconds(60)) diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 9519ad57..e21c68cc 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -717,7 +717,10 @@ private struct CompactToolStatus: View { MorphText( text: shortDesc(desc), font: .system(size: 11, weight: .medium, design: .monospaced), - color: .white.opacity(0.7) + color: .white.opacity(0.7), + streamsRapidly: displaySession.map { + SessionSnapshot.rapidStreamingSources.contains($0.source) + } ?? false ) .truncationMode(.tail) } @@ -1822,6 +1825,8 @@ private struct SessionListView: View { ("kiro", "Kiro"), ("cline", "Cline"), ("zcode", "ZCode"), + ("aiwork", "AiWork"), + ("aiwork-cli", "AiWork CLI"), ] var result: [(String, String?, [String])] = [] var seen = Set() @@ -2558,7 +2563,9 @@ private struct SessionCard: View { MorphText( text: session.toolDescription ?? tool, font: .system(size: fontSize, design: .monospaced), - color: .white.opacity(0.75) + color: .white.opacity(0.75), + streamsRapidly: SessionSnapshot.rapidStreamingSources + .contains(session.source) ) .truncationMode(.tail) } else { @@ -2848,6 +2855,8 @@ private struct TerminalBadge: View { "stepfun": "com.stepfun.app", "codex": "com.openai.codex", "opencode": "ai.opencode.desktop", + "aiwork": "com.alipay.dtcoder.ide", + "aiwork-cli": "com.alipay.dtcoder.ide", ] private static var termIconCache: [String: NSImage] = [:] @@ -3050,6 +3059,11 @@ private let cliIconFiles: [String: String] = [ "opencode": "opencode", "cline": "cline", "dsh": "dsh", + // AiWork (formerly DTCoder). GUI and CLI share one asset, as qoder/qoder-cli + // and cursor/cursor-cli do; the NSWorkspace branch in cliIcon() prefers the + // installed app's own icon and falls back to this when AiWork is absent. + "aiwork": "aiwork", + "aiwork-cli": "aiwork", // Rendered from the in-house pixel mascots via // MascotRenderHarness/testRenderCliIcons (MASCOT_ICON_DIR=…). "kiro": "kiro", @@ -3061,6 +3075,17 @@ private var cliIconCache: [String: NSImage] = [:] func cliIcon(source: String, size: CGFloat = 16) -> NSImage? { let key = "\(source)_\(Int(size))" if let cached = cliIconCache[key] { return cached } + + // AiWork (formerly DTCoder): prefer the installed app's own icon, falling + // back to the bundled aiwork.png below when AiWork is not installed. + if source == "aiwork" || source == "aiwork-cli", + let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.alipay.dtcoder.ide") { + let icon = NSWorkspace.shared.icon(forFile: appURL.path) + icon.size = NSSize(width: size, height: size) + cliIconCache[key] = icon + return icon + } + guard let filename = cliIconFiles[source], let url = Bundle.appModule.url(forResource: filename, withExtension: "png", subdirectory: "Resources/cli-icons"), let image = NSImage(contentsOf: url) diff --git a/Sources/CodeIsland/Resources/cli-icons/aiwork.png b/Sources/CodeIsland/Resources/cli-icons/aiwork.png new file mode 100644 index 00000000..f67e161a Binary files /dev/null and b/Sources/CodeIsland/Resources/cli-icons/aiwork.png differ diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 11c14459..406f4d32 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -95,7 +95,7 @@ struct SettingsView: View { case .sound: SoundPage() case .shortcuts: ShortcutsPage() case .remote: RemoteHostsPage() - case .hooks: HooksPage() + case .hooks: HooksPage(appState: appState) case .buddy: BuddyPage() case .about: AboutPage() } @@ -611,6 +611,7 @@ private struct BehaviorPage: View { // MARK: - Hooks Page private struct HooksPage: View { + var appState: AppState? @ObservedObject private var l10n = L10n.shared @State private var cliStatuses: [String: Bool] = [:] @State private var statusMessage = "" @@ -627,6 +628,24 @@ private struct HooksPage: View { cliStatuses[cli.source] = ConfigInstaller.isInstalled(source: cli.source) } cliStatuses["opencode"] = ConfigInstaller.isInstalled(source: "opencode") + cliStatuses["aiwork"] = ConfigInstaller.isInstalled(source: "aiwork") + cliStatuses["aiwork-cli"] = ConfigInstaller.isInstalled(source: "aiwork-cli") + } + + private func applySourceToggle(source: String, enabled: Bool) { + if source == "aiwork" || source == "aiwork-cli" { + if enabled { + appState?.startAiWorkWatcher() + } else { + appState?.removeAiWorkSessions(source: source) + if !ConfigInstaller.isAnyAiWorkMonitoringEnabled() { + appState?.stopAiWorkWatcher() + } else { + appState?.pruneDisabledAiWorkSessions() + } + } + } + refreshCLIStatuses() } private func statusText(installed: Bool, exists: Bool) -> String { @@ -646,7 +665,7 @@ private struct HooksPage: View { fullPath: cli.fullPath, installed: installed, exists: exists - ) { _ in refreshCLIStatuses() } + ) { enabled in applySourceToggle(source: cli.source, enabled: enabled) } .id("\(cli.source)-\(refreshKey)") } // OpenCode (plugin-based, not hooks) @@ -659,8 +678,33 @@ private struct HooksPage: View { fullPath: NSHomeDirectory() + "/.config/opencode/config.json", installed: ocInstalled, exists: ocExists - ) { _ in refreshCLIStatuses() } + ) { enabled in applySourceToggle(source: "opencode", enabled: enabled) } .id("opencode-\(refreshKey)") + + // AiWork GUI + CLI/TUI (Agentix daemon watch — no hooks) + let dtInstalled = cliStatuses["aiwork"] ?? false + let dtExists = ConfigInstaller.cliExists(source: "aiwork") + CLIStatusRow( + name: "AiWork", + source: "aiwork", + configPath: ConfigInstaller.aiworkDisplayConfigPath, + fullPath: ConfigInstaller.aiworkFullConfigPath, + installed: dtInstalled, + exists: dtExists + ) { enabled in applySourceToggle(source: "aiwork", enabled: enabled) } + .id("aiwork-\(refreshKey)") + + let dtCliInstalled = cliStatuses["aiwork-cli"] ?? false + let dtCliExists = ConfigInstaller.cliExists(source: "aiwork-cli") + CLIStatusRow( + name: "AiWork CLI", + source: "aiwork-cli", + configPath: ConfigInstaller.aiworkCliDisplayConfigPath, + fullPath: ConfigInstaller.aiworkFullConfigPath, + installed: dtCliInstalled, + exists: dtCliExists + ) { enabled in applySourceToggle(source: "aiwork-cli", enabled: enabled) } + .id("aiwork-cli-\(refreshKey)") } Section("Custom CLIs") { @@ -738,7 +782,19 @@ private struct HooksPage: View { if ConfigInstaller.cliExists(source: "opencode") { UserDefaults.standard.set(true, forKey: "cli_enabled_opencode") } + if ConfigInstaller.cliExists(source: "aiwork") { + UserDefaults.standard.set(true, forKey: "cli_enabled_aiwork") + } + if ConfigInstaller.cliExists(source: "aiwork-cli") { + UserDefaults.standard.set(true, forKey: "cli_enabled_aiwork-cli") + } if ConfigInstaller.install() { + // Started only after install succeeds — otherwise a failed + // install left the watcher running while the button + // reported failure. + if ConfigInstaller.isAnyAiWorkMonitoringEnabled() { + appState?.startAiWorkWatcher() + } refreshCLIStatuses() refreshKey += 1 statusMessage = l10n["hooks_installed"] @@ -759,6 +815,9 @@ private struct HooksPage: View { UserDefaults.standard.set(false, forKey: "cli_enabled_\(cli.source)") } UserDefaults.standard.set(false, forKey: "cli_enabled_opencode") + UserDefaults.standard.set(false, forKey: "cli_enabled_aiwork") + UserDefaults.standard.set(false, forKey: "cli_enabled_aiwork-cli") + appState?.stopAiWorkWatcher() ConfigInstaller.uninstall() refreshCLIStatuses() refreshKey += 1 @@ -1095,6 +1154,8 @@ private struct MascotsPage: View { ("OpBot", "opencode", "OpenCode", Color(red: 0.55, green: 0.55, blue: 0.57)), ("ClineBot", "cline", "Cline", Color(red: 0.00, green: 0.70, blue: 0.49)), ("Gemini", "google-antigravity", "Google Antigravity", Color(red: 0.278, green: 0.588, blue: 0.894)), + ("AiWorkBot", "aiwork", "AiWork", Color(red: 0.12, green: 0.72, blue: 0.28)), + ("AiWorkBot", "aiwork-cli", "AiWork CLI", Color(red: 0.12, green: 0.55, blue: 0.90)), ] var body: some View { diff --git a/Sources/CodeIsland/TerminalActivator.swift b/Sources/CodeIsland/TerminalActivator.swift index 8896c517..c195c94c 100644 --- a/Sources/CodeIsland/TerminalActivator.swift +++ b/Sources/CodeIsland/TerminalActivator.swift @@ -60,6 +60,10 @@ struct TerminalActivator { "stepfun": "com.stepfun.app", "opencode": "ai.opencode.desktop", "workbuddy": "com.workbuddy.workbuddy", + // AiWork GUI only. `aiwork-cli` is deliberately absent: a TUI session runs + // in a terminal, so it must fall through to terminal tab-matching instead + // of raising — or launching — the IDE. + "aiwork": "com.alipay.dtcoder.ide", ] /// Bundle IDs of apps that have both APP and CLI modes. @@ -76,6 +80,7 @@ struct TerminalActivator { "com.stepfun.app": "StepFun", "ai.opencode.desktop": "OpenCode", "com.workbuddy.workbuddy": "WorkBuddy", + "com.alipay.dtcoder.ide": "AiWork", // Claude Code Desktop (#211). Deliberately NOT in sourceToNativeAppBundleId: // most "claude" sessions are terminal CLI runs, and that fallback would // steal their click-to-jump whenever the desktop app happens to be open. diff --git a/Sources/CodeIslandCore/AiWorkWatchClient.swift b/Sources/CodeIslandCore/AiWorkWatchClient.swift new file mode 100644 index 00000000..2298da8f --- /dev/null +++ b/Sources/CodeIslandCore/AiWorkWatchClient.swift @@ -0,0 +1,796 @@ +import Foundation + +/// A parsed Agentix daemon V2 frame (`ResponseEnvelopeV2` or `StreamEventV2`). +/// +/// Unlike Codex app-server (classic JSON-RPC notifications), the Agentix daemon +/// speaks a custom NDJSON envelope with a top-level `kind` of `"response"` or +/// `"event"`. See agentix `crates/common/src/rpc_protocol.rs`. +public struct AiWorkFrame: Equatable { + public enum Kind: Equatable { + case response(operation: String, ok: Bool) + case event(name: String, phase: String?) + case unknown + } + + public let raw: [String: AnyCodableLike] + public let kind: Kind + + public init(raw: [String: AnyCodableLike], kind: Kind) { + self.raw = raw + self.kind = kind + } + + public var dataObject: [String: AnyCodableLike]? { + raw["data"]?.asObject + } + + public var metaObject: [String: AnyCodableLike]? { + raw["meta"]?.asObject + } + + /// Prefer `meta.session_id`, then `data.session.session_id`, then `data.session_id`. + public var sessionId: String? { + if let sid = metaObject?["session_id"]?.asString, !sid.isEmpty { return sid } + if let sid = dataObject?["session"]?.asObject?["session_id"]?.asString, !sid.isEmpty { + return sid + } + if let sid = dataObject?["session_id"]?.asString, !sid.isEmpty { return sid } + return nil + } + + public var eventName: String? { + if case .event(let name, _) = kind { return name } + return nil + } +} + +public enum AiWorkWatchError: Error, Equatable { + case socketMissing(String) + case notConnected + case connectFailed(String) + case writeFailed(String) +} + +/// Connects to an Agentix daemon Unix socket and speaks newline-delimited V2 +/// frames. Used by CodeIsland to subscribe to `sessions.watch` for AiWork +/// GUI / TUI task status (read-only). +/// +/// Thread safety: `start`, `stop`, and `sendRequest` may be called from any +/// thread; I/O is serialized on an internal queue. Handler closures run on the +/// caller-supplied callback queue (default: main). +public final class AiWorkWatchClient: @unchecked Sendable { + public typealias FrameHandler = @Sendable (AiWorkFrame) -> Void + public typealias ExitHandler = @Sendable () -> Void + + public let socketPath: String + private let callbackQueue: DispatchQueue + private let ioQueue: DispatchQueue + + private let lock = NSLock() + private var socketFD: Int32 = -1 + private var readSource: DispatchSourceRead? + private var readBuffer: Data = Data() + private var nextRequestId: Int64 = 1 + private var isStopped: Bool = true + private var didSignalExit: Bool = false + + public var onFrame: FrameHandler? + public var onExit: ExitHandler? + + public init( + socketPath: String, + callbackQueue: DispatchQueue = .main + ) { + self.socketPath = socketPath + self.callbackQueue = callbackQueue + self.ioQueue = DispatchQueue(label: "com.codeisland.aiwork-watch-io") + } + + public var isConnected: Bool { + lock.lock(); defer { lock.unlock() } + return socketFD >= 0 && !isStopped + } + + // MARK: - Lifecycle + + public func start() throws { + lock.lock() + if socketFD >= 0, !isStopped { + lock.unlock() + return + } + guard FileManager.default.fileExists(atPath: socketPath) else { + lock.unlock() + throw AiWorkWatchError.socketMissing(socketPath) + } + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + lock.unlock() + throw AiWorkWatchError.connectFailed("socket() failed: \(errno)") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + if pathBytes.count > MemoryLayout.size(ofValue: addr.sun_path) { + close(fd) + lock.unlock() + throw AiWorkWatchError.connectFailed("socket path too long") + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + for (i, b) in pathBytes.enumerated() { + dest[i] = b + } + } + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + let err = errno + close(fd) + lock.unlock() + throw AiWorkWatchError.connectFailed("connect(\(socketPath)) failed: \(err)") + } + + // Non-blocking reads driven by DispatchSource. + let flags = fcntl(fd, F_GETFL) + _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) + + self.socketFD = fd + self.readBuffer.removeAll(keepingCapacity: true) + self.isStopped = false + self.didSignalExit = false + + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: ioQueue) + source.setEventHandler { [weak self] in + self?.readAvailable() + } + source.setCancelHandler { [weak self] in + // Own the captured `fd` rather than re-reading socketFD: stop() clears + // socketFD *before* cancelling, so re-reading it here always saw -1 and + // the descriptor was never closed. unaryCall() calls stop() on every RPC, + // so that leaked one fd per call until RLIMIT_NOFILE. + if let self { + self.lock.lock() + if self.socketFD == fd { self.socketFD = -1 } + self.lock.unlock() + } + close(fd) + } + self.readSource = source + lock.unlock() + source.resume() + } + + public func stop() { + lock.lock() + isStopped = true + let source = readSource + readSource = nil + let fd = socketFD + socketFD = -1 + lock.unlock() + + source?.cancel() + // If the source was never created / already cancelled, close directly. + if source == nil, fd >= 0 { + close(fd) + } + } + + // MARK: - Send + + @discardableResult + public func sendRequest(method: String, params: Any? = nil) throws -> Int64 { + let id: Int64 + lock.lock() + id = nextRequestId + nextRequestId &+= 1 + lock.unlock() + + var body: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "method": method, + ] + if let params { + body["params"] = params + } else { + body["params"] = [String: Any]() + } + try writeEnvelope(body) + return id + } + + /// Subscribe to the daemon's global session event bus. + @discardableResult + public func watchSessions(watchAckId: String? = nil) throws -> Int64 { + var params: [String: Any] = [:] + if let watchAckId { params["watchAckId"] = watchAckId } + return try sendRequest(method: "sessions.watch", params: params) + } + + private func writeEnvelope(_ body: [String: Any]) throws { + let data = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) + var payload = data + payload.append(0x0A) + + lock.lock() + guard !isStopped, socketFD >= 0 else { + lock.unlock() + throw AiWorkWatchError.notConnected + } + let fd = socketFD + lock.unlock() + + var written = 0 + let bytes = [UInt8](payload) + while written < bytes.count { + let n = bytes.withUnsafeBytes { raw -> Int in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return Darwin.send(fd, base.advanced(by: written), bytes.count - written, 0) + } + if n < 0 { + if errno == EINTR { continue } + throw AiWorkWatchError.writeFailed("send failed: \(errno)") + } + if n == 0 { + throw AiWorkWatchError.writeFailed("send returned 0") + } + written += n + } + } + + // MARK: - Receive + + private func readAvailable() { + lock.lock() + let fd = socketFD + let stopped = isStopped + lock.unlock() + guard !stopped, fd >= 0 else { return } + + var tmp = [UInt8](repeating: 0, count: 65_536) + while true { + let capacity = tmp.count + let n = tmp.withUnsafeMutableBytes { raw -> Int in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return Darwin.recv(fd, base, capacity, 0) + } + if n > 0 { + ingest(data: Data(tmp[0.. Self.maxBufferedBytes { + readBuffer.removeAll(keepingCapacity: false) + handleDisconnect() + return + } + let parsed = AiWorkWatchClient.drainFrames(buffer: &readBuffer) + guard !parsed.isEmpty else { return } + for frame in parsed { + let handler = self.onFrame + callbackQueue.async { handler?(frame) } + } + } + + private func handleDisconnect() { + lock.lock() + if isStopped || didSignalExit { + lock.unlock() + return + } + didSignalExit = true + isStopped = true + // Hand the fd to the cancel handler and clear it here too, so a stop() + // racing this path sees -1 and cannot close the same descriptor twice. + socketFD = -1 + let source = readSource + readSource = nil + let handler = onExit + lock.unlock() + + source?.cancel() + callbackQueue.async { handler?() } + } + + // MARK: - Pure parser (exposed for tests) + + public static func drainFrames(buffer: inout Data) -> [AiWorkFrame] { + var results: [AiWorkFrame] = [] + let newline: UInt8 = 0x0A + var searchStart = buffer.startIndex + + while searchStart < buffer.endIndex { + guard let newlineIndex = buffer[searchStart..= buffer.endIndex { + buffer.removeAll(keepingCapacity: true) + } else { + buffer = Data(buffer[searchStart.. AiWorkFrame? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let raw = AnyCodableLike.from(obj).asObject ?? [:] + let kindLabel = (obj["kind"] as? String) ?? "" + + switch kindLabel { + case "response": + let operation = (obj["operation"] as? String) ?? "" + let ok = (obj["ok"] as? Bool) ?? false + return AiWorkFrame(raw: raw, kind: .response(operation: operation, ok: ok)) + case "event": + let eventObj = obj["event"] as? [String: Any] + let name = (eventObj?["name"] as? String) ?? "" + let phase = eventObj?["phase"] as? String + return AiWorkFrame(raw: raw, kind: .event(name: name, phase: phase)) + default: + if obj["error"] != nil || obj["result"] != nil || obj["method"] != nil { + return AiWorkFrame(raw: raw, kind: .unknown) + } + return nil + } + } + + // MARK: - Path helpers + + /// Owning agent of a daemon session id shaped `channel:agent:uuid` + /// (e.g. `acp:coder:` -> `coder`). nil when the id carries no agent. + public static func agentId(fromDaemonSessionId id: String) -> String? { + let parts = id.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count >= 3, !parts[1].isEmpty else { return nil } + return String(parts[1]) + } + + /// Default Agentix state directory (`$AGENTIX_STATE_DIR` or `~/.agentix`). + public static func defaultStateDir( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> String { + if let env = environment["AGENTIX_STATE_DIR"], !env.isEmpty { + return env + } + return homeDirectory + "/.agentix" + } + + /// Discover running daemon sockets under `{stateDir}/run/*/agent.sock` + /// that also have an `agent.ready` marker. + public static func discoverReadyDaemons( + stateDir: String? = nil, + fileManager: FileManager = .default + ) -> [(agentId: String, socketPath: String)] { + let root = stateDir ?? defaultStateDir() + let runDir = root + "/run" + guard let entries = try? fileManager.contentsOfDirectory(atPath: runDir) else { + return [] + } + var results: [(agentId: String, socketPath: String)] = [] + for name in entries.sorted() { + if name == "runtime" { continue } + let agentDir = runDir + "/" + name + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: agentDir, isDirectory: &isDir), isDir.boolValue else { + continue + } + let sock = agentDir + "/agent.sock" + let ready = agentDir + "/agent.ready" + guard fileManager.fileExists(atPath: sock), + fileManager.fileExists(atPath: ready) else { + continue + } + results.append((agentId: name, socketPath: sock)) + } + return results + } + + /// Perform a one-shot unary RPC on a fresh connection and return the + /// response frame (or nil on failure / timeout). Used for backfill via + /// `sessions.list` / `agent.stats` without disturbing the watch stream. + public static func unaryCall( + socketPath: String, + method: String, + params: Any? = nil, + timeoutSeconds: TimeInterval = 5.0 + ) -> AiWorkFrame? { + final class Box: @unchecked Sendable { + let lock = NSLock() + var response: AiWorkFrame? + var finished = false + let group = DispatchGroup() + + func finish(_ frame: AiWorkFrame?) { + lock.lock() + defer { lock.unlock() } + guard !finished else { return } + finished = true + response = frame + group.leave() + } + } + + let box = Box() + box.group.enter() + + let client = AiWorkWatchClient( + socketPath: socketPath, + callbackQueue: DispatchQueue(label: "com.codeisland.aiwork-unary") + ) + client.onFrame = { frame in + // Match the response to the request we sent. The daemon echoes the + // method name in `operation` (see ResponseEnvelopeV2), and this + // connection carries exactly one request, so a mismatch means the + // frame is not ours. Error detail on ok:false stays reachable through + // `frame.raw`. + if case .response(let operation, _) = frame.kind, operation == method { + box.finish(frame) + } + } + client.onExit = { + box.finish(nil) + } + + do { + try client.start() + try client.sendRequest(method: method, params: params ?? [String: Any]()) + } catch { + client.stop() + return nil + } + + let wait = box.group.wait(timeout: .now() + timeoutSeconds) + client.stop() + if wait == .timedOut { return nil } + box.lock.lock(); defer { box.lock.unlock() } + return box.response + } +} + +// MARK: - Status mapping helpers (pure) + +public enum AiWorkStatusMapper { + /// Map a daemon stream event name onto CodeIsland's AgentStatus. + /// Returns nil when the event should not change status (e.g. diagnostics). + public static func status(forEventName name: String) -> AgentStatus? { + switch name { + case "stream.approval_required", + "stream.plan_confirmation_required": + return .waitingApproval + case "stream.question_required": + return .waitingQuestion + case "stream.tool_call": + return .running + case "stream.tool_result", + "stream.approval_resolved", + "stream.plan_confirmation_resolved", + "stream.question_resolved": + return .processing + case "stream.started", + "stream.progress", + "stream.thinking_delta", + "stream.text_delta", + "stream.ack", + "stream.fallback_started": + return .processing + case "stream.completed", + "stream.failed", + "stream.aborted": + return .idle + default: + return nil + } + } + + /// Whether a terminal event should enqueue a completion card. + public static func isTerminalEvent(_ name: String) -> Bool { + switch name { + case "stream.completed", "stream.failed", "stream.aborted": + return true + default: + return false + } + } + + /// Extract a short tool label from a `stream.tool_call` data payload. + public static func toolName(from data: [String: AnyCodableLike]?) -> String? { + guard let data else { return nil } + if let display = toolDisplayObject(from: data) { + if let title = display["title"]?.asString, !title.isEmpty { + // Prefer the short tool name when title is a long "name: detail". + if let name = display["tool_name"]?.asString ?? data["name"]?.asString, + !name.isEmpty { + return name + } + return title + } + if let name = display["tool_name"]?.asString, !name.isEmpty { return name } + } + if let name = data["name"]?.asString, !name.isEmpty { return name } + if let title = data["title"]?.asString, !title.isEmpty { return title } + if let tool = data["tool"]?.asString, !tool.isEmpty { return tool } + if let toolName = data["tool_name"]?.asString, !toolName.isEmpty { return toolName } + return nil + } + + /// Human-readable one-line detail for the notch process row. + public static func toolDescription(from data: [String: AnyCodableLike]?) -> String? { + guard let data else { return nil } + if let display = toolDisplayObject(from: data) { + if let title = display["title"]?.asString, !title.isEmpty { + return truncate(title, max: 160) + } + if case .object(let fields) = display["primary_fields"] { + let parts = fields.compactMap { key, value -> String? in + guard let text = value.asString, !text.isEmpty else { return nil } + return "\(key)=\(text)" + } + if !parts.isEmpty { + return truncate(parts.sorted().joined(separator: " "), max: 160) + } + } + } + if let command = data["input"]?.asObject?["command"]?.asString, !command.isEmpty { + return truncate(command, max: 160) + } + if let query = data["input"]?.asObject?["query"]?.asString, !query.isEmpty { + return truncate(query, max: 160) + } + if let path = data["input"]?.asObject?["path"]?.asString, !path.isEmpty { + return truncate(path, max: 160) + } + return toolName(from: data).map { truncate($0, max: 80) } + } + + public static func progressText(from data: [String: AnyCodableLike]?) -> String? { + guard let data else { return nil } + if let text = data["text"]?.asString, !text.isEmpty { + return truncate(text, max: 160) + } + if let signal = data["readable_summary"]?.asObject?["signal"]?.asString, !signal.isEmpty { + return truncate(signal, max: 160) + } + return nil + } + + public static func progressLabel(from data: [String: AnyCodableLike]?, eventName: String) -> String { + if let phase = data?["phase"]?.asString, !phase.isEmpty { + return compactPhaseLabel(phase) + } + switch eventName { + case "stream.thinking_delta": return "thinking" + case "stream.text_delta": return "reply" + case "stream.progress": return "progress" + case "stream.fallback_started": return "fallback" + case "stream.ack": return "ack" + default: return "working" + } + } + + public static func compactPhaseLabel(_ phase: String) -> String { + switch phase { + case "context_build": return "context" + case "llm_request": return "llm" + case "llm_streaming": return "llm" + case "tool_execution": return "tool" + case "post_processing": return "post" + case "provider_waiting": return "waiting" + case "provider_retrying": return "retry" + default: + return truncate(phase.replacingOccurrences(of: "_", with: " "), max: 24) + } + } + + public static func truncate(_ text: String, max: Int) -> String { + guard text.count > max else { return text } + return String(text.prefix(max - 1)) + "…" + } + + private static func toolDisplayObject(from data: [String: AnyCodableLike]) -> [String: AnyCodableLike]? { + if let direct = data["agentix.tool_display.v1"]?.asObject { return direct } + if let meta = data["_meta"]?.asObject { + if let v = meta["agentix.tool_display.v1"]?.asObject { return v } + if let agentix = meta["agentix"]?.asObject, + let v = agentix["tool_display"]?.asObject ?? agentix["tool_display.v1"]?.asObject { + return v + } + } + return nil + } + + /// Display label aligned with Hooks settings: `AiWork` / `AiWork CLI`. + /// + /// Prefer `client_type` from `sessions.get`. The daemon still emits + /// `DTCoderGUI` / `DTCoderTUI`; accept `AiWork*` as well after the rename. + /// Modern `aiwork`/`aiwork tui` attaches via ACP, so both GUI and TUI + /// session ids often share the `acp:` prefix — prefix alone is not enough. + public static func sourceLabel( + forDaemonSessionId sessionId: String?, + clientType: String? = nil + ) -> String { + if let label = label(forClientType: clientType) { + return label + } + guard let sessionId else { return "AiWork" } + if sessionId.hasPrefix("cli:") { return "AiWork CLI" } + if sessionId.hasPrefix("acp:") { return "AiWork" } + if sessionId.hasPrefix("gateway:") { return "AiWork" } + return "AiWork" + } + + public static func label(forClientType clientType: String?) -> String? { + guard let raw = clientType?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { return nil } + switch raw { + case "DTCoderGUI", "DTCoderGui", "AiWorkGUI", "AiWorkGui": + return "AiWork" + case "DTCoderTUI", "DTCoderTui", "DTCoderCLI", "DTCoderCli", + "AiWorkTUI", "AiWorkTui", "AiWorkCLI", "AiWorkCli": + return "AiWork CLI" + default: + break + } + let lower = raw.lowercased() + if lower.contains("tui") || lower.contains("cli") { return "AiWork CLI" } + if lower.contains("gui") { return "AiWork" } + return nil + } + + /// CodeIsland `source` key: GUI → `aiwork`, TUI/CLI → `aiwork-cli`. + /// GUI and CLI/TUI get separate keys so each surface toggles independently. + public static func codeIslandSource( + clientType: String?, + daemonSessionId: String? = nil + ) -> String { + if let label = label(forClientType: clientType) { + return label == "AiWork CLI" ? "aiwork-cli" : "aiwork" + } + // Legacy session-id prefixes when client_type is not hydrated yet. + if let daemonSessionId, daemonSessionId.hasPrefix("cli:") { + return "aiwork-cli" + } + return "aiwork" + } + + /// Whether a `sessions.list` entry looks actively busy (for backfill). + public static func isActiveListEntry(_ entry: [String: AnyCodableLike]) -> Bool { + if let status = entry["status"]?.asString { + switch status { + case "waiting_approval", + "waiting_plan_confirmation", + "running": + return true + default: + break + } + } + if let phase = entry["readable_summary"]?.asObject?["phase"]?.asString { + switch phase { + case "idle", "none", "": + break + default: + return true + } + } + if entry["external_turn"]?.asObject != nil { + return true + } + return false + } + + public static func agentStatus(fromListEntry entry: [String: AnyCodableLike]) -> AgentStatus { + if let status = entry["status"]?.asString { + switch status { + case "waiting_approval", "waiting_plan_confirmation": + return .waitingApproval + case "running": + return .running + default: + break + } + } + if let waitingFor = entry["external_turn"]?.asObject?["waiting_for"]?.asString, + waitingFor == "question" { + return .waitingQuestion + } + if let phase = entry["readable_summary"]?.asObject?["phase"]?.asString { + switch phase { + case "waiting_answer": + return .waitingQuestion + case "waiting_approval", "waiting_plan_confirmation": + return .waitingApproval + case "tool_execution": + return .running + case "idle": + return .idle + default: + return .processing + } + } + if entry["external_turn"]?.asObject != nil { + return .processing + } + return .processing + } + + /// Whether a locally non-idle AiWork session should be forced idle because + /// the daemon no longer reports it in `active_request_details`. + public static func shouldForceIdleSession( + daemonSessionId: String, + lastActivity: Date, + busyDaemonIds: Set, + now: Date = Date(), + grace: TimeInterval = 8 + ) -> Bool { + if busyDaemonIds.contains(daemonSessionId) { return false } + // Grace avoids racing a turn that just started before stats refresh. + return now.timeIntervalSince(lastActivity) > grace + } + + /// Session ids currently executing a turn, from `agent.stats`. + /// Returns `nil` when the RPC fails so callers do not treat "unreachable" + /// as "nothing is busy" and mass-idle every session. + public static func fetchBusyDaemonSessionIds(socketPath: String) -> Set? { + guard let stats = AiWorkWatchClient.unaryCall( + socketPath: socketPath, + method: "agent.stats", + params: [String: Any](), + timeoutSeconds: 3 + ), case .response(_, true) = stats.kind else { + return nil + } + + var busy = Set() + if case .array(let rows) = stats.dataObject?["active_request_details"] { + for row in rows { + guard let obj = row.asObject else { continue } + if let sid = obj["session_id"]?.asString ?? obj["sessionId"]?.asString, + !sid.isEmpty { + busy.insert(sid) + } + } + } + return busy + } +} diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/CodeIslandCore/Models.swift index 92a6862f..a1cefcc8 100644 --- a/Sources/CodeIslandCore/Models.swift +++ b/Sources/CodeIslandCore/Models.swift @@ -159,7 +159,7 @@ public enum CLIProcessResolver { } } -public enum AgentStatus: Sendable { +public enum AgentStatus: Sendable, Equatable { case idle case processing case running diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 94d498e6..cb003ad3 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -40,6 +40,18 @@ public struct SessionSnapshot: Sendable { "cline", "dsh", "zcode", + "aiwork", + "aiwork-cli", + ] + + /// Sources whose tool/description text arrives as a rapid delta stream + /// (AiWork/Agentix `stream.text_delta`). MorphText swaps text immediately for + /// these instead of restarting its blur morph on every update, which would + /// otherwise leave the label permanently unreadable. Scoped deliberately: + /// every other source keeps the original morph behaviour. + public static let rapidStreamingSources: Set = [ + "aiwork", + "aiwork-cli", ] public static let ideCompletionSources: Set = [ @@ -152,6 +164,9 @@ public struct SessionSnapshot: Sendable { public var sessionTitle: String? public var sessionTitleSource: SessionTitleSource? public var providerSessionId: String? + /// AiWork/Agentix `client_type` from `sessions.get` (e.g. `DTCoderGUI`, `AiWorkGUI`). + /// Prefer this over the `acp:`/`cli:` session-id prefix — modern TUI also uses `acp:`. + public var aiworkClientType: String? public var remoteHostId: String? public var remoteHostName: String? /// nil = unchecked, false = not YOLO, true = YOLO @@ -272,6 +287,12 @@ public struct SessionSnapshot: Sendable { "qoderclicn": "qoder-cli", "qodercli-cn": "qoder-cli", "qoder-cn": "qoder-cli", + // AiWork (formerly DTCoder) — the pre-rename spelling and the + // no-separator CLI variants normalise onto the `aiwork*` keys. + "dtcoder": "aiwork", + "dtcoder-cli": "aiwork-cli", + "dtcodercli": "aiwork-cli", + "aiworkcli": "aiwork-cli", "qodercn": "qoder-cli", // QoderWork — Qoder's standalone desktop assistant app (not the // IDE); own hooks file at ~/.qoderwork/settings.json (#249). @@ -631,6 +652,8 @@ public struct SessionSnapshot: Sendable { case "kiro": return "Kiro" case "cline": return "Cline" case "zcode": return "ZCode" + case "aiwork": return "AiWork" + case "aiwork-cli": return "AiWork CLI" default: if let customName = Self.loadCustomSourceNames()[source] { return customName @@ -691,6 +714,7 @@ public struct SessionSnapshot: Sendable { // Claude Code Desktop (#211): local Code-tab sessions run the same engine // and fire the same ~/.claude/settings.json hooks as the CLI. "com.anthropic.claudefordesktop": "Claude", + "com.alipay.dtcoder.ide": "AiWork", ] /// Maps native app bundle IDs to their expected source identifier. @@ -711,6 +735,7 @@ public struct SessionSnapshot: Sendable { // Claude Code Desktop (#211) — hook subprocesses inherit the app's // __CFBundleIdentifier, so desktop Code sessions arrive tagged with it. "com.anthropic.claudefordesktop": "claude", + "com.alipay.dtcoder.ide": "aiwork", ] /// Source id for the native app that owns `bundleId`, if any. @@ -785,6 +810,9 @@ public struct SessionSnapshot: Sendable { if isRemote { return remoteDisplayName ?? "Remote" } + // AiWork (formerly DTCoder): badge names match Hooks settings. + if source == "aiwork" { return "AiWork" } + if source == "aiwork-cli" { return "AiWork CLI" } // If termBundleId is a known app, show app name (APP mode) if let bid = termBundleId, let name = Self.appBundleNames[bid] { return name diff --git a/Tests/CodeIslandCoreTests/AiWorkWatchClientTests.swift b/Tests/CodeIslandCoreTests/AiWorkWatchClientTests.swift new file mode 100644 index 00000000..2c224772 --- /dev/null +++ b/Tests/CodeIslandCoreTests/AiWorkWatchClientTests.swift @@ -0,0 +1,258 @@ +import XCTest +@testable import CodeIslandCore + +final class AiWorkWatchClientTests: XCTestCase { + + // MARK: - Frame parsing + + func testParseResponseFrame() { + let json = #"{"kind":"response","ok":true,"category":"session","operation":"sessions.list","data":{"sessions":[]},"meta":{"schema_version":"2.0"}}"# + let frame = AiWorkWatchClient.parseFrame(Data(json.utf8)) + XCTAssertEqual(frame?.kind, .response(operation: "sessions.list", ok: true)) + XCTAssertNotNil(frame?.dataObject) + } + + func testParseEventFrameExtractsSessionIdFromMeta() { + let json = #"{"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.tool_call","phase":"start"},"data":{"title":"exec"},"meta":{"session_id":"acp:coder:abc","schema_version":"2.0"}}"# + let frame = AiWorkWatchClient.parseFrame(Data(json.utf8)) + XCTAssertEqual(frame?.kind, .event(name: "stream.tool_call", phase: "start")) + XCTAssertEqual(frame?.sessionId, "acp:coder:abc") + XCTAssertEqual(AiWorkStatusMapper.toolName(from: frame?.dataObject), "exec") + } + + func testParseEventFrameFallsBackToDataSession() { + let json = #"{"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.started","phase":"start"},"data":{"session":{"session_id":"cli:coder:xyz"}},"meta":{}}"# + let frame = AiWorkWatchClient.parseFrame(Data(json.utf8)) + XCTAssertEqual(frame?.sessionId, "cli:coder:xyz") + } + + func testDrainFramesKeepsPartialTrailingLine() { + var buffer = Data() + buffer.append(Data(#"{"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.started","phase":"start"},"data":{},"meta":{}}"#.utf8)) + buffer.append(0x0A) + buffer.append(Data(#"{"kind":"event","event":{"name":"stream.partial"#.utf8)) + + let frames = AiWorkWatchClient.drainFrames(buffer: &buffer) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames[0].eventName, "stream.started") + XCTAssertEqual(String(data: buffer, encoding: .utf8), #"{"kind":"event","event":{"name":"stream.partial"#) + } + + // MARK: - Status mapping + + func testStatusMappingPriorityEvents() { + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.approval_required"), .waitingApproval) + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.plan_confirmation_required"), .waitingApproval) + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.question_required"), .waitingQuestion) + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.tool_call"), .running) + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.thinking_delta"), .processing) + XCTAssertEqual(AiWorkStatusMapper.status(forEventName: "stream.completed"), .idle) + XCTAssertNil(AiWorkStatusMapper.status(forEventName: "stream.session_watch_diagnostic")) + } + + func testSourceLabelFromSessionPrefix() { + XCTAssertEqual(AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "acp:coder:1"), "AiWork") + XCTAssertEqual(AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "cli:coder:1"), "AiWork CLI") + XCTAssertEqual(AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "gateway:coder:1"), "AiWork") + XCTAssertEqual(AiWorkStatusMapper.sourceLabel(forDaemonSessionId: nil), "AiWork") + } + + func testSourceLabelPrefersClientTypeOverAcpPrefix() { + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel( + forDaemonSessionId: "acp:coder:1", + clientType: "DTCoderTUI" + ), + "AiWork CLI" + ) + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel( + forDaemonSessionId: "acp:coder:1", + clientType: "DTCoderGUI" + ), + "AiWork" + ) + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel( + forDaemonSessionId: "acp:coder:1", + clientType: "AiWorkTUI" + ), + "AiWork CLI" + ) + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel( + forDaemonSessionId: "acp:coder:1", + clientType: "AiWorkGUI" + ), + "AiWork" + ) + } + + func testActiveListEntryFilter() { + let idle: [String: AnyCodableLike] = [ + "status": .string("active"), + "readable_summary": .object(["phase": .string("idle")]) + ] + XCTAssertFalse(AiWorkStatusMapper.isActiveListEntry(idle)) + + let waiting: [String: AnyCodableLike] = [ + "status": .string("waiting_approval") + ] + XCTAssertTrue(AiWorkStatusMapper.isActiveListEntry(waiting)) + + let streaming: [String: AnyCodableLike] = [ + "status": .string("active"), + "readable_summary": .object(["phase": .string("llm_streaming")]) + ] + XCTAssertTrue(AiWorkStatusMapper.isActiveListEntry(streaming)) + } + + func testAgentStatusFromListEntry() { + let waitingQ: [String: AnyCodableLike] = [ + "status": .string("active"), + "external_turn": .object(["status": .string("waiting_user_input"), "waiting_for": .string("question")]) + ] + XCTAssertEqual(AiWorkStatusMapper.agentStatus(fromListEntry: waitingQ), .waitingQuestion) + + let tool: [String: AnyCodableLike] = [ + "status": .string("active"), + "readable_summary": .object(["phase": .string("tool_execution")]) + ] + XCTAssertEqual(AiWorkStatusMapper.agentStatus(fromListEntry: tool), .running) + } + + // MARK: - Discovery + + func testDiscoverReadyDaemonsRequiresSockAndReady() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("codeisland-aiwork-\(UUID().uuidString)") + defer { try? fm.removeItem(at: root) } + + let coder = root.appendingPathComponent("run/coder", isDirectory: true) + try fm.createDirectory(at: coder, withIntermediateDirectories: true) + try Data().write(to: coder.appendingPathComponent("agent.sock")) + try Data("1".utf8).write(to: coder.appendingPathComponent("agent.ready")) + + let incomplete = root.appendingPathComponent("run/default", isDirectory: true) + try fm.createDirectory(at: incomplete, withIntermediateDirectories: true) + try Data().write(to: incomplete.appendingPathComponent("agent.sock")) + // no agent.ready + + let found = AiWorkWatchClient.discoverReadyDaemons(stateDir: root.path, fileManager: fm) + XCTAssertEqual(found.map(\.agentId), ["coder"]) + XCTAssertTrue(found[0].socketPath.hasSuffix("/run/coder/agent.sock")) + } + + func testDefaultStateDirHonorsEnv() { + let path = AiWorkWatchClient.defaultStateDir( + environment: ["AGENTIX_STATE_DIR": "/tmp/custom-agentix"], + homeDirectory: "/Users/test" + ) + XCTAssertEqual(path, "/tmp/custom-agentix") + + let fallback = AiWorkWatchClient.defaultStateDir( + environment: [:], + homeDirectory: "/Users/test" + ) + XCTAssertEqual(fallback, "/Users/test/.agentix") + } + + /// Live smoke against a running local Agentix daemon (skipped when absent). + func testLiveUnaryAgentStatsIfDaemonPresent() throws { + let daemons = AiWorkWatchClient.discoverReadyDaemons() + try XCTSkipIf(daemons.isEmpty, "No Agentix daemon ready under ~/.agentix/run") + let sock = try XCTUnwrap(daemons.first?.socketPath) + let frame = AiWorkWatchClient.unaryCall( + socketPath: sock, + method: "agent.stats", + params: [String: Any](), + timeoutSeconds: 5 + ) + guard let frame else { + XCTFail("unary agent.stats returned nil") + return + } + guard case .response(let op, let ok) = frame.kind else { + XCTFail("expected response frame, got \(frame.kind)") + return + } + XCTAssertEqual(op, "agent.stats") + XCTAssertTrue(ok) + XCTAssertNotNil(frame.dataObject?["agent_id"]) + } + + func testToolDescriptionPrefersCommandAndDisplayTitle() { + let withCommand: [String: AnyCodableLike] = [ + "name": .string("exec"), + "input": .object(["command": .string("pwd")]) + ] + XCTAssertEqual(AiWorkStatusMapper.toolDescription(from: withCommand), "pwd") + + let withDisplay: [String: AnyCodableLike] = [ + "name": .string("web_search"), + "_meta": .object([ + "agentix.tool_display.v1": .object([ + "title": .string("web_search: today news"), + "tool_name": .string("web_search") + ]) + ]) + ] + XCTAssertEqual(AiWorkStatusMapper.toolName(from: withDisplay), "web_search") + XCTAssertEqual(AiWorkStatusMapper.toolDescription(from: withDisplay), "web_search: today news") + } + + func testProgressLabelAndText() { + let data: [String: AnyCodableLike] = [ + "phase": .string("provider_waiting"), + "text": .string("等待模型服务…") + ] + XCTAssertEqual(AiWorkStatusMapper.progressLabel(from: data, eventName: "stream.progress"), "waiting") + XCTAssertEqual(AiWorkStatusMapper.progressText(from: data), "等待模型服务…") + } + + func testShouldForceIdleSessionUsesBusySetAndGrace() { + let now = Date() + XCTAssertTrue( + AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: "acp:coder:1", + lastActivity: now.addingTimeInterval(-15), + busyDaemonIds: [], + now: now, + grace: 8 + ) + ) + XCTAssertFalse( + AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: "acp:coder:1", + lastActivity: now.addingTimeInterval(-15), + busyDaemonIds: ["acp:coder:1"], + now: now, + grace: 8 + ) + ) + } + + func testCodeIslandSourceSplitsGuiAndCli() { + XCTAssertEqual(AiWorkStatusMapper.codeIslandSource(clientType: "DTCoderGUI"), "aiwork") + XCTAssertEqual(AiWorkStatusMapper.codeIslandSource(clientType: "DTCoderTUI"), "aiwork-cli") + XCTAssertEqual(AiWorkStatusMapper.codeIslandSource(clientType: "AiWorkGUI"), "aiwork") + XCTAssertEqual(AiWorkStatusMapper.codeIslandSource(clientType: "AiWorkTUI"), "aiwork-cli") + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: nil, daemonSessionId: "cli:coder:x"), + "aiwork-cli" + ) + } + /// Force-idle groups stuck sessions per owning daemon; that grouping is only + /// correct if `channel:agent:uuid` yields the agent segment. + func testAgentIdFromDaemonSessionId() { + XCTAssertEqual(AiWorkWatchClient.agentId(fromDaemonSessionId: "acp:coder:abc123"), "coder") + XCTAssertEqual(AiWorkWatchClient.agentId(fromDaemonSessionId: "cli:coder:abc123"), "coder") + XCTAssertEqual(AiWorkWatchClient.agentId(fromDaemonSessionId: "gateway:review:x"), "review") + // No agent segment / malformed ids must not attribute to a wrong daemon. + XCTAssertNil(AiWorkWatchClient.agentId(fromDaemonSessionId: "acp:coder")) + XCTAssertNil(AiWorkWatchClient.agentId(fromDaemonSessionId: "bare-uuid")) + XCTAssertNil(AiWorkWatchClient.agentId(fromDaemonSessionId: "acp::abc123")) + XCTAssertNil(AiWorkWatchClient.agentId(fromDaemonSessionId: "")) + } + +} diff --git a/Tests/CodeIslandTests/AppStateAiWorkWatchTests.swift b/Tests/CodeIslandTests/AppStateAiWorkWatchTests.swift new file mode 100644 index 00000000..8e05a2d5 --- /dev/null +++ b/Tests/CodeIslandTests/AppStateAiWorkWatchTests.swift @@ -0,0 +1,443 @@ +import XCTest +@testable import CodeIsland +@testable import CodeIslandCore + +@MainActor +final class AppStateAiWorkWatchTests: XCTestCase { + + func testSessionPrefixKeepsNamespaceDisjoint() { + XCTAssertEqual(AppState.aiworkSessionPrefix, "aiwork:") + } + + func testApplyStreamEventToolCallSetsRunningAndTool() { + var snapshot = SessionSnapshot() + snapshot.status = .idle + + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.tool_call", + data: [ + "name": .string("exec"), + "input": .object(["command": .string("ls -la")]) + ], + status: .running + ) + + XCTAssertEqual(snapshot.status, .running) + XCTAssertEqual(snapshot.currentTool, "exec") + XCTAssertEqual(snapshot.toolDescription, "ls -la") + } + + func testApplyStreamEventProgressFillsProcessRow() { + var snapshot = SessionSnapshot() + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.progress", + data: [ + "phase": .string("llm_streaming"), + "text": .string("正在流式推理…") + ], + status: .processing + ) + XCTAssertEqual(snapshot.status, .processing) + XCTAssertEqual(snapshot.currentTool, "llm") + XCTAssertEqual(snapshot.toolDescription, "正在流式推理…") + } + + func testApplyStreamEventThinkingFillsProcessRow() { + var snapshot = SessionSnapshot() + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.thinking_delta", + data: ["text": .string("先检查目录结构")], + status: .processing + ) + XCTAssertEqual(snapshot.currentTool, "thinking") + XCTAssertEqual(snapshot.toolDescription, "先检查目录结构") + XCTAssertEqual(snapshot.lastAssistantMessage, "先检查目录结构") + } + + func testApplyStreamEventTextDeltasShowAccumulatedPreviewNotRawChunk() { + var snapshot = SessionSnapshot() + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.text_delta", + data: ["text": .string("深度分析")], + status: .processing + ) + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.text_delta", + data: ["text": .string("这个框架")], + status: .processing + ) + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.text_delta", + data: ["text": .string("的问题")], + status: .processing + ) + + XCTAssertEqual(snapshot.currentTool, "reply") + XCTAssertEqual(snapshot.lastAssistantMessage, "深度分析这个框架的问题") + // `$` row must show the readable accumulated preview, not the last fragment. + XCTAssertEqual(snapshot.toolDescription, "深度分析这个框架的问题") + } + + func testApplyStreamEventTerminalClearsToolAndIdles() { + var snapshot = SessionSnapshot() + snapshot.status = .running + snapshot.currentTool = "exec" + snapshot.lastUserPrompt = "列出目录" + snapshot.addRecentMessage(ChatMessage(isUser: true, text: "列出目录")) + + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.completed", + data: ["final_text": .string("目录如下:src/ tests/")], + status: .idle + ) + + XCTAssertEqual(snapshot.status, .idle) + XCTAssertNil(snapshot.currentTool) + XCTAssertNil(snapshot.toolDescription) + XCTAssertEqual(snapshot.recentMessages.count, 2) + XCTAssertEqual(snapshot.recentMessages[0].isUser, true) + XCTAssertEqual(snapshot.recentMessages[0].text, "列出目录") + XCTAssertEqual(snapshot.recentMessages[1].isUser, false) + XCTAssertEqual(snapshot.recentMessages[1].text, "目录如下:src/ tests/") + } + + func testApplyStreamEventStartedRecordsUserPrompt() { + var snapshot = SessionSnapshot() + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.started", + data: ["display_text": .string("帮我看下这个仓库")], + status: .processing + ) + XCTAssertEqual(snapshot.lastUserPrompt, "帮我看下这个仓库") + XCTAssertEqual(snapshot.recentMessages.count, 1) + XCTAssertTrue(snapshot.recentMessages[0].isUser) + XCTAssertEqual(snapshot.recentMessages[0].text, "帮我看下这个仓库") + } + + func testApplyStreamEventTerminalKeepsIdleChatPreviewWithoutFinalText() { + var snapshot = SessionSnapshot() + snapshot.addRecentMessage(ChatMessage(isUser: true, text: "跑一下测试")) + AppState.applyAiWorkStreamEvent( + &snapshot, + eventName: "stream.completed", + data: nil, + status: .idle + ) + XCTAssertEqual(snapshot.recentMessages.count, 2) + XCTAssertEqual( + snapshot.recentMessages[1].text, + L10n.shared["reply_complete_placeholder"] + ) + } + + /// #322 removed a hardcoded Chinese "reply complete" placeholder; these three + /// must stay routed through L10n and unbracketed, matching L10nTests' guard. + func testTerminalPlaceholdersAreLocalizedAndUnbracketed() { + for event in ["stream.completed", "stream.failed", "stream.aborted"] { + let value = AppState.aiworkTerminalPlaceholder(eventName: event) + XCTAssertFalse(value.isEmpty, "\(event) placeholder must resolve") + XCTAssertFalse(value.hasPrefix("["), "\(event) placeholder must not start with '['") + XCTAssertFalse(value.hasSuffix("]"), "\(event) placeholder must not end with ']'") + } + XCTAssertEqual( + AppState.aiworkTerminalPlaceholder(eventName: "stream.failed"), + L10n.shared["reply_failed_placeholder"] + ) + XCTAssertEqual( + AppState.aiworkTerminalPlaceholder(eventName: "stream.aborted"), + L10n.shared["reply_aborted_placeholder"] + ) + } + + func testHandleStreamEventCreatesPrefixedSession() { + let appState = AppState() + let frame = AiWorkWatchClient.parseFrame(Data(#""" + {"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.started","phase":"start"},"data":{"session":{"session_id":"acp:coder:abc","cwd":"/tmp/proj","title":"Hello"}},"meta":{"session_id":"acp:coder:abc"}} + """#.utf8))! + + appState.handleAiWorkStreamEvent(name: "stream.started", frame: frame, agentId: "coder") + + let key = "aiwork:acp:coder:abc" + let session = appState.sessions[key] + XCTAssertNotNil(session) + XCTAssertEqual(session?.source, "aiwork") + XCTAssertEqual(session?.providerSessionId, "acp:coder:abc") + XCTAssertEqual(session?.cwd, "/tmp/proj") + XCTAssertEqual(session?.sessionTitle, "Hello") + XCTAssertEqual(session?.status, .processing) + XCTAssertEqual(session?.sourceLabel, "AiWork") + XCTAssertEqual(session?.termBundleId, AppState.aiworkAppBundleId) + XCTAssertEqual(session?.terminalName, "AiWork") + XCTAssertEqual(session?.termApp, "AiWork") + } + + func testSessionInfoChangedUpdatesTitleWithoutStatusMapper() { + let appState = AppState() + appState.sessions["aiwork:acp:coder:abc"] = { + var s = SessionSnapshot() + s.source = "aiwork" + s.providerSessionId = "acp:coder:abc" + s.status = .processing + return s + }() + + let frame = AiWorkWatchClient.parseFrame(Data(#""" + {"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.session_info_changed","phase":"info"},"data":{"session":{"session_id":"acp:coder:abc","title":"Renamed Chat","cwd":"/work/repo"}},"meta":{"session_id":"acp:coder:abc"}} + """#.utf8))! + appState.handleAiWorkStreamEvent(name: "stream.session_info_changed", frame: frame, agentId: "coder") + + let session = appState.sessions["aiwork:acp:coder:abc"] + XCTAssertEqual(session?.sessionTitle, "Renamed Chat") + XCTAssertEqual(session?.cwd, "/work/repo") + XCTAssertEqual(session?.status, .processing) + XCTAssertEqual(session?.sessionLabel, "Renamed Chat") + } + + /// The GUI/TUI decision itself. Asserting `SessionSnapshot.sourceLabel` cannot + /// test this: that property dispatches on `source` alone, so it stays green even + /// if the client_type-over-prefix priority breaks. Exercise the mapper directly. + func testSourceLabelPrefersClientTypeOverSessionIdPrefix() { + // Modern `aiwork tui` also attaches over ACP, so the acp: prefix must lose + // to an explicit TUI client_type. + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "acp:coder:2", clientType: "DTCoderTUI"), + "AiWork CLI" + ) + // And the reverse: a cli: prefix must lose to an explicit GUI client_type. + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "cli:coder:9", clientType: "DTCoderGUI"), + "AiWork" + ) + // Post-rename spellings resolve the same way. + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "acp:coder:3", clientType: "AiWorkCLI"), + "AiWork CLI" + ) + // The prefix decides only when client_type is absent or unrecognised. + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "cli:coder:1", clientType: nil), + "AiWork CLI" + ) + XCTAssertEqual( + AiWorkStatusMapper.sourceLabel(forDaemonSessionId: "acp:coder:1", clientType: " "), + "AiWork" + ) + } + + /// Separate and genuinely about the snapshot: badge/label text per source key. + func testSnapshotLabelsPerSourceKey() { + var gui = SessionSnapshot() + gui.source = "aiwork" + XCTAssertEqual(gui.sourceLabel, "AiWork") + + var tui = SessionSnapshot() + tui.source = "aiwork-cli" + XCTAssertEqual(tui.sourceLabel, "AiWork CLI") + XCTAssertEqual(tui.terminalName, "AiWork CLI") + } + + func testApplyListEntryReadsClientType() { + var snapshot = SessionSnapshot() + AppState.applyAiWorkListEntry( + &snapshot, + daemonSessionId: "acp:coder:bnb", + entry: [ + "session_id": .string("acp:coder:bnb"), + "title": .string("BNB-eval 架构分析"), + "client_type": .string("DTCoderTUI"), + "status": .string("active") + ] + ) + XCTAssertEqual(snapshot.aiworkClientType, "DTCoderTUI") + XCTAssertEqual(snapshot.source, "aiwork-cli") + XCTAssertEqual(snapshot.terminalName, "AiWork CLI") + XCTAssertEqual(snapshot.sourceLabel, "AiWork CLI") + } + + func testRemoveAiWorkSessionsByAgent() { + let appState = AppState() + appState.sessions["aiwork:acp:coder:1"] = { + var s = SessionSnapshot(); s.source = "aiwork"; s.providerSessionId = "acp:coder:1"; return s + }() + appState.sessions["aiwork:cli:coder:2"] = { + var s = SessionSnapshot(); s.source = "aiwork"; s.providerSessionId = "cli:coder:2"; return s + }() + appState.sessions["aiwork:acp:default:3"] = { + var s = SessionSnapshot(); s.source = "aiwork"; s.providerSessionId = "acp:default:3"; return s + }() + appState.sessions["claude-other"] = SessionSnapshot() + + appState.removeAiWorkSessions(agentId: "coder") + + XCTAssertNil(appState.sessions["aiwork:acp:coder:1"]) + XCTAssertNil(appState.sessions["aiwork:cli:coder:2"]) + XCTAssertNotNil(appState.sessions["aiwork:acp:default:3"]) + XCTAssertNotNil(appState.sessions["claude-other"]) + } + + func testApplyListEntryBackfillFilterHelpers() { + var snapshot = SessionSnapshot() + let entry: [String: AnyCodableLike] = [ + "session_id": .string("cli:coder:xyz"), + "status": .string("waiting_approval"), + "cwd": .string("/work"), + "title": .string("Need approve") + ] + XCTAssertTrue(AiWorkStatusMapper.isActiveListEntry(entry)) + AppState.applyAiWorkListEntry(&snapshot, daemonSessionId: "cli:coder:xyz", entry: entry) + XCTAssertEqual(snapshot.source, "aiwork-cli") + XCTAssertEqual(snapshot.status, .waitingApproval) + XCTAssertEqual(snapshot.cwd, "/work") + XCTAssertEqual(snapshot.sessionTitle, "Need approve") + XCTAssertEqual(snapshot.sourceLabel, "AiWork CLI") + XCTAssertEqual(snapshot.termBundleId, AppState.aiworkAppBundleId) + XCTAssertEqual(snapshot.terminalName, "AiWork CLI") + // TUI uses the IDE bundle for badging but is not native-app mode. + XCTAssertFalse(snapshot.isNativeAppMode) + } + + func testGUISessionShowsAiWorkAppBadge() { + var snapshot = SessionSnapshot() + snapshot.source = "aiwork" + snapshot.providerSessionId = "acp:coder:1" + snapshot.aiworkClientType = "DTCoderGUI" + AppState.applyAiWorkAppIdentity(&snapshot) + XCTAssertEqual(snapshot.terminalName, "AiWork") + XCTAssertEqual(snapshot.termBundleId, "com.alipay.dtcoder.ide") + XCTAssertTrue(snapshot.isNativeAppMode) + } + + func testCodeIslandSourceMapsClientType() { + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: "DTCoderGUI"), + "aiwork" + ) + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: "DTCoderTUI"), + "aiwork-cli" + ) + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: "AiWorkGUI"), + "aiwork" + ) + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: "AiWorkTUI"), + "aiwork-cli" + ) + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: nil, daemonSessionId: "cli:coder:1"), + "aiwork-cli" + ) + XCTAssertEqual( + AiWorkStatusMapper.codeIslandSource(clientType: nil, daemonSessionId: "acp:coder:1"), + "aiwork" + ) + } + + func testSupportedSourcesIncludesAiWorkAndCLI() { + XCTAssertTrue(SessionSnapshot.supportedSources.contains("aiwork")) + XCTAssertTrue(SessionSnapshot.supportedSources.contains("aiwork-cli")) + XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("aiwork"), "aiwork") + XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("aiwork-cli"), "aiwork-cli") + } + + func testIgnoresEventsWithoutSessionId() { + let appState = AppState() + let frame = AiWorkWatchClient.parseFrame(Data(#""" + {"kind":"event","category":"session","operation":"sessions.watch","event":{"name":"stream.started","phase":"start"},"data":{},"meta":{}} + """#.utf8))! + appState.handleAiWorkStreamEvent(name: "stream.started", frame: frame, agentId: "coder") + XCTAssertTrue(appState.sessions.keys.filter { $0.hasPrefix("aiwork:") }.isEmpty) + } + + func testShouldForceIdleWhenDaemonNoLongerBusy() { + let now = Date() + XCTAssertTrue( + AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: "acp:coder:cc8c", + lastActivity: now.addingTimeInterval(-20), + busyDaemonIds: [], + now: now, + grace: 8 + ) + ) + XCTAssertFalse( + AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: "acp:coder:cc8c", + lastActivity: now.addingTimeInterval(-20), + busyDaemonIds: ["acp:coder:cc8c"], + now: now, + grace: 8 + ), + "still-busy sessions must not be force-idled" + ) + XCTAssertFalse( + AiWorkStatusMapper.shouldForceIdleSession( + daemonSessionId: "acp:coder:cc8c", + lastActivity: now.addingTimeInterval(-3), + busyDaemonIds: [], + now: now, + grace: 8 + ), + "grace window avoids racing a just-started turn" + ) + } + + func testApplyBusyReconcileClearsStuckRunningSession() { + let appState = AppState() + let key = "aiwork:acp:coder:cc8c" + appState.sessions[key] = { + var s = SessionSnapshot(startTime: Date().addingTimeInterval(-120)) + s.source = "aiwork" + s.providerSessionId = "acp:coder:cc8c" + s.status = .running + s.currentTool = "exec" + s.toolDescription = "exec: cd /tmp && echo hi" + s.lastActivity = Date().addingTimeInterval(-30) + return s + }() + + appState.applyAiWorkBusyReconcile( + localSessions: [(key, "acp:coder:cc8c", Date().addingTimeInterval(-30))], + busyDaemonIds: [], + now: Date(), + grace: 8 + ) + + XCTAssertEqual(appState.sessions[key]?.status, .idle) + XCTAssertNil(appState.sessions[key]?.currentTool) + XCTAssertNil(appState.sessions[key]?.toolDescription) + } + + func testApplyBusyReconcileKeepsTrulyBusySession() { + let appState = AppState() + let key = "aiwork:acp:coder:busy" + appState.sessions[key] = { + var s = SessionSnapshot() + s.source = "aiwork" + s.providerSessionId = "acp:coder:busy" + s.status = .running + s.currentTool = "exec" + s.lastActivity = Date().addingTimeInterval(-30) + return s + }() + + appState.applyAiWorkBusyReconcile( + localSessions: [(key, "acp:coder:busy", Date().addingTimeInterval(-30))], + busyDaemonIds: ["acp:coder:busy"], + now: Date(), + grace: 8 + ) + + XCTAssertEqual(appState.sessions[key]?.status, .running) + XCTAssertEqual(appState.sessions[key]?.currentTool, "exec") + } +} diff --git a/Tests/CodeIslandTests/MascotRenderHarness.swift b/Tests/CodeIslandTests/MascotRenderHarness.swift index 8c1ff19c..edc0f4ab 100644 --- a/Tests/CodeIslandTests/MascotRenderHarness.swift +++ b/Tests/CodeIslandTests/MascotRenderHarness.swift @@ -148,6 +148,7 @@ private struct MascotContactSheet: View { case "kimi": KimiView(status: status, size: size) case "pi": PiView(status: status, size: size) case "cline": ClineView(status: status, size: size) + case "aiwork", "aiwork-cli": AiWorkView(status: status, size: size) default: ClawdView(status: status, size: size) } } diff --git a/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj index a71dfbc7..bcbdfbe1 100644 --- a/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj +++ b/ios/CodeIslandCompanion/CodeIslandCompanion.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 7211483270726AC25E7F53C1 /* PixelCharacterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6345881A14A90FBB79C7453 /* PixelCharacterView.swift */; }; 740A096612F9E66F04D7A29C /* WatchStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD1EEEF11FA911FA8E42BF /* WatchStateStore.swift */; }; 756DC3DF126278851C03BD1D /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + A1W0RKV1EW0000000000BF01 /* AiWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */; }; 77548822FFD6282873D688CC /* StandByLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683697BA44F284DC6FFD36DA /* StandByLayoutTests.swift */; }; 786E71DFAABF2A42048E9091 /* CodeIslandCompanionWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD9F5E97A2CDBC4E3F54478 /* CodeIslandCompanionWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7881FAFAE3BB0A5F0644BED2 /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; @@ -87,6 +88,7 @@ AB197B458D906CA80DE838DD /* StandByLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1C7AD9BDAF97493D0648FE /* StandByLayout.swift */; }; ABCA1FD581A1C3E2089F9540 /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; AF508E52E331531A53A2EA2B /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + A1W0RKV1EW0000000000BF02 /* AiWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */; }; B2DC1BB3B6FBB3C59494D5F7 /* L10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83800637B7C6322D5633FD36 /* L10n.swift */; }; B6E51EE8DB8BF988DACB1276 /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; B7CB440FDD0903B05E12AA85 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E766978AB60149BF2A008DF4 /* ContentView.swift */; }; @@ -119,6 +121,7 @@ E35A6938CFF1C6DA6BBE6B3B /* CompanionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */; }; EB02F7CC8391BFB931BE0421 /* CodeIslandWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E248E1F7A7695B40CE083370 /* CodeIslandWatchApp.swift */; }; EB3D23EDE1100113E8909302 /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + A1W0RKV1EW0000000000BF03 /* AiWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */; }; EC22CA87FDA68D8875939E1B /* CodeIslandWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB36FBACC9BA81390F2EC8BE /* CodeIslandWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; ED41E65AAA673D9FBFD48377 /* CompanionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78474E5ED47A37C22FB49F33 /* CompanionModels.swift */; }; EF55FCB89771F97175749ECF /* CodeIslandActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA23CC7B351415E136CC947 /* CodeIslandActivityAttributes.swift */; }; @@ -127,6 +130,7 @@ F6A28D1C6855BA337D8076F8 /* HermesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE12267AF913F735B14B643A /* HermesView.swift */; }; F6BF2F0FBFE41711C6160DFE /* AntiGravityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312741CE7E44499956C35C45 /* AntiGravityView.swift */; }; F7141403977B0ACAA74FE90C /* ClineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96CB90CA9C700A293F0F841 /* ClineView.swift */; }; + A1W0RKV1EW0000000000BF04 /* AiWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */; }; F894E1D744DA83959EB19554 /* CopilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */; }; F8E524B7360A360440DD9690 /* GeminiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA2D1F95A933787580CCAA6 /* GeminiView.swift */; }; FA95ADB80B6A9DFB0A62B3DC /* MascotTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D972640FA1E9018DDC125FAD /* MascotTimeline.swift */; }; @@ -260,6 +264,7 @@ E766978AB60149BF2A008DF4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; ED3A7210B0F1BDDB7803D970 /* WatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBridge.swift; sourceTree = ""; }; F40F9340746E45E572758656 /* CodeIslandWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeIslandWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiWorkView.swift; sourceTree = ""; }; F96CB90CA9C700A293F0F841 /* ClineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClineView.swift; sourceTree = ""; }; F9F350CB2292BF84421D9BD4 /* CodeIslandActivityState+Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodeIslandActivityState+Payload.swift"; sourceTree = ""; }; FB1A12DC31BC8AC80FFB96D8 /* StepFunView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepFunView.swift; sourceTree = ""; }; @@ -343,6 +348,7 @@ children = ( 312741CE7E44499956C35C45 /* AntiGravityView.swift */, 83B07028BAA056A1DA7C7791 /* BuddyView.swift */, + A1W0RKV1EW0000000000FF01 /* AiWorkView.swift */, F96CB90CA9C700A293F0F841 /* ClineView.swift */, 7F7787F642A8E89A5AA5FFFC /* CopilotView.swift */, A637A87231D2C2493FF97990 /* CursorView.swift */, @@ -608,6 +614,7 @@ files = ( F6BF2F0FBFE41711C6160DFE /* AntiGravityView.swift in Sources */, FC9D256D2F2BC3010E633FBF /* BuddyView.swift in Sources */, + A1W0RKV1EW0000000000BF02 /* AiWorkView.swift in Sources */, AF508E52E331531A53A2EA2B /* ClineView.swift in Sources */, D47274A29745CA64E55F1C21 /* CodeIslandActivityAttributes.swift in Sources */, 0190F180CA1516FF19DE1D09 /* CodeIslandActivityState+Payload.swift in Sources */, @@ -649,6 +656,7 @@ files = ( ABCA1FD581A1C3E2089F9540 /* AntiGravityView.swift in Sources */, 581D6458B7EF4DDD4D45E4D6 /* BuddyView.swift in Sources */, + A1W0RKV1EW0000000000BF04 /* AiWorkView.swift in Sources */, F7141403977B0ACAA74FE90C /* ClineView.swift in Sources */, 3942DC95ED8F566BE7D6D8B5 /* CodeIslandWatchStatusWidget.swift in Sources */, A52DA811D5FA84CF55DE9E9E /* CodeIslandWatchWidgetBundle.swift in Sources */, @@ -682,6 +690,7 @@ files = ( 1A9B2D28C0B651802CC22E46 /* AntiGravityView.swift in Sources */, 8087A336412F9084936AF405 /* BuddyView.swift in Sources */, + A1W0RKV1EW0000000000BF03 /* AiWorkView.swift in Sources */, EB3D23EDE1100113E8909302 /* ClineView.swift in Sources */, EF55FCB89771F97175749ECF /* CodeIslandActivityAttributes.swift in Sources */, D6346705BBF85F63A3C032D8 /* CodeIslandCompanionWidgetBundle.swift in Sources */, @@ -725,6 +734,7 @@ files = ( 3235DBD0E8E40BAF3ABC7CB2 /* AntiGravityView.swift in Sources */, 54D41F7821F5757358C1E651 /* BuddyView.swift in Sources */, + A1W0RKV1EW0000000000BF01 /* AiWorkView.swift in Sources */, 756DC3DF126278851C03BD1D /* ClineView.swift in Sources */, EB02F7CC8391BFB931BE0421 /* CodeIslandWatchApp.swift in Sources */, 17193B5CDD14E270C08D8BA7 /* CompanionDisplayText.swift in Sources */, diff --git a/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift b/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift index 435831c6..968913ba 100644 --- a/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift +++ b/ios/CodeIslandCompanion/Shared/CompanionDisplayText.swift @@ -19,6 +19,12 @@ enum CompanionDisplayText { return "OPENCODE" case "qwen": return "QWEN" + case "aiwork": + return "AIWORK" + case "aiwork-cli": + // The uppercase fallback would render "AIWORK-CLI"; match the Mac's + // "AiWork CLI" spacing instead. + return "AIWORK CLI" default: return trimmed.uppercased() } diff --git a/ios/CodeIslandCompanion/Shared/SharedMascotView.swift b/ios/CodeIslandCompanion/Shared/SharedMascotView.swift index b2be095e..0af756ec 100644 --- a/ios/CodeIslandCompanion/Shared/SharedMascotView.swift +++ b/ios/CodeIslandCompanion/Shared/SharedMascotView.swift @@ -94,6 +94,8 @@ struct SharedMascotView: View { KimiView(status: status, size: size) case "cline": ClineView(status: status, size: size) + case "aiwork", "aiwork-cli": + AiWorkView(status: status, size: size) default: ClawdView(status: status, size: size) } diff --git a/ios/CodeIslandCompanion/project.yml b/ios/CodeIslandCompanion/project.yml index 9c6aa49f..ecbddf07 100644 --- a/ios/CodeIslandCompanion/project.yml +++ b/ios/CodeIslandCompanion/project.yml @@ -19,6 +19,7 @@ targets: sources: - path: CodeIslandCompanion - path: Shared + - path: ../../Sources/CodeIsland/AiWorkView.swift - path: ../../Sources/CodeIsland/AntiGravityView.swift - path: ../../Sources/CodeIsland/BuddyView.swift - path: ../../Sources/CodeIsland/ClineView.swift @@ -59,6 +60,7 @@ targets: sources: - path: CodeIslandCompanionWidget - path: Shared + - path: ../../Sources/CodeIsland/AiWorkView.swift - path: ../../Sources/CodeIsland/AntiGravityView.swift - path: ../../Sources/CodeIsland/BuddyView.swift - path: ../../Sources/CodeIsland/ClineView.swift @@ -96,6 +98,7 @@ targets: - path: Shared/SharedMascotView.swift - path: Shared/WatchStateStore.swift - path: Shared/L10n.swift + - path: ../../Sources/CodeIsland/AiWorkView.swift - path: ../../Sources/CodeIsland/AntiGravityView.swift - path: ../../Sources/CodeIsland/BuddyView.swift - path: ../../Sources/CodeIsland/ClineView.swift @@ -137,6 +140,7 @@ targets: - path: Shared/SharedMascotView.swift - path: Shared/WatchStateStore.swift - path: Shared/L10n.swift + - path: ../../Sources/CodeIsland/AiWorkView.swift - path: ../../Sources/CodeIsland/AntiGravityView.swift - path: ../../Sources/CodeIsland/BuddyView.swift - path: ../../Sources/CodeIsland/ClineView.swift