From b2b87696a9aa59cfd7d97ca67f49c6e399031a55 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 11:15:29 +0800 Subject: [PATCH] Show remote terminal title in menu bar --- Termind/App/RemoteWindowTitleMenu.swift | 205 ++++++++++++++++++ Termind/App/RootView.swift | 6 + Termind/App/SettingsKey.swift | 7 + .../Features/Settings/SettingsPageView.swift | 8 + .../Features/Terminal/SSHSessionModel.swift | 32 +++ Termind/Resources/Localizable.xcstrings | 34 ++- TermindTests/CommandHistoryTests.swift | 15 ++ TermindTests/RemoteWindowTitleTests.swift | 124 +++++++++++ 8 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 Termind/App/RemoteWindowTitleMenu.swift create mode 100644 TermindTests/RemoteWindowTitleTests.swift diff --git a/Termind/App/RemoteWindowTitleMenu.swift b/Termind/App/RemoteWindowTitleMenu.swift new file mode 100644 index 00000000..7a161c00 --- /dev/null +++ b/Termind/App/RemoteWindowTitleMenu.swift @@ -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 ` :: `. 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) + } +} diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 9d04ee76..8a2a54a2 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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, diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 71942e07..968a0c6c 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -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" diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index 6d5803ff..5922a679 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -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 { diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index c5e9ac71..98d44c6f 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -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) { 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): diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 57a9b2a2..3e8a8b84 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -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" : { diff --git a/TermindTests/CommandHistoryTests.swift b/TermindTests/CommandHistoryTests.swift index ffe8aeae..fc31234a 100644 --- a/TermindTests/CommandHistoryTests.swift +++ b/TermindTests/CommandHistoryTests.swift @@ -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") diff --git a/TermindTests/RemoteWindowTitleTests.swift b/TermindTests/RemoteWindowTitleTests.swift new file mode 100644 index 00000000..b2b0c669 --- /dev/null +++ b/TermindTests/RemoteWindowTitleTests.swift @@ -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 +}()