Преглед на файлове

Add shell mode (virtual FS) to web terminal

Introduce an interactive shell mode for the web Terminal. Backend (session.agi): implement shell state, virtual path resolution, many shell commands (pwd, ls, cd, cat, mkdir, touch, rm, cp, mv, echo, whoami, clear, exit, help), tab-completion, prompt building, safe filelib wrappers, and new websocket message types (shellmode, clear, complete, shellPrompt in result). Frontend (index.html): track shellMode/shellPrompt, render shell output differently, handle shell enter/exit and clear messages, send/handle completion requests/responses, adjust input handling (Tab completion and disabling REPL dot-commands while in shell). Add .claude/launch.json to run a local static server for src/web. This enables a secure virtual filesystem shell inside the AGI REPL without host FS access.
Toby Chui преди 4 дни
родител
ревизия
d14d6aabae
променени са 3 файла, в които са добавени 575 реда и са изтрити 19 реда
  1. 11 0
      .claude/launch.json
  2. 433 3
      src/web/Terminal/backend/session.agi
  3. 131 16
      src/web/Terminal/index.html

+ 11 - 0
.claude/launch.json

@@ -0,0 +1,11 @@
+{
+  "version": "0.0.1",
+  "configurations": [
+    {
+      "name": "webroot-static",
+      "runtimeExecutable": "python",
+      "runtimeArgs": ["-m", "http.server", "8123", "--directory", "src/web"],
+      "port": 8123
+    }
+  ]
+}

+ 433 - 3
src/web/Terminal/backend/session.agi

@@ -12,9 +12,12 @@
       { type: "ping" }                 keep-alive
 
     Server → Client
-      { type: "ready",  user, build }                  session started
-      { type: "result", output, error, logs }           exec result
-      { type: "pong" }                                  ping response
+      { type: "ready",     user, build }                   session started
+      { type: "result",    output, error, logs,
+                           shellPrompt? }                  exec result
+      { type: "shellmode", active, cwd?, prompt? }         shell mode changed
+      { type: "clear" }                                    clear terminal
+      { type: "pong" }                                     ping response
 */
 
 requirelib("websocket");
@@ -81,6 +84,388 @@ function _formatResult(v) {
     }
 }
 
+// ── Shell mode ─────────────────────────────────────────────────────────────
+//
+//  Type "bash" in the REPL to enter shell mode.  All subsequent input is
+//  interpreted as shell commands operating on the virtual filesystem.
+//  Type "exit" (or "quit") inside shell mode to return to the AGI REPL.
+//
+//  Virtual path conventions
+//  ─────────────────────────
+//    user:/Desktop/file.txt   absolute virtual path
+//    ~/Documents              ~ expands to user:/
+//    Desktop/file.txt         relative to current shell CWD
+//    ..                       parent directory
+//  All file operations go through filelib and respect the user's VFS
+//  permissions — no host filesystem access is possible.
+
+var _shellMode = false;
+var _shellCwd  = "user:/";
+
+function _shellStrEndsWith(str, suffix) {
+    return str.length >= suffix.length &&
+           str.lastIndexOf(suffix) === str.length - suffix.length;
+}
+
+function _shellPadLeft(val, len, ch) {
+    var s = String(val);
+    ch = ch || " ";
+    while (s.length < len) { s = ch + s; }
+    return s;
+}
+
+function _shellBuildPrompt() {
+    var display = _shellCwd;
+    if (display === "user:/") {
+        display = "~";
+    } else if (display.indexOf("user:/") === 0) {
+        display = "~/" + display.substring(6);
+    }
+    // strip trailing slash for display
+    if (display.length > 1 && _shellStrEndsWith(display, "/")) {
+        display = display.substring(0, display.length - 1);
+    }
+    return USERNAME + "@arozos:" + display + "$ ";
+}
+
+// Tokenise a command line — splits on whitespace, honours single/double quotes.
+function _shellTokenize(cmdline) {
+    var tokens = [];
+    var cur = "";
+    var inQuote = false;
+    var qch = "";
+    for (var i = 0; i < cmdline.length; i++) {
+        var c = cmdline.charAt(i);
+        if (inQuote) {
+            if (c === qch) { inQuote = false; }
+            else           { cur += c; }
+        } else if (c === '"' || c === "'") {
+            inQuote = true; qch = c;
+        } else if (c === " " || c === "\t") {
+            if (cur !== "") { tokens.push(cur); cur = ""; }
+        } else {
+            cur += c;
+        }
+    }
+    if (cur !== "") tokens.push(cur);
+    return tokens;
+}
+
+// Resolve a path relative to the current shell CWD.
+function _shellResolvePath(input) {
+    if (!input || input === "" || input === ".") return _shellCwd;
+    if (input === "~") return "user:/";
+    if (input.indexOf("~/") === 0) return "user:/" + input.substring(2);
+    // Absolute virtual path already contains :/
+    if (input.indexOf(":/") !== -1) return input;
+    // Build from CWD components
+    var base = _shellCwd;
+    if (!_shellStrEndsWith(base, "/")) base = base + "/";
+    var baseParts = base.replace(/\/$/, "").split("/");
+    var segs = input.split("/");
+    for (var i = 0; i < segs.length; i++) {
+        var s = segs[i];
+        if (s === "" || s === ".") { continue; }
+        if (s === "..") { if (baseParts.length > 2) baseParts.pop(); }
+        else            { baseParts.push(s); }
+    }
+    return baseParts.join("/");
+}
+
+// Safe wrapper: filelib.isDir() throws when path does not exist instead of
+// returning false.  This wrapper catches that exception and returns false.
+function _shellSafeIsDir(p) {
+    try { return filelib.isDir(p); } catch(e) { return false; }
+}
+
+// ── Shell commands ──────────────────────────────────────────────────────────
+
+function _shellCd(args) {
+    var target = (args.length > 0) ? args[0] : "~";
+    var resolved = _shellResolvePath(target);
+    if (!_shellStrEndsWith(resolved, "/")) resolved = resolved + "/";
+    if (!_shellSafeIsDir(resolved)) {
+        return {output: "cd: " + target + ": No such directory", error: true};
+    }
+    _shellCwd = resolved;
+    return {output: "", error: false};
+}
+
+function _shellLs(args, longMode) {
+    var showHidden = false;
+    var path = _shellCwd;
+    for (var i = 0; i < args.length; i++) {
+        var arg = args[i];
+        if (arg.charAt(0) === "-") {
+            if (arg.indexOf("a") !== -1) showHidden = true;
+            if (arg.indexOf("l") !== -1) longMode = true;
+        } else {
+            path = _shellResolvePath(arg);
+        }
+    }
+    if (!_shellStrEndsWith(path, "/")) path = path + "/";
+    if (!_shellSafeIsDir(path)) {
+        return {output: "ls: " + path + ": No such directory", error: true};
+    }
+    try {
+        var entries = filelib.readdir(path);
+        if (!entries || entries.length === 0) return {output: "", error: false};
+        var lines = [];
+        for (var j = 0; j < entries.length; j++) {
+            var e = entries[j];
+            if (!showHidden && e.Filename.charAt(0) === ".") continue;
+            if (longMode) {
+                var dtype = e.IsDir ? "d" : "-";
+                var size  = e.IsDir ? "       -" : _shellPadLeft(e.Filesize, 8);
+                var d     = new Date(e.Modtime * 1000);
+                var ds    = _shellPadLeft(d.getMonth()+1, 2, "0") + "-" +
+                            _shellPadLeft(d.getDate(), 2, "0") + " " +
+                            _shellPadLeft(d.getHours(), 2, "0") + ":" +
+                            _shellPadLeft(d.getMinutes(), 2, "0");
+                lines.push(dtype + "rwxr-xr-x  " + size + "  " + ds + "  " +
+                           e.Filename + (e.IsDir ? "/" : ""));
+            } else {
+                lines.push(e.Filename + (e.IsDir ? "/" : ""));
+            }
+        }
+        if (lines.length === 0) return {output: "", error: false};
+        return {output: lines.join(longMode ? "\n" : "  "), error: false};
+    } catch(e) {
+        return {output: "ls: " + String(e), error: true};
+    }
+}
+
+function _shellCat(args) {
+    if (args.length === 0) return {output: "cat: missing operand", error: true};
+    var out = [];
+    for (var i = 0; i < args.length; i++) {
+        var p = _shellResolvePath(args[i]);
+        if (_shellSafeIsDir(p)) {
+            out.push("cat: " + args[i] + ": Is a directory");
+        } else if (!filelib.fileExists(p)) {
+            out.push("cat: " + args[i] + ": No such file or directory");
+        } else {
+            try { out.push(filelib.readFile(p)); }
+            catch(e) { out.push("cat: " + args[i] + ": " + String(e)); }
+        }
+    }
+    return {output: out.join("\n"), error: false};
+}
+
+function _shellMkdir(args) {
+    if (args.length === 0) return {output: "mkdir: missing operand", error: true};
+    for (var i = 0; i < args.length; i++) {
+        if (args[i].charAt(0) === "-") continue;
+        var p = _shellResolvePath(args[i]);
+        try { filelib.mkdir(p); }
+        catch(e) { return {output: "mkdir: " + args[i] + ": " + String(e), error: true}; }
+    }
+    return {output: "", error: false};
+}
+
+function _shellTouch(args) {
+    if (args.length === 0) return {output: "touch: missing operand", error: true};
+    for (var i = 0; i < args.length; i++) {
+        var p = _shellResolvePath(args[i]);
+        if (!filelib.fileExists(p)) {
+            try { filelib.writeFile(p, ""); }
+            catch(e) { return {output: "touch: " + args[i] + ": " + String(e), error: true}; }
+        }
+    }
+    return {output: "", error: false};
+}
+
+function _shellRm(args) {
+    if (args.length === 0) return {output: "rm: missing operand", error: true};
+    var recursive = false;
+    var targets = [];
+    for (var i = 0; i < args.length; i++) {
+        if (args[i].charAt(0) === "-") {
+            if (args[i].indexOf("r") !== -1 || args[i].indexOf("R") !== -1) recursive = true;
+        } else {
+            targets.push(args[i]);
+        }
+    }
+    if (targets.length === 0) return {output: "rm: missing operand", error: true};
+    for (var j = 0; j < targets.length; j++) {
+        var p = _shellResolvePath(targets[j]);
+        if (!filelib.fileExists(p) && !_shellSafeIsDir(p)) {
+            return {output: "rm: " + targets[j] + ": No such file or directory", error: true};
+        }
+        if (_shellSafeIsDir(p) && !recursive) {
+            return {output: "rm: " + targets[j] + ": Is a directory (use rm -r)", error: true};
+        }
+        try { filelib.deleteFile(p); }
+        catch(e) { return {output: "rm: " + targets[j] + ": " + String(e), error: true}; }
+    }
+    return {output: "", error: false};
+}
+
+function _shellCp(args) {
+    if (args.length < 2) return {output: "cp: missing destination operand", error: true};
+    var src = _shellResolvePath(args[0]);
+    var dst = _shellResolvePath(args[1]);
+    if (!filelib.fileExists(src)) {
+        return {output: "cp: " + args[0] + ": No such file", error: true};
+    }
+    if (_shellSafeIsDir(src)) {
+        return {output: "cp: " + args[0] + ": Is a directory (recursive copy not supported)", error: true};
+    }
+    if (_shellSafeIsDir(dst)) {
+        var fname = src.split("/").pop();
+        dst = (_shellStrEndsWith(dst, "/") ? dst : dst + "/") + fname;
+    }
+    try {
+        filelib.writeBinaryFile(dst, filelib.readBinaryFile(src));
+    } catch(e) {
+        return {output: "cp: " + String(e), error: true};
+    }
+    return {output: "", error: false};
+}
+
+function _shellMv(args) {
+    if (args.length < 2) return {output: "mv: missing destination operand", error: true};
+    var src = _shellResolvePath(args[0]);
+    var dst = _shellResolvePath(args[1]);
+    if (!filelib.fileExists(src) && !_shellSafeIsDir(src)) {
+        return {output: "mv: " + args[0] + ": No such file or directory", error: true};
+    }
+    if (_shellSafeIsDir(src)) {
+        return {output: "mv: " + args[0] + ": Is a directory (directory move not supported)", error: true};
+    }
+    if (_shellSafeIsDir(dst)) {
+        var fname = src.split("/").pop();
+        dst = (_shellStrEndsWith(dst, "/") ? dst : dst + "/") + fname;
+    }
+    try {
+        filelib.writeBinaryFile(dst, filelib.readBinaryFile(src));
+        filelib.deleteFile(src);
+    } catch(e) {
+        return {output: "mv: " + String(e), error: true};
+    }
+    return {output: "", error: false};
+}
+
+function _shellHelp() {
+    return {output: [
+        "ArozOS Shell — virtual filesystem commands",
+        "",
+        "  pwd                        print working directory",
+        "  ls [-l] [-a] [path]        list directory contents",
+        "  ll [path]                  list with details (alias: ls -l)",
+        "  cd [path]                  change directory  (~ = user home)",
+        "  cat <file> [...]           print file contents",
+        "  mkdir <dir> [...]          create directory",
+        "  touch <file> [...]         create empty file",
+        "  rm [-r] <path> [...]       remove file or directory",
+        "  cp <src> <dst>             copy file",
+        "  mv <src> <dst>             move / rename file",
+        "  echo <text>                print text",
+        "  whoami                     print current user",
+        "  clear                      clear terminal",
+        "  exit                       return to AGI REPL",
+        "  bash()                     enter shell mode (from AGI REPL)",
+        "",
+        "Paths: use user:/path for absolute, or relative to current dir.",
+        "       ~ expands to user:/",
+        "       filenames with spaces can be quoted: cat \"Hello World.txt\""
+    ].join("\n"), error: false};
+}
+
+// Dispatch a shell command line.
+function _shellDispatch(cmdline) {
+    cmdline = cmdline.replace(/^\s+|\s+$/g, "");
+    if (cmdline === "") return {output: "", error: false};
+    var tokens = _shellTokenize(cmdline);
+    if (tokens.length === 0) return {output: "", error: false};
+    var cmd  = tokens[0];
+    var args = tokens.slice(1);
+
+    if (cmd === "exit" || cmd === "quit") {
+        _shellMode = false;
+        return {output: "Returned to AGI runtime.", error: false, shellExit: true};
+    }
+    if (cmd === "pwd")    return {output: _shellCwd.replace(/\/$/, ""), error: false};
+    if (cmd === "whoami") return {output: USERNAME, error: false};
+    if (cmd === "clear")  return {output: "", error: false, shellClear: true};
+    if (cmd === "echo")   return {output: args.join(" "), error: false};
+    if (cmd === "cd")     return _shellCd(args);
+    if (cmd === "ls")     return _shellLs(args, false);
+    if (cmd === "ll" || cmd === "dir") return _shellLs(args, true);
+    if (cmd === "cat")    return _shellCat(args);
+    if (cmd === "mkdir")  return _shellMkdir(args);
+    if (cmd === "touch")  return _shellTouch(args);
+    if (cmd === "rm")     return _shellRm(args);
+    if (cmd === "cp")     return _shellCp(args);
+    if (cmd === "mv")     return _shellMv(args);
+    if (cmd === "help" || cmd === "?") return _shellHelp();
+    return {output: cmd + ": command not found  (type 'help' for available commands)", error: true};
+}
+
+// "bash" — callable from the AGI REPL to enter shell mode.
+function bash() {
+    requirelib("filelib");
+    _shellMode = true;
+    websocket.send(JSON.stringify({
+        type:   "shellmode",
+        active: true,
+        cwd:    _shellCwd,
+        prompt: _shellBuildPrompt()
+    }));
+}
+
+// ── Tab completion ──────────────────────────────────────────────────────────
+//
+//  Called by the frontend when Tab is pressed in shell mode.
+//  Returns {candidates: [...], partial: "..."} where:
+//    candidates — list of matching filenames/paths (dirs suffixed with /)
+//    partial    — the token that was being completed (for replacement)
+
+var _SHELL_CMDS = [
+    "bash","cat","cd","clear","cp","dir","echo","exit",
+    "help","ll","ls","mkdir","mv","pwd","quit","rm","touch","whoami"
+];
+
+function _shellComplete(line) {
+    var endsSpace = line.length > 0 &&
+                    (line.charAt(line.length - 1) === " " ||
+                     line.charAt(line.length - 1) === "\t");
+    var tokens  = _shellTokenize(line);
+    var partial = endsSpace ? "" : (tokens.length > 0 ? tokens[tokens.length - 1] : "");
+    var isCmd   = tokens.length === 0 || (tokens.length === 1 && !endsSpace);
+
+    // Command name completion for the first token
+    if (isCmd) {
+        var cmdCandidates = [];
+        for (var c = 0; c < _SHELL_CMDS.length; c++) {
+            if (_SHELL_CMDS[c].indexOf(partial) === 0) cmdCandidates.push(_SHELL_CMDS[c]);
+        }
+        return {candidates: cmdCandidates, partial: partial};
+    }
+
+    // Path completion for arguments
+    var lastSlash = partial.lastIndexOf("/");
+    var dirPart  = lastSlash !== -1 ? partial.substring(0, lastSlash + 1) : "";
+    var filePart = lastSlash !== -1 ? partial.substring(lastSlash + 1)    : partial;
+    var listDir  = dirPart === "" ? _shellCwd : _shellResolvePath(dirPart);
+    if (!_shellStrEndsWith(listDir, "/")) listDir = listDir + "/";
+
+    try {
+        if (!filelib.isDir(listDir)) return {candidates: [], partial: partial};
+        var entries = filelib.readdir(listDir);
+        var pathCandidates = [];
+        for (var p = 0; p < entries.length; p++) {
+            var e = entries[p];
+            if (e.Filename.indexOf(filePart) === 0) {
+                pathCandidates.push(dirPart + e.Filename + (e.IsDir ? "/" : ""));
+            }
+        }
+        return {candidates: pathCandidates, partial: partial};
+    } catch(err) {
+        return {candidates: [], partial: partial};
+    }
+}
+
 // ── Session ready ──────────────────────────────────────────────────────────
 
 websocket.send(JSON.stringify({
@@ -111,8 +496,53 @@ while (_sessionRunning && !websocket.isClosed()) {
         continue;
     }
 
+    // Tab-completion request (shell mode only)
+    if (req.type === "complete" && _shellMode) {
+        var _cr = _shellComplete(req.line || "");
+        websocket.send(JSON.stringify({
+            type:       "complete",
+            candidates: _cr.candidates,
+            partial:    _cr.partial
+        }));
+        continue;
+    }
+
     if (req.type !== "exec" || typeof req.code !== "string") continue;
 
+    // ── Shell mode ─────────────────────────────────────────────────────────
+    if (_shellMode) {
+        var _sr;
+        try {
+            _sr = _shellDispatch(req.code);
+        } catch(e) {
+            _sr = {output: "shell: " + String(e), error: true};
+        }
+
+        if (_sr.shellClear) {
+            websocket.send(JSON.stringify({type: "clear"}));
+            websocket.send(JSON.stringify({
+                type: "result", output: "", error: false,
+                logs: [], shellPrompt: _shellBuildPrompt()
+            }));
+            continue;
+        }
+
+        if (_sr.shellExit) {
+            websocket.send(JSON.stringify({type: "shellmode", active: false}));
+        }
+
+        websocket.send(JSON.stringify({
+            type:        "result",
+            output:      _sr.output,
+            error:       _sr.error,
+            logs:        [],
+            shellPrompt: _shellBuildPrompt()
+        }));
+        continue;
+    }
+
+    // ── Normal REPL mode ───────────────────────────────────────────────────
+
     // Reset per-invocation state
     _logs         = [];
     _capturedResp = "";

+ 131 - 16
src/web/Terminal/index.html

@@ -216,6 +216,9 @@
         var pendingCode  = null;
         var sessionReady = false;
         var pendingFiles = [];
+        var shellMode    = false;
+        var shellPrompt  = ">";
+        var _tabLastLine = null; // tracks input at last Tab press (for double-Tab list)
 
         // ── WebSocket ────────────────────────────────────────────────────────
         function wsEndpoint() {
@@ -263,16 +266,55 @@
                 focusInput();
                 return;
             }
+            if (msg.type === "shellmode") {
+                shellMode = msg.active;
+                if (msg.active) {
+                    shellPrompt = msg.prompt || "$ ";
+                    document.querySelector(".term-prompt").textContent = shellPrompt;
+                    document.getElementById("termInput").placeholder = "shell command…";
+                    line("sys", "# Entered shell mode — virtual filesystem.  Type ‘exit’ to return to the AGI REPL.");
+                    line("sys", "# cwd: " + (msg.cwd || "user:/"));
+                } else {
+                    shellPrompt = ">";
+                    document.querySelector(".term-prompt").textContent = ">";
+                    document.getElementById("termInput").placeholder = "JavaScript / AGI…";
+                    line("sys", "# Returned to AGI REPL.");
+                }
+                return;
+            }
+            if (msg.type === "clear") {
+                clearOutput();
+                return;
+            }
+            if (msg.type === "complete") {
+                handleCompletion(msg.candidates || [], msg.partial || "");
+                return;
+            }
             if (msg.type === "result") {
+                // Update shell prompt after every shell command (cd changes it)
+                if (msg.shellPrompt) {
+                    shellPrompt = msg.shellPrompt;
+                    document.querySelector(".term-prompt").textContent = shellPrompt;
+                }
                 if (msg.logs && msg.logs.length) msg.logs.forEach(function(l) { line("log", "· " + l); });
-                if (msg.error) {
-                    line("err", "✕ " + msg.output);
-                } else if (msg.output !== "" && msg.output !== undefined) {
-                    String(msg.output).split("\n").forEach(function(l, i) {
-                        line("ok", (i === 0 ? "← " : "  ") + l);
-                    });
+                if (shellMode) {
+                    // Shell mode: raw output, no ← prefix
+                    if (msg.error) {
+                        if (msg.output) String(msg.output).split("\n").forEach(function(l) { line("err", l); });
+                    } else if (msg.output) {
+                        String(msg.output).split("\n").forEach(function(l) { line("ok", l); });
+                    }
                 } else {
-                    line("dim", "← undefined");
+                    // REPL mode: prefix with ← / ✕
+                    if (msg.error) {
+                        line("err", "✕ " + msg.output);
+                    } else if (msg.output !== "" && msg.output !== undefined) {
+                        String(msg.output).split("\n").forEach(function(l, i) {
+                            line("ok", (i === 0 ? "← " : "  ") + l);
+                        });
+                    } else {
+                        line("dim", "← undefined");
+                    }
                 }
             }
         }
@@ -282,10 +324,80 @@
             wsSend({ type: "exec", code: code });
         }
 
+        // ── Tab completion ────────────────────────────────────────────────────
+        function handleCompletion(candidates, partial) {
+            if (candidates.length === 0) return; // no match — do nothing
+
+            // Find the longest common prefix among all candidates
+            var common = candidates[0];
+            for (var i = 1; i < candidates.length; i++) {
+                while (candidates[i].indexOf(common) !== 0) {
+                    common = common.substring(0, common.length - 1);
+                    if (common === "") break;
+                }
+            }
+
+            // Replace the partial token at the end of the input with the common prefix.
+            // Filenames that contain spaces are wrapped in double quotes so the
+            // shell tokenizer treats them as a single argument.
+            var cur = inp().value;
+            var needsQuoting = common.indexOf(" ") !== -1;
+            if (needsQuoting) { common = '"' + common + '"'; }
+
+            if (partial !== "") {
+                var idx = cur.lastIndexOf(partial);
+                if (idx !== -1) {
+                    // If the user already typed an opening quote for this partial,
+                    // consume it so we don't end up with doubled quotes.
+                    var start = idx;
+                    if (needsQuoting && start > 0 &&
+                            (cur.charAt(start - 1) === '"' || cur.charAt(start - 1) === "'")) {
+                        start = start - 1;
+                    }
+                    inp().value = cur.substring(0, start) + common;
+                }
+            } else {
+                // Completing a brand-new argument (input ends with space)
+                inp().value = cur + common;
+            }
+            caretEnd();
+
+            // Multiple candidates: show the list below the current line.
+            // On the second Tab (same input as last time) always show the list;
+            // on the first Tab only show it when the common prefix didn't extend.
+            var inputAfter = inp().value;
+            if (candidates.length > 1) {
+                var extended = (inputAfter !== cur);
+                if (!extended || _tabLastLine === cur) {
+                    // Format in columns — up to 4 per row, padded to 20 chars
+                    var cols = 4;
+                    var colW = 20;
+                    var rows = [];
+                    for (var r = 0; r < candidates.length; r += cols) {
+                        var row = "";
+                        for (var c = 0; c < cols && r + c < candidates.length; c++) {
+                            var cell = candidates[r + c];
+                            while (cell.length < colW) cell += " ";
+                            row += cell;
+                        }
+                        rows.push(row.replace(/\s+$/, ""));
+                    }
+                    rows.forEach(function(r) { line("log", r); });
+                }
+            }
+            _tabLastLine = inputAfter;
+        }
+
         // ── Input ────────────────────────────────────────────────────────────
         function handleKey(e) {
             if (e.key === "Enter") {
                 submitInput();
+            } else if (e.key === "Tab") {
+                e.preventDefault();
+                if (shellMode) {
+                    wsSend({ type: "complete", line: inp().value });
+                }
+                return; // skip _tabLastLine reset below
             } else if (e.key === "ArrowUp") {
                 e.preventDefault();
                 if (histIdx < cmdHistory.length - 1) { histIdx++; inp().value = cmdHistory[histIdx]; caretEnd(); }
@@ -296,6 +408,7 @@
             } else if (e.key === "Escape") {
                 cancelPending();
             }
+            _tabLastLine = null; // any non-Tab key resets double-Tab tracking
         }
 
         function submitInput() {
@@ -316,15 +429,17 @@
                 if (cmdHistory.length > 500) cmdHistory.pop();
             }
             if (pendingCode !== null) cancelPending();
-            line("in", "> " + raw);
-
-            // Client-side dot commands
-            var cmd = raw.trim();
-            if      (cmd === ".help")    { showHelp();    return; }
-            else if (cmd === ".clear")   { clearOutput(); return; }
-            else if (cmd === ".reset")   { reconnect();   return; }
-            else if (cmd === ".history") { showHistory(); return; }
-            else if (cmd === ".docs")    { toggleDocs();  return; }
+            line("in", (shellMode ? shellPrompt : "> ") + raw);
+
+            // Client-side dot commands (REPL mode only)
+            if (!shellMode) {
+                var cmd = raw.trim();
+                if      (cmd === ".help")    { showHelp();    return; }
+                else if (cmd === ".clear")   { clearOutput(); return; }
+                else if (cmd === ".reset")   { reconnect();   return; }
+                else if (cmd === ".history") { showHistory(); return; }
+                else if (cmd === ".docs")    { toggleDocs();  return; }
+            }
 
             execCode(raw);
         }