[gjs/ewlsh/nova-repl] Use Node.js readline



commit 1ad24d23c4afec06cc32fb3f436f697ceb870aa4
Author: Evan Welsh <contact evanwelsh com>
Date:   Sun Jan 23 21:25:42 2022 -0800

    Use Node.js readline

 .eslintignore                    |   1 -
 .gitlab-ci.yml                   |   2 +-
 installed-tests/js/meson.build   |   1 +
 installed-tests/js/testRepl.js   | 135 ++++++
 js.gresource.xml                 |   5 +-
 modules/esm/_bootstrap/repl.js   |   2 +-
 modules/esm/_repl/callbacks.js   | 101 +++++
 modules/esm/_repl/cliffy.js      | 929 ---------------------------------------
 modules/esm/_repl/primordials.js |  33 ++
 modules/esm/_repl/utils.js       | 427 ++++++++++++++++++
 modules/esm/events.js            | 104 +++++
 modules/esm/repl.js              | 729 +++++++++++++++---------------
 modules/script/console.js        |   2 +-
 tools/cliffy/.eslintrc.yml       |   7 -
 tools/cliffy/ansi.js             |   8 -
 tools/cliffy/bundle.sh           |   7 -
 tools/cliffy/lib.js              |  30 --
 tools/cliffy/transform.js        |  22 -
 18 files changed, 1164 insertions(+), 1381 deletions(-)
---
diff --git a/.eslintignore b/.eslintignore
index e61a4aaba..372ae4593 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -5,6 +5,5 @@ installed-tests/js/jasmine.js
 installed-tests/js/modules/badOverrides/WarnLib.js
 installed-tests/js/modules/subBadInit/__init__.js
 modules/script/jsUnit.js
-modules/esm/_repl/cliffy.js
 /_build
 /builddir
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index cf4cad15a..5c4a9e33f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -266,7 +266,7 @@ codespell:
   stage: source_check
   script:
     - codespell --version
-    - codespell -S "*.png,*.po,*.jpg,*.wrap,.git,LICENSES" -f --builtin "code,usage,clear" 
--skip="./installed-tests/js/jasmine.js,./README.md,./build/flatpak/*.json,./modules/esm/_repl/cliffy.js" 
--ignore-words-list="afterall,befores,files',filetest,gir,inout,stdio,uint,upto,xdescribe"
+    - codespell -S "*.png,*.po,*.jpg,*.wrap,.git,LICENSES" -f --builtin "code,usage,clear" 
--skip="./installed-tests/js/jasmine.js,./README.md,./build/flatpak/*.json,./modules/esm/_repl/*.js" 
--ignore-words-list="afterall,befores,files',filetest,gir,inout,stdio,uint,upto,xdescribe"
   except:
     - schedules
 
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index 6a5c40b50..17796eeb5 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -233,6 +233,7 @@ endif
 modules_tests = [
     'Async',
     'Console',
+    'Repl',
     'ESModules',
     'Encoding',
     'GLibLogWriter',
diff --git a/installed-tests/js/testRepl.js b/installed-tests/js/testRepl.js
new file mode 100644
index 000000000..4b3b80f85
--- /dev/null
+++ b/installed-tests/js/testRepl.js
@@ -0,0 +1,135 @@
+import Gio from 'gi://Gio';
+
+import { AsyncReadline } from 'repl';
+
+function createReadline() {
+    return new AsyncReadline({ stdin: null, stdout: null, stderr: null, prompt: '> ', enableColor: false });
+}
+
+function createReadlineWithStreams() {
+    const stdin = new Gio.MemoryInputStream();
+    const stdout = Gio.MemoryOutputStream.new_resizable();
+    const stderr = Gio.MemoryOutputStream.new_resizable();
+
+    const readline = new AsyncReadline({ stdin, stdout, stderr, prompt: '> ', enableColor: false });
+
+    return {
+        readline, async teardown() {
+            readline.cancel();
+
+            try {
+                readline.stdout.close(null);
+            } catch { }
+
+            try {
+                readline.stdin.close(null);
+            } catch { }
+
+            try {
+                readline.stderr.close(null);
+            } catch { }
+        }
+    };
+}
+
+function expectReadlineOutput({ readline, input, output, keystrokes = 1 }) {
+    return new Promise((resolve) => {
+        let renderCount = 0;
+
+        readline.connect('render', () => {
+            if (++renderCount === keystrokes) {
+                readline.disconnectAll();
+
+                expect(readline.line).toBe(output);
+                resolve();
+            }
+        });
+
+        readline.stdin.add_bytes(new TextEncoder().encode(input));
+    });
+}
+
+describe('Repl', () => {
+    it('handles key events on stdin', async function () {
+        const { readline, teardown } = createReadlineWithStreams();
+
+        readline.prompt();
+
+        await expectReadlineOutput({
+            readline,
+            input: 'a',
+            output: 'a'
+        });
+
+        await expectReadlineOutput({
+            readline,
+            input: 'b',
+            output: 'ab'
+        });
+
+        await expectReadlineOutput({
+            readline,
+            input: '\x1b[D\x1b[Dcr',
+            output: 'crab',
+            keystrokes: 4
+        });
+
+        teardown();
+    });
+});
+
+describe('Readline', () => {
+
+    it('can move word left', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = readline.line.length;
+
+        readline.wordLeft();
+
+        expect(readline.line).toBe('lorem ipsum');
+        expect(readline.cursor).toBe('lorem '.length);
+    });
+
+
+    it('can move word right', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = 0;
+
+        readline.wordRight();
+
+        expect(readline.line).toBe('lorem ipsum');
+        expect(readline.cursor).toBe('lorem '.length);
+    });
+
+    it('can delete word left', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = readline.line.length;
+
+        readline.deleteWordLeft();
+
+        const output = 'lorem ';
+
+        expect(readline.line).toBe(output);
+        expect(readline.cursor).toBe(output.length);
+    });
+
+    it('can delete word right', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = 0;
+
+        readline.deleteWordRight();
+
+        const output = 'ipsum';
+
+        expect(readline.line).toBe(output);
+        expect(readline.cursor).toBe(0);
+    });
+});
\ No newline at end of file
diff --git a/js.gresource.xml b/js.gresource.xml
index 6817f0b32..ba3212ed4 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -17,12 +17,15 @@
 
     <file>modules/esm/_timers.js</file>
 
-    <file>modules/esm/_repl/cliffy.js</file>
+    <file>modules/esm/_repl/utils.js</file>
+    <file>modules/esm/_repl/primordials.js</file>
+    <file>modules/esm/_repl/callbacks.js</file>
   
     <file>modules/esm/cairo.js</file>
     <file>modules/esm/gettext.js</file>
     <file>modules/esm/console.js</file>
     <file>modules/esm/gi.js</file>
+    <file>modules/esm/events.js</file>
     <file>modules/esm/repl.js</file>
     <file>modules/esm/system.js</file>
 
diff --git a/modules/esm/_bootstrap/repl.js b/modules/esm/_bootstrap/repl.js
index fa51a1fbe..cb8e98eb0 100644
--- a/modules/esm/_bootstrap/repl.js
+++ b/modules/esm/_bootstrap/repl.js
@@ -7,4 +7,4 @@ const repl = new Repl();
 
 globalThis.repl = repl;
 
-repl.run();
+repl.start();
diff --git a/modules/esm/_repl/callbacks.js b/modules/esm/_repl/callbacks.js
new file mode 100644
index 000000000..375f86ae1
--- /dev/null
+++ b/modules/esm/_repl/callbacks.js
@@ -0,0 +1,101 @@
+/* eslint-disable no-nested-ternary */
+'use strict';
+
+/* eslint-disable max-statements-per-line */
+/* eslint-disable jsdoc/require-param-description */
+/* eslint-disable jsdoc/require-param-type */
+
+import {primordials} from './primordials.js';
+
+const {
+    NumberIsNaN,
+} = primordials;
+
+const ERR_INVALID_ARG_VALUE = Error;
+const ERR_INVALID_CURSOR_POS = Error;
+
+// Adapted from 
https://github.com/nodejs/node/blob/56679eb53044b03e4da0f7420774d54f0c550eec/lib/internal/readline/callbacks.js
+
+import {CSI} from './utils.js';
+
+const {
+    kClearLine,
+    kClearToLineBeginning,
+    kClearToLineEnd,
+} = CSI;
+
+
+/**
+ * moves the cursor to the x and y coordinate on the given stream
+ */
+
+/**
+ * @param x
+ * @param y
+ */
+function cursorTo(x, y) {
+    if (NumberIsNaN(x))
+        throw new ERR_INVALID_ARG_VALUE('x', x);
+    if (NumberIsNaN(y))
+        throw new ERR_INVALID_ARG_VALUE('y', y);
+
+    if (typeof x !== 'number')
+        throw new ERR_INVALID_CURSOR_POS();
+
+    const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`;
+    return data;
+}
+
+/**
+ * moves the cursor relative to its current location
+ */
+
+/**
+ * @param dx
+ * @param dy
+ */
+function moveCursor(dx, dy) {
+    let data = '';
+
+    if (dx < 0)
+        data += CSI`${-dx}D`;
+    else if (dx > 0)
+        data += CSI`${dx}C`;
+
+
+    if (dy < 0)
+        data += CSI`${-dy}A`;
+    else if (dy > 0)
+        data += CSI`${dy}B`;
+
+
+    return data;
+}
+
+/**
+ * clears the current line the cursor is on:
+ *   -1 for left of the cursor
+ *   +1 for right of the cursor
+ *    0 for the entire line
+ */
+
+/**
+ * @param dir
+ */
+function clearLine(dir) {
+    const type =
+        dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine;
+    return type;
+}
+
+
+
+/**
+ * clears the screen from the current position of the cursor down
+ */
+
+export {
+    clearLine,
+    cursorTo,
+    moveCursor
+};
diff --git a/modules/esm/_repl/primordials.js b/modules/esm/_repl/primordials.js
new file mode 100644
index 000000000..d2825fbea
--- /dev/null
+++ b/modules/esm/_repl/primordials.js
@@ -0,0 +1,33 @@
+/**
+ * @typedef {F extends ((...args: infer Args) => infer Result) ?  ((instance: I, ...args: Args) => Result) : 
never} UncurriedFunction
+ * @template I
+ * @template F
+ */
+
+/**
+ * @template {Record<string, any>} T
+ * @template {keyof T} K
+ * @param {T} [type] the instance type for the function
+ * @param {K} key the function to curry
+ * @returns {UncurriedFunction<T, T[K]>}
+ */
+function uncurryThis(type, key) {
+    const func = type[key];
+    return (instance, ...args) => func.apply(instance, args);
+}
+
+const primordials = {
+    ArrayPrototypeSlice: uncurryThis(Array.prototype, 'slice'),
+    ArrayPrototypeSort: uncurryThis(Array.prototype, 'sort'),
+    RegExpPrototypeTest: uncurryThis(RegExp.prototype, 'test'),
+    StringFromCharCode: String.fromCharCode,
+    StringPrototypeCharCodeAt: uncurryThis(String.prototype, 'charCodeAt'),
+    StringPrototypeCodePointAt: uncurryThis(String.prototype, 'codePointAt'),
+    StringPrototypeMatch: uncurryThis(String.prototype, 'match'),
+    StringPrototypeSlice: uncurryThis(String.prototype, 'slice'),
+    StringPrototypeToLowerCase: uncurryThis(String.prototype, 'toLowerCase'),
+    Symbol,
+    NumberIsNaN: Number.isNaN,
+};
+
+export {primordials, uncurryThis};
diff --git a/modules/esm/_repl/utils.js b/modules/esm/_repl/utils.js
new file mode 100644
index 000000000..79995e30b
--- /dev/null
+++ b/modules/esm/_repl/utils.js
@@ -0,0 +1,427 @@
+/* eslint-disable max-statements-per-line */
+/* eslint-disable jsdoc/require-param-description */
+/* eslint-disable jsdoc/require-param-type */
+
+import {primordials} from './primordials.js';
+
+// From 
https://github.com/nodejs/node/blob/56679eb53044b03e4da0f7420774d54f0c550eec/lib/internal/readline/utils.js
+
+const {
+    ArrayPrototypeSlice,
+    ArrayPrototypeSort,
+    RegExpPrototypeTest,
+    StringFromCharCode,
+    StringPrototypeCharCodeAt,
+    StringPrototypeCodePointAt,
+    StringPrototypeMatch,
+    StringPrototypeSlice,
+    StringPrototypeToLowerCase,
+    Symbol,
+} = primordials;
+
+/**
+ * @typedef {object} KeyDefinition
+ * @property {string} name
+ * @property {string} sequence
+ * @property {boolean} ctrl
+ * @property {boolean} meta
+ * @property {boolean} shift
+ */
+
+/**
+ * @typedef {object} KeyEvent
+ * @property {string | undefined} sequence
+ * @property {KeyDefinition} key
+ */
+
+const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
+const kEscape = '\x1b';
+const kSubstringSearch = Symbol('kSubstringSearch');
+
+/**
+ * @param strings
+ * @param {...any} args
+ */
+function CSI(strings, ...args) {
+    let ret = `${kEscape}[`;
+    for (let n = 0; n < strings.length; n++) {
+        ret += strings[n];
+        if (n < args.length)
+            ret += args[n];
+    }
+    return ret;
+}
+
+CSI.kEscape = kEscape;
+CSI.kClearToLineBeginning = CSI`1K`;
+CSI.kClearToLineEnd = CSI`0K`;
+CSI.kClearLine = CSI`2K`;
+CSI.kClearScreenDown = CSI`0J`;
+
+// TODO(BridgeAR): Treat combined characters as single character, i.e,
+// 'a\u0301' and '\u0301a' (both have the same visual output).
+// Check Canonical_Combining_Class in
+// http://userguide.icu-project.org/strings/properties
+/**
+ * @param str
+ * @param i
+ */
+function charLengthLeft(str, i) {
+    if (i <= 0)
+        return 0;
+    if ((i > 1 &&
+        StringPrototypeCodePointAt(str, i - 2) >= kUTF16SurrogateThreshold) ||
+        StringPrototypeCodePointAt(str, i - 1) >= kUTF16SurrogateThreshold)
+        return 2;
+
+    return 1;
+}
+
+/**
+ * @param str
+ * @param i
+ */
+function charLengthAt(str, i) {
+    if (str.length <= i) {
+        // Pretend to move to the right. This is necessary to autocomplete while
+        // moving to the right.
+        return 1;
+    }
+    return StringPrototypeCodePointAt(str, i) >= kUTF16SurrogateThreshold ? 2 : 1;
+}
+
+/*
+  Some patterns seen in terminal key escape codes, derived from combos seen
+  at http://www.midnight-commander.org/browser/lib/tty/key.c
+
+  ESC letter
+  ESC [ letter
+  ESC [ modifier letter
+  ESC [ 1 ; modifier letter
+  ESC [ num char
+  ESC [ num ; modifier char
+  ESC O letter
+  ESC O modifier letter
+  ESC O 1 ; modifier letter
+  ESC N letter
+  ESC [ [ num ; modifier char
+  ESC [ [ 1 ; modifier letter
+  ESC ESC [ num char
+  ESC ESC O letter
+
+  - char is usually ~ but $ and ^ also happen with rxvt
+  - modifier is 1 +
+                (shift     * 1) +
+                (left_alt  * 2) +
+                (ctrl      * 4) +
+                (right_alt * 8)
+  - two leading ESCs apparently mean the same as one leading ESC
+*/
+
+/**
+ * @param callback
+ * @returns {Generator<KeyEvent | undefined, never, string>}
+ */
+function* emitKeys(callback) {
+    while (true) {
+        let ch = yield;
+        let s = ch;
+        let escaped = false;
+        const key = {
+            sequence: null,
+            name: undefined,
+            ctrl: false,
+            meta: false,
+            shift: false,
+        };
+
+        if (ch === kEscape) {
+            escaped = true;
+            s += ch = yield;
+
+            if (ch === kEscape)
+                s += ch = yield;
+        }
+
+        if (escaped && (ch === 'O' || ch === '[')) {
+            // ANSI escape sequence
+            let code = ch;
+            let modifier = 0;
+
+            if (ch === 'O') {
+                // ESC O letter
+                // ESC O modifier letter
+                s += ch = yield;
+
+                if (ch >= '0' && ch <= '9') {
+                    modifier = (ch >> 0) - 1;
+                    s += ch = yield;
+                }
+
+                code += ch;
+            } else if (ch === '[') {
+                // ESC [ letter
+                // ESC [ modifier letter
+                // ESC [ [ modifier letter
+                // ESC [ [ num char
+                s += ch = yield;
+
+                if (ch === '[') {
+                    // \x1b[[A
+                    //      ^--- escape codes might have a second bracket
+                    code += ch;
+                    s += ch = yield;
+                }
+
+                /*
+         * Here and later we try to buffer just enough data to get
+         * a complete ascii sequence.
+         *
+         * We have basically two classes of ascii characters to process:
+         *
+         *
+         * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 }
+         *
+         * This particular example is featuring Ctrl+F12 in xterm.
+         *
+         *  - `;5` part is optional, e.g. it could be `\x1b[24~`
+         *  - first part can contain one or two digits
+         *
+         * So the generic regexp is like /^\d\d?(;\d)?[~^$]$/
+         *
+         *
+         * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 }
+         *
+         * This particular example is featuring Ctrl+Home in xterm.
+         *
+         *  - `1;5` part is optional, e.g. it could be `\x1b[H`
+         *  - `1;` part is optional, e.g. it could be `\x1b[5H`
+         *
+         * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/
+         *
+         */
+                const cmdStart = s.length - 1;
+
+                // Skip one or two leading digits
+                if (ch >= '0' && ch <= '9') {
+                    s += ch = yield;
+
+                    if (ch >= '0' && ch <= '9')
+                        s += ch = yield;
+                }
+
+                // skip modifier
+                if (ch === ';') {
+                    s += ch = yield;
+
+                    if (ch >= '0' && ch <= '9')
+                        s += yield;
+                }
+
+                /*
+         * We buffered enough data, now trying to extract code
+         * and modifier from it
+         */
+                const cmd = StringPrototypeSlice(s, cmdStart);
+                let match;
+
+                if ((match = StringPrototypeMatch(cmd, /^(\d\d?)(;(\d))?([~^$])$/))) {
+                    code += match[1] + match[4];
+                    modifier = (match[3] || 1) - 1;
+                } else if (
+                    (match = StringPrototypeMatch(cmd, /^((\d;)?(\d))?([A-Za-z])$/))
+                ) {
+                    code += match[4];
+                    modifier = (match[3] || 1) - 1;
+                } else {
+                    code += cmd;
+                }
+            }
+
+            // Parse the key modifier
+            key.ctrl = !!(modifier & 4);
+            key.meta = !!(modifier & 10);
+            key.shift = !!(modifier & 1);
+            key.code = code;
+
+            // Parse the key itself
+            switch (code) {
+            /* xterm/gnome ESC [ letter (with modifier) */
+            case '[P': key.name = 'f1'; break;
+            case '[Q': key.name = 'f2'; break;
+            case '[R': key.name = 'f3'; break;
+            case '[S': key.name = 'f4'; break;
+
+                /* xterm/gnome ESC O letter (without modifier) */
+            case 'OP': key.name = 'f1'; break;
+            case 'OQ': key.name = 'f2'; break;
+            case 'OR': key.name = 'f3'; break;
+            case 'OS': key.name = 'f4'; break;
+
+                /* xterm/rxvt ESC [ number ~ */
+            case '[11~': key.name = 'f1'; break;
+            case '[12~': key.name = 'f2'; break;
+            case '[13~': key.name = 'f3'; break;
+            case '[14~': key.name = 'f4'; break;
+
+                /* from Cygwin and used in libuv */
+            case '[[A': key.name = 'f1'; break;
+            case '[[B': key.name = 'f2'; break;
+            case '[[C': key.name = 'f3'; break;
+            case '[[D': key.name = 'f4'; break;
+            case '[[E': key.name = 'f5'; break;
+
+                /* common */
+            case '[15~': key.name = 'f5'; break;
+            case '[17~': key.name = 'f6'; break;
+            case '[18~': key.name = 'f7'; break;
+            case '[19~': key.name = 'f8'; break;
+            case '[20~': key.name = 'f9'; break;
+            case '[21~': key.name = 'f10'; break;
+            case '[23~': key.name = 'f11'; break;
+            case '[24~': key.name = 'f12'; break;
+
+                /* xterm ESC [ letter */
+            case '[A': key.name = 'up'; break;
+            case '[B': key.name = 'down'; break;
+            case '[C': key.name = 'right'; break;
+            case '[D': key.name = 'left'; break;
+            case '[E': key.name = 'clear'; break;
+            case '[F': key.name = 'end'; break;
+            case '[H': key.name = 'home'; break;
+
+                /* xterm/gnome ESC O letter */
+            case 'OA': key.name = 'up'; break;
+            case 'OB': key.name = 'down'; break;
+            case 'OC': key.name = 'right'; break;
+            case 'OD': key.name = 'left'; break;
+            case 'OE': key.name = 'clear'; break;
+            case 'OF': key.name = 'end'; break;
+            case 'OH': key.name = 'home'; break;
+
+                /* xterm/rxvt ESC [ number ~ */
+            case '[1~': key.name = 'home'; break;
+            case '[2~': key.name = 'insert'; break;
+            case '[3~': key.name = 'delete'; break;
+            case '[4~': key.name = 'end'; break;
+            case '[5~': key.name = 'pageup'; break;
+            case '[6~': key.name = 'pagedown'; break;
+
+                /* putty */
+            case '[[5~': key.name = 'pageup'; break;
+            case '[[6~': key.name = 'pagedown'; break;
+
+                /* rxvt */
+            case '[7~': key.name = 'home'; break;
+            case '[8~': key.name = 'end'; break;
+
+                /* rxvt keys with modifiers */
+            case '[a': key.name = 'up'; key.shift = true; break;
+            case '[b': key.name = 'down'; key.shift = true; break;
+            case '[c': key.name = 'right'; key.shift = true; break;
+            case '[d': key.name = 'left'; key.shift = true; break;
+            case '[e': key.name = 'clear'; key.shift = true; break;
+
+            case '[2$': key.name = 'insert'; key.shift = true; break;
+            case '[3$': key.name = 'delete'; key.shift = true; break;
+            case '[5$': key.name = 'pageup'; key.shift = true; break;
+            case '[6$': key.name = 'pagedown'; key.shift = true; break;
+            case '[7$': key.name = 'home'; key.shift = true; break;
+            case '[8$': key.name = 'end'; key.shift = true; break;
+
+            case 'Oa': key.name = 'up'; key.ctrl = true; break;
+            case 'Ob': key.name = 'down'; key.ctrl = true; break;
+            case 'Oc': key.name = 'right'; key.ctrl = true; break;
+            case 'Od': key.name = 'left'; key.ctrl = true; break;
+            case 'Oe': key.name = 'clear'; key.ctrl = true; break;
+
+            case '[2^': key.name = 'insert'; key.ctrl = true; break;
+            case '[3^': key.name = 'delete'; key.ctrl = true; break;
+            case '[5^': key.name = 'pageup'; key.ctrl = true; break;
+            case '[6^': key.name = 'pagedown'; key.ctrl = true; break;
+            case '[7^': key.name = 'home'; key.ctrl = true; break;
+            case '[8^': key.name = 'end'; key.ctrl = true; break;
+
+                /* misc. */
+            case '[Z': key.name = 'tab'; key.shift = true; break;
+            default: key.name = 'undefined'; break;
+            }
+        } else if (ch === '\r') {
+            // carriage return
+            key.name = 'return';
+            key.meta = escaped;
+        } else if (ch === '\n') {
+            // Enter, should have been called linefeed
+            key.name = 'enter';
+            key.meta = escaped;
+        } else if (ch === '\t') {
+            // tab
+            key.name = 'tab';
+            key.meta = escaped;
+        } else if (ch === '\b' || ch === '\x7f') {
+            // backspace or ctrl+h
+            key.name = 'backspace';
+            key.meta = escaped;
+        } else if (ch === kEscape) {
+            // escape key
+            key.name = 'escape';
+            key.meta = escaped;
+        } else if (ch === ' ') {
+            key.name = 'space';
+            key.meta = escaped;
+        } else if (!escaped && ch <= '\x1a') {
+            // ctrl+letter
+            key.name = StringFromCharCode(
+                StringPrototypeCharCodeAt(ch) + StringPrototypeCharCodeAt('a') - 1
+            );
+            key.ctrl = true;
+        } else if (RegExpPrototypeTest(/^[0-9A-Za-z]$/, ch)) {
+            // Letter, number, shift+letter
+            key.name = StringPrototypeToLowerCase(ch);
+            key.shift = RegExpPrototypeTest(/^[A-Z]$/, ch);
+            key.meta = escaped;
+        } else if (escaped) {
+            // Escape sequence timeout
+            key.name = ch.length ? undefined : 'escape';
+            key.meta = true;
+        }
+
+        key.sequence = s;
+
+        if (s.length !== 0 && (key.name !== undefined || escaped)) {
+            /* Named character or sequence */
+            callback(escaped ? undefined : s, key);
+        } else if (charLengthAt(s, 0) === s.length) {
+            /* Single unnamed character, e.g. "." */
+            callback(s, key);
+        }
+        /* Unrecognized or broken escape sequence, don't emit anything */
+    }
+}
+
+// This runs in O(n log n).
+/**
+ * @param strings
+ */
+function commonPrefix(strings) {
+    if (strings.length === 1)
+        return strings[0];
+
+    const sorted = ArrayPrototypeSort(ArrayPrototypeSlice(strings));
+    const min = sorted[0];
+    const max = sorted[sorted.length - 1];
+    for (let i = 0; i < min.length; i++) {
+        if (min[i] !== max[i])
+            return StringPrototypeSlice(min, 0, i);
+    }
+    return min;
+}
+
+export {
+    charLengthAt,
+    charLengthLeft,
+    commonPrefix,
+    emitKeys,
+    kSubstringSearch,
+    CSI
+};
diff --git a/modules/esm/events.js b/modules/esm/events.js
new file mode 100644
index 000000000..0eb646428
--- /dev/null
+++ b/modules/esm/events.js
@@ -0,0 +1,104 @@
+export class EventEmitter {
+    #signalConnections = [];
+    #nextConnectionId = 1;
+
+    connect(name, callback) {
+        // be paranoid about callback arg since we'd start to throw from emit()
+        // if it was messed up
+        if (typeof callback !== 'function')
+            throw new Error('When connecting signal must give a callback that is a function');
+
+        let id = this.#nextConnectionId;
+        this.#nextConnectionId += 1;
+
+        // this makes it O(n) in total connections to emit, but I think
+        // it's right to optimize for low memory and reentrancy-safety
+        // rather than speed
+        this.#signalConnections.push({
+            id,
+            name,
+            callback,
+            'disconnected': false,
+        });
+        return id;
+    }
+
+    disconnect(id) {
+        let i;
+        let length = this.#signalConnections.length;
+        for (i = 0; i < length; ++i) {
+            let connection = this.#signalConnections[i];
+            if (connection.id === id) {
+                if (connection.disconnected)
+                    throw new Error(`Signal handler id ${id} already disconnected`);
+
+                // set a flag to deal with removal during emission
+                connection.disconnected = true;
+                this.#signalConnections.splice(i, 1);
+
+                return;
+            }
+        }
+
+        throw new Error(`No signal connection ${id} found`);
+    }
+
+    signalHandlerIsConnected(id) {
+        const {length} = this.#signalConnections;
+        for (let i = 0; i < length; ++i) {
+            const connection = this.#signalConnections[i];
+            if (connection.id === id)
+                return !connection.disconnected;
+        }
+
+        return false;
+    }
+
+    disconnectAll() {
+        while (this.#signalConnections.length > 0)
+            this.disconnect(this.#signalConnections[0].id);
+    }
+
+    emit(name, ...args) {
+        // To deal with re-entrancy (removal/addition while
+        // emitting), we copy out a list of what was connected
+        // at emission start; and just before invoking each
+        // handler we check its disconnected flag.
+        let handlers = [];
+        let i;
+        let length = this.#signalConnections.length;
+        for (i = 0; i < length; ++i) {
+            let connection = this.#signalConnections[i];
+            if (connection.name === name)
+                handlers.push(connection);
+        }
+
+        // create arg array which is emitter + everything passed in except
+        // signal name. Would be more convenient not to pass emitter to
+        // the callback, but trying to be 100% consistent with GObject
+        // which does pass it in. Also if we pass in the emitter here,
+        // people don't create closures with the emitter in them,
+        // which would be a cycle.
+        let argArray = [this, ...args];
+
+        length = handlers.length;
+        for (i = 0; i < length; ++i) {
+            let connection = handlers[i];
+            if (!connection.disconnected) {
+                try {
+                    // since we pass "null" for this, the global object will be used.
+                    let ret = connection.callback.apply(null, argArray);
+
+                    // if the callback returns true, we don't call the next
+                    // signal handlers
+                    if (ret === true)
+                        break;
+                } catch (e) {
+                    // just log any exceptions so that callbacks can't disrupt
+                    // signal emission
+                    logError(e, `Exception in callback for signal: ${name}`);
+                }
+            }
+        }
+    }
+}
diff --git a/modules/esm/repl.js b/modules/esm/repl.js
index 7dac78ef0..0ab9e059b 100644
--- a/modules/esm/repl.js
+++ b/modules/esm/repl.js
@@ -4,7 +4,13 @@
 import GLib from 'gi://GLib';
 let Gio;
 
-import {Ansi, Keycode} from './_repl/cliffy.js';
+import {emitKeys, CSI} from './_repl/utils.js';
+import {cursorTo} from './_repl/callbacks.js';
+
+import {EventEmitter} from './events.js';
+
+const cursorHide = CSI`?25l`;
+const cursorShow = CSI`?25h`;
 
 const Console = import.meta.importSync('_consoleNative');
 
@@ -29,313 +35,331 @@ function toString(value) {
     return `${value}`;
 }
 
-class ReplInput {
-    #inputHandler;
-    #exitHandler;
-    #exitWarning;
+/**
+ * @param {string} string
+ * @param {number} index
+ * @param {number} removeCount
+ * @param {string} replacement
+ * @returns {string}
+ */
+function StringSplice(string, index, removeCount = 0, replacement = '') {
+    return string.slice(0, index) + replacement + string.slice(index + removeCount);
+}
 
+export class Readline extends EventEmitter {
     #prompt;
+    #input = '';
     #cancelling = false;
-    #cancellable = null;
 
     /**
-     * @param {object} _ _
-     * @param {Gio.UnixOutputStream} _.stdin the input stream to treat as stdin
-     * @param {Gio.UnixOutputStream} _.stdout the output stream to treat as stdout
-     * @param {Gio.UnixOutputStream} _.stderr the output stream to treat as stderr
-     * @param {boolean} _.enableColor whether to print ANSI color codes
+     * Store pending lines
+     *
+     * @example
+     * gjs > 'a pending line...
+     * ..... '
+     *
+     * @type {string[]}
      */
-    constructor({stdin, stdout, stderr, enableColor}) {
-        this.stdin = stdin;
-        this.stdout = stdout;
-        this.stderr = stderr;
-        this.enableColor = enableColor;
+    #pendingInputLines = [];
 
-        this.#prompt = this.#buildPrompt();
-        this.#cancelling = false;
+    /**
+     * @param {object} _ _
+     * @param {string} _.prompt
+     */
+    constructor({prompt}) {
+        ({Gio} = imports.gi);
 
-        /**
-         * Store previously inputted lines
-         *
-         * @type {string[]}
-         */
-        this.history = [];
-        /**
-         * Store pending lines
-         *
-         * @example
-         * gjs > 'a pending line...
-         * ..... '
-         *
-         * @type {string[]}
-         */
-        this.pendingInputLines = [];
-        /**
-         * The current input buffer (in chars)
-         *
-         * @type {string[]}
-         */
-        this.currentInputChars = [];
-
-        /**
-         * The cursor's current column position.
-         */
-        this.cursorColumn = 0;
-
-        this.#inputHandler =
-            /**
-             * @param {string} _input the inputted line or lines (separated by \n)
-             */
-            _input => { };
+        super();
 
-        this.#exitWarning = false;
+        this.#prompt = prompt;
     }
 
     [Symbol.toStringTag]() {
-        return 'Repl';
+        return 'Readline';
     }
 
     get cancelled() {
         return this.#cancelling;
     }
 
-    /**
-     * @param {string} pointer the pointer to prefix the line with
-     */
-    #buildPrompt(pointer = '>') {
-        const renderedPrompt = `${pointer} `;
-        const length = renderedPrompt.length;
+    get line() {
+        return this.#input;
+    }
 
-        return {
-            pointer,
-            length,
-            renderedPrompt,
-        };
+    set line(value) {
+        this.#input = value;
     }
 
-    /**
-     * @returns {string}
-     */
-    getValue() {
-        if (this.historyIndex >= 0)
-            return this.history[this.historyIndex].join('');
+    validate(input) {
+        return Console.isValid(input);
+    }
 
+    processLine() {
+        const {line} = this;
+        // Rebuild the input...
+        const js = [...this.#pendingInputLines, line].join('\n');
 
-        return this.currentInputChars.join('');
-    }
+        // Reset state...
+        this.#input = '';
 
-    /**
-     * @returns {string[]}
-     */
-    getEditableValue() {
-        if (this.historyIndex > -1) {
-            // TODO(ewlsh): This allows editing each history entry
-            // 'in place'.
-            return this.history[this.historyIndex];
+        // Only trigger input if this is a compilable unit...
+        if (this.validate(js)) {
+            // Reset lines before input is triggered
+            this.#pendingInputLines = [];
+            this.emit('line', js);
+        } else {
+            // Buffer the input until a compilable unit is found...
+            this.#pendingInputLines.push(line);
         }
-        return this.currentInputChars;
     }
 
-    editValue(editor) {
-        if (this.historyIndex > -1) {
-            this.history[this.historyIndex] = editor(this.history[this.historyIndex]);
-            return this.history[this.historyIndex];
+    get inputPrompt() {
+        if (this.#pendingInputLines.length > 0) {
+            // Create a prefix like '... '
+            return ' '.padStart(4, '.');
         }
 
-        this.currentInputChars = editor(this.currentInputChars);
-        return this.currentInputChars;
+        return this.#prompt;
     }
 
-    validate(input) {
-        return Console.isValid(input);
+    print(output) {
+    }
+
+    render() {
+    }
+
+    prompt() {
+        this.#cancelling = false;
+    }
+
+    exit() {
+    }
+
+    cancel() {
+        this.#cancelling = true;
     }
+}
+
+export class AsyncReadline extends Readline {
+    #exitWarning;
+
+    #parser;
+
+    #cancellable = null;
+
+    /**
+     * Store previously inputted lines
+     *
+     * @type {string[]}
+     */
+    #history = [];
+
+    /**
+     * The cursor's current column position.
+     */
+    #cursorColumn = 0;
+
+    /**
+     * @param {object} _ _
+     * @param {Gio.UnixOutputStream} _.stdin the input stream to treat as stdin
+     * @param {Gio.UnixOutputStream} _.stdout the output stream to treat as stdout
+     * @param {Gio.UnixOutputStream} _.stderr the output stream to treat as stderr
+     * @param {boolean} _.enableColor whether to print ANSI color codes
+     * @param _.prompt
+     */
+    constructor({stdin, stdout, stderr, enableColor, prompt}) {
+        super({prompt});
+
+        this.stdin = stdin;
+        this.stdout = stdout;
+        this.stderr = stderr;
+        this.enableColor = enableColor;
+
+
+        this.#parser = emitKeys(this.#onKeyPress.bind(this));
+        this.#parser.next();
+
 
-    clear(lines = 1) {
-        this.writeSync(Ansi.cursorLeft);
-        this.writeSync(Ansi.eraseDown(lines));
+        this.#exitWarning = false;
+    }
+
+    get line() {
+        if (this.historyIndex > -1)
+            return this.#history[this.historyIndex];
+
+
+        return super.line;
+    }
+
+    set line(value) {
+        if (this.historyIndex > -1) {
+            this.#history[this.historyIndex] = value;
+            return;
+        }
+
+        super.line = value;
     }
 
     exit() {
         if (this.#exitWarning) {
             this.#exitWarning = false;
-            this.#exitHandler?.();
+            this.emit('exit');
         } else {
             this.#exitWarning = true;
-            this.writeSync('\n(To exit, press Ctrl+C again or Ctrl+D)\n');
-            this.flush();
+            this.print('\n(To exit, press Ctrl+C again or Ctrl+D)\n');
         }
     }
 
     historyUp() {
-        if (this.historyIndex < this.history.length - 1) {
+        if (this.historyIndex < this.#history.length - 1) {
             this.historyIndex++;
-            this.cursorColumn = -1;
+            this.cursor = -1;
         }
     }
 
     historyDown() {
         if (this.historyIndex >= 0) {
             this.historyIndex--;
-            this.cursorColumn = -1;
+            this.cursor = -1;
         }
     }
 
     moveCursorToBeginning() {
-        this.cursorColumn = 0;
+        this.cursor = 0;
     }
 
     moveCursorToEnd() {
-        this.cursorColumn = this.getValue().length;
+        this.cursor = this.line.length;
     }
 
     moveCursorLeft() {
-        if (this.cursorColumn > 0)
-            this.cursorColumn--;
+        this.cursor--;
     }
 
     moveCursorRight() {
-        if (this.cursorColumn < this.getValue().length)
-            this.cursorColumn++;
+        this.cursor++;
     }
 
     addChar(char) {
-        const editableValue = this.getEditableValue();
-        editableValue.splice(this.cursorColumn, 0, char);
-
-        this.cursorColumn++;
+        this.line = StringSplice(this.line, this.cursor, 0, char);
+        this.moveCursorRight();
     }
 
     deleteChar() {
-        const editableValue = this.getEditableValue();
-        if (this.cursorColumn > 0 && editableValue.length > 0)
-            editableValue.splice(this.cursorColumn - 1, 1);
+        const {line} = this;
 
-        this.moveCursorLeft();
+        if (line.length > 0 && this.cursor > 0) {
+            const x = StringSplice(line, this.cursor - 1, 1);
+
+            this.line = x;
+            this.moveCursorLeft();
+        }
     }
 
     deleteCharRightOrClose() {
-        const editableValue = this.getEditableValue();
-        if (this.cursorColumn < editableValue.length - 1)
-            editableValue.splice(this.cursorColumn, 1);
+        const {line} = this;
+
+        if (this.cursor < line.length - 1)
+            this.line = StringSplice(this.line, this.cursor, 1);
         else
             this.exit();
     }
 
     deleteToBeginning() {
-        const editableValue = this.getEditableValue();
-
-        editableValue.splice(0, this.cursorColumn);
+        this.line = StringSplice(this.line, 0, this.cursor);
     }
 
     deleteToEnd() {
-        const editableValue = this.getEditableValue();
-
-        editableValue.splice(this.cursorColumn);
+        this.line = StringSplice(this.line, this.cursor);
     }
 
     /**
      * Adapted from lib/readline.js in Node.js
      */
-    _deleteWordLeft() {
-        this.editValue(value => {
-            if (this.cursorColumn > 0) {
-                // Reverse the string and match a word near beginning
-                // to avoid quadratic time complexity
-                let leading = value.slice(0, this.cursorColumn);
-                const reversed = [...leading].reverse().join('');
-                const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/);
-                leading = leading.slice(0,
-                    leading.length - match[0].length);
-                value = leading.concat(value.slice(this.cursorColumn));
-                this.cursorColumn = leading.length;
-
-                return value;
-            }
-        });
+    deleteWordLeft() {
+        const {line} = this;
+
+        if (this.cursor > 0) {
+            // Reverse the string and match a word near beginning
+            // to avoid quadratic time complexity
+            let leading = line.slice(0, this.cursor);
+            const reversed = [...leading].reverse().join('');
+            const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/);
+            leading = leading.slice(0,
+                leading.length - match[0].length);
+            this.line = leading.concat(line.slice(this.cursor));
+            this.cursor = leading.length;
+        }
     }
 
     /**
      * Adapted from lib/readline.js in Node.js
      */
-    _deleteWordRight() {
-        this.editValue(value => {
-            if (this.currentInputChars.length > 0 && this.cursorColumn < value.length) {
-                const trailing = value.slice(this.cursorColumn).join('');
-                const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/);
-                value = value.slice(0, this.cursorColumn).concat(
-                    trailing.slice(match[0].length));
-                return value;
-            }
-        });
+    deleteWordRight() {
+        const {line} = this;
+
+        if (line.length > 0 && this.cursor < line.length) {
+            const trailing = line.slice(this.cursor);
+            const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/);
+            this.line = line.slice(0, this.cursor).concat(
+                trailing.slice(match[0].length));
+        }
     }
 
     /**
      * Adapted from lib/readline.js in Node.js
      */
-    _wordLeft() {
-        if (this.cursorColumn > 0) {
-            const value = this.getValue();
+    wordLeft() {
+        const {line} = this;
+        if (this.cursor > 0) {
             // Reverse the string and match a word near beginning
             // to avoid quadratic time complexity
-            const leading = value.slice(0, this.cursorColumn);
-            const reversed = Array.from(leading).reverse().join('');
+            const leading = line.slice(0, this.cursor);
+            const reversed = [...leading].reverse().join('');
             const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/);
 
-            this.cursorColumn -= match[0].length;
-            this.cursorColumn = Math.max(0, this.cursorColumn);
+            this.cursor -= match[0].length;
+            this.cursor = Math.max(0, this.cursor);
         }
     }
 
     /**
      * Adapted from lib/readline.js in Node.js
      */
-    _wordRight() {
-        const value = this.getValue();
+    wordRight() {
+        const {line} = this;
 
-        if (this.cursorColumn < value.length) {
-            const trailing = value.slice(this.cursorColumn);
+        if (this.cursor < line.length) {
+            const trailing = line.slice(this.cursor);
             const match = trailing.match(/^(?:\s+|[^\w\s]+|\w+)\s*/);
 
-            this.cursorColumn += match[0].length;
+            this.cursor += match[0].length;
         }
     }
 
-    get isRaw() {
-        return true;
-    }
-
     processLine() {
-        const value = this.getValue();
-        // Rebuild the input...
-        const js = [...this.pendingInputLines, value].join('\n');
+        const {line} = this;
 
-        // Reset state...
-        this.history.unshift(value.split(''));
+        this.#history.unshift(line);
         this.historyIndex = -1;
-        this.currentInputChars = [];
-        this.cursorColumn = 0;
-
-        // In raw mode we need to manually write a
-        // new line...
-        if (this.isRaw) {
-            // Append the new line...
-            this.writeSync('\n');
-            this.flush();
-        }
+        this.#exitWarning = false;
+        this.cursor = 0;
+        this.#write('\n');
 
-        // Only trigger input if this is a compilable unit...
-        if (this.validate(js)) {
-            // Reset lines before input is triggered
-            this.pendingInputLines = [];
-            this.#exitWarning = false;
-            this.#inputHandler?.(js);
-        } else {
-            // Buffer the input until a compilable unit is found...
-            this.pendingInputLines.push(value);
-        }
+        super.processLine();
+    }
+
+    #onKeyPress(sequence, key) {
+        this.#processKey(key);
+
+        if (!this.cancelled)
+            this.render();
     }
 
-    handleEvent(key) {
+    #processKey(key) {
+        if (!key.sequence)
+            return;
+
         if (key.ctrl && !key.meta && !key.shift) {
             switch (key.name) {
             case 'c':
@@ -379,31 +403,31 @@ class ReplInput {
                 return;
             case 'w':
             case 'backspace':
-                this._deleteWordLeft();
+                this.deleteWordLeft();
                 return;
             case 'delete':
-                this._deleteWordRight();
+                this.deleteWordRight();
                 return;
             case 'left':
-                this._wordLeft();
+                this.wordLeft();
                 return;
             case 'right':
-                this._wordRight();
+                this.wordRight();
                 return;
             }
         } else if (key.meta && !key.shift) {
             switch (key.name) {
             case 'd':
-                this._deleteWordRight();
+                this.deleteWordRight();
                 return;
             case 'backspace':
-                this._deleteWordLeft();
+                this.deleteWordLeft();
                 return;
             case 'b':
-                this._wordLeft();
+                this.wordLeft();
                 return;
             case 'f':
-                this._wordRight();
+                this.wordRight();
                 return;
             }
         }
@@ -432,203 +456,184 @@ class ReplInput {
         this.addChar(key.sequence);
     }
 
-    updateInputIndex(value) {
-        if (this.cursorColumn === -1)
-            this.cursorColumn = value.length;
-
+    /**
+     * @param {number} length
+     */
+    set cursor(length) {
+        if (length < 0) {
+            this.#cursorColumn = 0;
+            return;
+        }
 
         // Ensure the input index isn't longer than the content...
-        this.cursorColumn = Math.min(this.cursorColumn, value.length);
+        this.#cursorColumn = Math.min(this.line.length, length);
     }
 
-    getPrompt() {
-        if (this.pendingInputLines.length > 0) {
-            // Create a prefix like '... '
-            return ' '.padStart(4, '.');
-        }
-
-        return this.#prompt.renderedPrompt;
+    get cursor() {
+        return this.#cursorColumn;
     }
 
     render() {
-        const value = this.getValue();
-
         // Prevent the cursor from flashing while we render...
-        this.writeSync(Ansi.cursorHide);
-        this.clear();
+        this.#write(cursorHide);
+
+        const {inputPrompt, line} = this;
 
-        const prompt = this.getPrompt();
-        this.writeSync(prompt + value);
+        this.#write(
+            cursorTo(0),
+            CSI.kClearScreenDown,
+            inputPrompt,
+            line,
+            cursorTo(inputPrompt.length + this.cursor),
+            cursorShow
+        );
 
-        this.updateInputIndex(value);
-        this.writeSync(Ansi.cursorTo(prompt.length + this.cursorColumn + 1));
-        this.writeSync(Ansi.cursorShow);
-        this.flush();
+        this.emit('render');
     }
 
-    flush() {
+    #write(...strings) {
+        const bytes = new TextEncoder().encode(strings.join(''));
+
+        this.stdout.write_bytes(bytes, null);
         this.stdout.flush(null);
     }
 
     /**
-     * @param {Uint8Array | string} buffer a string or Uint8Array to write to stdout
+     * @param {string[]} strings strings to write to stdout
      */
-    writeSync(buffer) {
-        if (typeof buffer === 'string')
-            buffer = new TextEncoder().encode(buffer);
-        this.stdout.write_bytes(buffer, null);
+    print(...strings) {
+        this.#write(...strings, '\n');
     }
 
+    /**
+     * @param {Uint8Array} bytes
+     * @returns {void}
+     */
     handleInput(bytes) {
         if (bytes.length === 0)
             return;
 
-        for (const event of Keycode.parse(bytes)) {
-            this.handleEvent(event);
+        const input = String.fromCharCode(...bytes.values());
 
-            if (this.#cancelling)
-                break;
+        for (const byte of input) {
+            this.#parser.next(byte);
 
-            this.render();
+            if (this.cancelled)
+                break;
         }
     }
 
     #asyncReadHandler(stream, result) {
-        this.#cancellable = null;
-
-        if (this.#cancelling)
-            return;
-
         if (result) {
-            const gbytes = stream.read_bytes_finish(result);
+            try {
+                const gbytes = stream.read_bytes_finish(result);
+
+                this.handleInput(gbytes.toArray());
+            } catch (error) {
+                if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
+                    console.error(error);
+                    imports.system.exit(1);
 
-            this.handleInput(gbytes.toArray());
+                    return;
+                }
+            }
         }
 
+        if (this.cancelled)
+            return;
+
         this.#cancellable = new Gio.Cancellable();
         stream.read_bytes_async(8, 0, this.#cancellable, this.#asyncReadHandler.bind(this));
     }
 
     cancel() {
-        this.#cancelling = true;
+        super.cancel();
 
         this.#cancellable?.cancel();
         this.#cancellable = null;
 
-        this.writeSync('\n');
-        this.flush();
+        this.#write('\n');
     }
 
-    read() {
+    prompt() {
+        super.prompt();
         this.render();
 
         // Start the async read loop...
         this.#asyncReadHandler(this.stdin);
     }
-
-    /**
-     *
-     * @param {(input: string) => void} inputHandler a callback when new lines are processed
-     */
-    prompt(inputHandler) {
-        this.#inputHandler = inputHandler;
-        this.#cancelling = false;
-        this.read();
-    }
-
-    onExit(exitHandler) {
-        this.#exitHandler = exitHandler;
-    }
 }
 
-class FallbackReplInput extends ReplInput {
-    constructor() {
-        super({});
+export class SyncReadline extends Readline {
+    constructor({prompt}) {
+        super({prompt});
     }
 
-    read() {
+    prompt() {
         while (!this.cancelled) {
-            const prompt = this.getPrompt();
-            this.editValue(() => {
-                try {
-                    return Console.interact(prompt).split('');
-                } catch {
-                    return '';
-                }
-            });
+            const {inputPrompt} = this;
+
+            try {
+                this.line = Console.interact(inputPrompt).split('');
+            } catch {
+                this.line = '';
+            }
+
 
             this.processLine();
         }
     }
 
-    get isRaw() {
-        return false;
+    print(output) {
+        print(output);
     }
-
-    writeSync(buffer) {
-        if (buffer instanceof Uint8Array)
-            buffer = new TextDecoder().decode(buffer);
-        print(buffer);
-    }
-
-    flush() { }
 }
 
 export class Repl {
     #lineNumber = 0;
-    #isRaw = false;
+    #isAsync = false;
 
+    /** @type {boolean} */
     #supportsColor;
-    #mainloop;
+    /** @type {string} */
     #version;
 
+    #mainloop = false;
+
     constructor() {
         ({Gio} = imports.gi);
 
         this.#version = imports.system.versionString;
-    }
 
-    get lineNumber() {
-        return this.#lineNumber;
-    }
+        try {
+            this.#supportsColor = GLib.log_writer_supports_color(1) && GLib.getenv('NO_COLOR') === null;
+        } catch {
+            this.#supportsColor ||= false;
+        }
 
-    get isRaw() {
-        return this.#isRaw;
+        try {
+            this.#isAsync &&= GLib.getenv('GJS_REPL_USE_FALLBACK') !== 'true';
+            this.#isAsync = 'UnixInputStream' in Gio && 'UnixOutputStream' in Gio;
+            this.#isAsync &&= Console.enableRawMode();
+        } catch {
+            this.#isAsync = false;
+        }
     }
 
-    #checkEnvironment() {
-        this.#supportsColor = GLib.log_writer_supports_color(1);
-        this.#supportsColor &&= GLib.getenv('NO_COLOR') === null;
-
-        let hasUnixStreams = 'UnixInputStream' in Gio;
-        hasUnixStreams = hasUnixStreams && 'UnixOutputStream' in Gio;
-
-        if (!hasUnixStreams)
-            return false;
-
-        const noMainLoop = GLib.getenv('GJS_REPL_NO_MAINLOOP');
-        // TODO: Environment variable for testing.
-        if (noMainLoop && noMainLoop === 'true')
-            return false;
-
-        return true;
+    [Symbol.toStringTag]() {
+        return 'Repl';
     }
 
-    #registerInputHandler() {
-        this.#print(`GJS v${this.#version}`);
+    get lineNumber() {
+        return this.#lineNumber;
+    }
 
-        // Start accepting input and rendering...
-        this.input.prompt(lines => {
-            if (lines.trim().startsWith('exit()'))
-                this.exit();
-            else
-                this.evaluate(lines);
-        });
+    get supportsColor() {
+        return this.#supportsColor;
     }
 
     #print(string) {
-        this.input.writeSync(`${string}${this.#isRaw ? '\n' : ''}`);
-        this.input.flush();
+        this.input.print(`${string}`);
     }
 
     #evaluateInternal(lines) {
@@ -672,90 +677,68 @@ export class Repl {
         this.#printError(error);
     }
 
+    #start() {
+        this.input.print(`GJS v${this.#version}`);
 
-    run() {
-        if (!this.#checkEnvironment()) {
-            this.input = new FallbackReplInput();
+        this.input.connect('line', (_, line) => {
+            if (typeof line === 'string' && line.trim().startsWith('exit()'))
+                this.exit();
+            else
+                this.evaluate(line);
+        });
 
-            this.#registerInputHandler();
-            return;
-        }
+        this.input.connect('exit', () => {
+            this.exit();
+        });
 
-        try {
-            this.#isRaw = Console.enableRawMode();
+        this.input.prompt();
+    }
 
-            if (!this.#isRaw) {
-                this.input = new FallbackReplInput();
+    start() {
+        if (!this.#isAsync) {
+            this.input = new SyncReadline({prompt: '> '});
 
-                this.#registerInputHandler();
-                return;
-            }
+            this.#start();
 
+            return;
+        }
+
+        try {
             const stdin = Gio.UnixInputStream.new(0, false);
-            const stdout = Gio.UnixOutputStream.new(1, false);
+            const stdout = new Gio.BufferedOutputStream({
+                baseStream: Gio.UnixOutputStream.new(1, false),
+                closeBaseStream: false,
+                autoGrow: true,
+            });
             const stderr = Gio.UnixOutputStream.new(2, false);
 
-            this.input = new ReplInput({
+            this.input = new AsyncReadline({
                 stdin,
                 stdout,
                 stderr,
                 enableColor: this.#supportsColor,
+                prompt: '> ',
             });
 
-            this.input.onExit(() => {
-                this.exit();
-            });
-
-            this.#registerInputHandler();
-
-            // Install our default mainloop...
-            this.replaceMainLoop(() => {
-                imports.mainloop.run('repl');
-            }, () => {
-                imports.mainloop.quit('repl');
-            });
-
-            let mainloop = this.#mainloop;
-            while (mainloop) {
-                const [start] = mainloop;
-
-                start();
+            this.#start();
 
-                mainloop = this.#mainloop;
-            }
+            this.#mainloop = true;
+            imports.mainloop.run('repl');
         } finally {
             Console.disableRawMode();
         }
     }
 
     exit() {
-        this.input.cancel();
-
-        const mainloop = this.#mainloop;
-        this.#mainloop = null;
-
-        if (mainloop) {
-            const [, quit] = mainloop;
-
-            quit?.();
-        }
-    }
-
-    replaceMainLoop(start, quit = () => {
-        // Force an exit if a user doesn't define their
-        // replacement mainloop's quit function.
-        imports.system.exit(1);
-    }) {
-        if (!(this.input instanceof ReplInput))
-            return;
-
-        const mainloop = this.#mainloop;
-        this.#mainloop = [start, quit];
-
-        if (mainloop) {
-            const [, previousQuit] = mainloop;
+        try {
+            this.input.cancel();
 
-            previousQuit?.();
+            if (this.#mainloop)
+                imports.mainloop.quit('repl');
+        } catch {
+            // Force an exit if a user doesn't define their
+            // replacement mainloop's quit function.
+            imports.system.exit(1);
         }
     }
 }
diff --git a/modules/script/console.js b/modules/script/console.js
index 82ae75cc8..d80b699c1 100644
--- a/modules/script/console.js
+++ b/modules/script/console.js
@@ -8,5 +8,5 @@ var Repl = null;
 function interact() {
     const repl = new Repl();
 
-    repl.run();
+    repl.start();
 }


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]