@youdie006/prodex 0.26.1 → 0.27.1

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,48 @@ 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
+ describeContext: async () => {
55
+ const { loadBrowserDefaults } = await import("./config.js");
56
+ const { getChatGptBrowserStatus } = await import("./chatgpt-browser.js");
57
+ const defaults = await loadBrowserDefaults(io.cwd).catch(() => undefined);
58
+ const status = await getChatGptBrowserStatus({ timeoutMs: 1_500 }).catch(() => undefined);
59
+ const browser = !status?.reachable
60
+ ? "not running - prodex pro browser login"
61
+ : status.blocker
62
+ ? status.blocker.code
63
+ : status.loggedInLikely
64
+ ? "ready"
65
+ : "reachable, not logged in";
66
+ return [
67
+ { label: "repo", value: io.cwd },
68
+ { label: "model", value: defaults?.model ?? "whatever the ChatGPT UI has selected" },
69
+ { label: "project", value: defaults?.project ?? "none pinned" },
70
+ { label: "browser", value: browser }
71
+ ];
72
+ },
73
+ listProjects: async () => {
74
+ const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
75
+ const listed = await listChatGptSidebarProjects({});
76
+ return listed.projects;
77
+ },
78
+ runConsult: (args, onProgress) => runCli(args, {
79
+ ...io,
80
+ stderr: (line) => {
81
+ if (line.startsWith("progress:"))
82
+ onProgress(line);
83
+ else
84
+ io.stderr(line);
85
+ }
86
+ })
87
+ });
88
+ }
47
89
  export async function runCli(args, io = defaultIo()) {
48
90
  const [command, ...rest] = args;
49
91
  const store = new BridgeStore(io.cwd);
@@ -51,12 +93,21 @@ export async function runCli(args, io = defaultIo()) {
51
93
  io.stdout(CLI_VERSION);
52
94
  return 0;
53
95
  }
96
+ // A person typing `prodex` used to get the agent-facing command wall and no
97
+ // way in. On a terminal, walk them through a consult instead; piped or
98
+ // scripted callers still get the banner and the command list they parse.
99
+ if (!command && io.isInteractive === true)
100
+ return runInteractiveUi(io);
54
101
  if (!command || command === "help" || command === "--help" || command === "-h") {
55
102
  if (shouldColorize())
56
103
  io.stdout(renderBanner({ color: true }));
57
104
  printHelp(io.stdout);
58
105
  return 0;
59
106
  }
107
+ if (command === "ui") {
108
+ assertOnlyOptions(rest, "ui", []);
109
+ return runInteractiveUi(io);
110
+ }
60
111
  if (command === "init")
61
112
  return runInitCommand(rest, io);
62
113
  if (command === "setup")
@@ -322,6 +373,7 @@ repo: ${cwd}
322
373
  ${cli} pro browser help${sourceCliOption}
323
374
  ${cli} pro browser check${sourceCliOption} --cwd ${quotedCwd}
324
375
  ${cli} pro browser smoke${sourceCliOption} --cwd ${quotedCwd}
376
+ 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
377
  Sharing the browser with other agents? Sends queue behind an in-flight response automatically; pass --busy-wait-ms 0 to fail fast instead.
326
378
  Hand ChatGPT a real file - the only way it can open a pdf, pptx, xlsx or image:
327
379
  ${cli} ask --cwd ${quotedCwd} --attach deck.pptx "Review slides 40-60" # uploads the file itself
@@ -0,0 +1,261 @@
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, renderContextPanel, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
11
+ const ESC = "";
12
+ const CLEAR = `${ESC}[2J${ESC}[H`;
13
+ // The alternate screen buffer: the terminal comes back exactly as it was, so a
14
+ // picker does not shove the user's scrollback off the top.
15
+ const ALT_SCREEN_ON = `${ESC}[?1049h`;
16
+ const ALT_SCREEN_OFF = `${ESC}[?1049l`;
17
+ const CLEAR_LINE = `${ESC}[2K\r`;
18
+ const HIDE_CURSOR = `${ESC}[?25l`;
19
+ const SHOW_CURSOR = `${ESC}[?25h`;
20
+ function readKey(io) {
21
+ return new Promise((resolve) => {
22
+ const onKey = (_str, key) => {
23
+ io.input.off("keypress", onKey);
24
+ resolve(key ?? {});
25
+ };
26
+ io.input.on("keypress", onKey);
27
+ });
28
+ }
29
+ function isCancel(key) {
30
+ return (key.ctrl === true && key.name === "c") || key.name === "escape" || key.name === "q";
31
+ }
32
+ // Terminals disagree about the Enter key: a carriage return arrives as
33
+ // "return", a line feed as "enter". Accept either, or the picker looks frozen.
34
+ function isConfirm(key) {
35
+ return key.name === "return" || key.name === "enter";
36
+ }
37
+ async function pick(io, input, header = "") {
38
+ let cursor = 0;
39
+ for (;;) {
40
+ io.write(CLEAR +
41
+ header +
42
+ renderSelectList({
43
+ ...input,
44
+ cursor,
45
+ width: terminalWidth(),
46
+ color: colorEnabled(),
47
+ footer: " up/down move 1-9 choose directly enter confirm q cancel"
48
+ }) +
49
+ "\n");
50
+ const key = await readKey(io);
51
+ if (isCancel(key))
52
+ return undefined;
53
+ const typed = numericChoice(key, input.options.length);
54
+ if (typed !== undefined)
55
+ return typed;
56
+ if (key.name === "up" || key.name === "k")
57
+ cursor = moveCursor(cursor, "up", input.options.length);
58
+ else if (key.name === "down" || key.name === "j")
59
+ cursor = moveCursor(cursor, "down", input.options.length);
60
+ else if (isConfirm(key))
61
+ return cursor;
62
+ }
63
+ }
64
+ function terminalWidth() {
65
+ return Math.max(40, Math.min(process.stdout.columns ?? 100, 120));
66
+ }
67
+ function colorEnabled() {
68
+ return process.stdout.isTTY === true && !process.env.NO_COLOR;
69
+ }
70
+ // Typing the row number is faster than arrowing to it, and matches how the
71
+ // pickers this sits beside are driven.
72
+ function numericChoice(key, length) {
73
+ const digit = Number(key.name);
74
+ if (!Number.isInteger(digit) || digit < 1 || digit > Math.min(9, length))
75
+ return undefined;
76
+ return digit - 1;
77
+ }
78
+ async function pickMany(io, input, header = "") {
79
+ let cursor = 0;
80
+ let selected = [];
81
+ for (;;) {
82
+ io.write(CLEAR +
83
+ header +
84
+ renderSelectList({
85
+ ...input,
86
+ cursor,
87
+ selected,
88
+ multi: true,
89
+ width: terminalWidth(),
90
+ color: colorEnabled(),
91
+ footer: " space toggle 1-9 toggle directly enter confirm (none is fine) q cancel"
92
+ }) +
93
+ "\n");
94
+ const key = await readKey(io);
95
+ if (isCancel(key))
96
+ return undefined;
97
+ const typed = numericChoice(key, input.options.length);
98
+ if (typed !== undefined)
99
+ selected = toggleSelection(selected, typed);
100
+ else if (key.name === "up" || key.name === "k")
101
+ cursor = moveCursor(cursor, "up", input.options.length);
102
+ else if (key.name === "down" || key.name === "j")
103
+ cursor = moveCursor(cursor, "down", input.options.length);
104
+ else if (key.name === "space")
105
+ selected = toggleSelection(selected, cursor);
106
+ else if (isConfirm(key))
107
+ return selected;
108
+ }
109
+ }
110
+ /** Read one line with the terminal back in cooked mode, so editing works. */
111
+ async function askLine(io, question) {
112
+ io.input.setRawMode?.(false);
113
+ const rl = readline.createInterface({ input: io.input, output: process.stdout, terminal: true });
114
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
115
+ rl.close();
116
+ // Closing the line reader detaches the keypress plumbing and pauses the
117
+ // stream, so the next picker would sit there ignoring every key. Re-arm both.
118
+ io.input.setRawMode?.(true);
119
+ readline.emitKeypressEvents(io.input);
120
+ io.input.resume();
121
+ return answer.trim();
122
+ }
123
+ const TOOL_CHOICES = [
124
+ { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes" },
125
+ { id: "web-search", label: "Web search", hint: "current facts, with sources" },
126
+ { id: "create-image", label: "Create image" }
127
+ ];
128
+ /**
129
+ * Walk the questions a send needs, then run it with a moving progress bar.
130
+ * Returns the process exit code.
131
+ */
132
+ export async function runInteractiveConsult(io, deps) {
133
+ const now = deps.now ?? Date.now;
134
+ readline.emitKeypressEvents(io.input);
135
+ io.input.setRawMode?.(true);
136
+ io.input.resume();
137
+ io.write(ALT_SCREEN_ON + HIDE_CURSOR);
138
+ let header = "";
139
+ try {
140
+ io.write(CLEAR);
141
+ // Show what the send will use before asking anything: choosing a project or
142
+ // a tool without knowing the model, or whether the browser is even up, is
143
+ // choosing blind - and a browser failure after four questions wastes all four.
144
+ if (deps.describeContext) {
145
+ try {
146
+ const rows = await deps.describeContext();
147
+ if (rows.length > 0)
148
+ header = `${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
149
+ }
150
+ catch {
151
+ // Context is a courtesy; never let it block the consult.
152
+ }
153
+ }
154
+ const prompt = await askLine(io, `${header}Ask ChatGPT Pro\n\n prompt: `);
155
+ if (prompt.length === 0) {
156
+ io.write("Nothing to ask.\n");
157
+ return 1;
158
+ }
159
+ const projectChoice = await pick(io, {
160
+ title: "Where should this consult land?",
161
+ step: { index: 1, total: 3 },
162
+ options: [
163
+ { label: "The chat that is already open", hint: "keeps the thread's context" },
164
+ { label: "An existing project" },
165
+ { label: "A new project" },
166
+ { label: "No project", hint: "plain chat list, ignores a pinned default" }
167
+ ]
168
+ }, header);
169
+ if (projectChoice === undefined)
170
+ return cancel(io);
171
+ const modes = ["current", "existing", "new", "none"];
172
+ const projectMode = modes[projectChoice];
173
+ let projectName;
174
+ if (projectMode === "existing") {
175
+ const projects = await deps.listProjects();
176
+ if (projects.length === 0) {
177
+ io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
178
+ return 1;
179
+ }
180
+ const chosen = await pick(io, {
181
+ title: "Which project?",
182
+ options: projects.map((name) => ({ label: name }))
183
+ }, header);
184
+ if (chosen === undefined)
185
+ return cancel(io);
186
+ projectName = projects[chosen];
187
+ }
188
+ else if (projectMode === "new") {
189
+ projectName = await askLine(io, `${CLEAR}New project\n\n name: `);
190
+ if (!projectName)
191
+ return cancel(io);
192
+ }
193
+ const toolChoice = await pickMany(io, {
194
+ title: "Turn on any composer tools for this send",
195
+ step: { index: 2, total: 3 },
196
+ options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
197
+ }, header);
198
+ if (toolChoice === undefined)
199
+ return cancel(io);
200
+ const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
201
+ const threadChoice = await pick(io, {
202
+ title: "Start a fresh thread?",
203
+ step: { index: 3, total: 3 },
204
+ options: [
205
+ { label: "Continue the current thread", hint: "follow-ups keep context" },
206
+ { label: "Start a new chat", hint: "recommended for an unrelated question" }
207
+ ]
208
+ }, header);
209
+ if (threadChoice === undefined)
210
+ return cancel(io);
211
+ const choices = {
212
+ prompt,
213
+ projectMode,
214
+ ...(projectName ? { projectName } : {}),
215
+ tools,
216
+ newChat: threadChoice === 1
217
+ };
218
+ const args = consultArgsFromChoices(choices);
219
+ // Leave the alternate screen before the send: the answer, the receipt id
220
+ // and any blocker belong in the scrollback the user keeps.
221
+ io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
222
+ io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
223
+ // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
224
+ // Fill the bar against that so the wait has a shape.
225
+ const budgetMs = tools.includes("deep-research") ? 30 * 60_000 : 20 * 60_000;
226
+ const startedAt = now();
227
+ let label = "starting";
228
+ let tick = 0;
229
+ const timer = setInterval(() => {
230
+ tick += 1;
231
+ io.write(CLEAR_LINE +
232
+ renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label, tick, width: Math.min(28, terminalWidth() - 52) }));
233
+ }, 250);
234
+ try {
235
+ const code = await deps.runConsult(args, (line) => {
236
+ label = line.replace(/^progress:\s*/, "").slice(0, 60);
237
+ });
238
+ clearInterval(timer);
239
+ io.write(CLEAR_LINE);
240
+ return code;
241
+ }
242
+ catch (error) {
243
+ clearInterval(timer);
244
+ io.write(CLEAR_LINE);
245
+ throw error;
246
+ }
247
+ }
248
+ finally {
249
+ io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
250
+ io.input.setRawMode?.(false);
251
+ io.input.pause();
252
+ }
253
+ }
254
+ function cancel(io) {
255
+ io.write("\nCancelled.\n");
256
+ return 130;
257
+ }
258
+ /** Quote only what a shell would need quoted, so the echo can be pasted. */
259
+ export function formatCommand(args) {
260
+ return args.map((arg) => (/^[A-Za-z0-9._\-/:=]+$/.test(arg) ? arg : JSON.stringify(arg))).join(" ");
261
+ }
package/dist/tui.js ADDED
@@ -0,0 +1,138 @@
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
+ const ESC = "";
42
+ const DIM = `${ESC}[2m`;
43
+ const BOLD = `${ESC}[1m`;
44
+ const ACCENT = `${ESC}[38;2;190;28;28m`;
45
+ const RESET = `${ESC}[0m`;
46
+ const CURSOR_BAR = "▌";
47
+ function paint(text, code, color) {
48
+ return color ? `${code}${text}${RESET}` : text;
49
+ }
50
+ /** Cut a line to the terminal rather than letting it wrap into a second row. */
51
+ export function truncateToWidth(text, width) {
52
+ if (width <= 0 || text.length <= width)
53
+ return text;
54
+ return width <= 3 ? text.slice(0, width) : `${text.slice(0, width - 3)}...`;
55
+ }
56
+ export function renderSelectList(input) {
57
+ const color = input.color ?? true;
58
+ const width = input.width ?? 100;
59
+ const selected = new Set(input.selected ?? []);
60
+ const step = input.step ? paint(` Step ${input.step.index} of ${input.step.total}`, DIM, color) : "";
61
+ const lines = [`${paint(input.title, BOLD, color)}${step}`, ""];
62
+ // Hints line up in their own column; ragged hints read as noise next to the
63
+ // labels they belong to.
64
+ const labelWidth = Math.max(...input.options.map((option) => option.label.length), 0);
65
+ input.options.forEach((option, index) => {
66
+ const onCursor = index === input.cursor;
67
+ // A left bar reads as a cursor even once the row is colored, where a ">"
68
+ // competes with the text. Rows that are not on the cursor keep the same
69
+ // indent so nothing shifts as it moves.
70
+ const bar = onCursor ? paint(CURSOR_BAR, ACCENT, color) : " ";
71
+ // A number per row means a choice can be typed instead of arrowed to.
72
+ const ordinal = paint(`${index + 1}`, onCursor ? ACCENT : DIM, color);
73
+ const box = input.multi ? (selected.has(index) ? "[x] " : "[ ] ") : "";
74
+ const label = onCursor ? paint(option.label, BOLD, color) : option.label;
75
+ const gap = " ".repeat(Math.max(0, labelWidth - option.label.length));
76
+ const hint = option.hint ? `${gap} ${paint(option.hint, DIM, color)}` : "";
77
+ lines.push(truncateToWidth(` ${bar} ${ordinal} ${box}${label}${hint}`, width + (color ? 64 : 0)));
78
+ });
79
+ if (input.footer)
80
+ lines.push("", paint(input.footer, DIM, color));
81
+ return lines.join("\n");
82
+ }
83
+ /**
84
+ * The settings a send is about to use, framed above the questions.
85
+ *
86
+ * Picking a project or a tool without seeing which model is pinned, or whether
87
+ * the browser is even reachable, is choosing blind - and a send that fails on
88
+ * the browser after four questions wastes all four.
89
+ */
90
+ export function renderContextPanel(rows, options = {}) {
91
+ const color = options.color ?? true;
92
+ const labelWidth = Math.max(...rows.map((row) => row.label.length), 0);
93
+ const texts = rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.value} `);
94
+ // Size the frame to its contents, capped by the terminal. A box stretched to
95
+ // the full width is mostly empty space with a border around it.
96
+ const inner = Math.min(Math.max(...texts.map((text) => text.length), 0), Math.max(20, (options.width ?? 72) - 2));
97
+ const body = texts.map((text) => `│${truncateToWidth(text, inner).padEnd(inner)}│`);
98
+ const top = `╭${"─".repeat(inner)}╮`;
99
+ const bottom = `╰${"─".repeat(inner)}╯`;
100
+ return [top, ...body, bottom].map((line) => (color ? paint(line, DIM, color) : line)).join("\n");
101
+ }
102
+ export function moveCursor(cursor, direction, length) {
103
+ if (length <= 0)
104
+ return 0;
105
+ return direction === "up" ? (cursor - 1 + length) % length : (cursor + 1) % length;
106
+ }
107
+ export function toggleSelection(selected, index) {
108
+ return selected.includes(index) ? selected.filter((entry) => entry !== index) : [...selected, index].sort((a, b) => a - b);
109
+ }
110
+ function formatElapsed(ms) {
111
+ const seconds = Math.floor(ms / 1000);
112
+ if (seconds < 60)
113
+ return `${seconds}s`;
114
+ const minutes = Math.floor(seconds / 60);
115
+ const rest = seconds % 60;
116
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
117
+ }
118
+ const SPINNER = ["|", "/", "-", "\\"];
119
+ /**
120
+ * A Pro consult can run for many minutes with nothing on screen. The bar fills
121
+ * against the send's own budget, and says so plainly once the wait outlives it
122
+ * rather than sitting at 100% pretending to be finished.
123
+ */
124
+ export function renderProgressBar(input) {
125
+ const elapsed = formatElapsed(input.elapsedMs);
126
+ const spinner = SPINNER[Math.abs(input.tick ?? 0) % SPINNER.length];
127
+ // Saying how to abort belongs on the waiting line itself: a consult can run
128
+ // for many minutes and the only other thing on screen is the bar.
129
+ const stop = "ctrl-c to stop";
130
+ if (!input.budgetMs || input.budgetMs <= 0)
131
+ return `${spinner} ${input.label} ${elapsed} ${stop}`;
132
+ const width = Math.max(4, input.width ?? 28);
133
+ const ratio = input.elapsedMs / input.budgetMs;
134
+ const filled = Math.min(width, Math.round(Math.min(ratio, 1) * width));
135
+ const bar = `${"#".repeat(filled)}${"-".repeat(width - filled)}`;
136
+ const over = ratio > 1 ? " over budget" : "";
137
+ return `${spinner} [${bar}] ${elapsed} / ${formatElapsed(input.budgetMs)} ${input.label}${over} ${stop}`;
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.26.1",
3
+ "version": "0.27.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",