Skip to content

Commit 9114520

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. Normalize KakaoTalk windows to a readable minimum size before scanning/opening so chat_id reads do not depend on a tiny or unrelated focused search window, while preserving existing larger windows and restoring the previous window position after automatic resizing. Add an opt-in read --layout mode for operator-controlled placement. The default preserve mode keeps the current location, while left/right layout uses AX frame control against the current display bounds instead of Mission Control or Split View automation. Constraint: KakaoTalk search fields can remain focused in unrelated small windows and fail AX focus verification. Constraint: Automatic resizing must avoid shrinking user-sized windows or moving them to a surprising screen position unless the operator explicitly asks for layout. Rejected: Only document FOCUS_FAIL recovery | leaves chat_id reads dependent on the same failing search path. Rejected: Always use global search for chat_id | wastes the stronger identity from the chat list registry and keeps the focus failure mode. Rejected: Drive Mission Control or Split View | too disruptive and not exposed as a stable public automation surface. Rejected: Clamp every chat window to a fixed maximum by default | would shrink user-sized windows and create avoidable disruption. Confidence: medium Scope-risk: moderate Tested: swift build; .build/debug/kmsg read --help; git diff --check Not-tested: live read --chat-id completion; local debug binary previously hung in auth/window bootstrap before returning output. swift test unavailable because the package has no Tests target. Python tests unavailable because pytest is not installed.
1 parent bf301b9 commit 9114520

5 files changed

Lines changed: 301 additions & 20 deletions

File tree

Sources/kmsg/Accessibility/UIElement.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,27 @@ public final class UIElement: @unchecked Sendable {
150150
return CGRect(origin: pos, size: size)
151151
}
152152

153+
public func setPosition(_ point: CGPoint) throws {
154+
var point = point
155+
guard let value = AXValueCreate(.cgPoint, &point) else {
156+
throw AccessibilityError.typeMismatch
157+
}
158+
try setAttribute(kAXPositionAttribute, value: value)
159+
}
160+
161+
public func setSize(_ size: CGSize) throws {
162+
var size = size
163+
guard let value = AXValueCreate(.cgSize, &size) else {
164+
throw AccessibilityError.typeMismatch
165+
}
166+
try setAttribute(kAXSizeAttribute, value: value)
167+
}
168+
169+
public func setFrame(_ frame: CGRect) throws {
170+
try setPosition(frame.origin)
171+
try setSize(frame.size)
172+
}
173+
153174
// MARK: - Hierarchy
154175

155176
public var parent: UIElement? {

Sources/kmsg/Commands/ReadCommand.swift

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import ArgumentParser
22
import Foundation
33

4+
extension ChatWindowLayoutMode: ExpressibleByArgument {}
5+
46
struct ReadCommand: ParsableCommand {
57
private struct ReadJSONResponse: Encodable {
68
let chat: String
@@ -19,11 +21,20 @@ struct ReadCommand: ParsableCommand {
1921
static let configuration = CommandConfiguration(
2022
commandName: "read",
2123
abstract: "Read messages from a chat",
22-
discussion: "When author is \"(me)\", the message was sent by you."
24+
discussion: """
25+
Use either:
26+
kmsg read <chat>
27+
kmsg read --chat-id <chat-id>
28+
29+
When author is "(me)", the message was sent by you.
30+
"""
2331
)
2432

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

2839
@Option(name: .shortAndLong, help: "Maximum number of messages to show")
2940
var limit: Int = 20
@@ -46,9 +57,25 @@ struct ReadCommand: ParsableCommand {
4657
)
4758
var deepRecovery: Bool = false
4859

60+
@Option(name: .long, help: "Window layout before reading: preserve, left, or right")
61+
var layout: ChatWindowLayoutMode = .preserve
62+
4963
@Flag(name: .long, help: "Output in JSON format")
5064
var json: Bool = false
5165

66+
func validate() throws {
67+
if let chatID, !chatID.isEmpty {
68+
guard chat == nil else {
69+
throw ValidationError("Chat name cannot be provided together with --chat-id.")
70+
}
71+
return
72+
}
73+
74+
guard let chat, !chat.isEmpty else {
75+
throw ValidationError("Chat name is required unless --chat-id is provided.")
76+
}
77+
}
78+
5279
func run() throws {
5380
guard AccessibilityPermission.ensureGranted() else {
5481
AccessibilityPermission.printInstructions()
@@ -60,15 +87,24 @@ struct ReadCommand: ParsableCommand {
6087
let chatWindowResolver = ChatWindowResolver(
6188
kakao: kakao,
6289
runner: runner,
63-
deepRecoveryEnabled: deepRecovery
90+
deepRecoveryEnabled: deepRecovery,
91+
layoutMode: layout
6492
)
6593
let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner)
6694

6795
let resolution: ChatWindowResolution
96+
let requestedChat: String
6897
do {
69-
resolution = try chatWindowResolver.resolve(query: chat)
98+
if let chatID {
99+
requestedChat = chatID
100+
resolution = try chatWindowResolver.resolve(chatID: chatID)
101+
} else {
102+
let chat = chat ?? ""
103+
requestedChat = chat
104+
resolution = try chatWindowResolver.resolve(query: chat)
105+
}
70106
} catch {
71-
print("No chat window found for '\(chat)'")
107+
print("No chat window found for '\(requestedChat)'")
72108
print("Reason: \(error)")
73109
print("\nAvailable windows:")
74110
for (index, window) in kakao.windows.enumerated() {
@@ -80,6 +116,11 @@ struct ReadCommand: ParsableCommand {
80116
let window = resolution.window
81117
if resolution.openedViaSearch {
82118
runner.log("read: opening chat via search")
119+
} else if resolution.method == .openedViaChatList {
120+
runner.log("read: opening chat via chat list")
121+
}
122+
123+
if resolution.openedTransiently {
83124
if keepWindow {
84125
runner.log("read: keep-window enabled; auto-opened window will be kept")
85126
} else {
@@ -90,16 +131,16 @@ struct ReadCommand: ParsableCommand {
90131
}
91132

92133
defer {
93-
if resolution.openedViaSearch && !keepWindow {
134+
if resolution.openedTransiently && !keepWindow {
94135
let resolvedTitle = window.title ?? ""
95-
if !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(chat) {
136+
if chatID == nil && !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(requestedChat) {
96137
runner.log("read: skipped auto-close because resolved title '\(resolvedTitle)' did not match query")
97138
} else if chatWindowResolver.closeWindow(window) {
98139
runner.log("read: auto-opened chat window closed")
99140
} else {
100141
runner.log("read: failed to close auto-opened chat window")
101142
}
102-
} else if resolution.openedViaSearch && keepWindow {
143+
} else if resolution.openedTransiently && keepWindow {
103144
runner.log("read: auto-opened chat window kept by --keep-window")
104145
}
105146
}
@@ -108,7 +149,7 @@ struct ReadCommand: ParsableCommand {
108149
do {
109150
snapshot = try transcriptReader.readSnapshot(
110151
from: window,
111-
fallbackChatTitle: chat,
152+
fallbackChatTitle: window.title ?? requestedChat,
112153
limit: limit
113154
)
114155
} 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(

0 commit comments

Comments
 (0)