diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index 0eeb8ae14885..9d9948d2e699 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -77,6 +77,90 @@ extension CMUXCLI { } } + /// Resolves Codex's own thread title in a detached process and sends the + /// plain title to the app. The stop hook must stay short and the app socket + /// handler must not perform Codex database I/O on the main actor. + func runCodexNativeTitleSyncHook( + commandArgs: [String], + environment: [String: String], + client: SocketClient, + telemetry: CLISocketSentryTelemetry + ) { + guard let sessionId = optionValue(commandArgs, name: "--session"), + let workspaceId = optionValue(commandArgs, name: "--workspace"), + let surfaceId = optionValue(commandArgs, name: "--surface"), + !sessionId.isEmpty, !workspaceId.isEmpty, !surfaceId.isEmpty else { + telemetry.breadcrumb("codex-hook.native-title-sync.invalid-target") + return + } + let titleStore = CodexNativeTitleStore( + codexHome: normalizedHookValue(environment["CODEX_HOME"]) + ) + guard let title = titleStore.title(forSessionId: sessionId) else { + telemetry.breadcrumb("codex-hook.native-title-sync.no-title") + return + } + let turnLedger = CodexTurnLedger(environment: environment) + guard (try? turnLedger.isCurrent(sessionID: sessionId, surfaceID: surfaceId)) ?? true else { + telemetry.breadcrumb("codex-hook.native-title-sync.stale") + return + } + do { + _ = try client.sendV2(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspaceId, + "panel_id": surfaceId, + "title": title + ]) + telemetry.breadcrumb("codex-hook.native-title-sync.sent") + } catch { + telemetry.breadcrumb("codex-hook.native-title-sync.send-failed") + } + } + + /// Starts the title lookup after the synchronous Codex Stop hook returns. + /// The title is already authoritative in Codex's state database, so this + /// path does not invoke a summarizer or depend on Workspace Auto-Naming. + func spawnDetachedCodexNativeTitleSync( + sessionId: String, + workspaceId: String, + surfaceId: String, + environment: [String: String], + telemetry: CLISocketSentryTelemetry + ) { + guard !sessionId.isEmpty, !workspaceId.isEmpty, !surfaceId.isEmpty else { return } + let selfPath: String = { + if let first = ProcessInfo.processInfo.arguments.first, + first.hasPrefix("/"), + FileManager.default.isExecutableFile(atPath: first) { + return first + } + if let bundled = normalizedHookValue(environment["CMUX_BUNDLED_CLI_PATH"]), + FileManager.default.isExecutableFile(atPath: bundled) { + return bundled + } + return "cmux" + }() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [ + "-c", + "nohup \"$0\" hooks codex sync-native-title --session \"$1\" --workspace \"$2\" --surface \"$3\" /dev/null 2>&1 &", + selfPath, + sessionId, + workspaceId, + surfaceId + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + telemetry.breadcrumb("codex-hook.native-title-sync.spawn-failed") + } + } + /// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must /// splice ahead of the user's args to enable cmux hooks for one Codex /// invocation without rewriting the user's Codex configuration. Returns diff --git a/CLI/CodexTurnLedger.swift b/CLI/CodexTurnLedger.swift index c64c3433357f..d52791c52d5b 100644 --- a/CLI/CodexTurnLedger.swift +++ b/CLI/CodexTurnLedger.swift @@ -56,6 +56,20 @@ final class CodexTurnLedger { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } deinit {} + + func isCurrent(sessionID: String, surfaceID: String) throws -> Bool { + guard let normalizedSessionID = Self.normalized(sessionID), + let normalizedSurfaceID = Self.normalized(surfaceID) else { + return false + } + return try withLockedState(persist: false) { state in + guard let ownerSessionID = state.surfaceOwners[normalizedSurfaceID] else { + return true + } + return ownerSessionID == normalizedSessionID + } + } + func sessionStart( sessionID: String, workspaceID: String?, diff --git a/CLI/CodexTurnLedgerPersistence.swift b/CLI/CodexTurnLedgerPersistence.swift index bd21ef5ccc5c..d428373d86d4 100644 --- a/CLI/CodexTurnLedgerPersistence.swift +++ b/CLI/CodexTurnLedgerPersistence.swift @@ -109,6 +109,7 @@ extension CodexTurnLedger { /// Serializes only the cross-process read/modify/atomic-replace transaction; /// the lock is released before any socket or UI work begins. func withLockedState( + persist: Bool = true, _ body: (inout CodexTurnLedgerFile) throws -> T ) throws -> T { let lockPath = path + ".lock" @@ -130,6 +131,7 @@ extension CodexTurnLedger { var state = try load() let result = try body(&state) + guard persist else { return result } let data = try encoder.encode(state) let temporary = parent.appendingPathComponent( ".\(parent.lastPathComponent).codex-ledger.\(UUID().uuidString).tmp" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0744df6a44db..9faa8ac82beb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33858,6 +33858,17 @@ export default CMUXSessionRestore; return } + if def.name == "codex", subcommand == "sync-native-title" { + runCodexNativeTitleSyncHook( + commandArgs: hookArgs, + environment: env, + client: client, + telemetry: telemetry + ) + print("OK") + return + } + if subcommand == "auto-name", autoNamingSource(for: def) != nil { // Detached re-invocation spawned from the codex Stop hook (see // spawnDetachedAgentAutoName): runs the full naming pass without @@ -36258,6 +36269,16 @@ export default CMUXSessionRestore; sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) } + if def.name == "codex", !suppressVisibleMutations, !sessionId.isEmpty { + spawnDetachedCodexNativeTitleSync( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + environment: env, + telemetry: telemetry + ) + } + // Opt-in auto-naming for generic-agent sessions: a detached pass so the // summarization subprocess never blocks this short sync hook. // Gate the fork on the live setting (one cheap socket probe) so a diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift new file mode 100644 index 000000000000..b391d2b9b209 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexNativeTitleStore.swift @@ -0,0 +1,62 @@ +import Foundation +import SQLite3 + +/// Reads Codex's authoritative native thread title from its state database. +public struct CodexNativeTitleStore: Sendable { + private let databasePath: String + + /// Creates a title store for the Codex profile at `codexHome`. + public init(codexHome: String? = nil) { + self.databasePath = Self.databasePath(forCodexHome: codexHome) + } + + /// Creates a title store for a database path. + /// + /// This initializer is internal so production callers select a Codex home, + /// while package tests can inject deterministic SQLite fixtures. + init(databasePath: String) { + self.databasePath = databasePath + } + + /// Returns the non-empty title for an active session, or nil when Codex's + /// database, session row, or title is unavailable. + public func title(forSessionId sessionId: String) -> String? { + let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedSessionId.isEmpty else { return nil } + + var database: OpaquePointer? + guard sqlite3_open_v2(databasePath, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, + let database else { + sqlite3_close(database) + return nil + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 250) + + var statement: OpaquePointer? + let query = "SELECT title FROM threads WHERE id = ?1 AND archived = 0 LIMIT 1" + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, + let statement else { + sqlite3_finalize(statement) + return nil + } + defer { sqlite3_finalize(statement) } + + let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, normalizedSessionId, -1, transient) + guard sqlite3_step(statement) == SQLITE_ROW, + let titleBytes = sqlite3_column_text(statement, 0) else { + return nil + } + let title = String(cString: titleBytes).trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? nil : title + } + + /// Builds the state-database path for a Codex home directory. + private static func databasePath(forCodexHome codexHome: String?) -> String { + let home = (codexHome ?? "~/.codex") as NSString + return URL(fileURLWithPath: home.expandingTildeInPath, isDirectory: true) + .appendingPathComponent("state_5.sqlite", isDirectory: false) + .path + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift new file mode 100644 index 000000000000..7211a7a19682 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexNativeTitleStoreTests.swift @@ -0,0 +1,70 @@ +import Foundation +import SQLite3 +import Testing + +@testable import CMUXAgentLaunch + +struct CodexNativeTitleStoreTests { + private enum FixtureError: Error { + case database + } + + /// Reads the title associated with the requested Codex session only. + @Test func readsTheNativeThreadTitleForAnExactSession() throws { + let fixture = try makeDatabase(sessionId: "codex-session", title: "測試 cmux 與 codex Tab 同步") + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let store = CodexNativeTitleStore(databasePath: fixture.database.path) + #expect(store.title(forSessionId: fixture.sessionId) == "測試 cmux 與 codex Tab 同步") + } + + /// Returns no title for missing, empty, or unavailable session data. + @Test(arguments: ["missing-session", ""]) + func returnsNilWhenTheTitleCannotBeResolved(sessionId: String) throws { + let fixture = try makeDatabase(sessionId: "codex-session", title: "existing title") + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let store = CodexNativeTitleStore(databasePath: fixture.database.path) + #expect(store.title(forSessionId: sessionId) == nil) + let missingDatabase = fixture.directory.appendingPathComponent("missing.sqlite") + let missingStore = CodexNativeTitleStore(databasePath: missingDatabase.path) + #expect(missingStore.title(forSessionId: sessionId) == nil) + } + + /// Creates a temporary SQLite database that mirrors the Codex thread table. + private func makeDatabase(sessionId: String, title: String) throws -> (directory: URL, database: URL, sessionId: String) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-title-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let database = directory.appendingPathComponent("state_5.sqlite") + + var connection: OpaquePointer? + guard sqlite3_open(database.path, &connection) == SQLITE_OK, let connection else { + throw FixtureError.database + } + defer { sqlite3_close(connection) } + + let schema = """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 + ) + """ + guard sqlite3_exec(connection, schema, nil, nil, nil) == SQLITE_OK else { + throw FixtureError.database + } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(connection, "INSERT INTO threads (id, title) VALUES (?1, ?2)", -1, &statement, nil) == SQLITE_OK, + let statement else { + throw FixtureError.database + } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, sessionId, -1, transient) + sqlite3_bind_text(statement, 2, title, -1, transient) + guard sqlite3_step(statement) == SQLITE_DONE else { throw FixtureError.database } + return (directory, database, sessionId) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 1a7171bfb6f7..bd5cd91812fb 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -304,6 +304,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "notification.create_for_target", "notification.create_for_caller", "workspace.set_auto_title", + "surface.sync_codex_native_title", // The v2 resolution reads (tranche D of issue #5757) — the implicit // handle-normalization reads nearly every CLI invocation pays 1-3 of. // Their nonisolated coordinator bodies diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index a0158cb9fb91..ca1226c8bc7b 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -291,6 +291,13 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "notification.clear") == .mainActor) } + @Test func codexNativeTitleSyncRunsAsyncOnTheWorker() { + #expect( + ControlCommandExecutionPolicy(forMethod: "surface.sync_codex_native_title") + == .socketWorker(mainThreadCallable: false) + ) + } + @Test func v1CommandsDefaultToTheMainActor() { for command in [ "right_sidebar", "focus_surface", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 28cdc93eb911..93ad6d904829 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -366,6 +366,108 @@ } } }, + "socket.surfaceSyncCodexNativeTitle.invalidTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid title" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトルが指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.asyncDispatchRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface.sync_codex_native_title requires asynchronous socket dispatch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface.sync_codex_native_title には非同期ソケットディスパッチが必要です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.tabManagerUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "TabManager not available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TabManager は利用できません" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.workspaceIdInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid workspace_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "workspace_id が指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.panelIdInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid panel_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "panel_id が指定されていないか無効です" + } + } + } + }, + "socket.surfaceSyncCodexNativeTitle.panelNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Panel not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パネルが見つかりません" + } + } + } + }, "socket.error.unknownMethod": { "extractionState": "manual", "localizations": { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b160f16e6b26..a576bad19157 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3849,17 +3849,15 @@ class TabManager: ObservableObject { func flushPendingPanelTitleUpdatesForWorkspaceSnapshot() { panelTitleUpdateCoalescer.flushNow() } - private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String, sourceSurface: TerminalSurface) { - guard let tab = workspacesById[tabId], - let terminalPanel = tab.terminalPanel(for: panelId), - terminalPanel.surface === sourceSurface else { return } + + @discardableResult + func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) -> Bool { + guard let tab = workspacesById[tabId] else { return false } let previousDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) - _ = tab.updatePanelTitle(panelId: panelId, title: title) - guard !tab.isRemoteTmuxMirror else { return } - if tab.focusedPanelId == panelId { - if selectedTabId == tabId { - updateWindowTitle(for: tab) - } + let applied = tab.updatePanelTitle(panelId: panelId, title: title) + guard !tab.isRemoteTmuxMirror else { return applied } + if tab.focusedPanelId == panelId, selectedTabId == tabId { + updateWindowTitle(for: tab) } let currentDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) if currentDisplayTitle != previousDisplayTitle { @@ -3872,6 +3870,14 @@ class TabManager: ObservableObject { ] ) } + return applied + } + + private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String, sourceSurface: TerminalSurface) { + guard let tab = workspacesById[tabId], + let terminalPanel = tab.terminalPanel(for: panelId), + terminalPanel.surface === sourceSurface else { return } + _ = updatePanelTitle(tabId: tabId, panelId: panelId, title: title) } func shouldScheduleRawTitleRefresh(forWorkspaceId workspaceId: UUID?) -> Bool { workspaceId == selectedTabId && !PanelTitleUpdateCoalescingSettings.isEnabled(settings: settings) } diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index af9bfa98bd3b..26280fd98b11 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -98,6 +98,16 @@ extension TerminalController { action: action ) } + if authorizedRequest.method == "surface.sync_codex_native_title" { + return await self.v2MainAsync { + self.v2Result( + id: authorizedRequest.id?.foundationObject, + self.v2SurfaceSyncCodexNativeTitle( + params: authorizedRequest.params.mapValues(\.foundationObject) + ) + ) + } + } if policy.runsOnSocketWorker { // Terminal rename performs an awaited cloud-link mutation. Keep the // actual socket connection task asynchronous instead of parking a diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6dd1505bfd88..82c281ea4e4e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1234,6 +1234,16 @@ class TerminalController { if let workspaceParamError = v2UnsupportedWorkspaceAliasError(method: request.method, params: request.params) { return v2Result(id: request.id, workspaceParamError) } + if request.method == "surface.sync_codex_native_title" { + return v2Error( + id: request.id, + code: "invalid_dispatch", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.asyncDispatchRequired", + defaultValue: "surface.sync_codex_native_title requires asynchronous socket dispatch" + ) + ) + } if Self.socketWorkerCoordinatorHopMethods.contains(request.method) { // Mirror processParsedV2Command's tail: one main hop for the // command body, encode after the hop on this worker thread. @@ -2788,6 +2798,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceCloudVMBind(params: params)) case "workspace.set_auto_title": return v2Result(id: id, self.v2WorkspaceSetAutoTitle(params: params)) + case "surface.sync_codex_native_title": + return v2Result(id: id, self.v2SurfaceSyncCodexNativeTitle(params: params)) // Settings/session/feedback: session.restore_previous, settings.open, and // feedback.open handled by ControlCommandCoordinator. @@ -3121,6 +3133,7 @@ class TerminalController { "workspace.prompt_submit", "workspace.rename", "workspace.set_auto_title", + "surface.sync_codex_native_title", "workspace.group.list", "workspace.group.create", "workspace.group.ungroup", @@ -4444,6 +4457,87 @@ class TerminalController { v2MainSync { AppDelegate.shared?.tabManagerFor(tabId: workspaceId) } } + /// `surface.sync_codex_native_title`: applies Codex's already-resolved + /// native thread title to the panel's raw title tier, the same tier used + /// by OSC terminal-title updates. The detached CLI hook owns the database + /// read; this app-side handler only resolves the panel and mutates + /// in-memory workspace state. + /// Applies the title on the main actor for the asynchronous socket bridge. + func v2SurfaceSyncCodexNativeTitle(params: [String: Any]) -> V2CallResult { + guard let title = v2String(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.invalidTitle", + defaultValue: "Missing or invalid title" + ), + data: nil + ) + } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err( + code: "unavailable", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.tabManagerUnavailable", + defaultValue: "TabManager not available" + ), + data: nil + ) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.workspaceIdInvalid", + defaultValue: "Missing or invalid workspace_id" + ), + data: nil + ) + } + guard let panelId = v2UUID(params, "panel_id") else { + return .err( + code: "invalid_params", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.panelIdInvalid", + defaultValue: "Missing or invalid panel_id" + ), + data: nil + ) + } + + var found = false + var applied = false + v2MainSync { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + let resolvedPanelId = workspace.panels[panelId] != nil + ? panelId + : workspace.panelIdFromSurfaceId(TabID(uuid: panelId)) + guard let resolvedPanelId else { return } + found = true + applied = tabManager.updatePanelTitle( + tabId: workspaceId, + panelId: resolvedPanelId, + title: title + ) + } + + guard found else { + return .err( + code: "not_found", + message: String( + localized: "socket.surfaceSyncCodexNativeTitle.panelNotFound", + defaultValue: "Panel not found" + ), + data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ] + ) + } + return .ok(["applied": applied]) + } + // MARK: - V2 Workspace Methods @MainActor diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index 877d1c2ddf43..6e355bf1ee3b 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -133,7 +133,7 @@ extension Workspace { didMutatePanelTitle = true } - if didMutatePanelTitle, + if !isRemoteTmuxMirror, let tabId = surfaceIdFromPanelId(panelId), let panel = panels[panelId], let existing = bonsplitController.tab(tabId) { @@ -147,6 +147,7 @@ extension Workspace { title: titleUpdate, hasCustomTitle: hasCustomTitle ) + didMutate = true } } diff --git a/cmuxTests/SetAutoTitleSocketTests.swift b/cmuxTests/SetAutoTitleSocketTests.swift index bcf12e7fa80c..74a2a6e203c4 100644 --- a/cmuxTests/SetAutoTitleSocketTests.swift +++ b/cmuxTests/SetAutoTitleSocketTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import CmuxSettings import Testing @@ -27,6 +28,17 @@ import Testing return try decodeResponse(TerminalController.shared.handleSocketLine(requestLine)) } + /// Sends a request through the asynchronous socket dispatcher used by + /// real control-socket connections. + private func callAsync(method: String, params: [String: Any]) async throws -> [String: Any] { + let request: [String: Any] = ["id": method, "method": method, "params": params] + let requestData = try JSONSerialization.data(withJSONObject: request) + let requestLine = try #require(String(data: requestData, encoding: .utf8)) + let rawResponse = await TerminalController.shared.processCommandUsingSocketExecutionPolicyAsync(requestLine) + let response = try #require(rawResponse) + return try decodeResponse(response) + } + /// Runs `body` with the auto-naming setting forced to `enabled`, restoring /// the user's previous value afterwards. private func withAutoNamingSetting(_ enabled: Bool, _ body: () throws -> T) rethrows -> T { @@ -43,6 +55,24 @@ import Testing return try body() } + /// Runs an asynchronous test body with the auto-naming setting restored. + private func withAutoNamingSettingAsync( + _ enabled: Bool, + _ body: () async throws -> T + ) async rethrows -> T { + let key = AutomationCatalogSection().workspaceAutoNaming.userDefaultsKey + let previous = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.set(enabled, forKey: key) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + return try await body() + } + /// Runs `body` with the auto-naming agent override set to `slug`, restoring /// the user's previous value afterwards. private func withAutoNamingAgentSetting(_ slug: String, _ body: () throws -> T) rethrows -> T { @@ -67,6 +97,17 @@ import Testing return try body(manager, workspace) } + /// Runs an asynchronous test body with an isolated active tab manager. + private func withManagerAsync( + _ body: @MainActor (TabManager, Workspace) async throws -> T + ) async throws -> T { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.tabs.first) + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(nil) } + return try await body(manager, workspace) + } + @Test func probeReportsSummarizerAgentOverride() throws { try withAutoNamingSetting(true) { // "auto" override → no summarizer_agent (null). @@ -293,6 +334,97 @@ import Testing } } + /// Applies a native Codex title without requiring auto-naming. + @Test func codexNativeTitleSyncAppliesToRawPanelTitleWithAutoNamingDisabled() async throws { + try await withAutoNamingSettingAsync(false) { + try await withManagerAsync { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) + + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": "測試 cmux 與 codex Tab 同步" + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(workspace.panelTitles[panelId] == "測試 cmux 與 codex Tab 同步") + #expect(workspace.panelCustomTitles[panelId] == nil) + } + } + } + + @Test func codexNativeTitleSyncRefreshesWindowTitleAndPublishesWorkspaceChange() async throws { + try await withManagerAsync { manager, workspace in + let panelId = try #require(workspace.focusedPanelId) + manager.selectedTabId = workspace.id + let window = NSWindow() + manager.window = window + window.title = "stale window title" + let title = "Codex native window title" + var notifiedWorkspaceIds: [UUID] = [] + let observer = NotificationCenter.default.addObserver( + forName: .workspaceTitleDidChange, + object: manager, + queue: nil + ) { notification in + if let workspaceId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID { + notifiedWorkspaceIds.append(workspaceId) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": title + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(window.title == title) + #expect(notifiedWorkspaceIds == [workspace.id]) + } + } + + /// Repairs the visible tab header when the raw title was already stored. + @Test func codexNativeTitleSyncReconcilesVisibleTabHeader() async throws { + try await withManagerAsync { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: false)?.id) + let tabId = try #require(workspace.surfaceIdFromPanelId(panelId)) + let title = "Codex conversation title" + workspace.panelTitles[panelId] = title + + #expect(workspace.bonsplitController.tab(tabId)?.title != title) + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": title + ]) + let result = try #require(envelope["result"] as? [String: Any]) + #expect(result["applied"] as? Bool == true) + #expect(workspace.bonsplitController.tab(tabId)?.title == title) + } + } + + /// Leaves an explicitly renamed panel unchanged when Codex syncs a title. + @Test func codexNativeTitleSyncPreservesExistingCustomPanelTitle() async throws { + try await withManagerAsync { _, workspace in + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let panelId = try #require(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) + _ = workspace.setPanelCustomTitle(panelId: panelId, title: "my renamed tab", source: .user) + + let envelope = try await callAsync(method: "surface.sync_codex_native_title", params: [ + "workspace_id": workspace.id.uuidString, + "panel_id": panelId.uuidString, + "title": "fresh Codex conversation title" + ]) + #expect(envelope["ok"] as? Bool == true) + #expect(workspace.panelCustomTitles[panelId] == "my renamed tab") + #expect(workspace.panelCustomTitleSources[panelId] == .user) + } + } + @Test func malformedParamsProduceCleanErrors() throws { try withAutoNamingSetting(true) { try withManager { _, workspace in