From 07e665dcb564b4c160a06e71946de994acbba714 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:05:17 +0000 Subject: [PATCH 01/10] test(next): a close of an unfocused terminal during a hidden read must not take the held keys Red on feat: Cmd-W on an idle terminal hides it and its read is held; keys are typed; the close button of another terminal (running cat) is pressed; more keys are typed. After the release, the running terminal asks and the person cancels: the keys typed during the read must reach the focused neighbour and never the cancelled close's terminal (review Low of #31875). --- ...CmdWIdleTerminalClosesInItsTurnTests.swift | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift index 452a6ff370c8..faf257c9582e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift @@ -204,8 +204,51 @@ struct CmdWIdleTerminalClosesInItsTurnTests { } } + /// (Review of #31875, Low) The close button of another terminal, one that does not have the focus, pressed while a + /// hidden close reads: that close never takes the keys typed meanwhile. They go to the terminal that has the focus + /// (the neighbour the hide showed), never into the other terminal when its program asks and the person cancels. + @Test func aCloseOfAnUnfocusedTerminalDuringTheReadNeverTakesTheHeldKeys() async throws { + try await Self.withFocusedTerminal { t in + let neighbour = try await Self.newTerminal(t) + let running = try await Self.newTerminal(t) // runs cat: its close asks + _ = try await t.connection.request(SendInputRequest(surface: running.surface, text: "cat\r")) + try await Self.waitRunning("cat", t, surface: running.surface) + t.pane.select(TabID(t.tab.id)) + let window = try #require(t.pane.view.window) + let focused = { t.services.keyRouter.focus(for: window).0?.focus.state.terminalTab } + try await PinnedPageTests.until({ focused() == t.tab.id }, report: { "the closing terminal never had the focus: \(focused() ?? "none")" }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + try Self.press("w", .command, keyCode: 13, in: window, t) // the idle terminal's Cmd-W: hidden, it reads + try await PinnedPageTests.until({ focused() == neighbour.id && hold.held == 1 }, + report: { "focus \(focused() ?? "none"), neighbour \(neighbour.id), reads held \(hold.held)" }) + let nonce = String(UInt32.random(in: 100_000...999_999)), before = "kq\(nonce)", after = "zz\(nonce)" + for character in before { try Self.press(String(character), in: window, t) } + t.pane.handle(.close(TabID(running.id), source: .mouse)) // its close button: the running terminal hides and reads + try await PinnedPageTests.until({ hold.held == 2 }, report: { "the running terminal's close did not read: \(hold.held) held" }) + for character in after { try Self.press(String(character), in: window, t) } + hold.release() + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no question for the running terminal" }) + _ = CmuxDialogCenter.shared.press(try #require(Self.question()).id, button: "cancel") + let typed = try? await t.connection.request(WaitForTextRequest(surface: neighbour.surface, pattern: before + after, timeoutMs: 10_000), + timeout: .seconds(15)) + let screen = (try? await t.connection.request(ReadScreenRequest(surface: running.surface), timeout: .seconds(5)))?.text ?? "" + #expect(typed?.matched == true, "the keys typed during the read did not reach the focused terminal: \(typed?.text.suffix(300) ?? "no answer")") + #expect(!screen.contains(before) && !screen.contains(after), "the cancelled close's terminal got the focused one's keys: \(screen.suffix(300))") + #expect(t.store.tab(surface: running.surface) != nil, "the terminal running cat closed with no answer") + } + } + // MARK: Fixture + /// A new terminal tab at the end of `t`'s pane, once its strip lists it. + private static func newTerminal(_ t: Terminal) async throws -> TabModel { + let surface = try await t.connection.newTab(in: t.pane.pane.handle).surface + try await PinnedPageTests.until({ t.store.tab(surface: surface).map { t.pane.orderedIDs.map(\.rawValue).contains($0.id) } == true }) + return try #require(t.store.tab(surface: surface)) + } + private struct Terminal { let services: AppServices let connection: DaemonConnection @@ -252,12 +295,12 @@ struct CmdWIdleTerminalClosesInItsTurnTests { _ = try await t.connection.request(SendInputRequest(surface: t.tab.surface, text: text)) } - /// Waits until the daemon names `program` as the terminal's foreground program (nil: its shell only). - private static func waitRunning(_ program: String?, _ t: Terminal) async throws { + /// Waits until the daemon names `program` as the foreground program of the terminal (`surface`, else `t`'s; nil: its shell only). + private static func waitRunning(_ program: String?, _ t: Terminal, surface: SurfaceID? = nil) async throws { var last = "no answer" for _ in 0..<100 { // Only an answer counts: a read that did not answer is not "its shell only". - if let answer = await t.connection.readProcessInfo(t.tab.surface, timeout: .seconds(1)) { + if let answer = await t.connection.readProcessInfo(surface ?? t.tab.surface, timeout: .seconds(1)) { if answer.runningProgram == program { return } last = answer.runningProgram ?? "its shell only" } From a038e93fca89aaa5b64e16271ac68617c7b147f8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:12:16 +0000 Subject: [PATCH 02/10] fix(next): a close of an unfocused terminal never takes the keys held for the focused terminal's close While a hidden Cmd-W read was open, CreationInputBuffer.isClosing(tab:) was true for every tab, so beginClose opened a key lane for any other terminal's close (its close button). That close took every key typed after it, and when the first close ended, settleClose moved its keys to that newest close. Its program asked; the person cancelled; the keys were typed into that terminal. A close opens a lane only when it takes the focused terminal, or, while that terminal's close still reads, the tab its pane selects now (the quick second Cmd-W). The keys of the first close then go to the focused terminal (or wait for an open question's answer, then go there). --- .../Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index ac8093c4273a..c3025da9edd2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -155,13 +155,13 @@ final class CreationInputCoordinator { } } - /// A person's close of `tabs` waits for an answer (the close warning's read): when it takes the focused live terminal (never a cancelled stand-in), or comes while that terminal's close still reads (two quick Cmd-Ws), keys typed meanwhile are the close's (P0 close-then-type). + /// A person's close of `tabs` waits for an answer (the close warning's read): when it takes the focused live terminal (never a cancelled stand-in), or while that terminal's close reads, the tab its pane selects now (a quick second Cmd-W), keys typed meanwhile are the close's (P0 close-then-type). Another terminal's close (its close button) opens no lane: its Cancel must never type them into it. /// `hides`: the close takes its tabs out of the strip while it reads, so every key typed in the window meanwhile is /// the close's, whatever the focus moved to (`CreationInputBuffer.openClose(closing:machine:takesEveryKey:)`). func beginClose(of tabs: [TabModel], in window: NSWindow?, hides: Bool = false) -> CreationInputBuffer.Ticket? { let live = tabs.filter(\.isLiveTerminal).map(\.id) guard let window, ActionRunScope.viewChangeAllowed(), let controller = router?.focus(for: window).0, let focused = controller.focus.state.terminalTab, !live.isEmpty, - live.contains(focused) || buffers[window.windowNumber]?.isClosing(tab: focused) == true else { return nil } + live.contains(focused) || buffers[window.windowNumber]?.isClosing(tab: focused) == true && controller.focus.state.resolved.pane.flatMap({ controller.content?.paneController(key: $0)?.stripModel.selectedID?.rawValue }).map(live.contains) == true else { return nil } return buffer(in: window).openClose(closing: live.contains(focused) ? [focused] + live.filter { $0 != focused } : live, machine: controller.content?.daemon.machineID, takesEveryKey: hides) } From 185fa460c455a73f62e1b2af832e659b1ee2d107 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:12:17 +0000 Subject: [PATCH 03/10] test(next): runningPrograms(of:unanswered:) never asks a stand-in terminal Pins the stand-in exclusion of #31875 (review Low): with unanswered set, a stand-in that is asked counts as a running program and the close asks "Still running: " for a terminal that runs nothing. --- .../RunningProgramsStandInTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift new file mode 100644 index 000000000000..67f1bd66045d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift @@ -0,0 +1,18 @@ +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextDaemonService +import Testing + +/// Review of #31875 (Low): `DaemonService.runningPrograms(of:unanswered:)` never asks a stand-in (a terminal still +/// opening). A stand-in has no process, and the daemon refuses a read of its surface, so a close that counts "no answer" +/// as running (`unanswered`, the hidden Cmd-W's read) asked "Still running: <its title>" for a terminal that runs nothing. +@MainActor @Suite struct RunningProgramsStandInTests { + @Test func aStandInIsNeverAskedAndCountsAsNoProgram() async { + let standIn = TabModel(TabSnapshot(surface: ProvisionalTab().surface, kind: .pty, title: "opening")) + let live = TabModel(TabSnapshot(surface: SurfaceID(rawValue: 7), kind: .pty, title: "live")) + // No connection: every terminal asked counts as `unanswered` says, so only the stand-in's exclusion keeps it out. + let asked = await DaemonService().runningPrograms(of: [standIn, live]) { $0.title } + #expect(asked.keys.sorted { $0.rawValue < $1.rawValue } == [live.surface], "a stand-in was asked: \(asked)") + #expect(asked.programNames == ["live"], "a stand-in counts as a running program: \(asked.programNames)") + } +} From 752ae267623ff76d3bc8def28fb96d716c1ee05a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:52:39 +0000 Subject: [PATCH 04/10] fix(next): held keys never run while a close's question is up; they go on after its answer The hidden close of the focused terminal ended just before another close's question showed: its keys were settled for the focused terminal, but the hold's flush ran a turn later, with the question up, and the key router gave every replayed key to the question (answersBlockingDialog): the keys reached no terminal, and a held Return would press the question's Close. While a close's question is up in the window (parkClose), the hold keeps its keys, also at its time limit; the answer (endClose) lets them go on, each to the terminal it was typed for. --- .../CmuxNextApp/Focus/CreationInputCoordinator.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index c3025da9edd2..6dfbd9d86761 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -178,9 +178,8 @@ final class CreationInputCoordinator { return parked } - /// The close ran: its keys (`parked` first) go to the terminal focused now, once it has the keyboard. The person - /// cancelled it (`stays`): its parked keys go to the terminal that stays now, by that terminal, wherever the focus is. - /// A close that ends while another close's question is up waits for that answer; a gone window takes its keys with it. + /// The close ran: its keys (`parked` first) go to the terminal focused now, once it has the keyboard. The person cancelled it (`stays`): its parked keys go to the terminal that stays now, by that terminal, wherever the focus is. + /// A close that ends while another close's question is up waits for that answer, as the hold's keys do (``flush(in:force:end:)``), which go on after it; a gone window takes its keys with it. func endClose(_ ticket: CreationInputBuffer.Ticket?, parked: CreationInputBuffer.ParkedClose? = nil, stays: Bool = false, in window: NSWindow?) { guard let window else { return } let number = window.windowNumber @@ -189,6 +188,7 @@ final class CreationInputCoordinator { return } let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking.removeValue(forKey: number) ?? []), closing: $0.closing, machine: $0.machine) } + defer { if parked != nil { scheduleFlush(in: window) } } // the question is answered: the keys the hold kept meanwhile go on if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) @@ -358,7 +358,7 @@ final class CreationInputCoordinator { /// `force`: the hold ends now (its limit, focus left the content), whatever it waited for. /// `end`: why a forced end happened (``CreationHoldLog/end(in:forced:)``). private func flush(in window: NSWindow, force: Bool = false, end forcedEnd: CreationHoldEnd = .limitReached) { - guard let buffer = buffers[window.windowNumber] else { return } + guard let buffer = buffers[window.windowNumber], asking[window.windowNumber] == nil else { return } // a close's question is up: the keys wait for its answer, never typed into it (a Return would press Close) guard force || buffer.isResolved else { // Before every reply, only a creation whose early view has the keyboard takes its own keys (a focus settle). if let lane = router?.focus(for: window).0.flatMap({ Self.keyboardTerminal($0, window).flatMap(buffer.earlyLane(tab:)) }) { _ = runAgain(buffer.take(lane: lane), from: buffer, in: window) } From 0b9ad02ae95841f495682bd1fd541cc3343acd46 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:11:11 +0000 Subject: [PATCH 05/10] test(next): Escape at a close's question cancels it through the key path, and the held line comes back Red at 752ae267623 (review of #32291, High): flush kept the hold while the question showed, so capture took every key typed at it; only a click answered the question. --- ...CmdWIdleTerminalClosesInItsTurnTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift index faf257c9582e..2d0b9697a97a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift @@ -240,6 +240,36 @@ struct CmdWIdleTerminalClosesInItsTurnTests { } } + /// (Review of #32291) While the question shows, the held keys wait for its answer, but a key typed at the question is + /// the question's: Escape through the app's key path cancels it, and the line held during the read comes back whole + /// to the terminal that stays. + @Test func escapeAtTheQuestionCancelsItAndTheHeldLineComesBack() async throws { + try await Self.withFocusedTerminal { t in + try await Self.input("cat\r", t) + try await Self.waitRunning("cat", t) + _ = try await Self.newTerminal(t) // the neighbour the hide shows + t.pane.select(TabID(t.tab.id)) + try await PinnedPageTests.until({ t.pane.stripModel.selectedID?.rawValue == t.tab.id }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + let window = try #require(t.pane.view.window) + try Self.press("w", .command, keyCode: 13, in: window, t) + try await PinnedPageTests.until({ hold.held == 1 }, report: { "the close did not read" }) + let nonce = String(UInt32.random(in: 100_000...999_999)) + for character in "es\(nonce)" { try Self.press(String(character), in: window, t) } + hold.release() + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no close question" }) + try Self.press("\u{1B}", keyCode: 53, in: window, t) + try await PinnedPageTests.until({ Self.question() == nil && !TabCloseGuard.asking.contains(t.tab.id) }, + report: { "Escape did not answer the question: asking \(TabCloseGuard.asking)" }) + #expect(t.pane.orderedIDs.map(\.rawValue).contains(t.tab.id), "Escape closed the terminal running cat") + let back = try? await t.connection.request(WaitForTextRequest(surface: t.tab.surface, pattern: "es\(nonce)", timeoutMs: 10_000), + timeout: .seconds(15)) + #expect(back?.matched == true, "the line held during the read did not come back to the terminal that stays") + } + } + // MARK: Fixture /// A new terminal tab at the end of `t`'s pane, once its strip lists it. From 5e62cc0e995b693222ab0ff64b0e7372e5fdc066 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:12:36 +0000 Subject: [PATCH 06/10] fix(next): a key typed at a close's question answers it; held keys wait for every open question Review of #32291 (High): flush kept the hold while the question showed, so capture took the keys typed at it (Return, Escape): only a click answered. Now capture passes every key on while a close's question is up in the window, and the held keys still wait. asking counts the open questions (Low: a second question in the same window got the keys at the first one's answer); the keys go on once none is up. --- .../Focus/CreationInputCoordinator.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index 6dfbd9d86761..2cbd245071a0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -166,13 +166,13 @@ final class CreationInputCoordinator { machine: controller.content?.daemon.machineID, takesEveryKey: hides) } - /// Keys of closes that ended while a close's question was up, by window: they wait for its answer with its own keys. - private var asking: [Int: [NSEvent]] = [:] + /// By window, while close questions are up (how many): the keys of closes that ended meanwhile, which wait for the answer with its own keys. + private var asking: [Int: (events: [NSEvent], questions: Int)] = [:] /// The close asks the person: its keys wait for the answer outside the hold (the question takes its own keys). func parkClose(_ ticket: CreationInputBuffer.Ticket?, in window: NSWindow?) -> CreationInputBuffer.ParkedClose? { guard let window else { return nil } - if asking[window.windowNumber] == nil { asking[window.windowNumber] = [] } + asking[window.windowNumber, default: (events: [], questions: 0)].questions += 1 guard let ticket, let parked = buffers[window.windowNumber]?.parkClose(ticket) else { return .init(events: [], closing: "", machine: nil) } scheduleFlush(in: window) return parked @@ -183,12 +183,11 @@ final class CreationInputCoordinator { func endClose(_ ticket: CreationInputBuffer.Ticket?, parked: CreationInputBuffer.ParkedClose? = nil, stays: Bool = false, in window: NSWindow?) { guard let window else { return } let number = window.windowNumber - if parked == nil, asking[number] != nil { - if let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.append(contentsOf: more.events); scheduleFlush(in: window) } - return - } - let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking.removeValue(forKey: number) ?? []), closing: $0.closing, machine: $0.machine) } - defer { if parked != nil { scheduleFlush(in: window) } } // the question is answered: the keys the hold kept meanwhile go on + if parked == nil, asking[number] != nil, let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.events.append(contentsOf: more.events) } + if parked == nil, asking[number] != nil { return } // the answer lets these keys go on (``flush(in:force:end:)`` waits for it) + let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking[number]?.events ?? []), closing: $0.closing, machine: $0.machine) } + // The question is answered: the keys the hold kept meanwhile go on, once no other close's question is up. + defer { if parked != nil { asking[number]?.events = []; asking[number]?.questions -= 1; if asking[number]?.questions == 0 { asking[number] = nil }; scheduleFlush(in: window) } } if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) @@ -215,6 +214,7 @@ final class CreationInputCoordinator { let shell = controller?.window ?? window // A held key runs again: never captured twice; a person's claim still keeps one typed for no terminal from the window. if replaying.on { return !replaying.lane && kind == .content && keepsFromWindow(event, in: shell) } + if asking[shell.windowNumber] != nil { return false } // a close's question is up: a key typed now is its own, the held keys wait for its answer // The focus names a terminal whose surface is not shown yet (a reopened window, a workspace shown // cold): the key waits for it, at most `holdLimit`, and goes to that terminal (REOPEN-F4); a second one gets its own lane. if buffers[shell.windowNumber].map({ $0.awaitedTab != nil }) ?? true, kind == .content, let controller, ActionRunScope.viewChangeAllowed(), From 486ef33aaaefc6fccaa5f297dff612cd52987b7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:35:36 +0000 Subject: [PATCH 07/10] test(next): a close that ends while another terminal's question asks keeps its keys for the focused terminal Red at 5e62cc0e995 (review of #32291, Medium): with the running terminal's read ending first, the idle close's keys joined that question's keys, and Cancel typed them into the running terminal. DebugCloseReadHold.releaseNewest (DEBUG and bench builds) lets the test order the two answers. --- .../DebugCloseReadHold.swift | 9 +++++ ...CmdWIdleTerminalClosesInItsTurnTests.swift | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift index 93275ea09fbe..b2f6eece6088 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift @@ -53,6 +53,15 @@ public final class DebugCloseReadHold { return resumed.count } + /// Lets only the newest waiting read go on and keeps the hold armed (a test orders two closes' answers); false when + /// none waits. + @discardableResult + public func releaseNewest() -> Bool { + guard !waiting.isEmpty else { return false } + waiting.removeLast().resume() + return true + } + /// `debug.close.hold` and `debug.close.release`. public var methods: [ControlMethod] { [ diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift index 2d0b9697a97a..340e450b4783 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift @@ -240,6 +240,41 @@ struct CmdWIdleTerminalClosesInItsTurnTests { } } + /// (Review of #32291, the reads in the other order) The running terminal's question shows before the idle terminal's + /// read ends: that close ends while the question asks, and Cancel still types only the question's own keys into the + /// terminal it asked about. The keys typed for the focused neighbour go to it. + @Test func aCloseThatEndsWhileAnotherTerminalAsksKeepsItsKeysForTheFocusedTerminal() async throws { + try await Self.withFocusedTerminal { t in + let neighbour = try await Self.newTerminal(t) + let running = try await Self.newTerminal(t) // runs cat: its close asks + _ = try await t.connection.request(SendInputRequest(surface: running.surface, text: "cat\r")) + try await Self.waitRunning("cat", t, surface: running.surface) + t.pane.select(TabID(t.tab.id)) + let window = try #require(t.pane.view.window) + let focused = { t.services.keyRouter.focus(for: window).0?.focus.state.terminalTab } + try await PinnedPageTests.until({ focused() == t.tab.id }, report: { "the closing terminal never had the focus: \(focused() ?? "none")" }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + try Self.press("w", .command, keyCode: 13, in: window, t) // the idle terminal's Cmd-W: hidden, it reads + try await PinnedPageTests.until({ focused() == neighbour.id && hold.held == 1 }, + report: { "focus \(focused() ?? "none"), neighbour \(neighbour.id), reads held \(hold.held)" }) + let nonce = String(UInt32.random(in: 100_000...999_999)), typed = "rv\(nonce)" + for character in typed { try Self.press(String(character), in: window, t) } + t.pane.handle(.close(TabID(running.id), source: .mouse)) // its close button: the running terminal hides and reads + try await PinnedPageTests.until({ hold.held == 2 }, report: { "the running terminal's close did not read: \(hold.held) held" }) + hold.releaseNewest() // the running terminal answers first: its question shows while the idle one still reads + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no question for the running terminal" }) + hold.release() // the idle terminal's close ends while the question asks + try await PinnedPageTests.until({ !TabCloseGuard.asking.contains(t.tab.id) }, report: { "the idle close never ended: asking \(TabCloseGuard.asking)" }) + _ = CmuxDialogCenter.shared.press(try #require(Self.question()).id, button: "cancel") + let back = try? await t.connection.request(WaitForTextRequest(surface: neighbour.surface, pattern: typed, timeoutMs: 10_000), timeout: .seconds(15)) + let screen = (try? await t.connection.request(ReadScreenRequest(surface: running.surface), timeout: .seconds(5)))?.text ?? "" + #expect(back?.matched == true, "the keys typed for the focused terminal did not reach it: \(back?.text.suffix(300) ?? "no answer")") + #expect(!screen.contains(typed), "Cancel typed another close's keys into the terminal it asked about: \(screen.suffix(300))") + } + } + /// (Review of #32291) While the question shows, the held keys wait for its answer, but a key typed at the question is /// the question's: Escape through the app's key path cancels it, and the line held during the read comes back whole /// to the terminal that stays. From 7ec7b66fba1ae6b93b3b44af5f827f726bbf9db1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:35:36 +0000 Subject: [PATCH 08/10] fix(next): Cancel types only the question's own keys into the terminal that stays Keys of other closes that ended while the question asked go to the focused terminal, as on Close (review of #32291, Medium). A closed window drops its open-question count (review Low). --- .../CmuxNextApp/Focus/CreationInputCoordinator.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index 2cbd245071a0..9b9aee0ee351 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -72,7 +72,7 @@ final class CreationInputCoordinator { } /// `window` closes: its claim goes with it. - func windowClosed(_ window: NSWindow?) { if let window { claims.end(inWindow: window.windowNumber) } } + func windowClosed(_ window: NSWindow?) { if let window { claims.end(inWindow: window.windowNumber); asking[window.windowNumber] = nil } } // its questions go with it /// Whether `window`'s claim holds now (``CreationKeyboardClaims/holds(in:shows:intents:lastInput:)``). private func claimHolds(in window: NSWindow) -> Bool { @@ -185,13 +185,12 @@ final class CreationInputCoordinator { let number = window.windowNumber if parked == nil, asking[number] != nil, let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.events.append(contentsOf: more.events) } if parked == nil, asking[number] != nil { return } // the answer lets these keys go on (``flush(in:force:end:)`` waits for it) - let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking[number]?.events ?? []), closing: $0.closing, machine: $0.machine) } // The question is answered: the keys the hold kept meanwhile go on, once no other close's question is up. defer { if parked != nil { asking[number]?.events = []; asking[number]?.questions -= 1; if asking[number]?.questions == 0 { asking[number] = nil }; scheduleFlush(in: window) } } - if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { - let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) - return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) - } + // Its own keys go to the terminal that stays; other closes' keys (ended while it asked) where theirs send them, never into it. + let others: [NSEvent] = parked == nil ? [] : asking[number]?.events ?? [], keeps = stays && parked?.closing.isEmpty == false + if keeps, let own = parked, !own.events.isEmpty { _ = runAgain(CreationInputBuffer.heldKeys(own.events, for: .init(tab: own.closing, machine: own.machine)), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) } + let parked = keeps ? (others.isEmpty ? nil : CreationInputBuffer.ParkedClose(events: others, closing: "", machine: nil)) : parked.map { CreationInputBuffer.ParkedClose(events: $0.events + others, closing: $0.closing, machine: $0.machine) } let controller = router?.focus(for: window).0 guard let ticket = ticket ?? parked.flatMap({ $0.events.isEmpty ? nil : buffer(in: window).openClose(closing: [$0.closing], machine: $0.machine) }) else { return } buffers[number]?.settleClose(ticket, parked: parked, keysTo: controller?.focus.state.terminalTab, machine: controller?.content?.daemon.machineID) From f84ba242c121a8f3b33d88317bc093708e8ceb46 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:46:22 +0000 Subject: [PATCH 09/10] revert(next): take back the split of own and other keys on Cancel (7ec7b66) The reverse-order test passes without it (step 2615afe3093a at 486ef33): after the first fix, a close of a terminal that does not have the focus opens no lane, so its parked close names no terminal, and Cancel sends every waiting key to the focused terminal. The split changed no observable case, so endClose is again as at 5e62cc0e995. The window-close cleanup of the open-question count stays. --- .../CmuxNextApp/Focus/CreationInputCoordinator.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index 9b9aee0ee351..6948a2c11849 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -185,12 +185,13 @@ final class CreationInputCoordinator { let number = window.windowNumber if parked == nil, asking[number] != nil, let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.events.append(contentsOf: more.events) } if parked == nil, asking[number] != nil { return } // the answer lets these keys go on (``flush(in:force:end:)`` waits for it) + let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking[number]?.events ?? []), closing: $0.closing, machine: $0.machine) } // The question is answered: the keys the hold kept meanwhile go on, once no other close's question is up. defer { if parked != nil { asking[number]?.events = []; asking[number]?.questions -= 1; if asking[number]?.questions == 0 { asking[number] = nil }; scheduleFlush(in: window) } } - // Its own keys go to the terminal that stays; other closes' keys (ended while it asked) where theirs send them, never into it. - let others: [NSEvent] = parked == nil ? [] : asking[number]?.events ?? [], keeps = stays && parked?.closing.isEmpty == false - if keeps, let own = parked, !own.events.isEmpty { _ = runAgain(CreationInputBuffer.heldKeys(own.events, for: .init(tab: own.closing, machine: own.machine)), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) } - let parked = keeps ? (others.isEmpty ? nil : CreationInputBuffer.ParkedClose(events: others, closing: "", machine: nil)) : parked.map { CreationInputBuffer.ParkedClose(events: $0.events + others, closing: $0.closing, machine: $0.machine) } + if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { + let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) + return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) + } let controller = router?.focus(for: window).0 guard let ticket = ticket ?? parked.flatMap({ $0.events.isEmpty ? nil : buffer(in: window).openClose(closing: [$0.closing], machine: $0.machine) }) else { return } buffers[number]?.settleClose(ticket, parked: parked, keysTo: controller?.focus.state.terminalTab, machine: controller?.content?.daemon.machineID) From 47bc755d110bc2a2890066c254fc28d328aa4af0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:47:10 +0000 Subject: [PATCH 10/10] style(next): endClose's Cancel branch names its lane inline (no App line growth over 7ec7b66) --- .../Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index 6948a2c11849..0a5fe3dd8213 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -188,9 +188,8 @@ final class CreationInputCoordinator { let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking[number]?.events ?? []), closing: $0.closing, machine: $0.machine) } // The question is answered: the keys the hold kept meanwhile go on, once no other close's question is up. defer { if parked != nil { asking[number]?.events = []; asking[number]?.questions -= 1; if asking[number]?.questions == 0 { asking[number] = nil }; scheduleFlush(in: window) } } - if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { - let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) - return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) + if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { // Cancel: the keys go to the terminal that stays + return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine)), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) } let controller = router?.focus(for: window).0 guard let ticket = ticket ?? parked.flatMap({ $0.events.isEmpty ? nil : buffer(in: window).openClose(closing: [$0.closing], machine: $0.machine) }) else { return }