Skip to content

Commit 45ea8c6

Browse files
committed
fix(read): avoid search focus for chat_id opens
chat_id lookups already come from the chat list registry, so prefer the visible chat-list row before falling back to KakaoTalk global search. This lets read and send avoid the brittle search-field focus path when the target room is already visible in the chat list. Constraint: KakaoTalk search fields can remain focused in unrelated small windows and fail AX focus verification. Rejected: Only document FOCUS_FAIL recovery | leaves chat_id reads dependent on the same failing search path. Confidence: medium Scope-risk: moderate Tested: swift build; .build/debug/kmsg read --help Not-tested: live read --chat-id completion; local debug binary hung in auth/window bootstrap before returning output. Python tests unavailable because pytest is not installed.
1 parent bf301b9 commit 45ea8c6

4 files changed

Lines changed: 130 additions & 18 deletions

File tree

Sources/kmsg/Commands/ReadCommand.swift

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ struct ReadCommand: ParsableCommand {
1919
static let configuration = CommandConfiguration(
2020
commandName: "read",
2121
abstract: "Read messages from a chat",
22-
discussion: "When author is \"(me)\", the message was sent by you."
22+
discussion: """
23+
Use either:
24+
kmsg read <chat>
25+
kmsg read --chat-id <chat-id>
26+
27+
When author is "(me)", the message was sent by you.
28+
"""
2329
)
2430

31+
@Option(name: .long, help: "Read using a chat_id from 'kmsg chats'")
32+
var chatID: String?
33+
2534
@Argument(help: "Name of the chat to read from (partial match supported)")
26-
var chat: String
35+
var chat: String?
2736

2837
@Option(name: .shortAndLong, help: "Maximum number of messages to show")
2938
var limit: Int = 20
@@ -49,6 +58,19 @@ struct ReadCommand: ParsableCommand {
4958
@Flag(name: .long, help: "Output in JSON format")
5059
var json: Bool = false
5160

61+
func validate() throws {
62+
if let chatID, !chatID.isEmpty {
63+
guard chat == nil else {
64+
throw ValidationError("Chat name cannot be provided together with --chat-id.")
65+
}
66+
return
67+
}
68+
69+
guard let chat, !chat.isEmpty else {
70+
throw ValidationError("Chat name is required unless --chat-id is provided.")
71+
}
72+
}
73+
5274
func run() throws {
5375
guard AccessibilityPermission.ensureGranted() else {
5476
AccessibilityPermission.printInstructions()
@@ -65,10 +87,18 @@ struct ReadCommand: ParsableCommand {
6587
let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner)
6688

6789
let resolution: ChatWindowResolution
90+
let requestedChat: String
6891
do {
69-
resolution = try chatWindowResolver.resolve(query: chat)
92+
if let chatID {
93+
requestedChat = chatID
94+
resolution = try chatWindowResolver.resolve(chatID: chatID)
95+
} else {
96+
let chat = chat ?? ""
97+
requestedChat = chat
98+
resolution = try chatWindowResolver.resolve(query: chat)
99+
}
70100
} catch {
71-
print("No chat window found for '\(chat)'")
101+
print("No chat window found for '\(requestedChat)'")
72102
print("Reason: \(error)")
73103
print("\nAvailable windows:")
74104
for (index, window) in kakao.windows.enumerated() {
@@ -80,6 +110,11 @@ struct ReadCommand: ParsableCommand {
80110
let window = resolution.window
81111
if resolution.openedViaSearch {
82112
runner.log("read: opening chat via search")
113+
} else if resolution.method == .openedViaChatList {
114+
runner.log("read: opening chat via chat list")
115+
}
116+
117+
if resolution.openedTransiently {
83118
if keepWindow {
84119
runner.log("read: keep-window enabled; auto-opened window will be kept")
85120
} else {
@@ -90,16 +125,16 @@ struct ReadCommand: ParsableCommand {
90125
}
91126

92127
defer {
93-
if resolution.openedViaSearch && !keepWindow {
128+
if resolution.openedTransiently && !keepWindow {
94129
let resolvedTitle = window.title ?? ""
95-
if !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(chat) {
130+
if chatID == nil && !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(requestedChat) {
96131
runner.log("read: skipped auto-close because resolved title '\(resolvedTitle)' did not match query")
97132
} else if chatWindowResolver.closeWindow(window) {
98133
runner.log("read: auto-opened chat window closed")
99134
} else {
100135
runner.log("read: failed to close auto-opened chat window")
101136
}
102-
} else if resolution.openedViaSearch && keepWindow {
137+
} else if resolution.openedTransiently && keepWindow {
103138
runner.log("read: auto-opened chat window kept by --keep-window")
104139
}
105140
}
@@ -108,7 +143,7 @@ struct ReadCommand: ParsableCommand {
108143
do {
109144
snapshot = try transcriptReader.readSnapshot(
110145
from: window,
111-
fallbackChatTitle: chat,
146+
fallbackChatTitle: window.title ?? requestedChat,
112147
limit: limit
113148
)
114149
} catch TranscriptReadError.transcriptContextUnavailable {

Sources/kmsg/Commands/SendCommand.swift

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,18 @@ struct SendCommand: ParsableCommand {
129129
runner.log("window strategy: focusedWindow -> mainWindow -> windows.first")
130130
let resolution: ChatWindowResolution
131131
if let chatID {
132-
guard let record = ChatIdentityRegistryStore.shared.record(for: chatID) else {
133-
throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.")
134-
}
135132
print("Looking for chat with \(targetDescription)...")
136-
print("Resolved \(targetDescription) to '\(record.displayName)'.")
137-
resolution = try chatWindowResolver.resolve(query: record.displayName)
138-
if resolution.openedViaSearch {
139-
print("No existing chat window. Opening via search...")
133+
resolution = try chatWindowResolver.resolve(chatID: chatID)
134+
if resolution.openedTransiently {
135+
print("No existing chat window. Opening via chat list or search...")
140136
} else {
141137
print("Found existing chat window.")
142138
}
143139
} else {
144140
let recipient = recipient ?? ""
145141
print("Looking for chat with \(targetDescription)...")
146142
resolution = try chatWindowResolver.resolve(query: recipient)
147-
if resolution.openedViaSearch {
143+
if resolution.openedTransiently {
148144
print("No existing chat window. Opening via search...")
149145
} else {
150146
print("Found existing chat window.")

Sources/kmsg/Commands/WatchCommand.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ struct WatchCommand: ParsableCommand {
100100

101101
var currentWindow = resolution.window
102102
var currentChatTitle = currentWindow.title ?? chat
103-
var autoOpenedWindow: UIElement? = resolution.openedViaSearch ? currentWindow : nil
103+
var autoOpenedWindow: UIElement? = resolution.openedTransiently ? currentWindow : nil
104104
var cachedContext: MessageTranscriptContext?
105105

106106
defer {
@@ -224,7 +224,7 @@ struct WatchCommand: ParsableCommand {
224224
currentWindow = resolution.window
225225
currentChatTitle = currentWindow.title ?? chat
226226
cachedContext = nil
227-
if resolution.openedViaSearch {
227+
if resolution.openedTransiently {
228228
autoOpenedWindow = currentWindow
229229
}
230230
return try stabilizeBaseline(

Sources/kmsg/KakaoTalk/ChatWindowResolver.swift

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Foundation
33

44
enum ChatWindowResolutionMethod {
55
case existingWindow
6+
case openedViaChatList
67
case openedViaSearch
78
}
89

@@ -13,6 +14,10 @@ struct ChatWindowResolution {
1314
var openedViaSearch: Bool {
1415
method == .openedViaSearch
1516
}
17+
18+
var openedTransiently: Bool {
19+
method != .existingWindow
20+
}
1621
}
1722

1823
private enum ChatWindowFailureCode: String {
@@ -72,6 +77,30 @@ struct ChatWindowResolver {
7277
return ChatWindowResolution(window: chatWindow, method: .openedViaSearch)
7378
}
7479

80+
func resolve(chatID: String) throws -> ChatWindowResolution {
81+
guard let record = ChatIdentityRegistryStore.shared.record(for: chatID) else {
82+
throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.")
83+
}
84+
85+
let usableWindow = try requireUsableWindow()
86+
let query = record.displayName
87+
88+
if let existingWindow = findMatchingChatWindow(in: kakao.windows, query: query) {
89+
return ChatWindowResolution(window: existingWindow, method: .existingWindow)
90+
}
91+
92+
if let chatListWindow = kakao.chatListWindow,
93+
let chatWindow = openChatListRow(chatID: chatID, query: query, in: chatListWindow, fallbackWindow: usableWindow)
94+
{
95+
return ChatWindowResolution(window: chatWindow, method: .openedViaChatList)
96+
}
97+
98+
runner.log("chat_id: falling back to search for '\(query)'")
99+
let searchWindow = selectSearchWindow(fallback: usableWindow)
100+
let chatWindow = try openChatViaSearch(query: query, in: searchWindow, fallbackWindow: usableWindow)
101+
return ChatWindowResolution(window: chatWindow, method: .openedViaSearch)
102+
}
103+
75104
@discardableResult
76105
func closeWindow(_ window: UIElement) -> Bool {
77106
let closeAction = "AXClose"
@@ -229,6 +258,58 @@ struct ChatWindowResolver {
229258
throw KakaoTalkError.windowNotFound("[\(ChatWindowFailureCode.windowNotReady.rawValue)] Chat window for '\(query)' did not open")
230259
}
231260

261+
private func openChatListRow(chatID: String, query: String, in chatListWindow: UIElement, fallbackWindow: UIElement) -> UIElement? {
262+
runner.log("chat_id: scanning chat list rows")
263+
let scanner = ChatListScanner()
264+
let snapshots = scanner.scan(in: chatListWindow, limit: 200, trace: { message in
265+
runner.log(message)
266+
})
267+
guard !snapshots.isEmpty else {
268+
runner.log("chat_id: chat list scan returned no rows")
269+
return nil
270+
}
271+
272+
let registry = ChatIdentityRegistryStore.shared
273+
let assignedIDs = registry.assignChatIDs(for: snapshots.map(\.discovery))
274+
guard let matchIndex = assignedIDs.firstIndex(of: chatID) else {
275+
runner.log("chat_id: no visible chat row matched \(chatID)")
276+
return nil
277+
}
278+
279+
let row = snapshots[matchIndex].element
280+
runner.log("chat_id: matched row title='\(snapshots[matchIndex].discovery.title)'")
281+
kakao.activate()
282+
_ = tryRaiseWindow(chatListWindow)
283+
284+
if triggerChatListRowOpen(row) {
285+
if let window = waitForOpenedChatWindow(query: query, fallbackWindow: fallbackWindow) {
286+
return window
287+
}
288+
}
289+
290+
runner.log("chat_id: matched row did not open a chat window")
291+
return nil
292+
}
293+
294+
private func triggerChatListRowOpen(_ row: UIElement) -> Bool {
295+
if tryActivateSearchResult(row, label: "chat list row") {
296+
return true
297+
}
298+
299+
let selected = trySelectSearchResult(row, label: "chat list row")
300+
if !selected, let parent = row.parent, trySelectSearchResult(parent, label: "chat list row.parent") {
301+
runner.pressEnterKey()
302+
return true
303+
}
304+
305+
if selected {
306+
runner.pressEnterKey()
307+
return true
308+
}
309+
310+
return false
311+
}
312+
232313
private func resolveCachedElement(
233314
slot: AXPathSlot,
234315
root: UIElement,

0 commit comments

Comments
 (0)