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
440 changes: 252 additions & 188 deletions CLI/CLI+Hooks.swift

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions CLI/CLI+TmuxCompat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,37 @@ extension ProgramaCLI {
token = String(token[..<dot])
}
if let colon = token.lastIndex(of: ":") {
let suffix = token[token.index(after: colon)...]
token = suffix.isEmpty ? String(token[..<colon]) : String(suffix)
let prefix = String(token[..<colon])
let suffix = String(token[token.index(after: colon)...])
if !prefix.isEmpty {
// Session-qualified target (tmux "session:window"). Programa has
// no session concept — a tmux "session" created via new-session
// becomes a single workspace titled with the session name — so
// try increasingly narrow interpretations before giving up.
let original = token
let items = try tmuxWorkspaceItems(client: client)

// 1. The full original token might legitimately be a workspace
// title containing a colon.
if let match = items.first(where: {
(($0["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == original
}), let id = match["id"] as? String {
return id
}

// 2. The session component alone matches a workspace title, and
// the window component is tmux's base-index for "the
// session's first window" (0 or 1).
if (suffix == "0" || suffix == "1"),
let match = items.first(where: {
(($0["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == prefix
}), let id = match["id"] as? String {
return id
}

throw CLIError(message: "Workspace target not found: '\(original)'. Programa has no tmux sessions; a session-qualified target like 'name:2' cannot be resolved — target the window by its own name or index.")
}
token = suffix
}
if token.hasPrefix("@") {
token = String(token.dropFirst())
Expand Down
9 changes: 6 additions & 3 deletions CLI/programa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1827,7 +1827,7 @@ struct ProgramaCLI {
do {
_ = try ctx.client.sendV2(method: "window.focus", params: ["window_id": target])
print("OK")
} catch {
} catch let error as CLIError where error.message.hasPrefix("not_found:") {
throw CLIError(message: "ERROR: Window not found")
}
}
Expand Down Expand Up @@ -1858,7 +1858,7 @@ struct ProgramaCLI {
do {
_ = try ctx.client.sendV2(method: "window.close", params: ["window_id": target])
print("OK")
} catch {
} catch let error as CLIError where error.message.hasPrefix("not_found:") {
throw CLIError(message: "ERROR: Window not found")
}
}
Expand Down Expand Up @@ -6090,7 +6090,10 @@ struct ProgramaCLI {
}
params["workspace_id"] = workspaceId
}
if let cwdOpt { params["cwd"] = cwdOpt }
// Help text promises "cwd = --cwd (or the current directory)"; without an
// explicit fallback here, an omitted --cwd sent nothing and the app fell
// back to its own new-tab heuristic instead.
params["cwd"] = cwdOpt ?? FileManager.default.currentDirectoryPath

let payload = try client.sendV2(method: "layout.apply", params: params)
let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown"
Expand Down
54 changes: 53 additions & 1 deletion Sources/MobileBridge/MobileBridgeListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ final class MobileBridgeListener: @unchecked Sendable {
private var isStarting = false
private var generation: UInt64 = 0

/// Live connections per admitted `endpointId`, so `revoke(endpointId:)`
/// can reach an in-progress relay rather than only blocking future
/// reconnects. Keyed by `ObjectIdentifier` (not a `Set`, since
/// `Connection` isn't `Hashable`) to tolerate more than one concurrent
/// connection from the same device. Guarded by `stateLock`, the same
/// lock as every other mutable field on this type. Registered in
/// `handleIncoming` right after `admit()` succeeds; unregistered via
/// `defer` so every relay exit path (normal completion, thrown error)
/// clears its entry.
private var liveConnections: [String: [ObjectIdentifier: Connection]] = [:]

private init() {}

/// Starts the endpoint if it is not already running or starting.
Expand Down Expand Up @@ -121,9 +132,42 @@ final class MobileBridgeListener: @unchecked Sendable {
return MobileBridgePairingInfo(ticket: ticket.description, token: tokenString, expiresAt: expiresAt)
}

/// Revokes a previously paired device immediately.
/// Revokes a previously paired device immediately: removes it from the
/// trusted store (so a reconnect is rejected at `admit()`, unchanged)
/// and closes every connection currently registered for it, so a
/// long-lived relay session doesn't keep running on borrowed trust
/// until the phone disconnects on its own.
func revoke(endpointId: String) async {
await MobileBridgeTrustedDeviceStore.shared.remove(endpointId: endpointId)

stateLock.lock()
let connections = liveConnections[endpointId]
stateLock.unlock()

guard let connections else { return }
for connection in connections.values {
try? connection.close(errorCode: 0, reason: Data("revoked".utf8))
}
}

/// Registers a live, admitted connection so `revoke(endpointId:)` can
/// close it later. Must be paired with `unregisterLiveConnection` on
/// every exit path (see call site in `handleIncoming`).
private func registerLiveConnection(_ connection: Connection, endpointId: String) {
let key = ObjectIdentifier(connection)
stateLock.lock()
liveConnections[endpointId, default: [:]][key] = connection
stateLock.unlock()
}

private func unregisterLiveConnection(_ connection: Connection, endpointId: String) {
let key = ObjectIdentifier(connection)
stateLock.lock()
liveConnections[endpointId]?[key] = nil
if liveConnections[endpointId]?.isEmpty == true {
liveConnections[endpointId] = nil
}
stateLock.unlock()
}

private func bindAndAccept(generation: UInt64) async {
Expand Down Expand Up @@ -248,6 +292,14 @@ final class MobileBridgeListener: @unchecked Sendable {
return
}

// Registered only once admitted -- `revoke()` must never be
// able to reach a connection that hasn't passed `admit()` yet.
// Unregistered via `defer` so this fires whether `relay()`
// returns normally, this scope exits early, or an error is
// thrown while unwinding out of the enclosing `do` block.
registerLiveConnection(connection, endpointId: idString)
defer { unregisterLiveConnection(connection, endpointId: idString) }

#if DEBUG
dlog("mobileBridge.connected id=\(idString)")
#endif
Expand Down
20 changes: 19 additions & 1 deletion Sources/SessionAutosaveCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ final class SessionAutosaveCoordinator {
private var sessionAutosaveTimer: DispatchSourceTimer?
private var sessionAutosaveTickInFlight = false
private var promptSaveScheduled = false
private var consecutiveDeclinedSaveRetries = 0
private static let maxConsecutiveDeclinedSaveRetries = 5
private var sessionAutosaveDeferredRetryPending = false
private var lastSessionAutosaveFingerprint: Data?
private var lastSessionAutosavePersistedAt: Date = .distantPast
Expand Down Expand Up @@ -189,10 +191,26 @@ final class SessionAutosaveCoordinator {
#if DEBUG
let saveStart = ProcessInfo.processInfo.systemUptime
#endif
_ = saveSnapshot(false, autosaveSnapshot)
let saved = saveSnapshot(false, autosaveSnapshot)
#if DEBUG
saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0
#endif
guard saved else {
// The save layer can decline (startup restore still in flight, empty
// snapshot). Recording the fingerprint anyway would suppress up to
// 60s of identical-content saves after a save that never happened,
// and a declined prompt save reopened the escrow shadow gap it was
// built to close (audit 2026-08-20, M3). Retry, bounded: the
// restore-in-flight decline clears within a few seconds, while a
// windowless app declines indefinitely and must not become a 1s
// polling loop — the periodic timer remains the steady cadence.
if consecutiveDeclinedSaveRetries < Self.maxConsecutiveDeclinedSaveRetries {
consecutiveDeclinedSaveRetries += 1
scheduleDeferredSessionAutosaveRetry(after: 1.0)
}
return
}
consecutiveDeclinedSaveRetries = 0
updateSessionAutosaveSaveState(
includeScrollback: false,
persistedAt: now,
Expand Down
9 changes: 8 additions & 1 deletion Sources/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ struct TabItemView: View, Equatable {
lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting &&
lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected &&
lhs.settings == rhs.settings &&
lhs.showsWorktreeBadge == rhs.showsWorktreeBadge
lhs.showsWorktreeBadge == rhs.showsWorktreeBadge &&
// Bindings are normally excluded (recreated per parent eval, don't
// affect rendering) — but body READS these two values (isBeingDragged
// opacity dim, showsCenteredTopDropIndicator), so excluding them froze
// drag visuals mid-drag (audit 2026-08-20, H2). Compare wrapped values;
// only drag interactions churn them, never typing.
lhs.draggedTabId == rhs.draggedTabId &&
lhs.dropIndicator == rhs.dropIndicator
}

// Use plain references instead of @EnvironmentObject to avoid subscribing
Expand Down
16 changes: 8 additions & 8 deletions Sources/TerminalController+Debug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ extension TerminalController {
}

private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }

let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: No tab selected"
Expand Down Expand Up @@ -984,7 +984,7 @@ extension TerminalController {
}

private func isTerminalFocused(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }

let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: is_terminal_focused <panel_id|idx>" }
Expand Down Expand Up @@ -1031,7 +1031,7 @@ extension TerminalController {
}

private func renderStats(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }

let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down Expand Up @@ -1253,7 +1253,7 @@ extension TerminalController {

#if DEBUG
private func focusFromNotification(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
let tabArg = parts.first ?? ""
Expand All @@ -1278,7 +1278,7 @@ extension TerminalController {
}

private func flashCount(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }

Expand Down Expand Up @@ -1320,7 +1320,7 @@ extension TerminalController {
private static var panelSnapshots: [UUID: PanelSnapshotState] = [:]

private func panelSnapshotReset(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: panel_snapshot_reset <panel_id|idx>" }

Expand Down Expand Up @@ -1410,7 +1410,7 @@ extension TerminalController {
}

private func panelSnapshot(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Usage: panel_snapshot <panel_id|idx> [label]" }

Expand Down Expand Up @@ -1520,7 +1520,7 @@ extension TerminalController {
}

private func layoutDebug() -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" }

var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
Expand Down
4 changes: 2 additions & 2 deletions Sources/TerminalController+Notification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,12 @@ extension TerminalController {
/// to that workspace's notifications only; without one, clears all notifications globally.
func v2NotificationClear(params: [String: Any]) -> V2CallResult {
if let workspaceId = v2UUID(params, "workspace_id") {
DispatchQueue.main.async {
v2MainSync {
TerminalNotificationStore.shared.clearNotifications(forTabId: workspaceId)
}
return .ok(["workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)])
}
DispatchQueue.main.async {
v2MainSync {
TerminalNotificationStore.shared.clearAll()
}
return .ok([:])
Expand Down
31 changes: 22 additions & 9 deletions Sources/TerminalController+Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,27 @@ extension TerminalController {
}

func v2WindowCurrent(params _: [String: Any]) -> V2CallResult {
guard let tabManager else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
enum Resolution {
case unavailable
case notFound
case found(UUID)
}
let resolution: Resolution = v2MainSync {
guard let tabManager = self.tabManager else { return .unavailable }
guard let windowId = self.v2ResolveWindowId(tabManager: tabManager) else { return .notFound }
return .found(windowId)
}
guard let windowId = v2ResolveWindowId(tabManager: tabManager) else {
switch resolution {
case .unavailable:
return .err(code: "unavailable", message: "TabManager not available", data: nil)
case .notFound:
return .err(code: "not_found", message: "Current window not found", data: nil)
case .found(let windowId):
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}

func v2WindowFocus(params: [String: Any]) -> V2CallResult {
Expand All @@ -59,8 +70,10 @@ extension TerminalController {
return .err(code: "internal_error", message: "Failed to create window", data: nil)
}
// The new window should become key, but setActiveTabManager defensively.
if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
setActiveTabManager(tm)
v2MainSync {
if let tm = AppDelegate.shared?.tabManagerFor(windowId: windowId) {
self.setActiveTabManager(tm)
}
}
return .ok([
"window_id": windowId.uuidString,
Expand Down
13 changes: 12 additions & 1 deletion Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,10 @@ class TerminalController {
}
}

// Caps the unterminated (no-newline-yet) line buffer so a client that never sends a
// newline can't grow `pending` without bound.
let maxPendingLineBytes = 8 * 1024 * 1024

var buffer = [UInt8](repeating: 0, count: 4096)
var pending = ""
var authenticated = false
Expand All @@ -1503,6 +1507,13 @@ class TerminalController {
let chunk = String(bytes: buffer[0..<bytesRead], encoding: .utf8) ?? ""
pending.append(chunk)

if pending.utf8.count > maxPendingLineBytes {
dilog("socket.conn", "pending line buffer exceeded \(maxPendingLineBytes) bytes; closing")
connection.writeLine("{\"ok\":false,\"error\":{\"code\":\"payload_too_large\"}}")
closeReason = "payload_too_large"
break
}

while let newlineIndex = pending.firstIndex(of: "\n") {
let line = String(pending[..<newlineIndex])
pending = String(pending[pending.index(after: newlineIndex)...])
Expand Down Expand Up @@ -2454,7 +2465,7 @@ class TerminalController {
return tm
}
}
return tabManager
return v2MainSync { self.tabManager }
}

func v2ResolveWindowId(tabManager: TabManager?) -> UUID? {
Expand Down
1 change: 1 addition & 0 deletions docs/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Every shortcut is editable in `Settings → Keyboard Shortcuts` and in `~/.confi
| ⌃ ⇧ Tab | Previous surface |
| ⌃ 1–8 | Jump to surface 1–8 |
| ⌃ 9 | Jump to last surface |
| ⌘ ⇧ T | Reopen closed panel |
| ⌘ W | Close surface |

## Split panes
Expand Down
Loading
Loading