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
3 changes: 3 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ public final class AppState {
/// Called to launch Claude in a terminal that just became ready.
public var onLaunchClaude: ((UUID) -> Void)? // receives terminal ID

/// Called when the user clicks "Retry" on a failed terminal surface.
public var onRetryTerminal: ((UUID) -> Void)? // receives terminal ID

/// Called to add a new plain-shell terminal tab to a session.
public var onAddTerminal: ((UUID) -> Void)? // receives session ID

Expand Down
2 changes: 2 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ public enum ClaudeState: String, Codable, Sendable {

/// Terminal surface lifecycle state.
public enum TerminalReadiness: String, Codable, Sendable, Comparable {
case failed // createSurface() exhausted retries; UI shows error overlay with Retry
case uninitialized // GhosttySurfaceView exists but createSurface() not called
case surfaceCreated // ghostty_surface_t exists, shell process spawning
case shellReady // Shell prompt detected (probe file appeared)
case claudeLaunched // claude --continue has been sent

private var sortOrder: Int {
switch self {
case .failed: -1
case .uninitialized: 0
case .surfaceCreated: 1
case .shellReady: 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ public final class GhosttySurfaceView: NSView {
private var markedTextStorage = NSMutableAttributedString()
private var trackingArea: NSTrackingArea?

/// Backoff schedule for retrying createSurface() when ghostty_surface_new returns nil.
/// 4 retries totalling ~7.5s before declaring permanent failure.
private static let retryDelays: [TimeInterval] = [0.5, 1.0, 2.0, 4.0]
private var createAttempts: Int = 0

/// Whether the Ghostty surface has been created (needs window attachment first).
public var hasSurface: Bool { surface != nil }

/// Called after createSurface() succeeds.
public var onSurfaceCreated: (() -> Void)?

/// Called after createSurface() exhausts its retry budget without producing a surface.
public var onSurfaceCreationFailed: (() -> Void)?

/// The working directory for the shell spawned in this surface.
public var workingDirectory: String?

Expand Down Expand Up @@ -82,6 +90,7 @@ public final class GhosttySurfaceView: NSView {
updateTrackingAreaInternal()

if surface != nil {
createAttempts = 0
NSLog("[Ghostty] createSurface() succeeded, hasCallback=\(onSurfaceCreated != nil)")
// Flush any text that arrived before the surface was ready
if !pendingText.isEmpty {
Expand All @@ -95,6 +104,20 @@ public final class GhosttySurfaceView: NSView {
onSurfaceCreated?()
} else {
NSLog("[Ghostty] createSurface() FAILED — surface is nil")
if createAttempts < Self.retryDelays.count {
let delay = Self.retryDelays[createAttempts]
createAttempts += 1
NSLog("[Ghostty] retrying createSurface in %.1fs (attempt %d/%d)",
delay, createAttempts, Self.retryDelays.count)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, self.surface == nil else { return }
self.createSurface()
}
} else {
NSLog("[Ghostty] createSurface() exhausted retries — giving up")
createAttempts = 0
onSurfaceCreationFailed?()
}
}
}

Expand Down Expand Up @@ -469,6 +492,7 @@ public final class GhosttySurfaceView: NSView {

public func destroy() {
onSurfaceCreated = nil
onSurfaceCreationFailed = nil
if let surface {
ghostty_surface_free(surface)
self.surface = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import GhosttyKit
public enum SurfaceState: String, Sendable {
case created // ghostty_surface_t created, shell spawning
case shellReady // Shell assumed ready after startup delay
case failed // ghostty_surface_new returned nil and retries were exhausted
}

/// Manages live terminal surfaces, keeping them alive across SwiftUI view reloads.
Expand Down Expand Up @@ -58,6 +59,10 @@ public final class TerminalManager {
NSLog("[TerminalManager] onSurfaceCreated (offscreen) for \(id)")
self?.surfaceDidCreate(id: id)
}
view.onSurfaceCreationFailed = { [weak self] in
NSLog("[TerminalManager] surface creation failed permanently for \(id)")
self?.surfaceDidFail(id: id)
}
surfaces[id] = view
// Adding to offscreenWindow triggers viewDidMoveToWindow → createSurface()
offscreenWindow.contentView?.addSubview(view)
Expand All @@ -67,17 +72,29 @@ public final class TerminalManager {
///
/// The returned view is kept alive in an internal dictionary so that
/// SwiftUI re-renders reuse the same `GhosttySurfaceView` instance.
/// A cached view whose underlying `ghostty_surface_t` is nil (i.e. a prior
/// `createSurface()` failed) is treated as a miss: it is destroyed and
/// replaced so callers don't get permanently stuck with a broken view.
public func surface(for id: UUID, workingDirectory: String, command: String? = nil) -> GhosttySurfaceView {
if let existing = surfaces[id] {
NSLog("[TerminalManager] surface(for: \(id)) — returning EXISTING view")
return existing
if existing.hasSurface {
NSLog("[TerminalManager] surface(for: \(id)) — returning EXISTING view")
return existing
}
NSLog("[TerminalManager] surface(for: \(id)) — cached view has nil surface, discarding")
existing.destroy()
surfaces.removeValue(forKey: id)
}
NSLog("[TerminalManager] surface(for: \(id)) — creating NEW view, setting onSurfaceCreated callback")
let view = GhosttySurfaceView(frame: .zero, workingDirectory: workingDirectory, command: command)
view.onSurfaceCreated = { [weak self] in
NSLog("[TerminalManager] onSurfaceCreated callback fired for \(id)")
self?.surfaceDidCreate(id: id)
}
view.onSurfaceCreationFailed = { [weak self] in
NSLog("[TerminalManager] surface creation failed permanently for \(id)")
self?.surfaceDidFail(id: id)
}
surfaces[id] = view
return view
}
Expand All @@ -88,6 +105,16 @@ public final class TerminalManager {
if let view = surfaces.removeValue(forKey: id) { view.destroy() }
}

/// Discard the cached view for `id` (if any) and re-run preInitialize.
/// Used by the UI's "Retry" affordance after a permanent surface-creation failure.
public func retry(id: UUID, workingDirectory: String, command: String? = nil) {
NSLog("[TerminalManager] retry(\(id)) — destroying broken surface and re-preInitializing")
if let view = surfaces.removeValue(forKey: id) { view.destroy() }
// Re-arm readiness tracking; surfaceDidFail removed the id from the set.
monitoredTerminals.insert(id)
preInitialize(id: id, workingDirectory: workingDirectory, command: command)
}

public func send(id: UUID, text: String) { surfaces[id]?.writeText(text) }

// MARK: - Readiness Monitoring
Expand Down Expand Up @@ -125,4 +152,10 @@ public final class TerminalManager {
}
}
}

/// Called after a surface's `createSurface()` exhausts its retry budget.
public func surfaceDidFail(id: UUID) {
monitoredTerminals.remove(id)
onStateChanged?(id, .failed)
}
}
25 changes: 23 additions & 2 deletions Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,29 @@ struct ReadinessAwareTerminal: View {
)
.id(terminal.id)

// Loading overlay while terminal is not yet ready
if readiness < .shellReady {
if readiness == .failed {
// Permanent failure overlay with Retry affordance.
VStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 28))
.foregroundStyle(.orange)
Text("Terminal failed to launch")
.font(.headline)
Text("Ghostty couldn't create a surface after several retries.")
.font(.caption)
.foregroundStyle(CorveilTheme.textMuted)
.multilineTextAlignment(.center)
Button("Retry") {
appState.onRetryTerminal?(terminal.id)
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(CorveilTheme.bgDeep.opacity(0.95))
} else if readiness < .shellReady {
// Loading overlay while terminal is not yet ready
VStack(spacing: 8) {
ProgressView()
.controlSize(.regular)
Expand Down
5 changes: 5 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,11 @@ struct SessionRow: View {
case .active:
// Reflect terminal readiness state
switch terminalReadiness {
case .failed:
Circle()
.fill(.red)
.frame(width: 8, height: 8)
.accessibilityLabel("Terminal failed to launch")
case .uninitialized, nil:
Circle()
.fill(CorveilTheme.textMuted)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
service?.launchClaude(terminalID: terminalID)
}

appState.onRetryTerminal = { [weak service] terminalID in
service?.retryTerminal(terminalID: terminalID)
}

// Wire terminal tab management
appState.onAddTerminal = { [weak service] sessionID in
service?.addTerminal(sessionID: sessionID)
Expand Down
28 changes: 28 additions & 0 deletions Sources/Crow/App/SessionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ final class SessionService {
///
/// Maps `SurfaceState.created` → `.surfaceCreated` and `.shellReady` → `.shellReady`,
/// only for terminals already registered in `terminalReadiness` (managed work session terminals).
/// `.failed` is forwarded unconditionally so the UI can render an error overlay with Retry.
func wireTerminalReadiness() {
NSLog("[SessionService] wireTerminalReadiness — setting onStateChanged callback")
TerminalManager.shared.onStateChanged = { [weak self] terminalID, state in
Expand All @@ -169,6 +170,10 @@ final class SessionService {
// Previously this was triggered by the SwiftUI view's onChange,
// but with offscreen pre-init the view may not be rendered yet.
self.launchClaude(terminalID: terminalID)
case .failed:
NSLog("[SessionService] terminal \(terminalID) failed to launch surface")
self.appState.terminalReadiness[terminalID] = .failed
// Do not auto-launch Claude; the UI will surface a Retry button.
}
}
}
Expand Down Expand Up @@ -241,6 +246,29 @@ final class SessionService {
}
}

/// Discard a failed terminal surface and re-attempt creation.
///
/// Called when the user clicks "Retry" on a terminal whose readiness is `.failed`.
/// Resets readiness back to `.uninitialized`, re-arms auto-launch (so Claude relaunches
/// on success), and asks `TerminalManager` to destroy the broken view and re-preInitialize.
func retryTerminal(terminalID: UUID) {
let terminal = appState.terminals.values.flatMap { $0 }.first(where: { $0.id == terminalID })
guard let terminal else {
NSLog("[SessionService] retryTerminal: no terminal record for \(terminalID)")
return
}
NSLog("[SessionService] retryTerminal(\(terminalID))")
appState.terminalReadiness[terminalID] = .uninitialized
if terminal.isManaged {
appState.autoLaunchTerminals.insert(terminalID)
}
TerminalManager.shared.retry(
id: terminalID,
workingDirectory: terminal.cwd,
command: terminal.command
)
}

// MARK: - Ensure Manager Session

func ensureManagerSession(devRoot: String) {
Expand Down
Loading