Skip to content
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ mise run e2e # Debug build + end-to-end suite against the real app

Requires macOS 14+, Swift 6.0+. Liquid glass and a few chrome refinements are macOS 26 (Tahoe) features that degrade gracefully on older systems (gated behind `WindowAppearance.glassSupported` / `#available`). GhosttyKit is a pre-built xcframework from `thdxg/ghostty` (a fork that adds CI builds); no zig toolchain needed.

`GhosttyKit.xcframework` and the `Macterm/Resources/{ghostty,terminfo,…}` contents are gitignored artifacts downloaded by `mise run setup` — **every fresh checkout, including a git worktree, must run `mise run setup`** before it can build. Don't symlink them from another checkout: `setup.sh` only re-downloads when the artifact is _absent_ (presence check, not version check), so a symlinked copy silently goes stale. To refresh a stale artifact, delete it and re-run setup.
`GhosttyKit.xcframework` and the `Macterm/Resources/{ghostty,terminfo,…}` contents are gitignored artifacts downloaded by `mise run setup` — **every fresh checkout, including a git worktree, must run `mise run setup`** before it can build. Don't symlink them from another checkout: setup normally uses a presence check rather than a version check, so a symlinked copy silently goes stale. The one capability exception is `GHOSTTY_ACTION_OUTPUT_ACTIVITY`: setup replaces a present framework that lacks this required ABI (probed by globbing the xcframework's slice dirs for the symbol in `Headers/ghostty.h`, never by naming a slice). The replacement is downloaded and validated in a scratch dir and only swapped in once it passes, so a release that also lacks the ABI leaves the existing framework alone rather than stranding the checkout with none. Requiring the ABI is also a **bisect hazard**: a checkout from before the reliable-activity-detection change doesn't need the ABI, but setup only ever fetches `latest`, so if that release ever regresses the symbol setup refuses to run at all. Bisecting across that commit means extracting a GhosttyKit from a `thdxg/ghostty` release contemporary with the older commit rather than running setup (its error message says so). To refresh any other stale artifact, delete it and re-run setup.

## Releasing & Updates

Expand Down Expand Up @@ -81,7 +81,7 @@ All keybinds are configurable via `HotkeyAction` + `HotkeyRegistry`. `KeyRouter`

### Tab Naming

A tab's auto-title is, by default, the pane's live **foreground process name** (`hx`, `btop`) — falling back to the login shell name (from `getpwuid`, not `$SHELL`) when idle, overridden by a user-set `customTitle`. `ProcessInspector.runningProcessName` reads the foreground pid's kernel `comm`. `AppState` polls panes adaptively (`PollCadence` + `refreshAllForegroundProcesses`, republishing only on change): 250ms during a ~5s burst after any poll event (tab switch, keystroke, OSC title, execution transition — all post `.terminalPollEvent`) or while a command runs frontmost, 1s when active-idle, 2s when inactive with a visible window, stopped when nothing is on screen (events resume it instantly; the quit dialog re-reads names one-shot since the poll may be paused). The status indicator's quiet-settle is skipped for occluded panes — their parked renderer emits no heartbeats, so silence proves nothing — with a fresh quiet window granted on the occluded→visible edge. OSC 0/2 titles are **provenance-gated** (`Pane.receiveReportedTitle`): the raw sequence can't distinguish a program naming its session (claude, ssh) from a shell titling its prompt (nushell, Starship emit the cwd), so the title string is adopted as `Pane.programTitle` only while the foreground process is a real program — not a shell — and is pinned to that pid; the poll expires it when the pid loses the foreground (`applyForegroundRefresh`), and prompt-time titles are discarded. `displayTitle` prefers `programTitle` over the process name; the quit dialog keeps the process-derived `processTitle`. Every OSC title arrival also triggers a process refresh (command boundary). Titles aren't persisted — always derived live.
A tab's auto-title is, by default, the pane's live **foreground process name** (`hx`, `btop`) — falling back to the login shell name (from `getpwuid`, not `$SHELL`) when idle, overridden by a user-set `customTitle`. `ProcessInspector.runningProcessName` reads the foreground pid's kernel `comm`. `AppState` polls panes adaptively (`PollCadence` + `refreshAllForegroundProcesses`, republishing only on change): 250ms during a ~5s burst after any poll event (tab switch, keystroke, OSC title, execution transition — all post `.terminalPollEvent`) or while a command runs frontmost, 1s when active-idle, 2s when inactive with a visible window, stopped when nothing is on screen (events resume it instantly; the quit dialog re-reads names one-shot since the poll may be paused). The throttled `GHOSTTY_ACTION_OUTPUT_ACTIVITY` heartbeat from the pty IO path is the **sole** activity source (the render-path `GHOSTTY_ACTION_SCROLLBAR` feeds only the overlay scrollbar): it keeps firing while occluded and `setup.sh` requires the ABI that emits it, so an activity-sourced run's silence is meaningful on or off screen — the indicator quiet-settles occluded and visible panes identically, with no occlusion exemption. The heartbeat carries the row total, keeping runs alive through in-place redraws and letting the tracker distinguish growth from a redraw. Row growth may start activity under the normal interaction guards. Non-growing output can start activity only for a recognized AI-agent foreground, as two heartbeats within 2s of an actually forwarded unmodified Return/newline submission with committed prompt content; a genuinely blank submission briefly suppresses even row-growth redraws. This covers Pi's nested `!` commands without treating Return in ordinary raw TUIs, startup output, typing, IME composition, or one prompt redraw as work. OSC 0/2 titles are **provenance-gated** (`Pane.receiveReportedTitle`): the raw sequence can't distinguish a program naming its session (claude, ssh) from a shell titling its prompt (nushell, Starship emit the cwd), so the title string is adopted as `Pane.programTitle` only while the foreground process is a real program — not a shell — and is pinned to that pid; the poll expires it when the pid loses the foreground (`applyForegroundRefresh`), and prompt-time titles are discarded. `displayTitle` prefers `programTitle` over the process name; the quit dialog keeps the process-derived `processTitle`. Every OSC title arrival also triggers a process refresh (command boundary). Titles aren't persisted — always derived live.

**Remote panes** (#104) leave the local pipeline entirely — the local process table only knows the `ssh` client, so `ProcessInspector.foregroundPID(forPane:)` returns nil for them (which also makes layout `save` emit plain leaves and reconcile match them as idle). Their naming is two-tier: OSC 0/2 titles gated by the OSC 133 execution state instead of a pid (`Pane.receiveRemoteReportedTitle` — adopt while running, discard prompt churn, expire on the running→ended edge), plus `RemoteForegroundResolver`, one BatchMode ssh per host per ~3s (frontmost project only, overlapping probes dropped, failures freeze names) running the same session→leader→tpgid→comm pipeline as a portable POSIX script (`RemoteSpawn.foregroundProbeScript`). Idle fallback is the host name.

Expand Down
1 change: 1 addition & 0 deletions CLI/Output.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ enum Output {
pane.session,
pane.process ?? "-",
pane.cwd ?? "-",
pane.state ?? "-",
]
}
printColumns(rows)
Expand Down
66 changes: 21 additions & 45 deletions Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
/// so the nonisolated deinit can read it — the object is being destroyed, so
/// there is no concurrent access. Tokens are `NSObjectProtocol` (what
/// `addObserver(forName:…)` returns).
nonisolated(unsafe) private var observerTokens: [(center: NotificationCenter, token: NSObjectProtocol)] = []

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / End-to-end

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / End-to-end

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Unit

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Unit

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

Check warning on line 146 in Macterm/App/AppState.swift

View workflow job for this annotation

GitHub Actions / Window states

'nonisolated(unsafe)' has no effect on property 'observerTokens', consider using 'nonisolated'

/// Injectable for tests (`PollCadence.Context` inputs). `NSApp` is nil
/// while the SwiftUI `App` struct (and thus AppState) is constructed —
Expand All @@ -161,21 +161,6 @@
(NSApp?.windows ?? []).contains { $0.isVisible && $0.occlusionState.contains(.visible) }
}

/// Whether a pane's surface is occluded — its renderer parked by
/// `ghostty_surface_set_occlusion`, so render/scrollbar heartbeats are
/// suppressed and silence says nothing about completion. Injectable for
/// tests. "No window" counts as occluded, which also covers panes
/// incubated off-screen (the incubator window is never visible).
@ObservationIgnored
var paneIsOccluded: (Pane) -> Bool = { pane in
!(pane.nsView?.window?.occlusionState.contains(.visible) ?? false)
}

/// Panes that were occluded on the previous poll tick, so the visible
/// transition can restart their quiet window before settling resumes.
@ObservationIgnored
private var previouslyOccludedPanes: Set<UUID> = []

/// zmx session-persistence client. Injectable so tests can observe
/// session kills without a real daemon.
@ObservationIgnored
Expand Down Expand Up @@ -236,8 +221,20 @@
let onEvent: @Sendable (Notification) -> Void = { [weak self] _ in
MainActor.assumeIsolated { self?.notePollEvent() }
}
let onQuietSettleDeadline: @Sendable (Notification) -> Void = { [weak self] _ in
// Do not route through notePollEvent: if another poll ran within
// 250ms, coalescing plus a fully occluded window would pause with
// no timer and never retry this deadline.
MainActor.assumeIsolated { self?.pollNow() }
}
let tokens: [(NotificationCenter, NSObjectProtocol)] = [
(center, center.addObserver(forName: .terminalPollEvent, object: nil, queue: .main, using: onEvent)),
(center, center.addObserver(
forName: .terminalQuietSettleDeadline,
object: nil,
queue: .main,
using: onQuietSettleDeadline
)),
(center, center.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main, using: onEvent
)),
Expand Down Expand Up @@ -375,13 +372,11 @@
// this feature.
let trackExecution = Preferences.shared.showTabStatusIndicator
var didAcknowledgeCompletion = false
var seenPanes: Set<UUID> = []
var sawBusyPane = false
var activeRemotePanes: [Pane] = []
for (projectID, ws) in workspaces {
for tab in ws.tabs {
for pane in tab.splitRoot.allPanes() {
seenPanes.insert(pane.id)
if pane.isRemote {
// The local process table only knows `ssh` here — a
// local refresh would stomp the probe-derived name
Expand All @@ -395,8 +390,12 @@
} else {
pane.refreshForegroundProcess(trackExecution: trackExecution)
}
// An activity-sourced run whose output has been quiet past
// the window settles to `.done`. The output heartbeat is
// occlusion-independent, so silence is meaningful whether or
// not the pane is on screen — no occlusion special-casing.
if trackExecution {
settleIfVisible(pane)
pane.settleTerminalActivityIfQuiet()
}
if pane.executionState == .running { sawBusyPane = true }
didAcknowledgeCompletion = acknowledgeFinishedCommandIfActive(
Expand All @@ -407,34 +406,13 @@
}
}
}
previouslyOccludedPanes.formIntersection(seenPanes)
lastPollSawBusyPane = sawBusyPane
if didAcknowledgeCompletion { saveWorkspaces() }
if !activeRemotePanes.isEmpty, isAnyWindowVisible() {
remoteForegroundResolver.refresh(panes: activeRemotePanes, probe: zmx.remoteForegroundComms)
}
}

/// Quiet-settle only while the surface actually renders: an occluded pane
/// emits no activity heartbeats (its renderer is parked), so settling it
/// would misread suppressed output as completion. On the occluded→visible
/// edge the quiet window restarts, giving a still-running program time to
/// deliver heartbeats again before the settle can fire.
///
/// Not private so tests can drive the guard directly (`paneIsOccluded` is
/// injectable) without a live surface or mutating the `Preferences`
/// singleton the poll reads.
func settleIfVisible(_ pane: Pane) {
if paneIsOccluded(pane) {
previouslyOccludedPanes.insert(pane.id)
return
}
if previouslyOccludedPanes.remove(pane.id) != nil {
pane.refreshTerminalActivityWindow()
}
pane.settleTerminalActivityIfQuiet()
}

private func recordProjectVisit(_ projectID: UUID) {
projectRecency.push(projectID)
Preferences.defaults.set(projectRecency.items.map(\.uuidString), forKey: recencyKey)
Expand Down Expand Up @@ -1469,12 +1447,10 @@
projectID: UUID,
saveImmediately: Bool = true
) -> Bool {
// The sidebar shows the *entire* active tab as idle (displayState masks
// `.done` for the tab the user is looking at), so every pane in that tab
// must actually be cleared — not just the focused one. Otherwise a
// non-focused split pane that finished a command stays `.done` under the
// hood, gets persisted, and reappears as a checkmark after restart even
// though the user saw an empty circle.
// Looking at the active tab acknowledges completion for the whole tab,
// not only its focused pane. Otherwise a non-focused split pane that
// finished a command stays `.done` under the hood, gets persisted, and
// reappears as a status dot after the user switches away or restarts.
// Route through the injected `isAppActive` seam (not `NSApp.isActive`
// directly): NSApp is nil during construction and unset in tests, and
// this path is reachable from init via pollNow().
Expand Down
9 changes: 9 additions & 0 deletions Macterm/App/Hotkeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ enum HotkeyRegistry {
keyCodeToBaseToken[keyCode]
}

/// The hardware key code for a base key token (`"c"` → 8), or nil for a
/// token we don't map — the inverse of `baseToken(forKeyCode:)`. Lets code
/// that carries its own key codes (`TerminalCommandSubmission`, which stays
/// isolation-free and so can't read this `@MainActor` map at runtime) pin
/// them to this vocabulary in a test.
static func keyCode(forToken token: String) -> UInt16? {
keyCodes[token]
}

private static let modifierOnlyCodes: Set<UInt16> = [54, 55, 56, 57, 58, 59, 60, 61, 62]

/// Characters produced by special keys → their named token form.
Expand Down
4 changes: 4 additions & 0 deletions Macterm/App/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ extension Notification.Name {
/// poll: tab switch, OSC title, user interaction, execution-state
/// transition. Observed by `AppState.notePollEvent()`.
static let terminalPollEvent = Notification.Name("MactermTerminalPollEvent")
/// A final IO heartbeat's quiet deadline. Unlike ordinary poll events,
/// this must force one poll even when coalescing and window occlusion would
/// otherwise leave the timer paused.
static let terminalQuietSettleDeadline = Notification.Name("MactermTerminalQuietSettleDeadline")
/// A zmx session was created, killed, or reattached — the
/// `ZmxForegroundResolver` name→leader-pid cache is stale. Observed by
/// `AppState`, which invalidates its `ZmxRefreshGate` and wakes the poll.
Expand Down
13 changes: 12 additions & 1 deletion Macterm/Control/ControlHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -769,10 +769,21 @@ final class ControlHandler {
title: pane.displayTitle,
process: pane.foregroundProcessName,
cwd: pane.nsView?.currentPwd ?? pane.projectPath,
focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID
focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID,
state: controlState(for: pane.executionState)
)
}

/// Wire representation of `TerminalExecutionState` — a plain string keeps
/// the protocol's JSON stable even if the enum's cases are renamed.
private func controlState(for state: TerminalExecutionState) -> String {
switch state {
case .idle: "idle"
case .running: "running"
case .done: "done"
}
}

private func paneIDsBySessionName() -> [String: String] {
var map: [String: String] = [:]
for workspace in appState.workspaces.values {
Expand Down
5 changes: 5 additions & 0 deletions Macterm/Control/ControlProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ struct ControlPaneInfo: Codable, Equatable {
var process: String?
var cwd: String?
var focused: Bool
/// The tab activity indicator's underlying state: `idle`, `running`, or
/// `done` (finished while unfocused — the indicator scripts want to poll
/// for). Optional per the additive-field convention above — nil when
/// decoded from an older server that predates this field.
var state: String?
}

struct ControlSessionInfo: Codable, Equatable {
Expand Down
Loading