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
30 changes: 30 additions & 0 deletions GraphcodeKit/Sources/Domain/TerminalLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ public struct TerminalLayout: Codable, Equatable, Sendable {
public var tabs: IdentifiedArrayOf<TabLayout>
public var selectedTabID: UUID

/// The selected tab's focused pane — the one the keyboard is in, and the one ⌘W
/// closes. `nil` only when there are no tabs, which the workspace never allows.
public var focusedSurface: SurfaceRef? {
tabs[id: selectedTabID]?.focusedSurface
}

public init(tabs: IdentifiedArrayOf<TabLayout>, selectedTabID: UUID) {
self.tabs = tabs
self.selectedTabID = selectedTabID
Expand All @@ -290,4 +296,28 @@ public struct TerminalLayout: Codable, Equatable, Sendable {
let tab = TabLayout(primary: SurfaceRef(id: nodeID, launchesClaudeCode: true))
return TerminalLayout(tabs: [tab], selectedTabID: tab.id)
}

/// The layout a node's workspace should open with: the saved one, with the node's own
/// surface — the agent pane every layout starts with — put back at the front if the
/// saved layout lost it. A tab (or split pane) can be closed like any other now, and a
/// layout persisted without the agent surface would otherwise reopen a running loop as
/// shells-only, with its live session attached to nothing on screen.
///
/// Repaired rather than replaced: the shell tabs a saved layout carries have live zmx
/// sessions of their own, and dropping them from the layout would not end those
/// sessions — only hide them, which is the leak `killSessions` exists to close. The
/// only layout thrown away is the one that was never saved.
public static func opening(forNode nodeID: UUID, saved: TerminalLayout?) -> TerminalLayout {
guard var layout = saved, !layout.tabs.isEmpty else { return .defaultLayout(forNode: nodeID) }
guard !layout.tabs.contains(where: { tab in tab.surfaces.contains { $0.id == nodeID } })
else { return layout }
let agentTab = TabLayout(primary: SurfaceRef(id: nodeID, launchesClaudeCode: true))
layout.tabs.insert(agentTab, at: 0)
// A selection naming a tab that is still here is the human's and stays theirs; one
// left dangling by whatever removed the agent tab lands on the tab just restored.
if layout.tabs[id: layout.selectedTabID] == nil {
layout.selectedTabID = agentTab.id
}
return layout
}
}
13 changes: 8 additions & 5 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -527,11 +527,14 @@ public enum ZmxSessionLauncher {
return ["/bin/zsh", "-i", "-l", "-c", script]
}

/// Kills the session behind an id that isn't a graph node — a quick chat. Public
/// because chats are app-owned: no daemon deletes their sessions for them, the way
/// `GraphStore` does when a loop is deleted.
public static func killSession(id: UUID) async {
await kill(LoopNode(id: id, title: ""))
/// Kills the session behind an id that isn't a graph node — a quick chat, or a plain
/// shell pane the human closed. Public because both are app-owned: no daemon deletes
/// their sessions for them, the way `GraphStore` does when a loop is deleted.
/// `projectPath` routes a remote project's session to the kill that runs on its host;
/// a shell surface has no session id banked for it, so the bookkeeping `kill` does
/// alongside is all no-ops for it.
public static func killSession(id: UUID, projectPath: String? = nil) async {
await kill(LoopNode(id: id, title: ""), projectPath: projectPath)
}

static func kill(_ node: LoopNode, projectPath: String? = nil) async {
Expand Down
3 changes: 2 additions & 1 deletion graphcode/Sources/Features/App/AppFeature+LoopSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ extension AppFeature {
func mountWorkspace(
node: LoopNode, graph: LoopGraph, projectPath: String, _ state: inout State
) {
let layout = terminalLayoutStore.load(forNode: node.id) ?? .defaultLayout(forNode: node.id)
let layout = TerminalLayout.opening(
forNode: node.id, saved: terminalLayoutStore.load(forNode: node.id))
state.openLoop = LoopWorkspaceFeature.State(
node: node,
graph: graph,
Expand Down
3 changes: 2 additions & 1 deletion graphcode/Sources/Features/App/AppFeature+QuickChats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ extension AppFeature {
loopType: .composite,
backend: chat.backend,
createdAt: chat.createdAt)
let layout = terminalLayoutStore.load(forNode: chat.id) ?? .defaultLayout(forNode: chat.id)
let layout = TerminalLayout.opening(
forNode: chat.id, saved: terminalLayoutStore.load(forNode: chat.id))
state.openLoop = LoopWorkspaceFeature.State(
node: node,
layout: layout,
Expand Down
61 changes: 56 additions & 5 deletions graphcode/Sources/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -545,12 +545,12 @@ struct AppFeature {
// the daemon rather than locally, the same way the sidebar's delete does: the
// resulting broadcast is what removes the card everywhere, and `GraphStore` also
// kills the loop's zmx session. Closed here as well rather than waiting for that
// broadcast, so the dead pane goes away even with the daemon unreachable.
// broadcast, so the dead pane goes away even with the daemon unreachable. A chat,
// though, is not a node in any graph — there is nothing to delete, and the chat
// itself should outlive its session. Just put the dead terminal away.
case .openLoop(.primaryExitAcknowledged):
guard let id = state.openLoop?.node.id, let projectPath = state.openLoop?.projectPath
else { return .none }
// A chat is not a node in any graph — there is nothing to delete, and the chat
// itself should outlive its session. Just put the dead terminal away.
guard !state.isQuickChat(id) else {
closeOpenWorkspace(&state)
state.detailSelection = .quickChats
Expand All @@ -563,6 +563,19 @@ struct AppFeature {
.graphCommand(projectPath: projectPath, command: .deleteNode(id)))
}

// Closing the workspace's last tab — by its x, by ⌘W, or by a plain shell simply
// exiting. There is nothing left to show, which for a loop means ending the loop,
// and unlike `.primaryExitAcknowledged` above the session may still be running:
// this is a live loop being thrown away by a keystroke every terminal on the
// machine binds to closing a tab. So it goes through the same "Delete Loop…"
// confirmation every other delete in the app does (`deleteNodeRequested`, whose
// dialog `AppView` hosts) rather than deleting behind the human's back. Confirming
// deletes through the daemon, and the broadcast that follows is what closes this
// workspace — see `.daemonEvent`. Cancelling leaves the tab exactly where it was,
// which is why `.tabClosed` retires nothing until the answer is in.
case .openLoop(.lastTabClosed):
return endOpenWorkspace(&state)

case .openLoop(.showInGraphTapped):
// Closing the workspace *without* ending its terminals: the loop keeps running,
// you are just looking at the graph again. `closeOpenWorkspace` is the other
Expand Down Expand Up @@ -774,12 +787,50 @@ extension AppFeature {
/// nothing in the sidebar pointing at it.
private func isGlobal(_ path: String) -> Bool { path == LoopGraphScope.globalPath }

/// The workspace's last tab going: nothing is left to show, which for a loop means
/// ending the loop. Put to the same "Delete Loop…" confirmation every other delete in
/// the app goes through (`deleteNodeRequested`, whose dialog `AppView` hosts) rather
/// than deleted outright — unlike `.primaryExitAcknowledged`, the session behind this
/// one may still be running, and ⌘W is a keystroke for closing a tab everywhere else
/// on the machine, not consent to throw a loop away. Confirming deletes through the
/// daemon and the broadcast that follows closes this workspace; cancelling leaves the
/// tab where it was, which is why `.tabClosed` retires nothing until the answer is in.
func endOpenWorkspace(_ state: inout State) -> Effect<Action> {
guard let id = state.openLoop?.node.id, let projectPath = state.openLoop?.projectPath
else { return .none }
// A chat is not a node in any graph: nothing to confirm and nothing to delete, and
// the chat itself outlives its session. Just put the terminal away.
guard !state.isQuickChat(id) else {
closeOpenWorkspace(&state)
state.detailSelection = .quickChats
return .none
}
// No project row means no graph holding this node and no dialog to present it —
// closing the workspace is all that is honestly available.
guard state.projects[id: projectPath] != nil else {
state.openLoop = nil
state.selectedProjectPath = projectPath
return .none
}
return .send(.projects(.element(id: projectPath, action: .deleteNodeRequested(id))))
}

/// Closes the open workspace *and ends its terminals* — for when the loop itself is
/// gone, as opposed to merely not being the one on screen any more. Not `private`
/// going away, as opposed to merely not being the one on screen any more. Not `private`
/// because a deleted chat needs the same treatment and lives in the other file.
///
/// Ending the terminals is two halves: the surfaces are retired (the attach ends), and
/// the plain shells behind them are killed (#254) — nothing owns a shell once its
/// workspace is gone, and one left running is invisible until reboot. The agent
/// surface is deliberately not on the kill list: the loop's session belongs to the
/// node, and whichever side deletes the node ends it (`GraphStore` on the daemon's
/// path, `QuickChats` for a chat).
func closeOpenWorkspace(_ state: inout State) {
guard let openLoop = state.openLoop else { return }
terminalSurfaceClient.retire(openLoop.layout.tabs.flatMap { $0.surfaces.map(\.id) })
let surfaces = openLoop.layout.tabs.flatMap { $0.surfaces }
terminalSurfaceClient.retire(surfaces.map(\.id))
terminalSurfaceClient.killSessions(
surfaces.filter { !$0.launchesClaudeCode }.map(\.id), openLoop.projectPath)
state.openLoop = nil
}

Expand Down
37 changes: 31 additions & 6 deletions graphcode/Sources/Features/App/GraphcodeCommands.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import ComposableArchitecture
import GraphcodeKit
import SwiftUI
Expand Down Expand Up @@ -31,6 +32,17 @@ struct GraphcodeCommands: Commands {
// `File ▸ Worktrees…` — the sweeper for the focused folder, same sheet the lane
// chip and the context menus open. In File because it is about the folder on disk,
// not about any loop.
// SwiftUI gives every `WindowGroup` a `File ▸ Close` at ⌘W, and AppKit resolves a
// key equivalent by walking the menu bar left to right: File would answer ⌘W before
// the Terminal menu ever saw it, so Close Pane below would never fire. Replacing the
// group is what frees the key. Closing the window keeps a shortcut, ⇧⌘W — Ghostty's
// own, and the pairing anyone who has closed a split before already has in their
// hands.
CommandGroup(replacing: .saveItem) {
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
.keyboardShortcut("w", modifiers: [.command, .shift])
}

CommandGroup(after: .newItem) {
Divider()
// Beside New Window rather than in a menu of its own: a workspace is the other
Expand Down Expand Up @@ -96,9 +108,26 @@ struct GraphcodeCommands: Commands {
Button("New Tab") { store.send(.openLoop(.newTabButtonTapped)) }
.keyboardShortcut("t", modifiers: .command)
.disabled(!hasWorkspace)
// ⌘W closes what the keyboard is in: the focused pane of a split, and the pane
// that *is* the tab when it isn't split — Ghostty's own `close_split` binding,
// which is the terminal these panes are. Through the reducer, closing the
// workspace's last one asks whether the loop should end (#254), which is why it is
// never disabled: there is always something ⌘W can close while a workspace is
// open. "Close Pane" rather than a title that follows the split — a label that
// renames itself to "Close Tab" collides with the item below it, and a menu with
// the same words twice teaches nobody which key does what.
Button("Close Pane") {
guard let layout = store.openLoop?.layout, let focused = layout.focusedSurface
else { return }
store.send(.openLoop(.paneClosed(tabID: layout.selectedTabID, surfaceID: focused.id)))
}
.keyboardShortcut("w", modifiers: .command)
.disabled(!hasWorkspace)
// Every pane of the tab at once. Unbound on purpose: ⌘W already closes a tab that
// isn't split, which is nearly every tab here, and the shortcut this used to carry
// is spent on Close Window below — the one ⌘W has to give back.
Button("Close Tab") { store.send(.openLoop(.tabClosed(selectedTabID))) }
.keyboardShortcut("w", modifiers: .command)
.disabled(!canCloseTab)
.disabled(!hasWorkspace)

Divider()

Expand Down Expand Up @@ -149,10 +178,6 @@ struct GraphcodeCommands: Commands {
(store.openLoop?.isRailVisible ?? false) ? "Hide Loop Panel" : "Show Loop Panel"
}

/// A workspace always keeps at least one tab, so closing the last one is refused by
/// the reducer — the menu says so rather than offering a no-op.
private var canCloseTab: Bool { (store.openLoop?.layout.tabs.count ?? 0) > 1 }

private var isSplit: Bool {
guard let layout = store.openLoop?.layout else { return false }
return layout.tabs[id: layout.selectedTabID]?.isSplit ?? false
Expand Down
42 changes: 32 additions & 10 deletions graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ struct LoopWorkspaceFeature {
case newTabButtonTapped
case tabSelected(UUID)
case tabClosed(UUID)
/// The workspace's last tab was closed. What that means is `AppFeature`'s to say:
/// for a loop it is the end of the loop itself, put to the same "Delete Loop…"
/// confirmation the sidebar's delete goes through — the loop may still be running,
/// and ⌘W is not consent. For a quick chat it is just the terminal being put away;
/// the chat outlives its session.
case lastTabClosed
case selectNextTab
case selectPreviousTab
case splitButtonTapped(direction: SplitDirection)
Expand Down Expand Up @@ -133,9 +139,10 @@ struct LoopWorkspaceFeature {
}

@Dependency(\.terminalLayoutStore) var terminalLayoutStore
/// Closing a tab or a pane is the one thing that should still end a surface. Switching
/// loops deliberately doesn't — see `TerminalSurfaceStore` — so without telling it
/// here, a closed pane's terminal would linger until it aged out of the cache.
/// Closing a tab or a pane is the one thing that should still end a surface — and,
/// since #254, the shell session behind it. Switching loops deliberately doesn't — see
/// `TerminalSurfaceStore` — so without telling it here, a closed pane's terminal would
/// linger until it aged out of the cache.
@Dependency(\.terminalSurfaceClient) var terminalSurfaceClient

var body: some ReducerOf<Self> {
Expand All @@ -155,13 +162,22 @@ struct LoopWorkspaceFeature {
return .none

case .tabClosed(let id):
// A workspace always keeps at least one tab.
guard state.layout.tabs.count > 1, let index = state.layout.tabs.index(id: id),
let tab = state.layout.tabs[id: id]
else {
return .none
}
guard let index = state.layout.tabs.index(id: id), let tab = state.layout.tabs[id: id]
else { return .none }
// The last tab going means the workspace has nothing left to show, which is the
// end of the loop itself — `AppFeature`'s call, since the deletion is its job,
// and it asks the human first. Nothing is torn down on the way out: the answer
// may be no, and a tab whose terminals were already retired and whose shells
// were already killed is not a tab anyone can be given back.
guard state.layout.tabs.count > 1 else { return .send(.lastTabClosed) }
terminalSurfaceClient.retire(tab.surfaces.map(\.id))
// The shells in the tab die with it — a pane's zmx session is the pane's reason
// for existing, and one left running after its pane is gone is invisible until
// reboot (#254). The agent surface is exempt: its session belongs to the loop,
// which outlives any pane and is ended by deleting the node, not by closing a
// tab in front of it.
terminalSurfaceClient.killSessions(
tab.surfaces.filter { !$0.launchesClaudeCode }.map(\.id), state.projectPath)
state.layout.tabs.remove(id: id)
if state.layout.selectedTabID == id {
let fallbackIndex = min(index, state.layout.tabs.count - 1)
Expand Down Expand Up @@ -229,6 +245,12 @@ struct LoopWorkspaceFeature {
// Only the pane that went is retired. Every survivor is the same live terminal it
// was, and is about to be re-mounted in the space the closed one gave up.
terminalSurfaceClient.retire([surfaceID])
// A shell the human closed ends with its pane (#254) — whether it was closed by
// the x, ⌘W, or its own process exiting. An agent pane is never killed here: the
// loop's session is the loop's, and ends when the node does.
if !paneOrder[closedIndex].launchesClaudeCode {
terminalSurfaceClient.killSessions([surfaceID], state.projectPath)
}
tab.root = root
if tab.focusedSurfaceID == surfaceID {
// Where the keyboard lands, matching Ghostty: the pane before the one that
Expand Down Expand Up @@ -312,7 +334,7 @@ struct LoopWorkspaceFeature {
return .none

case .stopLoopTapped, .restartLoopTapped, .showInGraphTapped, .railTargetTapped,
.primaryExitAcknowledged:
.primaryExitAcknowledged, .lastTabClosed:
// Handled by `AppFeature`'s parent `Reduce` — see the actions' own doc comment.
return .none
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ struct TabPillView: View {
let state: LoopState?
let isSelected: Bool
let shortcutHint: String?
let canClose: Bool
let onSelect: () -> Void
let onClose: () -> Void

Expand Down Expand Up @@ -89,7 +88,10 @@ struct TabPillView: View {

@ViewBuilder
private var trailingGlyph: some View {
if canClose && isHovering {
// Every tab closes, the loop's own included — a lone tab used to hide the button
// because closing it "did nothing", but now the last tab's close ends the loop
// itself (see `.tabClosed`), which is exactly when an x is most needed (#254).
if isHovering {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ struct LoopWorkspaceView: View {
state: tab.surfaces.contains(where: \.launchesClaudeCode) ? store.node.state : nil,
isSelected: tab.id == store.layout.selectedTabID,
shortcutHint: index < 9 ? "⌘\(index + 1)" : nil,
canClose: store.layout.tabs.count > 1,
onSelect: { store.send(.tabSelected(tab.id)) },
onClose: { store.send(.tabClosed(tab.id)) }
)
Expand Down Expand Up @@ -311,7 +310,9 @@ struct LoopWorkspaceView: View {
PaneHeaderView(
title: ref.launchesClaudeCode ? "agent" : "shell",
isFocused: isFocused && tab.id == store.layout.selectedTabID,
detail: ref.launchesClaudeCode ? store.node.backend.displayName.lowercased() : "zsh")
detail: ref.launchesClaudeCode ? store.node.backend.displayName.lowercased() : "zsh",
canClose: tab.isSplit,
onClose: { store.send(.paneClosed(tabID: tab.id, surfaceID: ref.id)) })
terminal(tab: tab, ref: ref)
}
// `ref.id` (not just this slot's structural position) is a surface's real
Expand Down
Loading
Loading