From bc4cc4decd72b31a9ddeb4b73541927605b9e05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 10:48:53 +0200 Subject: [PATCH 1/9] Some shell fixes Properly handle some characters and input folding on char removal --- components/shell/readline.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/components/shell/readline.ts b/components/shell/readline.ts index 7189c01..498bae1 100644 --- a/components/shell/readline.ts +++ b/components/shell/readline.ts @@ -122,7 +122,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); @@ -165,7 +174,21 @@ export class Readline { } private onBackspace(): void { - if (this.cursor === 0) return; + if (this.cursor === 0) { + // On an empty continuation line, fold back into the previous line so the + // user can keep editing it. The prompts are the same visible width, so + // a plain `\x1b[A` after clearing keeps the cursor in column 8 (just + // past the prompt); we then walk forward to the end of the restored + // buffer. + if (this.buffer.length === 0 && this.accumulated.length > 0) { + const prev = this.accumulated.pop()!; + this.handlers.write("\r\x1b[K\x1b[A"); + if (prev.length > 0) this.handlers.write(`\x1b[${prev.length}C`); + this.buffer = prev; + this.cursor = prev.length; + } + return; + } const before = this.buffer.slice(0, this.cursor - 1); const after = this.buffer.slice(this.cursor); this.buffer = before + after; From 4a8949037204bb1ecee591340853a4075a7a3298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 10:58:50 +0200 Subject: [PATCH 2/9] fix --- components/shell/Terminal.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx index 31732a4..c58784c 100644 --- a/components/shell/Terminal.tsx +++ b/components/shell/Terminal.tsx @@ -143,6 +143,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 = () => { From 16c05815b52902aca5d26c121889cead9f019cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 11:00:42 +0200 Subject: [PATCH 3/9] fix --- components/shell/readline.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/shell/readline.ts b/components/shell/readline.ts index 498bae1..51e3d72 100644 --- a/components/shell/readline.ts +++ b/components/shell/readline.ts @@ -176,13 +176,13 @@ export class Readline { private onBackspace(): void { if (this.cursor === 0) { // On an empty continuation line, fold back into the previous line so the - // user can keep editing it. The prompts are the same visible width, so - // a plain `\x1b[A` after clearing keeps the cursor in column 8 (just - // past the prompt); we then walk forward to the end of the restored - // buffer. + // user can keep editing it. `\x1b[2K` clears the current line without + // moving the cursor, so after `\x1b[A` we're at the same column on the + // line above — which lines up with the end of the previous prompt + // because both prompts have the same visible width. if (this.buffer.length === 0 && this.accumulated.length > 0) { const prev = this.accumulated.pop()!; - this.handlers.write("\r\x1b[K\x1b[A"); + this.handlers.write("\x1b[2K\x1b[A"); if (prev.length > 0) this.handlers.write(`\x1b[${prev.length}C`); this.buffer = prev; this.cursor = prev.length; From 00fc8a98e1ba99bae153f7273aabb75306817eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 11:07:08 +0200 Subject: [PATCH 4/9] more fixes --- components/shell/Terminal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx index c58784c..4b8b946 100644 --- a/components/shell/Terminal.tsx +++ b/components/shell/Terminal.tsx @@ -214,8 +214,8 @@ export function Terminal({ lessonSlug }: Props) { }, [lessonSlug]); return ( -
-
+
+
); } From bd7d6a2ad2438e165ade3f0f7629c2ae2b76b6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 11:20:21 +0200 Subject: [PATCH 5/9] refactor cursor handling --- components/shell/Terminal.tsx | 1 + components/shell/readline.ts | 140 ++++++++++++++++++++-------------- 2 files changed, 85 insertions(+), 56 deletions(-) diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx index 4b8b946..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. diff --git a/components/shell/readline.ts b/components/shell/readline.ts index 51e3d72..258916e 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 { @@ -159,53 +161,88 @@ 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. + */ + private redrawLine(prevCursor: number): 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); + this.handlers.write("\r"); + if (prevRow > 0) this.handlers.write(`\x1b[${prevRow}A`); + this.handlers.write("\x1b[J" + prompt + this.buffer); + + const endTotal = PROMPT_WIDTH + this.buffer.length; + const targetTotal = PROMPT_WIDTH + this.cursor; + this.moveCursorBetween(endTotal, targetTotal, cols); + } + + private moveCursorBetween(from: number, to: number, cols: number): void { + const fromRow = Math.floor(from / cols); + const fromCol = from % cols; + const toRow = Math.floor(to / cols); + const toCol = to % cols; + const dRow = toRow - fromRow; + if (dRow > 0) this.handlers.write(`\x1b[${dRow}B`); + else if (dRow < 0) this.handlers.write(`\x1b[${-dRow}A`); + if (toCol !== fromCol) { + this.handlers.write("\r"); + if (toCol > 0) this.handlers.write(`\x1b[${toCol}C`); + } + } + // ---------- 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) { - // On an empty continuation line, fold back into the previous line so the - // user can keep editing it. `\x1b[2K` clears the current line without - // moving the cursor, so after `\x1b[A` we're at the same column on the - // line above — which lines up with the end of the previous prompt - // because both prompts have the same visible width. + // 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()!; + // Clear current (one-row, empty) continuation prompt and move up one + // visual row — that lands us on prev's last visual row. this.handlers.write("\x1b[2K\x1b[A"); - if (prev.length > 0) this.handlers.write(`\x1b[${prev.length}C`); this.buffer = prev; this.cursor = prev.length; + // Tell redrawLine we're at the end of prev so it walks back the right + // number of wrapped rows before rewriting. + this.redrawLine(prev.length); } 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 { @@ -215,18 +252,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 { @@ -235,40 +271,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 { @@ -276,10 +313,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 { @@ -287,10 +321,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 ---------- @@ -341,11 +372,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 { @@ -358,14 +388,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); } } From 989581bdf516a788f3eb192fc8829519990bcf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 15:27:30 +0200 Subject: [PATCH 6/9] fix flickering --- components/shell/readline.ts | 43 ++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/components/shell/readline.ts b/components/shell/readline.ts index 258916e..68b1d8c 100644 --- a/components/shell/readline.ts +++ b/components/shell/readline.ts @@ -168,35 +168,45 @@ export class Readline { * `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. + * 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): void { + 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); - this.handlers.write("\r"); - if (prevRow > 0) this.handlers.write(`\x1b[${prevRow}A`); - this.handlers.write("\x1b[J" + prompt + this.buffer); - const endTotal = PROMPT_WIDTH + this.buffer.length; const targetTotal = PROMPT_WIDTH + this.cursor; - this.moveCursorBetween(endTotal, targetTotal, cols); + + 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) this.handlers.write(`\x1b[${dRow}B`); - else if (dRow < 0) this.handlers.write(`\x1b[${-dRow}A`); + if (dRow > 0) seq += `\x1b[${dRow}B`; + else if (dRow < 0) seq += `\x1b[${-dRow}A`; if (toCol !== fromCol) { - this.handlers.write("\r"); - if (toCol > 0) this.handlers.write(`\x1b[${toCol}C`); + seq += "\r"; + if (toCol > 0) seq += `\x1b[${toCol}C`; } + return seq; } // ---------- insertion / deletion ---------- @@ -217,14 +227,13 @@ export class Readline { // buffer, then redraw from there. if (this.buffer.length === 0 && this.accumulated.length > 0) { const prev = this.accumulated.pop()!; - // Clear current (one-row, empty) continuation prompt and move up one - // visual row — that lands us on prev's last visual row. - this.handlers.write("\x1b[2K\x1b[A"); this.buffer = prev; this.cursor = prev.length; - // Tell redrawLine we're at the end of prev so it walks back the right - // number of wrapped rows before rewriting. - this.redrawLine(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; } From 94c6b0738ff9f1187d067cd1dad03e73514c080d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 15:31:10 +0200 Subject: [PATCH 7/9] add padding for scroll bar --- app/globals.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/globals.css b/app/globals.css index 37ff499..7234739 100644 --- a/app/globals.css +++ b/app/globals.css @@ -25,3 +25,10 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Reserve space for the (overlay) scrollbar so the last text column isn't + hidden behind it. FitAddon subtracts the .xterm root's padding when sizing + columns, so this also shrinks the column count to match. */ +.xterm { + padding-right: 12px; +} From 3538df069eb7acc4315dbf39f9f465e627765c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 16:08:40 +0200 Subject: [PATCH 8/9] fix term padding --- app/globals.css | 7 ------- components/shell/Terminal.tsx | 8 ++++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7234739..37ff499 100644 --- a/app/globals.css +++ b/app/globals.css @@ -25,10 +25,3 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } - -/* Reserve space for the (overlay) scrollbar so the last text column isn't - hidden behind it. FitAddon subtracts the .xterm root's padding when sizing - columns, so this also shrinks the column count to match. */ -.xterm { - padding-right: 12px; -} diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx index cd70d15..871fc28 100644 --- a/components/shell/Terminal.tsx +++ b/components/shell/Terminal.tsx @@ -175,6 +175,14 @@ export function Terminal({ lessonSlug }: Props) { const fitNow = () => { try { fit?.fit(); + // FitAddon reserves 14px for the scrollbar, but the renderer rounds + // the canvas width up to whole device pixels (`DomRenderer.ts:124`), + // which can push the rendered screen 1–2 logical pixels wider than + // FitAddon assumed — enough to slide the last character behind the + // scrollbar overlay. Shave one column for safety. + if (term && term.cols > 2) { + term.resize(term.cols - 1, term.rows); + } } catch { /* container not yet sized */ } From ecc5046e6b7209e2f474f2ec9fb4552efa96b8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 16:19:36 +0200 Subject: [PATCH 9/9] fix --- components/shell/Terminal.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/components/shell/Terminal.tsx b/components/shell/Terminal.tsx index 871fc28..cd70d15 100644 --- a/components/shell/Terminal.tsx +++ b/components/shell/Terminal.tsx @@ -175,14 +175,6 @@ export function Terminal({ lessonSlug }: Props) { const fitNow = () => { try { fit?.fit(); - // FitAddon reserves 14px for the scrollbar, but the renderer rounds - // the canvas width up to whole device pixels (`DomRenderer.ts:124`), - // which can push the rendered screen 1–2 logical pixels wider than - // FitAddon assumed — enough to slide the last character behind the - // scrollbar overlay. Shave one column for safety. - if (term && term.cols > 2) { - term.resize(term.cols - 1, term.rows); - } } catch { /* container not yet sized */ }