Skip to content
Draft
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
86 changes: 86 additions & 0 deletions supacode-cli/Commands/TabCommand.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ArgumentParser
import CryptoKit
import Foundation

struct TabCommand: ParsableCommand {
Expand All @@ -9,6 +10,7 @@ struct TabCommand: ParsableCommand {
List.self,
Focus.self,
New.self,
AdoptZmx.self,
Close.self,
],
defaultSubcommand: Focus.self
Expand Down Expand Up @@ -90,6 +92,71 @@ extension TabCommand {
}
}

struct AdoptZmx: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "adopt-zmx",
abstract: "Add or move an existing zmx session into a worktree tab."
)

@Argument(help: "Existing zmx session name.")
var session: String

@Option(name: [.short, .long], help: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID.")
var worktree: String?

@Option(help: "Tab title to show in Supacode.")
var title: String?

@Option(name: [.short, .customLong("id")], help: "Stable UUID for the adopted tab.")
var newID: String?

@OptionGroup var timeoutOption: TimeoutOption

func run() throws {
let sessionID = try ZmxSessionArgument.normalized(session)
let worktreeID = try resolveWorktreeID(worktree)
let resolvedID: String
if let newID {
resolvedID = try Self.validatedTabID(newID)
} else {
resolvedID = Self.stableTabID(sessionID: sessionID)
}
try Dispatcher.dispatch(
deeplinkURL: DeeplinkURLBuilder.tabAdoptZmx(
worktreeID: worktreeID,
sessionID: sessionID,
title: title,
id: resolvedID
),
timeoutSeconds: timeoutOption.timeout
)
print(resolvedID)
}

private static func stableTabID(sessionID: String) -> String {
let digest = SHA256.hash(data: Data("supacode:zmx-session:\(sessionID)".utf8))
var bytes = Array(digest.prefix(16))
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
return UUID(
uuid: (
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
).uuidString
}

private static func validatedTabID(_ raw: String) throws -> String {
guard let uuid = UUID(uuidString: raw) else {
throw ValidationError("Invalid --id value: expected a UUID.")
}
return uuid.uuidString
}
}

struct Close: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Close a tab.")

Expand All @@ -111,3 +178,22 @@ extension TabCommand {
}
}
}

private nonisolated enum ZmxSessionArgument {
static func normalized(_ value: String) throws -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw ValidationError("zmx session name cannot be empty.")
}
guard trimmed != ".", trimmed != "..",
!trimmed.unicodeScalars.contains(where: isRejectedScalar)
else {
throw ValidationError("zmx session name cannot be '.', '..', contain '/', or contain control characters.")
}
return trimmed
}

private static func isRejectedScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value == 0 || scalar.value == 0x2F || CharacterSet.controlCharacters.contains(scalar)
}
}
11 changes: 11 additions & 0 deletions supacode-cli/Helpers/DeeplinkURLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ nonisolated enum DeeplinkURLBuilder {
return url
}

static func tabAdoptZmx(worktreeID: String, sessionID: String, title: String?, id: String) -> String {
var url = "supacode://worktree/\(worktreeID)/tab/adopt-zmx"
var params = [
"session=\(percentEncodeQueryValue(sessionID))",
"id=\(id)",
]
if let title { params.append("title=\(percentEncodeQueryValue(title))") }
url += "?\(params.joined(separator: "&"))"
return url
}

static func tabClose(worktreeID: String, tabID: String) -> String {
"supacode://worktree/\(worktreeID)/tab/\(tabID)/destroy"
}
Expand Down
15 changes: 15 additions & 0 deletions supacode/Clients/Deeplink/DeeplinkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ private nonisolated enum DeeplinkParser {
) -> Deeplink? {
// "tab/<tab-uuid>" → focus tab.
// "tab/new" → create new tab.
// "tab/adopt-zmx" → ensure an existing zmx session has a tab.
// "tab/<tab-uuid>/destroy" → close tab.
// "tab/<tab-uuid>/surface/<surface-uuid>" → focus surface.
// "tab/<tab-uuid>/surface/<surface-uuid>/split" → split surface.
Expand All @@ -202,6 +203,20 @@ private nonisolated enum DeeplinkParser {
let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:))
return .worktree(id: worktreeID, action: .tabNew(input: input, id: id))
}
if thirdSegment == "adopt-zmx" {
guard let rawSessionID = queryItems.first(where: { $0.name == "session" })?.value,
let sessionID = ZmxExternalSessionName.normalized(rawSessionID)
else {
logger.warning("adopt-zmx deeplink missing or invalid session.")
return nil
}
guard let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:)) else {
logger.warning("adopt-zmx deeplink missing or invalid id.")
return nil
}
let title = queryItems.first(where: { $0.name == "title" })?.value
return .worktree(id: worktreeID, action: .tabAdoptZmx(sessionID: sessionID, title: title, id: id))
}

guard let tabUUID = UUID(uuidString: thirdSegment) else {
logger.warning("Invalid tab UUID: \(thirdSegment)")
Expand Down
1 change: 1 addition & 0 deletions supacode/Clients/Terminal/TerminalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct TerminalClient {
enum Command: Equatable {
case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil)
case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil)
case adoptZmxSession(Worktree, sessionID: String, title: String?, id: UUID)
case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool)
case stopRunScript(Worktree)
case stopScript(Worktree, definitionID: UUID)
Expand Down
32 changes: 32 additions & 0 deletions supacode/Clients/Zmx/ZmxClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,20 @@ nonisolated enum ZmxSessionID {
}
}

nonisolated enum ZmxExternalSessionName {
static func normalized(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed != ".", trimmed != ".." else { return nil }
guard !trimmed.unicodeScalars.contains(where: isRejectedScalar) else { return nil }
return trimmed
}

private static func isRejectedScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value == 0 || scalar.value == 0x2F || CharacterSet.controlCharacters.contains(scalar)
}
}

nonisolated enum ZmxSocketBudget {
/// macOS `sockaddr_un.sun_path` limit, minus a small safety margin.
static let sunPathLimit = 104
Expand Down Expand Up @@ -388,6 +402,24 @@ nonisolated enum ZmxAttach {
[executablePath, "attach", sessionID]
}

static func buildExternalAttachCommand(executablePath: String, sessionID: String) -> String? {
guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil }
return existingExternalAttachScript(executable: shellQuote(executablePath), sessionID: sessionID)
}

static func buildRemoteExternalAttachCommand(host: RemoteHost, sessionID: String) -> String? {
guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil }
let remote = posixShellWrapped(existingExternalAttachScript(executable: "zmx", sessionID: sessionID))
return SSHCommand.commandLine(host: host, remoteCommand: remote)
}

private static func existingExternalAttachScript(executable: String, sessionID: String) -> String {
let quotedSessionID = shellQuote(sessionID)
return "if \(executable) list --short | grep -Fx -- \(quotedSessionID) >/dev/null; "
+ "then exec \(executable) attach \(quotedSessionID); "
+ "else printf 'zmx session not found: %s\\n' \(quotedSessionID); exit 1; fi"
}

/// Resolves how a surface launches under zmx, given the budget-gated executable
/// path (nil when zmx is unbundled or over budget). Interactive surfaces
/// (`command == nil`) keep a nil command and get an argv `command-wrapper`, so
Expand Down
1 change: 1 addition & 0 deletions supacode/Domain/Deeplink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ enum Deeplink: Equatable, Sendable {
case appearance(title: String?, color: String?)
case tab(tabID: UUID)
case tabNew(input: String?, id: UUID?)
case tabAdoptZmx(sessionID: String, title: String?, id: UUID)
case tabDestroy(tabID: UUID)
case surface(tabID: UUID, surfaceID: UUID, input: String?)
case surfaceSplit(tabID: UUID, surfaceID: UUID, direction: SplitDirection, input: String?, id: UUID?)
Expand Down
25 changes: 24 additions & 1 deletion supacode/Features/App/Reducer/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1853,7 +1853,7 @@ struct AppFeature {
// user can actually clear the orphan.
let spawnsShell: Bool
switch action {
case .run, .runScript, .tabNew, .surfaceSplit:
case .run, .runScript, .tabNew, .tabAdoptZmx, .surfaceSplit:
spawnsShell = true
case .surface(_, _, let input):
spawnsShell = input?.isEmpty == false
Expand Down Expand Up @@ -2008,6 +2008,29 @@ struct AppFeature {
return awaitingCompletion(
effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) },
responseFD: responseFD, timeoutSeconds: timeoutSeconds, state: &state)
case .tabAdoptZmx(let sessionID, let title, let id):
guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else {
state.alert = AlertState {
TextState("Invalid zmx session")
} actions: {
ButtonState(role: .cancel, action: .dismiss) { TextState("OK") }
} message: {
TextState("The zmx session name contains unsupported characters.")
}
return .none
}
let alreadyExists = terminalClient.tabExists(worktreeID, TerminalTabID(rawValue: id))
let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in
.adoptZmxSession(worktree, sessionID: sessionID, title: title, id: id)
}
guard !alreadyExists else { return effect }
return awaitingCompletion(
effect,
match: .tabInWorktree(worktreeID: worktreeID, tabID: id),
responseFD: responseFD,
timeoutSeconds: timeoutSeconds,
state: &state
)
case .tabDestroy(let tabID):
guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return .none }
guard bypassConfirmation else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ final class WorktreeTerminalManager {
Task {
createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, tabID: id)
}
case .adoptZmxSession(let worktree, let sessionID, let title, let id):
adoptZmxSession(in: worktree, sessionID: sessionID, title: title, tabID: id)
case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing):
let state = state(for: worktree) { runSetupScriptIfNew }
state.ensureInitialTab(focusing: focusing)
Expand Down Expand Up @@ -363,7 +365,7 @@ final class WorktreeTerminalManager {
state(for: worktree).navigateSearchOnFocusedSurface(.previous)
case .endSearch(let worktree):
state(for: worktree).performBindingActionOnFocusedSurface("end_search")
case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript,
case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript,
.runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction,
.performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface,
.destroyTab, .destroySurface, .setImagePasteAgents, .prune, .setNotificationsEnabled, .setSelectedWorktreeID,
Expand All @@ -381,7 +383,7 @@ final class WorktreeTerminalManager {
state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID)
case .setImagePasteAgents(let surfaceID, let agents):
setImagePasteAgents(agents, onSurfaceID: surfaceID)
case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript,
case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript,
.runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection,
.navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex,
.focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled,
Expand Down Expand Up @@ -419,7 +421,7 @@ final class WorktreeTerminalManager {
// event fires; refresh here or the window keeps the previous tint.
refreshFocusedSurfaceBackground()
terminalLogger.info("Selected worktree \(id?.rawValue ?? "nil")")
case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript,
case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript,
.runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction,
.performBindingActionOnSurface, .setImagePasteAgents, .startSearch, .searchSelection, .navigateSearchNext,
.navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface,
Expand Down Expand Up @@ -607,6 +609,29 @@ final class WorktreeTerminalManager {
worktreeID: worktree.id, attemptedID: tabID, message: "Could not create the tab."))
}

private func adoptZmxSession(
in worktree: Worktree,
sessionID: String,
title: String?,
tabID: UUID
) {
let terminalTabID = TerminalTabID(rawValue: tabID)
for (existingWorktreeID, existingState) in states where existingWorktreeID != worktree.id {
guard existingState.hasTab(terminalTabID) else { continue }
existingState.closeTab(terminalTabID)
break
}
let state = state(for: worktree)
let adopted = state.adoptZmxSession(sessionID: sessionID, title: title, tabID: tabID)
guard adopted == nil else { return }
emit(
.surfaceCreationFailed(
worktreeID: worktree.id,
attemptedID: tabID,
message: "Could not adopt the zmx session."
))
}

@discardableResult
func closeFocusedTab(in worktree: Worktree) -> Bool {
let state = state(for: worktree)
Expand All @@ -632,7 +657,7 @@ final class WorktreeTerminalManager {
}
let prunedSurfaceIDs = Set(removed.flatMap { _, state in state.allSurfaceIDs })
let prunedSessionIDs = removed.flatMap { _, state in
state.allSurfaceIDs.map { ZmxSessionID.make(surfaceID: $0) }
state.ownedZmxSessionIDs(forSurfaceIDs: state.allSurfaceIDs)
}
let prunedRemoteSessions = Self.remoteSessions(in: removed.map(\.1))
for (id, state) in removed {
Expand All @@ -659,16 +684,16 @@ final class WorktreeTerminalManager {
killZmxSessions(prunedSessionIDs, remoteSessions: prunedRemoteSessions)
}

/// Host-side zmx sessions owned by the given states, one entry per surface
/// of each remote worktree. Unconditional on the persistence toggle: a host
/// session may exist from an earlier launch, and the kill invocation is a
/// silent no-op when nothing exists.
/// Host-side zmx sessions owned by the given states, one entry per owned
/// surface of each remote worktree. Unconditional on the persistence toggle:
/// a host session may exist from an earlier launch, and the kill invocation
/// is a silent no-op when nothing exists.
private static func remoteSessions(
in states: [WorktreeTerminalState]
) -> [(host: RemoteHost, sessionID: String)] {
states.flatMap { state -> [(host: RemoteHost, sessionID: String)] in
guard let host = state.remoteHost else { return [] }
return state.allSurfaceIDs.map { (host, ZmxSessionID.make(surfaceID: $0)) }
return state.ownedZmxSessionIDs(forSurfaceIDs: state.allSurfaceIDs).map { (host, $0) }
}
}

Expand Down
4 changes: 3 additions & 1 deletion supacode/Features/Terminal/Models/TerminalTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ final class TerminalTabManager {
guard let index = tabs.firstIndex(where: { $0.id == id }) else { return }
guard !tabs[index].isTitleLocked else { return }
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
tabs[index].customTitle = trimmed.isEmpty ? nil : trimmed
let customTitle = trimmed.isEmpty ? nil : trimmed
guard tabs[index].customTitle != customTitle else { return }
tabs[index].customTitle = customTitle
}

func isBlockingScript(_ id: TerminalTabID) -> Bool {
Expand Down
Loading
Loading