From 0d4f006ebf5fc1a011efc13b410fcba62be0edb2 Mon Sep 17 00:00:00 2001 From: BinaryFroggy Date: Mon, 11 May 2026 17:33:19 +0800 Subject: [PATCH 1/5] refactor(pet)!: collapse per-tool pets into a single global pet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PetInstance per AITool model was a v0.1 placeholder: only Claude ever rendered, and registry.activeTools hard-pinned the list. Move to one global PetInstance owned by SessionRegistry; sessions from every tool aggregate into it. Drops AITool from PetInstance, replaces registry.pets[tool] with registry.pet, renames showAll/toggleAll on the window controller and SceneRouter to show/togglePet. BREAKING CHANGE: SessionRegistry.pets, SessionRegistry.activeTools, PetInstance(tool:), PetWindowController.showAll()/toggleAll()/ locate(_:), and SceneRouter.toggleAllPets() are removed. Callers should use registry.pet, PetWindowController.show()/toggle()/locate(), and SceneRouter.togglePet(). See devDocs/architecture.md §3, devDocs/features.md §2. --- Sources/Hopet/App/MenuBarItem.swift | 2 +- Sources/Hopet/App/SceneRouter.swift | 4 +- Sources/Hopet/Core/PetAggregator.swift | 19 ++-- Sources/Hopet/Core/SessionRegistry.swift | 34 +++---- Sources/Hopet/Models/PetInstance.swift | 5 +- Sources/Hopet/Notch/NotchView.swift | 23 ++--- Sources/Hopet/Panel/BindingsTab.swift | 23 +---- Sources/Hopet/Panel/OverviewTab.swift | 20 ++-- Sources/Hopet/Pet/InputCoordinator.swift | 10 ++ Sources/Hopet/Pet/PetBadgeView.swift | 37 +++---- Sources/Hopet/Pet/PetStageView.swift | 44 +++++---- Sources/Hopet/Pet/PetWindow.swift | 7 +- Sources/Hopet/Pet/PetWindowController.swift | 37 ++++--- Sources/Hopet/Pet/SessionBubbleView.swift | 43 ++++++-- devDocs/architecture.md | 103 ++++++++++++-------- devDocs/features.md | 20 ++-- 16 files changed, 226 insertions(+), 205 deletions(-) diff --git a/Sources/Hopet/App/MenuBarItem.swift b/Sources/Hopet/App/MenuBarItem.swift index 88b8f04..6ee9af3 100644 --- a/Sources/Hopet/App/MenuBarItem.swift +++ b/Sources/Hopet/App/MenuBarItem.swift @@ -34,7 +34,7 @@ public final class MenuBarItem { } @objc private func openPrefs() { router.openPreferences() } - @objc private func togglePets() { router.toggleAllPets() } + @objc private func togglePets() { router.togglePet() } @objc private func quit() { NSApp.terminate(nil) } private static func loadStatusBarIcon() -> NSImage? { diff --git a/Sources/Hopet/App/SceneRouter.swift b/Sources/Hopet/App/SceneRouter.swift index f66b16a..334bf2c 100644 --- a/Sources/Hopet/App/SceneRouter.swift +++ b/Sources/Hopet/App/SceneRouter.swift @@ -91,7 +91,7 @@ public final class SceneRouter { thinkingTimer.start() decayTimer.start() - petWindowController.showAll() + petWindowController.show() // 刘海条暂不展示,等三态/降级顶条视觉打磨完再开。controller / wiring 保留。 // notchController.show() HopetLog.info("Hopet booted.") @@ -110,7 +110,7 @@ public final class SceneRouter { } public func openPreferences() { preferencesController.show() } - public func toggleAllPets() { petWindowController.toggleAll() } + public func togglePet() { petWindowController.toggle() } /// 把外观偏好映射到 `NSApp.appearance`:light → .aqua / dark → .darkAqua / system → nil。 /// See preferences.md §6.4. diff --git a/Sources/Hopet/Core/PetAggregator.swift b/Sources/Hopet/Core/PetAggregator.swift index 4fb4d23..cad3511 100644 --- a/Sources/Hopet/Core/PetAggregator.swift +++ b/Sources/Hopet/Core/PetAggregator.swift @@ -1,7 +1,8 @@ import Foundation import Combine -/// 把多 session → 单宠物的优先级聚合,权威算法见 hooks-and-priority.md §3.2。 +/// 把多 session → 单宠物的优先级聚合(全局唯一宠物,跨所有 AI 工具)。 +/// 权威算法见 hooks-and-priority.md §3.2。 @MainActor public final class PetAggregator { private unowned let registry: SessionRegistry @@ -14,22 +15,20 @@ public final class PetAggregator { .sink { [weak self] mutation in guard let self else { return } switch mutation { - case .added(_, let tool), - .removed(_, let tool), - .stateChanged(_, let tool, _, _): - self.recompute(tool: tool) + case .added, .removed, .stateChanged: + self.recompute() case .fieldsUpdated: break } } .store(in: &bag) - for tool in registry.pets.keys { recompute(tool: tool) } + recompute() } - public func recompute(tool: AITool) { - guard let current = registry.pets[tool] else { return } - let active = registry.activeSessions(of: tool) + public func recompute() { + let current = registry.pet + let active = registry.activeSessions let newState: PetState let newDriver: String? if active.isEmpty { @@ -47,7 +46,7 @@ public final class PetAggregator { newDriver = leader.id } guard current.aggregatedState != newState || current.drivenBySessionId != newDriver else { return } - registry.updatePet(tool) { + registry.updatePet { $0.aggregatedState = newState $0.drivenBySessionId = newDriver } diff --git a/Sources/Hopet/Core/SessionRegistry.swift b/Sources/Hopet/Core/SessionRegistry.swift index f78d536..d2e8e43 100644 --- a/Sources/Hopet/Core/SessionRegistry.swift +++ b/Sources/Hopet/Core/SessionRegistry.swift @@ -5,7 +5,8 @@ import Combine @MainActor public final class SessionRegistry: ObservableObject { @Published public private(set) var sessions: [String: Session] = [:] - @Published public private(set) var pets: [AITool: PetInstance] = [:] + /// 全局唯一宠物(v0.x 起:所有 AI 工具共用一只)。 + @Published public private(set) var pet: PetInstance /// 「单 session 状态变化 / 加入 / 移除」的细粒度变更广播。 public let mutations = PassthroughSubject() @@ -17,24 +18,13 @@ public final class SessionRegistry: ObservableObject { case fieldsUpdated(sessionId: String) } - /// v0.1:只为 Claude 创建宠物实例。Codex 留到 v0.2,那时 hooks 也成熟。 - public static let activeTools: [AITool] = [.claudeCode] - public init() { - for tool in Self.activeTools { - pets[tool] = PetInstance(tool: tool, screenPosition: defaultPosition(for: tool)) - } + self.pet = PetInstance(screenPosition: Self.defaultPosition()) } - private func defaultPosition(for tool: AITool) -> CGPoint { - // 主屏右下,Codex 再向左偏移 200。具体 frame 在 PetWindow 创建时再 clamp。 - let baseX: CGFloat = 1400 - let baseY: CGFloat = 200 - switch tool { - case .claudeCode: return CGPoint(x: baseX, y: baseY) - case .codex: return CGPoint(x: baseX - 200, y: baseY) - case .custom: return CGPoint(x: baseX - 400, y: baseY) - } + /// 全局宠物默认位置。后续若引入位置持久化,会读 ConfigStore。 + private static func defaultPosition() -> CGPoint { + CGPoint(x: 1400, y: 200) } // MARK: - Mutations @@ -79,17 +69,23 @@ public final class SessionRegistry: ObservableObject { mutations.send(.fieldsUpdated(sessionId: sessionId)) } + /// 同工具的活跃 session。`EventRouter.pruneStaleSiblings` 等仍按 tool 维度判定"同一终端的躺尸"。 public func activeSessions(of tool: AITool) -> [Session] { sessions.values.filter { $0.tool == tool } } + /// 所有活跃 session(跨 tool)。PetAggregator / PetStageView 消费此列表。 + public var activeSessions: [Session] { + Array(sessions.values) + } + public func session(_ id: String) -> Session? { sessions[id] } // MARK: - Pet aggregation hook - public func updatePet(_ tool: AITool, mutate: (inout PetInstance) -> Void) { - guard var p = pets[tool] else { return } + public func updatePet(_ mutate: (inout PetInstance) -> Void) { + var p = pet mutate(&p) - pets[tool] = p + pet = p } } diff --git a/Sources/Hopet/Models/PetInstance.swift b/Sources/Hopet/Models/PetInstance.swift index 801ff82..440c3b1 100644 --- a/Sources/Hopet/Models/PetInstance.swift +++ b/Sources/Hopet/Models/PetInstance.swift @@ -1,10 +1,9 @@ import Foundation import CoreGraphics -/// 一只宠物的渲染实例。1:1 绑定一个 AITool。 +/// 全局唯一的宠物渲染实例。状态由所有活跃 session 聚合得出,不再按 AI 工具分组。 public struct PetInstance: Identifiable, Sendable, Equatable { public let id: UUID - public let tool: AITool public var themeId: String public var screenPosition: CGPoint public var visible: Bool @@ -13,7 +12,6 @@ public struct PetInstance: Identifiable, Sendable, Equatable { public init( id: UUID = UUID(), - tool: AITool, themeId: String = "hopi.default", screenPosition: CGPoint = .zero, visible: Bool = true, @@ -21,7 +19,6 @@ public struct PetInstance: Identifiable, Sendable, Equatable { drivenBySessionId: String? = nil ) { self.id = id - self.tool = tool self.themeId = themeId self.screenPosition = screenPosition self.visible = visible diff --git a/Sources/Hopet/Notch/NotchView.swift b/Sources/Hopet/Notch/NotchView.swift index 3870393..74e7747 100644 --- a/Sources/Hopet/Notch/NotchView.swift +++ b/Sources/Hopet/Notch/NotchView.swift @@ -8,24 +8,25 @@ public struct NotchView: View { self.registry = registry } - /// 当前最高优先级的"宠物 + 状态"——只展示一条。 - private var leader: (PetInstance, AITool, Session?)? { - let candidates = registry.pets.values - .sorted { $0.aggregatedState.priority < $1.aggregatedState.priority } - guard let pet = candidates.first else { return nil } - let session = pet.drivenBySessionId.flatMap { registry.session($0) } - return (pet, pet.tool, session) + /// 当前 leader session(驱动宠物动画的那条)。可能为空——所有 session 都退出后 pet 进 idle, + /// `drivenBySessionId` 为 nil。 + private var leaderSession: Session? { + registry.pet.drivenBySessionId.flatMap { registry.session($0) } } public var body: some View { + let pet = registry.pet + let session = leaderSession HStack(spacing: 12) { - if let (pet, tool, session) = leader { + if session != nil || pet.aggregatedState != .idle { Circle() .fill(pet.aggregatedState.accentColor) .frame(width: 10, height: 10) - Text(tool.displayName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) + if let tool = session?.tool { + Text(tool.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } Text(pet.aggregatedState.notchCaption) .font(.system(size: 12)) .foregroundStyle(.white.opacity(0.8)) diff --git a/Sources/Hopet/Panel/BindingsTab.swift b/Sources/Hopet/Panel/BindingsTab.swift index 8e44b00..64ea73a 100644 --- a/Sources/Hopet/Panel/BindingsTab.swift +++ b/Sources/Hopet/Panel/BindingsTab.swift @@ -1,6 +1,6 @@ import SwiftUI -/// AI ↔ Theme 绑定:v0.1 全局单一主题。每个 AI 都用 `themes.activeTheme` 渲染。 +/// 主题选择:全局单一主题。所有 AI 工具共用同一只宠物、同一套动画。 /// 下拉选择全局主题;Picker 弹出菜单保持系统外观(preferences.md §11.2.3 的不像素化部件清单)。 /// See preferences.md §11.6. struct BindingsTab: View { @@ -10,7 +10,7 @@ struct BindingsTab: View { PreferencesPaneScaffold("Bindings") { PixelCard("GLOBAL THEME", titleTint: PixelPalette.sky) { VStack(alignment: .leading, spacing: 8) { - Text("v0.1 ships a single global theme for every AI. Per-tool bindings arrive in v0.2.") + Text("Hopet 现在全局只有一只宠物,所有 AI 工具共用此主题。") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) Picker("", selection: $themes.activeThemeId) { @@ -21,25 +21,6 @@ struct BindingsTab: View { .font(.system(size: 12, design: .monospaced)) } } - - PixelCard("PER-TOOL", titleTint: PixelPalette.mint) { - VStack(alignment: .leading, spacing: 6) { - ForEach(SessionRegistry.activeTools, id: \.self) { tool in - HStack { - Text(tool.displayName) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .frame(width: 120, alignment: .leading) - Text("→ \(themes.activeTheme.name)") - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - Spacer() - Text("(globally bound)") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.tertiary) - } - } - } - } } } } diff --git a/Sources/Hopet/Panel/OverviewTab.swift b/Sources/Hopet/Panel/OverviewTab.swift index bceab18..1f0d9a5 100644 --- a/Sources/Hopet/Panel/OverviewTab.swift +++ b/Sources/Hopet/Panel/OverviewTab.swift @@ -1,6 +1,6 @@ import SwiftUI -/// 概览:每个 AI 一张像素 PetCard 显示当前聚合状态 + 活跃 session 数;下方是会话列表。 +/// 概览:单张 PetCard 显示全局宠物的聚合状态 + 活跃 session 数;下方是会话列表。 /// See preferences.md §11.6. struct OverviewTab: View { @ObservedObject var registry: SessionRegistry @@ -9,14 +9,11 @@ struct OverviewTab: View { var body: some View { PreferencesPaneScaffold("Overview") { HStack(spacing: 12) { - ForEach(SessionRegistry.activeTools, id: \.self) { tool in - PixelPetCard( - tool: tool, - pet: registry.pets[tool] ?? PetInstance(tool: tool), - sessionCount: registry.activeSessions(of: tool).count, - onLocate: { controller.locate(tool) } - ) - } + PixelPetCard( + pet: registry.pet, + sessionCount: registry.sessions.count, + onLocate: { controller.locate() } + ) Spacer(minLength: 0) } @@ -47,13 +44,12 @@ struct OverviewTab: View { } private struct PixelPetCard: View { - let tool: AITool let pet: PetInstance let sessionCount: Int let onLocate: () -> Void var body: some View { - PixelCard(tool.displayName.uppercased(), accent: pet.aggregatedState.accentColor, titleTint: PixelPalette.sky) { + PixelCard("HOPET", accent: pet.aggregatedState.accentColor, titleTint: PixelPalette.sky) { VStack(spacing: 6) { Text(pet.aggregatedState.glyph) .font(.system(size: 26)) @@ -61,7 +57,7 @@ private struct PixelPetCard: View { .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) Button("Locate", action: onLocate) - .buttonStyle(PixelButtonStyle(tint: .accentColor, prominent: false)) + .buttonStyle(PixelButtonStyle(tint: PixelPalette.sky, prominent: false)) } .frame(width: 140, height: 120) } diff --git a/Sources/Hopet/Pet/InputCoordinator.swift b/Sources/Hopet/Pet/InputCoordinator.swift index 00ad9ef..e10a629 100644 --- a/Sources/Hopet/Pet/InputCoordinator.swift +++ b/Sources/Hopet/Pet/InputCoordinator.swift @@ -36,4 +36,14 @@ public final class InputCoordinator { cancel: cancel ) } + + /// 用户在气泡上手动点关闭:从 registry 移除该会话。 + /// 不清 transcriptToPrimary 映射——这是"软"消失:如果会话仍活着,下一次 + /// userPrompt / preToolUse / permission_ask 会通过 EventRouter 的冷启路径 + /// 重建 session,气泡随即回来;真僵尸会话则永远不再有事件,气泡保持清除。 + /// cancelPending 兜底处理罕见的"defaultCard 仍残留 pendingQuestion"场景。 + public func dismissSession(_ sessionId: String) { + permissionPrompter.cancelPending(sessionId: sessionId) + registry.remove(sessionId) + } } diff --git a/Sources/Hopet/Pet/PetBadgeView.swift b/Sources/Hopet/Pet/PetBadgeView.swift index 9b794e6..a4a0677 100644 --- a/Sources/Hopet/Pet/PetBadgeView.swift +++ b/Sources/Hopet/Pet/PetBadgeView.swift @@ -5,12 +5,10 @@ public struct PetBadgeView: View { /// 渲染框尺寸;PetStageView 的视口高度计算依赖该值,必须保持单一来源。 public static let renderedSize: CGFloat = 128 - public let tool: AITool public let state: PetState public let theme: ThemePackage - public init(tool: AITool, state: PetState, theme: ThemePackage) { - self.tool = tool + public init(state: PetState, theme: ThemePackage) { self.state = state self.theme = theme } @@ -26,27 +24,22 @@ public struct PetBadgeView: View { } } .animation(.easeInOut(duration: 0.2), value: state) - .accessibilityLabel("\(tool.displayName) \(state.badgeLabel)") + .accessibilityLabel(state.badgeLabel) } private var fallbackBadge: some View { - VStack(spacing: 4) { - Text(theme.glyph(for: state)) - .font(.system(size: 28, weight: .semibold)) - .lineLimit(1) - Text(tool.displayName) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - } - .frame(width: Self.renderedSize, height: Self.renderedSize) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(state.accentColor, lineWidth: state == .idle ? 1.5 : 3) - ) - .shadow(color: state.accentColor.opacity(0.35), radius: 8, x: 0, y: 4) + Text(theme.glyph(for: state)) + .font(.system(size: 36, weight: .semibold)) + .lineLimit(1) + .frame(width: Self.renderedSize, height: Self.renderedSize) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(state.accentColor, lineWidth: state == .idle ? 1.5 : 3) + ) + .shadow(color: state.accentColor.opacity(0.35), radius: 8, x: 0, y: 4) } } diff --git a/Sources/Hopet/Pet/PetStageView.swift b/Sources/Hopet/Pet/PetStageView.swift index 5799ef0..585a79e 100644 --- a/Sources/Hopet/Pet/PetStageView.swift +++ b/Sources/Hopet/Pet/PetStageView.swift @@ -9,11 +9,13 @@ import SwiftUI public struct PetStageView: View { @ObservedObject var registry: SessionRegistry @ObservedObject var themes: ThemeStore - let tool: AITool /// (sessionId, requestId, decision, reason). `reason` 仅在 plan-approval 卡片的 deny 路径上非 nil。 let onResolvePermission: (String, String, String, String?) -> Void /// (sessionId, requestId, answers, cancel) let onResolveAskUser: (String, String, [String: String], Bool) -> Void + /// 手动关闭:用户点 defaultCard 右上角的 ✕,让用户兜底清掉僵尸气泡。 + /// 真活会话被误关也会在下一次状态事件冷启时重新出现。 + let onDismiss: (String) -> Void @State private var now: Date = Date() @State private var scrollMetrics = ScrollMetrics() @@ -22,8 +24,14 @@ public struct PetStageView: View { /// default 卡片场景下希望同时可见的条数;第 6 条开始进入滚动区。 private static let maxVisibleBubbles: Int = 5 - /// 单个 default 卡片的估算高度(两行文本 + 内外 padding),见 SessionBubbleView.defaultCard。 - private static let defaultBubbleHeight: CGFloat = 56 + /// 单个 default 卡片的估算高度。 + /// SessionBubbleView.defaultCard 的实际度量: + /// padding 8×2 + 第一行 ~16 + spacing 4 + 第二行 9pt × lineLimit(2) ≈ 22 + /// = 58;再加 PixelChrome strokeInset+1 = 3×2 = 6 → ≈ 64。 + /// 历史值 56 只覆盖 1 行回复;2 行回复时 measured 上报前会被估算"卡矮", + /// `.frame(minHeight: viewportHeight, alignment: .bottom)` 把顶部内容压出可视区。 + /// 取 76 留出额外缓冲(中英文混排实际行高可达 16pt),让初始帧就能完整展示。 + private static let defaultBubbleHeight: CGFloat = 76 /// 展开卡片的保守估算高度。pending 刚出现时 ScrollView 仍握着旧的 default 高度, /// 必须先用这些 hint 撑开视口,下一轮 GeometryReader 才能量到真实高度。 private static let permissionBubbleHeight: CGFloat = 260 @@ -55,38 +63,38 @@ public struct PetStageView: View { public init( registry: SessionRegistry, themes: ThemeStore, - tool: AITool, onResolvePermission: @escaping (String, String, String, String?) -> Void, - onResolveAskUser: @escaping (String, String, [String: String], Bool) -> Void + onResolveAskUser: @escaping (String, String, [String: String], Bool) -> Void, + onDismiss: @escaping (String) -> Void = { _ in } ) { self.registry = registry self.themes = themes - self.tool = tool self.onResolvePermission = onResolvePermission self.onResolveAskUser = onResolveAskUser + self.onDismiss = onDismiss } private var pet: PetInstance { - registry.pets[tool] ?? PetInstance(tool: tool) + registry.pet } - /// 列表里的会话:按 `startedAt` 倒序——最新在数组头,最旧在数组末。 + /// 列表里的会话(跨所有 AI 工具):按 `startedAt` 倒序——最新在数组头,最旧在数组末。 /// VStack 末项(数组末)= 最旧会话 = 紧贴宠物头顶;新会话从顶部插入。 private var sessions: [Session] { - registry.activeSessions(of: tool) + registry.activeSessions .sorted { $0.startedAt > $1.startedAt } } - /// 少于 5 条普通气泡时视口跟内容等高,否则离海豹头顶会出现一截空白; - /// 超过 5 条时 cap 住启用滚动。若存在 pending 展开卡片,临时放大 cap 让卡片完整进入视口。 + /// 视口高度:完全由 estimate 决定,**不再用 measured 反馈**。 + /// 旧实现 `min(max(measured, estimated), maxHeight)` 配合 `.frame(minHeight: viewportHeight)` + /// 形成 measured ↔ viewport 互相驱动的循环——一旦 estimate 偏小(如 2 行回复 > 1 行估算), + /// 一帧内 viewport 仍卡在旧值,content 顶部被 alignment .bottom 推出 ScrollView 可视区, + /// 视觉上呈现"气泡比视口高、展示不完整"。 + /// 改为单向:sessions 形态变 → estimate 重算 → viewport 直接同步;measured 仅给滚动条用。 private func bubbleViewportHeight(sessions: [Session]) -> CGFloat { guard !sessions.isEmpty else { return 0 } let estimated = estimatedBubbleContentHeight(sessions: sessions) - let measured = scrollMetrics.contentHeight > 0 ? scrollMetrics.contentHeight : estimated - let maxHeight = pendingFocusId(in: sessions) == nil - ? PetStageView.defaultBubbleAreaMaxHeight - : PetStageView.expandedBubbleAreaMaxHeight - return min(max(measured, estimated), maxHeight) + return min(estimated, PetStageView.expandedBubbleAreaMaxHeight) } private func estimatedBubbleContentHeight(sessions: [Session]) -> CGFloat { @@ -133,7 +141,8 @@ public struct PetStageView: View { onResolveAskUser: { answers, cancel in guard let pa = session.pendingAskUser else { return } onResolveAskUser(session.id, pa.requestId, answers, cancel) - } + }, + onDismiss: { onDismiss(session.id) } ) .id(session.id) .transition(.asymmetric( @@ -195,7 +204,6 @@ public struct PetStageView: View { } PetBadgeView( - tool: tool, state: pet.aggregatedState, theme: themes.activeTheme ) diff --git a/Sources/Hopet/Pet/PetWindow.swift b/Sources/Hopet/Pet/PetWindow.swift index 1d6c2fa..9d8ec5b 100644 --- a/Sources/Hopet/Pet/PetWindow.swift +++ b/Sources/Hopet/Pet/PetWindow.swift @@ -11,10 +11,7 @@ public final class PetWindow: NSPanel { /// PetStageView / hosting view / Window 必须使用同一组尺寸。 public static let stageSize = CGSize(width: 380, height: 620) - public let tool: AITool - - public init(tool: AITool, contentView: NSView, initialOrigin: CGPoint) { - self.tool = tool + public init(contentView: NSView, initialOrigin: CGPoint) { let frame = NSRect(origin: initialOrigin, size: NSSize(width: PetWindow.stageSize.width, height: PetWindow.stageSize.height)) super.init( contentRect: frame, @@ -30,7 +27,7 @@ public final class PetWindow: NSPanel { self.isMovableByWindowBackground = true self.hidesOnDeactivate = false self.contentView = contentView - self.title = "Hopet · \(tool.displayName)" + self.title = "Hopet" self.titlebarAppearsTransparent = true } diff --git a/Sources/Hopet/Pet/PetWindowController.swift b/Sources/Hopet/Pet/PetWindowController.swift index 57f8231..e40cb4a 100644 --- a/Sources/Hopet/Pet/PetWindowController.swift +++ b/Sources/Hopet/Pet/PetWindowController.swift @@ -1,13 +1,13 @@ import AppKit import SwiftUI -/// 管理两只宠物窗口(Claude / Codex)的可见性、位置与拖拽持久化。 +/// 管理全局唯一宠物窗口的可见性与拖拽。 @MainActor public final class PetWindowController { private let registry: SessionRegistry private let themes: ThemeStore private let inputCoordinator: InputCoordinator - private var windows: [AITool: PetWindow] = [:] + private var window: PetWindow? public init( registry: SessionRegistry, @@ -19,23 +19,20 @@ public final class PetWindowController { self.inputCoordinator = inputCoordinator } - public func showAll() { - for tool in SessionRegistry.activeTools { - ensureWindow(for: tool).orderFrontRegardless() - } + public func show() { + ensureWindow().orderFrontRegardless() } - public func toggleAll() { - let anyVisible = windows.values.contains { $0.isVisible } - if anyVisible { - windows.values.forEach { $0.orderOut(nil) } + public func toggle() { + if let win = window, win.isVisible { + win.orderOut(nil) } else { - showAll() + show() } } - public func locate(_ tool: AITool) { - guard let win = windows[tool] else { return } + public func locate() { + guard let win = window else { return } win.makeKeyAndOrderFront(nil) // 简单的"闪烁定位":alpha 抖动一次。 win.alphaValue = 0.3 @@ -45,14 +42,13 @@ public final class PetWindowController { } } - private func ensureWindow(for tool: AITool) -> PetWindow { - if let existing = windows[tool] { return existing } + private func ensureWindow() -> PetWindow { + if let existing = window { return existing } - let origin = registry.pets[tool]?.screenPosition ?? .zero + let origin = registry.pet.screenPosition let stageView = PetStageView( registry: registry, themes: themes, - tool: tool, onResolvePermission: { [weak self] sessionId, requestId, decision, reason in self?.inputCoordinator.resolvePermission( sessionId: sessionId, @@ -68,13 +64,16 @@ public final class PetWindowController { answers: answers, cancel: cancel ) + }, + onDismiss: { [weak self] sessionId in + self?.inputCoordinator.dismissSession(sessionId) } ) let hosting = FirstClickHostingView(rootView: stageView) hosting.frame = NSRect(origin: .zero, size: PetWindow.stageSize) - let win = PetWindow(tool: tool, contentView: hosting, initialOrigin: origin) - windows[tool] = win + let win = PetWindow(contentView: hosting, initialOrigin: origin) + window = win return win } } diff --git a/Sources/Hopet/Pet/SessionBubbleView.swift b/Sources/Hopet/Pet/SessionBubbleView.swift index 5c1009d..34bf005 100644 --- a/Sources/Hopet/Pet/SessionBubbleView.swift +++ b/Sources/Hopet/Pet/SessionBubbleView.swift @@ -27,19 +27,24 @@ public struct SessionBubbleView: View { /// AskUserQuestion 答题提交回调。`answers` 形如 `{ "问题文案": "回答" }`; /// `cancel = true` 表示用户取消(让 Claude 走自身 UI)。 let onResolveAskUser: ([String: String], Bool) -> Void + /// 手动关闭:用户点 defaultCard / askUserCard 右上角的 ✕。 + /// 真活会话被误关时下一次状态事件会冷启重建(详见 InputCoordinator.dismissSession)。 + let onDismiss: () -> Void public init( bubble: SessionBubble, isLeader: Bool, stateDurationPhrase: String, onResolvePermission: @escaping (String, String?) -> Void = { _, _ in }, - onResolveAskUser: @escaping ([String: String], Bool) -> Void = { _, _ in } + onResolveAskUser: @escaping ([String: String], Bool) -> Void = { _, _ in }, + onDismiss: @escaping () -> Void = {} ) { self.bubble = bubble self.isLeader = isLeader self.stateDurationPhrase = stateDurationPhrase self.onResolvePermission = onResolvePermission self.onResolveAskUser = onResolveAskUser + self.onDismiss = onDismiss } @FocusState private var inputFocused: Bool @@ -167,6 +172,22 @@ public struct SessionBubbleView: View { .buttonStyle(.plain) } + /// defaultCard / askUserCard 右上角的"清除气泡"按钮: + /// 比 paletteDismissButton 更小、更低对比度,避免在闲置卡片里抢眼。 + /// 仅从 registry 移除该 session;真活会话下一次事件冷启会重建气泡,僵尸气泡则永久消失。 + @ViewBuilder + private func defaultDismissButton(action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Color.white) + .frame(width: 14, height: 14) + .background(Circle().fill(Color.secondary.opacity(0.45))) + } + .buttonStyle(.plain) + .help("清除此气泡(会话若仍活跃,下次状态变化会重新出现)") + } + /// 权限请求卡片(PermissionRequest hook 触发)。 private var permissionCard: some View { let pp = bubble.pendingPermission! @@ -186,7 +207,7 @@ public struct SessionBubbleView: View { } // 主标题 - Text("Claude 想执行此操作") + Text("\(bubble.tool.displayName) 想执行此操作") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) @@ -512,13 +533,17 @@ public struct SessionBubbleView: View { } /// 旧 fire-and-forget pendingQuestion 卡片(PreToolUse `tool_name=AskUserQuestion` 路径)。 - /// 这条路径没带 requestId,无法同步回包,只能展示提示让用户回到原终端作答; - /// 新会话列表布局下卡片常驻显示,没有"关闭"语义——pendingQuestion 由协议层在 - /// 下一次状态变更时自然清空,视图随即回到 defaultCard。 + /// 这条路径没带 requestId,无法同步回包,只能展示提示让用户回到原终端作答。 + /// pendingQuestion 通常由协议层在下一次状态变更时自然清空,视图随即回到 defaultCard; + /// 右上角的 ✕ 兜底——遇到僵尸卡片(事件未到达)让用户手动清掉。 private var askUserCard: some View { VStack(alignment: .leading, spacing: 6) { - Text("❓ 在等你回答") - .font(.system(size: 11, weight: .semibold)) + HStack(alignment: .center) { + Text("❓ 在等你回答") + .font(.system(size: 11, weight: .semibold)) + Spacer() + defaultDismissButton { popThen(onDismiss) } + } if let q = bubble.pendingQuestion { Text(q) .font(.system(size: 10)) @@ -548,7 +573,7 @@ public struct SessionBubbleView: View { Text(headerLabel) .font(.system(size: 11, weight: .semibold)) .lineLimit(1) - .truncationMode(.middle) + .truncationMode(.tail) Spacer(minLength: 6) @@ -557,6 +582,8 @@ public struct SessionBubbleView: View { .foregroundStyle(.secondary) .lineLimit(1) .fixedSize() + + defaultDismissButton { popThen(onDismiss) } } Text(secondLineText) diff --git a/devDocs/architecture.md b/devDocs/architecture.md index bc6ac97..fba42f8 100644 --- a/devDocs/architecture.md +++ b/devDocs/architecture.md @@ -21,17 +21,17 @@ | 状态感知动画(idle / responding / thinking / tool-use / permission-prompt / completed / error-interrupted) | ✅ Claude Code 全链路 | — | | 状态感知动画(ask-user) | ✅ 通过 Claude 内置 `AskUserQuestion` tool 的 `PreToolUse` / `PostToolUse` hook + `tool_name` 过滤识别 | — | | Claude Code hooks 接入 | ✅ | ✅ | -| Codex 接入 | ⚠️ 实验性"完成通知"(基于 `notify` 字段) | ✅ 完整生命周期 | +| Codex 接入 | ⚠️ 实验性"完成通知"(基于 `notify` 字段) | ✅ 完整生命周期(`hooks.json` 6 hook:SessionStart / UserPromptSubmit / Pre&PostToolUse / PermissionRequest / Stop) | | 刘海屏 Dynamic Notch + 无刘海机型降级顶条 | ✅ | ✅ | -| 桌面宠物(**每个 AI 工具一只**:Claude / Codex 各 1,含拖拽、位置记忆) | ✅ | ✅ | -| 会话气泡(每只宠物周围环绕,每个气泡 = 1 个活跃 session) | ✅ 默认显示一层 cwd / 标题 / 距上次状态变更耗时 | ✅ + 拖拽重排 | +| 桌面宠物(**全局一只**:聚合所有 AI 工具、所有 session,含拖拽、位置记忆) | ✅ | ✅ | +| 会话气泡(围绕全局宠物,每个气泡 = 1 个活跃 session) | ✅ 默认显示一层 cwd / 标题 / 距上次状态变更耗时 | ✅ + 拖拽重排 | | 状态聚合(多会话 → 单宠物按优先级聚合,详见 [hooks-and-priority.md §2](./hooks-and-priority.md#2-petstate-优先级)) | ✅ | ✅ | | 点击宠物本体 → 弹出"目录选择 + 输入"对话框 → 新开终端启动 CLI | ⛔ v0.1 不交付(详见 §12.5) | 视用户需求决定 | | 点击会话气泡 → 展开**只读**状态卡 / 在 Permission/AskUserQuestion 挂起时展开**可交互**卡片 | ✅ Permission Allow-Deny + AskUserQuestion 结构化答题(hook 同步回包,跨所有宿主);**不**支持气泡里自由输入消息(详见 §12.5) | 评估 PTY wrapper / IDE 扩展两条路 | | AskUserQuestion 触发 → 该 session 气泡自动展开为对话气泡,原位回答 | ✅ 通过 PermissionRequest hook + `updatedInput.answers` | ✅ | | 内置默认 Hopi 主题 | ✅ | ✅ | | `.hopettheme` 第三方主题导入 | — | ✅ | -| AI 工具 ↔ 主题绑定 | 全局单一主题 | 按 AI 工具分别绑定 | +| 主题切换(全局唯一) | ✅ | ✅ | | 宠物管理面板(Overview / Themes / Bindings / Hooks / Behavior / Notifications / About) | ✅ 骨架 | ✅ 完整 | | Hook 安装向导 + Doctor | ✅ | ✅ | | `hopet` CLI 伴侣 | — | ✅ | @@ -59,7 +59,7 @@ | **Hopet.app** | 用户可见的主 App,提供宠物渲染、管理面板、刘海条 UI。 | | **HopetCore** | 内置在 App 主进程内的守护子系统,负责 IPC、会话状态机、事件分发。 | | **Session** | 一个活跃的 AI CLI 会话(Claude Code / Codex 的一个运行实例),由 `sessionId` 唯一标识。 | -| **PetInstance** | 一只宠物的渲染实例,1:1 绑定一个 `AITool`(v0.1 即 Claude / Codex 各一只),状态由该 AI 下所有活跃 session 聚合得出。 | +| **PetInstance** | 全局唯一的宠物渲染实例。状态由所有 AI 工具下的所有活跃 session 聚合得出。 | | **SessionBubble** | 围绕宠物环绕的会话气泡,1:1 绑定一个 Session,承载 cwd / 标题 / 状态时长等元信息。 | | **ThemePackage** | 一个主题包,包含动画资源与 manifest.json。 | | **Hook 脚本** | AI 工具在状态转换点调用的 shell 脚本,把事件推送到 HopetCore。 | @@ -330,22 +330,21 @@ enum EventKind: String, Codable { ### 6.5 PetInstance 与 SessionBubble -**关键变化(v0.1 起)**:宠物按 **AI 工具** 实例化(Claude 一只、Codex 一只),不再按 session。每只宠物周围环绕若干 `SessionBubble`,每个气泡对应该 AI 下的一个活跃 session。 +**关键变化(v0.x 起)**:宠物**全局唯一**——所有 AI 工具的所有活跃 session 共用同一只宠物。宠物状态由全部 session 聚合得出(最高优先级胜出,见 §7.4)。`SessionBubble.tool` 字段保留作为来源元信息,但不再决定归属哪只宠物。 ```swift struct PetInstance: Identifiable { let id: UUID // 渲染实例 id - let tool: AITool // 关键:宠物绑定的是 AI 工具 var themeId: String var screenPosition: CGPoint // 宠物本体的中心坐标 var visible: Bool - var aggregatedState: PetState // 所有 session 中最高优先级状态(见 §7.4) + var aggregatedState: PetState // 全体活跃 session 中最高优先级状态(见 §7.4) var drivenBySessionId: String? // 当前驱动动画的那个 session(高亮该气泡) } struct SessionBubble: Identifiable { let id: String // = sessionId - let tool: AITool // 用于定位归属哪只宠物 + let tool: AITool // 仅作为来源元信息(气泡上的 chip / " 想执行此操作") var orbitAngle: Double // 气泡在宠物周围的角度位置 (0–360°) var orbitRing: Int // 第几环(默认 0;超过 6 个气泡时第二环为 1,依此类推) var displayTitle: String // 派生自 Session.title,再次截断到 18 字符 @@ -359,7 +358,7 @@ struct SessionBubble: Identifiable { 气泡布局算法、视觉规格见 §[12.4](#124-会话气泡布局算法)。 **气泡生命周期**: -- **创建**:`session_start` → 在该 AI 的宠物周围插入新气泡,按 `startedAt` 顺序顺时针排列 +- **创建**:`session_start` → 在唯一宠物周围插入新气泡,按 `startedAt` 顺序顺时针排列 - **更新**:`stateSince` 变化 → 重算 `displayElapsed`;`title` 变化 → 重算 `displayTitle` - **淘汰**:`session_end` 或 60 分钟无事件 → 气泡淡出动画 0.3s 后移除,剩余气泡重排 @@ -432,14 +431,9 @@ enum TransitionStyle: String, Codable { - 单个 state 缺帧 → 该 state fallback 到 `idle`,导入成功但 UI 显示警告徽章 - `ThemePackage` 不实现 `Codable`,避免与 manifest 来回切换的混乱 -### 6.7 Binding(AI ↔ 主题) +### 6.7 主题选择(全局单一) -```swift -struct ToolThemeBinding: Codable { - var tool: AITool - var themeId: String -} -``` +v0.x 起宠物全局唯一,主题也只有一个全局值——`HopetConfig.activeThemeId`。`ToolThemeBinding` 结构已废弃,未来若引入按 cwd / session 切主题,会单独建模,不会回到"按 AI 工具绑定"。 --- @@ -509,7 +503,7 @@ stateDiagram-v2 ### 7.4 宠物聚合状态(多 session → 单宠物) -宠物按 AI 工具实例化(每个 AI 一只),其 `aggregatedState` 等于该 AI 下所有活跃 session 的最高优先级状态。 +宠物全局唯一,其 `aggregatedState` 等于所有活跃 session(跨工具)的最高优先级状态。 **优先级表、聚合算法、tie-break 规则、边界情况** —— 单一权威来源在 [hooks-and-priority.md §2 与 §3](./hooks-and-priority.md#2-petstate-优先级)。本节仅描述触发与广播的 Combine 链路: @@ -520,7 +514,7 @@ Session.currentState 变化 SessionRegistry.didMutate(.session(id, newState)) │ ▼ -PetAggregator.recompute(tool: ..) ← 同步执行 +PetAggregator.recompute() ← 同步执行 │ ▼ PetInstance.aggregatedState 改变 @@ -686,33 +680,59 @@ hopet-emit --tool claude-code --event pre_tool_use \ 这与 §10.3 隐私边界一致。 -### 8.5 Codex Hook 脚本样例(v0.1 实验性) +### 8.5 Codex Hook 脚本样例(v0.2 起接入官方 hooks) -Codex CLI 当前没有 Claude Code 那种细粒度生命周期 hook 体系,主要通过 `~/.codex/config.toml` 的 `notify` 字段在每次 turn 完成时调用一个外部命令。 +Codex CLI 0.129.0-alpha 起公开了与 Claude 几乎一致的细粒度生命周期 hook 体系。启用方式:`~/.codex/config.toml` 设 `[features] codex_hooks = true`,hook 注册写入 `~/.codex/hooks.json`,顶层结构与 Claude `settings.json.hooks` 字典完全同形(`{ "hooks": { : [{ "hooks": [{ "type": "command", "command": "...", "timeout": }] }] } }`)。Hopet v0.2 起直接接入这套 hook,取代 v0.1 的 `[notify]` 完成通知。 -**v0.1 仅实现"完成通知实验支持"**,而非完整状态链路: +#### 8.5.1 `~/.codex/hooks.json` 片段(HopetHookKit 自动 merge) -```toml -# ~/.codex/config.toml (HopetHookKit 自动 merge) -[notify] -command = ["~/.hopet/bin/hopet-emit", "--tool", "codex", "--event", "stop"] +```json +{ + "hooks": { + "SessionStart": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event session_start", "timeout": 30 }] } + ], + "UserPromptSubmit": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event user_prompt", "timeout": 30 }] } + ], + "PreToolUse": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event pre_tool_use", "timeout": 30 }] } + ], + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event post_tool_use", "timeout": 30 }] } + ], + "PermissionRequest": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event permission_ask", "timeout": 590 }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "~/.hopet/bin/hopet-emit --tool codex --event stop", "timeout": 30 }] } + ] + } +} ``` -**v0.1 Codex 的能力边界**(写入 README 与 Onboarding): +合并策略与 Claude 一致:append Hopet 的 marker 条目、保留其它工具(clawd-on-desk 等)已经注册的条目;卸载时只剔除 hopet marker 的命令。`PermissionRequest` 的 timeout 给足够大的值(≈10 分钟)允许用户慢决策。 -| 状态 | 是否可识别 | 备注 | -| --- | --- | --- | -| `stop` / `completed` | ✅ | 来自 `notify` | -| `idle` | ✅ | `completed → 2s → idle` 自然回到 | -| `responding` / `thinking` / `tool-use` / `permission-prompt` | ❌ v0.1 | 无可靠事件源 | -| `ask-user` | ❌ v0.1 | 同上 | -| `error-interrupted` | ⚠️ | 仅当 `notify` payload 显式带 `status=error` 时识别 | +#### 8.5.2 `hopet-emit` 对 Codex payload 的差异处理 + +详细字段差异表见 [hooks-and-priority.md §1.2](./hooks-and-priority.md#12-codex-cli-实际订阅的-6-个-hook-v02)。`hopet-emit` 在 `--tool codex` 路径上额外做两件事: -**v0.2+ 升级路径**: -- 若 Codex 增加细粒度 hooks,由 `hopet-emit` 直接消费 -- 否则提供 `hopet codex` wrapper:以子进程方式包裹 `codex`,从 stdout/stderr 解析状态变化(实验性) +1. **`stop_hook_active=true` 静默退出**:Codex 的 Stop hook 同 Claude 一样有自递归保护标志,必须早退避免无限递归 +2. **`session_id` 兜底**:Codex 经常发空 `session_id`,但 `transcript_path` 形如 `rollout--.jsonl`,文件名 UUID 稳定唯一。空 sid 时从 transcript_path 抽 uuid 拼成 `codex-` 写回 payload,避免被 EventRouter 的 `anon-` 前缀逻辑当 subagent 丢弃 + +#### 8.5.3 Codex 能力边界(v0.2) + +| 状态 | 是否可识别 | 事件源 | +| --- | --- | --- | +| `idle` | ✅ | SessionStart | +| `responding` / `thinking` | ✅ | UserPromptSubmit + Core 8s 定时器升级 | +| `tool-use` | ✅ | PreToolUse / PostToolUse | +| `permission-prompt` | ✅ | PermissionRequest(同步回包) | +| `completed` | ✅ | Stop | +| `ask-user` | ❌ | Codex 无 AskUserQuestion 内置 tool | +| `error-interrupted` | ❌ | Codex 不分流错误 hook | -> v0.1 不承诺 Codex 的"正在回复"动画。在 Codex session 期间,宠物会处于 idle,直到 `notify` 触发 `completed` 动画。这是已知限制,需在功能矩阵 (features.md 2.1) 显式标注。 +**迁移:旧版 Hopet 写入的 `~/.codex/config.toml [notify]` 块** 在 install 时会被自动清理(识别 `# >>> hopet-managed >>>` / `# <<< hopet-managed <<<` 守卫行),避免 stop 事件被双发。 --- @@ -914,7 +934,7 @@ SpriteKit 实现为 `SpriteKitPetRenderer`,后续可新增 `Live2DPetRenderer` ### 12.4 会话气泡布局算法 -宠物按 AI 工具实例化(每个 AI 一只),周围环绕若干 `SessionBubble`,每个气泡 = 一个活跃 session。 +宠物全局唯一,周围环绕若干 `SessionBubble`,每个气泡 = 一个活跃 session(不区分来源 AI 工具)。 **布局参数**: @@ -980,7 +1000,7 @@ SpriteKit 实现为 `SpriteKitPetRenderer`,后续可新增 `Live2DPetRenderer` - [ ] `hopet-emit` Swift CLI helper(替代 shell JSON 拼接) - [ ] Claude Code hooks:SessionStart / SessionEnd / UserPromptSubmit / Pre/PostToolUse(含 AskUserQuestion 路由)/ PostToolUseFailure / PermissionRequest / Notification(filtered) / Stop / StopFailure(详见 [hooks-and-priority.md §1.1](./hooks-and-priority.md#11-v01-实际订阅的-8-个-hook)) - [ ] 内置 Hopi 主题(8 种状态动画各 8–16 帧) -- [ ] PetInstance 按 AI 实例化(Claude / Codex 各 1)+ SpriteKit 渲染 + 聚合状态切换(含 ask-user) +- [ ] PetInstance 全局唯一 + SpriteKit 渲染 + 聚合状态切换(含 ask-user) - [ ] SessionBubble 渲染(环绕布局、cwd / title / elapsed 显示、leader 高亮、AskUserQuestion 自动展开) - [ ] PetAggregator(按优先级聚合多 session → 单宠物动画) - [ ] NotchWindow 三态 + 无刘海机型降级顶条 @@ -993,7 +1013,7 @@ SpriteKit 实现为 `SpriteKitPetRenderer`,后续可新增 `Live2DPetRenderer` - ⛔ **气泡里自由打字往已有 session 注入消息**(详见 §12.5;macOS 无干净通用注入路径,需 PTY wrapper 或 IDE 扩展,留待后续版本评估) - ⛔ `.hopettheme` 第三方主题导入 -- ⛔ 按 AI 工具分别绑定主题(v0.1 全局单一) +- ⛔ 按 AI 工具实例化多只宠物(v0.x 起统一为全局单只) - ⛔ Codex 细粒度状态(v0.1 仅完成通知实验性) - ⛔ MCP `Elicitation` / `ElicitationResult` 路由(v0.2 接入) - ⛔ Sparkle 自动更新 @@ -1006,7 +1026,6 @@ SpriteKit 实现为 `SpriteKitPetRenderer`,后续可新增 `Live2DPetRenderer` - [ ] MCP `Elicitation` / `ElicitationResult` 路由到 ask_user / ask_user_resolved - [ ] 气泡拖拽重排(用户自定义环绕顺序) - [ ] `.hopettheme` 导入 + 主题管理 UI(含 §9.3.1 zip slip 防护) -- [ ] 按 AI 工具绑定主题 - [ ] `hopet` CLI 伴侣(doctor / theme / send) - [ ] Sparkle 2 + EdDSA 签名 diff --git a/devDocs/features.md b/devDocs/features.md index 8624be1..7370d7f 100644 --- a/devDocs/features.md +++ b/devDocs/features.md @@ -80,7 +80,7 @@ | **P6** | `completed` | Claude `Stop` hook;Codex `notify` | 开心拍鳍 + 小跳(非循环,1s) | 「完成 ✓」 | 可配置 | ✅ | | **P7** | `idle` | 无活跃 session / completed 后 2s | 趴坐眨眼,身体随呼吸起伏,偶尔轻拍短尾鳍 | 「Claude — Idle」 | — | ✅ | -**聚合规则**:宠物展示的是该 AI 下所有活跃 session 中**优先级最高**的那个状态(权威定义见 [hooks-and-priority.md §2](./hooks-and-priority.md#2-petstate-优先级))。宠物每只对应一个 AI 工具(Claude / Codex),不再随 session 数量增加。**leader session 的气泡边框会高亮**,让用户一眼看出当下宠物状态来自哪个 session。 +**聚合规则**:宠物展示的是所有活跃 session(跨所有 AI 工具)中**优先级最高**的那个状态(权威定义见 [hooks-and-priority.md §2](./hooks-and-priority.md#2-petstate-优先级))。Hopet 全局只有一只宠物,不再随 session 数量或工具数量增加。**leader session 的气泡边框会高亮**,让用户一眼看出当下宠物状态来自哪个 session。 动画切换采用 `AnimationController` 的 `cross-dissolve` 0.2s 过渡;`completed` → `idle` 为 `fade` 过渡。 @@ -123,7 +123,7 @@ stateDiagram-v2 ### 3.3 桌面宠物本体 -每个 AI 工具实例化**一只**宠物(v0.1:Claude 一只、Codex 一只,最多两只),而非每个会话一只。宠物的状态 = 该 AI 下所有活跃 session 的最高优先级状态(聚合规则见 [hooks-and-priority.md §2-3](./hooks-and-priority.md#2-petstate-优先级))。 +Hopet **全局只有一只**宠物,所有 AI 工具(Claude / Codex / 未来其它)的所有活跃 session 共用之。宠物状态 = 所有活跃 session 中最高优先级的那个 session 状态(聚合规则见 [hooks-and-priority.md §2-3](./hooks-and-priority.md#2-petstate-优先级))。 #### 3.3.1 窗口行为 @@ -134,24 +134,22 @@ stateDiagram-v2 #### 3.3.2 位置与拖拽 -- 启动时从 `config.json` 读取 `lastPosition[tool]`;首次启动 Claude 宠物在主屏右下、Codex 宠物在右下偏左 200px +- 启动时从 `config.json` 读取 `lastPosition`;首次启动宠物默认在主屏右下 - 长按 0.2s 进入拖拽态;松开吸附到最近的屏幕边缘(可关闭吸附) - 拖动宠物时**会话气泡跟随移动**(保持环绕几何) - 拖出屏幕时自动 clamp 回可见区 -#### 3.3.3 双宠物排布 +#### 3.3.3 无活跃 session 时 -- v0.1 最多两只宠物(Claude / Codex) -- 二者位置相互独立,由用户拖拽决定;首次启动给出默认间距避免重叠 -- 一只宠物的某个 AI 完全没有活跃 session 时,宠物保持显示(显示为 idle,作为"快速开新会话"入口),可在偏好里设为"无 session 时隐藏" +宠物保持显示为 idle(作为"看一眼当前状态"的窗口),可在偏好里设为"无 session 时隐藏"。 #### 3.3.4 交互反馈 | 操作 | 反馈 | | --- | --- | -| 鼠标悬停(≥400ms) | 宠物头顶显示小 tooltip:AI 工具名 + 当前 leader session 标题 + 当前聚合状态 | +| 鼠标悬停(≥400ms) | 宠物头顶显示小 tooltip:当前 leader session 标题 + 当前聚合状态 | | 左键单击宠物本体 | v0.1 无操作(曾用于"新开 session",已移除) | -| 右键点击宠物 | 弹出 context menu:显示/隐藏 Codex 宠物、切换主题、关闭所有 session、打开管理面板 | +| 右键点击宠物 | 弹出 context menu:显示/隐藏宠物、切换主题、关闭所有 session、打开管理面板 | | 长按(≥0.2s) | 进入拖拽 | | 鼠标悬停某个气泡 | 气泡放大 1.1×、显示完整 cwd 路径 tooltip | | **左键单击气泡** | 气泡展开为只读状态卡(标题/cwd/状态/耗时);Permission/AskUserQuestion 挂起时自动展开为可交互卡片(见 §3.4.1 / §3.4.2) | @@ -225,7 +223,7 @@ v0.1 只有两个用户输入入口,都建立在 **Claude 主动开口**(hoo ### 3.5 会话气泡(Session Bubbles) -每只宠物周围环绕若干圆形气泡,每个气泡 = 一个活跃 session。气泡是宠物身份信息的最小载体,提供 cwd / 标题 / 距上次状态变更耗时等关键元信息。 +宠物周围环绕若干圆形气泡,每个气泡 = 一个活跃 session(跨 AI 工具)。气泡是宠物身份信息的最小载体,提供 cwd / 标题 / 距上次状态变更耗时等关键元信息;来源工具通过气泡上的 chip 区分。 #### 3.5.1 默认显示内容 @@ -297,7 +295,7 @@ v0.1 只有两个用户输入入口,都建立在 **Claude 主动开口**(hoo #### 3.6.1 Overview -- 当前两只宠物的卡片(Claude / Codex):缩略图、聚合状态徽章、活跃 session 数、可见性开关、定位按钮(让宠物闪烁 2s) +- 全局宠物卡片:缩略图、聚合状态徽章、活跃 session 数、可见性开关、定位按钮(让宠物闪烁 2s) - 下方 Sessions 列表:每个 session 一行(cwd / title / state / elapsed),可关闭某个 session - 空状态提示 + "如何开始" 链接(跳到 Hook 安装 Tab) From d0f17437cdea8a311dba8662f36728914870081e Mon Sep 17 00:00:00 2001 From: BinaryFroggy Date: Mon, 11 May 2026 17:33:45 +0800 Subject: [PATCH 2/5] feat(hookkit,emit): switch Codex from [notify] to ~/.codex/hooks.json Codex CLI 0.129.0+ ships fine-grained lifecycle hooks via ~/.codex/hooks.json (features.codex_hooks=true), matching Claude's hook shape. Replace the v0.1 [notify]-only completion path with the full event set so Codex sessions now drive PetState transitions the same way Claude does. HookInstaller merges Hopet's entries into hooks.json while preserving third-party tool registrations, and on install strips any legacy [notify] block from config.toml so a single stop event no longer double-fires through both channels. hopet-emit gains a Codex-format transcript tail parser; the rest of its event plumbing is shared. See devDocs/hooks-and-priority.md. --- Sources/Hopet/HookKit/HookDoctor.swift | 2 +- Sources/Hopet/HookKit/HookInstaller.swift | 109 +++++++++--- .../Hopet/HookKit/HookScriptTemplates.swift | 41 ++++- Sources/hopet-emit/main.swift | 157 ++++++++++++++++-- devDocs/hooks-and-priority.md | 53 ++++-- 5 files changed, 311 insertions(+), 51 deletions(-) diff --git a/Sources/Hopet/HookKit/HookDoctor.swift b/Sources/Hopet/HookKit/HookDoctor.swift index deb55c6..5e5864e 100644 --- a/Sources/Hopet/HookKit/HookDoctor.swift +++ b/Sources/Hopet/HookKit/HookDoctor.swift @@ -22,7 +22,7 @@ public enum HookDoctor { // Tools lines.append("Claude hooks installed: \(installer.isInstalled(.claudeCode) ? "yes" : "no")") - lines.append("Codex notify installed: \(installer.isInstalled(.codex) ? "yes" : "no")") + lines.append("Codex hooks installed: \(installer.isInstalled(.codex) ? "yes" : "no")") return lines.joined(separator: "\n") } diff --git a/Sources/Hopet/HookKit/HookInstaller.swift b/Sources/Hopet/HookKit/HookInstaller.swift index e4cf69a..61cc080 100644 --- a/Sources/Hopet/HookKit/HookInstaller.swift +++ b/Sources/Hopet/HookKit/HookInstaller.swift @@ -9,6 +9,12 @@ public final class HookInstaller { .appendingPathComponent(".claude/settings.json") } + private var codexHooksFile: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/hooks.json") + } + + /// 仅在 install/uninstall 时用于清理历史 `[notify]` 块;不再作为事件源。 private var codexConfig: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex/config.toml") @@ -27,8 +33,9 @@ public final class HookInstaller { guard let hooks = json["hooks"] as? [String: Any] else { return false } return containsHopetMarker(in: hooks) case .codex: - guard let text = try? String(contentsOf: codexConfig) else { return false } - return text.contains(HookScriptTemplates.hopetMarker) + guard let json = try? readCodexHooks(), + let hooks = json["hooks"] as? [String: Any] else { return false } + return containsHopetMarker(in: hooks) case .custom: return false } @@ -50,7 +57,7 @@ public final class HookInstaller { switch tool { case .claudeCode: return try uninstallClaude() case .codex: return try uninstallCodex() - case .custom: return codexConfig + case .custom: return codexHooksFile } } @@ -129,35 +136,85 @@ public final class HookInstaller { } // MARK: - Codex + // + // Codex CLI 0.129.0+ 用 `~/.codex/hooks.json` 注册生命周期 hook(需 features.codex_hooks)。 + // 安装时顺手清掉 config.toml 里 v0.1 留下的 [notify] 块,避免 stop 事件双发触发两次 + // completed 切换。See devDocs/hooks-and-priority.md. private func installCodex() throws -> URL { - try FileManager.default.createDirectory(at: codexConfig.deletingLastPathComponent(), + try FileManager.default.createDirectory(at: codexHooksFile.deletingLastPathComponent(), withIntermediateDirectories: true) - try backup(codexConfig) + var json = (try? readCodexHooks()) ?? [:] + try backup(codexHooksFile) - let args = HookScriptTemplates.codexNotifyArguments(emitPath: emitPath) - let arrayLit = "[" + args.map { "\"\($0)\"" }.joined(separator: ", ") + "]" - let block = """ - # >>> hopet-managed >>> - [notify] - command = \(arrayLit) - # <<< hopet-managed <<< - """ + var hooks = (json["hooks"] as? [String: Any]) ?? [:] + let hopetEntries = HookScriptTemplates.codexHooks(emitPath: emitPath) - var existing = (try? String(contentsOf: codexConfig)) ?? "" - existing = stripHopetBlock(existing) - if !existing.hasSuffix("\n") && !existing.isEmpty { existing += "\n" } - existing += block + "\n" - try existing.write(to: codexConfig, atomically: true, encoding: .utf8) - return codexConfig + for (key, hopetItems) in hopetEntries { + var existing = (hooks[key] as? [Any]) ?? [] + existing = existing.filter { !isHopetEntry($0) } + existing.append(contentsOf: hopetItems) + hooks[key] = existing + } + json["hooks"] = hooks + + let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: codexHooksFile, options: .atomic) + + // 迁移:剔除 v0.1 在 config.toml 中写的 [notify] 块。 + stripLegacyCodexNotify() + + return codexHooksFile } private func uninstallCodex() throws -> URL { - guard var existing = try? String(contentsOf: codexConfig) else { return codexConfig } - try backup(codexConfig) - existing = stripHopetBlock(existing) - try existing.write(to: codexConfig, atomically: true, encoding: .utf8) - return codexConfig + // 同时清 hooks.json 中 Hopet 条目与 config.toml 历史 notify 块——卸载语义是 + // "把 Hopet 之前留下的 codex 接入全部撤掉",两条路径都要兜底。 + stripLegacyCodexNotify() + + guard var json = try? readCodexHooks(), + var hooks = json["hooks"] as? [String: Any] else { + return codexHooksFile + } + try backup(codexHooksFile) + for (key, value) in hooks { + guard let arr = value as? [Any] else { continue } + let filtered = arr.filter { !isHopetEntry($0) } + if filtered.isEmpty { + hooks.removeValue(forKey: key) + } else { + hooks[key] = filtered + } + } + if hooks.isEmpty { + json.removeValue(forKey: "hooks") + } else { + json["hooks"] = hooks + } + if json.isEmpty { + // 整个 hooks.json 只剩 Hopet 时直接删除文件,避免留空对象。 + try? FileManager.default.removeItem(at: codexHooksFile) + } else { + let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: codexHooksFile, options: .atomic) + } + return codexHooksFile + } + + private func readCodexHooks() throws -> [String: Any] { + guard let data = try? Data(contentsOf: codexHooksFile), !data.isEmpty else { return [:] } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } + return json + } + + /// 清掉 `~/.codex/config.toml` 中 hopet 历史注入的 `[notify]` 块。文件本身不一定 + /// 存在(v0.1 没装过 Hopet 的用户),不存在就静默跳过。 + private func stripLegacyCodexNotify() { + guard let existing = try? String(contentsOf: codexConfig) else { return } + guard existing.contains(HookScriptTemplates.codexNotifyBlockBegin) else { return } + try? backup(codexConfig) + let stripped = stripHopetBlock(existing) + try? stripped.write(to: codexConfig, atomically: true, encoding: .utf8) } private func stripHopetBlock(_ text: String) -> String { @@ -165,8 +222,8 @@ public final class HookInstaller { var out: [String] = [] var inside = false for line in lines { - if line.contains("# >>> hopet-managed >>>") { inside = true; continue } - if line.contains("# <<< hopet-managed <<<") { inside = false; continue } + if line.contains(HookScriptTemplates.codexNotifyBlockBegin) { inside = true; continue } + if line.contains(HookScriptTemplates.codexNotifyBlockEnd) { inside = false; continue } if !inside { out.append(line) } } return out.joined(separator: "\n") diff --git a/Sources/Hopet/HookKit/HookScriptTemplates.swift b/Sources/Hopet/HookKit/HookScriptTemplates.swift index 0b16563..02bca2f 100644 --- a/Sources/Hopet/HookKit/HookScriptTemplates.swift +++ b/Sources/Hopet/HookKit/HookScriptTemplates.swift @@ -43,11 +43,46 @@ enum HookScriptTemplates { ] } - /// Codex `notify` 字段(架构文档 §8.5)。 - static func codexNotifyArguments(emitPath: String) -> [String] { - [emitPath, "--tool", "codex", "--event", "stop"] + /// Hopet 注入到 ~/.codex/hooks.json `hooks` 字典里的全部条目(架构文档 §8.5)。 + /// + /// Codex CLI 0.129.0-alpha 起公开了与 Claude Code 几乎一致的细粒度生命周期 hook + /// (`[features] codex_hooks = true` 启用,文件 `~/.codex/hooks.json`)。共 6 个事件, + /// 无 SessionEnd / AskUserQuestion / PostToolUseFailure / StopFailure,因此映射比 Claude 精简。 + static func codexHooks(emitPath: String) -> [String: [[String: Any]]] { + func entry(_ command: String, timeout: Int) -> [String: Any] { + ["hooks": [["type": "command", "command": command, "timeout": timeout]]] + } + // PermissionRequest 走双向同步回包,用户可能长时间不点。Codex 跟 Claude 一样 + // 把 hook timeout 当上限——给一个足够大的值让用户自由决策;其它非阻塞 hook 30s 够用。 + let permissionTimeout = 590 + let stateTimeout = 30 + return [ + "SessionStart": [ + entry("\(emitPath) --tool codex --event session_start", timeout: stateTimeout) + ], + "UserPromptSubmit": [ + entry("\(emitPath) --tool codex --event user_prompt", timeout: stateTimeout) + ], + "PreToolUse": [ + entry("\(emitPath) --tool codex --event pre_tool_use", timeout: stateTimeout) + ], + "PostToolUse": [ + entry("\(emitPath) --tool codex --event post_tool_use", timeout: stateTimeout) + ], + "PermissionRequest": [ + entry("\(emitPath) --tool codex --event permission_ask", timeout: permissionTimeout) + ], + "Stop": [ + entry("\(emitPath) --tool codex --event stop", timeout: stateTimeout) + ] + ] } /// 区分 "Hopet 写的" 与 "用户写的" 的标记字符串。卸载时用它筛选 hopet-emit 命令。 static let hopetMarker = "/.hopet/bin/hopet-emit" + + /// `~/.codex/config.toml` 里旧版 `[notify]` 块的起止标记。v0.2 起 Codex 走 hooks.json, + /// install 时顺手清掉历史 notify 注入,避免一个 stop 事件被 notify + hooks.json 双发。 + static let codexNotifyBlockBegin = "# >>> hopet-managed >>>" + static let codexNotifyBlockEnd = "# <<< hopet-managed <<<" } diff --git a/Sources/hopet-emit/main.swift b/Sources/hopet-emit/main.swift index 91b8513..646549e 100644 --- a/Sources/hopet-emit/main.swift +++ b/Sources/hopet-emit/main.swift @@ -91,7 +91,7 @@ guard let toolRaw = args.tool, let eventRaw = args.event else { // 配对的 PostToolUse 因此永远不到 cancelPending,权限气泡卡死、宠物状态不切。 let stdin = FileHandle.standardInput let stdinData = stdin.readDataToEndOfFile() -let hookJson: [String: Any] +var hookJson: [String: Any] if stdinData.isEmpty { hookJson = [:] } else if let obj = try? JSONSerialization.jsonObject(with: stdinData) as? [String: Any] { @@ -100,6 +100,26 @@ if stdinData.isEmpty { hookJson = [:] } +// MARK: - Codex rollout 文件名解析 +// +// Codex 把 session 持久化到 `~/.codex/sessions/.../rollout--.jsonl`,hook payload +// 的 `transcript_path` 就是这个路径。session_id 字段经常为空,但文件名的 UUID 是稳定唯一的。 +// 提取 UUID 用作兜底 sessionId,避免 EventRouter 把 anon-XXXX 当 subagent 丢。 +private let codexRolloutUuidRegex: NSRegularExpression = { + let pattern = "rollout-.+-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\\.jsonl$" + return try! NSRegularExpression(pattern: pattern) +}() + +func extractCodexSessionUuid(fromTranscriptPath path: String) -> String? { + let fileName = (path as NSString).lastPathComponent + let range = NSRange(fileName.startIndex..., in: fileName) + guard let match = codexRolloutUuidRegex.firstMatch(in: fileName, range: range), + match.numberOfRanges >= 2, + let r = Range(match.range(at: 1), in: fileName) + else { return nil } + return String(fileName[r]) +} + // MARK: - Field path lookup (dotted path) func value(at path: String, in dict: [String: Any]) -> Any? { @@ -120,6 +140,28 @@ func stringify(_ any: Any?) -> String? { return nil } +// 这里必须先剥再截断到 256:选中的代码常常超过 256 字符,截掉闭合标签后 EventRouter +// 的正则匹配不到,title 就会变成 "The user selected...". 与 +// `EventRouter.stripLeadingContextTags` 是同一份正则——AGENTS.md §2.1 禁止 hopet-emit +// 反向依赖主 App 符号,两份需要同步修改。 +private let leadingContextTagRegex: NSRegularExpression = { + let pattern = "\\A\\s*<([A-Za-z][A-Za-z0-9_-]*)(?:\\s[^>]*)?>[\\s\\S]*?\\s*" + return try! NSRegularExpression(pattern: pattern) +}() + +func stripLeadingContextTags(_ raw: String) -> String { + var s = raw + while true { + let range = NSRange(s.startIndex..., in: s) + guard let match = leadingContextTagRegex.firstMatch(in: s, range: range), + match.range.location == 0, + let r = Range(match.range, in: s) + else { break } + s = String(s[r.upperBound...]) + } + return s.trimmingCharacters(in: .whitespacesAndNewlines) +} + // --require / --exclude for (k, v) in args.requires { guard stringify(value(at: k, in: hookJson)) == v else { exit(0) } @@ -128,6 +170,26 @@ for (k, v) in args.excludes { if stringify(value(at: k, in: hookJson)) == v { exit(0) } } +// MARK: - Codex 防递归 / session_id 兜底 +// +// Codex 的 Stop hook 跟 Claude 一样有自递归保护标志:当本次 hook 是 stop_hook_active=true +// 触发的,必须静默退出,否则会产生无限递归调用。 +// +// 同时 Codex 经常发空 session_id;真值需从 transcript_path 抽,文件名形如 +// `rollout--.jsonl`。抓到 uuid 后写回 hookJson.session_id, +// 让下方通用 sessionId 解析自然走最高优先级分支。 +if toolRaw == "codex" { + if let active = value(at: "stop_hook_active", in: hookJson) as? Bool, active { + exit(0) + } + let existingSid = stringify(value(at: "session_id", in: hookJson)) ?? "" + if existingSid.isEmpty, + let tp = stringify(value(at: "transcript_path", in: hookJson)), + let uuid = extractCodexSessionUuid(fromTranscriptPath: tp) { + hookJson["session_id"] = "codex-\(uuid)" + } +} + // MARK: - Build StateEvent payload (whitelist fields only) let allowedKeys: [String] = [ @@ -157,6 +219,12 @@ let allowedKeys: [String] = [ var safePayload: [String: Any] = [:] for path in allowedKeys { if var v = value(at: path, in: hookJson) { + // prompt 字段先剥前导上下文标签块,再走通用截断。全是标签时回退到原文, + // 保留至少能看到点东西的下限。 + if path == "prompt", let s = v as? String { + let stripped = stripLeadingContextTags(s) + v = stripped.isEmpty ? s : stripped + } if let s = v as? String, s.count > 256 { v = String(s.prefix(256)) } @@ -265,23 +333,87 @@ func lastAssistantText(fromTranscriptAt path: String) -> String? { return nil } -/// 等 transcript 写入本轮 end_turn 行后再抽 assistant text。 +/// Codex transcript 格式与 Claude 不同(详见下方解析器内的注释)。从尾向前扫, +/// 找最后一条 `phase == "final_answer"` 的 assistant message,拼接所有 +/// `output_text` 块;命中 user 行(真用户提问)则提前返回 nil 让上游重试。 +func lastCodexAssistantText(fromTranscriptAt path: String) -> String? { + guard let handle = FileHandle(forReadingAtPath: path) else { return nil } + defer { try? handle.close() } + + let size: UInt64 + do { size = try handle.seekToEnd() } catch { return nil } + let readLen = Int(min(UInt64(transcriptTailBudget), size)) + let startOffset = size - UInt64(readLen) + do { try handle.seek(toOffset: startOffset) } catch { return nil } + guard let data = try? handle.read(upToCount: readLen), + let raw = String(data: data, encoding: .utf8) else { return nil } + + let text: Substring + if startOffset > 0, let firstNewline = raw.firstIndex(of: "\n") { + text = raw[raw.index(after: firstNewline)...] + } else { + text = Substring(raw) + } + + // Codex `rollout-*.jsonl` 行结构示例: + // { "timestamp": ..., "type": "response_item", + // "payload": { "type": "message", "role": "assistant", + // "content": [{ "type": "output_text", "text": "..." }], + // "phase": "final_answer" } } + // { "type": "event_msg", "payload": { "type": "task_started", ... } } + // { "type": "response_item", + // "payload": { "type": "message", "role": "user", "content": [...] } } + // + // 扫描终止条件: + // - 命中 payload.role=="assistant" && payload.phase=="final_answer":返回该行 text。 + // - 命中 payload.role=="user" 的 response_item:本轮 final_answer 还没刷盘,立即 nil 让上游重试。 + // - event_msg / 中间 message(含 reasoning / tool_call):跳过。 + let lines = text.split(separator: "\n", omittingEmptySubsequences: true) + for line in lines.reversed() { + guard let lineData = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] + else { continue } + guard (obj["type"] as? String) == "response_item", + let payload = obj["payload"] as? [String: Any], + (payload["type"] as? String) == "message" + else { continue } + let role = payload["role"] as? String + if role == "user" { + // 真用户提问行(Codex 不会用 tool_result 占据 user 位):本轮 final 未到。 + return nil + } + guard role == "assistant" else { continue } + guard (payload["phase"] as? String) == "final_answer" else { continue } + guard let content = payload["content"] as? [[String: Any]] else { continue } + var pieces: [String] = [] + for block in content { + guard (block["type"] as? String) == "output_text", + let t = block["text"] as? String else { continue } + pieces.append(t) + } + let joined = pieces.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !joined.isEmpty { return joined } + } + return nil +} + +/// 等 transcript 写入本轮 end_turn / final_answer 行后再抽 assistant text。 /// /// Stop hook 触发到文件 flush 之间存在毫秒级(甚至秒级)滞后,且滞后量随磁盘 IO、 -/// Claude Code 版本、系统负载漂移。固定时长 polling 不可靠,改用 fsevents 监听 -/// 文件 write,事件即重读,找到本轮 end_turn 立刻返回。 +/// CLI 版本、系统负载漂移。固定时长 polling 不可靠,改用 fsevents 监听文件 write, +/// 事件即重读,找到本轮终态立刻返回。 /// /// 流程: /// 1. 立即同步读一次(transcript 已 flush 完的常见路径走这里)。 /// 2. 没读到则打开 fd + DispatchSource 监听 write/extend/delete/rename。 /// 3. resume 后再 async 读一次(catch-up:覆盖 register 与文件写入之间错过事件的窗口)。 -/// 4. 等 semaphore,超时(默认 5s)兜底——Claude 异常路径下 lastAssistantMessage -/// 宁可留空也不挂住 Stop hook。 +/// 4. 等 semaphore,超时(默认 5s)兜底——异常路径下 lastAssistantMessage 宁可留空 +/// 也不挂住 Stop hook。 /// /// 同一 serial queue 跑 event handler 与 catch-up read,settled 标志只在 queue 内 -/// 修改,无需额外锁。 -func waitForLastAssistantText(transcriptPath: String, timeout: TimeInterval) -> String? { - if let found = lastAssistantText(fromTranscriptAt: transcriptPath) { +/// 修改,无需额外锁。`parser` 是 Claude / Codex 专属的同步解析器之一。 +func waitForLastAssistantText(transcriptPath: String, timeout: TimeInterval, parser: @escaping (String) -> String?) -> String? { + if let found = parser(transcriptPath) { return found } @@ -300,7 +432,7 @@ func waitForLastAssistantText(transcriptPath: String, timeout: TimeInterval) -> let tryReadAndSignal: () -> Void = { if settled { return } - if let found = lastAssistantText(fromTranscriptAt: transcriptPath) { + if let found = parser(transcriptPath) { result = found settled = true sem.signal() @@ -325,7 +457,10 @@ if eventRaw == "stop" { if (raw ?? "").isEmpty, let tp = stringify(value(at: "transcript_path", in: hookJson)), !tp.isEmpty { - raw = waitForLastAssistantText(transcriptPath: tp, timeout: 5.0) + let parser: (String) -> String? = toolRaw == "codex" + ? lastCodexAssistantText(fromTranscriptAt:) + : lastAssistantText(fromTranscriptAt:) + raw = waitForLastAssistantText(transcriptPath: tp, timeout: 5.0, parser: parser) } if var msg = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { // 多行折叠成单空格:UI 第二行只展示开头,让换行白白吃掉显示长度不划算。 diff --git a/devDocs/hooks-and-priority.md b/devDocs/hooks-and-priority.md index fda0b9d..b298cae 100644 --- a/devDocs/hooks-and-priority.md +++ b/devDocs/hooks-and-priority.md @@ -48,7 +48,7 @@ | 27 | `Elicitation` | MCP server 请求用户输入 | `server` 名称 | ✅ accept/decline/cancel | ⚠️ v0.1 不订阅(路由仅含 AskUserQuestion);v0.2 同样路由到 `ask_user` | | 28 | `ElicitationResult` | 用户响应 MCP elicitation | `server`、`content` | ✅ override/block | ⚠️ v0.1 不订阅;v0.2 路由到 `ask_user_resolved` | -### 1.1 v0.1 实际订阅的 8 个 hook +### 1.1 v0.1 Claude Code 实际订阅的 8 个 hook ``` SessionStart → session_start @@ -65,11 +65,44 @@ StopFailure → error > 注:`PostToolUseFailure` / `StopFailure` 是从训练知识里漏掉的事件,把它们纳入后 `error-interrupted` 状态在 v0.1 即拥有真实事件源,无需等 v0.2。 +### 1.2 Codex CLI 实际订阅的 6 个 hook(v0.2+) + +Codex CLI 0.129.0-alpha 起公开了与 Claude 几乎一致的细粒度生命周期 hook 体系(启用方式:`~/.codex/config.toml` 设 `[features] codex_hooks = true`,配置文件 `~/.codex/hooks.json`,结构与 Claude `hooks` 字典同形)。Hopet v0.2 起直接接入这套 hook,取代 v0.1 仅有的 `[notify]` 完成通知。 + +``` +SessionStart → session_start +UserPromptSubmit → user_prompt +PreToolUse → pre_tool_use +PostToolUse → post_tool_use +PermissionRequest → permission_ask +Stop → stop +``` + +Codex 当前**不暴露**这些 Claude 有的事件,因此 Hopet 不订阅、靠 IPC 端的其它路径兜底: + +- `SessionEnd` — 用架构 §7.3 的"60 分钟无事件回收"路径替代 +- `AskUserQuestion` — Codex 没有这个内置 tool;问询场景由 `PermissionRequest` 承载 +- `PostToolUseFailure` / `StopFailure` — Codex 不分流错误事件 +- `Notification` / `Compact` / `Subagent*` / `Task*` — Codex 不发 + +**Payload 命名差异**: + +| 字段 | Claude | Codex | hopet-emit 处理 | +| --- | --- | --- | --- | +| 事件名 | `hookEventName`(camelCase) | `hook_event_name`(snake_case) | 不读取(白名单未含此字段) | +| session id | `session_id` | `session_id`(**常为空串**) | Codex 路径下空时从 `transcript_path` 的 `rollout--.jsonl` 抽 uuid 兜底成 `codex-` | +| 工具相关 | `tool_name` / `tool_input` / `tool_use_id` | 同 | 直接复用白名单 | +| Stop 防递归 | — | `stop_hook_active`(true 时必须静默退出,否则递归) | Codex 路径下识别后 `exit 0` | + +**Permission 回包格式**:Codex 与 Claude 一致,输出 `{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow"|"deny", message? } } }`,`hopet-emit.emitDecision()` 现成可用。 + +> 已知体验注意点:若同机器上 Codex `hooks.json` 同时被多个工具(如 clawd-on-desk)注册了 `PermissionRequest`,多个 hook 会并发收到事件并各自请求决策。Hopet 的合并策略保留其它工具条目(只 append/卸载自己的 marker 行),不主动清理别人——多端决策的优先级由 Codex 内部规则决定。 + --- ## 2. PetState 优先级 -新设计中宠物按 **AI 工具** 而非 session 实例化,宠物所展示的 `aggregatedState` 等于其下所有活跃 session 中**优先级最高**的那个 session 状态。 +新设计中宠物**全局唯一**,所有 AI 工具的所有活跃 session 共用一只宠物;宠物展示的 `aggregatedState` 等于所有活跃 session 中**优先级最高**的那个 session 状态。 | 优先级 | PetState | 含义 | 选定理由 | | --- | --- | --- | --- | @@ -93,7 +126,7 @@ StopFailure → error ### 2.2 同优先级的 tie-break -当同一 AI 下多个 session 处于同一优先级状态时: +当多个 session(无论来自哪个 AI 工具)处于同一优先级状态时: 1. 先按 `stateSince` 时间倒序(最近变更的在前) 2. 再按 `sessionId` 字母序(确定性) @@ -106,16 +139,16 @@ StopFailure → error ### 3.1 触发条件 -每当任何 session 的 `currentState` 变化、任何 session 被加入或移除、Core 定时器把 `responding` 升级为 `thinking`,都会触发对应宠物的重新聚合。 +每当任何 session 的 `currentState` 变化、任何 session 被加入或移除、Core 定时器把 `responding` 升级为 `thinking`,都会触发宠物重新聚合。 ### 3.2 算法 ```swift -func recomputeAggregatedState(for tool: AITool) { - let sessions = registry.activeSessions(of: tool) +func recomputeAggregatedState() { + let sessions = registry.activeSessions // 跨所有 AI 工具的活跃 session guard !sessions.isEmpty else { - pet(of: tool).aggregatedState = .idle // 无活跃会话 → idle - pet(of: tool).drivenBySessionId = nil + pet.aggregatedState = .idle // 无活跃会话 → idle + pet.drivenBySessionId = nil return } @@ -127,8 +160,8 @@ func recomputeAggregatedState(for tool: AITool) { return a.id < b.id } let leader = sorted.first! - pet(of: tool).aggregatedState = leader.currentState - pet(of: tool).drivenBySessionId = leader.id + pet.aggregatedState = leader.currentState + pet.drivenBySessionId = leader.id } ``` From 5aeb868b9f103326ce29cb364364371421649181 Mon Sep 17 00:00:00 2001 From: BinaryFroggy Date: Mon, 11 May 2026 17:34:07 +0800 Subject: [PATCH 3/5] feat(core,app,panel): add listener soft-mute toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the v0.1 listener model (toggle = install/uninstall hooks) with a soft-mute: hooks are now unconditionally installed at boot, and the Hooks tab toggle only flips an EventRouter gate. When off, EventRouter silently drops events from that tool (sync requests reply nil so Claude/Codex fall back to their built-in UI) and SceneRouter sweeps the registry to remove that tool's idle bubbles. Bubbles with pending permission/askUser are kept so the user can finish the current decision; once pending clears, the next mutation re-sweeps and drops them. HopetConfig.Listeners gains a subscript over AITool so call sites stop open-coding the same switch in three places. `AITool.recognized` becomes the canonical iteration target for hook install + toggle UI. See devDocs/preferences.md §11.6. --- Sources/Hopet/App/SceneRouter.swift | 77 ++++++++++++++++++++-------- Sources/Hopet/Core/HopetConfig.swift | 25 ++++++++- Sources/Hopet/IPC/EventRouter.swift | 39 ++++++++++++-- Sources/Hopet/Models/AITool.swift | 7 ++- Sources/Hopet/Panel/HooksTab.swift | 61 +++++++++------------- devDocs/preferences.md | 6 +-- 6 files changed, 148 insertions(+), 67 deletions(-) diff --git a/Sources/Hopet/App/SceneRouter.swift b/Sources/Hopet/App/SceneRouter.swift index 334bf2c..01d0692 100644 --- a/Sources/Hopet/App/SceneRouter.swift +++ b/Sources/Hopet/App/SceneRouter.swift @@ -39,7 +39,13 @@ public final class SceneRouter { self.thinkingTimer = ThinkingTimer(registry: registry) self.decayTimer = CompletedDecayTimer(registry: registry) self.permissionPrompter = prompter - self.router = EventRouter(registry: registry, permissionPrompter: prompter) + self.router = EventRouter( + registry: registry, + permissionPrompter: prompter, + isToolListening: { [weak configStore] tool in + configStore?.current.listeners[tool] ?? true + } + ) self.petWindowController = PetWindowController( registry: registry, @@ -67,19 +73,39 @@ public final class SceneRouter { } applyAppearance(configStore.current.appearance) - applyListeners(configStore.current.listeners) + ensureHooksInstalled() themes.reload() - // 监听 config 变化:appearance 即时应用,listeners 在 Toggle 翻转时同步装/卸 hooks。 configStore.$current .map(\.appearance) .removeDuplicates() .sink { [weak self] in self?.applyAppearance($0) } .store(in: &configCancellables) + + // @Published 在 willSet 阶段 emit——sink 拿到的 configStore.current 仍是旧值, + // 必须用闭包参数里的新 listeners,否则 toggle off 后 sweep 看到的还是 on。 configStore.$current .map(\.listeners) .removeDuplicates() - .sink { [weak self] in self?.applyListeners($0) } + .sink { [weak self] listeners in self?.enforceListenerMute(listeners: listeners) } + .store(in: &configCancellables) + + // pending 清空 / 状态切换时再扫一遍,让 toggle off 期间挂起的会话在挂起 + // 解除瞬间被清掉。.added 不会触发 mute,.removed 会无限递归——都跳过。 + registry.mutations + .compactMap { mut -> Void? in + switch mut { + case .fieldsUpdated, .stateChanged: return () + case .added, .removed: return nil + } + } + .sink { [weak self] _ in + guard let self else { return } + let listeners = self.configStore.current.listeners + // 两个 listener 都开着时无人会被静音,跳过 activeSessions 扫描。 + guard !listeners.claudeCode || !listeners.codex else { return } + self.enforceListenerMute(listeners: listeners) + } .store(in: &configCancellables) let router = self.router @@ -122,26 +148,37 @@ public final class SceneRouter { } } - /// 仅当 listener 当前安装态与目标不一致时调用 install/uninstall,避免每次启动重写文件。 - /// Codex 路径失败仅 warn,不影响 Claude 安装与启动(见 preferences.md §6.1)。 - private func applyListeners(_ listeners: HopetConfig.Listeners) { - sync(.claudeCode, desired: listeners.claudeCode) - sync(.codex, desired: listeners.codex) + /// 启动时确保所有已识别 AI 工具的 hook 都已落盘。已安装则跳过(避免每次启动重写文件与 + /// 堆积备份)。listener toggle 不再影响这里——toggle 只在 EventRouter 入口做静音。 + /// 真正卸载 Hopet hook 走 Hooks Tab 的显式入口(v0.3 计划)。 + private func ensureHooksInstalled() { + for tool in AITool.recognized { + installIfNeeded(tool) + } } - private func sync(_ tool: AITool, desired: Bool) { - let installed = hookInstaller.isInstalled(tool) - guard installed != desired else { return } + private func installIfNeeded(_ tool: AITool) { + guard !hookInstaller.isInstalled(tool) else { return } do { - if desired { - try hookInstaller.install(tool) - HopetLog.trace("\(tool.displayName) hooks installed.") - } else { - try hookInstaller.uninstall(tool) - HopetLog.trace("\(tool.displayName) hooks uninstalled.") - } + try hookInstaller.install(tool) + HopetLog.trace("\(tool.displayName) hooks installed at boot.") } catch { - HopetLog.warn("\(tool.displayName) hook sync failed: \(error)") + HopetLog.warn("\(tool.displayName) hook install failed: \(error)") + } + } + + /// 折中静音清扫:扫一遍 registry,把"工具 listener 关闭 + 当前无待决策"的 session + /// 整体移除。带 pending 的留着,等用户落决策后 registry.mutations 再次触发本函数 + /// 把它一并清掉。幂等。`listeners` 须由调用方传入,详见 boot() 里 sink 的注释。 + private func enforceListenerMute(listeners: HopetConfig.Listeners) { + let victims = registry.activeSessions.filter { !listeners[$0.tool] && $0.pendingKind == nil } + guard !victims.isEmpty else { return } + for s in victims { + HopetLog.trace("listener-mute", + "remove sid=\(s.id.hopetShortId) tool=\(s.tool.rawValue) state=\(s.currentState.rawValue)") + permissionPrompter.cancelPending(sessionId: s.id) + registry.remove(s.id) + router.purgeTranscriptMaps(for: s.id) } } } diff --git a/Sources/Hopet/Core/HopetConfig.swift b/Sources/Hopet/Core/HopetConfig.swift index f0c3434..79094e2 100644 --- a/Sources/Hopet/Core/HopetConfig.swift +++ b/Sources/Hopet/Core/HopetConfig.swift @@ -26,14 +26,37 @@ public struct HopetConfig: Codable, Equatable, Sendable { case light, dark, system } + /// Listener 软开关(折中静音)。toggle 不动 hook 文件——hooks 启动时已落盘, + /// off 只让 EventRouter 静默丢事件、SceneRouter 清掉无待决策的气泡;带 pending + /// 的气泡保留到用户落决策后再清。彻底卸载走 Hooks Tab(v0.3 计划)。 + /// See preferences.md §11.6. public struct Listeners: Codable, Equatable, Sendable { public var claudeCode: Bool public var codex: Bool - public init(claudeCode: Bool = true, codex: Bool = false) { + public init(claudeCode: Bool = true, codex: Bool = true) { self.claudeCode = claudeCode self.codex = codex } + + /// `.custom` 默认视为开启:与 EventRouter 兜底一致——v0.1 不识别的工具 + /// 不挂软静音,避免因未来扩展工具静默丢事件。Hooks Tab 没有它的 toggle。 + public subscript(tool: AITool) -> Bool { + get { + switch tool { + case .claudeCode: return claudeCode + case .codex: return codex + case .custom: return true + } + } + set { + switch tool { + case .claudeCode: claudeCode = newValue + case .codex: codex = newValue + case .custom: break + } + } + } } } diff --git a/Sources/Hopet/IPC/EventRouter.swift b/Sources/Hopet/IPC/EventRouter.swift index 83fe278..0c971b1 100644 --- a/Sources/Hopet/IPC/EventRouter.swift +++ b/Sources/Hopet/IPC/EventRouter.swift @@ -6,6 +6,10 @@ import Foundation public final class EventRouter { private unowned let registry: SessionRegistry private let permissionPrompter: PermissionPrompter + /// Listener 软开关闭包。返回 false 时 EventRouter 静默丢弃来自该工具的事件, + /// hook 文件保持安装态——用户在 Hooks Tab 关 toggle 即关静音,开 toggle 即恢复。 + /// 由 SceneRouter 注入,读取 ConfigStore.current.listeners。 + private let isToolListening: (AITool) -> Bool // 子 agent 识别(v0.x,因 Claude Code 还未稳定暴露 agent_id/parent_session_id): // 用 transcript_path 做"一个 chat panel 一个气泡"的归并 —— @@ -15,9 +19,14 @@ public final class EventRouter { private var transcriptToPrimary: [String: String] = [:] private var sessionToTranscript: [String: String] = [:] - public init(registry: SessionRegistry, permissionPrompter: PermissionPrompter) { + public init( + registry: SessionRegistry, + permissionPrompter: PermissionPrompter, + isToolListening: @escaping (AITool) -> Bool = { _ in true } + ) { self.registry = registry self.permissionPrompter = permissionPrompter + self.isToolListening = isToolListening } private func transcriptPath(of event: StateEvent) -> String? { @@ -51,10 +60,23 @@ public final class EventRouter { } } + /// 外部(SceneRouter 的 listener 静音清扫)移除 session 后回调,清掉与之关联的 + /// transcript 缓存。listener toggle 回开后,残留的 transcriptToPrimary 会把 + /// 后来发起的子 agent 同步请求重路由到已死的 sid,必须随 session 一起清。 + public func purgeTranscriptMaps(for sessionId: String) { + cleanupMaps(removedSessionId: sessionId) + } + /// 直接喂入 StateEvent。 public func handle(_ event: StateEvent) { let sid = event.sessionId.hopetShortId let evt = event.event.rawValue + // Listener 软开关:用户在 Hooks Tab 关闭该工具的 toggle 时直接丢弃事件。 + // hook 仍在 ~/.claude/settings.json / ~/.codex/hooks.json 中注册,仅做静音。 + if !isToolListening(event.tool) { + HopetLog.trace("skip", "reason=listener-off sid=\(sid) evt=\(evt) tool=\(event.tool.rawValue)") + return + } // 子 agent(Task 工具触发的子上下文)不进 registry,不显示气泡。 // 一个用户会话只有一个气泡,子 agent 的活动通过父 session 的状态体现。 if isSubagentEvent(event) { @@ -95,6 +117,14 @@ public final class EventRouter { public func handleRaw(_ data: Data, channel: SocketServer.ClientChannel) { do { let raw = try Self.decoder.decode(StateEvent.self, from: data) + // Listener 软开关:toggle off 时直接释放连接,让 hopet-emit 收 EOF 并输出 `{}`, + // Claude/Codex 因此走自家内置 UI(permission/askUser 不至于丢失),气泡完全沉默。 + if !isToolListening(raw.tool) { + HopetLog.trace("skip-raw", + "reason=listener-off tool=\(raw.tool.rawValue) evt=\(raw.event.rawValue) sid=\(raw.sessionId.hopetShortId)") + channel.reply(nil) + return + } // AskUserQuestion 在 hook 协议里是 permission_ask + tool_name="AskUserQuestion"。 // 在 router 入口归一为 .askUser,下游(状态机、enqueue 分支)只看 EventKind。 let isAskUser = raw.event == .permissionAsk && isAskUserQuestion(raw) @@ -302,10 +332,9 @@ public final class EventRouter { } } - /// 匹配领头的成对 XML 风格上下文块:`...`,跨行非贪婪。 - /// Claude Code 在 IDE 下会把 `` / `` / - /// `` / `` 等块塞到 prompt 头部,气泡标题 - /// 不该把它们当成用户输入。 + /// 匹配 prompt 头部的成对上下文块(`` / `` 等)。 + /// 与 `Sources/hopet-emit/main.swift` 中同名工具是一份手工副本——AGENTS.md §2.1 + /// 禁止 hopet-emit 反向依赖主 App 符号,两份需要同步修改。 private static let leadingContextTagRegex: NSRegularExpression = { let pattern = "\\A\\s*<([A-Za-z][A-Za-z0-9_-]*)(?:\\s[^>]*)?>[\\s\\S]*?\\s*" return try! NSRegularExpression(pattern: pattern) diff --git a/Sources/Hopet/Models/AITool.swift b/Sources/Hopet/Models/AITool.swift index a302e0d..de65e6e 100644 --- a/Sources/Hopet/Models/AITool.swift +++ b/Sources/Hopet/Models/AITool.swift @@ -1,11 +1,16 @@ import Foundation -/// 接入的 AI 工具枚举。每个 case 对应一只宠物(PetInstance)。 +/// 接入的 AI 工具枚举。仅作为 Session 来源标签与 hook 路由白名单—— +/// 宠物全局唯一,所有工具的 session 共用同一只 PetInstance。 public enum AITool: String, Codable, CaseIterable, Hashable, Sendable { case claudeCode = "claude-code" case codex = "codex" case custom + /// Hopet 当前识别的 AI 工具集合。驱动 hook 安装、listener toggle、事件路由白名单等。 + /// `.custom` 暂未支持,第三方接入 API 等 v0.3。 + public static let recognized: [AITool] = [.claudeCode, .codex] + public var displayName: String { switch self { case .claudeCode: return "Claude" diff --git a/Sources/Hopet/Panel/HooksTab.swift b/Sources/Hopet/Panel/HooksTab.swift index 89abad8..f2f39d8 100644 --- a/Sources/Hopet/Panel/HooksTab.swift +++ b/Sources/Hopet/Panel/HooksTab.swift @@ -1,8 +1,10 @@ import SwiftUI -/// 监听设置:每个工具一行 Toggle,勾选 = 安装 hooks,取消 = 卸载。 -/// Codex 在 v0.2 占位(底层 install 报 unimplemented,UI 仍允许勾选写入 config,启动期同步会 warn)。 -/// See preferences.md §3 / §6.1 / §11.6. +/// 监听设置:每个工具一行 Toggle,软静音开关(折中策略)。 +/// hook 在 App 启动时已落盘到 ~/.claude/settings.json / ~/.codex/hooks.json, +/// 这里的 toggle 不影响 hook 文件本身。off 时:EventRouter 静默丢弃事件, +/// 同时 SceneRouter 立即清掉该工具下无待决策的气泡;仍挂着 permission/askUser +/// 的气泡留到用户落决策后再清。See preferences.md §3 / §6.1 / §11.6。 struct HooksTab: View { let installer: HookInstaller @ObservedObject var configStore: ConfigStore @@ -10,24 +12,15 @@ struct HooksTab: View { var body: some View { PreferencesPaneScaffold("Listeners") { - HookToggleRow( - tool: .claudeCode, - isOn: Binding( - get: { configStore.current.listeners.claudeCode }, - set: { v in configStore.update { $0.listeners.claudeCode = v } } - ), - isInstalled: installer.isInstalled(.claudeCode) - ) - - HookToggleRow( - tool: .codex, - isOn: Binding( - get: { configStore.current.listeners.codex }, - set: { v in configStore.update { $0.listeners.codex = v } } - ), - isInstalled: installer.isInstalled(.codex), - placeholderNote: "Codex 在 v0.3 启用:勾选当前仅写入偏好,不会真正安装。" - ) + ForEach(AITool.recognized, id: \.self) { tool in + HookToggleRow( + tool: tool, + isOn: Binding( + get: { configStore.current.listeners[tool] }, + set: { v in configStore.update { $0.listeners[tool] = v } } + ) + ) + } DoctorBlock(installer: installer, report: $doctorReport) } @@ -37,28 +30,22 @@ struct HooksTab: View { private struct HookToggleRow: View { let tool: AITool @Binding var isOn: Bool - let isInstalled: Bool - var placeholderNote: String? = nil var body: some View { - PixelCard(tool.displayName.uppercased(), accent: .accentColor, titleTint: PixelPalette.sky) { + PixelCard(tool.displayName.uppercased(), accent: PixelPalette.sky, titleTint: PixelPalette.sky) { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text(isInstalled ? "Installed" : "Not installed") + Text(isOn ? "Listening on" : "Listening off") .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(isInstalled ? PixelPalette.mint : .secondary) - if let placeholderNote { - Text(placeholderNote) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - } else { - Text(isOn ? "Hopet listens to this terminal." : "Listening disabled.") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - } + .foregroundStyle(isOn ? PixelPalette.mint : .secondary) + Text(isOn + ? "Hopet reacts to this terminal's hooks." + : "Hooks remain installed; idle bubbles cleared, pending ones drain.") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) } Spacer() - PixelToggle(isOn: $isOn, tint: .accentColor) + PixelToggle(isOn: $isOn, tint: PixelPalette.mint) } } } @@ -79,7 +66,7 @@ private struct DoctorBlock: View { Button("Run") { report = HookDoctor.run(installer: installer) } - .buttonStyle(PixelButtonStyle(tint: .accentColor, prominent: false)) + .buttonStyle(PixelButtonStyle(tint: PixelPalette.sky, prominent: false)) } ScrollView { diff --git a/devDocs/preferences.md b/devDocs/preferences.md index feb10e0..329f742 100644 --- a/devDocs/preferences.md +++ b/devDocs/preferences.md @@ -557,11 +557,11 @@ PreferencesPaneScaffold(title: "Installed Themes") { | Tab | 主要部件 | 备注 | |---|---|---| -| OverviewTab | 顶部一排像素 `PixelPetCard`(每个 AI 一张:状态 glyph + 工具名 + 活跃 session 数 + Locate 按钮)+ 下方 `PixelCard` 包裹的 session 列表 | session 列表每行:状态色圆点 + 工具名 + 标题 + badgeLabel + 用时 + `×` 删除按钮(`PixelButtonStyle.gray`) | +| OverviewTab | 顶部单张像素 `PixelPetCard`(全局宠物:状态 glyph + 活跃 session 数 + Locate 按钮)+ 下方 `PixelCard` 包裹的 session 列表 | session 列表每行:状态色圆点 + 工具名 + 标题 + badgeLabel + 用时 + `×` 删除按钮(`PixelButtonStyle.gray`) | | ThemesTab | `PixelButtonStyle.prominent` 的 "Import Theme…" + `PixelCard` ×N(每主题一张:56×56 预览首帧 + 名称 + 描述 + Apply / Delete 按钮) | 主题预览首帧用 `FrameAnimationView` 渲染并叠 `PixelChrome` 边框,关闭抗锯齿;用户主题显示 `[user]` 角标 | | AppearanceTab | `PixelCard` 包裹 `PixelSegmentedControl` 三选一 | 选项文字 "Light / Dark / System";下方一行 monospaced 11pt 解释当前生效 | -| BindingsTab | 两个 `PixelCard`:①全局主题 `Picker(.menu)`;②每个 AI 的当前绑定列表 | Picker 弹层保留系统外观(§11.2.3) | -| HooksTab | `PixelCard` ×N(每工具一张:工具名 + Installed/Not installed + `PixelToggle`);底部 `PixelCard` 包裹 Doctor "Run" 按钮 + monospaced ScrollView | Codex 行附占位说明,勾选只写 config(§1.2 非目标) | +| BindingsTab | 单个 `PixelCard`:全局主题 `Picker(.menu)` | Picker 弹层保留系统外观(§11.2.3);宠物全局唯一,无按工具绑定 | +| HooksTab | 由 `AITool.recognized` 循环渲染 `PixelCard`(每工具一张:工具名 + Listening on/off + `PixelToggle`,软静音开关);底部 `PixelCard` 包裹 Doctor "Run" 按钮 + monospaced ScrollView | hooks 启动时无条件落盘;toggle off 时 EventRouter 静默丢弃事件,且 SceneRouter 立即清扫该工具下无待决策气泡(挂着 permission/askUser 的会话保留到下一轮决策落定再清) | | BehaviorTab | 4 个 `PixelCard`(General / Notch / Terminal / Diagnostics):前两块全 `PixelToggle`,后两块 `PixelSegmentedControl` | `preferredTerminal` 2 选、`logLevel` 4 选 | | NotificationsTab | 2 个 `PixelCard`(Banners 全 `PixelToggle` / Sound 占位说明) | | | AboutTab | 居中 `PixelCard`:项目标题 + 版本 + 一句话描述 + feedback Link | | From 54a8ecc243d789445f5d3f6b96c6ed7067bb063b Mon Sep 17 00:00:00 2001 From: BinaryFroggy Date: Mon, 11 May 2026 17:34:28 +0800 Subject: [PATCH 4/5] feat(theme,panel): add press flash and pixel drop slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PixelButtonStyle's press visual was a single frame in practice: any action that immediately rebuilt the view (sheet, modal, state change) ate configuration.isPressed before SwiftUI could render it. Split the style into PixelButtonBody and latch flashPressed for 120ms on the press-down edge so the chrome actually flashes. ThemesTab's per-state GIF drop slot was rolled by hand on top of PixelChrome; replace it with a dedicated PixelDropSlotButtonStyle that reuses the same flash trick, plus a one-runloop delay before opening NSOpenPanel so the press animation has time to render before the modal swallows mouse events. Also swap stale .accentColor uses in ThemesTab for the explicit PixelPalette tints used elsewhere. See devDocs/preferences.md §11.4. --- Sources/Hopet/Panel/ThemesTab.swift | 67 +++++++++++++++++------ Sources/Hopet/Theme/PixelChrome.swift | 70 ++++++++++++++++++++++--- Sources/Hopet/Theme/PixelControls.swift | 4 +- 3 files changed, 117 insertions(+), 24 deletions(-) diff --git a/Sources/Hopet/Panel/ThemesTab.swift b/Sources/Hopet/Panel/ThemesTab.swift index e431ba7..19e2332 100644 --- a/Sources/Hopet/Panel/ThemesTab.swift +++ b/Sources/Hopet/Panel/ThemesTab.swift @@ -16,7 +16,7 @@ struct ThemesTab: View { } label: { Label("Import Theme…", systemImage: "square.and.arrow.down") } - .buttonStyle(PixelButtonStyle(tint: .accentColor, prominent: true)) + .buttonStyle(PixelButtonStyle(tint: PixelPalette.mint, prominent: true)) ForEach(themes.themes, id: \.id) { theme in ThemeRow( @@ -68,7 +68,7 @@ private struct ThemeRow: View { var body: some View { PixelCard( theme.name.uppercased(), - accent: isActive ? .accentColor : .clear, + accent: isActive ? PixelPalette.mint : .clear, titleTint: theme.isUserProvided ? PixelPalette.mint : PixelPalette.sky ) { HStack(alignment: .top, spacing: 12) { @@ -104,7 +104,7 @@ private struct ThemeRow: View { .foregroundStyle(.green) } else { Button("Apply", action: onApply) - .buttonStyle(PixelButtonStyle(tint: .accentColor, prominent: false)) + .buttonStyle(PixelButtonStyle(tint: PixelPalette.sky, prominent: false)) } if theme.isUserProvided { Button("Delete", action: onDelete) @@ -208,7 +208,7 @@ private struct ThemeImportSheet: View { Button("Cancel") { dismiss() } .buttonStyle(PixelButtonStyle(tint: .gray, prominent: false)) Button("Import") { runImport() } - .buttonStyle(PixelButtonStyle(tint: .accentColor, prominent: true)) + .buttonStyle(PixelButtonStyle(tint: PixelPalette.mint, prominent: true)) .disabled(!canImport) } } @@ -238,13 +238,17 @@ private struct ThemeImportSheet: View { } /// 单个 PetState 的 GIF 拖拽 / 选择槽。 +/// 由于 `pickFile()` 立即弹 `NSOpenPanel` modal 接管鼠标事件,原生 `isPressed` 在松开前 +/// 就被截断,按下动效几乎渲染不到一帧。这里用 `flashPressed` + 延迟一次 RunLoop 再弹 panel +/// 的方式手动 flash 按下视觉。 private struct PixelDropSlot: View { - @Environment(\.colorScheme) private var colorScheme let state: PetState @Binding var gifURL: URL? + @State private var flashPressed = false + @State private var isTargeted = false var body: some View { - Button(action: pickFile) { + Button(action: handleTap) { HStack(alignment: .center, spacing: 10) { Circle().fill(state.accentColor).frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { @@ -257,24 +261,19 @@ private struct PixelDropSlot: View { .lineLimit(1) .truncationMode(.middle) } - Spacer() + Spacer(minLength: 0) if gifURL != nil { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.green) } } - .padding(8) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) } - .buttonStyle(.plain) - .modifier(PixelChrome( - cornerRadius: 6, + .buttonStyle(PixelDropSlotButtonStyle( accent: gifURL == nil ? .clear : state.accentColor, - strokeWidth: 1.5, - strokeColor: PixelPalette.stroke + forcedPressed: flashPressed || isTargeted )) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in + .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in guard let provider = providers.first else { return false } _ = provider.loadObject(ofClass: URL.self) { url, _ in guard let url else { return } @@ -286,6 +285,18 @@ private struct PixelDropSlot: View { } } + private func handleTap() { + // 1. 立即点亮按下视觉。 + withAnimation(.linear(duration: 0.06)) { flashPressed = true } + // 2. 等一拍让按下帧先渲染,再回弹 + 弹出 NSOpenPanel。 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.14) { + withAnimation(.linear(duration: 0.12)) { flashPressed = false } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + pickFile() + } + } + } + private func pickFile() { let panel = NSOpenPanel() panel.canChooseFiles = true @@ -297,3 +308,29 @@ private struct PixelDropSlot: View { } } } + +/// PixelDropSlot 专用按钮样式:整个 chrome 区域接受点击;按下时整槽下沉、accent 切到 +/// `lemon` 亮色 + 描边加粗,与 `PixelButtonStyle` 的"按入"语言同源但更夸张, +/// 避免 modal 弹出时反馈一闪而过。 +private struct PixelDropSlotButtonStyle: ButtonStyle { + let accent: Color + let forcedPressed: Bool + + func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed || forcedPressed + return configuration.label + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) + .contentShape(Rectangle()) + .modifier(PixelChrome( + cornerRadius: 6, + accent: pressed ? PixelPalette.lemon : accent, + strokeWidth: pressed ? 2.0 : 1.5, + strokeColor: PixelPalette.stroke + )) + .scaleEffect(pressed ? 0.97 : 1.0, anchor: .center) + .offset(y: pressed ? 3 : 0) + .animation(.easeOut(duration: 0.12), value: pressed) + } +} diff --git a/Sources/Hopet/Theme/PixelChrome.swift b/Sources/Hopet/Theme/PixelChrome.swift index fcfa78e..1afa07d 100644 --- a/Sources/Hopet/Theme/PixelChrome.swift +++ b/Sources/Hopet/Theme/PixelChrome.swift @@ -50,6 +50,12 @@ enum PixelPalette { scheme == .dark ? Color.white.opacity(0.58) : Color.black.opacity(0.52) } + /// 次操作按钮的文字色:浅色模式下用深灰(避免使用 chromeBlue 蓝字带来的"全场都是品牌色"), + /// 暗色模式沿用 ink 的白字保持可读。See preferences.md §11.4。 + static func buttonInk(_ scheme: ColorScheme) -> Color { + scheme == .dark ? Color.white.opacity(0.92) : Color.black.opacity(0.78) + } + /// 主题强调色覆盖底色的染色比例。dark 拉高饱和度。 static func accentTint(_ scheme: ColorScheme) -> Double { scheme == .dark ? 0.40 : 0.10 @@ -234,33 +240,83 @@ struct PixelButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { + PixelButtonBody( + configuration: configuration, + tint: tint, + prominent: prominent, + compact: compact, + colorScheme: colorScheme + ) + } +} + +/// PixelButtonStyle 的实际渲染。拆成独立 View 是为了让 `@State flashPressed` 持有 +/// "按下后短暂保留高亮"的状态——某些按钮 action(sheet / modal / 状态切换会立即重建视图) +/// 会瞬间吃掉 `configuration.isPressed`,单帧 `isPressed=true` 不够 SwiftUI 渲染出动效。 +/// 这里在 onChange(isPressed) 上升沿点亮 flash,下沿延 120ms 关掉,保证用户能看到。 +private struct PixelButtonBody: View { + let configuration: ButtonStyle.Configuration + let tint: Color + let prominent: Bool + let compact: Bool + let colorScheme: ColorScheme + + @State private var flashPressed: Bool = false + + var body: some View { let shape = RoundedRectangle(cornerRadius: 4, style: .continuous) - let pressed = configuration.isPressed + let pressed = configuration.isPressed || flashPressed let fontSize: CGFloat = compact ? 11 : 12 let padH: CGFloat = compact ? 10 : 14 let padV: CGFloat = compact ? 5 : 7 let stroke: CGFloat = compact ? 1.2 : 1.5 + return configuration.label .font(.system(size: fontSize, weight: .bold, design: .monospaced)) .padding(.horizontal, padH) .padding(.vertical, padV) - .foregroundStyle(prominent ? Color.white : PixelPalette.ink(colorScheme)) + .foregroundStyle(prominent ? Color.white : PixelPalette.buttonInk(colorScheme)) .background( ZStack { if !pressed { shape .fill(PixelPalette.stroke) - .offset(x: 0, y: 2) + .offset(x: 0, y: 3) + } + if prominent { + shape.fill(tint) + if pressed { + // 按下时叠一层暗色让 prominent 按钮"陷下去"的对比更明显。 + shape.fill(Color.black.opacity(0.18)) + } + } else { + shape.fill(PixelPalette.base(colorScheme)) + let dyeOpacity: Double = pressed + ? (colorScheme == .dark ? 0.55 : 0.32) + : (colorScheme == .dark ? 0.30 : 0.10) + shape.fill(tint.opacity(dyeOpacity)) } - shape.fill(prominent ? AnyShapeStyle(tint) : AnyShapeStyle(tint.opacity(0.22))) shape.stroke(PixelPalette.stroke, lineWidth: stroke) shape .inset(by: stroke) - .stroke(Color.white.opacity(prominent ? 0.45 : 0.60), lineWidth: 1) + .stroke( + Color.white.opacity(pressed ? 0.20 : (prominent ? 0.45 : 0.70)), + lineWidth: 1 + ) } ) - .offset(x: 0, y: pressed ? 2 : 0) - .animation(.linear(duration: 0.05), value: pressed) + .scaleEffect(pressed ? 0.96 : 1.0, anchor: .center) + .offset(x: 0, y: pressed ? 3 : 0) + .animation(.spring(response: 0.22, dampingFraction: 0.62), value: pressed) + .onChange(of: configuration.isPressed) { _, newValue in + if newValue { + flashPressed = true + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + flashPressed = false + } + } + } } } diff --git a/Sources/Hopet/Theme/PixelControls.swift b/Sources/Hopet/Theme/PixelControls.swift index c139c06..8ef4979 100644 --- a/Sources/Hopet/Theme/PixelControls.swift +++ b/Sources/Hopet/Theme/PixelControls.swift @@ -77,7 +77,7 @@ struct PixelToggle: View { @Binding var isOn: Bool let tint: Color - init(isOn: Binding, tint: Color = .accentColor) { + init(isOn: Binding, tint: Color = PixelPalette.mint) { self._isOn = isOn self.tint = tint } @@ -140,7 +140,7 @@ struct PixelSegmentedControl: View { let options: [(value: Value, label: String)] let tint: Color - init(selection: Binding, options: [(Value, String)], tint: Color = .accentColor) { + init(selection: Binding, options: [(Value, String)], tint: Color = PixelPalette.sky) { self._selection = selection self.options = options self.tint = tint From a1d6caf30e594206948d2591abc05d14af9934b5 Mon Sep 17 00:00:00 2001 From: BinaryFroggy Date: Mon, 11 May 2026 17:34:42 +0800 Subject: [PATCH 5/5] chore(docs): drop unused statusbar expression assets statusbar-expression-omega.png and statusbar-expression-seal-mouth.png were spec exploration screenshots for a menu bar expression slot that was never built and is not on the current roadmap. devDocs no longer references either file. --- devDocs/assets/statusbar-expression-omega.png | Bin 25387 -> 0 bytes .../assets/statusbar-expression-seal-mouth.png | Bin 21047 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 devDocs/assets/statusbar-expression-omega.png delete mode 100644 devDocs/assets/statusbar-expression-seal-mouth.png diff --git a/devDocs/assets/statusbar-expression-omega.png b/devDocs/assets/statusbar-expression-omega.png deleted file mode 100644 index c52e6777639cf6697114d138968cc4b2434dfcb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25387 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 diff --git a/devDocs/assets/statusbar-expression-seal-mouth.png b/devDocs/assets/statusbar-expression-seal-mouth.png deleted file mode 100644 index 4ac3dd4063e2e14f35821fbb4f0041ee0176cabd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21047 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 H{owxq@pDvj