Show remote terminal title in menu bar

This commit is contained in:
Kaede Akino committed 2026-08-29 11:15:29 +08:00
1 parent 82ea7c35d0
commit b2b87696a9
8 files changed
+430 -1

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import AppKit
import SwiftUI
/// A terminal title is authored by the remote host. Keep it on one printable
/// line before allowing it into AppKit's global menu structure.
nonisolated enum RemoteWindowTitle {
static func sanitized(_ proposed: String) -> String? {
enum EscapeState { case text, escape, controlSequence }
var state = EscapeState.text
var result = ""
var insertsSeparator = false
for scalar in proposed.unicodeScalars {
switch state {
case .escape:
state = scalar.value == 0x5B ? .controlSequence : .text
case .controlSequence:
// ECMA-48 CSI sequences end at the first final byte.
if (0x40...0x7E).contains(scalar.value) { state = .text }
case .text:
if scalar.value == 0x1B {
state = .escape
} else if scalar.value == 0x9B {
state = .controlSequence
} else if CharacterSet.controlCharacters.contains(scalar)
|| CharacterSet.newlines.contains(scalar)
{
insertsSeparator = !result.isEmpty
} else {
if insertsSeparator, !CharacterSet.whitespaces.contains(scalar) {
result.append(" ")
}
insertsSeparator = false
result.unicodeScalars.append(scalar)
}
}
}
let cleaned = result.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? nil : cleaned
}
}
/// Mirrors Kitty's macOS title item: an empty top-level item whose empty
/// submenu carries ` :: <active terminal title>`. Appending it to `mainMenu`
/// places it after the ordinary Help menu without turning the title into an
/// application command.
@MainActor
final class RemoteWindowTitleMenuCoordinator {
static let shared = RemoteWindowTitleMenuCoordinator()
private final class Report {
weak var window: NSWindow?
var title: String?
var revision: UInt64
init(window: NSWindow?, title: String?, revision: UInt64) {
self.window = window
self.title = title
self.revision = revision
}
}
private static let itemIdentifier = NSUserInterfaceItemIdentifier(
"com.akinokaede.termind.remote-window-title"
)
private let mainMenuProvider: () -> NSMenu?
private let keyWindowProvider: () -> NSWindow?
private var reports: [UUID: Report] = [:]
private var revision: UInt64 = 0
private var keyWindowObservers: [any NSObjectProtocol] = []
private var titleMenuItem: NSMenuItem?
init(
mainMenuProvider: @escaping () -> NSMenu? = { NSApp.mainMenu },
keyWindowProvider: @escaping () -> NSWindow? = { NSApp.keyWindow },
observesKeyWindowChanges: Bool = true
) {
self.mainMenuProvider = mainMenuProvider
self.keyWindowProvider = keyWindowProvider
if observesKeyWindowChanges { observeKeyWindowChanges() }
}
func report(title: String?, for window: NSWindow?, token: UUID) {
revision &+= 1
reports[token] = Report(window: window, title: title, revision: revision)
refresh()
}
func withdraw(token: UUID) {
reports[token] = nil
refresh()
}
func refresh() {
reports = reports.filter { $0.value.window != nil }
guard let keyWindow = keyWindowProvider() else {
removeTitleMenuItem()
return
}
let title = reports.values
.filter { $0.window === keyWindow }
.max { $0.revision < $1.revision }?
.title
guard let title = title.flatMap(RemoteWindowTitle.sanitized) else {
removeTitleMenuItem()
return
}
installOrUpdateTitleMenuItem(title)
}
private func observeKeyWindowChanges() {
let center = NotificationCenter.default
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] {
keyWindowObservers.append(
center.addObserver(
forName: name, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.refresh() }
})
}
}
private func installOrUpdateTitleMenuItem(_ title: String) {
guard let mainMenu = mainMenuProvider() else { return }
let item: NSMenuItem
if let installed = titleMenuItem, installed.menu === mainMenu {
item = installed
} else if let existing = mainMenu.items.first(where: {
$0.identifier == Self.itemIdentifier
}) {
titleMenuItem = existing
item = existing
} else {
let created = NSMenuItem(title: "", action: nil, keyEquivalent: "")
created.identifier = Self.itemIdentifier
created.submenu = NSMenu()
mainMenu.addItem(created)
titleMenuItem = created
item = created
}
item.submenu?.title = " :: \(title)"
}
private func removeTitleMenuItem() {
guard let item = titleMenuItem else { return }
item.menu?.removeItem(item)
titleMenuItem = nil
}
}
/// A dedicated observation boundary for the nested SSH session. Reading the
/// session title here makes Observation invalidate this small reporter whenever
/// Ghostty publishes another OSC 0/2 value, without rebuilding `RootView`.
struct ActiveRemoteWindowTitleReporter: View {
let sshSession: SSHSessionModel?
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var isEnabled =
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
var body: some View {
RemoteWindowTitleMenuBridge(
title: isEnabled ? sshSession?.menuBarWindowTitle : nil
)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
}
private struct RemoteWindowTitleMenuBridge: NSViewRepresentable {
let title: String?
@MainActor
final class ReportingView: NSView {
let token = UUID()
var reportedTitle: String?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
report()
}
func report() {
RemoteWindowTitleMenuCoordinator.shared.report(
title: reportedTitle, for: window, token: token
)
}
}
func makeNSView(context _: Context) -> ReportingView {
let view = ReportingView(frame: .zero)
view.reportedTitle = title
return view
}
func updateNSView(_ view: ReportingView, context _: Context) {
view.reportedTitle = title
view.report()
}
static func dismantleNSView(_ view: ReportingView, coordinator _: ()) {
RemoteWindowTitleMenuCoordinator.shared.withdraw(token: view.token)
}
}
+6
View File
@@ -51,6 +51,12 @@ struct RootView: View {
)
}
.navigationSplitViewStyle(.balanced)
// Kitty-style macOS menu-bar title. This tiny child observes only the
// focused SSH session, so OSC 0/2 changes do not invalidate the full
// window hierarchy.
.background {
ActiveRemoteWindowTitleReporter(sshSession: window.focusedSSHSession)
}
.overlay { ExternalAuthWindowPresenter() }
.frame(
minWidth: Metrics.Window.minWidth,
+7
View File
@@ -50,6 +50,13 @@ nonisolated enum SettingsKey {
static var isShellIntegrationEnabled: Bool {
UserDefaults.standard.object(forKey: shellIntegration) as? Bool ?? true
}
/// Mirror the focused SSH terminal's OSC 0/2 title in the macOS menu bar.
static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
static let defaultRemoteWindowTitleInMenuBarEnabled = true
static var isRemoteWindowTitleInMenuBarEnabled: Bool {
UserDefaults.standard.object(forKey: remoteWindowTitleInMenuBar) as? Bool
?? defaultRemoteWindowTitleInMenuBarEnabled
}
/// Restore the previous window's Workspaces on launch. Keep the persisted
/// key stable so this UI rename does not discard the user's preference.
static let restoreWorkspaces = "window.restoreTabs"
@@ -591,6 +591,8 @@ private struct TerminalSettings: View {
@AppStorage(SettingsKey.terminalFontSize) private var fontSize = 12.0
@AppStorage(SettingsKey.terminalFontFollowTheme) private var fontFollowTheme = true
@AppStorage(SettingsKey.shellIntegration) private var shellIntegration = true
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var remoteWindowTitleInMenuBar =
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
@AppStorage(SettingsKey.terminalThemeDark) private var darkTheme = SettingsKey.defaultTerminalThemeDark
@AppStorage(SettingsKey.terminalThemeLight) private var lightTheme = SettingsKey.defaultTerminalThemeLight
@AppStorage(SettingsKey.terminalCustomConfigurationDarkEnabled) private var darkCustomEnabled = false
@@ -643,6 +645,12 @@ private struct TerminalSettings: View {
"Keeps the remote working directory current, records confirmed commands, and provides detected distribution, kernel, and architecture details to AI features."
)
.font(.caption).foregroundStyle(.secondary)
Toggle("Show Terminal Title in Menu Bar", isOn: $remoteWindowTitleInMenuBar)
Text(
"Shows the focused SSH terminal's reported title after Help in the macOS menu bar."
)
.font(.caption).foregroundStyle(.secondary)
}
}
.onAppear {
@@ -174,6 +174,15 @@ final class SSHSessionModel: Identifiable {
/// Whether the remote shell accepted OSC 133 integration.
var shellIntegrationAvailable: Bool = false
private(set) var integratedShell: String?
/// The most recent OSC 0/2 title reported by this SSH connection.
///
/// Ghostty owns parsing and publishes the value through `TerminalViewState`.
/// Keep a session-scoped copy here so reconnecting can discard a title from
/// the old remote shell even though the long-lived terminal surface survives.
private(set) var remoteWindowTitle: String?
var menuBarWindowTitle: String? {
status.isLive ? remoteWindowTitle : nil
}
private(set) var remoteSystemInfo: RemoteSystemInfo? {
didSet {
guard remoteSystemInfo != oldValue else { return }
@@ -219,6 +228,7 @@ final class SSHSessionModel: Identifiable {
private var hasConnectedOnce = false
private var isRecoveringConnection = false
private var workingDirectoryObservation: AnyCancellable?
private var remoteWindowTitleObservation: AnyCancellable?
/// The exact authenticated route used by the live terminal, retained only
/// in memory so a drop can open an independent SFTP connection without
/// asking for credentials a second time. Cleared with the SSH connection.
@@ -293,6 +303,7 @@ final class SSHSessionModel: Identifiable {
#endif
relay.owner = self
observeWorkingDirectory(from: state, through: relay)
observeRemoteWindowTitle(from: state, through: relay)
}
convenience init(host: Host, usernameHint: String? = nil, initialCommand: String? = nil) {
@@ -343,6 +354,7 @@ final class SSHSessionModel: Identifiable {
#endif
relay.owner = self
observeWorkingDirectory(from: state, through: relay)
observeRemoteWindowTitle(from: state, through: relay)
}
var agentTemporaryTarget: AgentHostTarget? {
@@ -519,6 +531,7 @@ final class SSHSessionModel: Identifiable {
private func startConnection(using store: any RemoteSessionHostStore) {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
homeResolution = SettingsKey.isShellIntegrationEnabled ? .pending : .unavailable
let reconnecting = hasConnectedOnce && isRecoveringConnection
workingDirectory = nil
@@ -925,6 +938,7 @@ final class SSHSessionModel: Identifiable {
func disconnect() {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferConfiguration = nil
@@ -953,6 +967,7 @@ final class SSHSessionModel: Identifiable {
func stopReconnecting() {
guard let reconnectState else { return }
remoteWindowTitle = nil
reconnectController.stop()
isRecoveringConnection = false
connectionAttemptID = nil
@@ -1065,6 +1080,7 @@ final class SSHSessionModel: Identifiable {
/// Workspace instead; transport failures remain visible for reconnection.
func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferConfiguration = nil
@@ -1109,6 +1125,7 @@ final class SSHSessionModel: Identifiable {
private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) {
resetCommandGeneratorShellState()
remoteWindowTitle = nil
isRecoveringConnection = true
connectionAttemptID = nil
connectionTask?.cancel()
@@ -1293,6 +1310,21 @@ final class SSHSessionModel: Identifiable {
}
}
/// Mirrors Ghostty's parsed OSC 0/2 title into the SSH connection's
/// lifecycle. `TerminalViewState.title` deliberately outlives a transport;
/// the copy does not, preventing a reconnected shell from inheriting the
/// previous shell or foreground program's menu-bar title.
private func observeRemoteWindowTitle(
from state: TerminalViewState,
through relay: SSHSessionRelay
) {
remoteWindowTitleObservation = state.$title.sink { [weak relay] title in
MainActor.assumeIsolated {
relay?.owner?.remoteWindowTitle = RemoteWindowTitle.sanitized(title)
}
}
}
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
switch event {
case .available(let shell, let systemInfo, let homeDirectory):
+33 -1
View File
@@ -9751,6 +9751,18 @@
}
}
},
"Show Terminal Title in Menu Bar" : {
"comment" : "A toggle that shows the name of the focused terminal in the macOS menu bar.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在菜单栏中显示终端标题"
}
}
}
},
"Show the plan" : {
"localizations" : {
"zh-Hans" : {
@@ -9799,6 +9811,18 @@
}
}
},
"Shows the focused SSH terminal's reported title after Help in the macOS menu bar." : {
"comment" : "A description of the feature that shows the name of the focused terminal in the menu bar.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在macOS菜单栏的“帮助”后显示当前SSH终端报告的标题。"
}
}
}
},
"Sidebar" : {
"localizations" : {
"zh-Hans" : {
@@ -10005,7 +10029,15 @@
},
"SSH" : {
"comment" : "Label for SSH connections.",
"isCommentAutoGenerated" : true
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH"
}
}
}
},
"SSH and SFTP, with an assistant at hand." : {
"localizations" : {
+15
View File
@@ -118,6 +118,21 @@ struct CommandHistoryTests {
@Suite
struct RemoteCommandEventFilterTests {
@Test
func ordinaryOSCWindowTitlesRemainVisibleToGhostty() {
var filter = RemoteCommandEventFilter(token: "TOKEN")
let data = Data(
"\u{1B}]0;kaede@host:/srv\u{7}\u{1B}]2;host: command\u{7}".utf8
)
let update = filter.receive(data)
#expect(update.visibleOutput == data)
#expect(update.commands.isEmpty)
#expect(!update.promptReady)
#expect(update.drafts.isEmpty)
}
@Test
func filtersChunkedEventsAndPreservesSurroundingOutput() {
var filter = RemoteCommandEventFilter(token: "TOKEN")
+124
View File
@@ -0,0 +1,124 @@
import AppKit
import Foundation
import GhosttyTerminal
import Testing
@testable import Termind
@Suite(.serialized)
@MainActor
struct RemoteWindowTitleTests {
@Test
func sshSessionMirrorsSanitizedGhosttyTitleOnlyWhileConnected() async {
let session = SSHSessionModel(hostID: UUID(), title: "Host")
session.terminalState.terminalDidChangeTitle(
"\u{1B}[31mkaede@lima-default:/Users/kaede\u{1B}[0m\nignored"
)
#expect(session.remoteWindowTitle == "kaede@lima-default:/Users/kaede ignored")
#expect(session.menuBarWindowTitle == nil)
session.status = .connected
#expect(session.menuBarWindowTitle == "kaede@lima-default:/Users/kaede ignored")
session.disconnect()
#expect(session.remoteWindowTitle == nil)
#expect(session.menuBarWindowTitle == nil)
}
@Test
func sanitizerKeepsOnePrintableLineAndRejectsEmptyTitles() {
#expect(RemoteWindowTitle.sanitized(" host: /srv ") == "host: /srv")
#expect(RemoteWindowTitle.sanitized("host\r\n/srv\u{7}") == "host /srv")
#expect(RemoteWindowTitle.sanitized("\u{1B}[1;32mgreen\u{1B}[0m") == "green")
#expect(RemoteWindowTitle.sanitized("\n\u{7}\u{1B}[31m") == nil)
}
@Test
func menuFollowsOnlyTheKeyWindowAndUsesKittyTitleFormat() throws {
let mainMenu = NSMenu()
let help = NSMenuItem(title: "Help", action: nil, keyEquivalent: "")
help.submenu = NSMenu(title: "Help")
mainMenu.addItem(help)
let firstWindow = NSWindow()
let secondWindow = NSWindow()
var keyWindow: NSWindow? = firstWindow
let coordinator = RemoteWindowTitleMenuCoordinator(
mainMenuProvider: { mainMenu }, keyWindowProvider: { keyWindow },
observesKeyWindowChanges: false
)
let firstToken = UUID()
let secondToken = UUID()
coordinator.report(
title: "kaede@lima-default:/Users/kaede",
for: firstWindow, token: firstToken
)
#expect(mainMenu.items.count == 2)
#expect(mainMenu.items[0] === help)
#expect(mainMenu.items[1].title.isEmpty)
#expect(
try #require(mainMenu.items[1].submenu).title
== " :: kaede@lima-default:/Users/kaede"
)
#expect(mainMenu.items[1].submenu?.items.isEmpty == true)
coordinator.report(title: "second:/tmp", for: secondWindow, token: secondToken)
#expect(mainMenu.items[1].submenu?.title == " :: kaede@lima-default:/Users/kaede")
keyWindow = secondWindow
coordinator.refresh()
#expect(mainMenu.items[1].submenu?.title == " :: second:/tmp")
coordinator.withdraw(token: secondToken)
#expect(mainMenu.items == [help])
}
@Test(
.disabled(
if: !ghosttyHasActiveDisplay,
"Ghostty's Metal surface requires an active display"
))
func hostManagedGhosttySurfaceParsesOSCZeroAndTwo() async throws {
let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
let state = TerminalViewState(configSource: .none)
state.configuration = TerminalSurfaceOptions(backend: .inMemory(backend))
let terminal = TerminalView(frame: NSRect(x: 0, y: 0, width: 640, height: 400))
terminal.delegate = state
terminal.configuration = state.configuration
terminal.controller = state.controller
let window = NSWindow(
contentRect: terminal.frame, styleMask: [.titled],
backing: .buffered, defer: false
)
window.contentView = terminal
window.makeKeyAndOrderFront(nil)
defer { window.close() }
backend.receive(Data("\u{1B}]0;zero-title\u{7}".utf8))
try await waitUntil { state.title == "zero-title" }
backend.receive(Data("\u{1B}]2;two-title\u{7}".utf8))
try await waitUntil { state.title == "two-title" }
}
private func waitUntil(
timeout: Duration = .seconds(2), condition: @MainActor () -> Bool
) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while !condition() {
guard clock.now < deadline else {
Issue.record("Timed out waiting for terminal state")
return
}
try await Task.sleep(for: .milliseconds(10))
}
}
}
private nonisolated let ghosttyHasActiveDisplay: Bool = {
var count: UInt32 = 0
guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false }
return count > 0
}()