Skip to content

Commit e768e27

Browse files
authored
Merge pull request #8 from alchemyplatform/cm/cleanup
feat: polish interactive REPL command rendering
2 parents e77015b + 165b928 commit e768e27

4 files changed

Lines changed: 115 additions & 82 deletions

File tree

src/commands/interactive.ts

Lines changed: 76 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as readline from "node:readline";
2-
import { stdin, stdout } from "node:process";
2+
import { stdin, stdout, stderr } from "node:process";
33
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
44
import { join, dirname } from "node:path";
55
import type { Command } from "commander";
@@ -12,11 +12,11 @@ import {
1212
setBrandedHelpSuppressed,
1313
} from "../lib/ui.js";
1414
import { isJSONMode } from "../lib/output.js";
15-
import { ScrollbackBuffer } from "../lib/scrollback.js";
1615
import { setReplMode } from "../index.js";
1716
import { getRPCNetworkIds } from "../lib/networks.js";
1817
import { configDir, load as loadConfig } from "../lib/config.js";
1918
import { getSetupMethod } from "../lib/onboarding.js";
19+
import { bgRgb, rgb, noColor } from "../lib/colors.js";
2020

2121
const COMMAND_NAMES = [
2222
"apps",
@@ -205,6 +205,7 @@ export async function startREPL(program: Command): Promise<void> {
205205
const printIntro = (): void => {
206206
if (isJSONMode()) return;
207207
process.stdout.write(brandedHelp({ force: true }));
208+
console.log("");
208209
console.log(` ${brand("◆")} ${bold("Welcome to Alchemy CLI")}`);
209210
console.log(` ${green("✓")} ${dim(`Configured auth: ${formatSetupMethodLabel()}`)}`);
210211
console.log(` ${dim("Run commands directly (no 'alchemy' prefix).")}`);
@@ -217,11 +218,57 @@ export async function startREPL(program: Command): Promise<void> {
217218
console.log("");
218219
console.log(` ${dim("Press TAB for autocomplete. Type 'exit' or 'quit' to leave.")}`);
219220
console.log("");
221+
console.log("");
222+
};
223+
224+
const PROMPT_STR = "\x1b[38;2;54;63;249m›\x1b[39m ";
225+
const submittedCommandBg = bgRgb(64, 64, 68);
226+
const submittedCommandFg = rgb(232, 232, 236);
227+
const OUTPUT_INDENT = " ";
228+
const styleSubmittedCommand = (command: string): string => {
229+
if (!stdout.isTTY || noColor) return command;
230+
return submittedCommandBg(submittedCommandFg(` ${command} `));
220231
};
232+
const runWithIndentedOutput = async (fn: () => Promise<void>): Promise<void> => {
233+
if (isJSONMode() || !stdout.isTTY) {
234+
await fn();
235+
return;
236+
}
237+
238+
const createIndentedWriter = (orig: typeof stdout.write): typeof stdout.write => {
239+
let atLineStart = true;
240+
return function (chunk: Uint8Array | string, ...rest: unknown[]): boolean {
241+
const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
242+
if (!str) return true;
243+
244+
let out = "";
245+
for (const ch of str) {
246+
if (atLineStart && ch !== "\n" && ch !== "\r") {
247+
out += OUTPUT_INDENT;
248+
atLineStart = false;
249+
}
250+
out += ch;
251+
if (ch === "\n" || ch === "\r") {
252+
atLineStart = true;
253+
}
254+
}
221255

222-
const scrollback = new ScrollbackBuffer();
223-
const PROMPT_STR = "alchemy \x1b[38;2;54;63;249m◆\x1b[39m ";
224-
scrollback.setPrompt(PROMPT_STR);
256+
return orig(out, ...(rest as [BufferEncoding, () => void]));
257+
} as typeof stdout.write;
258+
};
259+
260+
const origStdoutWrite = stdout.write.bind(stdout);
261+
const origStderrWrite = stderr.write.bind(stderr);
262+
stdout.write = createIndentedWriter(origStdoutWrite);
263+
stderr.write = createIndentedWriter(origStderrWrite);
264+
265+
try {
266+
await fn();
267+
} finally {
268+
stdout.write = origStdoutWrite as typeof stdout.write;
269+
stderr.write = origStderrWrite as typeof stderr.write;
270+
}
271+
};
225272

226273
const rl = readline.createInterface({
227274
input: stdin,
@@ -231,40 +278,6 @@ export async function startREPL(program: Command): Promise<void> {
231278
removeHistoryDuplicates: true,
232279
});
233280

234-
// ── Mouse-event stdin filtering ────────────────────────────────────
235-
// Grab the data listener installed by readline/emitKeypressEvents and
236-
// replace it with a wrapper that strips SGR mouse sequences. The
237-
// original listener (the keypress parser) never sees mouse bytes.
238-
const MOUSE_SEQ_RE = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
239-
const dataListeners = stdin.listeners("data") as ((...args: unknown[]) => void)[];
240-
const origDataListener = dataListeners[dataListeners.length - 1];
241-
stdin.removeListener("data", origDataListener);
242-
243-
const filteredDataListener = (chunk: string | Buffer): void => {
244-
const str = typeof chunk === "string" ? chunk : chunk.toString("utf8");
245-
let match: RegExpExecArray | null;
246-
MOUSE_SEQ_RE.lastIndex = 0;
247-
while ((match = MOUSE_SEQ_RE.exec(str)) !== null) {
248-
const button = parseInt(match[1], 10);
249-
if (button === 64) {
250-
scrollback.scrollUp();
251-
} else if (button === 65) {
252-
scrollback.scrollDown();
253-
if (!scrollback.isScrolled) {
254-
// Reached the bottom via mouse scroll — readline doesn't know the
255-
// screen was redrawn, so force it to redraw the prompt.
256-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
257-
const r = rl as any;
258-
if (r.prevRows !== undefined) r.prevRows = 0;
259-
if (r._refreshLine) r._refreshLine();
260-
}
261-
}
262-
}
263-
const cleaned = str.replace(MOUSE_SEQ_RE, "");
264-
if (cleaned) origDataListener(cleaned);
265-
};
266-
stdin.on("data", filteredDataListener);
267-
268281
// ── History & inline suggestion ────────────────────────────────────
269282
const initialHistory = loadReplHistory();
270283
const rlWithHistory = rl as readline.Interface & { history?: string[] };
@@ -287,15 +300,6 @@ export async function startREPL(program: Command): Promise<void> {
287300
readline.moveCursor(stdout, -suggestion.length, 0);
288301
};
289302

290-
const rlWithRefresh = rl as readline.Interface & { _refreshLine?: () => void };
291-
const originalRefreshLine = rlWithRefresh._refreshLine?.bind(rl);
292-
if (originalRefreshLine) {
293-
rlWithRefresh._refreshLine = (): void => {
294-
originalRefreshLine();
295-
renderInlineSuggestion();
296-
};
297-
}
298-
299303
const acceptInlineCompletion = (): void => {
300304
const line = rl.line;
301305
const cursor = typeof rl.cursor === "number" ? rl.cursor : line.length;
@@ -325,14 +329,13 @@ export async function startREPL(program: Command): Promise<void> {
325329

326330
if (originalTTYWrite) {
327331
rlWithTTYWrite._ttyWrite = (s: string, key: readline.Key): void => {
332+
if (key?.name === "return" && !rl.line.trim()) {
333+
return;
334+
}
328335
if (key?.name === "tab") {
329336
acceptInlineCompletion();
330337
return;
331338
}
332-
if (scrollback.isScrolled) {
333-
scrollback.snapToBottom();
334-
if (rlWithRefresh._refreshLine) rlWithRefresh._refreshLine();
335-
}
336339
originalTTYWrite(s, key);
337340
};
338341
}
@@ -359,6 +362,12 @@ export async function startREPL(program: Command): Promise<void> {
359362
rl.prompt();
360363
};
361364

365+
const printPostOutputSpacing = (): void => {
366+
if (!isJSONMode() && stdout.isTTY) {
367+
console.log("");
368+
}
369+
};
370+
362371
const onLine = async (line: string): Promise<void> => {
363372
const trimmed = line.trim();
364373
if (!trimmed) {
@@ -371,6 +380,13 @@ export async function startREPL(program: Command): Promise<void> {
371380
return;
372381
}
373382

383+
if (!isJSONMode() && stdout.isTTY) {
384+
readline.moveCursor(stdout, 0, -1);
385+
readline.clearLine(stdout, 0);
386+
stdout.write(styleSubmittedCommand(trimmed) + "\n");
387+
console.log("");
388+
}
389+
374390
const words = trimmed.split(/\s+/);
375391

376392
// Friendly REPL help shortcuts:
@@ -379,10 +395,13 @@ export async function startREPL(program: Command): Promise<void> {
379395
if (words[0] === "help") {
380396
const target = words.slice(1);
381397
try {
382-
await program.parseAsync(["node", "alchemy", ...target, "--help"]);
398+
await runWithIndentedOutput(async () => {
399+
await program.parseAsync(["node", "alchemy", ...target, "--help"]);
400+
});
383401
} catch {
384402
// Commander help/errors are already handled by exitOverride
385403
}
404+
printPostOutputSpacing();
386405
prompt();
387406
return;
388407
}
@@ -395,11 +414,14 @@ export async function startREPL(program: Command): Promise<void> {
395414
}
396415

397416
try {
398-
await program.parseAsync(["node", "alchemy", ...words]);
417+
await runWithIndentedOutput(async () => {
418+
await program.parseAsync(["node", "alchemy", ...words]);
419+
});
399420
} catch {
400421
// Commander errors are already handled by exitOverride
401422
}
402423

424+
printPostOutputSpacing();
403425
prompt();
404426
};
405427

@@ -412,9 +434,6 @@ export async function startREPL(program: Command): Promise<void> {
412434
setReplMode(false);
413435
setBrandedHelpSuppressed(false);
414436
stdin.off("keypress", onKeypress);
415-
stdin.removeListener("data", filteredDataListener);
416-
stdin.on("data", origDataListener);
417-
scrollback.dispose();
418437
resolve();
419438
});
420439
printIntro();

src/index.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -196,33 +196,22 @@ program
196196
}
197197

198198
if (isInteractiveAllowed(program)) {
199-
const useAltScreen = Boolean(process.stdout.isTTY);
200-
if (useAltScreen) {
201-
process.stdout.write("\x1b[?1049h\x1b[H\x1b[2J");
202-
process.stdout.write("\x1b[?1000h\x1b[?1006h");
203-
}
204-
try {
205-
if (shouldRunOnboarding(program, cfg)) {
206-
const { runOnboarding } = await import("./commands/onboarding.js");
207-
const completed = await runOnboarding(program);
208-
if (!completed) {
209-
// User skipped or aborted onboarding while setup remains incomplete.
210-
// Do not enter REPL; return to shell without forcing interactive mode.
211-
return;
212-
}
213-
}
214-
const { startREPL } = await import("./commands/interactive.js");
215-
// In REPL mode, override exitOverride so errors don't kill the process
216-
program.exitOverride();
217-
program.configureOutput({
218-
writeErr: () => {},
219-
});
220-
await startREPL(program);
221-
} finally {
222-
if (useAltScreen) {
223-
process.stdout.write("\x1b[?1000l\x1b[?1006l\x1b[?1049l");
199+
if (shouldRunOnboarding(program, cfg)) {
200+
const { runOnboarding } = await import("./commands/onboarding.js");
201+
const completed = await runOnboarding(program);
202+
if (!completed) {
203+
// User skipped or aborted onboarding while setup remains incomplete.
204+
// Do not enter REPL; return to shell without forcing interactive mode.
205+
return;
224206
}
225207
}
208+
const { startREPL } = await import("./commands/interactive.js");
209+
// In REPL mode, override exitOverride so errors don't kill the process
210+
program.exitOverride();
211+
program.configureOutput({
212+
writeErr: () => {},
213+
});
214+
await startREPL(program);
226215
return;
227216
}
228217
program.help();

src/lib/colors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ export const esc = (code: string) =>
1212

1313
export const rgb = (r: number, g: number, b: number) =>
1414
noColor ? identity : (s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
15+
16+
export const bgRgb = (r: number, g: number, b: number) =>
17+
noColor ? identity : (s: string) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;

src/lib/scrollback.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ export class ScrollbackBuffer {
142142
this.origStdoutWrite("\x1b[J");
143143
}
144144

145+
rawWrite(data: string): void {
146+
this.origStdoutWrite(data);
147+
}
148+
149+
updateLastLine(content: string): void {
150+
if (this.lines.length > 0) {
151+
this.lines[this.lines.length - 1] = content;
152+
}
153+
}
154+
155+
removeLastLines(count: number): void {
156+
this.lines.splice(-count, count);
157+
}
158+
159+
rewriteLastLine(content: string): void {
160+
if (this.lines.length === 0) return;
161+
this.lines[this.lines.length - 1] = content;
162+
if (this.scrollOffset === 0) {
163+
this.origStdoutWrite("\x1b[1A\x1b[2K" + content + "\n");
164+
}
165+
}
166+
145167
dispose(): void {
146168
if (this.partial) {
147169
this.lines.push(this.partial);

0 commit comments

Comments
 (0)