diff --git a/Sources/Hopet/App/SceneRouter.swift b/Sources/Hopet/App/SceneRouter.swift index 582b939..1e72a92 100644 --- a/Sources/Hopet/App/SceneRouter.swift +++ b/Sources/Hopet/App/SceneRouter.swift @@ -63,14 +63,9 @@ public final class SceneRouter { HopetLog.trace("claude hooks install failed: \(error)") } - // restore recent sessions - for session in PersistentStore.loadRecent() { - registry.upsert(session) - } - let router = self.router - let server = SocketServer { data, reply in - Task { @MainActor in router.handleRaw(data, reply: reply) } + let server = SocketServer { data, channel in + Task { @MainActor in router.handleRaw(data, channel: channel) } } try server.start() self.server = server @@ -89,7 +84,6 @@ public final class SceneRouter { } public func shutdown() { - PersistentStore.save(Array(registry.sessions.values)) thinkingTimer.stop() decayTimer.stop() notchController.hide() diff --git a/Sources/Hopet/Core/HopetPaths.swift b/Sources/Hopet/Core/HopetPaths.swift index 5386562..333a771 100644 --- a/Sources/Hopet/Core/HopetPaths.swift +++ b/Sources/Hopet/Core/HopetPaths.swift @@ -13,7 +13,6 @@ public enum HopetPaths { public static var logs: URL { home.appendingPathComponent("logs", isDirectory: true) } public static var socket: URL { run.appendingPathComponent("hopetd.sock") } - public static var sessionsState: URL { state.appendingPathComponent("sessions.json") } public static var configFile: URL { home.appendingPathComponent("config.json") } public static var bindingsFile: URL { home.appendingPathComponent("bindings.json") } diff --git a/Sources/Hopet/Core/PersistentStore.swift b/Sources/Hopet/Core/PersistentStore.swift deleted file mode 100644 index 48f701a..0000000 --- a/Sources/Hopet/Core/PersistentStore.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// 把 SessionRegistry 的快照写到 ~/.hopet/state/sessions.json。 -/// 启动时恢复 10 分钟内的 session 为灰度 idle。 -public enum PersistentStore { - private static let encoder: JSONEncoder = { - let e = JSONEncoder() - e.dateEncodingStrategy = .iso8601 - e.outputFormatting = [.prettyPrinted, .sortedKeys] - return e - }() - - private static let decoder: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 - return d - }() - - public static func save(_ sessions: [Session]) { - do { - let data = try encoder.encode(sessions) - try data.write(to: HopetPaths.sessionsState, options: .atomic) - } catch { - HopetLog.warn("save sessions failed: \(error)") - } - } - - public static func loadRecent() -> [Session] { - guard let data = try? Data(contentsOf: HopetPaths.sessionsState), !data.isEmpty else { return [] } - do { - let all = try decoder.decode([Session].self, from: data) - let cutoff = Date().addingTimeInterval(-600) // 10 分钟 - return all.filter { $0.stateSince >= cutoff } - .map { var s = $0; s.currentState = .idle; return s } - } catch { - HopetLog.warn("load sessions failed: \(error)") - return [] - } - } -} diff --git a/Sources/Hopet/Core/SessionStateMachine.swift b/Sources/Hopet/Core/SessionStateMachine.swift index cd8de3c..37facdc 100644 --- a/Sources/Hopet/Core/SessionStateMachine.swift +++ b/Sources/Hopet/Core/SessionStateMachine.swift @@ -47,9 +47,10 @@ public enum SessionStateMachine { case (.askUser, .askUserResolved): return .responding - // 错误:任意状态进入 errorInterrupted - case (_, .error): - return .errorInterrupted + // .error 来自 PostToolUseFailure,覆盖 grep / head / ls 这类 exit-code-非-0 的常态情况, + // 不再映射成 .errorInterrupted —— 否则用户每次正常会话里都会看到海豹变红。EventRouter + // 仍会借 .error 调 cancelPending 清待决策气泡,但状态机不切。`.errorInterrupted` 保留为 + // 类型值,留给未来真正需要"会话级错误"语义的事件源(目前没有)。 // stop:任意活跃状态进入 completed case (.responding, .stop), diff --git a/Sources/Hopet/IPC/EventRouter.swift b/Sources/Hopet/IPC/EventRouter.swift index 58b89bc..096e59b 100644 --- a/Sources/Hopet/IPC/EventRouter.swift +++ b/Sources/Hopet/IPC/EventRouter.swift @@ -83,15 +83,16 @@ public final class EventRouter { } } - /// 喂入原始 JSON Data(来自 socket)+ 可回写决策的 reply 闭包。 - /// permission_ask + 携带 requestId 的事件会挂起 reply,等用户在气泡上做决定。 + /// 喂入原始 JSON Data(来自 socket)+ 可回写决策的连接 channel。 + /// permission_ask + 携带 requestId 的事件会挂起 channel,等用户在气泡上做决定; + /// 对端 EOF 时 channel 会触发 PermissionPrompter 注册的 disconnect 回调清理 UI。 /// AskUserQuestion(tool_name == "AskUserQuestion")也通过 PermissionRequest hook 进来, /// 走结构化 elicitation 路径:回包带 updatedInput.answers。 /// /// 子 agent 处理:fire-and-forget(pre/post tool use 等)丢弃避免气泡爆炸; /// 但 permission_ask / askUser 这两类用户必须回答的同步事件,**重路由**到所属 transcript 的主 /// session 上挂气泡——否则 Hopet 完全沉默而 Claude 弹自己的 fallback UI,体验割裂。 - public func handleRaw(_ data: Data, reply: @escaping @Sendable (Data?) -> Void) { + public func handleRaw(_ data: Data, channel: SocketServer.ClientChannel) { do { let raw = try Self.decoder.decode(StateEvent.self, from: data) // AskUserQuestion 在 hook 协议里是 permission_ask + tool_name="AskUserQuestion"。 @@ -105,10 +106,10 @@ public final class EventRouter { let isSyncRequest = (normalized.event == .permissionAsk || normalized.event == .askUser) && normalized.requestId != nil - // 子 agent 的 fire-and-forget:保留原"丢弃 + reply nil"。trace 由 handle() 写一行。 + // 子 agent 的 fire-and-forget:保留原"丢弃 + 关连接"。trace 由 handle() 写一行。 if subagent && !isSyncRequest { handle(normalized) - reply(nil) + channel.reply(nil) return } @@ -132,19 +133,19 @@ public final class EventRouter { switch routed.event { case .askUser: HopetLog.trace("askuser", "enqueue sid=\(sidShort) reqId=\(reqShort)") - permissionPrompter.enqueueAskUser(routed, reply: reply) + permissionPrompter.enqueueAskUser(routed, channel: channel) case .permissionAsk: HopetLog.trace("perm", "enqueue sid=\(sidShort) reqId=\(reqShort)") - permissionPrompter.enqueue(routed, reply: reply) + permissionPrompter.enqueue(routed, channel: channel) default: - reply(nil) + channel.reply(nil) } } else { - reply(nil) + channel.reply(nil) } } catch { HopetLog.trace("error", "decode StateEvent failed: \(error)") - reply(nil) + channel.reply(nil) } } @@ -188,20 +189,23 @@ public final class EventRouter { /// 时清掉。漏发 SessionEnd 的常见场景:宿主被强关、Hopet 启停错位、IDE extension host 重载导致 /// claude-code 子进程换 PID + 换 sid。不清的话用户就会看到"一个会话却有多个气泡"。 /// - /// 区分两类同 cwd 的旧 session: - /// - IDE 内嵌(terminalTty == nil):extension host 独占该项目目录的 claude-code 实例,新 session_start - /// 必然是替身,无视状态一律清。 - /// - 真终端(terminalTty != nil):iTerm / Ghostty 多 tab 可能合法并发,仅清确实闲下来的; - /// 仍在运行的(PetState.isRunning == true)保留。 + /// 但 IDE(Cursor)同一项目下也支持多 chat panel 并行,真终端也支持多 tab 并发——不能因为 + /// 同 cwd / 同宿主就一律清。判据只看"是否还活着": + /// - 状态是终态(idle / completed / errorInterrupted)→ 清,旧会话已经收尾。 + /// - 状态 running 但 lastActivityAt 远超 stalenessThreshold → 清,认定卡死躺尸。 + /// - 状态 running 且最近有事件 → 保留,是用户主动并行的合法会话。 + private static let stalenessThreshold: TimeInterval = 90 private func pruneStaleSiblings(of new: Session) { + let now = Date() let victims = registry.activeSessions(of: new.tool).filter { other in guard other.id != new.id, other.cwd == new.cwd else { return false } - // IDE 内嵌(无 tty)→ 一律清;真终端 → 只清非运行中的。 - return other.terminalTty == nil || !other.currentState.isRunning + if !other.currentState.isRunning { return true } + return now.timeIntervalSince(other.lastActivityAt) > Self.stalenessThreshold } for v in victims { let host = v.terminalTty.map { "tty=\($0)" } ?? "ide-embedded" - HopetLog.trace("autoprune", "remove stale peer sid=\(v.id.hopetShortId) cwd=\(new.cwd) state=\(v.currentState.rawValue) host=\(host) (replaced by sid=\(new.id.hopetShortId))") + let idle = Int(now.timeIntervalSince(v.lastActivityAt)) + HopetLog.trace("autoprune", "remove stale peer sid=\(v.id.hopetShortId) cwd=\(new.cwd) state=\(v.currentState.rawValue) idle=\(idle)s host=\(host) (replaced by sid=\(new.id.hopetShortId))") permissionPrompter.cancelPending(sessionId: v.id) registry.remove(v.id) cleanupMaps(removedSessionId: v.id) @@ -210,7 +214,19 @@ public final class EventRouter { private func handleStateEvent(_ event: StateEvent) { // 若 session 不存在(外部启动 + 跳过 SessionStart),按需即时创建。 + // 仅"流程开端"类事件(user_prompt / pre_tool_use / permission_ask / ask_user / thinking_start) + // 才允许冷启建 session,其它(stop / error / post_tool_use / ask_user_resolved / session_end) + // 在缺少先行配对时大概率是迟到帧 / 手工 emit / 污染源——凭空建一个 session 然后立刻 + // .completed/.idle 只会让用户看到莫名其妙的"僵尸气泡"。这类事件下 session 不存在就静默丢弃。 if registry.session(event.sessionId) == nil { + switch event.event { + case .sessionStart, .userPrompt, .preToolUse, .permissionAsk, .askUser, .thinkingStart: + break + default: + HopetLog.trace("skip", + "orphan evt=\(event.event.rawValue) sid=\(event.sessionId.hopetShortId) (no session)") + return + } let session = Session( id: event.sessionId, tool: event.tool, @@ -235,15 +251,28 @@ public final class EventRouter { case .userPrompt: if let snippet = event.stringValue(forKey: "prompt") { s.lastPromptSnippet = String(snippet.prefix(256)) - if s.title == nil { - s.title = String(snippet.prefix(32)) - } + // 每次提问都刷新 title:会话标题随当前语境走,而不是冻结在第一次提问。 + // 否则用户连发几轮提问后,气泡 header 还停留在第一次的开头。 + s.title = String(snippet.prefix(32)) } + // 新一轮开始:清掉上一轮的 assistant 尾声,避免在"思考中"语境里 + // 还显示几分钟前那段已不相关的回复。 + s.lastAssistantMessage = nil case .askUser: let q = event.stringValue(forKey: "tool_input.question") ?? event.stringValue(forKey: "message") ?? "Claude 在等你回答" s.pendingQuestion = q + case .stop: + // hopet-emit 已从 transcript 抽取最后一段 assistant 文本并截断到 120 字符; + // 此处直接落到 session 让默认气泡第二行渲染。空字符串视同没拿到,置 nil。 + if let msg = event.stringValue(forKey: "assistant_message") { + let trimmed = msg.trimmingCharacters(in: .whitespacesAndNewlines) + s.lastAssistantMessage = trimmed.isEmpty ? nil : trimmed + HopetLog.trace("stop", "sid=\(event.sessionId.hopetShortId) msg.len=\(trimmed.count) head=\"\(trimmed.prefix(32))\"") + } else { + HopetLog.trace("stop", "sid=\(event.sessionId.hopetShortId) no assistant_message in payload") + } default: break } diff --git a/Sources/Hopet/IPC/SocketServer.swift b/Sources/Hopet/IPC/SocketServer.swift index 1014088..85f74e1 100644 --- a/Sources/Hopet/IPC/SocketServer.swift +++ b/Sources/Hopet/IPC/SocketServer.swift @@ -5,10 +5,107 @@ import Darwin /// 用 BSD socket API 直接实现,避免 Network.framework 在 Unix path 上的兼容性问题。 /// /// 协议:每个 client 连接发一个长度前缀 JSON 帧;服务端可以选择性地回写一帧响应(permission_ask 走这条), -/// 然后关闭连接。fire-and-forget 的事件,上层 reply(nil) 即关闭。 +/// 然后关闭连接。fire-and-forget 的事件,上层 channel.reply(nil) 即关闭。 +/// +/// 同步请求(permission_ask)路径下,上层在收到帧后挂起 channel 等用户决策;如果对端进程 +/// (hopet-emit)在用户作答前先死了(典型场景:用户在 cc 终端 deny ⇒ cc 直接 kill emit), +/// channel 会触发 `onPeerDisconnect` 注册的回调,让上层主动清理 UI 上的待决策气泡, +/// 避免"对端已关,气泡却还在"的躺尸。 public final class SocketServer { - public typealias Reply = @Sendable (Data?) -> Void - public typealias FrameHandler = @Sendable (Data, @escaping Reply) -> Void + public typealias FrameHandler = @Sendable (Data, ClientChannel) -> Void + + /// 单次连接的双向通道。reply 与 onPeerDisconnect 互斥触发一次(先到者胜出), + /// 都会负责关 fd;调用顺序无关。 + public final class ClientChannel: @unchecked Sendable { + private let lock = NSLock() + private let monitorQueue: DispatchQueue + private var fd: Int32 + private var fired = false + private var disconnectSource: DispatchSourceRead? + private var disconnectHandler: (@Sendable () -> Void)? + + fileprivate init(fd: Int32, monitorQueue: DispatchQueue) { + self.fd = fd + self.monitorQueue = monitorQueue + } + + /// 上层做决策后回包:data 非 nil 即写一帧响应,nil 表示无回包但请关闭连接。 + public func reply(_ data: Data?) { + lock.lock() + if fired { lock.unlock(); return } + fired = true + disconnectHandler = nil + let source = disconnectSource + disconnectSource = nil + let localFd = fd + fd = -1 + lock.unlock() + + source?.cancel() + if let data { + let resp = FrameCodec.encode(data) + resp.withUnsafeBytes { raw in + var sent = 0 + while sent < resp.count { + let r = write(localFd, raw.baseAddress!.advanced(by: sent), resp.count - sent) + if r <= 0 { break } + sent += r + } + } + } + close(localFd) + } + + /// 注册对端 EOF/HUP 时的清理回调,并立即起 DispatchSource 监听 fd。 + /// 重要:必须在这里同步 attach source,不能在 onFrame 后由 caller 单独 attach —— + /// 上层 onFrame 通常会 `Task { @MainActor in ... }` 异步调度到主 actor, + /// 当 source 已 resume 但 handler 尚未注册时 EOF 触发会丢事件,UI 留躺尸气泡。 + /// reply 已 fire 时 early-return;首次调本函数时同步建源,DispatchSource 在 resume + /// 时会把已发生的 EOF 立刻投递出来。 + public func onPeerDisconnect(_ handler: @escaping @Sendable () -> Void) { + lock.lock() + if fired { lock.unlock(); return } + disconnectHandler = handler + let needsSource = (disconnectSource == nil) && (fd >= 0) + let localFd = fd + var newSource: DispatchSourceRead? = nil + if needsSource { + newSource = DispatchSource.makeReadSource(fileDescriptor: localFd, queue: monitorQueue) + disconnectSource = newSource + } + lock.unlock() + + guard let source = newSource else { return } + source.setEventHandler { [weak self] in + guard let self else { return } + // 探一字节判定是真有数据还是 EOF。本协议下 client 发完一帧后就只等响应, + // 不会再写——所以 read > 0 也是异常情况,按 EOF 处理同样安全。 + var byte: UInt8 = 0 + let n = read(localFd, &byte, 1) + if n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + self.fireDisconnect() + } + } + source.resume() + } + + private func fireDisconnect() { + lock.lock() + if fired { lock.unlock(); return } + fired = true + let handler = disconnectHandler + disconnectHandler = nil + let source = disconnectSource + disconnectSource = nil + let localFd = fd + fd = -1 + lock.unlock() + + source?.cancel() + if localFd >= 0 { close(localFd) } + handler?() + } + } private let queue = DispatchQueue(label: "com.hopet.socket-server", qos: .userInitiated) private let acceptQueue = DispatchQueue(label: "com.hopet.socket-accept") @@ -105,6 +202,14 @@ public final class SocketServer { } private func handle(client fd: Int32) { + // 屏蔽 SIGPIPE:对端 hopet-emit 被宿主 kill(用户在 cc 终端 deny)后, + // socket 进入 close-wait;此后用户在 Hopet 气泡上做决策走到 write(fd, ...) 时, + // 内核会同时返回 EPIPE 并向进程投 SIGPIPE。Hopet 无 GUI 进程默认 SIGPIPE handler + // ⇒ 直接被信号杀死。SO_NOSIGPIPE 让此 fd 上的 write 仅返回 EPIPE,由调用方静默忽略。 + var noSigpipe: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, + &noSigpipe, socklen_t(MemoryLayout.size)) + let onFrame = self.onFrame queue.async { // 读到第一个完整帧。 @@ -130,38 +235,11 @@ public final class SocketServer { return } - let didReply = ReplyGuard() - let reply: Reply = { responseData in - guard didReply.markIfFirst() else { return } - if let responseData { - let resp = FrameCodec.encode(responseData) - resp.withUnsafeBytes { raw in - var sent = 0 - while sent < resp.count { - let r = write(fd, raw.baseAddress!.advanced(by: sent), resp.count - sent) - if r <= 0 { break } - sent += r - } - } - } - close(fd) - } - onFrame(frame, reply) + let channel = ClientChannel(fd: fd, monitorQueue: self.queue) // 不再设兜底超时:用户可能很久才在气泡上做决策,本地超时只会让按钮变成"假活"。 - // 上层(PermissionPrompter)在用户决策 / EventRouter 收到外部已处理事件时调 reply(), - // 异常退出场景靠进程结束自动回收 fd。 + // 异常退出场景靠上层在 onPeerDisconnect 注册时 lazy attach 的 source 触发清理; + // 最坏情况下进程结束自动回收 fd。 + onFrame(frame, channel) } } } - -/// 简单的 once-only 标志,保证 reply 只生效一次。 -private final class ReplyGuard: @unchecked Sendable { - private let lock = NSLock() - private var fired = false - func markIfFirst() -> Bool { - lock.lock(); defer { lock.unlock() } - if fired { return false } - fired = true - return true - } -} diff --git a/Sources/Hopet/Models/Session.swift b/Sources/Hopet/Models/Session.swift index 4aa8396..b86ab8a 100644 --- a/Sources/Hopet/Models/Session.swift +++ b/Sources/Hopet/Models/Session.swift @@ -95,6 +95,10 @@ public struct Session: Codable, Identifiable, Hashable, Sendable { /// 最近一次收到任何事件的时间戳;用于陈旧 session 清理(VS Code 插件等不发 session_end 的场景)。 public var lastActivityAt: Date public var lastPromptSnippet: String? + /// 上一轮 Claude 完成回复时(Stop hook)写入的回复开头,已截断到 120 字符。 + /// 默认气泡第二行渲染用。在 UserPromptSubmit(开始新一轮)时清空,避免把上一轮尾声 + /// 串到新一轮的"思考中"语境里。 + public var lastAssistantMessage: String? /// AskUserQuestion 当前的提问文案(PreToolUse 路径填,fire-and-forget,仅展示用)。 /// 当 `pendingAskUser` 也存在时,气泡优先用结构化的 `pendingAskUser` 来渲染并允许直接作答。 @@ -117,6 +121,7 @@ public struct Session: Codable, Identifiable, Hashable, Sendable { stateSince: Date = Date(), lastActivityAt: Date = Date(), lastPromptSnippet: String? = nil, + lastAssistantMessage: String? = nil, pendingQuestion: String? = nil, pendingAskUser: PendingAskUser? = nil, pendingPermission: PendingPermission? = nil @@ -133,6 +138,7 @@ public struct Session: Codable, Identifiable, Hashable, Sendable { self.stateSince = stateSince self.lastActivityAt = lastActivityAt self.lastPromptSnippet = lastPromptSnippet + self.lastAssistantMessage = lastAssistantMessage self.pendingQuestion = pendingQuestion self.pendingAskUser = pendingAskUser self.pendingPermission = pendingPermission diff --git a/Sources/Hopet/Models/SessionBubble.swift b/Sources/Hopet/Models/SessionBubble.swift index 4c9eea2..b7a4491 100644 --- a/Sources/Hopet/Models/SessionBubble.swift +++ b/Sources/Hopet/Models/SessionBubble.swift @@ -1,19 +1,17 @@ import Foundation -/// 围绕宠物环绕的会话气泡。1:1 绑定一个 sessionId。 +/// 列入会话气泡列表的一个会话视图模型。1:1 绑定一个 sessionId。 public struct SessionBubble: Identifiable, Sendable { public let id: String public let tool: AITool - public var orbitAngle: Double - public var orbitRing: Int public var displayTitle: String /// 是否有用户/AI 写入的真实标题。`false` 时 displayTitle 是从 cwd 回退出来的占位, /// 视图应避免再单独渲染"标题",否则会和目录行重复。 public var hasTitle: Bool public var displayCwd: String - public var displayElapsed: String public var state: PetState - public var expanded: Bool + /// Stop hook 抽到的最近一次 Claude 回复开头(已 ≤120 字符)。默认卡片第二行渲染用;nil 时不渲染。 + public var lastAssistantMessage: String? public var pendingQuestion: String? public var pendingAskUser: PendingAskUser? public var pendingPermission: PendingPermission? @@ -21,28 +19,22 @@ public struct SessionBubble: Identifiable, Sendable { public init( id: String, tool: AITool, - orbitAngle: Double = 0, - orbitRing: Int = 0, displayTitle: String, hasTitle: Bool = false, displayCwd: String, - displayElapsed: String = "0s", state: PetState = .idle, - expanded: Bool = false, + lastAssistantMessage: String? = nil, pendingQuestion: String? = nil, pendingAskUser: PendingAskUser? = nil, pendingPermission: PendingPermission? = nil ) { self.id = id self.tool = tool - self.orbitAngle = orbitAngle - self.orbitRing = orbitRing self.displayTitle = displayTitle self.hasTitle = hasTitle self.displayCwd = displayCwd - self.displayElapsed = displayElapsed self.state = state - self.expanded = expanded + self.lastAssistantMessage = lastAssistantMessage self.pendingQuestion = pendingQuestion self.pendingAskUser = pendingAskUser self.pendingPermission = pendingPermission diff --git a/Sources/Hopet/Pet/BubbleLayout.swift b/Sources/Hopet/Pet/BubbleLayout.swift deleted file mode 100644 index 2be09da..0000000 --- a/Sources/Hopet/Pet/BubbleLayout.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import CoreGraphics - -/// 会话气泡环绕布局算法(架构文档 §12.4)。 -public enum BubbleLayout { - public struct Params { - public var petRadius: CGFloat = 64 - public var bubbleRadius: CGFloat = 32 - public var gap: CGFloat = 16 - public var maxBubblesPerRing: Int = 6 - public var ringAngularOffsetDegrees: CGFloat = 30 - - public init() {} - } - - public struct Slot { - public let ring: Int - public let angleDegrees: Double - public let offsetX: CGFloat - public let offsetY: CGFloat - } - - public static func radius(forRing ring: Int, params: Params = .init()) -> CGFloat { - params.petRadius - + params.gap - + CGFloat(2 * ring + 1) * params.bubbleRadius - + CGFloat(ring) * params.gap - } - - public static func slots(count: Int, params: Params = .init()) -> [Slot] { - guard count > 0 else { return [] } - var slots: [Slot] = [] - slots.reserveCapacity(count) - - var index = 0 - var ring = 0 - while index < count { - let remaining = count - index - let inThisRing = min(params.maxBubblesPerRing, remaining) - let r = radius(forRing: ring, params: params) - let baseOffset = Double(params.ringAngularOffsetDegrees) * Double(ring) - for k in 0.. Void) { + public func enqueue(_ event: StateEvent, channel: SocketServer.ClientChannel) { guard let requestId = event.requestId, !requestId.isEmpty else { - reply(nil) + channel.reply(nil) return } @@ -43,7 +43,7 @@ public final class PermissionPrompter { return String(trimmed.prefix(16_384)) }() - pending[requestId] = reply + pending[requestId] = { data in channel.reply(data) } registry.patch(event.sessionId) { s in s.pendingPermission = PendingPermission( requestId: requestId, @@ -53,6 +53,50 @@ public final class PermissionPrompter { plan: plan ) } + + registerDisconnectCleanup(channel: channel, sessionId: event.sessionId, requestId: requestId) + } + + /// hopet-emit 在用户作答前先死了(典型:cc 终端 deny 直接 kill 子进程)⇒ 主动清气泡。 + /// 不发任何回包,仅清 pending 表 + 把状态机推出 permissionPrompt/askUser 子状态,让 UI 不留躺尸。 + /// `[weak self]` 捕获的是 var,不能直接进 @Sendable Task 闭包;先 rebind 成 let 常量。 + private func registerDisconnectCleanup( + channel: SocketServer.ClientChannel, + sessionId: String, + requestId: String + ) { + channel.onPeerDisconnect { [weak self] in + guard let strong = self else { return } + Task { @MainActor [strong] in + strong.handlePeerDisconnect(sessionId: sessionId, requestId: requestId) + } + } + } + + /// 选取与已清 pending 类型对应的状态机事件: + /// - permission 路径 → `.postToolUse`,让 `permissionPrompt` 切回上一态。 + /// - askUser 路径 → `.askUserResolved`,让 `askUser` 切回 `responding`。 + /// 状态机表里没有 `(.askUser, .postToolUse)`,统一用 `.postToolUse` 会让 askUser 路径下宠物动画卡住。 + private func handlePeerDisconnect(sessionId: String, requestId: String) { + guard pending.removeValue(forKey: requestId) != nil else { return } + let sid = sessionId.hopetShortId + let rid = requestId.hopetShortId + HopetLog.trace("peer-disconnect", "sid=\(sid) reqId=\(rid) (emit died before user answered)") + + let wasAskUser = registry.session(sessionId)?.pendingAskUser?.requestId == requestId + registry.patch(sessionId) { s in + if s.pendingPermission?.requestId == requestId { + s.pendingPermission = nil + } + if s.pendingAskUser?.requestId == requestId { + s.pendingAskUser = nil + } + } + let advanceEvent: EventKind = wasAskUser ? .askUserResolved : .postToolUse + if let current = registry.session(sessionId)?.currentState, + let next = SessionStateMachine.nextState(from: current, event: advanceEvent) { + registry.transition(sessionId: sessionId, to: next) + } } /// 用户在气泡上点击决策后调用。decision ∈ {"allow", "deny", "ask"}。 @@ -99,9 +143,9 @@ public final class PermissionPrompter { // MARK: - AskUserQuestion(elicitation) /// SocketServer 收到 permission_ask 帧且 `tool_name == "AskUserQuestion"` 时走这里。 - public func enqueueAskUser(_ event: StateEvent, reply: @escaping @Sendable (Data?) -> Void) { + public func enqueueAskUser(_ event: StateEvent, channel: SocketServer.ClientChannel) { guard let requestId = event.requestId, !requestId.isEmpty else { - reply(nil) + channel.reply(nil) return } @@ -110,7 +154,7 @@ public final class PermissionPrompter { let originalJSON = (try? JSONSerialization.data(withJSONObject: toolInputDict, options: [])) ?? Data() // 同时清掉早先 PreToolUse fire-and-forget 设的简单 pendingQuestion,避免气泡内容冲突。 - pending[requestId] = reply + pending[requestId] = { data in channel.reply(data) } registry.patch(event.sessionId) { s in s.pendingQuestion = nil s.pendingAskUser = PendingAskUser( @@ -119,6 +163,8 @@ public final class PermissionPrompter { originalToolInputJSON: originalJSON ) } + + registerDisconnectCleanup(channel: channel, sessionId: event.sessionId, requestId: requestId) } /// 用户提交答案。answers 形如 `{ "问题文案": "回答" }`,对应 questions 顺序映射。 diff --git a/Sources/Hopet/Pet/PetStageView.swift b/Sources/Hopet/Pet/PetStageView.swift index 8e05711..93e9fc7 100644 --- a/Sources/Hopet/Pet/PetStageView.swift +++ b/Sources/Hopet/Pet/PetStageView.swift @@ -1,6 +1,9 @@ import SwiftUI -/// 一只宠物 + 它周围的环绕气泡。整体放进 PetWindow 里。 +/// 一只宠物 + 紧贴它头顶的会话气泡列。整体放进 PetWindow 里。 +/// +/// 气泡列从下往上堆叠:最贴近宠物的是最新一个会话,越上越旧;最多保留 5 条, +/// 第 6 个新会话进来时把最旧那条挤出列表。空状态下只显示宠物。 public struct PetStageView: View { @ObservedObject var registry: SessionRegistry @ObservedObject var themes: ThemeStore @@ -10,10 +13,18 @@ public struct PetStageView: View { /// (sessionId, requestId, answers, cancel) let onResolveAskUser: (String, String, [String: String], Bool) -> Void - @State private var expandedBubbleId: String? @State private var now: Date = Date() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + /// 列表最多展示几个会话气泡。超出的旧会话被滚出视野(仍存在于 registry,仅不显示)。 + private static let maxVisibleBubbles: Int = 5 + /// 气泡列与宠物头顶之间的视觉间距。 + private static let bubbleToPetGap: CGFloat = 6 + /// 气泡之间的纵向间距。 + private static let interBubbleSpacing: CGFloat = 6 + /// 宠物距离窗口底部的留白。clamp 到屏幕底之后,这一段就是海豹和 dock 之间的安全距离。 + private static let petBottomPadding: CGFloat = 30 + public init( registry: SessionRegistry, themes: ThemeStore, @@ -32,128 +43,69 @@ public struct PetStageView: View { registry.pets[tool] ?? PetInstance(tool: tool) } + /// 列表里的会话:按 `startedAt` 升序倒排,最新的排数组开头。 + /// 注意 ForEach 渲染时再做一次 reverse —— 想让"最新会话紧贴宠物",VStack 里 + /// 它必须出现在最后一个位置。 private var sessions: [Session] { registry.activeSessions(of: tool) - .sorted { $0.startedAt < $1.startedAt } + .sorted { $0.startedAt > $1.startedAt } + .prefix(PetStageView.maxVisibleBubbles) + .map { $0 } } public var body: some View { - let slots = BubbleLayout.slots(count: sessions.count) - return ZStack { - // 环绕气泡先渲染(在宠物之后避免遮挡,但 zIndex 控制叠放) - ForEach(Array(zip(sessions.indices, sessions)), id: \.1.id) { idx, session in - let slot = slots[idx] - let bubble = makeBubble(session: session, slot: slot) - // 有任何待决策项时强制展开(这是用户必须看到的)。 - let mustExpand = session.pendingPermission != nil - || session.pendingAskUser != nil - || session.pendingQuestion != nil - let isExpanded = mustExpand || (expandedBubbleId == session.id) - let displayBubble = bubble.with(expanded: isExpanded) - let cardSize = expandedSize(for: session) - let offset = bubbleOffset(slot: slot, isExpanded: isExpanded, cardSize: cardSize) + VStack(spacing: 0) { + Spacer(minLength: 0) - SessionBubbleView( - bubble: displayBubble, - isLeader: pet.drivenBySessionId == session.id, - elapsedShort: session.elapsedDescription(now: now), - stateDurationPhrase: session.stateDurationPhrase(now: now), - onTap: { - expandedBubbleId = (expandedBubbleId == session.id) ? nil : session.id - }, - onResolvePermission: { decision, reason in - guard let pp = session.pendingPermission else { return } - onResolvePermission(session.id, pp.requestId, decision, reason) - }, - onResolveAskUser: { answers, cancel in - guard let pa = session.pendingAskUser else { return } - onResolveAskUser(session.id, pa.requestId, answers, cancel) - expandedBubbleId = nil - }, - onDismiss: { expandedBubbleId = nil } - ) - .offset(x: offset.x, y: offset.y) - .animation(.spring(response: 0.32, dampingFraction: 0.78), value: isExpanded) - .zIndex(isExpanded ? 10 : 1) + // 旧 → 新(自上而下)。VStack 末项 = 最新会话,紧贴宠物头顶。 + VStack(spacing: PetStageView.interBubbleSpacing) { + ForEach(Array(sessions.reversed())) { session in + SessionBubbleView( + bubble: makeBubble(session: session), + isLeader: pet.drivenBySessionId == session.id, + stateDurationPhrase: session.stateDurationPhrase(now: now), + onResolvePermission: { decision, reason in + guard let pp = session.pendingPermission else { return } + onResolvePermission(session.id, pp.requestId, decision, reason) + }, + onResolveAskUser: { answers, cancel in + guard let pa = session.pendingAskUser else { return } + onResolveAskUser(session.id, pa.requestId, answers, cancel) + } + ) + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .opacity + )) + } } + .padding(.bottom, PetStageView.bubbleToPetGap) + .animation(.spring(response: 0.32, dampingFraction: 0.82), value: sessions.map(\.id)) PetBadgeView( tool: tool, state: pet.aggregatedState, theme: themes.activeTheme ) - .zIndex(5) + + Spacer().frame(height: PetStageView.petBottomPadding) } .frame(width: PetWindow.stageSize.width, height: PetWindow.stageSize.height) .onReceive(timer) { now = $0 } } - /// 展开后卡片的尺寸上限(与 SessionBubbleView 内部 frame 必须保持一致)。 - /// 部分卡片高度自适应内容,这里取保守上限,仅用于外推距离的"避让"计算 —— - /// 估高一点只会让卡片离宠物更远,不会遮挡;估低则可能压到宠物。 - private func expandedSize(for session: Session) -> CGSize { - if let pp = session.pendingPermission { - // ExitPlanMode 渲染整段 plan markdown,尺寸明显大于普通 allow/deny 卡片。 - return pp.isPlanApproval - ? CGSize(width: 420, height: 540) - : CGSize(width: 380, height: 240) - } - if session.pendingAskUser != nil { return CGSize(width: 360, height: 460) } - if session.pendingQuestion != nil { return CGSize(width: 320, height: 130) } - return CGSize(width: 360, height: 96) - } - - /// 折叠态保持原 slot 偏移;展开态沿 slot 方向把卡片外推, - /// 保证卡片靠近宠物那一侧的边距宠物中心 ≥ 宠物半径 + 安全间距,绝不遮挡主体。 - private func bubbleOffset(slot: BubbleLayout.Slot, isExpanded: Bool, cardSize: CGSize) -> CGPoint { - if !isExpanded { - return CGPoint(x: slot.offsetX, y: slot.offsetY) - } - let theta = slot.angleDegrees * .pi / 180 - let dirX = CGFloat(cos(theta)) - let dirY = CGFloat(sin(theta)) - // 宠物 128×128 圆角矩形:水平/垂直方向半径 64,对角处略小,用 64 作为最保守半径。 - let petHalfExtent: CGFloat = 64 - let safeGap: CGFloat = 16 - let cardHalfExtentAlongDir = projectedHalfExtent(width: cardSize.width, height: cardSize.height, dirX: dirX, dirY: dirY) - let minCenterDistance = petHalfExtent + safeGap + cardHalfExtentAlongDir - let slotRadius = sqrt(slot.offsetX * slot.offsetX + slot.offsetY * slot.offsetY) - let dist = max(slotRadius, minCenterDistance) - return CGPoint(x: dirX * dist, y: dirY * dist) - } - - /// 轴对齐矩形从中心沿单位方向 (dirX, dirY) 到边的距离。 - private func projectedHalfExtent(width: CGFloat, height: CGFloat, dirX: CGFloat, dirY: CGFloat) -> CGFloat { - let ax = abs(dirX) - let ay = abs(dirY) - if ax < 1e-6 { return height / 2 } - if ay < 1e-6 { return width / 2 } - return min(width / (2 * ax), height / (2 * ay)) - } - - private func makeBubble(session: Session, slot: BubbleLayout.Slot) -> SessionBubble { + private func makeBubble(session: Session) -> SessionBubble { SessionBubble( id: session.id, tool: session.tool, - orbitAngle: slot.angleDegrees, - orbitRing: slot.ring, displayTitle: session.displayTitle, hasTitle: session.title != nil, displayCwd: session.cwdLastComponent, - displayElapsed: session.elapsedDescription(now: now), state: session.currentState, - expanded: false, + lastAssistantMessage: session.lastAssistantMessage, pendingQuestion: session.pendingQuestion, pendingAskUser: session.pendingAskUser, pendingPermission: session.pendingPermission ) } } - -private extension SessionBubble { - func with(expanded: Bool) -> SessionBubble { - var copy = self - copy.expanded = expanded - return copy - } -} diff --git a/Sources/Hopet/Pet/PetWindow.swift b/Sources/Hopet/Pet/PetWindow.swift index 2c82a9f..1d6c2fa 100644 --- a/Sources/Hopet/Pet/PetWindow.swift +++ b/Sources/Hopet/Pet/PetWindow.swift @@ -3,10 +3,13 @@ import SwiftUI /// 宿主一只宠物的非激活、跨 Space、置顶 NSPanel。 public final class PetWindow: NSPanel { - /// 宠物舞台容器尺寸。需要够大以容下"任一展开卡片沿环绕方向外推 + 卡片半宽", - /// 否则第一环水平展开(外推中心 ~260px + 卡片半宽 180px = 440px)会超出窗口被裁切。 + /// 宠物舞台容器尺寸。布局:海豹(128px)固定在窗口底部 80px 留白上方,气泡列从海豹头顶 + /// 自下往上堆 5 条;宽度按最宽气泡卡片(plan-approval = 320)+ 余量;高度按 1 个 plan-approval + /// (~400) + 海豹 + padding 估算,能让最常见与最坏情形都不裁切,又不至于把太多空白塞进窗口 + /// 让用户拖窗口时拖到的全是顶部空气、海豹永远爬不到屏幕上半部分。 + /// 拖动通过 constrainFrameRect 约束到 screen.visibleFrame 内,不会跑到屏幕外。 /// PetStageView / hosting view / Window 必须使用同一组尺寸。 - public static let stageSize = CGSize(width: 1100, height: 800) + public static let stageSize = CGSize(width: 380, height: 620) public let tool: AITool @@ -33,6 +36,42 @@ public final class PetWindow: NSPanel { public override var canBecomeKey: Bool { true } public override var canBecomeMain: Bool { false } + + /// 拖动 / 程序设置 frame 都路过它。把窗口完整约束到当前 screen 的 visibleFrame(去 menu bar / dock)。 + /// 横向额外留 `horizontalEdgeInset` 边距,让海豹贴边时和屏幕边沿之间有视觉呼吸空间。 + /// 窗口比 visibleFrame 大时退化为不强求贴齐,仅锁不出界。 + private static let horizontalEdgeInset: CGFloat = 30 + + /// 纯函数 clamp:给定 rect 与 visibleFrame,按 horizontalEdgeInset 把 rect 锁进可视区。 + /// AppKit 坐标 y 朝上:visible.minY 是 dock 上沿,maxY 是 menu bar 下沿。 + private static func clamp(_ rect: NSRect, into visible: NSRect) -> NSRect { + var r = rect + if r.width + horizontalEdgeInset * 2 <= visible.width { + r.origin.x = min(max(r.origin.x, visible.minX + horizontalEdgeInset), + visible.maxX - horizontalEdgeInset - r.width) + } + if r.height <= visible.height { + r.origin.y = min(max(r.origin.y, visible.minY), visible.maxY - r.height) + } + return r + } + + /// isMovableByWindowBackground 拖动直接调 setFrameOrigin,绕过 constrainFrameRect, + /// 必须在这里截一道才能让左右上下都被 clamp。 + public override func setFrameOrigin(_ point: NSPoint) { + super.setFrameOrigin(constrainFrameRect(NSRect(origin: point, size: frame.size), to: self.screen).origin) + } + + public override func setFrame(_ frameRect: NSRect, display flag: Bool) { + super.setFrame(constrainFrameRect(frameRect, to: self.screen), display: flag) + } + + /// 系统路径(zoom / 多屏切换 / NSWindowRestoration)+ setFrame/setFrameOrigin 都走这一条。 + public override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { + let target = screen ?? self.screen ?? NSScreen.main + guard let visible = target?.visibleFrame else { return frameRect } + return PetWindow.clamp(frameRect, into: visible) + } } /// `NSHostingView` 子类:让窗口非 key 时的第一次点击直接送到按钮, diff --git a/Sources/Hopet/Pet/SessionBubbleView.swift b/Sources/Hopet/Pet/SessionBubbleView.swift index a6eb639..f4a4eac 100644 --- a/Sources/Hopet/Pet/SessionBubbleView.swift +++ b/Sources/Hopet/Pet/SessionBubbleView.swift @@ -1,6 +1,7 @@ import SwiftUI -/// 单个会话气泡。折叠态 64×64,展开态根据 pendingPermission / pendingAskUser / pendingQuestion 自适应。 +/// 单个会话气泡。永远以圆角矩形像素风卡片形式展示,根据 pendingPermission / +/// pendingAskUser / pendingQuestion 自适应内容;空闲态展示 cwd / 标题 / 状态徽章。 /// /// 可交互的输入只出现在 PermissionRequest hook 同步打通的两条路径上: /// - `pendingPermission`:Allow / Deny / Ask 按钮,回包通过挂起的 socket。 @@ -18,37 +19,27 @@ public struct SessionBubbleView: View { let bubble: SessionBubble let isLeader: Bool - /// 折叠态用的紧凑耗时,例如 "5m" / "30s"。 - let elapsedShort: String - /// 展开态用的描述:运行中显示"已运行 X",闲置显示"X 前"。 + /// 描述:运行中显示"已运行 X",闲置显示"X 前"。 let stateDurationPhrase: String - let onTap: () -> Void /// (decision, reason). `decision` ∈ {"allow", "deny", "ask"};`reason` 仅 deny 路径有意义 /// (承载 plan-approval "继续规划" 默认理由或用户自定义反馈)。 let onResolvePermission: (String, String?) -> Void /// AskUserQuestion 答题提交回调。`answers` 形如 `{ "问题文案": "回答" }`; /// `cancel = true` 表示用户取消(让 Claude 走自身 UI)。 let onResolveAskUser: ([String: String], Bool) -> Void - let onDismiss: () -> Void public init( bubble: SessionBubble, isLeader: Bool, - elapsedShort: String, stateDurationPhrase: String, - onTap: @escaping () -> Void, onResolvePermission: @escaping (String, String?) -> Void = { _, _ in }, - onResolveAskUser: @escaping ([String: String], Bool) -> Void = { _, _ in }, - onDismiss: @escaping () -> Void + onResolveAskUser: @escaping ([String: String], Bool) -> Void = { _, _ in } ) { self.bubble = bubble self.isLeader = isLeader - self.elapsedShort = elapsedShort self.stateDurationPhrase = stateDurationPhrase - self.onTap = onTap self.onResolvePermission = onResolvePermission self.onResolveAskUser = onResolveAskUser - self.onDismiss = onDismiss } @FocusState private var inputFocused: Bool @@ -68,19 +59,7 @@ public struct SessionBubbleView: View { public var body: some View { ZStack { - if bubble.expanded { - expandedCard - .transition(.asymmetric( - insertion: .scale(scale: 0.35, anchor: .center).combined(with: .opacity), - removal: .bubblePopOut - )) - } else { - collapsedDot - .transition(.asymmetric( - insertion: .scale(scale: 0.6, anchor: .center).combined(with: .opacity), - removal: .scale(scale: 0.6, anchor: .center).combined(with: .opacity) - )) - } + cardBody // 炸开粒子层:8-bit 风格用方块代替圆形,硬边描边,无模糊。 ForEach(popParticles) { p in @@ -94,13 +73,11 @@ public struct SessionBubbleView: View { } .allowsHitTesting(false) } - .animation(.spring(response: 0.34, dampingFraction: 0.78), value: bubble.expanded) } - /// 触发"炸开 → 执行 action":先散粒子,再调闭包。所有让大泡泡消失的入口都走它。 - /// - 默认普通态点击收起:`popThen { onTap() }` - /// - 决策类按钮:`popThen { onResolvePermission(...) }` / `popThen { onResolveAskUser(...) }` - /// - 关闭按钮:`popThen { onDismiss() }` + /// 决策按钮按下的反馈动画:先散粒子,再调闭包回写决策。 + /// `pendingPermission` / `pendingAskUser` 解决后由协议层把字段置空, + /// 视图自然重渲染回默认卡片(不再有"小气泡缩回"语义)。 private func popThen(_ action: @escaping () -> Void) { guard popParticles.isEmpty else { return } // 防抖:动画期间忽略二次触发 let count = 10 @@ -137,66 +114,30 @@ public struct SessionBubbleView: View { } } - private var collapsedDot: some View { - let isRunning = bubble.state.isRunning - - return VStack(spacing: 2) { - HStack(spacing: 3) { - Rectangle() - .fill(bubble.state.accentColor) - .frame(width: 6, height: 6) - Text(bubble.displayCwd) - .font(.system(size: 10, weight: .bold, design: .monospaced)) - .lineLimit(1) - .minimumScaleFactor(0.6) - .truncationMode(.middle) - } - HStack(spacing: 2) { - Image(systemName: isRunning ? "play.fill" : "clock") - .font(.system(size: 7)) - Text(elapsedShort) - .font(.system(size: 9, weight: .semibold, design: .monospaced)) - .lineLimit(1) - } - .foregroundStyle(.secondary) - } - .padding(.horizontal, 6) - .frame(width: 64, height: 64) - .modifier(PixelChrome( - shape: .circle, - accent: bubble.state.accentColor, - strokeWidth: isLeader ? 1.5 : 1, - strokeColor: Color(white: 0.32).opacity(0.85) - )) - .contentShape(Circle()) - .onTapGesture { onTap() } - } - + /// 卡片主体:所有形态都共用同一种像素圆角矩形外壳,仅内部内容随 + /// pendingPermission / pendingAskUser / pendingQuestion 自适应。 + /// 优先级:plan-approval > permission > AskUserQuestion 答题 > 旧 fire-and-forget pendingQuestion > 默认信息卡。 @ViewBuilder - private var expandedCard: some View { - // 优先级:权限请求 > 结构化 AskUserQuestion > 早期 fire-and-forget pendingQuestion > 普通输入。 - // 同时只能渲染一种主体内容。 - // 决策类卡片信息密集(按钮组、列表、文本框),保持像素圆角矩形; - // 默认大气泡是单行信息卡,做成胶囊更接近漫画对话框。 + private var cardBody: some View { if let pp = bubble.pendingPermission, pp.isPlanApproval { - planApprovalCard.modifier(decisionChrome(shape: .rect(cornerRadius: 10))) + planApprovalCard.modifier(decisionChrome()) } else if bubble.pendingPermission != nil { - permissionCard.modifier(decisionChrome(shape: .rect(cornerRadius: 10))) + permissionCard.modifier(decisionChrome()) } else if bubble.pendingAskUser != nil { - elicitationCard.modifier(decisionChrome(shape: .rect(cornerRadius: 10))) + elicitationCard.modifier(decisionChrome()) } else if bubble.pendingQuestion != nil { - askUserCard.modifier(decisionChrome(shape: .rect(cornerRadius: 10))) + askUserCard.modifier(decisionChrome()) } else { - defaultCard.modifier(decisionChrome(shape: .capsule)) + defaultCard.modifier(decisionChrome()) } } - /// 大气泡共用外壳:accent 跟随当前 PetState,黑描边 + 2pt 厚度统一。 - private func decisionChrome(shape: PixelChrome.Shape) -> PixelChrome { + /// 大气泡共用外壳:圆角矩形像素风。leader session 描边略加粗以区分谁在驱动宠物。 + private func decisionChrome() -> PixelChrome { PixelChrome( - shape: shape, + cornerRadius: 10, accent: bubble.state.accentColor, - strokeWidth: 2, + strokeWidth: isLeader ? 2.5 : 2, strokeColor: Color.black.opacity(0.92) ) } @@ -229,15 +170,15 @@ public struct SessionBubbleView: View { /// 权限请求卡片(PermissionRequest hook 触发)。 private var permissionCard: some View { let pp = bubble.pendingPermission! - return VStack(alignment: .leading, spacing: 14) { + return VStack(alignment: .leading, spacing: 10) { // Header:工具名(caps tracking)+ 隐藏式关闭。 - HStack(spacing: 8) { + HStack(spacing: 6) { Image(systemName: "shield.lefthalf.filled") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(bubble.state.accentColor) Text(pp.toolName.uppercased()) - .font(.system(size: 10, weight: .semibold)) - .tracking(0.8) + .font(.system(size: 9, weight: .semibold)) + .tracking(0.7) .foregroundStyle(.secondary) .lineLimit(1) Spacer() @@ -246,26 +187,26 @@ public struct SessionBubbleView: View { // 主标题 Text("Claude 想执行此操作") - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) // 详情:command / filePath if let cmd = pp.command { detailBlock { Text(cmd) - .font(.system(size: 12, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.primary) - .lineLimit(5) + .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) } } else if let fp = pp.filePath { detailBlock { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 5) { Image(systemName: "doc.text") - .font(.system(size: 11)) + .font(.system(size: 10)) .foregroundStyle(.secondary) Text(fp) - .font(.system(size: 12, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.primary) .lineLimit(2) .truncationMode(.middle) @@ -275,7 +216,7 @@ public struct SessionBubbleView: View { } // 操作按钮:所有按钮都先播炸开动画再回调 - HStack(spacing: 8) { + HStack(spacing: 6) { Button("拒绝") { popThen { onResolvePermission("deny", nil) } } .buttonStyle(PixelButtonStyle(tint: SessionBubbleView.denyRose, prominent: true)) Spacer() @@ -285,11 +226,10 @@ public struct SessionBubbleView: View { .keyboardShortcut(.return, modifiers: []) .buttonStyle(PixelButtonStyle(tint: .green, prominent: true)) } - .padding(.top, 2) } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .frame(width: 380) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: 300) .fixedSize(horizontal: false, vertical: true) } @@ -298,10 +238,10 @@ public struct SessionBubbleView: View { private var planApprovalCard: some View { let pp = bubble.pendingPermission! - return VStack(alignment: .leading, spacing: 12) { + return VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text("接受此 plan?") - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() handoffXButton { popThen { onResolvePermission("ask", nil) } } @@ -311,16 +251,16 @@ public struct SessionBubbleView: View { detailBlock { ScrollView { Text(plan) - .font(.system(size: 11)) + .font(.system(size: 10)) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } - .frame(maxHeight: 220) + .frame(maxHeight: 180) } } - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { planOptionRow(index: 1, label: "允许执行", isPrimary: true) { popThen { onResolvePermission("allow", nil) } } @@ -333,18 +273,18 @@ public struct SessionBubbleView: View { .textFieldStyle(.roundedBorder) .lineLimit(1...3) .focused($inputFocused) - .font(.system(size: 11)) + .font(.system(size: 10)) .onSubmit { submitPlanFeedback() } Text("如需后续自动放行,请到 Claude Code 终端按 Shift+Tab 切换 auto-accept 模式") - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(.secondary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .frame(width: 420) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: 320) .fixedSize(horizontal: false, vertical: true) } @@ -414,10 +354,10 @@ public struct SessionBubbleView: View { let idx = min(elicitationIndex, total - 1) let current: AskUserQuestionItem? = pa.questions.indices.contains(idx) ? pa.questions[idx] : nil - return VStack(alignment: .leading, spacing: 8) { + return VStack(alignment: .leading, spacing: 6) { HStack { Text(total > 1 ? "❓ 在等你回答(\(idx + 1)/\(total))" : "❓ 在等你回答") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) Spacer() paletteDismissButton { popThen { onResolveAskUser([:], true) } @@ -426,7 +366,7 @@ public struct SessionBubbleView: View { if let q = current { Text(q.question) - .font(.system(size: 11)) + .font(.system(size: 10)) .foregroundStyle(.primary) .lineLimit(4) @@ -436,7 +376,7 @@ public struct SessionBubbleView: View { let multi = q.multiSelect == true let selected = elicitationSelected[q.question] ?? [] ScrollView { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { ForEach(opts, id: \.label) { opt in let isSelected = multi && selected.contains(opt.label) Button(action: { @@ -447,27 +387,27 @@ public struct SessionBubbleView: View { advanceOrSubmit(pa: pa) } }) { - HStack(alignment: .top, spacing: 6) { + HStack(alignment: .top, spacing: 5) { if multi { Image(systemName: isSelected ? "checkmark.square.fill" : "square") .foregroundStyle(isSelected ? Color.accentColor : .secondary) .padding(.top, 1) } - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { Text(opt.label) - .font(.system(size: 11, weight: .medium)) + .font(.system(size: 10, weight: .medium)) .foregroundStyle(.primary) if let desc = opt.description, !desc.isEmpty { Text(desc) - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } Spacer(minLength: 0) } - .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.horizontal, 7) + .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 6) @@ -487,6 +427,7 @@ public struct SessionBubbleView: View { .textFieldStyle(.roundedBorder) .lineLimit(1...4) .focused($inputFocused) + .font(.system(size: 10)) .onSubmit { advanceOrSubmit(pa: pa) } } } @@ -495,6 +436,7 @@ public struct SessionBubbleView: View { if idx > 0 { Button("上一题") { elicitationIndex = idx - 1 } .buttonStyle(.bordered) + .controlSize(.small) } Spacer() Button(idx < total - 1 ? "下一题" : "发送") { @@ -502,12 +444,13 @@ public struct SessionBubbleView: View { } .keyboardShortcut(.return, modifiers: []) .buttonStyle(.borderedProminent) + .controlSize(.small) .disabled(!hasAnswer(for: current)) } } - .padding(12) - .frame(width: 360) - .frame(minHeight: 220, maxHeight: 460) + .padding(10) + .frame(width: 300) + .frame(minHeight: 180, maxHeight: 380) .onAppear { inputFocused = true } } @@ -569,81 +512,78 @@ public struct SessionBubbleView: View { } /// 旧 fire-and-forget pendingQuestion 卡片(PreToolUse `tool_name=AskUserQuestion` 路径)。 - /// 这条路径没带 requestId,无法同步回包,只能展示提示让用户回到原终端作答。 - /// 新版本若 PermissionRequest 同步路径触发,会优先走 elicitationCard。 + /// 这条路径没带 requestId,无法同步回包,只能展示提示让用户回到原终端作答; + /// 新会话列表布局下卡片常驻显示,没有"关闭"语义——pendingQuestion 由协议层在 + /// 下一次状态变更时自然清空,视图随即回到 defaultCard。 private var askUserCard: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("❓ 在等你回答") - .font(.system(size: 12, weight: .semibold)) - Spacer() - paletteDismissButton { popThen { onDismiss() } } - } + VStack(alignment: .leading, spacing: 6) { + Text("❓ 在等你回答") + .font(.system(size: 11, weight: .semibold)) if let q = bubble.pendingQuestion { Text(q) - .font(.system(size: 11)) + .font(.system(size: 10)) .foregroundStyle(.primary) .lineLimit(4) } Text("请到原终端 / Claude UI 回答。") - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(.secondary) } - .padding(12) - .frame(width: 320, height: 130) + .padding(10) + .frame(width: 260) + .fixedSize(horizontal: false, vertical: true) } - /// 默认卡片:横向不规则大气泡。展示目录、标题(若有)、状态持续时长。整张卡再点击一次收起。 + /// 默认卡片:两行布局—— + /// - 第 1 行:状态圆点 + `cwd · title`(无 title 只显示 cwd)+ 右端 stateDurationPhrase + /// - 第 2 行:最近一次 Claude 回复的开头,9pt secondary,最多 2 行;没有回复时回退到状态徽章 + /// (状态文字已在第一行右端用耗时短语承担时间信息,这里 fallback 让卡片不出现空行抖动) private var defaultCard: some View { - HStack(alignment: .top, spacing: 12) { - Circle() - .fill(bubble.state.accentColor) - .frame(width: 8, height: 8) - .padding(.top, 6) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + Circle() + .fill(bubble.state.accentColor) + .frame(width: 6, height: 6) - VStack(alignment: .leading, spacing: 3) { - if bubble.hasTitle { - Text(bubble.displayCwd) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Text(bubble.displayTitle) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } else { - // 没有真实标题:只渲染目录,不再补占位"标题"行,避免重复。 - Text(bubble.displayCwd) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - .truncationMode(.middle) - } - } + Text(headerLabel) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) - Spacer(minLength: 8) + Spacer(minLength: 6) - VStack(alignment: .trailing, spacing: 3) { - // 状态文字保持中性色:状态色由左侧小圆点承担,文字不再夹带 emoji 也不再换色。 - Text(bubble.state.badgeLabel) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() Text(stateDurationPhrase) - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(.secondary) .lineLimit(1) .fixedSize() } + + Text(secondLineText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .frame(width: 360, alignment: .leading) - .frame(minHeight: 76) - .contentShape(Rectangle()) - .onTapGesture { popThen { onTap() } } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 260, alignment: .leading) + } + + /// 第一行:有真实标题时 `cwd · title`,否则只渲染 cwd。 + private var headerLabel: String { + bubble.hasTitle + ? "\(bubble.displayCwd) · \(bubble.displayTitle)" + : bubble.displayCwd + } + + /// 第二行:优先展示最近一次回复的开头;没回复就 fallback 到状态徽章文字(避免卡片高度跳变)。 + private var secondLineText: String { + if let msg = bubble.lastAssistantMessage, !msg.isEmpty { + return msg + } + return bubble.state.badgeLabel } } @@ -658,28 +598,11 @@ private struct PopParticle: Identifiable { var scale: CGFloat } -private extension AnyTransition { - /// 8-bit 风的"压扁消失":缩放 + 淡出,无模糊。配合方块粒子完成 pop 效果。 - static var bubblePopOut: AnyTransition { - .scale(scale: 0.85, anchor: .center).combined(with: .opacity) - } -} - /// 8-bit 像素风外壳:阶梯像素圆角 + 近白底 + 顶部高光 + 块状阴影。所有几何沿 `pixelSize` 方格对齐, /// 圆角处呈现可见的 2pt 颗粒阶梯——视觉上对齐参考素材的复古 UI 边缘,与小海豹 sprite 同语言。 -/// `shape` 切换三种漫画对话框形状: -/// - `.rect(r)`:固定圆角矩形(决策类卡片) -/// - `.capsule`:cornerRadius = min(w,h)/2,长条椭圆/胶囊(默认大气泡) -/// - `.circle`:min(w,h)/2 + 任意框成正方形时退化成圆(折叠态小气泡) -/// 描边宽度 / 颜色独立可调:折叠态小气泡偏好细灰描边(不抢戏),决策类大卡片偏好粗黑描边(强调)。 +/// 描边宽度 / 颜色独立可调:leader session 偏好略粗的黑描边(强调),其余气泡保持基础粗细。 private struct PixelChrome: ViewModifier { - enum Shape: Equatable { - case rect(cornerRadius: CGFloat) - case capsule - case circle - } - - let shape: Shape + let cornerRadius: CGFloat let accent: Color let strokeWidth: CGFloat let strokeColor: Color @@ -692,8 +615,8 @@ private struct PixelChrome: ViewModifier { return content .padding(strokeInset + 1) // 让内容不撞到内层亮边 .background( - GeometryReader { proxy in - let cr = self.cornerRadius(in: proxy.size) + GeometryReader { _ in + let cr = self.cornerRadius let outer = PixelRoundedRectangle(cornerRadius: cr, pixelSize: pixel) let inner = PixelRoundedRectangle( cornerRadius: max(pixel, cr - strokeInset), @@ -726,13 +649,6 @@ private struct PixelChrome: ViewModifier { } ) } - - private func cornerRadius(in size: CGSize) -> CGFloat { - switch shape { - case .rect(let r): return r - case .capsule, .circle: return min(size.width, size.height) / 2 - } - } } /// 像素化圆角矩形:把 4 个圆角拆成 `pixelSize` 大小的方格阶梯,整体呈现 8-bit UI 边缘的颗粒感。 diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/00.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/00.png new file mode 100644 index 0000000..9535fd2 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/00.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/01.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/01.png new file mode 100644 index 0000000..0e80f10 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/01.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/02.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/02.png new file mode 100644 index 0000000..6a4196f Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/02.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/03.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/03.png new file mode 100644 index 0000000..d387c30 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/03.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/04.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/04.png new file mode 100644 index 0000000..e95c5c5 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/04.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/05.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/05.png new file mode 100644 index 0000000..431b1cd Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/05.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/06.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/06.png new file mode 100644 index 0000000..5e71b1b Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/06.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/07.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/07.png new file mode 100644 index 0000000..5e71b1b Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/07.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/08.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/08.png new file mode 100644 index 0000000..ebc4ad2 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/08.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/09.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/09.png new file mode 100644 index 0000000..9e688f8 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/09.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/10.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/10.png new file mode 100644 index 0000000..00abb81 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/10.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/11.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/11.png new file mode 100644 index 0000000..ea51762 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/11.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/12.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/12.png new file mode 100644 index 0000000..74762f7 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/12.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/13.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/13.png new file mode 100644 index 0000000..35e062d Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/13.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/14.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/14.png new file mode 100644 index 0000000..35e062d Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/14.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/15.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/15.png new file mode 100644 index 0000000..378f993 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/15.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/16.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/16.png new file mode 100644 index 0000000..e52c86a Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/16.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/17.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/17.png new file mode 100644 index 0000000..7574d67 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/17.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/18.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/18.png new file mode 100644 index 0000000..e97dd6f Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/18.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/19.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/19.png new file mode 100644 index 0000000..dbae100 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/19.png differ diff --git a/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/20.png b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/20.png new file mode 100644 index 0000000..dbae100 Binary files /dev/null and b/Sources/Hopet/Resources/Themes/Hopi/seal-play-ball/20.png differ diff --git a/Sources/Hopet/Theme/DefaultTheme.swift b/Sources/Hopet/Theme/DefaultTheme.swift index a7529fb..91c1c46 100644 --- a/Sources/Hopet/Theme/DefaultTheme.swift +++ b/Sources/Hopet/Theme/DefaultTheme.swift @@ -27,7 +27,7 @@ public enum DefaultTheme { .idle: "seal-idle", .thinking: "seal-thinking", .responding: "seal-working", - .toolUse: "seal-tool-use", + .toolUse: "seal-play-ball", .permissionPrompt: "seal-permission-prompt", .askUser: "seal-ask-user", .completed: "seal-completed", diff --git a/Sources/hopet-emit/main.swift b/Sources/hopet-emit/main.swift index 9b90cab..91b8513 100644 --- a/Sources/hopet-emit/main.swift +++ b/Sources/hopet-emit/main.swift @@ -164,6 +164,182 @@ for path in allowedKeys { } } +// MARK: - Stop hook: extract last assistant message +// +// Stop hook 触发时希望把 Claude 这一轮回复的开头送到 Hopet,让默认气泡第二行能展示。 +// 取数顺序: +// 1. 若 stdin JSON 直接带 `assistant_message`(部分版本 Claude Code 已暴露),用它。 +// 2. 否则解析 `transcript_path` 指向的 JSONL,从尾向前找最后一条 `type:"assistant"` +// 或 `message.role:"assistant"` 行,把所有 `content[].type=="text"` 的 text 拼接。 +// 取到后 trim → 去换行折叠 → 截断到 120 字符 → 写进 safePayload["assistant_message"], +// EventRouter 在 .stop 分支读出这一字段并落到 Session.lastAssistantMessage。 +// +// 解析失败一律静默:fire-and-forget hook,宁可气泡少显示一行也不能阻塞 Claude。 + +func collectAssistantText(fromMessage msg: Any) -> String? { + // Claude transcript 行常见两种结构: + // { "type": "assistant", "message": { "content": [...] } } + // { "type": "assistant", "content": [...] } + let content: Any? + if let m = msg as? [String: Any], let c = m["content"] { + content = c + } else { + content = msg + } + guard let arr = content as? [[String: Any]] else { + if let s = content as? String { return s } + return nil + } + var pieces: [String] = [] + for block in arr { + guard (block["type"] as? String) == "text", + let text = block["text"] as? String else { continue } + pieces.append(text) + } + let joined = pieces.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : joined +} + +/// 只读 transcript 尾部最多这么多字节。Claude transcript 含 tool_use / tool_result 单 session +/// 常见 1–10 MiB;fsevents 每次 write 重读全量在长会话上叠加 100ms+ × N 次。本轮 end_turn +/// assistant text + 前一条 user prompt 几乎必在末尾 1 MiB 内,read 范围按这个上限。 +private let transcriptTailBudget: Int = 1 * 1024 * 1024 + +func lastAssistantText(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 } + + // 起点切到行中间时,首个 '\n' 之前是不完整 JSON 片段,丢弃。从文件 0 起读时保留首行。 + let text: Substring + if startOffset > 0, let firstNewline = raw.firstIndex(of: "\n") { + text = raw[raw.index(after: firstNewline)...] + } else { + text = Substring(raw) + } + + // JSONL:从尾向前扫。只接受位于"最后一条用户提问之后"的 end_turn assistant, + // 否则可能拿到上一轮残留的 end_turn(hopet-emit 启动时本轮 assistant 还没刷盘的常见场景)。 + // + // 扫描终止条件: + // - 命中 stop_reason="end_turn" 的 assistant:返回该行 text。 + // - 命中 user 提问行(不是 tool_result):返回 nil,让调用方轮询等本轮 end_turn 落地。 + // - 中间 stop_reason="tool_use" / tool_result / attachment / system 等:跳过。 + 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 } + let topType = obj["type"] as? String + let messageDict = obj["message"] as? [String: Any] + let role = messageDict?["role"] as? String + + // user 行:可能是真用户提问,也可能是 tool_result 注入。tool_result 跳过; + // 真用户提问意味着本轮 end_turn 还没刷盘,立即放弃以触发重试。 + if topType == "user" && role == "user" { + let content = messageDict?["content"] + if let arr = content as? [[String: Any]] { + let allToolResult = !arr.isEmpty && arr.allSatisfy { ($0["type"] as? String) == "tool_result" } + if allToolResult { continue } + } + return nil + } + + guard topType == "assistant" || role == "assistant" else { continue } + let messageBlock: Any = messageDict ?? obj + // stop_reason 通常嵌在 message 里;少数 transcript 把它放在顶层。两处都查一下。 + let stopReason = (messageDict?["stop_reason"] as? String) + ?? (obj["stop_reason"] as? String) + guard stopReason == "end_turn" else { continue } + if let extracted = collectAssistantText(fromMessage: messageBlock) { + return extracted + } + } + return nil +} + +/// 等 transcript 写入本轮 end_turn 行后再抽 assistant text。 +/// +/// Stop hook 触发到文件 flush 之间存在毫秒级(甚至秒级)滞后,且滞后量随磁盘 IO、 +/// Claude Code 版本、系统负载漂移。固定时长 polling 不可靠,改用 fsevents 监听 +/// 文件 write,事件即重读,找到本轮 end_turn 立刻返回。 +/// +/// 流程: +/// 1. 立即同步读一次(transcript 已 flush 完的常见路径走这里)。 +/// 2. 没读到则打开 fd + DispatchSource 监听 write/extend/delete/rename。 +/// 3. resume 后再 async 读一次(catch-up:覆盖 register 与文件写入之间错过事件的窗口)。 +/// 4. 等 semaphore,超时(默认 5s)兜底——Claude 异常路径下 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) { + return found + } + + let fd = open(transcriptPath, O_EVTONLY) + guard fd >= 0 else { return nil } + + let queue = DispatchQueue(label: "hopet-emit.transcript-watch") + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .delete, .rename], + queue: queue + ) + let sem = DispatchSemaphore(value: 0) + var result: String? = nil + var settled = false + + let tryReadAndSignal: () -> Void = { + if settled { return } + if let found = lastAssistantText(fromTranscriptAt: transcriptPath) { + result = found + settled = true + sem.signal() + } + } + + source.setEventHandler(handler: tryReadAndSignal) + source.setCancelHandler { close(fd) } + source.resume() + + // catch-up:register/resume 与文件写入之间若已 flush 完成,就不会再有 write + // 事件落到 handler,必须主动补一次读。同 queue 串行,与 event handler 不抢。 + queue.async(execute: tryReadAndSignal) + + _ = sem.wait(timeout: .now() + timeout) + source.cancel() + return result +} + +if eventRaw == "stop" { + var raw: String? = stringify(value(at: "assistant_message", in: hookJson)) + if (raw ?? "").isEmpty, + let tp = stringify(value(at: "transcript_path", in: hookJson)), + !tp.isEmpty { + raw = waitForLastAssistantText(transcriptPath: tp, timeout: 5.0) + } + if var msg = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { + // 多行折叠成单空格:UI 第二行只展示开头,让换行白白吃掉显示长度不划算。 + msg = msg.split(whereSeparator: { $0.isNewline }) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + if msg.count > 120 { + msg = String(msg.prefix(120)) + } + safePayload["assistant_message"] = msg + } +} + // session_id 解析顺序:override > stdin.session_id > random let sessionId: String = args.sessionIdOverride ?? (stringify(value(at: "session_id", in: hookJson))) diff --git a/devDocs/assets/seal-play-ball-spritesheet.png b/devDocs/assets/seal-play-ball-spritesheet.png new file mode 100644 index 0000000..48f2508 Binary files /dev/null and b/devDocs/assets/seal-play-ball-spritesheet.png differ diff --git a/devDocs/assets/seal-play-ball.gif b/devDocs/assets/seal-play-ball.gif new file mode 100644 index 0000000..5c5ad07 Binary files /dev/null and b/devDocs/assets/seal-play-ball.gif differ diff --git a/devDocs/hooks-and-priority.md b/devDocs/hooks-and-priority.md index 5b244b5..fda0b9d 100644 --- a/devDocs/hooks-and-priority.md +++ b/devDocs/hooks-and-priority.md @@ -34,7 +34,7 @@ | 13 | `SubagentStop` | Subagent 完成 | `agent_type` | ✅ | ⛔ v0.1 不订阅 | | 14 | `TaskCreated` | TaskCreate 创建 task | (task metadata) | ✅ | ⛔ v0.1 不订阅 | | 15 | `TaskCompleted` | task 标记完成 | (task metadata) | ✅ | ⛔ v0.1 不订阅 | -| 16 | `Stop` | Claude 完成回复 | (turn metadata) | ✅ | ✅ 注册 → `stop` | +| 16 | `Stop` | Claude 完成回复 | `transcript_path`(必有);部分版本带 `assistant_message` 字段 | ✅ | ✅ 注册 → `stop`,hopet-emit 从 `transcript_path` 反向扫描,遇到本轮 user prompt 即停止(避免取到上一轮残留的 end_turn),只接受位于其后的 `stop_reason=="end_turn"` assistant text;同时跳过中间 `tool_use` turn 的过渡文本。end_turn 行尚未 flush 时用 `DispatchSource` 监听 transcript write/extend,事件即重读,5s 超时兜底(hook fire-and-forget,不阻塞 Claude)。截断 120 字符后随 payload 上行(字段名 `assistant_message`)。EventRouter 写入 `Session.lastAssistantMessage`,供默认气泡第二行展示;下一次 `UserPromptSubmit` 清空 | | 17 | `StopFailure` | turn 因 API 错误终止 | `error_type` (rate_limit/auth_failed/billing_error/...) | 否 | ✅ 注册 → `error`(携带 error_type) | | 18 | `TeammateIdle` | Agent team 队友 idle | (team metadata) | ✅ | ⛔ v0.1 不订阅 | | 19 | `InstructionsLoaded` | CLAUDE.md / .claude/rules/*.md 加载 | `file_path`、`load_reason`、`memory_type` | 否 | ⛔ 不订阅 |