diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 2332704fb..f8f4c6766 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -339,7 +339,10 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi tree. `ProcessSweeper` is nil by default because a test's fake listing names real pids. - `session.scratch` is a third, nonpersisted login shell with on/off/toggle. It spawns lazily, survives hiding, recreates after exit, and renders as a full translucent cover below overlay. It has no session - PWD/title link but a weak watermark link. GUI surfaces are Command-J, titlebar, View, and palette. + link but a weak watermark one; its PWD reaches only `Session.scratchCwd`, never the row's cwd or title. + A local scratch seeds that field with its start directory, so `AGT_SESSION_PWD` and + `AGT_SESSION_HOST` agree. + GUI surfaces are Command-J, titlebar, View, and palette. Showing never selects; `--follow` does, as on `overlay.open`. An unselected session's scratch still spawns, the deck mounting every session, and takes no focus: `deckActive` gates the grab. `ControlActions` keeps the four-argument requirement and defaults the `follow:` overload to it. @@ -797,7 +800,8 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi trailing `.`, `?`, `!` or `;` too, so a path ending in one also lists up to three spellings with one character removed at a time (`trimmedSpellings`), each split for a suffix the same way; a relative link is joined to the clicking pane's session cwd before any scheme parse, and a first segment with a colon is never a directory, so - `http://localhost:3000` stays a web link. The scratch, quick and overlay terminals pass no cwd. Every candidate has + `http://localhost:3000` stays a web link. A local scratch passes its own cwd; a remote window's scratch, whose + directory is the server's, and the quick and overlay terminals pass none. Every candidate has passed `guardedLocalPath` (absolute, no `//`, no automount root) and a remote pane offers none, so a refused path is never stat'ed. `LinkOpener` checks them off the main actor and routes the first that exists; with none on disk the link keeps its typed route. @@ -1570,6 +1574,8 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi the last reported split cwd to its restored initial cwd, then the primary effective cwd. Omitted without a split or on older servers; it is model read-back, not a fresh process query. `title` stays the raw primary OSC title; exposing `splitTitle` is deferred. +- `scratchCwd` reports `cwd(for: .scratch)` while a scratch surface exists, shown or hidden: its last reported + or start directory, else the primary effective cwd. `closeScratch` clears it with the surface. - `window.resize` echoes the applied frame size after `setFrame`, rounded to integer points like `window.list` geometry, as `result.width`/`result.height`. Human output is `W H`. - Session nodes include foreground/split foreground argv, idle shell basenames, background spec, overlay diff --git a/.claude/rules/keymap.md b/.claude/rules/keymap.md index 93e3138ca..eab5c7979 100644 --- a/.claude/rules/keymap.md +++ b/.claude/rules/keymap.md @@ -147,7 +147,9 @@ paths: in `{...}` or `$...` form, no-op with notice; empty `{AGT_SESSION_PWD}` can turn `rm -rf .../*` into a root glob. Commands using only `AGT_SOCKET`/`AGT_WINDOW`/`AGT_PANE` may run sessionless. - `{AGT_SESSION_HOST}`/`$AGT_SESSION_HOST` is the SSH destination of a `zmx attach` session, empty - otherwise; an `ssh` typed into a local session leaves it empty while `AGT_SESSION_PWD` still follows + otherwise and from that session's scratch, a local shell (`Session.remoteHost(for:)`); a remote window's + scratch attaches the server's shell and keeps it. + An `ssh` typed into a local session leaves it empty while `AGT_SESSION_PWD` still follows any cwd reports that shell emits. `AGT_SESSION_PWD` stays the pane's reported path, remote or not; the command's execution directory comes separately from `Session.localWorkingDirectory`, which returns that path for a local session and, for a remote one, only when it exists here as a directory, else diff --git a/agterm/Commands/CustomCommandRunner.swift b/agterm/Commands/CustomCommandRunner.swift index 1f5ef8f4d..99f0f81c3 100644 --- a/agterm/Commands/CustomCommandRunner.swift +++ b/agterm/Commands/CustomCommandRunner.swift @@ -495,7 +495,7 @@ final class CustomCommandRunner { sessionID: session.id.uuidString, sessionName: session.displayName, sessionPWD: session.cwd(for: pane), - sessionHost: TerminalText.sanitized(session.remoteHost ?? ""), + sessionHost: TerminalText.sanitized(session.remoteHost(for: pane) ?? ""), workspaceID: workspace?.id.uuidString ?? "", workspaceName: workspace?.name ?? "", windowID: windowID?.uuidString ?? "", diff --git a/agterm/Ghostty/GhosttySurfaceView+Input.swift b/agterm/Ghostty/GhosttySurfaceView+Input.swift index 184fd5cbc..e5139a403 100644 --- a/agterm/Ghostty/GhosttySurfaceView+Input.swift +++ b/agterm/Ghostty/GhosttySurfaceView+Input.swift @@ -647,10 +647,13 @@ extension GhosttySurfaceView: @preconcurrency NSTextInputClient { session.flatMap { isSplitPane ? $0.splitPaneIdentity : $0.paneIdentity } } - /// The directory a relative link names a file in: this pane's reported cwd. Nil for the scratch, quick and - /// overlay terminals, whose own directory the app does not track, so a relative link there stays ignored. + /// The directory a relative link names a file in: this pane's reported cwd, a local scratch's included. Nil + /// for a remote window's scratch, whose directory is on the server, and for the quick and overlay + /// terminals, whose own directory the app does not track, so a relative link there stays ignored. var linkClickCwd: String? { - session.map { isSplitPane ? $0.cwd(for: .right) : $0.effectiveCwd } + if let session { return isSplitPane ? session.cwd(for: .right) : session.effectiveCwd } + guard let owner = scratchOwner, owner.catalogScratch == nil else { return nil } + return owner.cwd(for: .scratch) } /// Which kind of surface a clicked link came from, read off the ownership each factory already sets. diff --git a/agterm/Ghostty/GhosttySurfaceView+Panes.swift b/agterm/Ghostty/GhosttySurfaceView+Panes.swift index f1273b60a..0369947d3 100644 --- a/agterm/Ghostty/GhosttySurfaceView+Panes.swift +++ b/agterm/Ghostty/GhosttySurfaceView+Panes.swift @@ -23,6 +23,12 @@ extension GhosttySurfaceView { /// surface's CURRENT slot even after a promote + re-split (#199). var paneToken: String { env["AGTERM_PANE_ID"] ?? "" } + /// The session whose live scratch this surface is, nil for every other surface. + var scratchOwner: Session? { + guard session == nil, let owner = watermarkSession, owner.scratchSurface === self else { return nil } + return owner + } + var zmxSessionName: String? { guard backedByZmx, let paneIdentity = UUID(uuidString: paneToken) else { return nil } return ZmxSupport.daemonName(for: paneIdentity) diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index db89abfaf..3a9476e06 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -70,8 +70,7 @@ final class GhosttySurfaceView: NSView, PaneRoleMutableSurface { let backedByZmx: Bool /// The session whose visual config this surface inherits when it deliberately has no `session`: the scratch - /// renders the owner's watermark without its OSC title/PWD reports mutating the session model. Nil for - /// overlays and quick terminals; main/split use `session`. + /// renders the owner's watermark and reports its PWD only to `scratchCwd`. Nil for overlays and quick terminals. weak var watermarkSession: Session? /// Whether this is the session's split (right) pane. Routes `applyPwd`/`applyTitle` to the session's @@ -511,6 +510,10 @@ final class GhosttySurfaceView: NSView, PaneRoleMutableSurface { // no save(): OSC 7 fires on every cd/prompt redraw and would thrash the disk. live cwd is persisted on // quit and on structural mutations, so a crash loses only cwd changes since the last save. the // equality guard matters likewise: an equal write still notifies observers and churns the reconcile. + if let owner = scratchOwner { + owner.scratchCwd = pwd + return + } if isSplitPane { if session?.splitCwd != pwd { session?.splitCwd = pwd } } else { diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift index c53f6ec28..6d80284c8 100644 --- a/agterm/agtermApp.swift +++ b/agterm/agtermApp.swift @@ -882,9 +882,9 @@ struct agtermApp: App { } /// Scratch-terminal surface factory: a third per-session shell, full-overlay rendered. Like the overlay it is - /// NOT operationally wired to the session (no `view.session`/`isSplitPane`), so its PWD/title never clobber - /// the sidebar name, but it keeps a weak visual-config link for the watermark and — unlike the overlay — - /// stays alive when hidden. Runs a login shell, or `session.scratchCommand` (`session.scratch --command`) + /// NOT operationally wired to the session (no `view.session`/`isSplitPane`), so its title never clobbers the + /// sidebar name and its PWD goes only to `scratchCwd`; it keeps a weak link for the watermark and — unlike + /// the overlay — stays alive when hidden. Runs a login shell, or `session.scratchCommand` (`session.scratch --command`) /// RUN-ONCE. `autoFocus` grabs first responder on show (winning the SwiftUI/AppKit responder race); the /// shell's `exit` runs `closeScratch`, hiding + tearing down so the next show is fresh. @MainActor @@ -897,6 +897,8 @@ struct agtermApp: App { let cwd = session.localWorkingDirectory(reported: session.effectiveCwd, homeDirectory: NSHomeDirectory()) // a remote window's scratch is its server's: a stopped attach keeps its screen until it reconnects let catalogScratch = session.catalogScratch + // a server scratch's directory is the server's, known only once it reports one + if catalogScratch == nil { session.scratchCwd = cwd } let view = GhosttySurfaceView(workingDirectory: cwd, fontSize: session.fontSize.map(Float.init), command: command, autoFocus: !suppressAutoFocus, env: env.merging(catalogScratch.map { ["AGTERM_PANE_ID": $0.uuidString] } ?? [:]) { $1 }) diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift index e67bdbd12..77759189d 100644 --- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift +++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift @@ -198,11 +198,12 @@ extension AppStore { } /// releaseScratchOwnedState drops what a departing scratch owned: a `.scratch`-tagged block (a main/split tag - /// survives), its background label, since a respawn runs another program, and its mailbox. + /// survives), its background label, since a respawn runs another program, its mailbox, and its directory. func releaseScratchOwnedState(of session: Session, token: String) { clearIndicatorOwnedByPane(.scratch, of: session) dropPaneBackground(.scratch, of: session) disposeMailbox(token: token) + session.scratchCwd = nil } /// clearPaneOwnedState drops what the program in `pane` owned when its shell is replaced in place: its diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift index dbda4b4ce..e50cc8f83 100644 --- a/agtermCore/Sources/agtermCore/AppStore.swift +++ b/agtermCore/Sources/agtermCore/AppStore.swift @@ -449,6 +449,7 @@ public final class AppStore { realized: session.surface?.isRealized ?? false, context: session.effectiveContext, remoteHost: session.remoteHost, splitCwd: session.hasSplit ? session.cwd(for: .right) : nil, + scratchCwd: session.scratchSurface == nil ? nil : session.cwd(for: .scratch), liveAttribution: mainAttribution?.rawValue, splitLiveAttribution: splitAttribution?.rawValue, liveHostStale: mainStale ? true : nil, splitLiveHostStale: splitStale ? true : nil, presentation: presentationNode(of: session), presenters: presentersNode(of: session), diff --git a/agtermCore/Sources/agtermCore/ControlProjection.swift b/agtermCore/Sources/agtermCore/ControlProjection.swift index 3a86a3e09..998e7fda6 100644 --- a/agtermCore/Sources/agtermCore/ControlProjection.swift +++ b/agtermCore/Sources/agtermCore/ControlProjection.swift @@ -195,6 +195,8 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { public let cwd: String /// The split pane's last reported or fallback directory, including hidden splits; omitted without one. public let splitCwd: String? + /// The scratch shell's last reported or start directory, hidden scratch included; omitted without one. + public let scratchCwd: String? /// The raw terminal title from the latest OSC 0/1/2 (a remote host over SSH, a shell `PROMPT_COMMAND`); /// nil/omitted when none reported. The unprocessed `Session.oscTitle`, distinct from `name` (the derived /// sidebar label, which uses it as one fallback); a remote session's local `cwd` goes stale, this does not. @@ -420,7 +422,7 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { background: BackgroundWatermark? = nil, paneBackgrounds: PaneBackgrounds? = nil, unseen: Int? = nil, fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil, surfaces: [ControlSurfaceNode]? = nil, realized: Bool? = nil, - context: String? = nil, remoteHost: String? = nil, splitCwd: String? = nil, + context: String? = nil, remoteHost: String? = nil, splitCwd: String? = nil, scratchCwd: String? = nil, liveAttribution: String? = nil, splitLiveAttribution: String? = nil, liveHostStale: Bool? = nil, splitLiveHostStale: Bool? = nil, presentation: ControlPresentationNode? = nil, presenters: ControlPresentersNode? = nil, @@ -487,6 +489,7 @@ public struct ControlSessionNode: Codable, Sendable, Equatable { self.surfaces = surfaces self.realized = realized self.splitCwd = splitCwd + self.scratchCwd = scratchCwd self.remoteHost = remoteHost self.liveAttribution = liveAttribution self.splitLiveAttribution = splitLiveAttribution diff --git a/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift b/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift new file mode 100644 index 000000000..61b8531a8 --- /dev/null +++ b/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift @@ -0,0 +1,31 @@ +import Foundation + +extension Session { + /// The working directory for a given pane role, each pane's own, falling back to the primary's. + public func cwd(for pane: CommandContext.Pane) -> String { + switch pane { + case .right: + return splitCwd ?? initialSplitCwd ?? effectiveCwd + case .scratch: + return scratchCwd ?? effectiveCwd + case .left: + return effectiveCwd + } + } + + /// The host a pane's shell runs on: `remoteHost`, except for a scratch that runs on this Mac, which an + /// attached session's is. A remote window's scratch attaches the server's shell, so it keeps the host. + public func remoteHost(for pane: CommandContext.Pane) -> String? { + pane == .scratch && catalogScratch == nil ? nil : remoteHost + } + + /// Where a LOCAL process for this session starts, given the pane path it would inherit: that path on a + /// local session; on a remote one, only when it exists here as a directory, else `homeDirectory`. The + /// reported path itself stays what `cwd(for:)` and `AGT_SESSION_PWD` carry. + public func localWorkingDirectory(reported path: String, homeDirectory: String) -> String { + guard remoteHost != nil else { return path } + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue ? path : homeDirectory + } +} diff --git a/agtermCore/Sources/agtermCore/Session.swift b/agtermCore/Sources/agtermCore/Session.swift index 6b6880fa9..a2627eccf 100644 --- a/agtermCore/Sources/agtermCore/Session.swift +++ b/agtermCore/Sources/agtermCore/Session.swift @@ -557,6 +557,10 @@ public final class Session: Identifiable { /// close, the shell's own `exit`, or session/workspace/window teardown), after which a show spawns fresh. @ObservationIgnored public var scratchSurface: (any TerminalSurface)? + /// The scratch shell's own cwd: where a local scratch started, then each PWD it reports. Separate from + /// `currentCwd` so a `cd` in the scratch never renames the row. Nil with no scratch; a departing scratch clears it. + @ObservationIgnored public var scratchCwd: String? + /// Bumped on every `closeScratch` so the deck keys the scratch view's identity on it. A `--command` /// respawn closes and re-shows inside one store call, so `scratchActive` never dips where SwiftUI can /// see it and the torn-down view would otherwise stay mounted. Observed, never persisted. @@ -648,19 +652,9 @@ public final class Session: Identifiable { /// The live `currentCwd` once a PWD report arrived, else `initialCwd`. Always the PRIMARY pane's, never /// focus-aware (cf. `focusedCwd`): it seeds new split, overlay, scratch and quick terminals. A custom - /// command's `AGT_SESSION_PWD` resolves through `cwd(for:)`, so the right pane's value can differ. + /// command's `AGT_SESSION_PWD` resolves through `cwd(for:)`, so the split's and scratch's values can differ. public var effectiveCwd: String { currentCwd ?? initialCwd } - /// The working directory for a given pane role: the split pane's while targeting `.right`, else the primary's. - public func cwd(for pane: CommandContext.Pane) -> String { - switch pane { - case .right: - return splitCwd ?? initialSplitCwd ?? effectiveCwd - case .left, .scratch: - return effectiveCwd - } - } - /// The stable token of the surface currently in `pane`'s slot (`TerminalSurface.paneToken`), the inverse /// of `paneRole(forToken:)`; empty while the slot holds no surface. public func paneToken(for pane: CommandContext.Pane) -> String { @@ -674,16 +668,6 @@ public final class Session: Identifiable { } } - /// Where a LOCAL process for this session starts, given the pane path it would inherit: that path on a - /// local session; on a remote one, only when it exists here as a directory, else `homeDirectory`. The - /// reported path itself stays what `cwd(for:)` and `AGT_SESSION_PWD` carry. - public func localWorkingDirectory(reported path: String, homeDirectory: String) -> String { - guard remoteHost != nil else { return path } - var isDirectory: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - return exists && isDirectory.boolValue ? path : homeDirectory - } - /// The focused pane's surface: the split (right) while it has focus and exists, else the primary. With the /// split hidden the detail pane maximizes this one and focus helpers target it, so typing always reaches /// the visible pane. diff --git a/agtermCore/Sources/agtermctlKit/SocketClient.swift b/agtermCore/Sources/agtermctlKit/SocketClient.swift index 398f9f7b9..17fbdd4a7 100644 --- a/agtermCore/Sources/agtermctlKit/SocketClient.swift +++ b/agtermCore/Sources/agtermctlKit/SocketClient.swift @@ -575,6 +575,7 @@ struct SocketClient { let tags = splitTag + realizedTag + overlayTag + (session.scratch ? " (scratch)" : "") + pendingTag + interactionTag let splitCwdSuffix = session.splitCwd.map { $0 == session.cwd ? "" : " split cwd: \($0)" } ?? "" + let scratchCwdSuffix = session.scratchCwd.map { $0 == session.cwd ? "" : " scratch cwd: \($0)" } ?? "" let titleSuffix = session.title.map { " title: \($0)" } ?? "" let earlier = " (earlier helper)" let attribution = session.liveAttribution.map { @@ -590,7 +591,8 @@ struct SocketClient { let presenters = session.presenters.map { ($0.presenter == true ? " presented remotely" : "") + ($0.mirrors > 0 ? " mirrored by: \($0.mirrors)" : "") } ?? "" - lines.append(" \(smark) \(session.name)\(tags) [\(session.id)] \(session.cwd)\(splitCwdSuffix)\(titleSuffix)\(attribution)\(splitAttribution)\(presentation)\(presenters)") + let cwds = session.cwd + splitCwdSuffix + scratchCwdSuffix + lines.append(" \(smark) \(session.name)\(tags) [\(session.id)] \(cwds)\(titleSuffix)\(attribution)\(splitAttribution)\(presentation)\(presenters)") } } return lines.joined(separator: "\n") diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 6ebc7b089..92fb8e694 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -1706,9 +1706,12 @@ struct AppStorePaneTests { session.scratchActive = true let scratch = SpySurface() session.scratchSurface = scratch + session.scratchCwd = "/a/docs" #expect(store.closeScratch(session.id) == true) #expect(session.scratchActive == false) #expect(session.scratchSurface == nil) + #expect(session.scratchCwd == nil) + #expect(session.cwd(for: .scratch) == "/a") #expect(scratch.teardownCount == 1) #expect(session.scratchGeneration == 1) // closing again (no surface) is a no-op. @@ -1716,6 +1719,19 @@ struct AppStorePaneTests { #expect(session.scratchGeneration == 1) } + // a departed server scratch with no surface left its directory for the next daemon's scratch + @Test func dropScratchDaemonForgetsTheDepartedScratchsDirectory() { + let store = makeStore() + let ws = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: ws.id, cwd: "/a")! + session.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: false) + session.scratchCwd = "/srv/old" + store.dropScratchDaemon(session.id) + #expect(session.scratchDaemon == nil) + #expect(session.scratchCwd == nil) + #expect(session.cwd(for: .scratch) == "/a") + } + @Test func closeScratchClearsStuckSearchWhenScratchOwnsIt() { let store = makeStore() let ws = store.addWorkspace(name: "work") diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift index 4e211e2e5..4e5e7102a 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift @@ -990,4 +990,32 @@ struct AppStoreTreeProjectionTests { let json = String(decoding: try JSONEncoder().encode(node), as: UTF8.self) #expect(json.contains(#""zoom":1.25"#)) } + + @Test(arguments: [false, true]) + func scratchCwdReportsTheScratchsOwnOrPrimaryDirectoryEvenWhenHidden(shown: Bool) throws { + let store = makeStore() + let workspace = store.addWorkspace(name: "work") + let session = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/primary")) + session.scratchSurface = SpySurface() + session.scratchActive = shown + for (reported, expected): (String?, String) in [(nil, "/primary"), ("/primary/docs", "/primary/docs")] { + session.scratchCwd = reported + let node = store.controlTree().workspaces[0].sessions[0] + let data = try JSONEncoder().encode(node) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["scratchCwd"] as? String == expected) + #expect(node.cwd == "/primary") + #expect(try JSONDecoder().decode(ControlSessionNode.self, from: data) == node) + } + } + + @Test func noScratchOmitsTheScratchDirectoryEvenWithAStaleValue() throws { + let store = makeStore() + let workspace = store.addWorkspace(name: "work") + let session = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/primary")) + session.scratchCwd = "/stale" + let node = store.controlTree().workspaces[0].sessions[0] + let json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(node)) as? [String: Any]) + #expect(json["scratchCwd"] == nil) + } } diff --git a/agtermCore/Tests/agtermCoreTests/SessionTests.swift b/agtermCore/Tests/agtermCoreTests/SessionTests.swift index 25a13876c..1268a8405 100644 --- a/agtermCore/Tests/agtermCoreTests/SessionTests.swift +++ b/agtermCore/Tests/agtermCoreTests/SessionTests.swift @@ -430,6 +430,27 @@ struct SessionTests { session.initialSplitCwd = nil #expect(session.cwd(for: .right) == "/repo/primary") + + session.scratchCwd = "/repo/docs" + #expect(session.cwd(for: .scratch) == "/repo/docs") + #expect(session.cwd(for: .left) == "/repo/primary") + #expect(session.focusedCwd == "/repo/primary") + } + + @Test func remoteHostForPaneLeavesALocalScratchWithoutTheAttachHost() throws { + let store = makeStore() + let workspace = store.addWorkspace(name: "work") + let local = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/repo")) + let attached = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/srv", remoteHost: "box")) + #expect(local.remoteHost(for: .scratch) == nil) + #expect(attached.remoteHost(for: .left) == "box") + #expect(attached.remoteHost(for: .right) == "box") + #expect(attached.remoteHost(for: .scratch) == nil) + + store.bindRemote(RemoteBinding(remoteSessionID: "r", daemonsByLocalPane: [:], presentationVersion: 1, catalogOwned: true), + forSession: attached.id) + attached.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: true) + #expect(attached.remoteHost(for: .scratch) == "box") } @Test func localWorkingDirectoryKeepsAnyPathForALocalSession() { diff --git a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift index 04fea9f72..52d2cce98 100644 --- a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift @@ -1161,6 +1161,16 @@ struct SocketClientTests { #expect(output == "* work [w]\n * shell (split) [s] \(expected)") } + @Test(arguments: [("/other", "/main scratch cwd: /other"), ("/main", "/main")]) + func formatTreeIncludesOnlyADifferingScratchDirectory(_ scratchCwd: String, _ expected: String) throws { + let data = Data(#"{"id":"s","name":"shell","cwd":"/main","scratchCwd":"\#(scratchCwd)","active":true,"split":false,"overlay":false,"scratch":true,"flagged":false}"#.utf8) + let session = try JSONDecoder().decode(ControlSessionNode.self, from: data) + let tree = ControlTree(workspaces: [ControlWorkspaceNode(id: "w", name: "work", active: true, sessions: [session])]) + let response = ControlResponse(ok: true, result: ControlResult(tree: tree)) + let output = SocketClient.formatResponse(response) + #expect(output == "* work [w]\n * shell (scratch) [s] \(expected)") + } + @Test func formatWindowResizeReportsAppliedWidthAndHeight() throws { let response = try JSONDecoder().decode(ControlResponse.self, from: Data(#"{"ok":true,"result":{"id":"w","width":1200,"height":800}}"#.utf8)) #expect(SocketClient.formatResponse(response) == "1200 800") diff --git a/agtermTests/CustomCommandRunnerTests.swift b/agtermTests/CustomCommandRunnerTests.swift index c64e59cb8..fbfb445a4 100644 --- a/agtermTests/CustomCommandRunnerTests.swift +++ b/agtermTests/CustomCommandRunnerTests.swift @@ -141,6 +141,23 @@ final class CustomCommandRunnerTests: XCTestCase { XCTAssertEqual(scratchContext.paneID, scratch.uuidString) } + func testAScratchCommandOnAnAttachedSessionNamesTheLocalScratchShell() throws { + let fixture = try fixture() + let workspace = try XCTUnwrap(fixture.store.workspaces.first?.id) + let session = try XCTUnwrap(fixture.store.addSession(toWorkspace: workspace, cwd: "/srv", remoteHost: "box")) + let before = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .scratch) + session.scratchCwd = "/Users/me/docs" + + let scratch = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .scratch) + let left = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .left) + + XCTAssertEqual(before.sessionPWD, "/srv") + XCTAssertEqual(scratch.sessionPWD, "/Users/me/docs") + XCTAssertEqual(scratch.sessionHost, "") + XCTAssertEqual(left.sessionPWD, "/srv") + XCTAssertEqual(left.sessionHost, "box") + } + func testMenuFunctionKeyConsumesRepeatAndRelease() throws { try assertMenuFunctionKeyOwnership(keymap: "map f5 next_session\n") } diff --git a/agtermTests/GhosttySurfaceViewInputTests.swift b/agtermTests/GhosttySurfaceViewInputTests.swift index 56574182f..6308d4f85 100644 --- a/agtermTests/GhosttySurfaceViewInputTests.swift +++ b/agtermTests/GhosttySurfaceViewInputTests.swift @@ -95,6 +95,27 @@ final class GhosttySurfaceViewInputTests: XCTestCase { XCTAssertEqual(remote.linkClickOrigin, .remotePane(remoteSession.id)) } + func testRelativeLinksInALocalScratchResolveAgainstItsOwnCwd() { + let store = AppStore(persistence: PersistenceStore(directory: FileManager.default.temporaryDirectory + .appendingPathComponent("agterm-scratch-link-\(UUID().uuidString)"))) + let workspace = store.addWorkspace(name: "work") + guard let session = store.addSession(toWorkspace: workspace.id, cwd: "/tmp/main", remoteHost: "box") else { + return XCTFail("no session") + } + let scratch = bareSurface() + scratch.watermarkSession = session + XCTAssertNil(scratch.linkClickCwd) + + session.scratchSurface = scratch + session.scratchCwd = "/tmp/main/docs" + XCTAssertEqual(scratch.linkClickCwd, "/tmp/main/docs") + + store.bindRemote(RemoteBinding(remoteSessionID: "r", daemonsByLocalPane: [:], presentationVersion: 1, catalogOwned: true), + forSession: session.id) + session.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: true) + XCTAssertNil(scratch.linkClickCwd) + } + func testCommandClickOnAnOSC8SessionLinkSelectsItsTarget() throws { let id = UUID() let fixture = try LinkClickFixture(printing: "\\033]8;;agterm://session/\(id.uuidString)\\033\\\\example session\\033]8;;\\033\\\\") diff --git a/agtermTests/GhosttySurfaceViewTitleTests.swift b/agtermTests/GhosttySurfaceViewTitleTests.swift index 49923ff75..268c2fc6e 100644 --- a/agtermTests/GhosttySurfaceViewTitleTests.swift +++ b/agtermTests/GhosttySurfaceViewTitleTests.swift @@ -123,6 +123,31 @@ final class GhosttySurfaceViewTitleTests: XCTestCase { XCTAssertNil(session.oscTitle) } + func testScratchPwdReachesOnlyTheScratchCwd() { + let session = Session(initialCwd: "/tmp/start") + let view = GhosttySurfaceView(workingDirectory: session.initialCwd) + view.watermarkSession = session + session.scratchSurface = view + + view.applyPwd("/tmp/start/\u{7}docs") + + XCTAssertEqual(session.scratchCwd, "/tmp/start/docs") + XCTAssertNil(session.currentCwd) + XCTAssertNil(session.splitCwd) + XCTAssertEqual(session.displayName, "start") + } + + func testAScratchNoLongerInItsSlotDropsItsPwd() { + let session = Session(initialCwd: "/tmp/start") + let view = GhosttySurfaceView(workingDirectory: session.initialCwd) + view.watermarkSession = session + + view.applyPwd("/tmp/old") + + XCTAssertNil(session.scratchCwd) + XCTAssertNil(session.currentCwd) + } + private func makeView(split: Bool = false) -> (Session, GhosttySurfaceView) { let session = Session(initialCwd: "/tmp/start") let view = GhosttySurfaceView(workingDirectory: session.initialCwd) diff --git a/agtermTests/SurfaceFactorySeedTests.swift b/agtermTests/SurfaceFactorySeedTests.swift index db8d6eeca..bd5325a3e 100644 --- a/agtermTests/SurfaceFactorySeedTests.swift +++ b/agtermTests/SurfaceFactorySeedTests.swift @@ -322,6 +322,17 @@ final class SurfaceFactorySeedTests: XCTestCase { XCTAssertEqual(overlaySurface(for: session).workingDirectory, "/nowhere/explicit") } + func testALocalScratchRecordsWhereItStarted() { + let local = Session(initialCwd: "/nowhere") + local.currentCwd = "/nowhere/primary" + _ = scratchSurface(for: local) + XCTAssertEqual(local.scratchCwd, "/nowhere/primary") + + let attached = remoteSession(reportedCwd: stateDir.appendingPathComponent("only-on-the-remote").path) + _ = scratchSurface(for: attached) + XCTAssertEqual(attached.scratchCwd, NSHomeDirectory()) + } + func testLocalSessionFactoriesKeepTheirInheritedPaths() { let session = Session(initialCwd: "/nowhere") session.currentCwd = "/nowhere/primary" diff --git a/plugins/agterm/skills/agterm/SKILL.md b/plugins/agterm/skills/agterm/SKILL.md index 20e6ed0b9..2db22574c 100644 --- a/plugins/agterm/skills/agterm/SKILL.md +++ b/plugins/agterm/skills/agterm/SKILL.md @@ -107,11 +107,12 @@ its slot until it closes, so opening another there is refused. Separately, the a or whatever share Settings sets instead; not part of the tree and not owned by a window). Inspect the live tree any time with `agtermctl tree --json` (workspaces → sessions, each with -`id`, `name`, `cwd`, `splitCwd`, `title`, `active`, `split`, `overlay`, `hud`, `ask`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC +`id`, `name`, `cwd`, `splitCwd`, `scratchCwd`, `title`, `active`, `split`, `overlay`, `hud`, `ask`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC terminal title (e.g. a remote host over SSH), omitted when none was reported — read it when a session's local `cwd` is stale because it's connected to a remote. `splitCwd` is the split pane's last reported directory, falling back to its restored directory, then the primary cwd. It is present for a -shown or hidden split and omitted without one or on older servers. `surfaces[].id` is the +shown or hidden split and omitted without one or on older servers. `scratchCwd` is the scratch shell's own +directory, present while a scratch exists, shown or hidden. `surfaces[].id` is the control address for `surface zoom` and `surface cursor` (`left`, `right`, `scratch`, `overlay`, `overlay-left`, or `overlay-right`), including hidden-but-alive split/scratch surfaces. The tree object also carries read-only top-level fields — `idleMs` (ms since the last user input in the window), `autoFollowMs` diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md index 58e8811f3..c6958bc99 100644 --- a/plugins/agterm/skills/agterm/reference.md +++ b/plugins/agterm/skills/agterm/reference.md @@ -144,7 +144,8 @@ waits forever on an absent app. SIGINT and SIGTERM use normal process behavior. `agtermctl tree [--json] [--window W]` — the workspace/session tree. Each session node: `id`, `name`, `cwd`, `splitCwd` (the split pane's last reported directory, falling back to its restored -directory, then the primary cwd; omitted without a split or on older servers), `title` (the raw OSC terminal +directory, then the primary cwd; omitted without a split or on older servers), `scratchCwd` (the scratch +shell's last reported directory or where it started; omitted without a scratch or on older servers), `title` (the raw OSC terminal title, for example a remote host over SSH, omitted when none reported; distinct from `name`, the derived sidebar label), `active` (selected), `split` (split SHOWN side by side, the read side of `session split on|off`), @@ -2111,14 +2112,14 @@ so `{AGT_SESSION_NAME}` and `{AGT_SESSION_PWD}` are as untrusted as `{AGT_SELECT `$AGT_*` form for any of them: - `{AGT_SESSION_NAME}` / `$AGT_SESSION_NAME` — the session's display name (the focused pane's terminal title, remote-settable via OSC). -- `{AGT_SESSION_PWD}` / `$AGT_SESSION_PWD` — the working directory of the pane the command fired from; - the scratch terminal reports the main pane's, since it tracks no cwd of its own. For a session opened - by `zmx attach` the path can be remote: the session starts with local HOME and follows subsequent cwd +- `{AGT_SESSION_PWD}` / `$AGT_SESSION_PWD` — the working directory of the pane the command fired from, + the scratch terminal included. For a session opened by `zmx attach` the path can be remote: the session starts with local HOME and follows subsequent cwd reports. See Remote sessions. On any session the command itself starts in that path only when it exists here as a directory, else in local HOME, so a deleted directory does not stop it; the variable keeps the reported path either way. - `{AGT_SESSION_HOST}` / `$AGT_SESSION_HOST` — the SSH destination of a session opened by `zmx attach`, verbatim as given (`user@alias` included); empty for a local session, an `ssh` typed into one included, + and from the scratch of an attached session, whose shell runs on this Mac, so branch on it: `if [ -n "$AGT_SESSION_HOST" ]; then ssh "$AGT_SESSION_HOST" uptime; fi` (a bare `&&` chain exits 1 on a local session and the runner reports that as a failure). - `{AGT_SELECTION}` / `$AGT_SELECTION` — the current selection. @@ -2523,7 +2524,8 @@ after a relaunch whatever the restore mode is. The pane reports the far side's working directory, and the local launches that would inherit it pick their start directory by one rule: the reported path when it exists here as a directory, else local HOME. Those launches are a custom command (its `$AGT_SESSION_PWD` keeps the reported path and -`$AGT_SESSION_HOST` names the destination), the scratch terminal, an overlay opened without `--cwd`, +`$AGT_SESSION_HOST` names the destination; one fired from the local scratch gets that shell's directory and an +empty host), the scratch terminal, an overlay opened without `--cwd`, the quick terminal, a local split (the first one on a remote session that arrived without a split, or one opened after the attach-time split is closed), Duplicate Session, and New Session when it is set to open in the current session's directory. An explicit overlay `--cwd` is used as given. Quote both diff --git a/site/commands.html b/site/commands.html index 2786dd528..2c83143f2 100644 --- a/site/commands.html +++ b/site/commands.html @@ -455,6 +455,9 @@ splitCwd (the split pane's last reported directory, falling back to its restored directory, then the primary cwd; present for shown or hidden splits, omitted without a split or on older servers), + scratchCwd (the scratch shell's last + reported directory or where it started; present for a shown or hidden scratch, omitted without one or on older + servers), title (raw OSC terminal title, omitted when none), active, split (both split panes are SHOWN), diff --git a/site/docs.html b/site/docs.html index cc1e26eda..902342bd6 100644 --- a/site/docs.html +++ b/site/docs.html @@ -3159,7 +3159,8 @@ exists here as a directory, else in your home. A custom command still sees the reported path in $AGT_SESSION_PWD and the destination in $AGT_SESSION_HOST, - empty for a local session, so it can reach the remote directory itself. When another client leads at a different + empty for a local session, so it can reach the remote directory itself; one fired from the scratch, a local shell, + sees that shell's directory and no destination. When another client leads at a different terminal size, local cursor and screen-text reads can disagree with the application's layout; automation relying on those reads is unsupported in that state.