@youdie006/prodex 0.28.1 → 0.30.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.
@@ -2462,6 +2462,62 @@ export function sidebarProjectNamesExpression() {
2462
2462
  // Read-only discovery for --project/setup --project: list the sidebar project
2463
2463
  // names exactly as ChatGPT renders them, so nobody has to guess spelling or
2464
2464
  // case. Polls briefly because the Projects section hydrates after navigation.
2465
+ /**
2466
+ * Recent conversations with their titles, for the interactive "continue an
2467
+ * existing chat" list.
2468
+ */
2469
+ /**
2470
+ * Point the dedicated tab at a conversation and wait until it is really there.
2471
+ *
2472
+ * `--target-url` confirms which conversation a send means; it deliberately does
2473
+ * not navigate, so nothing moves a shared browser behind the user's back. When
2474
+ * the user has just picked a conversation from a list, moving there IS the
2475
+ * request, so the picker navigates first and then confirms.
2476
+ */
2477
+ export async function navigateChatGptTabTo(url, options = {}) {
2478
+ const port = resolveCdpPort(options.port);
2479
+ const page = await findChatGptPage(port, 3_000);
2480
+ if (!page.ok || !page.page)
2481
+ return false;
2482
+ const target = normalizeChatGptTargetUrl(url);
2483
+ const cdp = await connectCdp(page.page.webSocketDebuggerUrl);
2484
+ try {
2485
+ await cdp.send("Runtime.enable");
2486
+ await cdp.evaluate(`location.assign(${JSON.stringify(target)})`);
2487
+ const deadline = Date.now() + Math.max(1_000, options.timeoutMs ?? 20_000);
2488
+ while (Date.now() < deadline) {
2489
+ await sleep(500);
2490
+ try {
2491
+ const here = await cdp.evaluate("location.href");
2492
+ if (typeof here === "string" && chatGptUrlsReferToSameTarget(here, target))
2493
+ return true;
2494
+ }
2495
+ catch {
2496
+ // Mid-navigation evaluate failures are expected; keep polling.
2497
+ }
2498
+ }
2499
+ return false;
2500
+ }
2501
+ finally {
2502
+ cdp.close();
2503
+ }
2504
+ }
2505
+ export async function listRecentChatGptConversations(input = {}) {
2506
+ const port = resolveCdpPort(input.port);
2507
+ const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
2508
+ if (!page.ok || !page.page)
2509
+ return [];
2510
+ const { recentConversationTitlesExpression } = await import("./tui.js");
2511
+ try {
2512
+ return ((await evaluateOnPage(page.page, recentConversationTitlesExpression(input.limit ?? 10), {
2513
+ timeoutMs: 30_000
2514
+ })) ?? []);
2515
+ }
2516
+ catch {
2517
+ // Nothing to continue from is a normal answer here, not a failure.
2518
+ return [];
2519
+ }
2520
+ }
2465
2521
  export async function listChatGptSidebarProjects(input = {}) {
2466
2522
  const port = resolveCdpPort(input.port);
2467
2523
  const timeoutMs = input.timeoutMs ?? 15_000;
package/dist/cli-pro.js CHANGED
@@ -838,7 +838,14 @@ export async function runAskProCommand(rest, io) {
838
838
  // reasoning axis explicitly suppresses the default for the other axis, and
839
839
  // pinning --target-url suppresses a default project (it would navigate away
840
840
  // from the confirmed tab).
841
- const selectionModel = explicitModel ?? browserDefaults?.model;
841
+ // Choosing an effort IS choosing the reasoning axis, and ChatGPT deselects
842
+ // Pro the moment an effort is set - so applying a pinned Pro first would
843
+ // select a model only to undo it, and a quick question would still pay for
844
+ // the Pro selection dance.
845
+ // --pro-mode refines Pro, so it keeps a pinned Pro. --effort replaces it:
846
+ // ChatGPT deselects Pro the moment an effort is set, so applying the pinned
847
+ // Pro first would select a model only to undo it.
848
+ const selectionModel = explicitModel ?? (explicitEffort !== undefined ? undefined : browserDefaults?.model);
842
849
  const selectionProjectNew = explicitProjectNew;
843
850
  // A persisted default project APPLIES under --new-chat: since 0.16.11 a
844
851
  // fresh chat inside the project is exactly what "--new-chat + project"
package/dist/cli.js CHANGED
@@ -70,6 +70,22 @@ async function runInteractiveUi(io) {
70
70
  { label: "browser", value: browser }
71
71
  ];
72
72
  },
73
+ pinnedProject: async () => {
74
+ const { loadBrowserDefaults } = await import("./config.js");
75
+ return (await loadBrowserDefaults(io.cwd).catch(() => undefined))?.project;
76
+ },
77
+ pinnedModel: async () => {
78
+ const { loadBrowserDefaults } = await import("./config.js");
79
+ return (await loadBrowserDefaults(io.cwd).catch(() => undefined))?.model;
80
+ },
81
+ listConversations: async () => {
82
+ const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
83
+ return listRecentChatGptConversations({});
84
+ },
85
+ openThread: async (url) => {
86
+ const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
87
+ return navigateChatGptTabTo(url, {});
88
+ },
73
89
  listProjects: async () => {
74
90
  const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
75
91
  const listed = await listChatGptSidebarProjects({});
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What the interactive picker asks, in the order it asks it.
3
+ *
4
+ * The first version asked for the prompt first and treated the kind of send as
5
+ * an afterthought, which is backwards: whether this is an ordinary chat or a
6
+ * ten-minute research run changes what you would type. Deciding the kind, then
7
+ * the destination, then writing the prompt matches how the request is actually
8
+ * formed - and it leaves the seconds spent choosing free for prodex to fetch
9
+ * the project and conversation lists the destination step needs.
10
+ */
11
+ export const SEND_KINDS = [
12
+ { id: "chat", label: "Normal chat", hint: "ordinary Pro answer", tools: [] },
13
+ { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes", tools: ["deep-research"] },
14
+ { id: "web-search", label: "Web search", hint: "current facts, with sources", tools: ["web-search"] },
15
+ { id: "create-image", label: "Create image", tools: ["create-image"] }
16
+ ];
17
+ /**
18
+ * One screen for the whole destination question. Splitting "which project" from
19
+ * "new thread or not" made the user answer twice for one decision, and left no
20
+ * way at all to say "that conversation, the one from yesterday".
21
+ */
22
+ export function destinationChoices(pinnedProject) {
23
+ return [
24
+ { id: "continue", label: "Continue an existing conversation", hint: "pick from your recent chats" },
25
+ {
26
+ id: "new",
27
+ label: "New chat",
28
+ ...(pinnedProject ? { hint: `in your pinned project: ${pinnedProject}` } : { hint: "in the plain chat list" })
29
+ },
30
+ { id: "project", label: "New chat in an existing project", hint: "pick from your sidebar projects" },
31
+ { id: "project-new", label: "New chat in a new project" },
32
+ ...(pinnedProject ? [{ id: "no-project", label: "New chat, ignoring the pinned project" }] : [])
33
+ ];
34
+ }
35
+ /**
36
+ * prodex is named for Pro, but the same signed-in browser runs the ordinary
37
+ * reasoning levels, and a one-line question does not want minutes of Pro.
38
+ * Offered only for an ordinary chat: the tool kinds bring their own pipeline,
39
+ * and ChatGPT deselects Pro as soon as an effort is set.
40
+ */
41
+ export function effortChoices(pinnedModel) {
42
+ return [
43
+ { label: pinnedModel ? `Keep ${pinnedModel}` : "Keep the current selection", hint: "whatever this repo pinned" },
44
+ { effort: "instant", label: "Instant", hint: "seconds, for a quick question" },
45
+ { effort: "medium", label: "Medium" },
46
+ { effort: "high", label: "High" },
47
+ { effort: "max", label: "Extra high", hint: "slowest of the standard levels" }
48
+ ];
49
+ }
package/dist/tui-run.js CHANGED
@@ -7,7 +7,9 @@
7
7
  * what keeps the interactive path from drifting away from the documented flags.
8
8
  */
9
9
  import readline from "node:readline";
10
- import { consultArgsFromChoices, moveCursor, progressLabel, renderContextPanel, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
10
+ import { renderBanner } from "./banner.js";
11
+ import { destinationChoices, effortChoices, SEND_KINDS } from "./tui-flow.js";
12
+ import { consultArgsFromChoices, conversationThreadUrl, moveCursor, progressLabel, renderContextPanel, renderProgressBar, renderSelectList } from "./tui.js";
11
13
  const ESC = "";
12
14
  const CLEAR = `${ESC}[2J${ESC}[H`;
13
15
  // The alternate screen buffer: the terminal comes back exactly as it was, so a
@@ -115,38 +117,6 @@ function numericChoice(key, length) {
115
117
  return undefined;
116
118
  return digit - 1;
117
119
  }
118
- async function pickMany(io, input, header = "") {
119
- let cursor = 0;
120
- let selected = [];
121
- for (;;) {
122
- io.write(CLEAR +
123
- header +
124
- renderSelectList({
125
- ...input,
126
- cursor,
127
- selected,
128
- multi: true,
129
- width: terminalWidth(),
130
- color: colorEnabled(),
131
- footer: " space toggle 1-9 toggle directly enter confirm (none is fine) q cancel"
132
- }) +
133
- "\n");
134
- const key = await readKey(io);
135
- if (isCancel(key))
136
- return undefined;
137
- const typed = numericChoice(key, input.options.length);
138
- if (typed !== undefined)
139
- selected = toggleSelection(selected, typed);
140
- else if (key.name === "up" || key.name === "k")
141
- cursor = moveCursor(cursor, "up", input.options.length);
142
- else if (key.name === "down" || key.name === "j")
143
- cursor = moveCursor(cursor, "down", input.options.length);
144
- else if (key.name === "space")
145
- selected = toggleSelection(selected, cursor);
146
- else if (isConfirm(key))
147
- return selected;
148
- }
149
- }
150
120
  /** Read one line with the terminal back in cooked mode, so editing works. */
151
121
  async function askLine(io, question) {
152
122
  io.input.setRawMode?.(false);
@@ -161,11 +131,6 @@ async function askLine(io, question) {
161
131
  keys?.drain();
162
132
  return answer.trim();
163
133
  }
164
- const TOOL_CHOICES = [
165
- { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes" },
166
- { id: "web-search", label: "Web search", hint: "current facts, with sources" },
167
- { id: "create-image", label: "Create image" }
168
- ];
169
134
  /**
170
135
  * Walk the questions a send needs, then run it with a moving progress bar.
171
136
  * Returns the process exit code.
@@ -179,85 +144,119 @@ export async function runInteractiveConsult(io, deps) {
179
144
  io.write(ALT_SCREEN_ON + HIDE_CURSOR);
180
145
  let header = "";
181
146
  try {
182
- io.write(CLEAR);
183
- // Gather the context WHILE the prompt is being typed. Reading the browser
184
- // state takes a second or two, and doing it first left the screen blank for
185
- // that long - anything typed into that gap was swallowed, prompt included.
147
+ // The logo is the program saying which program it is; the panel says what
148
+ // this send will use. Both belong above the first question, not nowhere.
149
+ const banner = colorEnabled() ? `${renderBanner({ color: true })}\n` : "";
150
+ io.write(CLEAR + banner);
151
+ // Everything the later screens need is fetched while the first question is
152
+ // being read, so no step waits on the network.
186
153
  const contextPromise = deps.describeContext?.().catch(() => []);
187
- const prompt = await askLine(io, "Ask ChatGPT Pro\n\n prompt: ");
188
- // The panel still leads every question that follows, which is where it
189
- // earns its place: choosing a project and tools without knowing the browser
190
- // is down wastes every answer given before the failure.
154
+ const pinnedPromise = deps.pinnedProject?.().catch(() => undefined);
155
+ const pinnedModelPromise = deps.pinnedModel?.().catch(() => undefined);
156
+ const conversationsPromise = deps.listConversations?.().catch(() => []);
157
+ const kindChoice = await pick(io, {
158
+ title: "What kind of send is this?",
159
+ step: { index: 1, total: 3 },
160
+ options: SEND_KINDS.map((kind) => ({ label: kind.label, ...(kind.hint ? { hint: kind.hint } : {}) }))
161
+ }, banner);
162
+ if (kindChoice === undefined)
163
+ return cancel(io);
164
+ const tools = SEND_KINDS[kindChoice].tools;
165
+ // Only an ordinary chat has a reasoning level to choose: the tool kinds
166
+ // bring their own pipeline, and an effort would deselect Pro under them.
167
+ let effort;
168
+ if (SEND_KINDS[kindChoice].id === "chat") {
169
+ const efforts = effortChoices(await pinnedModelPromise);
170
+ const chosen = await pick(io, {
171
+ title: "How much reasoning?",
172
+ options: efforts.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
173
+ }, banner);
174
+ if (chosen === undefined)
175
+ return cancel(io);
176
+ effort = efforts[chosen].effort;
177
+ }
191
178
  const rows = (await contextPromise) ?? [];
192
179
  if (rows.length > 0)
193
- header = `${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
194
- if (prompt.length === 0) {
195
- io.write("Nothing to ask.\n");
196
- return 1;
197
- }
198
- const projectChoice = await pick(io, {
199
- title: "Where should this consult land?",
200
- step: { index: 1, total: 3 },
201
- options: [
202
- { label: "The chat that is already open", hint: "keeps the thread's context" },
203
- { label: "An existing project" },
204
- { label: "A new project" },
205
- { label: "No project", hint: "plain chat list, ignores a pinned default" }
206
- ]
180
+ header = `${banner}${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
181
+ else
182
+ header = banner;
183
+ const pinnedProject = await pinnedPromise;
184
+ const destinations = destinationChoices(pinnedProject);
185
+ const destinationChoice = await pick(io, {
186
+ title: "Where should it go?",
187
+ step: { index: 2, total: 3 },
188
+ options: destinations.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
207
189
  }, header);
208
- if (projectChoice === undefined)
190
+ if (destinationChoice === undefined)
209
191
  return cancel(io);
210
- const modes = ["current", "existing", "new", "none"];
211
- const projectMode = modes[projectChoice];
192
+ const destination = destinations[destinationChoice].id;
193
+ let projectMode = "current";
212
194
  let projectName;
213
- if (projectMode === "existing") {
195
+ let targetUrl;
196
+ if (destination === "continue") {
197
+ const conversations = (await conversationsPromise) ?? [];
198
+ if (conversations.length === 0) {
199
+ io.write("\nNo recent conversations were readable. Start a new chat instead.\n");
200
+ return 1;
201
+ }
202
+ const chosen = await pick(io, { title: "Which conversation?", options: conversations.map((entry) => ({ label: entry.title })) }, header);
203
+ if (chosen === undefined)
204
+ return cancel(io);
205
+ targetUrl = conversationThreadUrl(conversations[chosen].id);
206
+ }
207
+ else if (destination === "project") {
214
208
  const projects = await deps.listProjects();
215
209
  if (projects.length === 0) {
216
210
  io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
217
211
  return 1;
218
212
  }
219
- const chosen = await pick(io, {
220
- title: "Which project?",
221
- options: projects.map((name) => ({ label: name }))
222
- }, header);
213
+ const chosen = await pick(io, { title: "Which project?", options: projects.map((name) => ({ label: name })) }, header);
223
214
  if (chosen === undefined)
224
215
  return cancel(io);
216
+ projectMode = "existing";
225
217
  projectName = projects[chosen];
226
218
  }
227
- else if (projectMode === "new") {
228
- projectName = await askLine(io, `${CLEAR}New project\n\n name: `);
219
+ else if (destination === "project-new") {
220
+ projectName = await askLine(io, `${CLEAR}${header}New project\n\n name: `);
229
221
  if (!projectName)
230
222
  return cancel(io);
223
+ projectMode = "new";
224
+ }
225
+ else if (destination === "no-project") {
226
+ projectMode = "none";
227
+ }
228
+ // The prompt comes last: what you type depends on what you just decided.
229
+ io.write(CLEAR + header + SHOW_CURSOR);
230
+ const kindLabel = SEND_KINDS[kindChoice].label;
231
+ const prompt = await askLine(io, `${kindLabel} Step 3 of 3\n\n prompt: `);
232
+ io.write(HIDE_CURSOR);
233
+ if (prompt.length === 0) {
234
+ io.write("Nothing to ask.\n");
235
+ return 1;
231
236
  }
232
- const toolChoice = await pickMany(io, {
233
- title: "Turn on any composer tools for this send",
234
- step: { index: 2, total: 3 },
235
- options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
236
- }, header);
237
- if (toolChoice === undefined)
238
- return cancel(io);
239
- const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
240
- const threadChoice = await pick(io, {
241
- title: "Start a fresh thread?",
242
- step: { index: 3, total: 3 },
243
- options: [
244
- { label: "Continue the current thread", hint: "follow-ups keep context" },
245
- { label: "Start a new chat", hint: "recommended for an unrelated question" }
246
- ]
247
- }, header);
248
- if (threadChoice === undefined)
249
- return cancel(io);
250
237
  const choices = {
251
238
  prompt,
252
239
  projectMode,
253
240
  ...(projectName ? { projectName } : {}),
241
+ ...(targetUrl ? { targetUrl } : {}),
242
+ ...(effort ? { effort } : {}),
254
243
  tools,
255
- newChat: threadChoice === 1
244
+ newChat: !targetUrl
256
245
  };
257
246
  const args = consultArgsFromChoices(choices);
258
247
  // Leave the alternate screen before the send: the answer, the receipt id
259
248
  // and any blocker belong in the scrollback the user keeps.
260
249
  io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
250
+ // --target-url confirms which conversation a send means; it deliberately
251
+ // does not navigate. Picking one from a list IS a request to go there, so
252
+ // move the tab first and let the flag confirm it landed.
253
+ if (targetUrl && deps.openThread) {
254
+ io.write("Opening the conversation you picked...\n");
255
+ if (!(await deps.openThread(targetUrl))) {
256
+ io.write(`Could not open ${targetUrl} in the dedicated browser.\n`);
257
+ return 1;
258
+ }
259
+ }
261
260
  io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
262
261
  // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
263
262
  // Fill the bar against that so the wait has a shape.
package/dist/tui.js CHANGED
@@ -10,6 +10,10 @@
10
10
  * The rendering and the choice-to-argument mapping are pure so they can be
11
11
  * tested without a terminal; only `runInteractiveConsult` touches stdin.
12
12
  */
13
+ /** Where a conversation lives, given its id. */
14
+ export function conversationThreadUrl(conversationId) {
15
+ return `https://chatgpt.com/c/${conversationId}`;
16
+ }
13
17
  /**
14
18
  * Map the picker's answers onto the exact command line a person could have
15
19
  * typed. Keeping this a pure function means the interactive path cannot drift
@@ -20,8 +24,23 @@ export function consultArgsFromChoices(choices) {
20
24
  if (prompt.length === 0)
21
25
  throw new Error("The prompt is empty - there is nothing to ask.");
22
26
  const args = ["pro", "browser", "ask"];
27
+ // A picked conversation is the whole destination: the send rejects a project
28
+ // or a fresh chat alongside a pinned target, and rightly so.
29
+ if (choices.targetUrl) {
30
+ args.push("--target-url", choices.targetUrl, "--confirm-target");
31
+ if (choices.effort)
32
+ args.push("--effort", choices.effort);
33
+ for (const attachment of choices.attachments ?? [])
34
+ args.push("--attach", attachment);
35
+ for (const tool of choices.tools)
36
+ args.push("--tool", tool);
37
+ args.push("--", prompt);
38
+ return args;
39
+ }
23
40
  if (choices.newChat)
24
41
  args.push("--new-chat");
42
+ if (choices.effort)
43
+ args.push("--effort", choices.effort);
25
44
  if (choices.projectMode === "existing" || choices.projectMode === "new") {
26
45
  const name = choices.projectName?.trim();
27
46
  if (!name)
@@ -38,6 +57,37 @@ export function consultArgsFromChoices(choices) {
38
57
  args.push("--", prompt);
39
58
  return args;
40
59
  }
60
+ /**
61
+ * Recent conversations with their titles, for the "continue an existing chat"
62
+ * list. Only the sidebar listing is fetched - the transcripts themselves are
63
+ * large and nothing here needs them.
64
+ */
65
+ export function recentConversationTitlesExpression(limit = 10) {
66
+ return `(async () => {
67
+ let token = "";
68
+ try {
69
+ const session = await fetch("/api/auth/session", { credentials: "include" });
70
+ if (!session.ok) return [];
71
+ const parsed = await session.json();
72
+ token = (parsed && parsed.accessToken) || "";
73
+ } catch (error) {
74
+ return [];
75
+ }
76
+ try {
77
+ const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
78
+ credentials: "include",
79
+ headers: token ? { Authorization: "Bearer " + token } : {}
80
+ });
81
+ if (!response.ok) return [];
82
+ const listed = await response.json();
83
+ return ((listed && listed.items) || [])
84
+ .filter((item) => item && item.id)
85
+ .map((item) => ({ id: item.id, title: (item.title || "").trim() || "Untitled" }));
86
+ } catch (error) {
87
+ return [];
88
+ }
89
+ })()`;
90
+ }
41
91
  const ESC = "";
42
92
  const DIM = `${ESC}[2m`;
43
93
  const BOLD = `${ESC}[1m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",