Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
20d3919
feat: add iOS terminal tab overview
austinywang Jun 18, 2026
263e082
Merge remote-tracking branch 'origin/main' into issue-6347-ios-tab-ov…
austinywang Jun 18, 2026
cc9ce95
chore: refresh Swift file length budget
austinywang Jun 18, 2026
0a657f3
fix: route mobile terminal close
austinywang Jun 18, 2026
04f90b7
fix: address iOS tab overview localization
austinywang Jun 18, 2026
fe07472
merge: resolve conflicts with main
austinywang Jun 18, 2026
d01bfc9
fix: mark mobile shell logger nonisolated
austinywang Jun 18, 2026
1a8c7be
fix: reuse terminal close workspace response
austinywang Jun 18, 2026
03caa0f
chore: remove tab overview screenshot artifact
austinywang Jun 18, 2026
aad6153
merge: resolve conflicts with main
austinywang Jun 20, 2026
8a2d30a
fix: keep terminal overview RPC off main actor
austinywang Jun 20, 2026
537ac4b
fix: bound terminal overview refresh
austinywang Jun 20, 2026
db56db3
chore: isolate terminal overview debug harness
austinywang Jun 20, 2026
579310a
fix: refresh stale terminal overview previews
austinywang Jun 20, 2026
20cfde0
Merge remote-tracking branch 'origin/main' into issue-6347-ios-tab-ov…
austinywang Jun 20, 2026
c6b8b5a
fix: honor mobile terminal close protections
austinywang Jun 20, 2026
b304136
fix: bound mobile overview previews
austinywang Jun 20, 2026
285fa48
fix: use bounded scalar preview cache
austinywang Jun 20, 2026
343cd4d
fix: hide protected terminal close actions
austinywang Jun 20, 2026
fe22c53
fix: honor hidden terminal close buttons
austinywang Jun 20, 2026
c793b12
fix: refresh closeability before tab overview
austinywang Jun 20, 2026
472a078
Merge remote-tracking branch 'origin/main' into issue-6347-ios-tab-ov…
austinywang Jun 20, 2026
792977e
fix: stabilize terminal overview review findings
austinywang Jun 20, 2026
0657303
fix: limit preview host capabilities to implemented actions
austinywang Jun 20, 2026
238f122
Merge remote-tracking branch 'origin/main' into issue-6347-ios-tab-ov…
austinywang Jun 20, 2026
f68f511
fix: align terminal close resolver and preview fixture
austinywang Jun 20, 2026
51d9819
chore: satisfy terminal overview policy gate
austinywang Jun 20, 2026
0f9a494
merge: sync with origin main
austinywang Jun 20, 2026
6bbcfab
fix: bound terminal overview preview rows
austinywang Jun 20, 2026
e7c3666
fix: guard terminal overview cover on UIKit
austinywang Jun 20, 2026
2eba02b
merge: sync with origin main
austinywang Jun 20, 2026
7f46692
fix: refresh mobile terminal close state
austinywang Jun 20, 2026
2b578bc
fix: refresh mobile close and chat navigation state
austinywang Jun 20, 2026
38d59cf
fix: stabilize mobile overview preview layout
austinywang Jun 20, 2026
11e7e4b
fix: keep mobile title clear of overview controls
austinywang Jun 20, 2026
cdd2720
fix: use async streams for mobile close updates
austinywang Jun 20, 2026
093e2f9
fix: scope mobile close policy updates
austinywang Jun 20, 2026
5824251
fix: return close policy signal stream
austinywang Jun 20, 2026
a4d05c4
fix: avoid close policy snapshot type
austinywang Jun 20, 2026
f1357ae
Merge remote-tracking branch 'origin/main' into issue-6347-ios-tab-ov…
austinywang Jun 20, 2026
721d363
fix: guard mobile close responses
austinywang Jun 20, 2026
53a02d4
chore: move mobile preview shell to debug sources
austinywang Jun 20, 2026
5f7e36d
fix: use catalog plural for mobile tab count
austinywang Jun 20, 2026
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
14 changes: 7 additions & 7 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
34335 CLI/cmux.swift
17710 Sources/AppDelegate.swift
16052 Sources/ContentView.swift
13992 Sources/TerminalController.swift
12695 Sources/Workspace.swift
14074 Sources/TerminalController.swift
12753 Sources/Workspace.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
11841 Sources/GhosttyTerminalView.swift
11411 Sources/Panels/BrowserPanel.swift
Expand All @@ -16,10 +16,10 @@
6317 cmuxTests/SessionPersistenceTests.swift
6220 cmuxTests/GhosttyConfigTests.swift
6196 CLI/cmux_open.swift
6182 cmuxTests/TerminalAndGhosttyTests.swift
6166 Sources/TabManager.swift
6072 Sources/TextBoxInput.swift
5922 cmuxTests/TerminalAndGhosttyTests.swift
5566 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5578 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5526 cmuxTests/BrowserConfigTests.swift
4477 Sources/cmuxApp.swift
4467 Sources/Panels/FilePreviewPanel.swift
Expand All @@ -39,7 +39,7 @@
2565 Sources/Panels/CmuxWebView.swift
2546 cmuxTests/WorkspaceManualUnreadTests.swift
2524 cmuxTests/CommandPaletteSearchEngineTests.swift
2395 Sources/Mobile/MobileHostService.swift
2396 Sources/Mobile/MobileHostService.swift
2355 Sources/FileExplorerView.swift
2328 cmuxTests/CJKIMEInputTests.swift
2257 Sources/TerminalNotificationStore.swift
Expand Down Expand Up @@ -88,6 +88,7 @@
1021 cmuxUITests/TerminalCmdClickUITests.swift
1009 cmuxTests/CmuxTopSnapshotScopeTests.swift
1006 cmuxTests/CmuxSSHURLRequestTests.swift
963 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
951 Sources/App/TerminalDirectoryOpenSupport.swift
947 Sources/TerminalNotificationPolicy.swift
945 Sources/SessionIndexRegisteredAgents.swift
Expand All @@ -103,7 +104,6 @@
877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
865 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
Expand Down Expand Up @@ -218,8 +218,8 @@
509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift
507 Sources/TerminalControllerTopSupport.swift
506 Sources/App/MainWindowVisibilityController.swift
505 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
505 cmuxUITests/DisplayResolutionRegressionUITests.swift
504 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
504 cmuxTests/TerminalNotificationSocketActionTests.swift
503 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
503 Sources/Settings/ConfigSource.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
)
case "mobile.terminal.create", "terminal.create":
return false
case "mobile.terminal.input", "terminal.input",
case "mobile.terminal.close", "terminal.close",
"mobile.terminal.input", "terminal.input",
"mobile.terminal.paste", "terminal.paste",
"mobile.terminal.paste_image", "terminal.paste_image",
"mobile.terminal.replay", "terminal.replay",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,16 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let isFocused: Bool
/// Whether the terminal surface is ready, if reported.
public let isReady: Bool?
/// Whether the Mac will accept a non-interactive close request for this terminal.
public let canClose: Bool?

private enum CodingKeys: String, CodingKey {
case id
case title
case currentDirectory = "current_directory"
case isFocused = "is_focused"
case isReady = "is_ready"
case canClose = "can_close"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ extension MobileTerminalPreview {
id: ID(rawValue: remote.id),
name: remote.title,
isReady: remote.isReady ?? true,
isFocused: remote.isFocused
isFocused: remote.isFocused,
canClose: remote.canClose ?? false
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#if DEBUG
import CmuxMobileShellModel

extension MobileShellComposite {
/// Builds a connected preview store for terminal overview simulator screenshots.
///
/// This is only used by the DEBUG `CMUX_UITEST_TERMINAL_OVERVIEW_PREVIEW`
/// launch hook. It avoids real auth/pairing dependencies while exercising
/// the same workspace, toolbar, and overview grid views as the app.
public static func terminalOverviewPreviewHarnessStore() -> CMUXMobileShellStore {
let store = preview()
store.signIn()
store.pairingCode = "debug"
store.connectPreviewHost()
store.terminalOverviewPreviewLinesByID = [
"terminal-build": [
"$ CMUX_SKIP_ZIG_BUILD=1 ./ios/scripts/reload.sh --tag issue-6347-ios-tab-overview",
"Building cmux-ios for iPhone 17 simulator",
"Compile Swift sources",
"Install and launch dev.cmux.ios.issue-6347-ios-tab-overview",
"Build succeeded",
],
"terminal-agent": [
"$ swift test --package-path Packages/iOS/CmuxMobileShell --filter MobileShellCompositePreviewTests",
"Suite MobileShellCompositePreviewTests started",
"overviewPreviewLinesUseRenderGridRows passed",
"closeTerminalRemovesSelectedTerminalAndSelectsNeighbor passed",
"Test run with 16 tests passed",
],
"terminal-tui": [
"LAZYGIT",
"files branches log",
"main issue-6347-ios-tab-overview",
"A TerminalTabOverviewView.swift",
"A TerminalTabOverviewCard.swift",
],
"terminal-notes": [
"$ rg terminal overview docs",
"iOS Safari-style tab switcher",
"grid previews, close buttons, and tab count",
],
]
return store
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ extension MobileShellComposite {
}

func deliverTerminalRenderGrid(_ frame: MobileTerminalRenderGridFrame, surfaceID: String) {
if frame.full {
let terminalID = MobileTerminalPreview.ID(rawValue: surfaceID)
terminalOverviewPreviewLinesByID[terminalID] =
Self.terminalOverviewPreviewLines(from: frame)
terminalOverviewPreviewUpdatedAtByID[terminalID] = runtimeNow()
}
deliverTerminalOutput(
TerminalOutputDelivery(
renderGrid: frame,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import CMUXMobileCore
internal import CmuxMobileRPC
public import CmuxMobileShellModel
internal import Foundation
internal import OSLog

nonisolated private let terminalOverviewLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "mobile-shell"
)

extension MobileShellComposite {
private static var terminalOverviewPreviewRefreshInterval: TimeInterval { 10 }

/// Returns the latest cached plain-text thumbnail rows for a terminal overview card.
/// - Parameter terminalID: The terminal whose overview preview should be read.
/// - Returns: Cached rows when a replay or live render-grid frame has populated them.
public func terminalOverviewPreviewLines(for terminalID: MobileTerminalPreview.ID) -> [String]? {
terminalOverviewPreviewLinesByID[terminalID]
}

/// Refreshes terminal overview thumbnails for every terminal in a workspace.
///
/// The Mac remains the source of truth: each card asks for the same
/// `mobile.terminal.replay` render-grid snapshot used by foreground terminal
/// attach, then stores a bounded plain-text projection for SwiftUI.
/// - Parameter workspaceID: The workspace whose terminal previews should refresh.
public func refreshTerminalOverviewPreviews(in workspaceID: MobileWorkspacePreview.ID) async {
guard let workspace = workspaces.first(where: { $0.id == workspaceID }) else {
return
}
pruneTerminalOverviewPreviewCacheForLiveTerminals()
let now = runtimeNow()
let terminalsNeedingReplay = workspace.terminals.filter { terminal in
terminal.isReady && shouldRefreshTerminalOverviewPreview(for: terminal.id, now: now)
}
guard !terminalsNeedingReplay.isEmpty else {
return
}
guard let client = remoteClient else {
return
}
for terminal in terminalsNeedingReplay {
guard !Task.isCancelled else { return }
do {
let lines = try await Self.requestTerminalOverviewPreviewLines(
workspaceID: workspaceID,
terminalID: terminal.id,
client: client
)
guard remoteClient === client, connectionState == .connected else { return }
terminalOverviewPreviewLinesByID[terminal.id] = lines
terminalOverviewPreviewUpdatedAtByID[terminal.id] = runtimeNow()
} catch {
guard remoteClient === client, connectionState == .connected else { return }
guard !disconnectForAuthorizationFailureIfNeeded(error) else { return }
terminalOverviewLog.error("terminal overview preview failed workspace=\(workspaceID.rawValue, privacy: .private) terminal=\(terminal.id.rawValue, privacy: .private) error=\(String(describing: error), privacy: .public)")
}
}
}

/// Closes a terminal from the mobile tab overview.
///
/// The Mac rejects protected cases such as closing the last terminal; iOS then
/// re-syncs from the authoritative workspace list. Preview mode applies the
/// same last-terminal rule against the in-memory fixtures.
/// - Parameters:
/// - terminalID: The terminal to close.
/// - workspaceID: The workspace containing `terminalID`.
public func closeTerminal(
id terminalID: MobileTerminalPreview.ID,
in workspaceID: MobileWorkspacePreview.ID
) async {
guard remoteClient != nil else {
closePreviewTerminal(id: terminalID, in: workspaceID)
return
}
guard supportsTerminalCloseActions else { return }
await closeRemoteTerminal(id: terminalID, in: workspaceID)
}

nonisolated static func terminalOverviewPreviewLines(from renderGrid: MobileTerminalRenderGridFrame) -> [String] {
guard renderGrid.full else { return [] }
let rows = renderGrid.plainRows()
.prefix(24)
.map { terminalOverviewPreviewLine(from: $0) }
guard rows.contains(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
return []
}
return rows
}

func pruneTerminalOverviewPreviewCacheForLiveTerminals() {
let liveTerminalIDs = Set(workspaces.flatMap { $0.terminals.map(\.id) })
terminalOverviewPreviewLinesByID = terminalOverviewPreviewLinesByID.filter { liveTerminalIDs.contains($0.key) }
terminalOverviewPreviewUpdatedAtByID = terminalOverviewPreviewUpdatedAtByID.filter { liveTerminalIDs.contains($0.key) }
}

private func shouldRefreshTerminalOverviewPreview(
for terminalID: MobileTerminalPreview.ID,
now: Date
) -> Bool {
guard terminalOverviewPreviewLinesByID[terminalID] != nil,
let updatedAt = terminalOverviewPreviewUpdatedAtByID[terminalID] else {
return true
}
return now.timeIntervalSince(updatedAt) >= Self.terminalOverviewPreviewRefreshInterval
}

// Keep RPC send/decode work off MobileShellComposite's main-actor state owner.
private nonisolated static func requestTerminalOverviewPreviewLines(
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID,
client: MobileCoreRPCClient
) async throws -> [String] {
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.replay",
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": terminalID.rawValue,
]
)
let data = try await client.sendRequest(request)
let payload = try MobileTerminalReplayResponse.decode(data)
guard let renderGrid = payload.renderGrid,
renderGrid.surfaceID == terminalID.rawValue else {
return []
}
return terminalOverviewPreviewLines(from: renderGrid)
}

private func closeRemoteTerminal(
id terminalID: MobileTerminalPreview.ID,
in workspaceID: MobileWorkspacePreview.ID
) async {
guard let client = remoteClient else { return }
terminalCloseRequestGeneration &+= 1
let closeGeneration = terminalCloseRequestGeneration
do {
let response = try await Self.requestCloseRemoteTerminal(
workspaceID: workspaceID,
terminalID: terminalID,
clientID: clientID,
client: client
)
guard remoteClient === client,
connectionState == .connected,
closeGeneration == terminalCloseRequestGeneration,
!Task.isCancelled else { return }
terminalOverviewPreviewLinesByID[terminalID] = nil
terminalOverviewPreviewUpdatedAtByID[terminalID] = nil
applyRemoteWorkspaceList(response, mergeExistingWorkspaces: true)
} catch {
guard remoteClient === client,
closeGeneration == terminalCloseRequestGeneration,
!Task.isCancelled else { return }
guard !disconnectForAuthorizationFailureIfNeeded(error) else { return }
markMacConnectionUnavailableIfNeeded(after: error)
terminalOverviewLog.error("terminal close failed workspace=\(workspaceID.rawValue, privacy: .private) terminal=\(terminalID.rawValue, privacy: .private) error=\(String(describing: error), privacy: .public)")
await refreshWorkspaces()
}
}

// Keep RPC send/decode work off MobileShellComposite's main-actor state owner.
private nonisolated static func requestCloseRemoteTerminal(
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID,
clientID: String,
client: MobileCoreRPCClient
) async throws -> MobileSyncWorkspaceListResponse {
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.close",
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": terminalID.rawValue,
"client_id": clientID,
]
)
let responseData = try await client.sendRequest(request)
return try MobileSyncWorkspaceListResponse.decode(responseData)
}

private func closePreviewTerminal(
id terminalID: MobileTerminalPreview.ID,
in workspaceID: MobileWorkspacePreview.ID
) {
guard let workspaceIndex = workspaces.firstIndex(where: { $0.id == workspaceID }) else {
return
}
let terminals = workspaces[workspaceIndex].terminals
guard terminals.count > 1,
let closingIndex = terminals.firstIndex(where: { $0.id == terminalID }) else {
return
}
workspaces[workspaceIndex].terminals.remove(at: closingIndex)
terminalOverviewPreviewLinesByID[terminalID] = nil
terminalOverviewPreviewUpdatedAtByID[terminalID] = nil
if selectedWorkspaceID == workspaceID, selectedTerminalID == terminalID {
let remaining = workspaces[workspaceIndex].terminals
let replacementIndex = min(closingIndex, remaining.count - 1)
selectedTerminalID = remaining.indices.contains(replacementIndex)
? remaining[replacementIndex].id
: remaining.first?.id
}
}

private nonisolated static func terminalOverviewPreviewLine(from row: String) -> String {
let maxScalars = 160
let maxUTF8Bytes = 1024
let space: Unicode.Scalar = " "
let tab: Unicode.Scalar = "\t"
var line = String()
line.reserveCapacity(maxUTF8Bytes)
var scalarCount = 0
var byteCount = 0
for scalar in row.unicodeScalars {
guard scalarCount < maxScalars else { break }
let scalarByteCount = String(scalar).utf8.count
guard byteCount + scalarByteCount <= maxUTF8Bytes else { break }
line.unicodeScalars.append(scalar)
scalarCount += 1
byteCount += scalarByteCount
}
while let last = line.unicodeScalars.last,
last == space || last == tab {
line.removeLast()
}
return line
}
}
Loading
Loading