From 5cf6167d278ab816f39413fbda0a65a6b6dbb482 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 5 Aug 2026 14:22:44 +0800 Subject: [PATCH 1/5] moriremote: add host-wide agent attention navigation --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../MoriRemote/App/RemoteRootModel.swift | 140 +++++++++++++++++- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Tmux/AgentMetadataProjector.swift | 120 ++++++++++++--- .../MoriRemote/Views/RemoteRootView.swift | 78 +++++++++- .../Tmux/TmuxSessionController.swift | 2 +- .../MoriRemoteTerminalFacadeTests.swift | 2 +- .../MoriRemoteTests/Phase4ShellTests.swift | 30 ++++ .../Phase5AgentMetadataTests.swift | 78 +++++++--- 11 files changed, 401 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0624e306..dc463b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation. MoriRemote now follows Remux client sizing: phone viewport and software-keyboard changes resize/reflow the shared tmux window, so other attached Mac clients may visibly reflow. - **iOS (MoriRemote)**: Restored the current Remux three-capsule terminal dock and its measured safe-area layout: Ctrl/Esc/Tab, Sessions/Windows/Panes, and Home/Keypad/Keyboard. Sessions, windows, and panes open the existing Navigator at the matching scope; Home opens the library; Keypad retains terminal shortcuts and photo/clipboard image upload. Modal forms still suspend the terminal responder, and Chinese and other IMEs still commit marked text exactly once. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone. +- **iOS (MoriRemote)**: Added a host-wide agent-attention navigator. It reuses the existing bounded tmux metadata query to rank waiting, working, and completed agents across saved sessions, then safely opens the selected session and pane after its runtime topology arrives. ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index a85bbcf5..09b1774c 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -12,6 +12,7 @@ - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态和自适应 iPhone/iPad 界面。MoriRemote 现遵循 Remux 的客户端尺寸规则:手机视口和软件键盘变化会调整/重排共享 tmux 窗口,因此其他已连接的 Mac 客户端也可能发生可见重排。 - **iOS(MoriRemote)**:恢复当前 Remux 的三胶囊终端 Dock 与按实际高度预留的安全区布局:Ctrl/Esc/Tab、会话/窗口/面板、主页/按键面板/键盘。会话、窗口和面板会在既有 Navigator 中打开对应范围;主页打开资源库;按键面板继续提供终端快捷键和照片/剪贴板图片上传。模态表单仍会暂停终端响应器,中文等输入法的组合文本仍只会提交一次。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 +- **iOS(MoriRemote)**:新增全主机 agent 注意事项导航器。它复用既有的受限 tmux 元数据查询,跨已保存会话按等待输入、运行中与完成状态排序,并在目标运行时拓扑到达后安全打开相应会话与 pane。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index 50ed0930..b9a0018e 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -62,6 +62,17 @@ enum RemoteNavigatorProjection { return query.isEmpty || String(pane.id).contains(query) || windowTitle.localizedCaseInsensitiveContains(query) } } + + static func attention(_ values: [AgentAttentionWorkspaceTarget], matching query: String) -> [AgentAttentionWorkspaceTarget] { + values.filter { value in + let target = value.target + return query.isEmpty + || value.workspace.tmuxSession.localizedCaseInsensitiveContains(query) + || target.windowTitle.localizedCaseInsensitiveContains(query) + || String(target.paneID).contains(query) + || (target.metadata.name?.localizedCaseInsensitiveContains(query) ?? false) + } + } } enum ServerSessionDiscoveryStatus: Equatable, Sendable { @@ -76,6 +87,38 @@ enum PendingSSHTrustAction: Equatable, Sendable { case discover(UUID) } +struct AgentAttentionWorkspaceTarget: Identifiable, Equatable, Sendable { + let workspace: SavedWorkspace + let target: AgentAttentionTarget + + var id: UInt64 { target.id } +} + +/// A selection from the host-wide snapshot cannot target a runtime until that +/// runtime has published the exact source topology. The instance fence drops a +/// late topology callback from a replaced control client. +struct PendingAgentAttentionSelection: Equatable, Sendable { + let workspaceID: UUID + let windowID: UInt64 + let paneID: UInt64 + private(set) var runtimeInstanceID: UUID? + + init(workspaceID: UUID, windowID: UInt64, paneID: UInt64) { + self.workspaceID = workspaceID + self.windowID = windowID + self.paneID = paneID + } + + mutating func bind(runtimeInstanceID: UUID) { self.runtimeInstanceID = runtimeInstanceID } + + func matches(workspaceID: UUID, runtimeInstanceID: UUID, topology: MoriRemoteTerminalTopology) -> Bool { + self.workspaceID == workspaceID + && self.runtimeInstanceID == runtimeInstanceID + && topology.windows.contains(where: { $0.id == windowID }) + && topology.panes.contains(where: { $0.id == paneID && $0.windowID == windowID }) + } +} + enum WorkspaceRuntimeStatus: Equatable { case connecting case ready @@ -152,12 +195,17 @@ final class ActiveWorkspaceRuntime { _ = metadataRevision return metadataProjector.metadata } + var agentAttention: [AgentAttentionTarget] { + _ = metadataRevision + return metadataProjector.attention + } var focusedPaneID: UInt64? { guard let activeWindowID = topology?.activeWindowID else { return nil } return topology?.windows.first(where: { $0.id == activeWindowID })?.activePaneID } private(set) var status: WorkspaceRuntimeStatus = .connecting var onTransportLoss: (@MainActor (UUID) -> Void)? + var onTopologyChange: (@MainActor (MoriRemoteTerminalTopology) -> Void)? init( workspace: SavedWorkspace, @@ -184,6 +232,7 @@ final class ActiveWorkspaceRuntime { self.topology = topology self.status = .ready self.metadataProjector.topologyDidChange(paneIDs: topology.panes.map(\.id)) + self.onTopologyChange?(topology) } session.onConnectionStateChange = { [weak self] state in self?.receive(state) } session.setPresentationActive(false) @@ -256,6 +305,7 @@ final class RemoteRootModel { var errorMessage: String? var migrationReport: LegacyMigrationReport? private var pendingTrustAction: PendingSSHTrustAction? + private var pendingAgentAttentionSelection: PendingAgentAttentionSelection? private(set) var isLoaded = false var libraryLoadError: String? { bootstrapFailure } var isBootstrapping: Bool { loadingTask != nil } @@ -284,6 +334,17 @@ final class RemoteRootModel { } func agentSummary(for workspaceID: UUID) -> AgentMetadata { runtimes[workspaceID]?.agentSummary ?? .unknown } func metadata(for workspaceID: UUID, paneID: UInt64) -> AgentMetadata { runtimes[workspaceID]?.metadata(for: paneID) ?? .unknown } + func agentAttention(for serverID: UUID) -> [AgentAttentionWorkspaceTarget] { + guard activeRuntime?.workspace.serverID == serverID else { return [] } + return activeRuntime?.agentAttention.compactMap { target in + workspace(serverID: serverID, tmuxSession: target.sessionName).map { + .init(workspace: $0, target: target) + } + } ?? [] + } + func agentAttentionSummary(for serverID: UUID) -> AgentAttentionSummary { + AgentAttentionProjection.summary(agentAttention(for: serverID).map(\.target)) + } func bootstrap() { guard !isLoaded, loadingTask == nil else { return } @@ -367,6 +428,9 @@ final class RemoteRootModel { activating: Bool? = nil, carriedViewport: TmuxControlViewport? = nil ) { + if !automatic, pendingAgentAttentionSelection?.workspaceID != workspaceID { + pendingAgentAttentionSelection = nil + } let shouldActivate = activating ?? (!automatic || activeWorkspaceID == nil || activeWorkspaceID == workspaceID) deferredReconnects.remove(workspaceID) if let runtime = runtimes[workspaceID] { @@ -376,7 +440,7 @@ final class RemoteRootModel { // surface and strand the user without a reconnect path. let carriedViewport = automatic ? (carriedViewport ?? runtime.carriedViewport) : nil Task { [weak self] in - await self?.disconnect(workspaceID: workspaceID) + await self?.disconnect(workspaceID: workspaceID, clearingPendingAttentionSelection: false) self?.connect( workspaceID: workspaceID, automatic: automatic, @@ -385,6 +449,7 @@ final class RemoteRootModel { } } else if shouldActivate { activate(workspaceID: workspaceID) + bindPendingAttentionSelection(to: runtime, workspaceID: workspaceID) } return } @@ -411,11 +476,17 @@ final class RemoteRootModel { runtime = created guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { await created.stop(); return } created.onTransportLoss = { [weak self] id in self?.lost(workspaceID: workspaceID, instanceID: id) } + created.onTopologyChange = { [weak self] topology in + self?.receivedTopology(workspaceID: workspaceID, instanceID: instanceID, topology: topology) + } self.runtimes[workspaceID] = created + self.bindPendingAttentionSelection(to: created, workspaceID: workspaceID) // An automatic reconnect must not steal focus from another // healthy workspace. If the lost workspace was focused, // disconnect() cleared the active ID and it is restored here. - if shouldActivate { + // A later attention tap may also upgrade an already-admitted + // background attempt into an explicit activation request. + if shouldActivate || self.pendingAgentAttentionSelection?.workspaceID == workspaceID { self.activate(workspaceID: workspaceID) } try await created.start() @@ -435,6 +506,7 @@ final class RemoteRootModel { self.pendingTrust = challenge self.pendingTrustAction = .connect(workspaceID) case let .error(message): + self.clearPendingAttentionSelection(for: workspaceID) self.pendingTrust = nil self.pendingTrustAction = nil self.errorMessage = message @@ -442,6 +514,7 @@ final class RemoteRootModel { } catch { guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { if let runtime { await self.stop(runtime, workspaceID: workspaceID) }; return } if let runtime { await self.stop(runtime, workspaceID: workspaceID) } + self.clearPendingAttentionSelection(for: workspaceID) self.connectionAttempts.end(attempt, for: workspaceID) if !automatic { self.errorMessage = error.localizedDescription } } @@ -449,6 +522,9 @@ final class RemoteRootModel { } func dismissTrust() { + if case let .connect(workspaceID) = pendingTrustAction { + clearPendingAttentionSelection(for: workspaceID) + } pendingTrust = nil pendingTrustAction = nil } @@ -470,9 +546,10 @@ final class RemoteRootModel { } } - func disconnect(workspaceID: UUID) async { + func disconnect(workspaceID: UUID, clearingPendingAttentionSelection: Bool = true) async { connectionAttempts.cancel(workspaceID: workspaceID) deferredReconnects.remove(workspaceID) + if clearingPendingAttentionSelection { clearPendingAttentionSelection(for: workspaceID) } guard let runtime = runtimes.removeValue(forKey: workspaceID) else { return } if activeWorkspaceID == workspaceID { activeWorkspaceID = nil } runtime.setVisible(false) @@ -480,8 +557,22 @@ final class RemoteRootModel { } func disconnectActive() { if let activeWorkspaceID { Task { await disconnect(workspaceID: activeWorkspaceID) } } } - func selectWindow(_ id: UInt64) { activeRuntime?.selectWindow(id) } - func selectPane(_ id: UInt64) { activeRuntime?.selectPane(id) } + func selectWindow(_ id: UInt64) { + pendingAgentAttentionSelection = nil + activeRuntime?.selectWindow(id) + } + func selectPane(_ id: UInt64) { + pendingAgentAttentionSelection = nil + activeRuntime?.selectPane(id) + } + func selectAttentionTarget(_ target: AgentAttentionWorkspaceTarget) { + pendingAgentAttentionSelection = .init( + workspaceID: target.workspace.id, + windowID: target.target.windowID, + paneID: target.target.paneID + ) + connect(workspaceID: target.workspace.id, activating: true) + } func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { activeRuntime?.performSharedMutation(mutation) } @@ -535,6 +626,43 @@ final class RemoteRootModel { runtimes[workspaceID]?.setVisible(sceneIsActive) } + private func workspace(serverID: UUID, tmuxSession: String) -> SavedWorkspace? { + workspaces + .filter { $0.serverID == serverID && $0.tmuxSession == tmuxSession } + .max { lhs, rhs in + let lhsActive = runtimes[lhs.id] != nil + let rhsActive = runtimes[rhs.id] != nil + if lhsActive != rhsActive { return !lhsActive && rhsActive } + return (lhs.lastConnectedAt ?? .distantPast, lhs.id.uuidString) + < (rhs.lastConnectedAt ?? .distantPast, rhs.id.uuidString) + } + } + + private func bindPendingAttentionSelection(to runtime: ActiveWorkspaceRuntime, workspaceID: UUID) { + guard pendingAgentAttentionSelection?.workspaceID == workspaceID else { return } + pendingAgentAttentionSelection?.bind(runtimeInstanceID: runtime.instanceID) + bindAndApplyPendingAttentionSelection(using: runtime, workspaceID: workspaceID) + } + + private func bindAndApplyPendingAttentionSelection(using runtime: ActiveWorkspaceRuntime, workspaceID: UUID) { + guard let topology = runtime.topology else { return } + receivedTopology(workspaceID: workspaceID, instanceID: runtime.instanceID, topology: topology) + } + + private func receivedTopology(workspaceID: UUID, instanceID: UUID, topology: MoriRemoteTerminalTopology) { + guard let selection = pendingAgentAttentionSelection, + selection.matches(workspaceID: workspaceID, runtimeInstanceID: instanceID, topology: topology), + runtimes[workspaceID]?.instanceID == instanceID + else { return } + pendingAgentAttentionSelection = nil + runtimes[workspaceID]?.selectPane(selection.paneID) + } + + private func clearPendingAttentionSelection(for workspaceID: UUID) { + guard pendingAgentAttentionSelection?.workspaceID == workspaceID else { return } + pendingAgentAttentionSelection = nil + } + private func attemptIsCurrent(_ attempt: UUID, workspaceID: UUID) -> Bool { connectionAttempts.isCurrent(attempt, for: workspaceID) } @@ -564,7 +692,7 @@ final class RemoteRootModel { Task { [weak self] in guard let self, self.runtimes[workspaceID]?.instanceID == instanceID else { return } let carriedViewport = self.runtimes[workspaceID]?.carriedViewport - await disconnect(workspaceID: workspaceID) + await disconnect(workspaceID: workspaceID, clearingPendingAttentionSelection: false) try? await Task.sleep(for: .seconds(1)) guard self.sceneIsActive else { self.deferredReconnects.insert(workspaceID) diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 0a80b0c5..639734b0 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -279,6 +279,7 @@ "Windows" = "Windows"; "Panes" = "Panes"; "Active workspaces" = "Active Workspaces"; +"Agent Attention" = "Agent Attention"; "Sessions on %@" = "Sessions on %@"; "Workspace controls" = "Workspace Controls"; "Server" = "Server"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 8722ca1c..b80849d1 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -279,6 +279,7 @@ "Windows" = "窗口"; "Panes" = "面板"; "Active workspaces" = "活动工作区"; +"Agent Attention" = "Agent 注意事项"; "Sessions on %@" = "%@ 上的会话"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; diff --git a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift index df918e57..a3be5b90 100644 --- a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift +++ b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift @@ -26,6 +26,26 @@ struct AgentMetadata: Equatable, Sendable { static let unknown = Self(state: .unknown, name: nil) } +/// One safe, host-wide agent record. The fixed tmux query is the sole source; +/// this never becomes a general remote-navigation command surface. +struct AgentAttentionTarget: Identifiable, Equatable, Sendable { + let sessionName: String + let windowID: UInt64 + let windowTitle: String + let paneID: UInt64 + let metadata: AgentMetadata + + var id: UInt64 { paneID } +} + +struct AgentAttentionSummary: Equatable, Sendable { + let waiting: Int + let working: Int + let done: Int + + var total: Int { waiting + working + done } +} + /// App-local projection of the facade's fixed query result. It intentionally /// carries no tmux-controller detail across the terminal boundary. struct AgentMetadataQueryResult: Sendable { @@ -33,28 +53,41 @@ struct AgentMetadataQueryResult: Sendable { let body: String } -/// Parses the one bounded, fixed-format tmux response. Pane options are -/// untrusted remote text: state is exact-match only, and labels cannot smuggle -/// a row/delimiter into the navigation projection. +/// Parses the one bounded, fixed-format tmux response. Every field is untrusted +/// remote text. A malformed or duplicate source record invalidates the whole +/// response; MoriRemote shadow sessions are discarded before pane de-duplication. struct AgentMetadataResponseParser: Sendable { static let maximumResponseBytes = 65_536 static let maximumRecords = 512 + static let maximumSessionNameLength = 128 + static let maximumWindowTitleLength = 256 static let maximumNameLength = 64 - func parse(_ body: String) -> [UInt64: AgentMetadata] { - guard body.utf8.count <= Self.maximumResponseBytes else { return [:] } + func parse(_ body: String) -> [AgentAttentionTarget] { + guard body.utf8.count <= Self.maximumResponseBytes else { return [] } let records = body.split(separator: "\n", omittingEmptySubsequences: true) // Never prefix-truncate: an injected valid row can otherwise be hidden // after the cap, and a duplicate must invalidate the whole response. - guard records.count <= Self.maximumRecords else { return [:] } - var result: [UInt64: AgentMetadata] = [:] + guard records.count <= Self.maximumRecords else { return [] } + var result: [AgentAttentionTarget] = [] var seenPaneIDs = Set() for line in records { let fields = line.split(separator: "\t", omittingEmptySubsequences: false) - guard let first = fields.first, let paneID = parsePaneID(first) else { continue } - guard seenPaneIDs.insert(paneID).inserted else { return [:] } - guard fields.count == 3 else { continue } - result[paneID] = .init(state: normalizeState(fields[1]), name: normalizeName(fields[2])) + guard fields.count == 6, + let sessionName = normalizeText(fields[0], maximumLength: Self.maximumSessionNameLength), + let windowID = parseWindowID(fields[1]), + let windowTitle = normalizeText(fields[2], maximumLength: Self.maximumWindowTitleLength), + let paneID = parsePaneID(fields[3]) + else { return [] } + guard !isMoriRemoteShadow(sessionName) else { continue } + guard seenPaneIDs.insert(paneID).inserted else { return [] } + result.append(.init( + sessionName: sessionName, + windowID: windowID, + windowTitle: windowTitle, + paneID: paneID, + metadata: .init(state: normalizeState(fields[4]), name: normalizeName(fields[5])) + )) } return result } @@ -64,32 +97,75 @@ struct AgentMetadataResponseParser: Sendable { return UInt64(field.dropFirst()) } + private func parseWindowID(_ field: Substring) -> UInt64? { + guard field.first == "@", field.dropFirst().allSatisfy(\.isNumber) else { return nil } + return UInt64(field.dropFirst()) + } + private func normalizeState(_ field: Substring) -> MoriAgentState { MoriAgentState(rawValue: String(field)) ?? .unknown } private func normalizeName(_ field: Substring) -> String? { + normalizeText(field, maximumLength: Self.maximumNameLength) + } + + private func normalizeText(_ field: Substring, maximumLength: Int) -> String? { let value = String(field) - guard !value.isEmpty, value.count <= Self.maximumNameLength, + guard !value.isEmpty, value.count <= maximumLength, value.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F }) else { return nil } return value } + + private func isMoriRemoteShadow(_ sessionName: String) -> Bool { + guard let marker = sessionName.range(of: "--mori-remote-", options: .backwards) else { return false } + return UUID(uuidString: String(sessionName[marker.upperBound...])) != nil + } } /// Projects response records only onto panes the active facade topology owns. /// A successful response is authoritative, so missing or cleared options erase /// old metadata; a failed response intentionally yields unknown instead. struct AgentMetadataProjection: Sendable { - static func merge(_ records: [UInt64: AgentMetadata], paneIDs: [UInt64]) -> [UInt64: AgentMetadata] { - // Corrupt/native snapshots must not crash the UI. First occurrence wins, - // matching the topology order used everywhere else in the projection. + static func retain(_ metadata: [UInt64: AgentMetadata], paneIDs: [UInt64]) -> [UInt64: AgentMetadata] { var projection: [UInt64: AgentMetadata] = [:] for paneID in paneIDs where projection[paneID] == nil { - projection[paneID] = records[paneID] ?? .unknown + projection[paneID] = metadata[paneID] ?? .unknown } return projection } + + static func merge(_ records: [AgentAttentionTarget], paneIDs: [UInt64]) -> [UInt64: AgentMetadata] { + // Corrupt/native snapshots must not crash the UI. First occurrence wins, + // matching the topology order used everywhere else in the projection. + let metadataByPaneID = Dictionary(uniqueKeysWithValues: records.map { ($0.paneID, $0.metadata) }) + return retain(metadataByPaneID, paneIDs: paneIDs) + } +} + +enum AgentAttentionProjection { + static func ordered(_ records: [AgentAttentionTarget]) -> [AgentAttentionTarget] { + records + .filter { $0.metadata.state != .unknown } + .sorted { lhs, rhs in + if lhs.metadata.state.priority != rhs.metadata.state.priority { + return lhs.metadata.state.priority > rhs.metadata.state.priority + } + let sessionOrder = lhs.sessionName.localizedStandardCompare(rhs.sessionName) + if sessionOrder != .orderedSame { return sessionOrder == .orderedAscending } + if lhs.windowID != rhs.windowID { return lhs.windowID < rhs.windowID } + return lhs.paneID < rhs.paneID + } + } + + static func summary(_ records: [AgentAttentionTarget]) -> AgentAttentionSummary { + .init( + waiting: records.count { $0.metadata.state == .waiting }, + working: records.count { $0.metadata.state == .working }, + done: records.count { $0.metadata.state == .done } + ) + } } /// A visible runtime owns one projector. It has no tmux parser or transport: @@ -113,6 +189,7 @@ final class AgentMetadataProjector { private var queryGeneration: UInt64 = 0 private(set) var metadata: [UInt64: AgentMetadata] = [:] + private(set) var attention: [AgentAttentionTarget] = [] private(set) var lastFailure: String? var onChange: (@MainActor () -> Void)? @@ -124,7 +201,7 @@ final class AgentMetadataProjector { func topologyDidChange(paneIDs: [UInt64]) { guard !stopped else { return } self.paneIDs = paneIDs - metadata = AgentMetadataProjection.merge(metadata, paneIDs: paneIDs) + metadata = AgentMetadataProjection.retain(metadata, paneIDs: paneIDs) onChange?() refreshImmediately() } @@ -147,6 +224,7 @@ final class AgentMetadataProjector { queryGeneration &+= 1 queryInFlight = false metadata = [:] + attention = [] onChange?() } } @@ -163,6 +241,7 @@ final class AgentMetadataProjector { refreshTask = nil queryInFlight = false metadata = [:] + attention = [] } private func refreshImmediately() { @@ -182,10 +261,13 @@ final class AgentMetadataProjector { queryInFlight = false if result.succeeded { lastFailure = nil - metadata = AgentMetadataProjection.merge(parser.parse(result.body), paneIDs: paneIDs) + let records = parser.parse(result.body) + metadata = AgentMetadataProjection.merge(records, paneIDs: paneIDs) + attention = AgentAttentionProjection.ordered(records) } else { lastFailure = result.body - metadata = AgentMetadataProjection.merge([:], paneIDs: paneIDs) + metadata = AgentMetadataProjection.merge([], paneIDs: paneIDs) + attention = [] } onChange?() } diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index ecfeec90..0e5c2552 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -353,6 +353,28 @@ private struct RemoteTerminalDetailView: View { .background(Color.black) } .background(Color.black.ignoresSafeArea()) + .overlay(alignment: .topTrailing) { + let summary = root.agentAttentionSummary(for: runtime.workspace.serverID) + if summary.total > 0 { + Button { + root.discoverSessions(serverID: runtime.workspace.serverID) + navigatorScope = .attention + showsNavigator = true + } label: { + HStack(spacing: 5) { + Image(systemName: "bell.badge.fill") + Text(summary.total, format: .number) + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .tint(attentionTint(for: summary)) + .padding(12) + .accessibilityLabel(String(localized: "Agent Attention")) + } + } .sheet(isPresented: $showsNavigator) { RemoteNavigatorView(root: root, runtime: runtime, initialScope: navigatorScope) { showsNavigator = false @@ -384,18 +406,25 @@ private struct RemoteTerminalDetailView: View { .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) } + private func attentionTint(for summary: AgentAttentionSummary) -> Color { + if summary.waiting > 0 { return .orange } + if summary.working > 0 { return .mint } + return .green + } + } @MainActor private struct RemoteNavigatorView: View { enum Scope: String, CaseIterable, Identifiable { - case sessions, windows, panes + case sessions, windows, panes, attention var id: Self { self } var title: String { switch self { case .sessions: String(localized: "Sessions") case .windows: String(localized: "Windows") case .panes: String(localized: "Panes") + case .attention: String(localized: "Agent Attention") } } } @@ -595,6 +624,30 @@ private struct RemoteNavigatorView: View { } } } + case .attention: + Section { + ForEach(filteredAttention) { value in + Button { + root.selectAttentionTarget(value) + dismiss() + } label: { + HStack { + Image(systemName: "bell.badge.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: value.workspace.tmuxSession) + Text(verbatim: "\(value.target.windowTitle) · %\(value.target.paneID)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + AgentMetadataBadge(metadata: value.target.metadata) + } + } + } + } header: { + AgentAttentionSummaryView(summary: root.agentAttentionSummary(for: selectedServerID)) + } } } @@ -630,8 +683,17 @@ private struct RemoteNavigatorView: View { matching: filter ) } + private var filteredAttention: [AgentAttentionWorkspaceTarget] { + guard isBrowsingActiveHost else { return [] } + return RemoteNavigatorProjection.attention(root.agentAttention(for: selectedServerID), matching: filter) + } private var visibleItemCount: Int { - switch scope { case .sessions: filteredSessions.count; case .windows: filteredWindows.count; case .panes: filteredPanes.count } + switch scope { + case .sessions: filteredSessions.count + case .windows: filteredWindows.count + case .panes: filteredPanes.count + case .attention: filteredAttention.count + } } private func windowTitle(for pane: MoriRemoteTerminalPane) -> String { runtime.topology?.windows.first(where: { $0.id == pane.windowID })?.title ?? String(localized: "Window") @@ -644,6 +706,18 @@ private struct RemoteNavigatorView: View { } } +private struct AgentAttentionSummaryView: View { + let summary: AgentAttentionSummary + + var body: some View { + Text(verbatim: [ + "\(String(localized: "Waiting")): \(summary.waiting)", + "\(String(localized: "Working")): \(summary.working)", + "\(String(localized: "Done")): \(summary.done)" + ].joined(separator: " · ")) + } +} + private extension RemoteNavigatorView.Scope { init(_ scope: MoriRemoteTerminalNavigatorScope) { self = switch scope { diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index e7bbaba6..c669b383 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -630,7 +630,7 @@ final class TmuxSessionController: @unchecked Sendable { /// leaves a stale server copy mode; renderer-local selection and scrolling /// never issue this command. static let cancelStaleSharedInputMode = "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" - static let agentMetadataQuery = "list-panes -a -F '#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + static let agentMetadataQuery = "list-panes -a -F '#{session_name}\\t#{window_id}\\t#{window_name}\\t#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" func sendInput(paneID: TmuxPaneID, _ bytes: Data) -> Bool { guard !bytes.isEmpty else { return true } diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift index 949b4d0b..6e86287d 100644 --- a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -77,7 +77,7 @@ final class MoriRemoteTerminalFacadeTests: XCTestCase { func testFixedMetadataCommandUsesMoriHookOptionNames() { XCTAssertEqual( TmuxSessionController.agentMetadataQuery, - "list-panes -a -F '#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + "list-panes -a -F '#{session_name}\\t#{window_id}\\t#{window_name}\\t#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" ) } diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index bbc1c976..bdd44cb0 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -109,6 +109,36 @@ import MoriRemoteTerminal #expect(RemoteNavigatorProjection.windows(windows, matching: "DEPLOY").map(\.id) == [2]) #expect(RemoteNavigatorProjection.panes(panes, windows: windows, matching: "editor").map(\.id) == [10]) #expect(RemoteNavigatorProjection.panes(panes, windows: windows, matching: "20").map(\.id) == [20]) + + let workspace = SavedWorkspace(serverID: serverID, name: "Main", tmuxSession: "cs/main") + let attention = AgentAttentionWorkspaceTarget( + workspace: workspace, + target: .init( + sessionName: "cs/main", windowID: 1, windowTitle: "editor", paneID: 10, + metadata: .init(state: .waiting, name: "claude") + ) + ) + #expect(RemoteNavigatorProjection.attention([attention], matching: "claude") == [attention]) + } + + @Test("attention selection applies only to its fenced runtime topology") + func fencedAttentionSelection() { + let workspaceID = UUID(), runtimeID = UUID() + var selection = PendingAgentAttentionSelection(workspaceID: workspaceID, windowID: 4, paneID: 8) + let matching = MoriRemoteTerminalTopology( + windows: [.init(id: 4, title: "editor", active: true, activePaneID: 8)], + panes: [.init(id: 8, windowID: 4, columns: 120, rows: 40)], + activeWindowID: 4 + ) + #expect(!selection.matches(workspaceID: workspaceID, runtimeInstanceID: runtimeID, topology: matching)) + selection.bind(runtimeInstanceID: runtimeID) + #expect(selection.matches(workspaceID: workspaceID, runtimeInstanceID: runtimeID, topology: matching)) + #expect(!selection.matches(workspaceID: workspaceID, runtimeInstanceID: UUID(), topology: matching)) + #expect(!selection.matches( + workspaceID: workspaceID, + runtimeInstanceID: runtimeID, + topology: .init(windows: [.init(id: 4, title: "editor", active: true, activePaneID: 9)], panes: [.init(id: 9, windowID: 4, columns: 120, rows: 40)], activeWindowID: 4) + )) } @Test("connection attempt admission is synchronous and stale tokens cannot finish") diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index f242b0db..4347d093 100644 --- a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -3,47 +3,60 @@ import Testing @testable import MoriRemote @Suite("Agent metadata facade projection") struct Phase5AgentMetadataTests { - @Test("parser strictly normalizes UInt64 pane IDs and bounds untrusted output") + @Test("parser retains host context and strictly normalizes untrusted output") func parserNormalization() { let parser = AgentMetadataResponseParser() - let metadata = parser.parse("%1\tworking\tclaude\n%2\tWAITING\tcodex\n%3\tdone\tpi\ninvalid\tworking\tbad\n%4\twaiting\tbad\u{0000}name\n") - #expect(metadata[1] == .init(state: .working, name: "claude")) - #expect(metadata[2] == .init(state: .unknown, name: "codex")) - #expect(metadata[3] == .init(state: .done, name: "pi")) - #expect(metadata[4] == .init(state: .waiting, name: nil)) - #expect(metadata[99] == nil) + let records = parser.parse("main\t@1\teditor\t%1\tworking\tclaude\nmain\t@2\tdeploy\t%2\tWAITING\tcodex\n") + #expect(records == [ + target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .working, name: "claude"), + target(session: "main", windowID: 2, windowTitle: "deploy", paneID: 2, state: .unknown, name: "codex") + ]) #expect(parser.parse(String(repeating: "x", count: AgentMetadataResponseParser.maximumResponseBytes + 1)).isEmpty) } - @Test("injected valid pane rows and over-limit responses fail closed") + @Test("malformed, injected, and duplicate source records fail closed while shadows stay hidden") func parserRejectsInjectionAndRecordOverflow() { let parser = AgentMetadataResponseParser() - let injectedName = "claude\n%2\twaiting\tclaude" - let response = "%1\tworking\t\(injectedName)\n%2\tdone\tpi\n" + let injectedName = "claude\nmain\t@2\tdeploy\t%2\twaiting\tclaude" + let response = "main\t@1\teditor\t%1\tworking\t\(injectedName)\nmain\t@2\tdeploy\t%2\tdone\tpi\n" #expect(parser.parse(response).isEmpty) + #expect(parser.parse("main\tbad\teditor\t%1\tworking\tclaude\n").isEmpty) + #expect(parser.parse("main\t@1\teditor\t%1\tworking\tclaude\nmain\t@1\teditor\t%1\tdone\tpi\n").isEmpty) + + let shadowID = UUID(uuidString: "00000000-0000-0000-0000-000000000123")! + let shadow = "main--mori-remote-\(shadowID.uuidString.lowercased())" + #expect(parser.parse("\(shadow)\t@1\teditor\t%1\tworking\tclaude\nmain\t@1\teditor\t%1\twaiting\tpi\n") == [ + target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .waiting, name: "pi") + ]) + let overLimit = (0...AgentMetadataResponseParser.maximumRecords) - .map { "%\($0)\tworking\tclaude\n" } + .map { "main\t@1\teditor\t%\($0)\tworking\tclaude\n" } .joined() #expect(parser.parse(overLimit).isEmpty) } - @Test("authoritative merge clears missing records and ignores removed panes") + @Test("host-wide attention is ordered and summarized before local projection") func projectionMerge() { - let records: [UInt64: AgentMetadata] = [ - 1: .init(state: .working, name: "claude"), - 9: .init(state: .done, name: "other") + let records = [ + target(session: "zeta", windowID: 2, windowTitle: "deploy", paneID: 9, state: .done, name: "other"), + target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .working, name: "claude"), + target(session: "main", windowID: 3, windowTitle: "review", paneID: 3, state: .waiting, name: "pi") ] let merged = AgentMetadataProjection.merge(records, paneIDs: [1, 2]) #expect(merged == [ 1: .init(state: .working, name: "claude"), 2: .unknown ]) + #expect(AgentAttentionProjection.ordered(records).map(\.paneID) == [3, 1, 9]) + #expect(AgentAttentionProjection.summary(records) == .init(waiting: 1, working: 1, done: 1)) } @Test("duplicate facade topology panes are deterministically uniqued") func projectionDuplicateTopology() { - let merged = AgentMetadataProjection.merge([1: .init(state: .done, name: "pi")], paneIDs: [1, 1, 2]) + let merged = AgentMetadataProjection.merge([ + target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .done, name: "pi") + ], paneIDs: [1, 1, 2]) #expect(merged == [1: .init(state: .done, name: "pi"), 2: .unknown]) } @@ -55,13 +68,13 @@ import Testing projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tclaude\n")) await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } #expect(projector.metadata[1] == .init(state: .working, name: "claude")) projector.foregrounded() await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\twaiting\tclaude\n")) await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() @@ -82,7 +95,7 @@ import Testing projector.foregrounded() await relay.waitUntilRequested() projector.stop() - relay.complete(.init(succeeded: true, body: "%1\tworking\tlate\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tlate\n")) await Task.yield() #expect(projector.metadata.isEmpty) } @@ -94,7 +107,7 @@ import Testing projector.topologyDidChange(paneIDs: [1]) projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tclaude\n")) await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } projector.foregrounded() @@ -103,10 +116,10 @@ import Testing #expect(projector.metadata.isEmpty) projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "%1\tdone\tlate\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tdone\tlate\n")) await Task.yield() #expect(projector.metadata.isEmpty) - relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\twaiting\tclaude\n")) await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() @@ -126,8 +139,8 @@ import Testing replacement.topologyDidChange(paneIDs: [1]) replacement.setVisible(true) await newRelay.waitUntilRequested() - oldRelay.complete(.init(succeeded: true, body: "%1\tdone\told\n")) - newRelay.complete(.init(succeeded: true, body: "%1\tworking\tnew\n")) + oldRelay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tdone\told\n")) + newRelay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tnew\n")) await eventually { replacement.metadata[1] == .init(state: .working, name: "new") } #expect(old.metadata.isEmpty) #expect(replacement.metadata[1] == .init(state: .working, name: "new")) @@ -135,6 +148,23 @@ import Testing } } +private func target( + session: String, + windowID: UInt64, + windowTitle: String, + paneID: UInt64, + state: MoriAgentState, + name: String? +) -> AgentAttentionTarget { + .init( + sessionName: session, + windowID: windowID, + windowTitle: windowTitle, + paneID: paneID, + metadata: .init(state: state, name: name) + ) +} + @MainActor private func eventually(_ condition: @escaping @MainActor () -> Bool) async { for _ in 0..<100 { From 1da8beeba2a07896cd94b4f83bf00f091b3fb8ae Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 5 Aug 2026 14:48:03 +0800 Subject: [PATCH 2/5] moriremote: expose agents in navigator --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../Resources/en.lproj/Localizable.strings | 4 +++- .../zh-Hans.lproj/Localizable.strings | 4 +++- .../MoriRemote/Views/RemoteRootView.swift | 22 ++++++++++++------- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc463b19..2dbd7d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation. MoriRemote now follows Remux client sizing: phone viewport and software-keyboard changes resize/reflow the shared tmux window, so other attached Mac clients may visibly reflow. - **iOS (MoriRemote)**: Restored the current Remux three-capsule terminal dock and its measured safe-area layout: Ctrl/Esc/Tab, Sessions/Windows/Panes, and Home/Keypad/Keyboard. Sessions, windows, and panes open the existing Navigator at the matching scope; Home opens the library; Keypad retains terminal shortcuts and photo/clipboard image upload. Modal forms still suspend the terminal responder, and Chinese and other IMEs still commit marked text exactly once. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone. -- **iOS (MoriRemote)**: Added a host-wide agent-attention navigator. It reuses the existing bounded tmux metadata query to rank waiting, working, and completed agents across saved sessions, then safely opens the selected session and pane after its runtime topology arrives. +- **iOS (MoriRemote)**: Added a dedicated Agents scope to the Navigator. It reuses the existing bounded tmux metadata query to list and rank waiting, working, and completed agents across saved sessions, then safely opens the selected session and pane after its runtime topology arrives. ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 09b1774c..4caa63e8 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -12,7 +12,7 @@ - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态和自适应 iPhone/iPad 界面。MoriRemote 现遵循 Remux 的客户端尺寸规则:手机视口和软件键盘变化会调整/重排共享 tmux 窗口,因此其他已连接的 Mac 客户端也可能发生可见重排。 - **iOS(MoriRemote)**:恢复当前 Remux 的三胶囊终端 Dock 与按实际高度预留的安全区布局:Ctrl/Esc/Tab、会话/窗口/面板、主页/按键面板/键盘。会话、窗口和面板会在既有 Navigator 中打开对应范围;主页打开资源库;按键面板继续提供终端快捷键和照片/剪贴板图片上传。模态表单仍会暂停终端响应器,中文等输入法的组合文本仍只会提交一次。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 -- **iOS(MoriRemote)**:新增全主机 agent 注意事项导航器。它复用既有的受限 tmux 元数据查询,跨已保存会话按等待输入、运行中与完成状态排序,并在目标运行时拓扑到达后安全打开相应会话与 pane。 +- **iOS(MoriRemote)**:Navigator 新增独立的 Agent 分类。它复用既有的受限 tmux 元数据查询,跨已保存会话列出 Agent 并按等待输入、运行中与完成状态排序,再在目标运行时拓扑到达后安全打开相应会话与 pane。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 639734b0..90eabd18 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -279,7 +279,9 @@ "Windows" = "Windows"; "Panes" = "Panes"; "Active workspaces" = "Active Workspaces"; -"Agent Attention" = "Agent Attention"; +"Agents" = "Agents"; +"No agents reporting status" = "No agents reporting status"; +"Start an agent with Mori hooks enabled to see it here." = "Start an agent with Mori hooks enabled to see it here."; "Sessions on %@" = "Sessions on %@"; "Workspace controls" = "Workspace Controls"; "Server" = "Server"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index b80849d1..e2875cc2 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -279,7 +279,9 @@ "Windows" = "窗口"; "Panes" = "面板"; "Active workspaces" = "活动工作区"; -"Agent Attention" = "Agent 注意事项"; +"Agents" = "Agent"; +"No agents reporting status" = "没有正在上报状态的 Agent"; +"Start an agent with Mori hooks enabled to see it here." = "启用 Mori hooks 并启动 Agent 后,它会显示在这里。"; "Sessions on %@" = "%@ 上的会话"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 0e5c2552..de5af511 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -358,7 +358,7 @@ private struct RemoteTerminalDetailView: View { if summary.total > 0 { Button { root.discoverSessions(serverID: runtime.workspace.serverID) - navigatorScope = .attention + navigatorScope = .agents showsNavigator = true } label: { HStack(spacing: 5) { @@ -372,7 +372,7 @@ private struct RemoteTerminalDetailView: View { .buttonStyle(.borderedProminent) .tint(attentionTint(for: summary)) .padding(12) - .accessibilityLabel(String(localized: "Agent Attention")) + .accessibilityLabel(String(localized: "Agents")) } } .sheet(isPresented: $showsNavigator) { @@ -417,14 +417,14 @@ private struct RemoteTerminalDetailView: View { @MainActor private struct RemoteNavigatorView: View { enum Scope: String, CaseIterable, Identifiable { - case sessions, windows, panes, attention + case sessions, windows, panes, agents var id: Self { self } var title: String { switch self { case .sessions: String(localized: "Sessions") case .windows: String(localized: "Windows") case .panes: String(localized: "Panes") - case .attention: String(localized: "Agent Attention") + case .agents: String(localized: "Agents") } } } @@ -624,7 +624,7 @@ private struct RemoteNavigatorView: View { } } } - case .attention: + case .agents: Section { ForEach(filteredAttention) { value in Button { @@ -632,8 +632,8 @@ private struct RemoteNavigatorView: View { dismiss() } label: { HStack { - Image(systemName: "bell.badge.fill") - .foregroundStyle(.orange) + Image(systemName: "person.crop.circle") + .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(verbatim: value.workspace.tmuxSession) Text(verbatim: "\(value.target.windowTitle) · %\(value.target.paneID)") @@ -654,6 +654,12 @@ private struct RemoteNavigatorView: View { @ViewBuilder private var emptyState: some View { if scope == .sessions, root.sessionDiscovery[selectedServerID] == .loading, filteredSessions.isEmpty { ProgressView(String(localized: "Loading sessions…")) + } else if scope == .agents, filteredAttention.isEmpty, filter.isEmpty { + ContentUnavailableView( + String(localized: "No agents reporting status"), + systemImage: "person.2", + description: Text(String(localized: "Start an agent with Mori hooks enabled to see it here.")) + ) } else if visibleItemCount == 0 { ContentUnavailableView(emptyTitle, systemImage: filter.isEmpty ? "rectangle.stack.badge.minus" : "magnifyingglass") } @@ -692,7 +698,7 @@ private struct RemoteNavigatorView: View { case .sessions: filteredSessions.count case .windows: filteredWindows.count case .panes: filteredPanes.count - case .attention: filteredAttention.count + case .agents: filteredAttention.count } } private func windowTitle(for pane: MoriRemoteTerminalPane) -> String { From de8918bd5de92ceb547046044551163580adfa85 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 5 Aug 2026 14:58:30 +0800 Subject: [PATCH 3/5] moriremote: render live agent states --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Tmux/AgentMetadataProjector.swift | 2 +- .../MoriRemote/Views/RemoteRootView.swift | 38 ++++++++++--------- .../Tmux/TmuxSessionController.swift | 5 ++- .../MoriRemoteTerminalFacadeTests.swift | 2 +- .../Phase5AgentMetadataTests.swift | 31 +++++++-------- 9 files changed, 46 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbd7d60..742997fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🐛 Bug Fixes +- **iOS (MoriRemote)**: Fixed the Agents scope showing zero states because tmux control mode preserved the query's `\\t` delimiters literally, and stopped the empty-state overlay from drawing over its summary header. - **iOS (MoriRemote)**: Make workspace runtime status, topology, and agent metadata directly observable, so an open Navigator and library badges update instead of writing to an unused revision counter. - **iOS (MoriRemote)**: Refresh local scrollback geometry after Ghostty publishes each completed renderer frame, so output arriving after the pre-render terminal-change callback no longer leaves a stale hard stop above the true bottom. - **iOS (MoriRemote)**: Prevented a usable terminal from remaining labeled “Connecting…” when a delayed syncing callback arrives after live topology. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 4caa63e8..fb28ecb6 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -16,6 +16,7 @@ ### 🐛 问题修复 +- **iOS(MoriRemote)**:修复 tmux control mode 将查询中的 `\\t` 分隔符原样保留,导致 Agent 分类始终显示零状态的问题;空状态也不再与统计标题重叠。 - **iOS(MoriRemote)**:让工作区运行状态、拓扑和 agent 元数据可被直接观察,避免 Navigator 与资源库徽章只写入无人读取的 revision 而不更新。 - **iOS(MoriRemote)**:在 Ghostty 发布每个完整渲染帧后刷新本地回滚区几何,避免预渲染终端变更回调读到旧状态,导致滚动在真实底部之前被硬性截断。 - **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 90eabd18..23a62b41 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -282,6 +282,7 @@ "Agents" = "Agents"; "No agents reporting status" = "No agents reporting status"; "Start an agent with Mori hooks enabled to see it here." = "Start an agent with Mori hooks enabled to see it here."; +"Filter sessions, windows, panes, and agents" = "Filter sessions, windows, panes, and agents"; "Sessions on %@" = "Sessions on %@"; "Workspace controls" = "Workspace Controls"; "Server" = "Server"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index e2875cc2..01aa22da 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -282,6 +282,7 @@ "Agents" = "Agent"; "No agents reporting status" = "没有正在上报状态的 Agent"; "Start an agent with Mori hooks enabled to see it here." = "启用 Mori hooks 并启动 Agent 后,它会显示在这里。"; +"Filter sessions, windows, panes, and agents" = "筛选会话、窗口、面板和 Agent"; "Sessions on %@" = "%@ 上的会话"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; diff --git a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift index a3be5b90..076e887d 100644 --- a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift +++ b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift @@ -72,7 +72,7 @@ struct AgentMetadataResponseParser: Sendable { var result: [AgentAttentionTarget] = [] var seenPaneIDs = Set() for line in records { - let fields = line.split(separator: "\t", omittingEmptySubsequences: false) + let fields = line.split(separator: "|", omittingEmptySubsequences: false) guard fields.count == 6, let sessionName = normalizeText(fields[0], maximumLength: Self.maximumSessionNameLength), let windowID = parseWindowID(fields[1]), diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index de5af511..02bbb71f 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -467,7 +467,7 @@ private struct RemoteNavigatorView: View { } .navigationTitle(String(localized: "Navigator")) .navigationBarTitleDisplayMode(.inline) - .searchable(text: $filter, prompt: String(localized: "Filter sessions, windows, and panes")) + .searchable(text: $filter, prompt: String(localized: "Filter sessions, windows, panes, and agents")) .toolbar { ToolbarItem(placement: .topBarLeading) { Button(action: showLibrary) { Image(systemName: "server.rack") } @@ -625,28 +625,30 @@ private struct RemoteNavigatorView: View { } } case .agents: - Section { - ForEach(filteredAttention) { value in - Button { - root.selectAttentionTarget(value) - dismiss() - } label: { - HStack { - Image(systemName: "person.crop.circle") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(verbatim: value.workspace.tmuxSession) - Text(verbatim: "\(value.target.windowTitle) · %\(value.target.paneID)") - .font(.caption.monospaced()) + if !filteredAttention.isEmpty { + Section { + ForEach(filteredAttention) { value in + Button { + root.selectAttentionTarget(value) + dismiss() + } label: { + HStack { + Image(systemName: "person.crop.circle") .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: value.workspace.tmuxSession) + Text(verbatim: "\(value.target.windowTitle) · %\(value.target.paneID)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + AgentMetadataBadge(metadata: value.target.metadata) } - Spacer() - AgentMetadataBadge(metadata: value.target.metadata) } } + } header: { + AgentAttentionSummaryView(summary: root.agentAttentionSummary(for: selectedServerID)) } - } header: { - AgentAttentionSummaryView(summary: root.agentAttentionSummary(for: selectedServerID)) } } } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index c669b383..2933a495 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -630,7 +630,10 @@ final class TmuxSessionController: @unchecked Sendable { /// leaves a stale server copy mode; renderer-local selection and scrolling /// never issue this command. static let cancelStaleSharedInputMode = "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" - static let agentMetadataQuery = "list-panes -a -F '#{session_name}\\t#{window_id}\\t#{window_name}\\t#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + // tmux control mode preserves `\t` as two literal characters in format + // output. Use one fixed printable delimiter; the app parser rejects any + // record whose untrusted fields introduce another delimiter. + static let agentMetadataQuery = "list-panes -a -F '#{session_name}|#{window_id}|#{window_name}|#{pane_id}|#{@mori-agent-state}|#{@mori-agent-name}'" func sendInput(paneID: TmuxPaneID, _ bytes: Data) -> Bool { guard !bytes.isEmpty else { return true } diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift index 6e86287d..601e46a6 100644 --- a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -77,7 +77,7 @@ final class MoriRemoteTerminalFacadeTests: XCTestCase { func testFixedMetadataCommandUsesMoriHookOptionNames() { XCTAssertEqual( TmuxSessionController.agentMetadataQuery, - "list-panes -a -F '#{session_name}\\t#{window_id}\\t#{window_name}\\t#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + "list-panes -a -F '#{session_name}|#{window_id}|#{window_name}|#{pane_id}|#{@mori-agent-state}|#{@mori-agent-name}'" ) } diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index 4347d093..066a95ea 100644 --- a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -6,7 +6,7 @@ import Testing @Test("parser retains host context and strictly normalizes untrusted output") func parserNormalization() { let parser = AgentMetadataResponseParser() - let records = parser.parse("main\t@1\teditor\t%1\tworking\tclaude\nmain\t@2\tdeploy\t%2\tWAITING\tcodex\n") + let records = parser.parse("main|@1|editor|%1|working|claude\nmain|@2|deploy|%2|WAITING|codex\n") #expect(records == [ target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .working, name: "claude"), target(session: "main", windowID: 2, windowTitle: "deploy", paneID: 2, state: .unknown, name: "codex") @@ -17,21 +17,22 @@ import Testing @Test("malformed, injected, and duplicate source records fail closed while shadows stay hidden") func parserRejectsInjectionAndRecordOverflow() { let parser = AgentMetadataResponseParser() - let injectedName = "claude\nmain\t@2\tdeploy\t%2\twaiting\tclaude" - let response = "main\t@1\teditor\t%1\tworking\t\(injectedName)\nmain\t@2\tdeploy\t%2\tdone\tpi\n" + let injectedName = "claude\nmain|@2|deploy|%2|waiting|claude" + let response = "main|@1|editor|%1|working|\(injectedName)\nmain|@2|deploy|%2|done|pi\n" #expect(parser.parse(response).isEmpty) - #expect(parser.parse("main\tbad\teditor\t%1\tworking\tclaude\n").isEmpty) - #expect(parser.parse("main\t@1\teditor\t%1\tworking\tclaude\nmain\t@1\teditor\t%1\tdone\tpi\n").isEmpty) + #expect(parser.parse("main|bad|editor|%1|working|claude\n").isEmpty) + #expect(parser.parse("main|@1|editor|%1|working|claude\nmain|@1|editor|%1|done|pi\n").isEmpty) + #expect(parser.parse("main|@1|editor|title|%1|working|claude\n").isEmpty) let shadowID = UUID(uuidString: "00000000-0000-0000-0000-000000000123")! let shadow = "main--mori-remote-\(shadowID.uuidString.lowercased())" - #expect(parser.parse("\(shadow)\t@1\teditor\t%1\tworking\tclaude\nmain\t@1\teditor\t%1\twaiting\tpi\n") == [ + #expect(parser.parse("\(shadow)|@1|editor|%1|working|claude\nmain|@1|editor|%1|waiting|pi\n") == [ target(session: "main", windowID: 1, windowTitle: "editor", paneID: 1, state: .waiting, name: "pi") ]) let overLimit = (0...AgentMetadataResponseParser.maximumRecords) - .map { "main\t@1\teditor\t%\($0)\tworking\tclaude\n" } + .map { "main|@1|editor|%\($0)|working|claude\n" } .joined() #expect(parser.parse(overLimit).isEmpty) } @@ -68,13 +69,13 @@ import Testing projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|working|claude\n")) await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } #expect(projector.metadata[1] == .init(state: .working, name: "claude")) projector.foregrounded() await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\twaiting\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|waiting|claude\n")) await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() @@ -95,7 +96,7 @@ import Testing projector.foregrounded() await relay.waitUntilRequested() projector.stop() - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tlate\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|working|late\n")) await Task.yield() #expect(projector.metadata.isEmpty) } @@ -107,7 +108,7 @@ import Testing projector.topologyDidChange(paneIDs: [1]) projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|working|claude\n")) await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } projector.foregrounded() @@ -116,10 +117,10 @@ import Testing #expect(projector.metadata.isEmpty) projector.setVisible(true) await relay.waitUntilRequested() - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tdone\tlate\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|done|late\n")) await Task.yield() #expect(projector.metadata.isEmpty) - relay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\twaiting\tclaude\n")) + relay.complete(.init(succeeded: true, body: "main|@1|editor|%1|waiting|claude\n")) await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() @@ -139,8 +140,8 @@ import Testing replacement.topologyDidChange(paneIDs: [1]) replacement.setVisible(true) await newRelay.waitUntilRequested() - oldRelay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tdone\told\n")) - newRelay.complete(.init(succeeded: true, body: "main\t@1\teditor\t%1\tworking\tnew\n")) + oldRelay.complete(.init(succeeded: true, body: "main|@1|editor|%1|done|old\n")) + newRelay.complete(.init(succeeded: true, body: "main|@1|editor|%1|working|new\n")) await eventually { replacement.metadata[1] == .init(state: .working, name: "new") } #expect(old.metadata.isEmpty) #expect(replacement.metadata[1] == .init(state: .working, name: "new")) From efd89f93d4c2e0284c593d5c6a78bddf6b106e1d Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 5 Aug 2026 15:09:33 +0800 Subject: [PATCH 4/5] moriremote: simplify agent navigation chrome --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../MoriRemote/Views/RemoteRootView.swift | 31 +------- .../App/MoriRemoteTerminalFacade.swift | 2 +- .../Ghostty/GhosttyKeyboardChrome.swift | 70 +++---------------- .../Ghostty/GhosttyTerminalCoreView.swift | 4 +- .../Tmux/TmuxTerminalScreenAdapter.swift | 22 ------ .../GhosttyKeyboardChromeActionsTests.swift | 12 ++-- .../TmuxTerminalScreenAdapterTests.swift | 10 +-- 9 files changed, 21 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 742997fe..0e0e96d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Features - **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation. MoriRemote now follows Remux client sizing: phone viewport and software-keyboard changes resize/reflow the shared tmux window, so other attached Mac clients may visibly reflow. -- **iOS (MoriRemote)**: Restored the current Remux three-capsule terminal dock and its measured safe-area layout: Ctrl/Esc/Tab, Sessions/Windows/Panes, and Home/Keypad/Keyboard. Sessions, windows, and panes open the existing Navigator at the matching scope; Home opens the library; Keypad retains terminal shortcuts and photo/clipboard image upload. Modal forms still suspend the terminal responder, and Chinese and other IMEs still commit marked text exactly once. +- **iOS (MoriRemote)**: Restored the current Remux three-capsule terminal dock and its measured safe-area layout: Ctrl/Esc/Tab, Sessions/Agents, and Home/Keypad/Keyboard. Sessions opens the complete Navigator, Agents jumps directly to agent status, Home opens the library, and Keypad retains terminal shortcuts and photo/clipboard image upload. Modal forms still suspend the terminal responder, and Chinese and other IMEs still commit marked text exactly once. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone. - **iOS (MoriRemote)**: Added a dedicated Agents scope to the Navigator. It reuses the existing bounded tmux metadata query to list and rank waiting, working, and completed agents across saved sessions, then safely opens the selected session and pane after its runtime topology arrives. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index fb28ecb6..505f1807 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,7 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态和自适应 iPhone/iPad 界面。MoriRemote 现遵循 Remux 的客户端尺寸规则:手机视口和软件键盘变化会调整/重排共享 tmux 窗口,因此其他已连接的 Mac 客户端也可能发生可见重排。 -- **iOS(MoriRemote)**:恢复当前 Remux 的三胶囊终端 Dock 与按实际高度预留的安全区布局:Ctrl/Esc/Tab、会话/窗口/面板、主页/按键面板/键盘。会话、窗口和面板会在既有 Navigator 中打开对应范围;主页打开资源库;按键面板继续提供终端快捷键和照片/剪贴板图片上传。模态表单仍会暂停终端响应器,中文等输入法的组合文本仍只会提交一次。 +- **iOS(MoriRemote)**:恢复当前 Remux 的三胶囊终端 Dock 与按实际高度预留的安全区布局:Ctrl/Esc/Tab、会话/Agent、主页/按键面板/键盘。会话打开完整 Navigator,Agent 直接进入状态列表;主页打开资源库;按键面板继续提供终端快捷键和照片/剪贴板图片上传。模态表单仍会暂停终端响应器,中文等输入法的组合文本仍只会提交一次。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 - **iOS(MoriRemote)**:Navigator 新增独立的 Agent 分类。它复用既有的受限 tmux 元数据查询,跨已保存会话列出 Agent 并按等待输入、运行中与完成状态排序,再在目标运行时拓扑到达后安全打开相应会话与 pane。 diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 02bbb71f..a000bd48 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -353,28 +353,6 @@ private struct RemoteTerminalDetailView: View { .background(Color.black) } .background(Color.black.ignoresSafeArea()) - .overlay(alignment: .topTrailing) { - let summary = root.agentAttentionSummary(for: runtime.workspace.serverID) - if summary.total > 0 { - Button { - root.discoverSessions(serverID: runtime.workspace.serverID) - navigatorScope = .agents - showsNavigator = true - } label: { - HStack(spacing: 5) { - Image(systemName: "bell.badge.fill") - Text(summary.total, format: .number) - } - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 8) - } - .buttonStyle(.borderedProminent) - .tint(attentionTint(for: summary)) - .padding(12) - .accessibilityLabel(String(localized: "Agents")) - } - } .sheet(isPresented: $showsNavigator) { RemoteNavigatorView(root: root, runtime: runtime, initialScope: navigatorScope) { showsNavigator = false @@ -406,12 +384,6 @@ private struct RemoteTerminalDetailView: View { .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) } - private func attentionTint(for summary: AgentAttentionSummary) -> Color { - if summary.waiting > 0 { return .orange } - if summary.working > 0 { return .mint } - return .green - } - } @MainActor @@ -730,8 +702,7 @@ private extension RemoteNavigatorView.Scope { init(_ scope: MoriRemoteTerminalNavigatorScope) { self = switch scope { case .sessions: .sessions - case .windows: .windows - case .panes: .panes + case .agents: .agents } } } diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index 9357ddb7..06525855 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -48,7 +48,7 @@ public enum MoriRemoteTerminalConnectionState: Equatable, Sendable { } public enum MoriRemoteTerminalNavigatorScope: Sendable { - case sessions, windows, panes + case sessions, agents } enum MoriRemoteTerminalConnectionProjection { diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index d4ba6bb0..611291cb 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -84,13 +84,6 @@ struct GhosttyRenderedBottomChromeHeightPreferenceKey: PreferenceKey { } } -struct GhosttyTerminalChromeTopologyProjection: Equatable { - let selectedWindowIndex: Int? - let windowCount: Int - let selectedPaneIndex: Int? - let paneCount: Int -} - struct GhosttyTerminalChromeStyle { let accent: Color let accentForeground: Color @@ -116,12 +109,10 @@ extension EnvironmentValues { } } -/// Testable action boundary. The navigator scopes remain deliberately distinct -/// even though Mori currently presents them in one existing Navigator sheet. +/// Testable action boundary for the terminal's persistent controls. struct GhosttyKeyboardChromeActions { let showSessions: () -> Void - let showWindows: () -> Void - let showPanes: () -> Void + let showAgents: () -> Void let showLibrary: () -> Void let toggleKeyboard: () -> Void let toggleControl: () -> Void @@ -135,11 +126,8 @@ struct GhosttyKeyboardChromeActions { case .sessions: showSessions() return true - case .windows: - showWindows() - return true - case .panes: - showPanes() + case .agents: + showAgents() return true case .library: showLibrary() @@ -232,7 +220,7 @@ struct GhosttyKeyboardChromeActions { } enum Action { - case sessions, windows, panes, library, keyboard, control, alt + case sessions, agents, library, keyboard, control, alt case escape, tab, shiftTab, arrowLeft, arrowUp, arrowDown, arrowRight case home, end, pageUp, pageDown, questionMark, slash case ctrlC, ctrlD, ctrlZ, ctrlL, ctrlA, ctrlE, ctrlR, ctrlU, ctrlK, ctrlW, altB, altF @@ -256,7 +244,6 @@ struct GhosttyKeyboardChrome: View { let isCompact: Bool let isControlArmed: Bool let isAltArmed: Bool - let topology: GhosttyTerminalChromeTopologyProjection let imageUploader: MoriRemoteTerminalImageUploader? let insertImagePath: (String) -> Bool let onImagePresentationChange: (Bool) -> Void @@ -346,22 +333,12 @@ struct GhosttyKeyboardChrome: View { _ = actions.perform(.sessions) } dock( - "rectangle.on.rectangle", - id: "terminal.windows", - label: windowLabel, - badge: badge(topology.selectedWindowIndex, topology.windowCount), - enabled: isEnabled && topology.windowCount > 0 - ) { - _ = actions.perform(.windows) - } - dock( - "square.split.2x1", - id: "terminal.panes", - label: paneLabel, - badge: badge(topology.selectedPaneIndex, topology.paneCount), - enabled: isEnabled && topology.paneCount > 0 + "person.2", + id: "terminal.agents", + label: String(localized: "Agents"), + enabled: true ) { - _ = actions.perform(.panes) + _ = actions.perform(.agents) } } } @@ -455,33 +432,6 @@ struct GhosttyKeyboardChrome: View { : GhosttyKeyboardChromeSizing.dockButtonWidth } - private var windowLabel: String { - topology.windowCount == 0 - ? String(localized: "Windows") - : String( - format: String(localized: "Window %lld of %lld"), - displayIndex(topology.selectedWindowIndex, topology.windowCount), - topology.windowCount - ) - } - - private var paneLabel: String { - topology.paneCount == 0 - ? String(localized: "Panes") - : String( - format: String(localized: "Pane %lld of %lld"), - displayIndex(topology.selectedPaneIndex, topology.paneCount), - topology.paneCount - ) - } - - private func badge(_ index: Int?, _ count: Int) -> String? { - count > 1 ? "\(displayIndex(index, count))" : nil - } - - private func displayIndex(_ index: Int?, _ count: Int) -> Int { - min(max((index ?? 0) + 1, 1), max(count, 1)) - } } private struct GhosttyKeyboardChromeDockButton: View { diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index d36ae1b7..dc76c7e3 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -159,7 +159,6 @@ struct GhosttyTerminalCoreView: View { isCompact: chrome.isCompact, isControlArmed: terminalInputController.isControlArmed, isAltArmed: terminalInputController.isAltArmed, - topology: screen.terminalChromeTopologyProjection, imageUploader: imageUploader, insertImagePath: insertUploadedImagePath, onImagePresentationChange: { @@ -167,8 +166,7 @@ struct GhosttyTerminalCoreView: View { }, actions: .init( showSessions: { onShowNavigator(.sessions) }, - showWindows: { onShowNavigator(.windows) }, - showPanes: { onShowNavigator(.panes) }, + showAgents: { onShowNavigator(.agents) }, showLibrary: onShowLibrary, toggleKeyboard: toggleKeyboard, toggleControl: { terminalInputController.toggleControl() }, diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift index 4c0fec9d..d3bd1ac8 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -121,28 +121,6 @@ extension TmuxTerminalScreenAdapter { ) } - /// A read-only summary for phone chrome. It reflects tmux topology only. - var terminalChromeTopologyProjection: GhosttyTerminalChromeTopologyProjection { - let windows = latestTopology?.windows ?? [] - let activeWindowID = latestTopology?.activeWindowID - let selectedWindowIndex = activeWindowID.flatMap { id in - windows.firstIndex(where: { $0.id == id }) - } - let panes = activeWindowID.flatMap { activeID in - latestTopology?.panes.filter { $0.windowID == activeID } - } ?? [] - let activePaneID = windows.first(where: { $0.id == activeWindowID })?.activePaneID - - return GhosttyTerminalChromeTopologyProjection( - selectedWindowIndex: selectedWindowIndex, - windowCount: windows.count, - selectedPaneIndex: activePaneID.flatMap { id in - panes.firstIndex(where: { $0.id == id }) - }, - paneCount: panes.count - ) - } - var isInputAvailable: Bool { isTransportWritable && activeManagedSurface != nil } diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift index 4b64343e..e24f9b3f 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -33,8 +33,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { var calls: [String] = [] let actions = GhosttyKeyboardChromeActions( showSessions: { calls.append("sessions") }, - showWindows: { calls.append("windows") }, - showPanes: { calls.append("panes") }, + showAgents: { calls.append("agents") }, showLibrary: { calls.append("library") }, toggleKeyboard: { calls.append("keyboard") }, toggleControl: { calls.append("control") }, @@ -53,8 +52,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { ) XCTAssertTrue(actions.perform(.sessions)) - XCTAssertTrue(actions.perform(.windows)) - XCTAssertTrue(actions.perform(.panes)) + XCTAssertTrue(actions.perform(.agents)) XCTAssertTrue(actions.perform(.library)) XCTAssertTrue(actions.perform(.keyboard)) XCTAssertTrue(actions.perform(.control)) @@ -65,7 +63,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { XCTAssertTrue(actions.perform(.closePane)) XCTAssertTrue(actions.perform(.closeWindow)) XCTAssertEqual(calls, [ - "sessions", "windows", "panes", "library", "keyboard", "control", "alt", + "sessions", "agents", "library", "keyboard", "control", "alt", "new-window", "split-horizontal", "split-vertical", "close-pane", "close-window", ]) } @@ -73,7 +71,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { func testCommonShortcutsSendExactTerminalSequences() { var sequences: [String] = [] let actions = GhosttyKeyboardChromeActions( - showSessions: {}, showWindows: {}, showPanes: {}, showLibrary: {}, + showSessions: {}, showAgents: {}, showLibrary: {}, toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, requestSharedMutation: { _ in }, sendShortcut: { sequences.append($0); return true }, @@ -96,7 +94,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { sendKey: @escaping (GhosttySurfaceKeyEvent) -> Bool ) -> GhosttyKeyboardChromeActions { GhosttyKeyboardChromeActions( - showSessions: {}, showWindows: {}, showPanes: {}, showLibrary: {}, + showSessions: {}, showAgents: {}, showLibrary: {}, toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, requestSharedMutation: { _ in }, sendShortcut: { _ in false }, sendKey: sendKey ) diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift index 2b0605a0..9466b2b8 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -5,7 +5,7 @@ import XCTest @MainActor final class TmuxTerminalScreenAdapterTests: XCTestCase { - func testTopologyProjectionReflectsEmittedTopologyImmediately() async throws { + func testViewportProjectionReflectsEmittedTopologyImmediately() async throws { let runtime = try GhosttyKitRuntime() let session = makeSession(runtime: runtime) let adapter = TmuxTerminalScreenAdapter() @@ -26,10 +26,6 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { let first = adapter.terminalViewportPresentationProjection XCTAssertEqual(first.windowCount, 2) - XCTAssertEqual( - adapter.terminalChromeTopologyProjection, - .init(selectedWindowIndex: 0, windowCount: 2, selectedPaneIndex: 0, paneCount: 2) - ) session.handleTopology(.init( sessionName: "fresh-test", @@ -40,10 +36,6 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { let second = adapter.terminalViewportPresentationProjection XCTAssertEqual(second.windowCount, 1) - XCTAssertEqual( - adapter.terminalChromeTopologyProjection, - .init(selectedWindowIndex: 0, windowCount: 1, selectedPaneIndex: 0, paneCount: 1) - ) await session.shutdown() } From a811ef0bcdbbc3ebfdd63918b03d490d0aef1730 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 5 Aug 2026 16:01:17 +0800 Subject: [PATCH 5/5] moriremote: harden agent metadata framing --- .../Tmux/AgentMetadataProjector.swift | 41 +++++++++++++------ .../MoriRemote/Views/RemoteRootView.swift | 7 +++- .../Tmux/TmuxSessionController.swift | 8 ++-- .../MoriRemoteTerminalFacadeTests.swift | 2 +- .../Phase5AgentMetadataTests.swift | 21 ++++++++-- 5 files changed, 57 insertions(+), 22 deletions(-) diff --git a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift index 076e887d..9c778677 100644 --- a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift +++ b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift @@ -72,11 +72,10 @@ struct AgentMetadataResponseParser: Sendable { var result: [AgentAttentionTarget] = [] var seenPaneIDs = Set() for line in records { - let fields = line.split(separator: "|", omittingEmptySubsequences: false) - guard fields.count == 6, + guard let fields = decodeShellEscapedFields(line), fields.count == 6, let sessionName = normalizeText(fields[0], maximumLength: Self.maximumSessionNameLength), let windowID = parseWindowID(fields[1]), - let windowTitle = normalizeText(fields[2], maximumLength: Self.maximumWindowTitleLength), + let windowTitle = normalizeText(fields[2], maximumLength: Self.maximumWindowTitleLength, allowsEmpty: true), let paneID = parsePaneID(fields[3]) else { return [] } guard !isMoriRemoteShadow(sessionName) else { continue } @@ -92,32 +91,50 @@ struct AgentMetadataResponseParser: Sendable { return result } - private func parsePaneID(_ field: Substring) -> UInt64? { + private func parsePaneID(_ field: String) -> UInt64? { guard field.first == "%", field.dropFirst().allSatisfy(\.isNumber) else { return nil } return UInt64(field.dropFirst()) } - private func parseWindowID(_ field: Substring) -> UInt64? { + private func parseWindowID(_ field: String) -> UInt64? { guard field.first == "@", field.dropFirst().allSatisfy(\.isNumber) else { return nil } return UInt64(field.dropFirst()) } - private func normalizeState(_ field: Substring) -> MoriAgentState { - MoriAgentState(rawValue: String(field)) ?? .unknown + private func normalizeState(_ value: String) -> MoriAgentState { + MoriAgentState(rawValue: value) ?? .unknown } - private func normalizeName(_ field: Substring) -> String? { - normalizeText(field, maximumLength: Self.maximumNameLength) + private func normalizeName(_ value: String) -> String? { + normalizeText(value, maximumLength: Self.maximumNameLength) } - private func normalizeText(_ field: Substring, maximumLength: Int) -> String? { - let value = String(field) - guard !value.isEmpty, value.count <= maximumLength, + private func normalizeText(_ value: String, maximumLength: Int, allowsEmpty: Bool = false) -> String? { + guard (allowsEmpty || !value.isEmpty), value.count <= maximumLength, value.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F }) else { return nil } return value } + /// Splits one record while decoding tmux 3.2's shell-style `q` escaping. + private func decodeShellEscapedFields(_ line: Substring) -> [String]? { + var fields = [""] + var escaped = false + for character in line { + if escaped { + fields[fields.count - 1].append(character) + escaped = false + } else if character == "\\" { + escaped = true + } else if character == "|" { + fields.append("") + } else { + fields[fields.count - 1].append(character) + } + } + return escaped ? nil : fields + } + private func isMoriRemoteShadow(_ sessionName: String) -> Bool { guard let marker = sessionName.range(of: "--mori-remote-", options: .backwards) else { return false } return UUID(uuidString: String(sessionName[marker.upperBound...])) != nil diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index a000bd48..b49ef25a 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -609,7 +609,7 @@ private struct RemoteNavigatorView: View { .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(verbatim: value.workspace.tmuxSession) - Text(verbatim: "\(value.target.windowTitle) · %\(value.target.paneID)") + Text(verbatim: "\(agentWindowTitle(value.target)) · %\(value.target.paneID)") .font(.caption.monospaced()) .foregroundStyle(.secondary) } @@ -678,6 +678,11 @@ private struct RemoteNavigatorView: View { private func windowTitle(for pane: MoriRemoteTerminalPane) -> String { runtime.topology?.windows.first(where: { $0.id == pane.windowID })?.title ?? String(localized: "Window") } + + private func agentWindowTitle(_ target: AgentAttentionTarget) -> String { + target.windowTitle.isEmpty ? String(localized: "Window") : target.windowTitle + } + private func windowMetadata(_ window: MoriRemoteTerminalWindow) -> AgentMetadata { runtime.topology?.panes .filter { $0.windowID == window.id } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index 2933a495..fb8c7633 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -630,10 +630,10 @@ final class TmuxSessionController: @unchecked Sendable { /// leaves a stale server copy mode; renderer-local selection and scrolling /// never issue this command. static let cancelStaleSharedInputMode = "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" - // tmux control mode preserves `\t` as two literal characters in format - // output. Use one fixed printable delimiter; the app parser rejects any - // record whose untrusted fields introduce another delimiter. - static let agentMetadataQuery = "list-panes -a -F '#{session_name}|#{window_id}|#{window_name}|#{pane_id}|#{@mori-agent-state}|#{@mori-agent-name}'" + // tmux 3.2's `q` modifier escapes separators and backslashes. Filter out + // control characters first because that version cannot encode them; this + // keeps every emitted pane on exactly one line without raising our minimum. + static let agentMetadataQuery = "list-panes -a -f '#{&&:#{==:#{m/r:[[:cntrl:]],#{session_name}},0},#{&&:#{==:#{m/r:[[:cntrl:]],#{window_name}},0},#{&&:#{==:#{m/r:[[:cntrl:]],#{@mori-agent-state}},0},#{==:#{m/r:[[:cntrl:]],#{@mori-agent-name}},0}}}}' -F '#{q:session_name}|#{window_id}|#{q:window_name}|#{pane_id}|#{q:@mori-agent-state}|#{q:@mori-agent-name}'" func sendInput(paneID: TmuxPaneID, _ bytes: Data) -> Bool { guard !bytes.isEmpty else { return true } diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift index 601e46a6..8d3880ca 100644 --- a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -77,7 +77,7 @@ final class MoriRemoteTerminalFacadeTests: XCTestCase { func testFixedMetadataCommandUsesMoriHookOptionNames() { XCTAssertEqual( TmuxSessionController.agentMetadataQuery, - "list-panes -a -F '#{session_name}|#{window_id}|#{window_name}|#{pane_id}|#{@mori-agent-state}|#{@mori-agent-name}'" + "list-panes -a -f '#{&&:#{==:#{m/r:[[:cntrl:]],#{session_name}},0},#{&&:#{==:#{m/r:[[:cntrl:]],#{window_name}},0},#{&&:#{==:#{m/r:[[:cntrl:]],#{@mori-agent-state}},0},#{==:#{m/r:[[:cntrl:]],#{@mori-agent-name}},0}}}}' -F '#{q:session_name}|#{window_id}|#{q:window_name}|#{pane_id}|#{q:@mori-agent-state}|#{q:@mori-agent-name}'" ) } diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index 066a95ea..17f3c4b0 100644 --- a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -14,12 +14,25 @@ import Testing #expect(parser.parse(String(repeating: "x", count: AgentMetadataResponseParser.maximumResponseBytes + 1)).isEmpty) } - @Test("malformed, injected, and duplicate source records fail closed while shadows stay hidden") + @Test("q fields preserve legal names and empty titles without delimiter collisions") + func parserDecodesShellEscaping() { + let parser = AgentMetadataResponseParser() + let records = parser.parse( + "build\\|prod\\ space|@1|api\\\"worker\\|x|%1|working|clau\\'de\\|x\n" + + "main|@2||%2|waiting|pi\\\\agent\n" + ) + #expect(records == [ + target(session: "build|prod space", windowID: 1, windowTitle: "api\"worker|x", paneID: 1, state: .working, name: "clau'de|x"), + target(session: "main", windowID: 2, windowTitle: "", paneID: 2, state: .waiting, name: "pi\\agent") + ]) + } + + @Test("malformed, injected, and duplicate escaped records fail closed while shadows stay hidden") func parserRejectsInjectionAndRecordOverflow() { let parser = AgentMetadataResponseParser() - let injectedName = "claude\nmain|@2|deploy|%2|waiting|claude" - let response = "main|@1|editor|%1|working|\(injectedName)\nmain|@2|deploy|%2|done|pi\n" - #expect(parser.parse(response).isEmpty) + #expect(parser.parse("main\tforged|@1|editor|%1|working|claude\n").isEmpty) + #expect(parser.parse("main|@1|editor|extra|%1|working|claude\n").isEmpty) + #expect(parser.parse("main|@1|editor|%1|working|trailing\\\n").isEmpty) #expect(parser.parse("main|bad|editor|%1|working|claude\n").isEmpty) #expect(parser.parse("main|@1|editor|%1|working|claude\nmain|@1|editor|%1|done|pi\n").isEmpty)