Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions components/shell/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function Terminal({ lessonSlug }: Props) {
},
loadHistory,
completer,
() => term?.cols ?? 80,
);

// Warm the cache so the first Tab works without a silent miss.
Expand All @@ -143,6 +144,32 @@ export function Terminal({ lessonSlug }: Props) {
write(BANNER);
readline.start();

// macOS keyboards with non-US layouts produce characters like `@` and
// `\` via Option-modified keys (e.g. Option+2 → `@` on Spanish). xterm.js
// drops these: its keydown handler bails on Alt+key when macOptionIsMeta
// is false, and its input-event handler filters out events that overlap
// with a keydown. Intercept here and feed the typed character to the
// readline directly.
const isMac =
typeof navigator !== "undefined" &&
/mac/i.test(navigator.platform || navigator.userAgent);
term.attachCustomKeyEventHandler((event) => {
if (
isMac &&
event.type === "keydown" &&
event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
event.key.length === 1 &&
event.key >= " "
) {
event.preventDefault();
readline.handleData(event.key);
return false;
}
return true;
});

dataDisposable = term.onData((d) => readline.handleData(d));

const fitNow = () => {
Expand Down Expand Up @@ -188,8 +215,8 @@ export function Terminal({ lessonSlug }: Props) {
}, [lessonSlug]);

return (
<div className="h-full w-full overflow-hidden rounded-lg border border-black/10 bg-[#09090b] dark:border-white/10">
<div ref={containerRef} className="h-full w-full p-3" />
<div className="h-full w-full overflow-hidden rounded-lg border border-black/10 bg-[#09090b] p-3 dark:border-white/10">
<div ref={containerRef} className="h-full w-full" />
</div>
);
}
Expand Down
164 changes: 112 additions & 52 deletions components/shell/readline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isStatementComplete } from "./sql-complete";

const PROMPT_PRIMARY = "\x1b[36mlearn=>\x1b[0m ";
const PROMPT_CONT = "\x1b[2mlearn->\x1b[0m ";
const PROMPT_WIDTH = 8; // visible width of "learn=> " / "learn-> "

export type ReadlineHandlers = {
write: (s: string) => void;
Expand Down Expand Up @@ -30,6 +31,7 @@ export class Readline {
private readonly handlers: ReadlineHandlers,
private readonly getHistory: () => string[],
private readonly completer: Completer = () => [],
private readonly getCols: () => number = () => 80,
) {}

setBusy(busy: boolean): void {
Expand Down Expand Up @@ -122,7 +124,16 @@ export class Readline {
}

if (seq === "\t") return this.onTab();
if (seq.startsWith("\x1b")) return; // unknown escape, ignore
if (seq.startsWith("\x1b")) {
// Some keyboard layouts (e.g. Spanish/German on macOS) require Option to
// produce characters like `@` or `\`, and the terminal can deliver those
// as `Esc <char>`. Treat an unknown Alt-printable as the bare character
// so meta commands like `\d` and email-style input still work.
if (seq.length === 2 && seq[1] >= " " && seq[1] !== "\x7f") {
return this.insert(seq[1]);
}
return;
}
if (seq < " ") return;

this.insert(seq);
Expand Down Expand Up @@ -150,39 +161,97 @@ export class Readline {
}
}

// ---------- redraw helpers ----------

/**
* Walk the terminal cursor from its current visual position (computed from
* `prevCursor`) back to the prompt's first row, clear to end of screen, and
* rewrite the prompt + buffer. This is the only correct way to mutate the
* line when it has wrapped across visual rows, since `\b` and `\x1b[D` won't
* cross row boundaries on their own. The whole sequence is sent as a single
* write so xterm renders just the final state — no intermediate cursor
* flicker at the prompt.
*/
private redrawLine(prevCursor: number, prefix = ""): void {
const cols = Math.max(1, this.getCols());
const prompt = this.accumulated.length > 0 ? PROMPT_CONT : PROMPT_PRIMARY;

const prevTotal = PROMPT_WIDTH + prevCursor;
const prevRow = Math.floor(prevTotal / cols);
const endTotal = PROMPT_WIDTH + this.buffer.length;
const targetTotal = PROMPT_WIDTH + this.cursor;

let out = prefix + "\r";
if (prevRow > 0) out += `\x1b[${prevRow}A`;
out += "\x1b[J" + prompt + this.buffer;
out += this.cursorMoveSeq(endTotal, targetTotal, cols);
this.handlers.write(out);
}

private moveCursorBetween(from: number, to: number, cols: number): void {
const seq = this.cursorMoveSeq(from, to, cols);
if (seq) this.handlers.write(seq);
}

private cursorMoveSeq(from: number, to: number, cols: number): string {
const fromRow = Math.floor(from / cols);
const fromCol = from % cols;
const toRow = Math.floor(to / cols);
const toCol = to % cols;
let seq = "";
const dRow = toRow - fromRow;
if (dRow > 0) seq += `\x1b[${dRow}B`;
else if (dRow < 0) seq += `\x1b[${-dRow}A`;
if (toCol !== fromCol) {
seq += "\r";
if (toCol > 0) seq += `\x1b[${toCol}C`;
}
return seq;
}

// ---------- insertion / deletion ----------

private insert(text: string): void {
const prevCursor = this.cursor;
const before = this.buffer.slice(0, this.cursor);
const after = this.buffer.slice(this.cursor);
this.buffer = before + text + after;
this.cursor += text.length;

// Write inserted text, then the tail (so it appears past the cursor),
// clear anything stale to EOL, then move the cursor back over the tail.
this.handlers.write(text + after + "\x1b[K");
if (after.length > 0) this.handlers.write(`\x1b[${after.length}D`);
this.redrawLine(prevCursor);
}

private onBackspace(): void {
if (this.cursor === 0) return;
if (this.cursor === 0) {
// On an empty continuation line, fold back into the previous accumulated
// line. Move up onto its last visual row (it may wrap), pop it into the
// buffer, then redraw from there.
if (this.buffer.length === 0 && this.accumulated.length > 0) {
const prev = this.accumulated.pop()!;
this.buffer = prev;
this.cursor = prev.length;
// Clear the (single-row, empty) continuation prompt and step up onto
// prev's last visual row, then let redrawLine walk back the rest of
// prev's wrapped rows and rewrite. All emitted as one write to avoid
// a render flicker at the cleared row.
this.redrawLine(prev.length, "\x1b[2K\x1b[A");
}
return;
}
const prevCursor = this.cursor;
const before = this.buffer.slice(0, this.cursor - 1);
const after = this.buffer.slice(this.cursor);
this.buffer = before + after;
this.cursor--;
this.handlers.write("\b" + after + " \x1b[K");
// We wrote tail + space; cursor is now `after.length + 1` past where it
// needs to be.
this.handlers.write(`\x1b[${after.length + 1}D`);
this.redrawLine(prevCursor);
}

private deleteForward(): void {
if (this.cursor >= this.buffer.length) return;
const prevCursor = this.cursor;
const before = this.buffer.slice(0, this.cursor);
const after = this.buffer.slice(this.cursor + 1);
this.buffer = before + after;
this.handlers.write(after + " \x1b[K");
this.handlers.write(`\x1b[${after.length + 1}D`);
this.redrawLine(prevCursor);
}

private onCtrlD(): void {
Expand All @@ -192,18 +261,17 @@ export class Readline {

private killToEnd(): void {
if (this.cursor >= this.buffer.length) return;
const prevCursor = this.cursor;
this.buffer = this.buffer.slice(0, this.cursor);
this.handlers.write("\x1b[K");
this.redrawLine(prevCursor);
}

private killToStart(): void {
if (this.cursor === 0) return;
const after = this.buffer.slice(this.cursor);
const back = this.cursor;
this.buffer = after;
const prevCursor = this.cursor;
this.buffer = this.buffer.slice(this.cursor);
this.cursor = 0;
this.handlers.write(`\x1b[${back}D` + after + "\x1b[K");
if (after.length > 0) this.handlers.write(`\x1b[${after.length}D`);
this.redrawLine(prevCursor);
}

private killPreviousWord(): void {
Expand All @@ -212,62 +280,57 @@ export class Readline {
while (i > 0 && /\s/.test(this.buffer[i - 1])) i--;
while (i > 0 && /\S/.test(this.buffer[i - 1])) i--;
if (i === this.cursor) return;
const removed = this.cursor - i;
const prevCursor = this.cursor;
const before = this.buffer.slice(0, i);
const after = this.buffer.slice(this.cursor);
this.buffer = before + after;
this.cursor = i;
this.handlers.write(`\x1b[${removed}D` + after + "\x1b[K");
if (after.length > 0) this.handlers.write(`\x1b[${after.length}D`);
this.redrawLine(prevCursor);
}

// ---------- cursor movement ----------

private moveCursor(newCursor: number): void {
if (newCursor === this.cursor) return;
const cols = Math.max(1, this.getCols());
const from = PROMPT_WIDTH + this.cursor;
const to = PROMPT_WIDTH + newCursor;
this.cursor = newCursor;
this.moveCursorBetween(from, to, cols);
}

private moveLeft(): void {
if (this.cursor === 0) return;
this.cursor--;
this.handlers.write("\x1b[D");
this.moveCursor(this.cursor - 1);
}

private moveRight(): void {
if (this.cursor >= this.buffer.length) return;
this.cursor++;
this.handlers.write("\x1b[C");
this.moveCursor(this.cursor + 1);
}

private moveHome(): void {
if (this.cursor === 0) return;
this.handlers.write(`\x1b[${this.cursor}D`);
this.cursor = 0;
this.moveCursor(0);
}

private moveEnd(): void {
const dist = this.buffer.length - this.cursor;
if (dist === 0) return;
this.handlers.write(`\x1b[${dist}C`);
this.cursor = this.buffer.length;
this.moveCursor(this.buffer.length);
}

private moveWordLeft(): void {
if (this.cursor === 0) return;
let i = this.cursor;
while (i > 0 && /\s/.test(this.buffer[i - 1])) i--;
while (i > 0 && /\S/.test(this.buffer[i - 1])) i--;
const delta = this.cursor - i;
if (delta === 0) return;
this.handlers.write(`\x1b[${delta}D`);
this.cursor = i;
this.moveCursor(i);
}

private moveWordRight(): void {
if (this.cursor >= this.buffer.length) return;
let i = this.cursor;
while (i < this.buffer.length && /\s/.test(this.buffer[i])) i++;
while (i < this.buffer.length && /\S/.test(this.buffer[i])) i++;
const delta = i - this.cursor;
if (delta === 0) return;
this.handlers.write(`\x1b[${delta}C`);
this.cursor = i;
this.moveCursor(i);
}

// ---------- enter / history / interrupt ----------
Expand Down Expand Up @@ -318,11 +381,10 @@ export class Readline {
}

private replaceBuffer(next: string): void {
// Move cursor to start of current buffer, clear to EOL, write new content.
if (this.cursor > 0) this.handlers.write(`\x1b[${this.cursor}D`);
this.handlers.write("\x1b[K" + next);
const prevCursor = this.cursor;
this.buffer = next;
this.cursor = next.length;
this.redrawLine(prevCursor);
}

private onCtrlC(): void {
Expand All @@ -335,14 +397,12 @@ export class Readline {
}

private onCtrlL(): void {
this.handlers.write("\x1b[2J\x1b[H");
this.handlers.write(
this.accumulated.length > 0 ? PROMPT_CONT : PROMPT_PRIMARY,
);
this.handlers.write(this.buffer);
if (this.cursor < this.buffer.length) {
this.handlers.write(`\x1b[${this.buffer.length - this.cursor}D`);
}
const cols = Math.max(1, this.getCols());
const prompt = this.accumulated.length > 0 ? PROMPT_CONT : PROMPT_PRIMARY;
this.handlers.write("\x1b[2J\x1b[H" + prompt + this.buffer);
const endTotal = PROMPT_WIDTH + this.buffer.length;
const targetTotal = PROMPT_WIDTH + this.cursor;
this.moveCursorBetween(endTotal, targetTotal, cols);
}
}

Expand Down
Loading