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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Supacode is a macOS terminal emulator that for running multiple coding agents in
- Avoid top-level free functions. Default to `static` methods, computed properties, or instance methods on a relevant type (enum/struct/extension). Free functions pollute the module namespace, are harder to discover, and easily drift from the inline implementation a consumer ends up writing instead. If the operation is pure and stateless, make it a `static` on a caseless `enum` or the most relevant type, not a top-level `func`.
- Closure-typed focused values invalidate the AppKit menu on every body run (closures have no Equatable conformance, so SwiftUI re-publishes every time). Always wrap menu-bar action closures with `FocusedAction<Input>` and publish via `.focusedSceneAction(_:enabled:token:perform:)` / `.focusedAction(_:enabled:token:perform:)`. The wrapper dedupes on `(isEnabled, token)`, so AppKit only rebuilds the menu when something the menu actually displays changes. Token rules in `App/Models/FocusedAction.swift`: set `token` to a hashable projection of any captured state that affects behavior; leave it `nil` when the closure captures only the store / `@State` bindings. Consumers should read the action with `@FocusedValue(\.x)` and gate with `action?.isEnabled != true`, not `action == nil`.
- Sidebar rows must not fan out invalidation. Per-row state lives in `RepositoriesFeature.State.sidebarItems` so a per-leaf mutation (notification tick, agent activity, running-script update) invalidates only that leaf, not every sibling. The view renders the cached `state.sidebarStructure` (computed in the reducer's post-reduce hook), never reading `sidebarItems[id:]` from a view body; derive per-leaf data in `computeSidebarStructure(...)`, not in the view.
- Never lift content-specific (e.g. terminal-only) mechanics or state into the layout layer. `LayoutFeature` owns topology and strip mechanics only (panes, tabs, selection, focus, zoom, rename identity); anything a specific content kind produces (agent badges, progress, script locks, busyness) lives on the content side, exposed to the strip through the content's observable `TabChrome` (`Features/Terminal/Content/TabChrome.swift`), never as layout reducer state or actions.
- Never lift content-specific (e.g. terminal-only) mechanics or state into the layout layer. `LayoutFeature` owns topology and strip mechanics only (panes, tabs, selection, focus, zoom, rename identity); anything a specific content kind produces (agent badges, progress, script locks, busyness, reported title, pwd) lives on the content side, exposed to the strip through the content's observable `TabChrome` (`Features/Terminal/Content/TabChrome.swift`), never as layout reducer state or actions. High-frequency reported signals (title, pwd, bell) are the sharp edge of this rule: an agent TUI rewrites its title several times a second, so a reported title lands on the content's chrome and reaches the layout only as one discrete commit at teardown (`titleCommitted`), never one reducer action per report. The strip and window title read the live value off `TabChrome` (`TabTitle.resolved`), and persistence pulls it into the snapshot (`TabTitle.stored`).

## UX Standards

Expand Down
4 changes: 3 additions & 1 deletion supacode/App/WindowTitle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ enum WindowTitle {
repository: repository,
repositories: repositories
)
// Through the content's chrome: reported titles never enter the reducer, so
// this read is what keeps the window title tracking the terminal.
let tabTitle = terminalManager.hostIfExists(for: worktreeID)?.focusedTab.flatMap { tab in
sanitize(tab.customTitle ?? tab.title)
sanitize(TabTitle.resolved(for: tab, runtime: ContentRuntime.liveValue))
}
return format(repo: repoTitle, tab: tabTitle)
}
Expand Down
27 changes: 27 additions & 0 deletions supacode/Clients/AppLifecycle/MemoryPressureClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import ComposableArchitecture
import Foundation

/// Process-wide memory-pressure warnings, as a stream the hibernation policy
/// subscribes to once. Injected so a test can drive pressure without the kernel.
struct MemoryPressureClient {
var warnings: @Sendable () -> AsyncStream<Void>
}

extension MemoryPressureClient: DependencyKey {
static let liveValue = MemoryPressureClient(
warnings: {
AsyncStream { continuation in
let source = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical],
queue: .main
)
source.setEventHandler { continuation.yield() }
continuation.onTermination = { _ in source.cancel() }
source.resume()
}
}
)

// Silent by default: only a test that opts in should see pressure.
static let testValue = MemoryPressureClient(warnings: { AsyncStream { $0.finish() } })
}
1 change: 1 addition & 0 deletions supacode/Features/App/Reducer/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,11 @@
refreshInstalledOpenActionsEffect(current: state.installedOpenActions),
.send(.repositories(.task)),
.send(.settings(.task)),
.send(.terminals(.task)),
.run { @MainActor send in
guard startupHotkey != nil else { return }
if !appLifecycleClient.updateGlobalHotkey(startupHotkey) {
await send(.settings(.setGlobalHotkeyRegistrationFailed(true)))

Check warning on line 405 in supacode/Features/App/Reducer/AppFeature.swift

View workflow job for this annotation

GitHub Actions / build

no 'async' operations occur within 'await' expression
}
},
.run { _ in
Expand Down Expand Up @@ -862,7 +863,7 @@
// refused policy switch would leave no surface at all. Fall back
// to the previous mode, which puts one of them back.
guard appLifecycleClient.applyVisibility(settings.appVisibility) else {
await send(.settings(.setAppVisibility(previousVisibility)))

Check warning on line 866 in supacode/Features/App/Reducer/AppFeature.swift

View workflow job for this annotation

GitHub Actions / build

no 'async' operations occur within 'await' expression
return
}
if dockIconReappeared {
Expand All @@ -875,7 +876,7 @@
effects.append(
.run { @MainActor send in
let succeeded = appLifecycleClient.updateGlobalHotkey(newGlobalHotkey)
await send(.settings(.setGlobalHotkeyRegistrationFailed(newGlobalHotkey != nil && !succeeded)))

Check warning on line 879 in supacode/Features/App/Reducer/AppFeature.swift

View workflow job for this annotation

GitHub Actions / build

no 'async' operations occur within 'await' expression
}
)
}
Expand Down
14 changes: 9 additions & 5 deletions supacode/Features/Repositories/Views/WorktreeDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ struct WorktreeDetailView: View {
}
} else if let selectedWorktree {
let shouldFocusTerminal = repositories.shouldFocusTerminal(for: selectedWorktree.id)
let pendingTerminalFocus: Worktree.ID? = shouldFocusTerminal ? selectedWorktree.id : nil
// No `.id` on purpose: keeping the view stable across a worktree switch
// lets the live surface reparent its cached wrapper instead of tearing
// the hosting chain down and rebuilding it at zero size.
WorktreeLayoutView(
worktree: selectedWorktree,
manager: terminalManager,
Expand All @@ -311,13 +315,13 @@ struct WorktreeDetailView: View {
forceAutoFocus: shouldFocusTerminal,
isLifecycleBusy: selectedSlice?.lifecycle.isBusy ?? false
)
.id(selectedWorktree.id)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.container, edges: .bottom)
.onAppear {
if shouldFocusTerminal {
store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id)))
}
// The subtree is stable across a switch, so `onAppear` fires only once;
// drive the consume from the focus request itself.
.onChange(of: pendingTerminalFocus, initial: true) { _, target in
guard let target else { return }
store.send(.repositories(.consumeTerminalFocus(target)))
}
} else if !repositories.isInitialLoadComplete {
DetailPlaceholderView()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ enum LayoutPersistence {
let live = runtime.content(for: contentID)
if let live {
overlaid.panes[paneIndex].tabs[tabIndex].content = live.snapshot()
// Reported titles never enter the layout reducer, so while the content
// is live the saved record pulls its latest one here.
overlaid.panes[paneIndex].tabs[tabIndex].title = TabTitle.stored(
for: overlaid.panes[paneIndex].tabs[tabIndex],
chrome: live.chrome
)
}
guard case .terminal(let state) = overlaid.panes[paneIndex].tabs[tabIndex].content.state
else { continue }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
let host = host
view.bridge.onTitleChange = { [weak view] title in
guard let view, isLive(view) else { return }
host.sendLayoutAction(.runtime(.titleChanged(id: contentID, title: title)))
host.updateReportedTitle(for: contentID, title: title)
}
// Layout topology belongs to the app's own chords, menus, and palette;
// a Ghostty keybind for it is consumed and ignored so the terminal can
Expand All @@ -60,7 +60,7 @@

private func wireLifecycleCallbacks(_ view: GhosttySurfaceView, contentID: ContentID, surfaceID: UUID) {
let host = host
let handleUnexpectedZmxClose = handleUnexpectedZmxClose

Check warning on line 63 in supacode/Features/Terminal/BusinessLogic/LayoutSurfaceConduit.swift

View workflow job for this annotation

GitHub Actions / build

initialization of immutable value 'handleUnexpectedZmxClose' was never used; consider replacing with assignment to '_' or removing it
view.bridge.onProgressReport = { [weak view] _ in
guard let view, isLive(view), let tabID = host.tabID(containing: surfaceID) else { return }
host.updateRunningState(for: tabID)
Expand Down
31 changes: 28 additions & 3 deletions supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ final class WorktreeContentHost {
/// Routes a topology mutation into the worktree's `LayoutFeature`.
@ObservationIgnored var sendLayoutAction: (LayoutFeature.Action) -> Void = { _ in }
@ObservationIgnored var onNotificationReceived: ((UUID, String, String, Bool) -> Void)?
/// A content reported a new title. Nothing in TCA moved (the title lives on
/// the content's chrome), so this exists only to re-arm the persistence
/// debounce, which pulls the title back at snapshot time.
@ObservationIgnored var onReportedTitleChanged: (() -> Void)?
@ObservationIgnored var onNotificationIndicatorChanged: (() -> Void)?
@ObservationIgnored var onFocusChanged: ((UUID) -> Void)?
@ObservationIgnored var onFocusedSurfaceColorChanged: (() -> Void)?
Expand Down Expand Up @@ -541,7 +545,28 @@ final class WorktreeContentHost {
/// The tab's content-owned strip chrome, nil for non-terminal contents.
private func terminalChrome(for tabID: TabID) -> TerminalTabChrome? {
guard let contentID = tab(withID: tabID)?.content.id else { return nil }
return runtime.content(for: contentID)?.chrome as? TerminalTabChrome
return terminalChrome(for: contentID)
}

private func terminalChrome(for contentID: ContentID) -> TerminalTabChrome? {
runtime.content(for: contentID)?.chrome as? TerminalTabChrome
}

/// A live or dormant terminal reported a title. Agent TUIs rewrite it several
/// times a second, so it lands on the content's observable chrome (only that
/// one tab label re-renders) and never as a layout action. The lock is
/// resolved at display and snapshot time, so a script tab's title survives its
/// shell's reports.
func updateReportedTitle(for contentID: ContentID, title: String) {
// Shells clear the title mid-command and reset it at the next prompt; ignore
// the empty report so the tab label holds its last real title instead of
// flashing to the layout's creation-time name.
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let chrome = terminalChrome(for: contentID),
chrome.reportedTitle != trimmed
else { return }
chrome.reportedTitle = trimmed
onReportedTitleChanged?()
}

func emitTaskStatusIfChanged() {
Expand Down Expand Up @@ -693,7 +718,7 @@ final class WorktreeContentHost {
if tabID(containing: surfaceID) != nil,
let title = liveSurface(surfaceID)?.bridge.state.title, !title.isEmpty
{
sendLayoutAction(.runtime(.titleChanged(id: ContentID(rawValue: surfaceID), title: title)))
updateReportedTitle(for: ContentID(rawValue: surfaceID), title: title)
}
emitFocusChangedIfNeeded(surfaceID)
}
Expand Down Expand Up @@ -1186,7 +1211,7 @@ final class WorktreeContentHost {
private func updateDormantTabTitle(surfaceID: UUID, title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, isDormantSurface(surfaceID) else { return }
sendLayoutAction(.runtime(.titleChanged(id: ContentID(rawValue: surfaceID), title: trimmed)))
updateReportedTitle(for: ContentID(rawValue: surfaceID), title: trimmed)
}

/// Full teardown on prune or quit: watchers stop, bookkeeping clears.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,12 @@ final class WorktreeTerminalManager {
self?.emitNotificationIndicatorCountIfNeeded()
self?.emitProjection(for: worktree.id)
}
// Only the debounce: the title itself is read back off the chrome when the
// snapshot is built, so a title storm costs one coalesced write, not one
// store send per report.
host.onReportedTitleChanged = { [weak self] in
self?.markLayoutDirty(worktreeID: worktree.id)
}
host.onFocusChanged = { [weak self] surfaceID in
self?.emit(.focusChanged(worktreeID: worktree.id, surfaceID: surfaceID))
self?.refreshFocusedSurfaceBackground()
Expand Down Expand Up @@ -858,6 +864,17 @@ final class WorktreeTerminalManager {
)
}

/// Hands the content's last reported title back to the layout. The title is
/// carried by the content's chrome alone, so every teardown that keeps the tab
/// (a reattach rebuild, quit-time termination) must commit it first or the tab
/// falls back to its creation-time name.
private func commitReportedTitle(of contentID: ContentID, worktreeID: Worktree.ID) {
guard let title = ContentRuntime.liveValue.content(for: contentID)?.chrome?.reportedTitle,
!title.isEmpty
else { return }
sendLayout(worktreeID, .runtime(.titleCommitted(id: contentID, title: title)))
}

/// An unexpected zmx exit: probe the session, then spare, kill, or reattach.
func handleUnexpectedZmxClose(_ view: GhosttySurfaceView, worktreeID: Worktree.ID) {
let surfaceID = view.id
Expand All @@ -883,6 +900,7 @@ final class WorktreeTerminalManager {
}
if session.clients == 0 {
// Reattachable: rebuild the same content at its persisted geometry.
self.commitReportedTitle(of: ContentID(rawValue: surfaceID), worktreeID: worktreeID)
ContentRuntime.liveValue.remove(ContentID(rawValue: surfaceID), tombstone: false)
self.sendLayout(worktreeID, .wakeTab(id: tabID))
return
Expand Down Expand Up @@ -1737,18 +1755,30 @@ final class WorktreeTerminalManager {
/// so "Quit and Terminate" must explicitly sweep orphan sessions or they
/// would survive forever.
func terminateAllSessions(killBudget: Duration = WorktreeTerminalManager.quitKillBudget) async {
let trackedSurfaceIDs = hosts.values.flatMap(\.allSurfaceIDs)
let trackedSessionIDs = Set(trackedSurfaceIDs.map(ZmxSessionID.make(surfaceID:)))
// Captured before `tearDown`, which clears the hosts' surface tracking.
let trackedByWorktree = hosts.flatMap { worktreeID, host in
host.allSurfaceIDs.map { (worktreeID: worktreeID, surfaceID: $0) }
}
let trackedSessionIDs = Set(trackedByWorktree.map { ZmxSessionID.make(surfaceID: $0.surfaceID) })
// "Quit and Terminate" promises nothing keeps running, so the host-side
// sessions of remote worktrees are swept too (best-effort over SSH).
let trackedRemoteSessions = Self.remoteSessions(in: Array(hosts.values))
// Commit reported titles before teardown: the content is still live, so
// this reaches the layout before the snapshot save empties the runtime. A
// commit after `tearDown` would re-run layout lifecycle reconciliation,
// which sees the removed renderers as dormant and restarts the very session
// watchers this teardown just stopped (the process survives a Terminate All).
for entry in trackedByWorktree {
commitReportedTitle(of: ContentID(rawValue: entry.surfaceID), worktreeID: entry.worktreeID)
}
for host in hosts.values {
host.tearDown()
}
for surfaceID in trackedSurfaceIDs {
guard let content = ContentRuntime.liveValue.content(for: ContentID(rawValue: surfaceID)) else { continue }
for entry in trackedByWorktree {
let contentID = ContentID(rawValue: entry.surfaceID)
guard let content = ContentRuntime.liveValue.content(for: contentID) else { continue }
content.hibernate()
ContentRuntime.liveValue.remove(content.id, tombstone: false)
ContentRuntime.liveValue.remove(contentID, tombstone: false)
}
emitHasAnyTerminalSurfaceIfNeeded()
// This instance's tracked local sessions are killed. A remote surface's
Expand Down
30 changes: 30 additions & 0 deletions supacode/Features/Terminal/Content/TabChrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@ protocol TabChrome: AnyObject {
/// Whether the terminal refuses input (a completed blocking script's parked
/// shell). The tab's own `isLocked` drives the visible lock marker.
var isReadOnly: Bool { get }
/// The title the content last reported, nil until it reports one (and nil for
/// content kinds that never report one). Agent TUIs rewrite it several times a
/// second, so it lives here rather than as a layout reducer action, and only
/// one tab's label re-renders on a report.
var reportedTitle: String? { get }
}

/// Resolves what a tab shows, and what the layout should persist for it, from
/// the layout's own title and the content's live reported title.
@MainActor
enum TabTitle {
/// The title the record persists: the content's live report when it has one,
/// else the layout's own. A locked tab owns its title (a shell report never
/// reaches it), and the user override is excluded (it persists in its field).
static func stored(for tab: TabItem, chrome: (any TabChrome)?) -> String {
guard !tab.isLocked, let reported = chrome?.reportedTitle, !reported.isEmpty else {
return tab.title
}
return reported
}

/// What the tab displays: a user override wins over the reported title.
static func resolved(for tab: TabItem, chrome: (any TabChrome)?) -> String {
tab.customTitle ?? stored(for: tab, chrome: chrome)
}

static func resolved(for tab: TabItem, runtime: ContentRuntime) -> String {
resolved(for: tab, chrome: runtime.content(for: tab.content.id)?.chrome)
}
}

/// Terminal chrome, written by the content host and the agent-presence
Expand All @@ -27,6 +56,7 @@ final class TerminalTabChrome: TabChrome {
var isWorking = false
var progress: TerminalTabProgressDisplay?
var isReadOnly = false
var reportedTitle: String?

var accessory: AnyView? {
guard !agents.isEmpty else { return nil }
Expand Down
87 changes: 0 additions & 87 deletions supacode/Features/Terminal/Models/SplitTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -359,47 +359,6 @@ nonisolated struct SplitTree<Leaf: Identifiable & Hashable>: Equatable {
return best?.leaf
}

var structuralIdentity: StructuralIdentity {
StructuralIdentity(self)
}

struct StructuralIdentity: Hashable {
private let root: Node?
private let zoomed: Node?

init(_ tree: SplitTree) {
self.root = tree.root
self.zoomed = tree.zoomed
}

static func == (lhs: Self, rhs: Self) -> Bool {
areNodesStructurallyEqual(lhs.root, rhs.root)
&& areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed)
}

func hash(into hasher: inout Hasher) {
hasher.combine(0)
if let root {
root.hashStructure(into: &hasher)
}
hasher.combine(1)
if let zoomed {
zoomed.hashStructure(into: &hasher)
}
}

private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
return true
case (let node1?, let node2?):
return node1.isStructurallyEqual(to: node2)
default:
return false
}
}
}

private init(root: Node?, zoomed: Node?) {
self.root = root
self.zoomed = zoomed
Expand Down Expand Up @@ -743,52 +702,6 @@ nonisolated extension SplitTree.Node {
return slots
}
}

var structuralIdentity: StructuralIdentity {
StructuralIdentity(self)
}

struct StructuralIdentity: Hashable {
private let node: SplitTree.Node

init(_ node: SplitTree.Node) {
self.node = node
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.node.isStructurallyEqual(to: rhs.node)
}

func hash(into hasher: inout Hasher) {
node.hashStructure(into: &hasher)
}
}

fileprivate func isStructurallyEqual(to other: Node) -> Bool {
switch (self, other) {
case (.leaf(let view1), .leaf(let view2)):
return view1 == view2
case (.split(let split1), .split(let split2)):
return split1.direction == split2.direction
&& split1.left.isStructurallyEqual(to: split2.left)
&& split1.right.isStructurallyEqual(to: split2.right)
default:
return false
}
}

fileprivate func hashStructure(into hasher: inout Hasher) {
switch self {
case .leaf(let view):
hasher.combine(0)
hasher.combine(view)
case .split(let split):
hasher.combine(1)
hasher.combine(split.direction)
split.left.hashStructure(into: &hasher)
split.right.hashStructure(into: &hasher)
}
}
}

nonisolated extension SplitTree.Spatial {
Expand Down
Loading
Loading