@youdie006/prodex 0.27.0 → 0.27.2

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,20 +7,64 @@
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`;
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
- });
20
+ /**
21
+ * Keys are queued by one long-lived listener rather than a listener attached
22
+ * per read.
23
+ *
24
+ * Attaching on demand drops every key pressed while nothing is waiting - during
25
+ * a redraw, or across the seconds it takes to read the project list out of the
26
+ * browser. Measured by using it: the first key after the prompt vanished, so
27
+ * "4" (no project) was swallowed and the next key landed on the wrong row.
28
+ */
29
+ class KeyQueue {
30
+ input;
31
+ pending = [];
32
+ waiting;
33
+ constructor(input) {
34
+ this.input = input;
35
+ this.input.on("keypress", this.onKey);
36
+ }
37
+ onKey = (_str, key) => {
38
+ const value = key ?? {};
39
+ if (this.waiting) {
40
+ const resolve = this.waiting;
41
+ this.waiting = undefined;
42
+ resolve(value);
43
+ return;
44
+ }
45
+ this.pending.push(value);
46
+ };
47
+ next() {
48
+ const buffered = this.pending.shift();
49
+ if (buffered)
50
+ return Promise.resolve(buffered);
51
+ return new Promise((resolve) => {
52
+ this.waiting = resolve;
53
+ });
54
+ }
55
+ /** Drop keys typed before a screen existed to receive them. */
56
+ drain() {
57
+ this.pending.length = 0;
58
+ }
59
+ dispose() {
60
+ this.input.off("keypress", this.onKey);
61
+ }
62
+ }
63
+ let keys;
64
+ function readKey(_io) {
65
+ if (!keys)
66
+ throw new Error("The key reader is not running.");
67
+ return keys.next();
24
68
  }
25
69
  function isCancel(key) {
26
70
  return (key.ctrl === true && key.name === "c") || key.name === "escape" || key.name === "q";
@@ -30,13 +74,25 @@ function isCancel(key) {
30
74
  function isConfirm(key) {
31
75
  return key.name === "return" || key.name === "enter";
32
76
  }
33
- async function pick(io, input) {
77
+ async function pick(io, input, header = "") {
34
78
  let cursor = 0;
35
79
  for (;;) {
36
- io.write(CLEAR + renderSelectList({ ...input, cursor, footer: " up/down move, enter choose, q cancel" }) + "\n");
80
+ io.write(CLEAR +
81
+ header +
82
+ renderSelectList({
83
+ ...input,
84
+ cursor,
85
+ width: terminalWidth(),
86
+ color: colorEnabled(),
87
+ footer: " up/down move 1-9 choose directly enter confirm q cancel"
88
+ }) +
89
+ "\n");
37
90
  const key = await readKey(io);
38
91
  if (isCancel(key))
39
92
  return undefined;
93
+ const typed = numericChoice(key, input.options.length);
94
+ if (typed !== undefined)
95
+ return typed;
40
96
  if (key.name === "up" || key.name === "k")
41
97
  cursor = moveCursor(cursor, "up", input.options.length);
42
98
  else if (key.name === "down" || key.name === "j")
@@ -45,23 +101,43 @@ async function pick(io, input) {
45
101
  return cursor;
46
102
  }
47
103
  }
48
- async function pickMany(io, input) {
104
+ function terminalWidth() {
105
+ return Math.max(40, Math.min(process.stdout.columns ?? 100, 120));
106
+ }
107
+ function colorEnabled() {
108
+ return process.stdout.isTTY === true && !process.env.NO_COLOR;
109
+ }
110
+ // Typing the row number is faster than arrowing to it, and matches how the
111
+ // pickers this sits beside are driven.
112
+ function numericChoice(key, length) {
113
+ const digit = Number(key.name);
114
+ if (!Number.isInteger(digit) || digit < 1 || digit > Math.min(9, length))
115
+ return undefined;
116
+ return digit - 1;
117
+ }
118
+ async function pickMany(io, input, header = "") {
49
119
  let cursor = 0;
50
120
  let selected = [];
51
121
  for (;;) {
52
122
  io.write(CLEAR +
123
+ header +
53
124
  renderSelectList({
54
125
  ...input,
55
126
  cursor,
56
127
  selected,
57
128
  multi: true,
58
- footer: " space toggle, enter confirm (none is fine), q cancel"
129
+ width: terminalWidth(),
130
+ color: colorEnabled(),
131
+ footer: " space toggle 1-9 toggle directly enter confirm (none is fine) q cancel"
59
132
  }) +
60
133
  "\n");
61
134
  const key = await readKey(io);
62
135
  if (isCancel(key))
63
136
  return undefined;
64
- if (key.name === "up" || key.name === "k")
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")
65
141
  cursor = moveCursor(cursor, "up", input.options.length);
66
142
  else if (key.name === "down" || key.name === "j")
67
143
  cursor = moveCursor(cursor, "down", input.options.length);
@@ -82,6 +158,7 @@ async function askLine(io, question) {
82
158
  io.input.setRawMode?.(true);
83
159
  readline.emitKeypressEvents(io.input);
84
160
  io.input.resume();
161
+ keys?.drain();
85
162
  return answer.trim();
86
163
  }
87
164
  const TOOL_CHOICES = [
@@ -98,23 +175,36 @@ export async function runInteractiveConsult(io, deps) {
98
175
  readline.emitKeypressEvents(io.input);
99
176
  io.input.setRawMode?.(true);
100
177
  io.input.resume();
101
- io.write(HIDE_CURSOR);
178
+ keys = new KeyQueue(io.input);
179
+ io.write(ALT_SCREEN_ON + HIDE_CURSOR);
180
+ let header = "";
102
181
  try {
103
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.
186
+ const contextPromise = deps.describeContext?.().catch(() => []);
104
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.
191
+ const rows = (await contextPromise) ?? [];
192
+ if (rows.length > 0)
193
+ header = `${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
105
194
  if (prompt.length === 0) {
106
195
  io.write("Nothing to ask.\n");
107
196
  return 1;
108
197
  }
109
198
  const projectChoice = await pick(io, {
110
199
  title: "Where should this consult land?",
200
+ step: { index: 1, total: 3 },
111
201
  options: [
112
202
  { label: "The chat that is already open", hint: "keeps the thread's context" },
113
203
  { label: "An existing project" },
114
204
  { label: "A new project" },
115
205
  { label: "No project", hint: "plain chat list, ignores a pinned default" }
116
206
  ]
117
- });
207
+ }, header);
118
208
  if (projectChoice === undefined)
119
209
  return cancel(io);
120
210
  const modes = ["current", "existing", "new", "none"];
@@ -129,7 +219,7 @@ export async function runInteractiveConsult(io, deps) {
129
219
  const chosen = await pick(io, {
130
220
  title: "Which project?",
131
221
  options: projects.map((name) => ({ label: name }))
132
- });
222
+ }, header);
133
223
  if (chosen === undefined)
134
224
  return cancel(io);
135
225
  projectName = projects[chosen];
@@ -141,18 +231,20 @@ export async function runInteractiveConsult(io, deps) {
141
231
  }
142
232
  const toolChoice = await pickMany(io, {
143
233
  title: "Turn on any composer tools for this send",
234
+ step: { index: 2, total: 3 },
144
235
  options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
145
- });
236
+ }, header);
146
237
  if (toolChoice === undefined)
147
238
  return cancel(io);
148
239
  const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
149
240
  const threadChoice = await pick(io, {
150
241
  title: "Start a fresh thread?",
242
+ step: { index: 3, total: 3 },
151
243
  options: [
152
244
  { label: "Continue the current thread", hint: "follow-ups keep context" },
153
245
  { label: "Start a new chat", hint: "recommended for an unrelated question" }
154
246
  ]
155
- });
247
+ }, header);
156
248
  if (threadChoice === undefined)
157
249
  return cancel(io);
158
250
  const choices = {
@@ -163,16 +255,21 @@ export async function runInteractiveConsult(io, deps) {
163
255
  newChat: threadChoice === 1
164
256
  };
165
257
  const args = consultArgsFromChoices(choices);
166
- io.write(CLEAR + SHOW_CURSOR);
258
+ // Leave the alternate screen before the send: the answer, the receipt id
259
+ // and any blocker belong in the scrollback the user keeps.
260
+ io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
167
261
  io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
168
262
  // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
169
263
  // Fill the bar against that so the wait has a shape.
170
264
  const budgetMs = tools.includes("deep-research") ? 30 * 60_000 : 20 * 60_000;
171
265
  const startedAt = now();
172
266
  let label = "starting";
267
+ let tick = 0;
173
268
  const timer = setInterval(() => {
174
- io.write(CLEAR_LINE + renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label }));
175
- }, 500);
269
+ tick += 1;
270
+ io.write(CLEAR_LINE +
271
+ renderProgressBar({ elapsedMs: now() - startedAt, budgetMs, label, tick, width: Math.min(28, terminalWidth() - 52) }));
272
+ }, 250);
176
273
  try {
177
274
  const code = await deps.runConsult(args, (line) => {
178
275
  label = line.replace(/^progress:\s*/, "").slice(0, 60);
@@ -188,7 +285,9 @@ export async function runInteractiveConsult(io, deps) {
188
285
  }
189
286
  }
190
287
  finally {
191
- io.write(SHOW_CURSOR);
288
+ keys?.dispose();
289
+ keys = undefined;
290
+ io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
192
291
  io.input.setRawMode?.(false);
193
292
  io.input.pause();
194
293
  }
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.2",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",