@youdie006/prodex 0.28.0 → 0.29.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-help.js CHANGED
@@ -27,9 +27,9 @@ Ask / consult commands:
27
27
  prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
28
28
  prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
29
29
  prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
30
- prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
30
+ prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
31
31
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
32
- prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
32
+ prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
33
33
 
34
34
  Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
35
35
  prodex init [--cwd /absolute/path/to/repo]
@@ -168,9 +168,9 @@ Commands:
168
168
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
169
169
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
170
170
  prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
171
- prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
+ prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
172
172
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
173
- prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
173
+ prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
174
174
 
175
175
  Use \`prodex pro ask\` for dry-run/manual previews.
176
176
  Use \`prodex pro browser ask\` only when you want an explicit visible-browser send.
package/dist/cli-pro.js CHANGED
@@ -575,7 +575,7 @@ export async function runProCommand(rest, io, runCliFn) {
575
575
  if (subcommand === "latest") {
576
576
  if (printHelpIfRequested(proArgs, "pro latest", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"] }))
577
577
  return 0;
578
- assertOnlyOptions(proArgs, "pro latest", ["--cwd", "--source-cli"]);
578
+ assertOnlyOptions(proArgs, "pro latest", ["--cwd", "--source-cli"], ["--json"]);
579
579
  const targetCwd = resolveCwdFlag(io.cwd, proArgs);
580
580
  const targetStore = new BridgeStore(targetCwd);
581
581
  const sourceCli = resolveOptionalFileFlag(io.cwd, proArgs, "--source-cli");
@@ -589,13 +589,19 @@ export async function runProCommand(rest, io, runCliFn) {
589
589
  }
590
590
  if (!consult)
591
591
  throw new Error("No GPT Pro answers found");
592
- io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
592
+ if (proArgs.includes("--json")) {
593
+ const modelUsed = await recordedModelUsed(targetStore, consult.task.id);
594
+ io.stdout(formatProAnswerJson(consult, sourceCli, answerOptions, modelUsed ? { modelUsed } : {}));
595
+ }
596
+ else {
597
+ io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
598
+ }
593
599
  return 0;
594
600
  }
595
601
  if (subcommand === "show") {
596
602
  if (printHelpIfRequested(proArgs, "pro show", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"], maxPositionals: 1 }))
597
603
  return 0;
598
- const [taskId] = readPositionalsWithOptions(proArgs, "pro show", 1, ["--cwd", "--source-cli"]);
604
+ const [taskId] = readPositionalsWithOptions(proArgs, "pro show", 1, ["--cwd", "--source-cli"], ["--json"]);
599
605
  if (!taskId)
600
606
  throw new Error("pro show requires <task-id|latest>");
601
607
  const targetCwd = resolveCwdFlag(io.cwd, proArgs);
@@ -611,7 +617,13 @@ export async function runProCommand(rest, io, runCliFn) {
611
617
  }
612
618
  if (!consult)
613
619
  throw new Error(taskId === "latest" ? "No GPT Pro answers found" : `GPT Pro answer not found: ${taskId}`);
614
- io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
620
+ if (proArgs.includes("--json")) {
621
+ const modelUsed = await recordedModelUsed(targetStore, consult.task.id);
622
+ io.stdout(formatProAnswerJson(consult, sourceCli, answerOptions, modelUsed ? { modelUsed } : {}));
623
+ }
624
+ else {
625
+ io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
626
+ }
615
627
  return 0;
616
628
  }
617
629
  if (subcommand === "debate-prompt") {
@@ -1432,6 +1444,42 @@ export function browserSendBlockerFromError(error) {
1432
1444
  next_step: "Resolve the visible browser issue manually, then rerun the consult if needed."
1433
1445
  };
1434
1446
  }
1447
+ /**
1448
+ * The same answer as `formatProAnswer`, shaped for a program.
1449
+ *
1450
+ * Every other read path is JSON; these two printed only the human rendering,
1451
+ * so an agent after the thread or the model that produced an answer had to
1452
+ * scrape prose.
1453
+ */
1454
+ export function formatProAnswerJson(consult, sourceCli, options = {}, extras = {}) {
1455
+ const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
1456
+ return JSON.stringify({
1457
+ task_id: consult.task.id,
1458
+ status: consult.result.status,
1459
+ thread: consult.task.provenance.thread ?? null,
1460
+ created_at: consult.result.created_at,
1461
+ answer: consult.result.summary,
1462
+ ...(extras.modelUsed ? { model_used: extras.modelUsed } : {}),
1463
+ warnings: consult.result.warnings ?? [],
1464
+ ...(blocker ? { blocker } : {})
1465
+ }, null, 2);
1466
+ }
1467
+ /**
1468
+ * Which model actually answered, from the receipt that recorded the answer.
1469
+ * It lives there rather than on the task because it is what ChatGPT tagged the
1470
+ * message with, not what prodex asked for.
1471
+ */
1472
+ async function recordedModelUsed(store, taskId) {
1473
+ try {
1474
+ const receipts = await store.listReceiptsReadOnly({ kind: "consult_answer_saved", task_id: taskId });
1475
+ const value = receipts[0]?.metadata?.model_used;
1476
+ return typeof value === "string" ? value : undefined;
1477
+ }
1478
+ catch {
1479
+ // A missing or unreadable receipt just means the field is unknown.
1480
+ return undefined;
1481
+ }
1482
+ }
1435
1483
  export function formatProAnswer(consult, sourceCli, options = {}) {
1436
1484
  const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
1437
1485
  const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
package/dist/cli.js CHANGED
@@ -70,6 +70,18 @@ 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
+ listConversations: async () => {
78
+ const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
79
+ return listRecentChatGptConversations({});
80
+ },
81
+ openThread: async (url) => {
82
+ const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
83
+ return navigateChatGptTabTo(url, {});
84
+ },
73
85
  listProjects: async () => {
74
86
  const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
75
87
  const listed = await listChatGptSidebarProjects({});
@@ -0,0 +1,34 @@
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
+ }
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, 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,104 @@ 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 conversationsPromise = deps.listConversations?.().catch(() => []);
156
+ const kindChoice = await pick(io, {
157
+ title: "What kind of send is this?",
158
+ step: { index: 1, total: 3 },
159
+ options: SEND_KINDS.map((kind) => ({ label: kind.label, ...(kind.hint ? { hint: kind.hint } : {}) }))
160
+ }, banner);
161
+ if (kindChoice === undefined)
162
+ return cancel(io);
163
+ const tools = SEND_KINDS[kindChoice].tools;
191
164
  const rows = (await contextPromise) ?? [];
192
165
  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
- ]
166
+ header = `${banner}${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
167
+ else
168
+ header = banner;
169
+ const pinnedProject = await pinnedPromise;
170
+ const destinations = destinationChoices(pinnedProject);
171
+ const destinationChoice = await pick(io, {
172
+ title: "Where should it go?",
173
+ step: { index: 2, total: 3 },
174
+ options: destinations.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
207
175
  }, header);
208
- if (projectChoice === undefined)
176
+ if (destinationChoice === undefined)
209
177
  return cancel(io);
210
- const modes = ["current", "existing", "new", "none"];
211
- const projectMode = modes[projectChoice];
178
+ const destination = destinations[destinationChoice].id;
179
+ let projectMode = "current";
212
180
  let projectName;
213
- if (projectMode === "existing") {
181
+ let targetUrl;
182
+ if (destination === "continue") {
183
+ const conversations = (await conversationsPromise) ?? [];
184
+ if (conversations.length === 0) {
185
+ io.write("\nNo recent conversations were readable. Start a new chat instead.\n");
186
+ return 1;
187
+ }
188
+ const chosen = await pick(io, { title: "Which conversation?", options: conversations.map((entry) => ({ label: entry.title })) }, header);
189
+ if (chosen === undefined)
190
+ return cancel(io);
191
+ targetUrl = conversationThreadUrl(conversations[chosen].id);
192
+ }
193
+ else if (destination === "project") {
214
194
  const projects = await deps.listProjects();
215
195
  if (projects.length === 0) {
216
196
  io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
217
197
  return 1;
218
198
  }
219
- const chosen = await pick(io, {
220
- title: "Which project?",
221
- options: projects.map((name) => ({ label: name }))
222
- }, header);
199
+ const chosen = await pick(io, { title: "Which project?", options: projects.map((name) => ({ label: name })) }, header);
223
200
  if (chosen === undefined)
224
201
  return cancel(io);
202
+ projectMode = "existing";
225
203
  projectName = projects[chosen];
226
204
  }
227
- else if (projectMode === "new") {
228
- projectName = await askLine(io, `${CLEAR}New project\n\n name: `);
205
+ else if (destination === "project-new") {
206
+ projectName = await askLine(io, `${CLEAR}${header}New project\n\n name: `);
229
207
  if (!projectName)
230
208
  return cancel(io);
209
+ projectMode = "new";
210
+ }
211
+ else if (destination === "no-project") {
212
+ projectMode = "none";
213
+ }
214
+ // The prompt comes last: what you type depends on what you just decided.
215
+ io.write(CLEAR + header + SHOW_CURSOR);
216
+ const kindLabel = SEND_KINDS[kindChoice].label;
217
+ const prompt = await askLine(io, `${kindLabel} Step 3 of 3\n\n prompt: `);
218
+ io.write(HIDE_CURSOR);
219
+ if (prompt.length === 0) {
220
+ io.write("Nothing to ask.\n");
221
+ return 1;
231
222
  }
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
223
  const choices = {
251
224
  prompt,
252
225
  projectMode,
253
226
  ...(projectName ? { projectName } : {}),
227
+ ...(targetUrl ? { targetUrl } : {}),
254
228
  tools,
255
- newChat: threadChoice === 1
229
+ newChat: !targetUrl
256
230
  };
257
231
  const args = consultArgsFromChoices(choices);
258
232
  // Leave the alternate screen before the send: the answer, the receipt id
259
233
  // and any blocker belong in the scrollback the user keeps.
260
234
  io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
235
+ // --target-url confirms which conversation a send means; it deliberately
236
+ // does not navigate. Picking one from a list IS a request to go there, so
237
+ // move the tab first and let the flag confirm it landed.
238
+ if (targetUrl && deps.openThread) {
239
+ io.write("Opening the conversation you picked...\n");
240
+ if (!(await deps.openThread(targetUrl))) {
241
+ io.write(`Could not open ${targetUrl} in the dedicated browser.\n`);
242
+ return 1;
243
+ }
244
+ }
261
245
  io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
262
246
  // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
263
247
  // 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,6 +24,17 @@ 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
+ for (const attachment of choices.attachments ?? [])
32
+ args.push("--attach", attachment);
33
+ for (const tool of choices.tools)
34
+ args.push("--tool", tool);
35
+ args.push("--", prompt);
36
+ return args;
37
+ }
23
38
  if (choices.newChat)
24
39
  args.push("--new-chat");
25
40
  if (choices.projectMode === "existing" || choices.projectMode === "new") {
@@ -38,6 +53,37 @@ export function consultArgsFromChoices(choices) {
38
53
  args.push("--", prompt);
39
54
  return args;
40
55
  }
56
+ /**
57
+ * Recent conversations with their titles, for the "continue an existing chat"
58
+ * list. Only the sidebar listing is fetched - the transcripts themselves are
59
+ * large and nothing here needs them.
60
+ */
61
+ export function recentConversationTitlesExpression(limit = 10) {
62
+ return `(async () => {
63
+ let token = "";
64
+ try {
65
+ const session = await fetch("/api/auth/session", { credentials: "include" });
66
+ if (!session.ok) return [];
67
+ const parsed = await session.json();
68
+ token = (parsed && parsed.accessToken) || "";
69
+ } catch (error) {
70
+ return [];
71
+ }
72
+ try {
73
+ const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
74
+ credentials: "include",
75
+ headers: token ? { Authorization: "Bearer " + token } : {}
76
+ });
77
+ if (!response.ok) return [];
78
+ const listed = await response.json();
79
+ return ((listed && listed.items) || [])
80
+ .filter((item) => item && item.id)
81
+ .map((item) => ({ id: item.id, title: (item.title || "").trim() || "Untitled" }));
82
+ } catch (error) {
83
+ return [];
84
+ }
85
+ })()`;
86
+ }
41
87
  const ESC = "";
42
88
  const DIM = `${ESC}[2m`;
43
89
  const BOLD = `${ESC}[1m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",