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..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 @@ -91,7 +117,7 @@ public final class SceneRouter { thinkingTimer.start() decayTimer.start() - petWindowController.showAll() + petWindowController.show() // 刘海条暂不展示,等三态/降级顶条视觉打磨完再开。controller / wiring 保留。 // notchController.show() HopetLog.info("Hopet booted.") @@ -110,7 +136,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. @@ -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/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/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/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/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/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/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/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/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/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 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/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/assets/statusbar-expression-omega.png b/devDocs/assets/statusbar-expression-omega.png deleted file mode 100644 index c52e677..0000000 Binary files a/devDocs/assets/statusbar-expression-omega.png and /dev/null differ diff --git a/devDocs/assets/statusbar-expression-seal-mouth.png b/devDocs/assets/statusbar-expression-seal-mouth.png deleted file mode 100644 index 4ac3dd4..0000000 Binary files a/devDocs/assets/statusbar-expression-seal-mouth.png and /dev/null differ 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) 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 } ``` 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 | |