@steipete/oracle 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +130 -45
  3. package/dist/bin/oracle-cli.js +613 -379
  4. package/dist/bin/oracle-mcp.js +2 -2
  5. package/dist/bin/oracle.js +165 -279
  6. package/dist/scripts/agent-send.js +31 -31
  7. package/dist/scripts/check.js +6 -6
  8. package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
  9. package/dist/scripts/docs-list.js +30 -30
  10. package/dist/scripts/git-policy.js +25 -23
  11. package/dist/scripts/run-cli.js +8 -8
  12. package/dist/scripts/runner.js +203 -195
  13. package/dist/scripts/test-browser.js +21 -18
  14. package/dist/scripts/test-remote-chrome.js +20 -20
  15. package/dist/src/bridge/connection.js +18 -18
  16. package/dist/src/bridge/userConfigFile.js +7 -7
  17. package/dist/src/browser/actions/assistantResponse.js +149 -101
  18. package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
  19. package/dist/src/browser/actions/attachments.js +246 -150
  20. package/dist/src/browser/actions/domEvents.js +2 -2
  21. package/dist/src/browser/actions/modelSelection.js +314 -104
  22. package/dist/src/browser/actions/navigation.js +161 -136
  23. package/dist/src/browser/actions/promptComposer.js +100 -64
  24. package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
  25. package/dist/src/browser/actions/thinkingTime.js +207 -110
  26. package/dist/src/browser/chromeLifecycle.js +62 -60
  27. package/dist/src/browser/config.js +34 -15
  28. package/dist/src/browser/constants.js +17 -12
  29. package/dist/src/browser/cookies.js +19 -19
  30. package/dist/src/browser/detect.js +62 -62
  31. package/dist/src/browser/domDebug.js +1 -1
  32. package/dist/src/browser/index.js +452 -303
  33. package/dist/src/browser/modelStrategy.js +1 -1
  34. package/dist/src/browser/pageActions.js +5 -5
  35. package/dist/src/browser/policies.js +16 -13
  36. package/dist/src/browser/profileState.js +44 -39
  37. package/dist/src/browser/prompt.js +72 -42
  38. package/dist/src/browser/promptSummary.js +5 -5
  39. package/dist/src/browser/providerDomFlow.js +17 -0
  40. package/dist/src/browser/providers/chatgptDomProvider.js +49 -0
  41. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +254 -0
  42. package/dist/src/browser/providers/index.js +2 -0
  43. package/dist/src/browser/reattach.js +67 -34
  44. package/dist/src/browser/reattachHelpers.js +31 -26
  45. package/dist/src/browser/sessionRunner.js +37 -25
  46. package/dist/src/browser/utils.js +9 -9
  47. package/dist/src/browserMode.js +1 -1
  48. package/dist/src/cli/bridge/claudeConfig.js +16 -16
  49. package/dist/src/cli/bridge/client.js +28 -20
  50. package/dist/src/cli/bridge/codexConfig.js +16 -16
  51. package/dist/src/cli/bridge/doctor.js +47 -39
  52. package/dist/src/cli/bridge/host.js +58 -56
  53. package/dist/src/cli/browserConfig.js +65 -45
  54. package/dist/src/cli/browserDefaults.js +27 -26
  55. package/dist/src/cli/bundleWarnings.js +1 -1
  56. package/dist/src/cli/clipboard.js +11 -2
  57. package/dist/src/cli/detach.js +7 -4
  58. package/dist/src/cli/dryRun.js +29 -25
  59. package/dist/src/cli/duplicatePromptGuard.js +3 -3
  60. package/dist/src/cli/engine.js +9 -9
  61. package/dist/src/cli/errorUtils.js +1 -1
  62. package/dist/src/cli/fileSize.js +11 -0
  63. package/dist/src/cli/format.js +2 -2
  64. package/dist/src/cli/help.js +28 -28
  65. package/dist/src/cli/hiddenAliases.js +3 -3
  66. package/dist/src/cli/markdownBundle.js +12 -8
  67. package/dist/src/cli/markdownRenderer.js +15 -15
  68. package/dist/src/cli/notifier.js +77 -67
  69. package/dist/src/cli/options.js +145 -87
  70. package/dist/src/cli/oscUtils.js +1 -1
  71. package/dist/src/cli/promptRequirement.js +2 -2
  72. package/dist/src/cli/renderOutput.js +1 -1
  73. package/dist/src/cli/rootAlias.js +1 -1
  74. package/dist/src/cli/runOptions.js +37 -25
  75. package/dist/src/cli/sessionCommand.js +31 -21
  76. package/dist/src/cli/sessionDisplay.js +182 -79
  77. package/dist/src/cli/sessionLineage.js +60 -0
  78. package/dist/src/cli/sessionRunner.js +118 -90
  79. package/dist/src/cli/sessionTable.js +28 -24
  80. package/dist/src/cli/stdin.js +22 -0
  81. package/dist/src/cli/tagline.js +121 -124
  82. package/dist/src/cli/tui/index.js +140 -127
  83. package/dist/src/cli/writeOutputPath.js +5 -5
  84. package/dist/src/config.js +7 -7
  85. package/dist/src/gemini-web/browserSessionManager.js +80 -0
  86. package/dist/src/gemini-web/client.js +81 -64
  87. package/dist/src/gemini-web/executionMode.js +16 -0
  88. package/dist/src/gemini-web/executor.js +327 -169
  89. package/dist/src/gemini-web/index.js +1 -1
  90. package/dist/src/mcp/server.js +16 -12
  91. package/dist/src/mcp/tools/consult.js +81 -64
  92. package/dist/src/mcp/tools/sessionResources.js +12 -12
  93. package/dist/src/mcp/tools/sessions.js +26 -17
  94. package/dist/src/mcp/types.js +5 -5
  95. package/dist/src/mcp/utils.js +15 -7
  96. package/dist/src/oracle/background.js +15 -15
  97. package/dist/src/oracle/claude.js +53 -25
  98. package/dist/src/oracle/client.js +84 -46
  99. package/dist/src/oracle/config.js +124 -58
  100. package/dist/src/oracle/errors.js +38 -38
  101. package/dist/src/oracle/files.js +69 -45
  102. package/dist/src/oracle/finishLine.js +10 -8
  103. package/dist/src/oracle/format.js +3 -3
  104. package/dist/src/oracle/gemini.js +37 -30
  105. package/dist/src/oracle/logging.js +7 -7
  106. package/dist/src/oracle/markdown.js +28 -28
  107. package/dist/src/oracle/modelResolver.js +16 -16
  108. package/dist/src/oracle/multiModelRunner.js +12 -12
  109. package/dist/src/oracle/oscProgress.js +8 -8
  110. package/dist/src/oracle/promptAssembly.js +6 -3
  111. package/dist/src/oracle/request.js +23 -15
  112. package/dist/src/oracle/run.js +172 -140
  113. package/dist/src/oracle/runUtils.js +8 -5
  114. package/dist/src/oracle/tokenEstimate.js +6 -6
  115. package/dist/src/oracle/tokenStats.js +5 -5
  116. package/dist/src/oracle/tokenStringifier.js +5 -5
  117. package/dist/src/oracle.js +12 -12
  118. package/dist/src/oracleHome.js +3 -3
  119. package/dist/src/remote/client.js +25 -25
  120. package/dist/src/remote/health.js +20 -20
  121. package/dist/src/remote/remoteServiceConfig.js +9 -9
  122. package/dist/src/remote/server.js +129 -118
  123. package/dist/src/sessionManager.js +81 -75
  124. package/dist/src/sessionStore.js +3 -3
  125. package/dist/src/version.js +10 -10
  126. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  127. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  128. package/dist/vendor/oracle-notifier/README.md +2 -0
  129. package/package.json +69 -65
  130. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  131. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  132. package/vendor/oracle-notifier/README.md +2 -0
  133. package/dist/markdansi/types/index.js +0 -4
  134. package/dist/oracle/bin/oracle-cli.js +0 -472
  135. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  136. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  137. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  138. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  139. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  140. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  141. package/dist/oracle/src/browser/config.js +0 -33
  142. package/dist/oracle/src/browser/constants.js +0 -40
  143. package/dist/oracle/src/browser/cookies.js +0 -210
  144. package/dist/oracle/src/browser/domDebug.js +0 -36
  145. package/dist/oracle/src/browser/index.js +0 -331
  146. package/dist/oracle/src/browser/pageActions.js +0 -5
  147. package/dist/oracle/src/browser/prompt.js +0 -88
  148. package/dist/oracle/src/browser/promptSummary.js +0 -20
  149. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  150. package/dist/oracle/src/browser/utils.js +0 -62
  151. package/dist/oracle/src/browserMode.js +0 -1
  152. package/dist/oracle/src/cli/browserConfig.js +0 -44
  153. package/dist/oracle/src/cli/dryRun.js +0 -59
  154. package/dist/oracle/src/cli/engine.js +0 -17
  155. package/dist/oracle/src/cli/errorUtils.js +0 -9
  156. package/dist/oracle/src/cli/help.js +0 -70
  157. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  158. package/dist/oracle/src/cli/options.js +0 -103
  159. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  160. package/dist/oracle/src/cli/rootAlias.js +0 -30
  161. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  162. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  163. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  164. package/dist/oracle/src/heartbeat.js +0 -43
  165. package/dist/oracle/src/oracle/client.js +0 -48
  166. package/dist/oracle/src/oracle/config.js +0 -29
  167. package/dist/oracle/src/oracle/errors.js +0 -101
  168. package/dist/oracle/src/oracle/files.js +0 -220
  169. package/dist/oracle/src/oracle/format.js +0 -33
  170. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  171. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  172. package/dist/oracle/src/oracle/request.js +0 -48
  173. package/dist/oracle/src/oracle/run.js +0 -444
  174. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  175. package/dist/oracle/src/oracle/types.js +0 -1
  176. package/dist/oracle/src/oracle.js +0 -9
  177. package/dist/oracle/src/sessionManager.js +0 -205
  178. package/dist/oracle/src/version.js +0 -39
  179. package/dist/scripts/chrome/browser-tools.js +0 -295
  180. package/dist/src/browser/profileSync.js +0 -141
  181. /package/dist/{oracle/src/browser/types.js → src/gemini-web/executionClients.js} +0 -0
@@ -1,35 +1,37 @@
1
- import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR } from '../constants.js';
2
- import { logDomFailure } from '../domDebug.js';
3
- import { buildClickDispatcher } from './domEvents.js';
1
+ import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
+ import { logDomFailure } from "../domDebug.js";
3
+ import { buildClickDispatcher } from "./domEvents.js";
4
4
  /**
5
- * Selects a specific thinking time level in ChatGPT's composer pill menu.
5
+ * Selects a specific thinking time level in ChatGPT's composer.
6
+ *
7
+ * Best-effort: if the chip / menu / option is missing (e.g. ChatGPT moved the
8
+ * effort selector into the per-model trailing button and we can't navigate it,
9
+ * or the language pack uses tokens we don't yet match), we log a debug dump
10
+ * and continue with whatever effort the UI defaults to.
11
+ *
6
12
  * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
7
13
  */
8
14
  export async function ensureThinkingTime(Runtime, level, logger) {
9
15
  const result = await evaluateThinkingTimeSelection(Runtime, level);
10
16
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
11
17
  switch (result?.status) {
12
- case 'already-selected':
18
+ case "already-selected":
13
19
  logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
14
20
  return;
15
- case 'switched':
21
+ case "switched":
16
22
  logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
17
23
  return;
18
- case 'chip-not-found': {
19
- await logDomFailure(Runtime, logger, 'thinking-chip');
20
- throw new Error('Unable to find the Thinking chip button in the composer area.');
21
- }
22
- case 'menu-not-found': {
23
- await logDomFailure(Runtime, logger, 'thinking-time-menu');
24
- throw new Error('Unable to find the Thinking time dropdown menu.');
25
- }
26
- case 'option-not-found': {
27
- await logDomFailure(Runtime, logger, `${level}-option`);
28
- throw new Error(`Unable to find the ${capitalizedLevel} option in the Thinking time menu.`);
24
+ case "chip-not-found":
25
+ case "menu-not-found":
26
+ case "option-not-found": {
27
+ await logDomFailure(Runtime, logger, `thinking-${result.status}`);
28
+ logger(`Thinking time: ${result.status.replaceAll("-", " ")} (requested ${capitalizedLevel}); continuing with ChatGPT default.`);
29
+ return;
29
30
  }
30
31
  default: {
31
- await logDomFailure(Runtime, logger, 'thinking-time-unknown');
32
- throw new Error(`Unknown error selecting ${capitalizedLevel} thinking time.`);
32
+ await logDomFailure(Runtime, logger, "thinking-time-unknown");
33
+ logger(`Thinking time: unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`);
34
+ return;
33
35
  }
34
36
  }
35
37
  }
@@ -43,22 +45,22 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
43
45
  const result = await evaluateThinkingTimeSelection(Runtime, level);
44
46
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
45
47
  switch (result?.status) {
46
- case 'already-selected':
48
+ case "already-selected":
47
49
  logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
48
50
  return true;
49
- case 'switched':
51
+ case "switched":
50
52
  logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
51
53
  return true;
52
- case 'chip-not-found':
53
- case 'menu-not-found':
54
- case 'option-not-found':
54
+ case "chip-not-found":
55
+ case "menu-not-found":
56
+ case "option-not-found":
55
57
  if (logger.verbose) {
56
- logger(`Thinking time: ${result.status.replaceAll('-', ' ')}; continuing with default.`);
58
+ logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
57
59
  }
58
60
  return false;
59
61
  default:
60
62
  if (logger.verbose) {
61
- logger('Thinking time: unknown outcome; continuing with default.');
63
+ logger("Thinking time: unknown outcome; continuing with default.");
62
64
  }
63
65
  return false;
64
66
  }
@@ -67,7 +69,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
67
69
  const message = error instanceof Error ? error.message : String(error);
68
70
  if (logger.verbose) {
69
71
  logger(`Thinking time selection failed (${message}); continuing with default.`);
70
- await logDomFailure(Runtime, logger, 'thinking-time');
72
+ await logDomFailure(Runtime, logger, "thinking-time");
71
73
  }
72
74
  return false;
73
75
  }
@@ -83,124 +85,219 @@ async function evaluateThinkingTimeSelection(Runtime, level) {
83
85
  function buildThinkingTimeExpression(level) {
84
86
  const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
85
87
  const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
88
+ const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
86
89
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
87
90
  return `(async () => {
88
91
  ${buildClickDispatcher()}
89
92
 
90
93
  const MENU_CONTAINER_SELECTOR = ${menuContainerLiteral};
91
94
  const MENU_ITEM_SELECTOR = ${menuItemLiteral};
95
+ const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
92
96
  const TARGET_LEVEL = ${targetLevelLiteral};
93
97
 
94
- const CHIP_SELECTORS = [
95
- '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
96
- 'button.__composer-pill[aria-haspopup="menu"]',
97
- '.__composer-pill-composite button[aria-haspopup="menu"]',
98
- ];
98
+ // Bilingual matchers: English level token + observed Chinese variants.
99
+ const LEVEL_TOKENS = {
100
+ light: ['light', '轻'],
101
+ standard: ['standard', '标准'],
102
+ extended: ['extended', '扩展', '深度', '加强'],
103
+ heavy: ['heavy', '重度', '加重', '高'],
104
+ };
105
+ const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
99
106
 
100
107
  const INITIAL_WAIT_MS = 150;
101
- const MAX_WAIT_MS = 10000;
108
+ const STEP_WAIT_MS = 200;
109
+ const MAX_WAIT_MS = 8000;
102
110
 
111
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
112
+ // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
103
113
  const normalize = (value) => (value || '')
104
114
  .toLowerCase()
105
- .replace(/[^a-z0-9]+/g, ' ')
115
+ .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
106
116
  .replace(/\\s+/g, ' ')
107
117
  .trim();
118
+ const matchesLevel = (text) => {
119
+ const t = normalize(text);
120
+ return targetTokens.some((tok) => t.includes(String(tok).toLowerCase()));
121
+ };
122
+ const optionIsSelected = (node) => {
123
+ if (!(node instanceof HTMLElement)) return false;
124
+ const ariaChecked = node.getAttribute('aria-checked');
125
+ const dataState = (node.getAttribute('data-state') || '').toLowerCase();
126
+ if (ariaChecked === 'true') return true;
127
+ return dataState === 'checked' || dataState === 'selected' || dataState === 'on';
128
+ };
129
+ const closeOpenMenus = () => {
130
+ try {
131
+ document.dispatchEvent(
132
+ new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }),
133
+ );
134
+ } catch {}
135
+ };
108
136
 
109
- const findThinkingChip = () => {
110
- for (const selector of CHIP_SELECTORS) {
111
- const buttons = document.querySelectorAll(selector);
112
- for (const btn of buttons) {
113
- // Skip toggle buttons (no haspopup) - only click dropdown triggers to avoid disabling Pro mode
137
+ // ---------- OLD UI: standalone composer chip labelled "Thinking" ----------
138
+ const OLD_CHIP_SELECTORS = [
139
+ '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
140
+ '.__composer-pill-composite button[aria-haspopup="menu"]',
141
+ ];
142
+ const findOldChip = () => {
143
+ for (const selector of OLD_CHIP_SELECTORS) {
144
+ for (const btn of document.querySelectorAll(selector)) {
114
145
  if (btn.getAttribute?.('aria-haspopup') !== 'menu') continue;
146
+ // The new model picker pill also reuses .__composer-pill — skip it.
147
+ if (btn.matches?.(MODEL_BUTTON_SELECTOR)) continue;
115
148
  const aria = normalize(btn.getAttribute?.('aria-label') ?? '');
116
149
  const text = normalize(btn.textContent ?? '');
117
- if (aria.includes('thinking') || text.includes('thinking')) {
118
- return btn;
119
- }
120
-
121
- // In some cases the pill is labeled "Pro".
122
- if (aria.includes('pro') || text.includes('pro')) {
123
- return btn;
124
- }
150
+ if (aria.includes('thinking') || text.includes('thinking')) return btn;
151
+ }
152
+ }
153
+ return null;
154
+ };
155
+ const findOldEffortMenu = () => {
156
+ const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
157
+ for (const menu of menus) {
158
+ const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
159
+ if (normalize(label?.textContent ?? '').includes('thinking time')) return menu;
160
+ const text = normalize(menu.textContent ?? '');
161
+ if (text.includes('standard') && text.includes('extended')) return menu;
162
+ }
163
+ return null;
164
+ };
165
+ const findOptionInMenu = (menu) => {
166
+ for (const item of menu.querySelectorAll(MENU_ITEM_SELECTOR)) {
167
+ if (
168
+ matchesLevel(item.textContent ?? '') ||
169
+ matchesLevel(item.getAttribute?.('aria-label') ?? '')
170
+ ) {
171
+ return item;
125
172
  }
126
173
  }
127
174
  return null;
128
175
  };
129
176
 
130
- const chip = findThinkingChip();
131
- if (!chip) {
177
+ const oldChip = findOldChip();
178
+ if (oldChip) {
179
+ dispatchClickSequence(oldChip);
180
+ const start = performance.now();
181
+ while (performance.now() - start < MAX_WAIT_MS) {
182
+ await sleep(100);
183
+ const menu = findOldEffortMenu();
184
+ if (!menu) continue;
185
+ const opt = findOptionInMenu(menu);
186
+ if (!opt) {
187
+ closeOpenMenus();
188
+ return { status: 'option-not-found' };
189
+ }
190
+ const already = optionIsSelected(opt);
191
+ const label = opt.textContent?.trim?.() || null;
192
+ dispatchClickSequence(opt);
193
+ await sleep(STEP_WAIT_MS);
194
+ closeOpenMenus();
195
+ return { status: already ? 'already-selected' : 'switched', label };
196
+ }
197
+ closeOpenMenus();
198
+ return { status: 'menu-not-found' };
199
+ }
200
+
201
+ // ---------- NEW UI: thinking effort lives inside the model picker ----------
202
+ // Each eligible model row carries a trailing button:
203
+ // [data-model-picker-thinking-effort-action="true"] (role="menuitem", aria-haspopup="menu")
204
+ // Clicking it expands a submenu of effort options. We use aria-controls to
205
+ // resolve the submenu deterministically rather than scoring menu contents.
206
+ const TRAILING_SELECTOR = '[data-model-picker-thinking-effort-action="true"]';
207
+
208
+ const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
209
+ const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
210
+ const pickTrailingForCurrentModel = () => {
211
+ const trailings = findTrailingButtons();
212
+ if (trailings.length === 0) return null;
213
+ if (trailings.length === 1) return trailings[0];
214
+ // Prefer the trailing button whose model row is currently selected.
215
+ for (const t of trailings) {
216
+ const row = t.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
217
+ if (row && (optionIsSelected(row) || row.querySelector('[aria-checked="true"]'))) return t;
218
+ }
219
+ // Fallback: first one with non-zero box.
220
+ for (const t of trailings) {
221
+ const r = t.getBoundingClientRect?.();
222
+ if (r && r.width > 0 && r.height > 0) return t;
223
+ }
224
+ return trailings[0];
225
+ };
226
+
227
+ const modelBtn = findModelButton();
228
+ if (!modelBtn) {
132
229
  return { status: 'chip-not-found' };
133
230
  }
134
231
 
135
- dispatchClickSequence(chip);
232
+ // Open model menu (idempotent — leaves it open if already open).
233
+ if (modelBtn.getAttribute('aria-expanded') !== 'true') {
234
+ dispatchClickSequence(modelBtn);
235
+ await sleep(INITIAL_WAIT_MS);
236
+ }
136
237
 
137
- return new Promise((resolve) => {
138
- const start = performance.now();
238
+ let trailing = null;
239
+ const trailingDeadline = performance.now() + MAX_WAIT_MS;
240
+ while (performance.now() < trailingDeadline) {
241
+ trailing = pickTrailingForCurrentModel();
242
+ if (trailing) break;
243
+ await sleep(100);
244
+ }
245
+ if (!trailing) {
246
+ closeOpenMenus();
247
+ return { status: 'chip-not-found' };
248
+ }
139
249
 
140
- const findMenu = () => {
141
- const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
142
- for (const menu of menus) {
143
- const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
144
- if (normalize(label?.textContent ?? '').includes('thinking time')) {
145
- return menu;
146
- }
147
- const text = normalize(menu.textContent ?? '');
148
- if (text.includes('standard') && text.includes('extended')) {
149
- return menu;
150
- }
151
- }
152
- return null;
153
- };
154
-
155
- const findTargetOption = (menu) => {
156
- const items = menu.querySelectorAll(MENU_ITEM_SELECTOR);
157
- for (const item of items) {
158
- const text = normalize(item.textContent ?? '');
159
- if (text.includes(TARGET_LEVEL)) {
160
- return item;
161
- }
162
- }
163
- return null;
164
- };
165
-
166
- const optionIsSelected = (node) => {
167
- if (!(node instanceof HTMLElement)) return false;
168
- const ariaChecked = node.getAttribute('aria-checked');
169
- const dataState = (node.getAttribute('data-state') || '').toLowerCase();
170
- if (ariaChecked === 'true') return true;
171
- if (dataState === 'checked' || dataState === 'selected' || dataState === 'on') return true;
172
- return false;
173
- };
250
+ dispatchClickSequence(trailing);
251
+ await sleep(STEP_WAIT_MS);
174
252
 
175
- const attempt = () => {
176
- const menu = findMenu();
177
- if (!menu) {
178
- if (performance.now() - start > MAX_WAIT_MS) {
179
- resolve({ status: 'menu-not-found' });
180
- return;
181
- }
182
- setTimeout(attempt, 100);
183
- return;
253
+ // Resolve the effort submenu via aria-controls when ChatGPT exposes it,
254
+ // otherwise fall back to scanning newly opened menus for our level tokens.
255
+ const resolveEffortMenu = () => {
256
+ const id = trailing.getAttribute('aria-controls');
257
+ if (id) {
258
+ const node = document.getElementById(id);
259
+ if (node) return node;
260
+ }
261
+ const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
262
+ let best = null;
263
+ for (const menu of menus) {
264
+ if (menu === modelBtn || menu.contains(trailing)) continue;
265
+ const text = normalize(menu.textContent ?? '');
266
+ let hits = 0;
267
+ for (const tokens of Object.values(LEVEL_TOKENS)) {
268
+ if (tokens.some((tok) => text.includes(String(tok).toLowerCase()))) hits += 1;
184
269
  }
270
+ if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits };
271
+ }
272
+ return best?.menu ?? null;
273
+ };
185
274
 
186
- const targetOption = findTargetOption(menu);
187
- if (!targetOption) {
188
- resolve({ status: 'option-not-found' });
189
- return;
190
- }
275
+ let effortMenu = null;
276
+ const effortDeadline = performance.now() + MAX_WAIT_MS;
277
+ while (performance.now() < effortDeadline) {
278
+ effortMenu = resolveEffortMenu();
279
+ if (effortMenu) break;
280
+ await sleep(100);
281
+ }
282
+ if (!effortMenu) {
283
+ closeOpenMenus();
284
+ return { status: 'menu-not-found' };
285
+ }
191
286
 
192
- const alreadySelected =
193
- optionIsSelected(targetOption) ||
194
- optionIsSelected(targetOption.querySelector?.('[aria-checked="true"], [data-state="checked"], [data-state="selected"]'));
195
- const label = targetOption.textContent?.trim?.() || null;
196
- dispatchClickSequence(targetOption);
197
- resolve({ status: alreadySelected ? 'already-selected' : 'switched', label });
198
- };
287
+ const targetOption = findOptionInMenu(effortMenu);
288
+ if (!targetOption) {
289
+ closeOpenMenus();
290
+ return { status: 'option-not-found' };
291
+ }
199
292
 
200
- setTimeout(attempt, INITIAL_WAIT_MS);
201
- });
293
+ const already = optionIsSelected(targetOption);
294
+ const label = targetOption.textContent?.trim?.() || null;
295
+ dispatchClickSequence(targetOption);
296
+ await sleep(STEP_WAIT_MS);
297
+ closeOpenMenus();
298
+ return { status: already ? 'already-selected' : 'switched', label };
202
299
  })()`;
203
300
  }
204
- export function buildThinkingTimeExpressionForTest(level = 'extended') {
301
+ export function buildThinkingTimeExpressionForTest(level = "extended") {
205
302
  return buildThinkingTimeExpression(level);
206
303
  }