@youdie006/prodex 0.27.0 → 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/dist/cli.js CHANGED
@@ -51,6 +51,25 @@ const DOCTOR_REQUIRED_MCP_TOOLS = [
51
51
  async function runInteractiveUi(io) {
52
52
  const { runInteractiveConsult } = await import("./tui-run.js");
53
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
+ },
54
73
  listProjects: async () => {
55
74
  const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
56
75
  const listed = await listChatGptSidebarProjects({});
package/dist/tui-run.js CHANGED
@@ -7,9 +7,13 @@
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, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
10
+ import { consultArgsFromChoices, moveCursor, renderContextPanel, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
11
11
  const ESC = "";
12
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`;
13
17
  const CLEAR_LINE = `${ESC}[2K\r`;
14
18
  const HIDE_CURSOR = `${ESC}[?25l`;
15
19
  const SHOW_CURSOR = `${ESC}[?25h`;
@@ -30,13 +34,25 @@ function isCancel(key) {
30
34
  function isConfirm(key) {
31
35
  return key.name === "return" || key.name === "enter";
32
36
  }
33
- async function pick(io, input) {
37
+ async function pick(io, input, header = "") {
34
38
  let cursor = 0;
35
39
  for (;;) {
36
- io.write(CLEAR + renderSelectList({ ...input, cursor, footer: " up/down move, enter choose, q cancel" }) + "\n");
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");
37
50
  const key = await readKey(io);
38
51
  if (isCancel(key))
39
52
  return undefined;
53
+ const typed = numericChoice(key, input.options.length);
54
+ if (typed !== undefined)
55
+ return typed;
40
56
  if (key.name === "up" || key.name === "k")
41
57
  cursor = moveCursor(cursor, "up", input.options.length);
42
58
  else if (key.name === "down" || key.name === "j")
@@ -45,23 +61,43 @@ async function pick(io, input) {
45
61
  return cursor;
46
62
  }
47
63
  }
48
- async function pickMany(io, input) {
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 = "") {
49
79
  let cursor = 0;
50
80
  let selected = [];
51
81
  for (;;) {
52
82
  io.write(CLEAR +
83
+ header +
53
84
  renderSelectList({
54
85
  ...input,
55
86
  cursor,
56
87
  selected,
57
88
  multi: true,
58
- footer: " space toggle, enter confirm (none is fine), q cancel"
89
+ width: terminalWidth(),
90
+ color: colorEnabled(),
91
+ footer: " space toggle 1-9 toggle directly enter confirm (none is fine) q cancel"
59
92
  }) +
60
93
  "\n");
61
94
  const key = await readKey(io);
62
95
  if (isCancel(key))
63
96
  return undefined;
64
- if (key.name === "up" || key.name === "k")
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")
65
101
  cursor = moveCursor(cursor, "up", input.options.length);
66
102
  else if (key.name === "down" || key.name === "j")
67
103
  cursor = moveCursor(cursor, "down", input.options.length);
@@ -98,23 +134,38 @@ export async function runInteractiveConsult(io, deps) {
98
134
  readline.emitKeypressEvents(io.input);
99
135
  io.input.setRawMode?.(true);
100
136
  io.input.resume();
101
- io.write(HIDE_CURSOR);
137
+ io.write(ALT_SCREEN_ON + HIDE_CURSOR);
138
+ let header = "";
102
139
  try {
103
140
  io.write(CLEAR);
104
- const prompt = await askLine(io, "Ask ChatGPT Pro\n\n prompt: ");
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: `);
105
155
  if (prompt.length === 0) {
106
156
  io.write("Nothing to ask.\n");
107
157
  return 1;
108
158
  }
109
159
  const projectChoice = await pick(io, {
110
160
  title: "Where should this consult land?",
161
+ step: { index: 1, total: 3 },
111
162
  options: [
112
163
  { label: "The chat that is already open", hint: "keeps the thread's context" },
113
164
  { label: "An existing project" },
114
165
  { label: "A new project" },
115
166
  { label: "No project", hint: "plain chat list, ignores a pinned default" }
116
167
  ]
117
- });
168
+ }, header);
118
169
  if (projectChoice === undefined)
119
170
  return cancel(io);
120
171
  const modes = ["current", "existing", "new", "none"];
@@ -129,7 +180,7 @@ export async function runInteractiveConsult(io, deps) {
129
180
  const chosen = await pick(io, {
130
181
  title: "Which project?",
131
182
  options: projects.map((name) => ({ label: name }))
132
- });
183
+ }, header);
133
184
  if (chosen === undefined)
134
185
  return cancel(io);
135
186
  projectName = projects[chosen];
@@ -141,18 +192,20 @@ export async function runInteractiveConsult(io, deps) {
141
192
  }
142
193
  const toolChoice = await pickMany(io, {
143
194
  title: "Turn on any composer tools for this send",
195
+ step: { index: 2, total: 3 },
144
196
  options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
145
- });
197
+ }, header);
146
198
  if (toolChoice === undefined)
147
199
  return cancel(io);
148
200
  const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
149
201
  const threadChoice = await pick(io, {
150
202
  title: "Start a fresh thread?",
203
+ step: { index: 3, total: 3 },
151
204
  options: [
152
205
  { label: "Continue the current thread", hint: "follow-ups keep context" },
153
206
  { label: "Start a new chat", hint: "recommended for an unrelated question" }
154
207
  ]
155
- });
208
+ }, header);
156
209
  if (threadChoice === undefined)
157
210
  return cancel(io);
158
211
  const choices = {
@@ -163,16 +216,21 @@ export async function runInteractiveConsult(io, deps) {
163
216
  newChat: threadChoice === 1
164
217
  };
165
218
  const args = consultArgsFromChoices(choices);
166
- io.write(CLEAR + SHOW_CURSOR);
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);
167
222
  io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
168
223
  // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
169
224
  // Fill the bar against that so the wait has a shape.
170
225
  const budgetMs = tools.includes("deep-research") ? 30 * 60_000 : 20 * 60_000;
171
226
  const startedAt = now();
172
227
  let label = "starting";
228
+ let tick = 0;
173
229
  const timer = setInterval(() => {
174
- io.write(CLEAR_LINE + renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label }));
175
- }, 500);
230
+ tick += 1;
231
+ io.write(CLEAR_LINE +
232
+ renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label, tick, width: Math.min(28, terminalWidth() - 52) }));
233
+ }, 250);
176
234
  try {
177
235
  const code = await deps.runConsult(args, (line) => {
178
236
  label = line.replace(/^progress:\s*/, "").slice(0, 60);
@@ -188,7 +246,7 @@ export async function runInteractiveConsult(io, deps) {
188
246
  }
189
247
  }
190
248
  finally {
191
- io.write(SHOW_CURSOR);
249
+ io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
192
250
  io.input.setRawMode?.(false);
193
251
  io.input.pause();
194
252
  }
package/dist/tui.js CHANGED
@@ -38,22 +38,67 @@ export function consultArgsFromChoices(choices) {
38
38
  args.push("--", prompt);
39
39
  return args;
40
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
+ }
41
56
  export function renderSelectList(input) {
57
+ const color = input.color ?? true;
58
+ const width = input.width ?? 100;
42
59
  const selected = new Set(input.selected ?? []);
43
- const lines = [input.title, ""];
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}`, ""];
44
62
  // Hints line up in their own column; ragged hints read as noise next to the
45
63
  // labels they belong to.
46
64
  const labelWidth = Math.max(...input.options.map((option) => option.label.length), 0);
47
65
  input.options.forEach((option, index) => {
48
- const pointer = index === input.cursor ? ">" : " ";
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);
49
73
  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}`);
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)));
52
78
  });
53
79
  if (input.footer)
54
- lines.push("", input.footer);
80
+ lines.push("", paint(input.footer, DIM, color));
55
81
  return lines.join("\n");
56
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
+ }
57
102
  export function moveCursor(cursor, direction, length) {
58
103
  if (length <= 0)
59
104
  return 0;
@@ -70,6 +115,7 @@ function formatElapsed(ms) {
70
115
  const rest = seconds % 60;
71
116
  return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
72
117
  }
118
+ const SPINNER = ["|", "/", "-", "\\"];
73
119
  /**
74
120
  * A Pro consult can run for many minutes with nothing on screen. The bar fills
75
121
  * against the send's own budget, and says so plainly once the wait outlives it
@@ -77,12 +123,16 @@ function formatElapsed(ms) {
77
123
  */
78
124
  export function renderProgressBar(input) {
79
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";
80
130
  if (!input.budgetMs || input.budgetMs <= 0)
81
- return `${input.label} ${elapsed}`;
131
+ return `${spinner} ${input.label} ${elapsed} ${stop}`;
82
132
  const width = Math.max(4, input.width ?? 28);
83
133
  const ratio = input.elapsedMs / input.budgetMs;
84
134
  const filled = Math.min(width, Math.round(Math.min(ratio, 1) * width));
85
135
  const bar = `${"#".repeat(filled)}${"-".repeat(width - filled)}`;
86
136
  const over = ratio > 1 ? " over budget" : "";
87
- return `[${bar}] ${elapsed} / ${formatElapsed(input.budgetMs)} ${input.label}${over}`;
137
+ return `${spinner} [${bar}] ${elapsed} / ${formatElapsed(input.budgetMs)} ${input.label}${over} ${stop}`;
88
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.27.0",
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",