Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ 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.

### 🐛 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.
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
### ✨ 新功能

- **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。

### 🐛 问题修复

- **iOS(MoriRemote)**:修复 tmux control mode 将查询中的 `\\t` 分隔符原样保留,导致 Agent 分类始终显示零状态的问题;空状态也不再与统计标题重叠。
- **iOS(MoriRemote)**:让工作区运行状态、拓扑和 agent 元数据可被直接观察,避免 Navigator 与资源库徽章只写入无人读取的 revision 而不更新。
- **iOS(MoriRemote)**:在 Ghostty 发布每个完整渲染帧后刷新本地回滚区几何,避免预渲染终端变更回调读到旧状态,导致滚动在真实底部之前被硬性截断。
- **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。
Expand Down
140 changes: 134 additions & 6 deletions MoriRemote/MoriRemote/App/RemoteRootModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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] {
Expand All @@ -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,
Expand All @@ -385,6 +449,7 @@ final class RemoteRootModel {
}
} else if shouldActivate {
activate(workspaceID: workspaceID)
bindPendingAttentionSelection(to: runtime, workspaceID: workspaceID)
}
return
}
Expand All @@ -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()
Expand All @@ -435,20 +506,25 @@ 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
}
} 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 }
}
}
}

func dismissTrust() {
if case let .connect(workspaceID) = pendingTrustAction {
clearPendingAttentionSelection(for: workspaceID)
}
pendingTrust = nil
pendingTrustAction = nil
}
Expand All @@ -470,18 +546,33 @@ 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)
await runtime.stop()
}

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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@
"Windows" = "Windows";
"Panes" = "Panes";
"Active workspaces" = "Active Workspaces";
"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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@
"Windows" = "窗口";
"Panes" = "面板";
"Active workspaces" = "活动工作区";
"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" = "服务器";
Expand Down
Loading
Loading