/ clis / chatgpt-app / ax.js
ax.js
  1  import { execFileSync, execSync } from 'node:child_process';
  2  const AX_READ_SCRIPT = `
  3  import Cocoa
  4  import ApplicationServices
  5  
  6  func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
  7      var value: CFTypeRef?
  8      guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
  9      return value as AnyObject?
 10  }
 11  
 12  func s(_ el: AXUIElement, _ name: String) -> String? {
 13      if let v = attr(el, name) as? String, !v.isEmpty { return v }
 14      return nil
 15  }
 16  
 17  func children(_ el: AXUIElement) -> [AXUIElement] {
 18      (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
 19  }
 20  
 21  func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) {
 22      let role = s(el, kAXRoleAttribute as String) ?? ""
 23      if role == kAXListRole as String { out.append(el) }
 24      for c in children(el) { collectLists(c, into: &out) }
 25  }
 26  
 27  func collectTexts(_ el: AXUIElement, into out: inout [String]) {
 28      let role = s(el, kAXRoleAttribute as String) ?? ""
 29      if role == kAXStaticTextRole as String {
 30          if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty {
 31              out.append(text)
 32          }
 33      }
 34      for c in children(el) { collectTexts(c, into: &out) }
 35  }
 36  
 37  guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
 38      fputs("ChatGPT not running\\n", stderr)
 39      exit(1)
 40  }
 41  
 42  let axApp = AXUIElementCreateApplication(app.processIdentifier)
 43  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
 44      fputs("No focused ChatGPT window\\n", stderr)
 45      exit(1)
 46  }
 47  
 48  var lists: [AXUIElement] = []
 49  collectLists(win, into: &lists)
 50  
 51  var best: [String] = []
 52  for list in lists {
 53      var texts: [String] = []
 54      collectTexts(list, into: &texts)
 55      if texts.count > best.count {
 56          best = texts
 57      }
 58  }
 59  
 60  let data = try! JSONSerialization.data(withJSONObject: best, options: [])
 61  print(String(data: data, encoding: .utf8)!)
 62  `;
 63  const AX_SEND_SCRIPT = `
 64  import Cocoa
 65  import ApplicationServices
 66  
 67  func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
 68      var value: CFTypeRef?
 69      guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
 70      return value as AnyObject?
 71  }
 72  
 73  func s(_ el: AXUIElement, _ name: String) -> String? {
 74      if let v = attr(el, name) as? String { return v }
 75      return nil
 76  }
 77  
 78  func isEnabled(_ el: AXUIElement) -> Bool {
 79      (attr(el, kAXEnabledAttribute as String) as? Bool) ?? true
 80  }
 81  
 82  func children(_ el: AXUIElement) -> [AXUIElement] {
 83      (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
 84  }
 85  
 86  func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) {
 87      guard depth < 25 else { return }
 88      let role = s(el, kAXRoleAttribute as String) ?? ""
 89      if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) {
 90          out.append(el)
 91      }
 92      for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) }
 93  }
 94  
 95  func isInput(_ el: AXUIElement) -> Bool {
 96      let role = s(el, kAXRoleAttribute as String) ?? ""
 97      return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String
 98  }
 99  
100  func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
101      guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
102          return nil
103      }
104      return isInput(focused) && isEnabled(focused) ? focused : nil
105  }
106  
107  func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
108      guard depth < 25 else { return nil }
109      let role = s(el, kAXRoleAttribute as String) ?? ""
110      let desc = s(el, kAXDescriptionAttribute as String) ?? ""
111      if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el }
112      for c in children(el) {
113          if let found = findByDescriptions(c, targets, depth: depth + 1) { return found }
114      }
115      return nil
116  }
117  
118  func press(_ el: AXUIElement) {
119      AXUIElementPerformAction(el, kAXPressAction as CFString)
120  }
121  
122  let args = CommandLine.arguments
123  guard args.count > 1 else {
124      fputs("Missing prompt text\\n", stderr)
125      exit(1)
126  }
127  let text = args[1]
128  
129  guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
130      fputs("ChatGPT not running\\n", stderr)
131      exit(1)
132  }
133  
134  let axApp = AXUIElementCreateApplication(app.processIdentifier)
135  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
136      fputs("No focused ChatGPT window\\n", stderr)
137      exit(1)
138  }
139  
140  var inputs: [AXUIElement] = []
141  collectEditableInputs(win, into: &inputs)
142  guard let input = focusedInput(axApp) ?? inputs.last else {
143      fputs("Could not find editable input area\\n", stderr)
144      exit(1)
145  }
146  
147  guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else {
148      fputs("Failed to set input value\\n", stderr)
149      exit(1)
150  }
151  
152  Thread.sleep(forTimeInterval: 0.2)
153  
154  guard s(input, kAXValueAttribute as String) == text else {
155      fputs("Failed to verify input value after AX set\\n", stderr)
156      exit(1)
157  }
158  
159  guard let sendButton = findByDescriptions(win, ["发送", "Send"]) else {
160      fputs("Could not find send button\\n", stderr)
161      exit(1)
162  }
163  
164  press(sendButton)
165  
166  var submitted = false
167  for _ in 0..<15 {
168      Thread.sleep(forTimeInterval: 0.1)
169      if s(input, kAXValueAttribute as String) != text {
170          submitted = true
171          break
172      }
173  }
174  
175  guard submitted else {
176      fputs("Prompt did not leave input after pressing send\\n", stderr)
177      exit(1)
178  }
179  
180  print("Sent")
181  `;
182  const AX_MODEL_SCRIPT = `
183  import Cocoa
184  import ApplicationServices
185  
186  func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
187      var value: CFTypeRef?
188      guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
189      return value as AnyObject?
190  }
191  
192  func s(_ el: AXUIElement, _ name: String) -> String? {
193      if let v = attr(el, name) as? String, !v.isEmpty { return v }
194      return nil
195  }
196  
197  func children(_ el: AXUIElement) -> [AXUIElement] {
198      (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
199  }
200  
201  func press(_ el: AXUIElement) {
202      AXUIElementPerformAction(el, kAXPressAction as CFString)
203  }
204  
205  func findByDesc(_ el: AXUIElement, _ target: String, prefix: Bool = false, depth: Int = 0) -> AXUIElement? {
206      guard depth < 20 else { return nil }
207      let desc = s(el, kAXDescriptionAttribute as String) ?? ""
208      if prefix ? desc.hasPrefix(target) : (desc == target) { return el }
209      for c in children(el) {
210          if let found = findByDesc(c, target, prefix: prefix, depth: depth + 1) { return found }
211      }
212      return nil
213  }
214  
215  func findPopover(_ el: AXUIElement, depth: Int = 0) -> AXUIElement? {
216      guard depth < 20 else { return nil }
217      let role = s(el, kAXRoleAttribute as String) ?? ""
218      if role == "AXPopover" { return el }
219      for c in children(el) {
220          if let found = findPopover(c, depth: depth + 1) { return found }
221      }
222      return nil
223  }
224  
225  func pressEscape() {
226      let src = CGEventSource(stateID: .combinedSessionState)
227      if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: true) { esc.post(tap: .cghidEventTap) }
228      if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: false) { esc.post(tap: .cghidEventTap) }
229  }
230  
231  guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
232      fputs("ChatGPT not running\\n", stderr); exit(1)
233  }
234  let axApp = AXUIElementCreateApplication(app.processIdentifier)
235  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
236      fputs("No focused ChatGPT window\\n", stderr); exit(1)
237  }
238  
239  let args = CommandLine.arguments
240  let target = args.count > 1 ? args[1] : ""
241  let needsLegacy = args.count > 2 && args[2] == "legacy"
242  
243  // Step 1: Click the "Options" button to open the popover (support both English and Chinese UI)
244  var optionsBtn: AXUIElement? = nil
245  if let btn = findByDesc(win, "Options") { optionsBtn = btn }
246  else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
247  guard let options = optionsBtn else {
248      fputs("Could not find Options button\\n", stderr); exit(1)
249  }
250  press(options)
251  Thread.sleep(forTimeInterval: 0.8)
252  
253  // Step 2: Find the popover that appeared, search ONLY within it
254  guard let popover = findPopover(win) else {
255      pressEscape()
256      fputs("Popover did not appear\\n", stderr); exit(1)
257  }
258  
259  // Step 3: If legacy, click "Legacy models" to expand submenu
260  if needsLegacy {
261      guard let legacyBtn = findByDesc(popover, "Legacy models") else {
262          pressEscape()
263          fputs("Could not find Legacy models button\\n", stderr); exit(1)
264      }
265      press(legacyBtn)
266      Thread.sleep(forTimeInterval: 0.8)
267  }
268  
269  // Step 4: Click the target model button within the popover (prefix match)
270  guard let modelBtn = findByDesc(popover, target, prefix: true) else {
271      pressEscape()
272      fputs("Could not find button starting with '\\(target)' in popover\\n", stderr); exit(1)
273  }
274  press(modelBtn)
275  print("Selected: \\(target)")
276  `;
277  const AX_GENERATING_SCRIPT = `
278  import Cocoa
279  import ApplicationServices
280  
281  func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
282      var value: CFTypeRef?
283      guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
284      return value as AnyObject?
285  }
286  
287  func s(_ el: AXUIElement, _ name: String) -> String? {
288      if let v = attr(el, name) as? String, !v.isEmpty { return v }
289      return nil
290  }
291  
292  func children(_ el: AXUIElement) -> [AXUIElement] {
293      (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
294  }
295  
296  func hasButton(_ el: AXUIElement, desc target: String, depth: Int = 0) -> Bool {
297      guard depth < 15 else { return false }
298      let role = s(el, kAXRoleAttribute as String) ?? ""
299      let desc = s(el, kAXDescriptionAttribute as String) ?? ""
300      if role == "AXButton" && desc == target { return true }
301      for c in children(el) {
302          if hasButton(c, desc: target, depth: depth + 1) { return true }
303      }
304      return false
305  }
306  
307  guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
308      print("false"); exit(0)
309  }
310  let axApp = AXUIElementCreateApplication(app.processIdentifier)
311  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
312      print("false"); exit(0)
313  }
314  let targets = ["Stop generating", "停止生成"]
315  print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
316  `;
317  const MODEL_MAP = {
318      'auto': { desc: 'Auto' },
319      'instant': { desc: 'Instant' },
320      'thinking': { desc: 'Thinking' },
321      '5.2-instant': { desc: 'GPT-5.2 Instant', legacy: true },
322      '5.2-thinking': { desc: 'GPT-5.2 Thinking', legacy: true },
323  };
324  export const MODEL_CHOICES = Object.keys(MODEL_MAP);
325  export function activateChatGPT(delaySeconds = 0.5) {
326      execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
327      execSync(`osascript -e 'delay ${delaySeconds}'`);
328  }
329  export function selectModel(model) {
330      const entry = MODEL_MAP[model];
331      if (!entry) {
332          throw new Error(`Unknown model "${model}". Choose from: ${MODEL_CHOICES.join(', ')}`);
333      }
334      const swiftArgs = ['-', entry.desc];
335      if (entry.legacy)
336          swiftArgs.push('legacy');
337      const output = execFileSync('swift', swiftArgs, {
338          input: AX_MODEL_SCRIPT,
339          encoding: 'utf-8',
340          maxBuffer: 10 * 1024 * 1024,
341      }).trim();
342      return output;
343  }
344  export function sendPrompt(text) {
345      return execFileSync('swift', ['-', text], {
346          input: AX_SEND_SCRIPT,
347          encoding: 'utf-8',
348          maxBuffer: 10 * 1024 * 1024,
349      }).trim();
350  }
351  export function isGenerating() {
352      try {
353          const output = execFileSync('swift', ['-'], {
354              input: AX_GENERATING_SCRIPT,
355              encoding: 'utf-8',
356              maxBuffer: 10 * 1024 * 1024,
357          }).trim();
358          return output === 'true';
359      }
360      catch {
361          return false;
362      }
363  }
364  export function getVisibleChatMessages() {
365      const output = execFileSync('swift', ['-'], {
366          input: AX_READ_SCRIPT,
367          encoding: 'utf-8',
368          maxBuffer: 10 * 1024 * 1024,
369      }).trim();
370      if (!output)
371          return [];
372      const parsed = JSON.parse(output);
373      if (!Array.isArray(parsed))
374          return [];
375      return parsed
376          .filter((item) => typeof item === 'string')
377          .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
378          .filter((item) => item.length > 0);
379  }
380  export const __test__ = {
381      AX_SEND_SCRIPT,
382      AX_GENERATING_SCRIPT,
383  };