From 7c6ff0c5dd229cdf9e9a1fc43515fd980fb95508 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 15 Oct 2026 14:43:49 -0400 Subject: [PATCH] fix: name the unmatched menu title and its siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu command whose title had no literal match refused with "The literal menu path is unavailable without opening ancestor menus", which blamed lazy menus when the title simply differed (githubnext/ace#427: "Save" for TextEdit's "Save…"). The refusal now names the component, its level, and up to 30 sibling titles (300 characters) already read, and suggests a lone sibling that only adds a trailing ellipsis. The ambiguous refusal also names the component. Matching, budgets, roles and refusal kind are unchanged. Why: native/patches/peekaboo-menu.patch | Menu titles match exact UTF-16; the refusal may suggest a near title but resolution must never accept one. --- native/patches/README.md | 2 ++ native/patches/peekaboo-menu.patch | 36 +++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/native/patches/README.md b/native/patches/README.md index a9a4b09..79eaed5 100644 --- a/native/patches/README.md +++ b/native/patches/README.md @@ -153,6 +153,8 @@ this accepted outcome. operation carries the exact process generation and title array, avoiding the existing String API's splitting, fuzzy normalization and intermediate presses. Fresh bounded raw AX traversal requires a unique path and supported enabled leaf before one AXPress; lazy missing paths refuse. +Titles match exact UTF-16, including ellipses. A title with no match refuses with its path level, +the bounded sibling titles already read, and a lone sibling that only adds a trailing ellipsis. The whole native operation runs off MainActor while retaining the existing process write lane until actual return, with cancellation checked before dispatch. Per-element AX messaging timeouts do not race or abandon native work. `cannotComplete` and other ambiguous delivery failures remain diff --git a/native/patches/peekaboo-menu.patch b/native/patches/peekaboo-menu.patch index c1e9188..83f1eb8 100644 --- a/native/patches/peekaboo-menu.patch +++ b/native/patches/peekaboo-menu.patch @@ -1,9 +1,9 @@ diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/MenuCommand.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/MenuCommand.swift new file mode 100644 -index 000000000..094ee51ee +index 000000000..5954f209d --- /dev/null +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/MenuCommand.swift -@@ -0,0 +1,285 @@ +@@ -0,0 +1,315 @@ +import ApplicationServices +import Foundation +import os @@ -258,6 +258,7 @@ index 000000000..094ee51ee + var chain: [AXUIElement] = [menu] + for (index, title) in self.request.path.enumerated() { + var matches: [AXUIElement] = [] ++ var candidates: [String] = [] + for child in try self.children(menu) { + let role = try self.role(child) + // Separators and known non-item roles cannot compete for this literal title. @@ -267,12 +268,13 @@ index 000000000..094ee51ee + guard let candidate = rawValue as? String else { + throw self.refusal("A candidate menu title is unavailable, so its uniqueness cannot be established.") + } ++ candidates.append(candidate) + if candidate.utf16.elementsEqual(title.utf16) { matches.append(child) } + } + guard matches.count == 1, let item = matches.first else { + throw DesktopActionFailure.preDispatchRefusal( + reason: .invalidRequest, -+ message: matches.isEmpty ? "The literal menu path is unavailable without opening ancestor menus." : "The literal menu path is ambiguous.") ++ message: self.mismatch(at: index, matches: matches.count, candidates: candidates)) + } + chain.append(item) + if index == self.request.path.count - 1 { return chain } @@ -288,6 +290,34 @@ index 000000000..094ee51ee + } + throw self.refusal("The literal menu path contains no command.") + } ++ ++ /// Names the failed component and the sibling titles already read; matching itself stays literal. ++ func mismatch(at index: Int, matches: Int, candidates: [String]) -> String { ++ let title = self.request.path[index] ++ let place = index == 0 ? "in the menu bar" : "under \(String(reflecting: self.request.path[index - 1]))" ++ let level = "level \(index + 1) of \(self.request.path.count)" ++ if matches > 1 { ++ return "The literal menu path is ambiguous: \(matches) items titled \(String(reflecting: title)) \(place) (\(level))." ++ } ++ var message = "No menu item titled \(String(reflecting: title)) \(place) (\(level))" ++ let near = candidates.filter { ++ $0.utf16.elementsEqual((title + "\u{2026}").utf16) || $0.utf16.elementsEqual((title + "...").utf16) ++ } ++ message += near.count == 1 ? "; did you mean \(String(reflecting: near[0]))?" : "." ++ message += " Titles must match exactly." ++ let titles = candidates.filter { !$0.isEmpty }.map { String(reflecting: $0) } ++ guard !titles.isEmpty else { return message + " No titled items were read \(place)." } ++ var listed: [String] = [] ++ var length = 0 ++ for quoted in titles { ++ guard listed.count < 30, length + quoted.count <= 300 else { break } ++ listed.append(quoted) ++ length += quoted.count + 2 ++ } ++ if listed.isEmpty { return message + " The \(titles.count) titles \(place) are too long to list." } ++ let rest = titles.count - listed.count ++ return message + " Titles \(place): \(listed.joined(separator: ", "))" + (rest > 0 ? ", and \(rest) more." : ".") ++ } +} diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeMenuCommand.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeMenuCommand.swift new file mode 100644