@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 +19 -0
- package/dist/tui-run.js +122 -23
- package/dist/tui.js +57 -7
- package/package.json +1 -1
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
|
137
|
+
return `${spinner} [${bar}] ${elapsed} / ${formatElapsed(input.budgetMs)} ${input.label}${over} ${stop}`;
|
|
88
138
|
}
|