macOS: add bottom bar when child exits (#12251)

### Closes #7649

The bar lives alongside URL Hover in VStack at the bottom. The current
body of SurfaceView is becoming rather long and complicated, so this pr
also contains some refactors:

- Move URL Hover to a separate file

> The text is copied from previous input string to keep it consistent,
also I’m confused with text on GTK so this is my first choice, but it
can be changed as the same as GTK.

Separate prs will be opened for:
1. Set to Read-only after exits
2. Hide cursor when in Read-only

### Preview


https://github.com/user-attachments/assets/eb44e211-eac5-4f40-836c-4912b18dfb01
This commit is contained in:
Mitchell Hashimoto
2026-04-13 06:47:47 -07:00
committed by GitHub
6 changed files with 155 additions and 46 deletions

View File

@@ -662,8 +662,7 @@ extension Ghostty {
case GHOSTTY_ACTION_QUIT_TIMER:
fallthrough
case GHOSTTY_ACTION_SHOW_CHILD_EXITED:
Ghostty.logger.info("known but unimplemented action action=\(action.tag.rawValue)")
return false
return showChildExited(app, target: target, v: action.action.child_exited)
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
return copyTitleToClipboard(app, target: target)
default:
@@ -1632,6 +1631,25 @@ extension Ghostty {
}
}
private static func showChildExited(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_surface_message_childexited_s,
) -> Bool {
switch target.tag {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
// We handle this when the window is visible and timetime_ms is greater than 0,
// which will rule out exit codes on launch
guard surfaceView.window != nil, v.timetime_ms > 0 else { return false }
surfaceView.setChildExitedMessage(.init(v))
return true
default:
return false
}
}
private static func copyTitleToClipboard(
_ app: ghostty_app_t,
target: ghostty_target_s) -> Bool {

View File

@@ -0,0 +1,23 @@
import Foundation
import GhosttyKit
import SwiftUI
extension Ghostty {
struct ChildExitedMessage {
enum Level {
case success, error
}
let text: String
let level: Level
init(_ message: ghostty_surface_message_childexited_s) {
switch Int(message.exit_code) {
case Int(EXIT_SUCCESS):
level = .success
default:
level = .error
}
text = "Process exited. Press any key to close the terminal."
}
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct ChildExitedMessageBar: View {
let msg: Ghostty.ChildExitedMessage
@State private var isHovered: Bool = false
var body: some View {
HStack(spacing: 6) {
Text(msg.text)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .center)
.background(msg.level.backgroundStyle)
.foregroundColor(msg.level.foregroundColor)
.contentShape(.rect)
.accessibilityLabel(msg.text)
.transition(.move(edge: .bottom))
.opacity(isHovered ? 0 : 1)
.allowsHitTesting(false)
.overlay {
Color.clear
.onHover {
isHovered = $0
}
}
}
}
private extension Ghostty.ChildExitedMessage.Level {
var foregroundColor: Color {
.primary
}
var backgroundStyle: AnyShapeStyle {
switch self {
case .success:
AnyShapeStyle(.background)
case .error:
AnyShapeStyle(.red.opacity(0.5))
}
}
}

View File

@@ -51,6 +51,9 @@ extension Ghostty {
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
/// A message sent from `ghostty_surface_t` when a child process exited
@Published private(set) var childExitedMessage: ChildExitedMessage?
var surface: ghostty_surface_t? {
nil
}
@@ -92,6 +95,10 @@ extension Ghostty {
}
}
func setChildExitedMessage(_ message: ChildExitedMessage) {
self.childExitedMessage = message
}
// MARK: - Placeholders
func focusDidChange(_ focused: Bool) {}

View File

@@ -47,9 +47,6 @@ extension Ghostty {
// Maintain whether our window has focus (is key) or not
@State private var windowFocus: Bool = true
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
@@ -135,49 +132,19 @@ extension Ghostty {
)
#endif
// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
let padding: CGFloat = 5
let cornerRadius: CGFloat = 9
ZStack {
HStack {
Spacer()
VStack(alignment: .leading) {
Spacer()
VStack(spacing: 0) {
// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
URLHoverBanner(url: url)
}
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 1 : 0)
}
}
HStack {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 0 : 1)
.onHover(perform: { hovering in
isHoveringURLLeft = hovering
})
}
Spacer()
}
// Show a bar to indicate a child process has exited.
if let msg = surfaceView.childExitedMessage {
ChildExitedMessageBar(msg: msg)
.font(.system(size: min(surfaceView.cellSize.height * 0.8, 30)))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
#if canImport(AppKit)
// If we have secure input enabled and we're the focused surface and window
@@ -242,7 +209,6 @@ extension Ghostty {
SurfaceGrabHandle(surfaceView: surfaceView)
#endif
}
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct URLHoverBanner: View {
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
let padding: CGFloat = 5
let cornerRadius: CGFloat = 9
let url: String
var body: some View {
ZStack {
HStack {
Spacer()
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 1 : 0)
}
}
HStack {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 0 : 1)
.onHover(perform: { hovering in
isHoveringURLLeft = hovering
})
}
Spacer()
}
}
}
}