@youdie006/prodex 0.26.1 → 0.27.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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
35
35
 
36
36
  `prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
37
37
 
38
- The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). It is read from the conversation transcript rather than scraped off the page, so markdown tables and fenced code arrive intact, citations keep their links, and a tab that drifts to another conversation mid-wait no longer costs you the answer; the rendered page stays as a fallback. Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to inline several files. `--file` puts a text file's CONTENTS into the prompt; `--attach` uploads the file itself, which is the only way to hand ChatGPT a pdf, pptx, xlsx or image and let it parse the original (`prodex ask --attach deck.pptx "Review slides 40-60"`). Both are restricted to paths inside the repo, so an agent cannot upload `~/.ssh` by asking nicely. The upload happens before the prompt is submitted and prodex waits for ChatGPT to finish accepting the file - the browser process reads the path, so the file has to live on the machine running the browser. `--tool` turns on a ChatGPT composer tool for that send: `--tool deep-research` (a browsed report - the timeout rises to 30 minutes automatically; prodex presses the start control, waits out the run and returns the full report. The report is read from the conversation transcript rather than the page, because deep research renders inside a widget iframe that leaves the thread looking empty - which also means `prodex pro browser recover --target-url <thread>` fetches a research report that finished after a timeout), `--tool web-search` (current facts with sources), `--tool create-image`. Any other label the menu shows works too, so a tool ChatGPT adds later needs no prodex release. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
38
+ Prefer to drive it yourself instead of remembering flags? `prodex ui` - or just `prodex` in a terminal - asks for the prompt, where the consult should land (the open chat, an existing project, a new one, or no project at all), which composer tools to turn on, and then shows a progress bar while Pro writes; it prints the equivalent command so the flags are learnable from it. The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). It is read from the conversation transcript rather than scraped off the page, so markdown tables and fenced code arrive intact, citations keep their links, and a tab that drifts to another conversation mid-wait no longer costs you the answer; the rendered page stays as a fallback. Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to inline several files. `--file` puts a text file's CONTENTS into the prompt; `--attach` uploads the file itself, which is the only way to hand ChatGPT a pdf, pptx, xlsx or image and let it parse the original (`prodex ask --attach deck.pptx "Review slides 40-60"`). Both are restricted to paths inside the repo, so an agent cannot upload `~/.ssh` by asking nicely. The upload happens before the prompt is submitted and prodex waits for ChatGPT to finish accepting the file - the browser process reads the path, so the file has to live on the machine running the browser. `--tool` turns on a ChatGPT composer tool for that send: `--tool deep-research` (a browsed report - the timeout rises to 30 minutes automatically; prodex presses the start control, waits out the run and returns the full report. The report is read from the conversation transcript rather than the page, because deep research renders inside a widget iframe that leaves the thread looking empty - which also means `prodex pro browser recover --target-url <thread>` fetches a research report that finished after a timeout), `--tool web-search` (current facts with sources), `--tool create-image`. Any other label the menu shows works too, so a tool ChatGPT adds later needs no prodex release. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
39
39
 
40
40
  ## Core Shape
41
41
 
package/dist/cli-args.js CHANGED
@@ -20,7 +20,8 @@ export const TOP_LEVEL_COMMANDS = [
20
20
  "sessions",
21
21
  "pro",
22
22
  "release",
23
- "mcp"
23
+ "mcp",
24
+ "ui"
24
25
  ];
25
26
  export function isHelpSubcommand(value) {
26
27
  return value === "help" || value === "--help" || value === "-h";
@@ -255,7 +256,9 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
255
256
  "--stdin",
256
257
  "--json",
257
258
  "--auto-login",
258
- "--no-auto-login"
259
+ "--no-auto-login",
260
+ // Send outside any project for once, overriding a pinned default.
261
+ "--no-project"
259
262
  ]);
260
263
  export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--model", "--pro-mode", "--effort"];
261
264
  export const ASK_PRO_VALUE_FLAGS = new Set([
package/dist/cli-help.js CHANGED
@@ -58,6 +58,7 @@ Agent / MCP integration:
58
58
  prodex claude prompt [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
59
59
  prodex claude config [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
60
60
  prodex project prompt [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
61
+ prodex ui # interactive: pick project/tools, watch progress (plain prodex in a terminal opens this)
61
62
  prodex onboard [--cwd /absolute/path/to/repo] [--source-cli /absolute/path/to/dist/cli.js]
62
63
 
63
64
  Maintenance:
package/dist/cli-pro.js CHANGED
@@ -152,6 +152,7 @@ export async function runProCommand(rest, io, runCliFn) {
152
152
  "--confirm-target",
153
153
  "--project",
154
154
  "--project-new",
155
+ "--no-project",
155
156
  "--model",
156
157
  "--pro-mode",
157
158
  "--effort",
@@ -797,6 +798,12 @@ export async function runAskProCommand(rest, io) {
797
798
  const browserDefaults = await loadBrowserDefaults(targetCwd);
798
799
  const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
799
800
  const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
801
+ // Opt out of a pinned default project for one send. Without this a repo
802
+ // that pins a project has no route back to the plain chat list.
803
+ const suppressProject = parsedAskPro.optionArgs.includes("--no-project");
804
+ if (suppressProject && (explicitProject !== undefined || explicitProjectNew !== undefined)) {
805
+ throw new Error("ask-pro cannot combine --no-project with --project/--project-new; pick one.");
806
+ }
800
807
  if (explicitProject !== undefined && explicitProjectNew !== undefined) {
801
808
  throw new Error("ask-pro cannot combine --project and --project-new; pick an existing project or create one.");
802
809
  }
@@ -827,7 +834,7 @@ export async function runAskProCommand(rest, io) {
827
834
  // consults stop landing in the general chat list. Only --target-url
828
835
  // (pinned tab) and --project-new suppress it.
829
836
  const selectionProject = explicitProject ??
830
- (normalizedTargetUrl || selectionProjectNew !== undefined ? undefined : browserDefaults?.project);
837
+ (normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject ? undefined : browserDefaults?.project);
831
838
  const reasoningAxisChosen = explicitProMode !== undefined || explicitEffort !== undefined;
832
839
  const selectionProMode = explicitProMode ?? (reasoningAxisChosen ? undefined : browserDefaults?.pro_mode);
833
840
  const selectionEffort = explicitEffort ?? (reasoningAxisChosen ? undefined : browserDefaults?.effort);
package/dist/cli.js CHANGED
@@ -44,6 +44,29 @@ const DOCTOR_REQUIRED_MCP_TOOLS = [
44
44
  "repo_write_file_apply",
45
45
  "repo_stage_reviewed_paths"
46
46
  ];
47
+ /**
48
+ * Run the interactive picker and hand its choices to the ordinary send path, so
49
+ * the terminal flow and the documented flags cannot drift apart.
50
+ */
51
+ async function runInteractiveUi(io) {
52
+ const { runInteractiveConsult } = await import("./tui-run.js");
53
+ return runInteractiveConsult({ write: (text) => process.stdout.write(text), input: process.stdin }, {
54
+ listProjects: async () => {
55
+ const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
56
+ const listed = await listChatGptSidebarProjects({});
57
+ return listed.projects;
58
+ },
59
+ runConsult: (args, onProgress) => runCli(args, {
60
+ ...io,
61
+ stderr: (line) => {
62
+ if (line.startsWith("progress:"))
63
+ onProgress(line);
64
+ else
65
+ io.stderr(line);
66
+ }
67
+ })
68
+ });
69
+ }
47
70
  export async function runCli(args, io = defaultIo()) {
48
71
  const [command, ...rest] = args;
49
72
  const store = new BridgeStore(io.cwd);
@@ -51,12 +74,21 @@ export async function runCli(args, io = defaultIo()) {
51
74
  io.stdout(CLI_VERSION);
52
75
  return 0;
53
76
  }
77
+ // A person typing `prodex` used to get the agent-facing command wall and no
78
+ // way in. On a terminal, walk them through a consult instead; piped or
79
+ // scripted callers still get the banner and the command list they parse.
80
+ if (!command && io.isInteractive === true)
81
+ return runInteractiveUi(io);
54
82
  if (!command || command === "help" || command === "--help" || command === "-h") {
55
83
  if (shouldColorize())
56
84
  io.stdout(renderBanner({ color: true }));
57
85
  printHelp(io.stdout);
58
86
  return 0;
59
87
  }
88
+ if (command === "ui") {
89
+ assertOnlyOptions(rest, "ui", []);
90
+ return runInteractiveUi(io);
91
+ }
60
92
  if (command === "init")
61
93
  return runInitCommand(rest, io);
62
94
  if (command === "setup")
@@ -322,6 +354,7 @@ repo: ${cwd}
322
354
  ${cli} pro browser help${sourceCliOption}
323
355
  ${cli} pro browser check${sourceCliOption} --cwd ${quotedCwd}
324
356
  ${cli} pro browser smoke${sourceCliOption} --cwd ${quotedCwd}
357
+ Prefer to drive it yourself? ${cli} ui (or just ${cli} in a terminal) asks where the consult should land, which composer tools to turn on, and shows a progress bar while Pro writes.
325
358
  Sharing the browser with other agents? Sends queue behind an in-flight response automatically; pass --busy-wait-ms 0 to fail fast instead.
326
359
  Hand ChatGPT a real file - the only way it can open a pdf, pptx, xlsx or image:
327
360
  ${cli} ask --cwd ${quotedCwd} --attach deck.pptx "Review slides 40-60" # uploads the file itself
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The interactive shell around the pure pickers in `tui.ts`.
3
+ *
4
+ * Node's readline in raw mode is enough for a list picker, so the package keeps
5
+ * carrying no UI dependency. Everything here is I/O; the decisions it collects
6
+ * are turned into a normal command line by `consultArgsFromChoices`, which is
7
+ * what keeps the interactive path from drifting away from the documented flags.
8
+ */
9
+ import readline from "node:readline";
10
+ import { consultArgsFromChoices, moveCursor, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
11
+ const ESC = "";
12
+ const CLEAR = `${ESC}[2J${ESC}[H`;
13
+ const CLEAR_LINE = `${ESC}[2K\r`;
14
+ const HIDE_CURSOR = `${ESC}[?25l`;
15
+ const SHOW_CURSOR = `${ESC}[?25h`;
16
+ function readKey(io) {
17
+ return new Promise((resolve) => {
18
+ const onKey = (_str, key) => {
19
+ io.input.off("keypress", onKey);
20
+ resolve(key ?? {});
21
+ };
22
+ io.input.on("keypress", onKey);
23
+ });
24
+ }
25
+ function isCancel(key) {
26
+ return (key.ctrl === true && key.name === "c") || key.name === "escape" || key.name === "q";
27
+ }
28
+ // Terminals disagree about the Enter key: a carriage return arrives as
29
+ // "return", a line feed as "enter". Accept either, or the picker looks frozen.
30
+ function isConfirm(key) {
31
+ return key.name === "return" || key.name === "enter";
32
+ }
33
+ async function pick(io, input) {
34
+ let cursor = 0;
35
+ for (;;) {
36
+ io.write(CLEAR + renderSelectList({ ...input, cursor, footer: " up/down move, enter choose, q cancel" }) + "\n");
37
+ const key = await readKey(io);
38
+ if (isCancel(key))
39
+ return undefined;
40
+ if (key.name === "up" || key.name === "k")
41
+ cursor = moveCursor(cursor, "up", input.options.length);
42
+ else if (key.name === "down" || key.name === "j")
43
+ cursor = moveCursor(cursor, "down", input.options.length);
44
+ else if (isConfirm(key))
45
+ return cursor;
46
+ }
47
+ }
48
+ async function pickMany(io, input) {
49
+ let cursor = 0;
50
+ let selected = [];
51
+ for (;;) {
52
+ io.write(CLEAR +
53
+ renderSelectList({
54
+ ...input,
55
+ cursor,
56
+ selected,
57
+ multi: true,
58
+ footer: " space toggle, enter confirm (none is fine), q cancel"
59
+ }) +
60
+ "\n");
61
+ const key = await readKey(io);
62
+ if (isCancel(key))
63
+ return undefined;
64
+ if (key.name === "up" || key.name === "k")
65
+ cursor = moveCursor(cursor, "up", input.options.length);
66
+ else if (key.name === "down" || key.name === "j")
67
+ cursor = moveCursor(cursor, "down", input.options.length);
68
+ else if (key.name === "space")
69
+ selected = toggleSelection(selected, cursor);
70
+ else if (isConfirm(key))
71
+ return selected;
72
+ }
73
+ }
74
+ /** Read one line with the terminal back in cooked mode, so editing works. */
75
+ async function askLine(io, question) {
76
+ io.input.setRawMode?.(false);
77
+ const rl = readline.createInterface({ input: io.input, output: process.stdout, terminal: true });
78
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
79
+ rl.close();
80
+ // Closing the line reader detaches the keypress plumbing and pauses the
81
+ // stream, so the next picker would sit there ignoring every key. Re-arm both.
82
+ io.input.setRawMode?.(true);
83
+ readline.emitKeypressEvents(io.input);
84
+ io.input.resume();
85
+ return answer.trim();
86
+ }
87
+ const TOOL_CHOICES = [
88
+ { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes" },
89
+ { id: "web-search", label: "Web search", hint: "current facts, with sources" },
90
+ { id: "create-image", label: "Create image" }
91
+ ];
92
+ /**
93
+ * Walk the questions a send needs, then run it with a moving progress bar.
94
+ * Returns the process exit code.
95
+ */
96
+ export async function runInteractiveConsult(io, deps) {
97
+ const now = deps.now ?? Date.now;
98
+ readline.emitKeypressEvents(io.input);
99
+ io.input.setRawMode?.(true);
100
+ io.input.resume();
101
+ io.write(HIDE_CURSOR);
102
+ try {
103
+ io.write(CLEAR);
104
+ const prompt = await askLine(io, "Ask ChatGPT Pro\n\n prompt: ");
105
+ if (prompt.length === 0) {
106
+ io.write("Nothing to ask.\n");
107
+ return 1;
108
+ }
109
+ const projectChoice = await pick(io, {
110
+ title: "Where should this consult land?",
111
+ options: [
112
+ { label: "The chat that is already open", hint: "keeps the thread's context" },
113
+ { label: "An existing project" },
114
+ { label: "A new project" },
115
+ { label: "No project", hint: "plain chat list, ignores a pinned default" }
116
+ ]
117
+ });
118
+ if (projectChoice === undefined)
119
+ return cancel(io);
120
+ const modes = ["current", "existing", "new", "none"];
121
+ const projectMode = modes[projectChoice];
122
+ let projectName;
123
+ if (projectMode === "existing") {
124
+ const projects = await deps.listProjects();
125
+ if (projects.length === 0) {
126
+ io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
127
+ return 1;
128
+ }
129
+ const chosen = await pick(io, {
130
+ title: "Which project?",
131
+ options: projects.map((name) => ({ label: name }))
132
+ });
133
+ if (chosen === undefined)
134
+ return cancel(io);
135
+ projectName = projects[chosen];
136
+ }
137
+ else if (projectMode === "new") {
138
+ projectName = await askLine(io, `${CLEAR}New project\n\n name: `);
139
+ if (!projectName)
140
+ return cancel(io);
141
+ }
142
+ const toolChoice = await pickMany(io, {
143
+ title: "Turn on any composer tools for this send",
144
+ options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
145
+ });
146
+ if (toolChoice === undefined)
147
+ return cancel(io);
148
+ const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
149
+ const threadChoice = await pick(io, {
150
+ title: "Start a fresh thread?",
151
+ options: [
152
+ { label: "Continue the current thread", hint: "follow-ups keep context" },
153
+ { label: "Start a new chat", hint: "recommended for an unrelated question" }
154
+ ]
155
+ });
156
+ if (threadChoice === undefined)
157
+ return cancel(io);
158
+ const choices = {
159
+ prompt,
160
+ projectMode,
161
+ ...(projectName ? { projectName } : {}),
162
+ tools,
163
+ newChat: threadChoice === 1
164
+ };
165
+ const args = consultArgsFromChoices(choices);
166
+ io.write(CLEAR + SHOW_CURSOR);
167
+ io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
168
+ // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
169
+ // Fill the bar against that so the wait has a shape.
170
+ const budgetMs = tools.includes("deep-research") ? 30 * 60_000 : 20 * 60_000;
171
+ const startedAt = now();
172
+ let label = "starting";
173
+ const timer = setInterval(() => {
174
+ io.write(CLEAR_LINE + renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label }));
175
+ }, 500);
176
+ try {
177
+ const code = await deps.runConsult(args, (line) => {
178
+ label = line.replace(/^progress:\s*/, "").slice(0, 60);
179
+ });
180
+ clearInterval(timer);
181
+ io.write(CLEAR_LINE);
182
+ return code;
183
+ }
184
+ catch (error) {
185
+ clearInterval(timer);
186
+ io.write(CLEAR_LINE);
187
+ throw error;
188
+ }
189
+ }
190
+ finally {
191
+ io.write(SHOW_CURSOR);
192
+ io.input.setRawMode?.(false);
193
+ io.input.pause();
194
+ }
195
+ }
196
+ function cancel(io) {
197
+ io.write("\nCancelled.\n");
198
+ return 130;
199
+ }
200
+ /** Quote only what a shell would need quoted, so the echo can be pasted. */
201
+ export function formatCommand(args) {
202
+ return args.map((arg) => (/^[A-Za-z0-9._\-/:=]+$/.test(arg) ? arg : JSON.stringify(arg))).join(" ");
203
+ }
package/dist/tui.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The interactive side of prodex.
3
+ *
4
+ * Everything an agent needs is already reachable as flags, but a person typing
5
+ * `prodex` got a wall of commands and no way in. This module is the picker that
6
+ * asks the same questions the flags answer - where the consult should land,
7
+ * which composer tools to turn on - and then shows something moving while a
8
+ * multi-minute Pro answer is written.
9
+ *
10
+ * The rendering and the choice-to-argument mapping are pure so they can be
11
+ * tested without a terminal; only `runInteractiveConsult` touches stdin.
12
+ */
13
+ /**
14
+ * Map the picker's answers onto the exact command line a person could have
15
+ * typed. Keeping this a pure function means the interactive path cannot drift
16
+ * from the documented flags - and the TUI can print the command it ran.
17
+ */
18
+ export function consultArgsFromChoices(choices) {
19
+ const prompt = choices.prompt.trim();
20
+ if (prompt.length === 0)
21
+ throw new Error("The prompt is empty - there is nothing to ask.");
22
+ const args = ["pro", "browser", "ask"];
23
+ if (choices.newChat)
24
+ args.push("--new-chat");
25
+ if (choices.projectMode === "existing" || choices.projectMode === "new") {
26
+ const name = choices.projectName?.trim();
27
+ if (!name)
28
+ throw new Error("Pick a project name, or choose to send without a project.");
29
+ args.push(choices.projectMode === "existing" ? "--project" : "--project-new", name);
30
+ }
31
+ // A pinned default project would otherwise swallow this choice.
32
+ if (choices.projectMode === "none")
33
+ args.push("--no-project");
34
+ for (const attachment of choices.attachments ?? [])
35
+ args.push("--attach", attachment);
36
+ for (const tool of choices.tools)
37
+ args.push("--tool", tool);
38
+ args.push("--", prompt);
39
+ return args;
40
+ }
41
+ export function renderSelectList(input) {
42
+ const selected = new Set(input.selected ?? []);
43
+ const lines = [input.title, ""];
44
+ // Hints line up in their own column; ragged hints read as noise next to the
45
+ // labels they belong to.
46
+ const labelWidth = Math.max(...input.options.map((option) => option.label.length), 0);
47
+ input.options.forEach((option, index) => {
48
+ const pointer = index === input.cursor ? ">" : " ";
49
+ const box = input.multi ? (selected.has(index) ? "[x] " : "[ ] ") : "";
50
+ const hint = option.hint ? `${" ".repeat(labelWidth - option.label.length)} ${option.hint}` : "";
51
+ lines.push(` ${pointer} ${box}${option.label}${hint}`);
52
+ });
53
+ if (input.footer)
54
+ lines.push("", input.footer);
55
+ return lines.join("\n");
56
+ }
57
+ export function moveCursor(cursor, direction, length) {
58
+ if (length <= 0)
59
+ return 0;
60
+ return direction === "up" ? (cursor - 1 + length) % length : (cursor + 1) % length;
61
+ }
62
+ export function toggleSelection(selected, index) {
63
+ return selected.includes(index) ? selected.filter((entry) => entry !== index) : [...selected, index].sort((a, b) => a - b);
64
+ }
65
+ function formatElapsed(ms) {
66
+ const seconds = Math.floor(ms / 1000);
67
+ if (seconds < 60)
68
+ return `${seconds}s`;
69
+ const minutes = Math.floor(seconds / 60);
70
+ const rest = seconds % 60;
71
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
72
+ }
73
+ /**
74
+ * A Pro consult can run for many minutes with nothing on screen. The bar fills
75
+ * against the send's own budget, and says so plainly once the wait outlives it
76
+ * rather than sitting at 100% pretending to be finished.
77
+ */
78
+ export function renderProgressBar(input) {
79
+ const elapsed = formatElapsed(input.elapsedMs);
80
+ if (!input.budgetMs || input.budgetMs <= 0)
81
+ return `${input.label} ${elapsed}`;
82
+ const width = Math.max(4, input.width ?? 28);
83
+ const ratio = input.elapsedMs / input.budgetMs;
84
+ const filled = Math.min(width, Math.round(Math.min(ratio, 1) * width));
85
+ const bar = `${"#".repeat(filled)}${"-".repeat(width - filled)}`;
86
+ const over = ratio > 1 ? " over budget" : "";
87
+ return `[${bar}] ${elapsed} / ${formatElapsed(input.budgetMs)} ${input.label}${over}`;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",