@youdie006/prodex 0.28.1 → 0.29.0
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/chatgpt-browser.js +56 -0
- package/dist/cli.js +12 -0
- package/dist/tui-flow.js +34 -0
- package/dist/tui-run.js +73 -89
- package/dist/tui.js +46 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -2462,6 +2462,62 @@ export function sidebarProjectNamesExpression() {
|
|
|
2462
2462
|
// Read-only discovery for --project/setup --project: list the sidebar project
|
|
2463
2463
|
// names exactly as ChatGPT renders them, so nobody has to guess spelling or
|
|
2464
2464
|
// case. Polls briefly because the Projects section hydrates after navigation.
|
|
2465
|
+
/**
|
|
2466
|
+
* Recent conversations with their titles, for the interactive "continue an
|
|
2467
|
+
* existing chat" list.
|
|
2468
|
+
*/
|
|
2469
|
+
/**
|
|
2470
|
+
* Point the dedicated tab at a conversation and wait until it is really there.
|
|
2471
|
+
*
|
|
2472
|
+
* `--target-url` confirms which conversation a send means; it deliberately does
|
|
2473
|
+
* not navigate, so nothing moves a shared browser behind the user's back. When
|
|
2474
|
+
* the user has just picked a conversation from a list, moving there IS the
|
|
2475
|
+
* request, so the picker navigates first and then confirms.
|
|
2476
|
+
*/
|
|
2477
|
+
export async function navigateChatGptTabTo(url, options = {}) {
|
|
2478
|
+
const port = resolveCdpPort(options.port);
|
|
2479
|
+
const page = await findChatGptPage(port, 3_000);
|
|
2480
|
+
if (!page.ok || !page.page)
|
|
2481
|
+
return false;
|
|
2482
|
+
const target = normalizeChatGptTargetUrl(url);
|
|
2483
|
+
const cdp = await connectCdp(page.page.webSocketDebuggerUrl);
|
|
2484
|
+
try {
|
|
2485
|
+
await cdp.send("Runtime.enable");
|
|
2486
|
+
await cdp.evaluate(`location.assign(${JSON.stringify(target)})`);
|
|
2487
|
+
const deadline = Date.now() + Math.max(1_000, options.timeoutMs ?? 20_000);
|
|
2488
|
+
while (Date.now() < deadline) {
|
|
2489
|
+
await sleep(500);
|
|
2490
|
+
try {
|
|
2491
|
+
const here = await cdp.evaluate("location.href");
|
|
2492
|
+
if (typeof here === "string" && chatGptUrlsReferToSameTarget(here, target))
|
|
2493
|
+
return true;
|
|
2494
|
+
}
|
|
2495
|
+
catch {
|
|
2496
|
+
// Mid-navigation evaluate failures are expected; keep polling.
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
finally {
|
|
2502
|
+
cdp.close();
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
export async function listRecentChatGptConversations(input = {}) {
|
|
2506
|
+
const port = resolveCdpPort(input.port);
|
|
2507
|
+
const page = await findChatGptPage(port, input.timeoutMs ?? 3_000);
|
|
2508
|
+
if (!page.ok || !page.page)
|
|
2509
|
+
return [];
|
|
2510
|
+
const { recentConversationTitlesExpression } = await import("./tui.js");
|
|
2511
|
+
try {
|
|
2512
|
+
return ((await evaluateOnPage(page.page, recentConversationTitlesExpression(input.limit ?? 10), {
|
|
2513
|
+
timeoutMs: 30_000
|
|
2514
|
+
})) ?? []);
|
|
2515
|
+
}
|
|
2516
|
+
catch {
|
|
2517
|
+
// Nothing to continue from is a normal answer here, not a failure.
|
|
2518
|
+
return [];
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2465
2521
|
export async function listChatGptSidebarProjects(input = {}) {
|
|
2466
2522
|
const port = resolveCdpPort(input.port);
|
|
2467
2523
|
const timeoutMs = input.timeoutMs ?? 15_000;
|
package/dist/cli.js
CHANGED
|
@@ -70,6 +70,18 @@ async function runInteractiveUi(io) {
|
|
|
70
70
|
{ label: "browser", value: browser }
|
|
71
71
|
];
|
|
72
72
|
},
|
|
73
|
+
pinnedProject: async () => {
|
|
74
|
+
const { loadBrowserDefaults } = await import("./config.js");
|
|
75
|
+
return (await loadBrowserDefaults(io.cwd).catch(() => undefined))?.project;
|
|
76
|
+
},
|
|
77
|
+
listConversations: async () => {
|
|
78
|
+
const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
|
|
79
|
+
return listRecentChatGptConversations({});
|
|
80
|
+
},
|
|
81
|
+
openThread: async (url) => {
|
|
82
|
+
const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
|
|
83
|
+
return navigateChatGptTabTo(url, {});
|
|
84
|
+
},
|
|
73
85
|
listProjects: async () => {
|
|
74
86
|
const { listChatGptSidebarProjects } = await import("./chatgpt-browser.js");
|
|
75
87
|
const listed = await listChatGptSidebarProjects({});
|
package/dist/tui-flow.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the interactive picker asks, in the order it asks it.
|
|
3
|
+
*
|
|
4
|
+
* The first version asked for the prompt first and treated the kind of send as
|
|
5
|
+
* an afterthought, which is backwards: whether this is an ordinary chat or a
|
|
6
|
+
* ten-minute research run changes what you would type. Deciding the kind, then
|
|
7
|
+
* the destination, then writing the prompt matches how the request is actually
|
|
8
|
+
* formed - and it leaves the seconds spent choosing free for prodex to fetch
|
|
9
|
+
* the project and conversation lists the destination step needs.
|
|
10
|
+
*/
|
|
11
|
+
export const SEND_KINDS = [
|
|
12
|
+
{ id: "chat", label: "Normal chat", hint: "ordinary Pro answer", tools: [] },
|
|
13
|
+
{ id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes", tools: ["deep-research"] },
|
|
14
|
+
{ id: "web-search", label: "Web search", hint: "current facts, with sources", tools: ["web-search"] },
|
|
15
|
+
{ id: "create-image", label: "Create image", tools: ["create-image"] }
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* One screen for the whole destination question. Splitting "which project" from
|
|
19
|
+
* "new thread or not" made the user answer twice for one decision, and left no
|
|
20
|
+
* way at all to say "that conversation, the one from yesterday".
|
|
21
|
+
*/
|
|
22
|
+
export function destinationChoices(pinnedProject) {
|
|
23
|
+
return [
|
|
24
|
+
{ id: "continue", label: "Continue an existing conversation", hint: "pick from your recent chats" },
|
|
25
|
+
{
|
|
26
|
+
id: "new",
|
|
27
|
+
label: "New chat",
|
|
28
|
+
...(pinnedProject ? { hint: `in your pinned project: ${pinnedProject}` } : { hint: "in the plain chat list" })
|
|
29
|
+
},
|
|
30
|
+
{ id: "project", label: "New chat in an existing project", hint: "pick from your sidebar projects" },
|
|
31
|
+
{ id: "project-new", label: "New chat in a new project" },
|
|
32
|
+
...(pinnedProject ? [{ id: "no-project", label: "New chat, ignoring the pinned project" }] : [])
|
|
33
|
+
];
|
|
34
|
+
}
|
package/dist/tui-run.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
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 {
|
|
10
|
+
import { renderBanner } from "./banner.js";
|
|
11
|
+
import { destinationChoices, SEND_KINDS } from "./tui-flow.js";
|
|
12
|
+
import { consultArgsFromChoices, conversationThreadUrl, moveCursor, progressLabel, renderContextPanel, renderProgressBar, renderSelectList } from "./tui.js";
|
|
11
13
|
const ESC = "";
|
|
12
14
|
const CLEAR = `${ESC}[2J${ESC}[H`;
|
|
13
15
|
// The alternate screen buffer: the terminal comes back exactly as it was, so a
|
|
@@ -115,38 +117,6 @@ function numericChoice(key, length) {
|
|
|
115
117
|
return undefined;
|
|
116
118
|
return digit - 1;
|
|
117
119
|
}
|
|
118
|
-
async function pickMany(io, input, header = "") {
|
|
119
|
-
let cursor = 0;
|
|
120
|
-
let selected = [];
|
|
121
|
-
for (;;) {
|
|
122
|
-
io.write(CLEAR +
|
|
123
|
-
header +
|
|
124
|
-
renderSelectList({
|
|
125
|
-
...input,
|
|
126
|
-
cursor,
|
|
127
|
-
selected,
|
|
128
|
-
multi: true,
|
|
129
|
-
width: terminalWidth(),
|
|
130
|
-
color: colorEnabled(),
|
|
131
|
-
footer: " space toggle 1-9 toggle directly enter confirm (none is fine) q cancel"
|
|
132
|
-
}) +
|
|
133
|
-
"\n");
|
|
134
|
-
const key = await readKey(io);
|
|
135
|
-
if (isCancel(key))
|
|
136
|
-
return undefined;
|
|
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")
|
|
141
|
-
cursor = moveCursor(cursor, "up", input.options.length);
|
|
142
|
-
else if (key.name === "down" || key.name === "j")
|
|
143
|
-
cursor = moveCursor(cursor, "down", input.options.length);
|
|
144
|
-
else if (key.name === "space")
|
|
145
|
-
selected = toggleSelection(selected, cursor);
|
|
146
|
-
else if (isConfirm(key))
|
|
147
|
-
return selected;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
120
|
/** Read one line with the terminal back in cooked mode, so editing works. */
|
|
151
121
|
async function askLine(io, question) {
|
|
152
122
|
io.input.setRawMode?.(false);
|
|
@@ -161,11 +131,6 @@ async function askLine(io, question) {
|
|
|
161
131
|
keys?.drain();
|
|
162
132
|
return answer.trim();
|
|
163
133
|
}
|
|
164
|
-
const TOOL_CHOICES = [
|
|
165
|
-
{ id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes" },
|
|
166
|
-
{ id: "web-search", label: "Web search", hint: "current facts, with sources" },
|
|
167
|
-
{ id: "create-image", label: "Create image" }
|
|
168
|
-
];
|
|
169
134
|
/**
|
|
170
135
|
* Walk the questions a send needs, then run it with a moving progress bar.
|
|
171
136
|
* Returns the process exit code.
|
|
@@ -179,85 +144,104 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
179
144
|
io.write(ALT_SCREEN_ON + HIDE_CURSOR);
|
|
180
145
|
let header = "";
|
|
181
146
|
try {
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
147
|
+
// The logo is the program saying which program it is; the panel says what
|
|
148
|
+
// this send will use. Both belong above the first question, not nowhere.
|
|
149
|
+
const banner = colorEnabled() ? `${renderBanner({ color: true })}\n` : "";
|
|
150
|
+
io.write(CLEAR + banner);
|
|
151
|
+
// Everything the later screens need is fetched while the first question is
|
|
152
|
+
// being read, so no step waits on the network.
|
|
186
153
|
const contextPromise = deps.describeContext?.().catch(() => []);
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
154
|
+
const pinnedPromise = deps.pinnedProject?.().catch(() => undefined);
|
|
155
|
+
const conversationsPromise = deps.listConversations?.().catch(() => []);
|
|
156
|
+
const kindChoice = await pick(io, {
|
|
157
|
+
title: "What kind of send is this?",
|
|
158
|
+
step: { index: 1, total: 3 },
|
|
159
|
+
options: SEND_KINDS.map((kind) => ({ label: kind.label, ...(kind.hint ? { hint: kind.hint } : {}) }))
|
|
160
|
+
}, banner);
|
|
161
|
+
if (kindChoice === undefined)
|
|
162
|
+
return cancel(io);
|
|
163
|
+
const tools = SEND_KINDS[kindChoice].tools;
|
|
191
164
|
const rows = (await contextPromise) ?? [];
|
|
192
165
|
if (rows.length > 0)
|
|
193
|
-
header = `${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
title: "Where should
|
|
200
|
-
step: { index:
|
|
201
|
-
options:
|
|
202
|
-
{ label: "The chat that is already open", hint: "keeps the thread's context" },
|
|
203
|
-
{ label: "An existing project" },
|
|
204
|
-
{ label: "A new project" },
|
|
205
|
-
{ label: "No project", hint: "plain chat list, ignores a pinned default" }
|
|
206
|
-
]
|
|
166
|
+
header = `${banner}${renderContextPanel(rows, { color: colorEnabled(), width: terminalWidth() })}\n\n`;
|
|
167
|
+
else
|
|
168
|
+
header = banner;
|
|
169
|
+
const pinnedProject = await pinnedPromise;
|
|
170
|
+
const destinations = destinationChoices(pinnedProject);
|
|
171
|
+
const destinationChoice = await pick(io, {
|
|
172
|
+
title: "Where should it go?",
|
|
173
|
+
step: { index: 2, total: 3 },
|
|
174
|
+
options: destinations.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
|
|
207
175
|
}, header);
|
|
208
|
-
if (
|
|
176
|
+
if (destinationChoice === undefined)
|
|
209
177
|
return cancel(io);
|
|
210
|
-
const
|
|
211
|
-
|
|
178
|
+
const destination = destinations[destinationChoice].id;
|
|
179
|
+
let projectMode = "current";
|
|
212
180
|
let projectName;
|
|
213
|
-
|
|
181
|
+
let targetUrl;
|
|
182
|
+
if (destination === "continue") {
|
|
183
|
+
const conversations = (await conversationsPromise) ?? [];
|
|
184
|
+
if (conversations.length === 0) {
|
|
185
|
+
io.write("\nNo recent conversations were readable. Start a new chat instead.\n");
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
const chosen = await pick(io, { title: "Which conversation?", options: conversations.map((entry) => ({ label: entry.title })) }, header);
|
|
189
|
+
if (chosen === undefined)
|
|
190
|
+
return cancel(io);
|
|
191
|
+
targetUrl = conversationThreadUrl(conversations[chosen].id);
|
|
192
|
+
}
|
|
193
|
+
else if (destination === "project") {
|
|
214
194
|
const projects = await deps.listProjects();
|
|
215
195
|
if (projects.length === 0) {
|
|
216
196
|
io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
|
|
217
197
|
return 1;
|
|
218
198
|
}
|
|
219
|
-
const chosen = await pick(io, {
|
|
220
|
-
title: "Which project?",
|
|
221
|
-
options: projects.map((name) => ({ label: name }))
|
|
222
|
-
}, header);
|
|
199
|
+
const chosen = await pick(io, { title: "Which project?", options: projects.map((name) => ({ label: name })) }, header);
|
|
223
200
|
if (chosen === undefined)
|
|
224
201
|
return cancel(io);
|
|
202
|
+
projectMode = "existing";
|
|
225
203
|
projectName = projects[chosen];
|
|
226
204
|
}
|
|
227
|
-
else if (
|
|
228
|
-
projectName = await askLine(io, `${CLEAR}New project\n\n name: `);
|
|
205
|
+
else if (destination === "project-new") {
|
|
206
|
+
projectName = await askLine(io, `${CLEAR}${header}New project\n\n name: `);
|
|
229
207
|
if (!projectName)
|
|
230
208
|
return cancel(io);
|
|
209
|
+
projectMode = "new";
|
|
210
|
+
}
|
|
211
|
+
else if (destination === "no-project") {
|
|
212
|
+
projectMode = "none";
|
|
213
|
+
}
|
|
214
|
+
// The prompt comes last: what you type depends on what you just decided.
|
|
215
|
+
io.write(CLEAR + header + SHOW_CURSOR);
|
|
216
|
+
const kindLabel = SEND_KINDS[kindChoice].label;
|
|
217
|
+
const prompt = await askLine(io, `${kindLabel} Step 3 of 3\n\n prompt: `);
|
|
218
|
+
io.write(HIDE_CURSOR);
|
|
219
|
+
if (prompt.length === 0) {
|
|
220
|
+
io.write("Nothing to ask.\n");
|
|
221
|
+
return 1;
|
|
231
222
|
}
|
|
232
|
-
const toolChoice = await pickMany(io, {
|
|
233
|
-
title: "Turn on any composer tools for this send",
|
|
234
|
-
step: { index: 2, total: 3 },
|
|
235
|
-
options: TOOL_CHOICES.map((tool) => ({ label: tool.label, ...(tool.hint ? { hint: tool.hint } : {}) }))
|
|
236
|
-
}, header);
|
|
237
|
-
if (toolChoice === undefined)
|
|
238
|
-
return cancel(io);
|
|
239
|
-
const tools = toolChoice.map((index) => TOOL_CHOICES[index].id);
|
|
240
|
-
const threadChoice = await pick(io, {
|
|
241
|
-
title: "Start a fresh thread?",
|
|
242
|
-
step: { index: 3, total: 3 },
|
|
243
|
-
options: [
|
|
244
|
-
{ label: "Continue the current thread", hint: "follow-ups keep context" },
|
|
245
|
-
{ label: "Start a new chat", hint: "recommended for an unrelated question" }
|
|
246
|
-
]
|
|
247
|
-
}, header);
|
|
248
|
-
if (threadChoice === undefined)
|
|
249
|
-
return cancel(io);
|
|
250
223
|
const choices = {
|
|
251
224
|
prompt,
|
|
252
225
|
projectMode,
|
|
253
226
|
...(projectName ? { projectName } : {}),
|
|
227
|
+
...(targetUrl ? { targetUrl } : {}),
|
|
254
228
|
tools,
|
|
255
|
-
newChat:
|
|
229
|
+
newChat: !targetUrl
|
|
256
230
|
};
|
|
257
231
|
const args = consultArgsFromChoices(choices);
|
|
258
232
|
// Leave the alternate screen before the send: the answer, the receipt id
|
|
259
233
|
// and any blocker belong in the scrollback the user keeps.
|
|
260
234
|
io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
|
|
235
|
+
// --target-url confirms which conversation a send means; it deliberately
|
|
236
|
+
// does not navigate. Picking one from a list IS a request to go there, so
|
|
237
|
+
// move the tab first and let the flag confirm it landed.
|
|
238
|
+
if (targetUrl && deps.openThread) {
|
|
239
|
+
io.write("Opening the conversation you picked...\n");
|
|
240
|
+
if (!(await deps.openThread(targetUrl))) {
|
|
241
|
+
io.write(`Could not open ${targetUrl} in the dedicated browser.\n`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
261
245
|
io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
|
|
262
246
|
// Deep research runs about ten minutes; an ordinary Pro answer, minutes.
|
|
263
247
|
// Fill the bar against that so the wait has a shape.
|
package/dist/tui.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* The rendering and the choice-to-argument mapping are pure so they can be
|
|
11
11
|
* tested without a terminal; only `runInteractiveConsult` touches stdin.
|
|
12
12
|
*/
|
|
13
|
+
/** Where a conversation lives, given its id. */
|
|
14
|
+
export function conversationThreadUrl(conversationId) {
|
|
15
|
+
return `https://chatgpt.com/c/${conversationId}`;
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Map the picker's answers onto the exact command line a person could have
|
|
15
19
|
* typed. Keeping this a pure function means the interactive path cannot drift
|
|
@@ -20,6 +24,17 @@ export function consultArgsFromChoices(choices) {
|
|
|
20
24
|
if (prompt.length === 0)
|
|
21
25
|
throw new Error("The prompt is empty - there is nothing to ask.");
|
|
22
26
|
const args = ["pro", "browser", "ask"];
|
|
27
|
+
// A picked conversation is the whole destination: the send rejects a project
|
|
28
|
+
// or a fresh chat alongside a pinned target, and rightly so.
|
|
29
|
+
if (choices.targetUrl) {
|
|
30
|
+
args.push("--target-url", choices.targetUrl, "--confirm-target");
|
|
31
|
+
for (const attachment of choices.attachments ?? [])
|
|
32
|
+
args.push("--attach", attachment);
|
|
33
|
+
for (const tool of choices.tools)
|
|
34
|
+
args.push("--tool", tool);
|
|
35
|
+
args.push("--", prompt);
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
23
38
|
if (choices.newChat)
|
|
24
39
|
args.push("--new-chat");
|
|
25
40
|
if (choices.projectMode === "existing" || choices.projectMode === "new") {
|
|
@@ -38,6 +53,37 @@ export function consultArgsFromChoices(choices) {
|
|
|
38
53
|
args.push("--", prompt);
|
|
39
54
|
return args;
|
|
40
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Recent conversations with their titles, for the "continue an existing chat"
|
|
58
|
+
* list. Only the sidebar listing is fetched - the transcripts themselves are
|
|
59
|
+
* large and nothing here needs them.
|
|
60
|
+
*/
|
|
61
|
+
export function recentConversationTitlesExpression(limit = 10) {
|
|
62
|
+
return `(async () => {
|
|
63
|
+
let token = "";
|
|
64
|
+
try {
|
|
65
|
+
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
66
|
+
if (!session.ok) return [];
|
|
67
|
+
const parsed = await session.json();
|
|
68
|
+
token = (parsed && parsed.accessToken) || "";
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
|
|
74
|
+
credentials: "include",
|
|
75
|
+
headers: token ? { Authorization: "Bearer " + token } : {}
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok) return [];
|
|
78
|
+
const listed = await response.json();
|
|
79
|
+
return ((listed && listed.items) || [])
|
|
80
|
+
.filter((item) => item && item.id)
|
|
81
|
+
.map((item) => ({ id: item.id, title: (item.title || "").trim() || "Untitled" }));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
})()`;
|
|
86
|
+
}
|
|
41
87
|
const ESC = "";
|
|
42
88
|
const DIM = `${ESC}[2m`;
|
|
43
89
|
const BOLD = `${ESC}[1m`;
|