diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx
index 31732a4..cd70d15 100644
--- a/components/shell/Terminal.tsx
+++ b/components/shell/Terminal.tsx
@@ -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.
@@ -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 = () => {
@@ -188,8 +215,8 @@ export function Terminal({ lessonSlug }: Props) {
}, [lessonSlug]);
return (
-
-
+
);
}
diff --git a/components/shell/readline.ts b/components/shell/readline.ts
index 7189c01..68b1d8c 100644
--- a/components/shell/readline.ts
+++ b/components/shell/readline.ts
@@ -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;
@@ -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 {
@@ -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
`. 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);
@@ -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 {
@@ -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 {
@@ -212,40 +280,41 @@ 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 {
@@ -253,10 +322,7 @@ export class Readline {
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 {
@@ -264,10 +330,7 @@ export class Readline {
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 ----------
@@ -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 {
@@ -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);
}
}