@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 +19 -0
- package/dist/tui-run.js +74 -16
- 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,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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
}
|