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
10 changes: 8 additions & 2 deletions .claude/rules/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .claude/rules/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion agterm/Commands/CustomCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand Down
9 changes: 6 additions & 3 deletions agterm/Ghostty/GhosttySurfaceView+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions agterm/Ghostty/GhosttySurfaceView+Panes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions agterm/Ghostty/GhosttySurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions agterm/agtermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion agtermCore/Sources/agtermCore/AppStore+Panes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agtermCore/Sources/agtermCore/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion agtermCore/Sources/agtermCore/ControlProjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions agtermCore/Sources/agtermCore/Session+PaneDirectory.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 5 additions & 21 deletions agtermCore/Sources/agtermCore/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion agtermCore/Sources/agtermctlKit/SocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
Loading
Loading