@youdie006/prodex 0.38.1 → 0.39.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.
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
8
8
  import os from "node:os";
9
- import { readPowerSliderSelection } from "./picker-interaction.js";
9
+ import { menuKeyboardStep, readPowerSliderSelection } from "./picker-interaction.js";
10
10
  export class ChatGptBrowserBlockerError extends Error {
11
11
  blocker;
12
12
  constructor(blocker) {
@@ -20,7 +20,13 @@ export class ChatGptBrowserBlockerError extends Error {
20
20
  // of this control rather than a model. Leaving it out meant --effort could not
21
21
  // reach the top of the slider at all, and the only route there - --model Pro -
22
22
  // answered with advice to "say --effort Pro", which then failed.
23
- const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Pro"];
23
+ /** A full trip round the menu is far more than any real picker needs. */
24
+ const MENU_KEYBOARD_WALK_LIMIT = 16;
25
+ // Two more rungs than before. Measured live: with the model row chosen rather
26
+ // than the recommended set, the slider reads Light / Medium / High / Extra High
27
+ // / Max / Ultra, and Ultra is the top of the machine. Pro is kept because older
28
+ // pickers still name the top step that way.
29
+ const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"];
24
30
  const PRO_MODES = ["기본", "확장"];
25
31
  // Aliases map friendly CLI input onto the exact Korean menu labels the picker
26
32
  // clicks by text. Keys are lowercased and space-stripped before lookup.
@@ -29,19 +35,26 @@ const REASONING_EFFORT_ALIASES = {
29
35
  instant: "즉시",
30
36
  medium: "중간",
31
37
  high: "높음",
32
- max: "매우 높음",
33
38
  extrahigh: "매우 높음",
39
+ max: "매우 높음",
40
+ light: "즉시",
41
+ "가벼움": "즉시",
42
+ "최대": "Max",
43
+ ultra: "Ultra",
44
+ "울트라": "Ultra",
34
45
  pro: "Pro",
35
46
  "프로": "Pro"
36
47
  };
37
48
  // Menu labels per canonical value, verified live in both the Korean and the
38
49
  // English (US) ChatGPT UI. Matching tries every candidate so either UI works.
39
50
  const EFFORT_MENU_LABELS = {
40
- "즉시": ["즉시", "Instant"],
51
+ "즉시": ["즉시", "Instant", "Light"],
41
52
  "중간": ["중간", "Medium"],
42
53
  "높음": ["높음", "High"],
43
54
  "매우 높음": ["매우 높음", "Extra High"],
44
- Pro: ["Pro", "프로"]
55
+ Max: ["Max", "최대"],
56
+ Ultra: ["Ultra", "울트라"],
57
+ Pro: ["Pro", "프로", "6-pro", "GPT-6 Pro", "6 Pro"]
45
58
  };
46
59
  const PRO_MODE_SUBMENU_LABELS = {
47
60
  "기본": ["Pro 기본", "Pro Standard", "Standard", "기본"],
@@ -60,7 +73,7 @@ export function parseReasoningEffort(raw) {
60
73
  const alias = REASONING_EFFORT_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
61
74
  if (alias)
62
75
  return alias;
63
- throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant, medium, high, max)`);
76
+ throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant/light, medium, high, extrahigh, max, ultra)`);
64
77
  }
65
78
  /** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
66
79
  export function parseProMode(raw) {
@@ -1422,6 +1435,38 @@ export function powerSliderStateExpression() {
1422
1435
  };
1423
1436
  })()`;
1424
1437
  }
1438
+ export function activeMenuItemExpression() {
1439
+ return `(() => {
1440
+ const a = document.activeElement;
1441
+ if (!a) return null;
1442
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1443
+ if (menu && !menu.contains(a)) return null;
1444
+ const label = ((a.innerText || a.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
1445
+ return { role: a.getAttribute("role"), label };
1446
+ })()`;
1447
+ }
1448
+ export function powerSliderPresentExpression() {
1449
+ return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
1450
+ }
1451
+ export function pickerClosedExpression() {
1452
+ return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
1453
+ }
1454
+ export function focusPickerMenuExpression() {
1455
+ return `(() => {
1456
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1457
+ if (!menu) return { ok: false, reason: "picker menu not open" };
1458
+ // Leave the focus the menu gave itself. It opens on the checked model row,
1459
+ // and ArrowDown from there walks the model list. Focusing the first item
1460
+ // instead lands on the label row above the slider, where ArrowDown only
1461
+ // cycles that row's own widgets - measured as four steps that repeat
1462
+ // forever and never reach a radio.
1463
+ if (menu.contains(document.activeElement)) return { ok: true };
1464
+ const radios = [...menu.querySelectorAll('[role="menuitemradio"]')];
1465
+ const target = radios.find((r) => r.getAttribute("aria-checked") === "true") || radios[0];
1466
+ if (target && typeof target.focus === "function") target.focus();
1467
+ return { ok: menu.contains(document.activeElement) };
1468
+ })()`;
1469
+ }
1425
1470
  export function focusPowerSliderExpression() {
1426
1471
  return `(() => {
1427
1472
  const slider = document.querySelector('[role="slider"]');
@@ -1680,7 +1725,45 @@ export function stepSelectionUnavailableWarning(requested, offered) {
1680
1725
  return (`step_not_applied: this ChatGPT picker has no "${requested}" step, so the send used the slider's current setting.${list} ` +
1681
1726
  'Pick one of those with --effort, or clear a saved default with `prodex setup --clear-model`.');
1682
1727
  }
1728
+ /**
1729
+ * Choose a model row through the menu's roving focus.
1730
+ *
1731
+ * The rows carry pointer-events: none and sit outside the menu's box, so
1732
+ * verifiedClickAt refuses them and .focus() is declined. ArrowDown from the row
1733
+ * the menu opens on does reach them, and Enter commits. Doing this BEFORE the
1734
+ * slider matters: with the recommended set active the slider walks a short
1735
+ * mixed ladder ending at Extra High, and picking the model swaps in that
1736
+ * model's own ladder, which continues past it.
1737
+ */
1738
+ async function selectPickerModelByKeyboard(cdp, candidates) {
1739
+ const focused = await cdp.evaluate(focusPickerMenuExpression());
1740
+ if (!focused?.ok)
1741
+ return false;
1742
+ const limit = MENU_KEYBOARD_WALK_LIMIT;
1743
+ for (let pressed = 0;; pressed += 1) {
1744
+ const active = await cdp.evaluate(activeMenuItemExpression());
1745
+ const move = menuKeyboardStep({ active, requested: candidates, pressed, limit });
1746
+ if (move === "exhausted")
1747
+ return false;
1748
+ if (move === "select") {
1749
+ await dispatchMenuKey(cdp, "Enter", 13);
1750
+ // Committing closes the menu. Waiting for it to finish closing keeps the
1751
+ // reopen below from landing mid-animation and toggling it shut again.
1752
+ await waitForExpressionTrue(cdp, pickerClosedExpression(), 3_000);
1753
+ return true;
1754
+ }
1755
+ await dispatchMenuKey(cdp, "ArrowDown", 40);
1756
+ await sleep(140);
1757
+ }
1758
+ }
1759
+ async function dispatchMenuKey(cdp, key, code) {
1760
+ for (const type of ["keyDown", "keyUp"]) {
1761
+ await cdp.send("Input.dispatchKeyEvent", { type, key, code: key, windowsVirtualKeyCode: code });
1762
+ }
1763
+ }
1683
1764
  async function selectPickerModel(cdp, requested, warnings = []) {
1765
+ if (await selectPickerModelByKeyboard(cdp, [requested]))
1766
+ return;
1684
1767
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
1685
1768
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
1686
1769
  const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
@@ -1692,6 +1775,29 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1692
1775
  }
1693
1776
  await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
1694
1777
  }
1778
+ /**
1779
+ * Make sure the composer picker is open, reopening it if it is not.
1780
+ *
1781
+ * Committing a model row closes the menu, and the slider lives inside it - so
1782
+ * without this the step that follows reported "the picker did not expose its
1783
+ * power slider" on a picker that was simply shut.
1784
+ */
1785
+ async function ensurePickerOpen(cdp) {
1786
+ // The slider, not the menu, is what the caller needs - and an open menu that
1787
+ // has not painted its slider yet looks identical to one that has.
1788
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1789
+ if (await cdp.evaluate(powerSliderPresentExpression()))
1790
+ return true;
1791
+ await waitForExpressionTrue(cdp, pickerClosedExpression(), 1_500);
1792
+ const button = await cdp.evaluate(modelButtonRectExpression());
1793
+ if (!button.ok || button.x === undefined || button.y === undefined)
1794
+ return false;
1795
+ await verifiedClickAt(cdp, button.x, button.y, "model selector");
1796
+ if (await waitForExpressionTrue(cdp, powerSliderPresentExpression(), MENU_OPEN_TIMEOUT_MS))
1797
+ return true;
1798
+ }
1799
+ return false;
1800
+ }
1695
1801
  async function selectPowerStep(cdp, requested) {
1696
1802
  // The menu paints a moment after it opens, and on a page that has just been
1697
1803
  // built - a project created seconds ago - that moment is longer. Failing the
@@ -1800,7 +1906,11 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1800
1906
  // the whole send). Retry briefly with FRESH coordinates; a persistent
1801
1907
  // cover still fails with the refusal message.
1802
1908
  const clickDeadline = Date.now() + 5_000;
1803
- for (;;) {
1909
+ // Clicking the trigger TOGGLES the picker, so clicking one that is already
1910
+ // open shuts it and the next step reports "power slider not found" about a
1911
+ // control that was on screen a moment earlier.
1912
+ const alreadyOpen = await cdp.evaluate(powerSliderPresentExpression());
1913
+ for (; !alreadyOpen;) {
1804
1914
  try {
1805
1915
  await verifiedClickAt(cdp, button.x, button.y, "model selector");
1806
1916
  break;
@@ -1832,7 +1942,16 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1832
1942
  });
1833
1943
  if (plan.warning)
1834
1944
  selectionWarnings.push(plan.warning);
1945
+ // The model goes first: choosing it replaces the ladder the slider walks,
1946
+ // so a step picked before it would be a step of the old ladder.
1947
+ if (plan.modelLabel) {
1948
+ await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1949
+ }
1835
1950
  if (plan.sliderLabel) {
1951
+ // Committing the model closed the menu; the slider is inside it.
1952
+ if (!(await ensurePickerOpen(cdp))) {
1953
+ throw new Error("ChatGPT's model picker would not reopen after the model was chosen.");
1954
+ }
1836
1955
  try {
1837
1956
  await selectPowerStep(cdp, plan.sliderLabel);
1838
1957
  }
@@ -1847,9 +1966,6 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1847
1966
  selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
1848
1967
  }
1849
1968
  }
1850
- if (plan.modelLabel) {
1851
- await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1852
- }
1853
1969
  // This branch cannot honour a sub-mode, and used to return without
1854
1970
  // saying so - the caller got a clean receipt for a 확장 it never got.
1855
1971
  const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
@@ -2839,7 +2955,11 @@ export function modelMenuOptionsExpression() {
2839
2955
  return `(() => {
2840
2956
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2841
2957
  if (!m) return [];
2842
- return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
2958
+ // Only the radios are models. The plain menuitems above them are the power
2959
+ // slider's own rows - its track, and the label pair naming the current model
2960
+ // and effort - and listing those put GPT-6 Astra in twice and Extra High in
2961
+ // as if it were a model.
2962
+ return [...m.querySelectorAll('[role="menuitemradio"]')]
2843
2963
  .map((it) => {
2844
2964
  // A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
2845
2965
  // and the value is the part a person actually wants to read.
@@ -3275,17 +3395,24 @@ async function readEffortSteps(cdp) {
3275
3395
  await dispatchArrowKey(cdp, "ArrowLeft");
3276
3396
  await sleep(120);
3277
3397
  }
3278
- const labels = [];
3398
+ // Every position is recorded, including repeats. One slider now walks a
3399
+ // ladder of model-and-effort pairs, so "Light" is three different rungs -
3400
+ // collapsing by label turned six positions into three and lost which model
3401
+ // each one sends with.
3402
+ const rungs = [];
3279
3403
  const record = async () => {
3280
3404
  const state = await cdp.evaluate(powerSliderStateExpression());
3281
- const label = state?.effort?.trim();
3282
- if (label && !labels.includes(label))
3283
- labels.push(label);
3405
+ if (!state?.ok)
3406
+ return;
3407
+ const effort = state.effort?.trim();
3408
+ if (!effort)
3409
+ return;
3410
+ rungs.push({ position: state.position ?? rungs.length, model: state.model?.trim() || null, effort });
3284
3411
  };
3285
3412
  await record();
3286
3413
  for (let i = 0; i < plan.climb; i += 1) {
3287
3414
  await dispatchArrowKey(cdp, "ArrowRight");
3288
- await sleep(120);
3415
+ await sleep(220);
3289
3416
  await record();
3290
3417
  }
3291
3418
  // Put it back. Reading someone's setting must not change it.
@@ -3293,7 +3420,7 @@ async function readEffortSteps(cdp) {
3293
3420
  await dispatchArrowKey(cdp, "ArrowLeft");
3294
3421
  await sleep(120);
3295
3422
  }
3296
- return labels.length > 0 ? { labels, current } : undefined;
3423
+ return rungs.length > 0 ? { rungs, current } : undefined;
3297
3424
  }
3298
3425
  catch {
3299
3426
  return undefined;
package/dist/cli-pro.js CHANGED
@@ -449,11 +449,18 @@ export async function runProCommand(rest, io, runCliFn) {
449
449
  }
450
450
  if (listed.effortSteps) {
451
451
  io.stdout("");
452
- io.stdout("Effort steps on this account (the slider was walked and put back):");
453
- for (const label of listed.effortSteps.labels) {
454
- io.stdout(`${label === listed.effortSteps.current ? "*" : " "} ${label}`);
452
+ // One slider walks a ladder of model-and-effort pairs, so the same
453
+ // effort name appears at more than one rung with a different model
454
+ // behind it. Printing bare names hid that.
455
+ io.stdout("Power slider on this account (the slider was walked and put back):");
456
+ const rungs = listed.effortSteps.rungs;
457
+ const width = String(rungs.length).length;
458
+ for (const rung of rungs) {
459
+ const here = rung.effort === listed.effortSteps.current ? "*" : " ";
460
+ const step = String(rung.position + 1).padStart(width, " ");
461
+ io.stdout(`${here} ${step}/${rungs.length} ${rung.model ?? "?"} - ${rung.effort}`);
455
462
  }
456
- io.stdout("Pass any of these to --effort. The list is complete: the slider shows one step at a time, so reading it any other way sees only the current one.");
463
+ io.stdout("Each row is one position of that single slider: it sets the model and the effort together, so they cannot be chosen apart. The list is complete - the slider shows one position at a time, so reading it any other way sees only the current one.");
457
464
  }
458
465
  else {
459
466
  io.stdout("An arrow shows what that row is set to now; --model / --effort reach into those submenus (e.g. --model Pro).");
package/dist/config.js CHANGED
@@ -11,7 +11,7 @@ const BrowserDefaultsSchema = z.object({
11
11
  model: z.string().min(1).optional(),
12
12
  pro_mode: z.enum(["기본", "확장"]).optional(),
13
13
  // Pro is the slider's fifth step, so a saved default may name it.
14
- effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Pro"]).optional(),
14
+ effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"]).optional(),
15
15
  project: z.string().min(1).optional()
16
16
  });
17
17
  const LocalConfigSchema = z.object({
@@ -10,6 +10,7 @@ export function readPowerSliderSelection(snapshot) {
10
10
  }));
11
11
  const powerLabels = new Set([
12
12
  "instant",
13
+ "light",
13
14
  "즉시",
14
15
  "빠름",
15
16
  "fast",
@@ -29,12 +30,43 @@ export function readPowerSliderSelection(snapshot) {
29
30
  const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
30
31
  const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
31
32
  const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
32
- const sliderOwner = items.find((item) => item.role === "menuitem" && item.containsSlider)?.lines[0] ?? null;
33
+ // The slider stopped being an effort control: one slider now walks a ladder of
34
+ // model-and-effort pairs, and a row above the track reads "<model>" then
35
+ // "<effort>". Measured live, that pair sits in a SIBLING row - the row that
36
+ // literally contains the slider carries no text - so the pair is found by its
37
+ // shape rather than by ownership of the track. Reading the pair's first line
38
+ // as the effort is what made the listing announce "Extra High" as a model.
39
+ const pairRow = items.find((item) => item.role === "menuitem" && item.lines.length >= 2 && item.lines[0] !== "Model" && item.lines[0] !== "Effort");
40
+ const owner = items.find((item) => item.role === "menuitem" && item.containsSlider);
41
+ const sliderOwnerModel = pairRow?.lines[0] ?? null;
42
+ // A single-line row is the older form, where it carried only the step name.
43
+ const sliderOwner = pairRow?.lines[1] ?? (owner && owner.lines.length === 1 ? owner.lines[0] : null) ?? null;
33
44
  const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
34
45
  const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
35
46
  const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
36
47
  return {
37
- model: legacyModel ?? checkedModel,
48
+ // The checked radio is "Default" - the recommended set - while the slider
49
+ // decides the model a send actually uses, so the slider's row wins.
50
+ model: legacyModel ?? sliderOwnerModel ?? checkedModel,
38
51
  effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
39
52
  };
40
53
  }
54
+ /**
55
+ * Decide the next move when walking the composer picker's menu by keyboard.
56
+ *
57
+ * The model rows carry pointer-events: none and refuse focus(), so the menu's
58
+ * own roving focus is the only way to reach them. Only a radio counts as the
59
+ * target: the row above the slider repeats the model name as its first line,
60
+ * and committing there selects nothing.
61
+ */
62
+ export function menuKeyboardStep(input) {
63
+ const { active, requested, pressed, limit } = input;
64
+ if (!active)
65
+ return "exhausted";
66
+ if (active.role === "menuitemradio") {
67
+ const label = active.label.trim().toLowerCase();
68
+ if (requested.some((candidate) => candidate.trim().toLowerCase() === label))
69
+ return "select";
70
+ }
71
+ return pressed < limit ? "down" : "exhausted";
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",