commit 31ad98214a9b6cd31dc0a448b556b8f1d3eada52
parent 6a58c5e43db70d5574bed25a50f4f20a1659ee3a
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 08:39:20 +0000
feat(web): interactive shell on the about page
The trailing prompt is now a real input backed by the same read-only
virtual fs as the ssh shell, served as json from GET /api/fs. Supports
ls, cd, pwd, cat, echo, whoami, help, clear, exit and the sudo joke,
with tab completion, up/down history, ctrl+l, ctrl+u, ctrl+w and ctrl+c.
Typing with nothing focused goes to the prompt; tab and help shortcuts
keep working. exit closes the session with a way to reconnect.
Diffstat:
8 files changed, 639 insertions(+), 7 deletions(-)
diff --git a/cmd/server/handlers/fs.go b/cmd/server/handlers/fs.go
@@ -0,0 +1,48 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/vfs"
+)
+
+// fsResponse is the read-only virtual filesystem behind the web shell on the
+// about page: the same tree the SSH shell uses, for the anonymous user.
+type fsResponse struct {
+ User string `json:"user"`
+ Home string `json:"home"`
+ Root *vfs.Node `json:"root"`
+}
+
+const fsCacheMaxAgeSeconds = 300
+
+// FSHandler serves GET /api/fs. Everything in it is generated from
+// config/content; nothing is read from the server's filesystem.
+func (h *Handler) FSHandler(w http.ResponseWriter, r *http.Request) {
+ projects, err := content.LoadProjects(h.gitService)
+ if err != nil {
+ http.Error(w, "Failed to load projects", http.StatusInternalServerError)
+ return
+ }
+ exp := config.GetExperience()
+ user := vfs.FallbackUser
+ root := vfs.Build(vfs.Source{
+ User: user,
+ About: config.GetAbout(),
+ CurrentJob: config.CurrentJob(exp),
+ Experience: exp,
+ Projects: projects,
+ Now: time.Now(),
+ })
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(fsCacheMaxAgeSeconds))
+ if err := json.NewEncoder(w).Encode(fsResponse{User: user, Home: vfs.HomeDir(user), Root: root}); err != nil {
+ http.Error(w, "Failed to encode fs", http.StatusInternalServerError)
+ }
+}
diff --git a/cmd/server/handlers/fs_test.go b/cmd/server/handlers/fs_test.go
@@ -0,0 +1,33 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/vfs"
+)
+
+func TestFSHandlerServesVisitorTree(t *testing.T) {
+ rec := httptest.NewRecorder()
+ (&Handler{}).FSHandler(rec, httptest.NewRequest(http.MethodGet, "/api/fs", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var got fsResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.User != vfs.FallbackUser || got.Home != "/home/visitor" {
+ t.Fatalf("user/home: %q %q", got.User, got.Home)
+ }
+ for _, p := range []string{"/home/visitor/bio", "/home/visitor/projects", "/tmp"} {
+ if got.Root.Lookup(p) == nil {
+ t.Errorf("missing %s", p)
+ }
+ }
+ if got.Root.Lookup("/etc") != nil || got.Root.Lookup("/srv") != nil {
+ t.Error("host paths leaked into the virtual fs")
+ }
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
@@ -56,6 +56,7 @@ func run() error {
r.Get("/sitemap.xml", h.SitemapHandler)
r.Get("/api/projects/{id}", h.ProjectDetailHandler)
+ r.Get("/api/fs", h.FSHandler)
if config.WakaTimeEnabled {
r.Get("/api/wakatime", h.WakaTimeHandler)
}
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -12,6 +12,7 @@ import (
"git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/external"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "git.prime8.dev/moncheeta/prime8dev/internal/vfs"
)
//go:embed templates/*.gohtml templates/partials/*.gohtml styles/*.css
@@ -59,6 +60,7 @@ func New() (*Renderer, error) {
"tagClass": utils.TagClass,
"tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
"shellPath": ShellPath,
+ "shellUser": func() string { return vfs.FallbackUser },
"tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
"inc": func(i int) int { return i + 1 },
"techMeta": utils.TechMeta,
diff --git a/internal/web/scripts/app.js b/internal/web/scripts/app.js
@@ -6,6 +6,7 @@ import { initLayout } from "./modules/layout.js";
import { initLightbox } from "./modules/lightbox.js";
import { initProjects } from "./modules/projects.js";
import { revealLines } from "./modules/reveal.js";
+import { initShell } from "./modules/shell.js";
function initApp() {
initHeader();
@@ -15,6 +16,7 @@ function initApp() {
const initDynamicContent = () => {
initAbout();
+ initShell();
initProjects();
initExperience();
updateAppTitle();
diff --git a/internal/web/scripts/modules/shell.js b/internal/web/scripts/modules/shell.js
@@ -0,0 +1,456 @@
+// Interactive prompt at the bottom of the about page. Mirrors the SSH shell
+// (internal/tui/shell.go) against the same read-only virtual fs, which the
+// server generates from site content and serves at /api/fs.
+
+import { isEditableTarget } from "./dom.js";
+
+const COMMANDS = ["cat", "cd", "clear", "echo", "exit", "help", "ls", "pwd", "whoami"];
+const MAX_HISTORY = 200;
+const HELP_TEXT = `commands:
+ ls [path] list a directory
+ cd [path] change directory (~ is home)
+ pwd print working directory
+ cat <file> print a file
+ clear clear the screen
+ help show this
+ exit close the session
+
+try: cat about, ls projects, cat projects/janitor/README.md`;
+
+let fsPromise = null;
+const shellHandlers = { keydown: null };
+
+function loadFS() {
+ if (!fsPromise) {
+ fsPromise = fetch("/api/fs", { credentials: "same-origin" })
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
+ .catch((err) => {
+ fsPromise = null;
+ throw err;
+ });
+ }
+ return fsPromise;
+}
+
+// --- paths (same rules as internal/vfs) ---
+
+function cleanPath(p) {
+ const out = [];
+ for (const part of p.split("/")) {
+ if (part === "" || part === ".") continue;
+ if (part === "..") out.pop();
+ else out.push(part);
+ }
+ return "/" + out.join("/");
+}
+
+function resolvePath(home, cwd, p) {
+ if (p === "" || p === "~") return home;
+ if (p.startsWith("~/")) p = home + p.slice(1);
+ else if (!p.startsWith("/")) p = cwd + "/" + p;
+ return cleanPath(p);
+}
+
+function displayPath(home, abs) {
+ if (abs === home) return "~";
+ if (abs.startsWith(home + "/")) return "~" + abs.slice(home.length);
+ return abs;
+}
+
+function lookup(root, abs) {
+ let n = root;
+ for (const part of abs.split("/")) {
+ if (part === "") continue;
+ if (!n.dir) return null;
+ const c = n.children?.[part];
+ if (!c) return null;
+ n = c;
+ }
+ return n;
+}
+
+function sortedChildren(n) {
+ return Object.values(n.children || {}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+}
+
+function commonPrefix(words) {
+ if (words.length === 0) return "";
+ const sorted = [...words].sort();
+ const first = sorted[0];
+ const last = sorted[sorted.length - 1];
+ let i = 0;
+ while (i < first.length && i < last.length && first[i] === last[i]) i++;
+ return first.slice(0, i);
+}
+
+// --- dom helpers ---
+
+function span(cls, text) {
+ const el = document.createElement("span");
+ el.className = cls;
+ el.textContent = text;
+ return el;
+}
+
+function promptNodes(user, path, input) {
+ return [
+ span("shell-user", user),
+ document.createTextNode("@"),
+ span("shell-host", "prime8"),
+ document.createTextNode(":"),
+ span("shell-path", path),
+ span("shell-dollar", "$"),
+ document.createTextNode(" "),
+ span("shell-cmd", input),
+ ];
+}
+
+export function initShell() {
+ const section = document.getElementById("shell");
+ if (!section || section.dataset.bound === "true") return;
+ section.dataset.bound = "true";
+
+ const layout = section.closest(".about-layout");
+ const historyEl = section.querySelector(".shell-history");
+ const live = section.querySelector(".shell-live");
+ const input = section.querySelector(".shell-input");
+ const pathEl = live?.querySelector(".shell-path");
+ const beforeEl = live?.querySelector("[data-before]");
+ const afterEl = live?.querySelector("[data-after]");
+ const cursorEl = live?.querySelector(".shell-cursor");
+ if (!historyEl || !live || !input || !pathEl || !beforeEl || !afterEl || !cursorEl) return;
+
+ let user = section.dataset.user || "visitor";
+ let home = "/home/" + user;
+ let cwd = home;
+ const recall = [];
+ let recallIndex = 0;
+ let draft = "";
+
+ function scrollToPrompt() {
+ live.scrollIntoView({ block: "nearest" });
+ }
+
+ function render() {
+ pathEl.textContent = displayPath(home, cwd);
+ const value = input.value;
+ const caret = input.selectionStart ?? value.length;
+ beforeEl.textContent = value.slice(0, caret);
+ cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0";
+ afterEl.textContent = value.slice(caret + 1);
+ }
+
+ function setInput(value) {
+ input.value = value;
+ input.setSelectionRange(value.length, value.length);
+ render();
+ }
+
+ function appendLine(nodes, cls) {
+ const line = document.createElement("div");
+ line.className = cls;
+ if (typeof nodes === "string") line.textContent = nodes;
+ else line.append(...nodes);
+ historyEl.appendChild(line);
+ while (historyEl.childElementCount > MAX_HISTORY * 2) historyEl.firstElementChild?.remove();
+ }
+
+ function clearScreen() {
+ historyEl.replaceChildren();
+ layout?.classList.add("shell-cleared");
+ }
+
+ function out(text, err = false) {
+ if (text === "") return;
+ appendLine(text, err ? "shell-out shell-err" : "shell-out");
+ }
+
+ function lsNodes(n) {
+ if (!n.dir) return [document.createTextNode(n.name)];
+ const nodes = [];
+ sortedChildren(n).forEach((c, i) => {
+ if (i) nodes.push(document.createTextNode(" "));
+ nodes.push(c.dir ? span("shell-dir", c.name + "/") : document.createTextNode(c.name));
+ });
+ return nodes;
+ }
+
+ function exec(root, line) {
+ const fields = line.trim().split(/\s+/).filter(Boolean);
+ if (fields.length === 0) return;
+ const [cmd, ...args] = fields;
+
+ switch (cmd) {
+ case "exit":
+ case "logout":
+ disconnect();
+ return;
+ case "clear":
+ clearScreen();
+ return;
+ case "help":
+ out(HELP_TEXT);
+ return;
+ case "pwd":
+ out(cwd);
+ return;
+ case "whoami":
+ out(user);
+ return;
+ case "echo":
+ out(args.join(" "));
+ return;
+ case "cd": {
+ if (args.length > 1) return out("cd: too many arguments", true);
+ const target = args[0] || "";
+ const abs = resolvePath(home, cwd, target);
+ const n = lookup(root, abs);
+ if (!n) return out(`cd: ${target}: No such file or directory`, true);
+ if (!n.dir) return out(`cd: ${target}: Not a directory`, true);
+ cwd = abs;
+ return;
+ }
+ case "ls": {
+ let paths = args.filter((a) => !a.startsWith("-"));
+ if (paths.length === 0) paths = ["."];
+ for (const p of paths) {
+ const n = lookup(root, resolvePath(home, cwd, p));
+ if (!n) {
+ out(`ls: cannot access '${p}': No such file or directory`, true);
+ continue;
+ }
+ if (paths.length > 1 && n.dir) out(p + ":");
+ const nodes = lsNodes(n);
+ if (nodes.length) appendLine(nodes, "shell-out");
+ }
+ return;
+ }
+ case "cat": {
+ if (args.length === 0) return out("cat: missing operand", true);
+ for (const p of args) {
+ const n = lookup(root, resolvePath(home, cwd, p));
+ if (!n) out(`cat: ${p}: No such file or directory`, true);
+ else if (n.dir) out(`cat: ${p}: Is a directory`, true);
+ else out(n.content || "");
+ }
+ return;
+ }
+ case "sudo":
+ out(`${user} is not in the sudoers file. This incident will be reported.`, true);
+ return;
+ default:
+ out(`${cmd}: command not found`, true);
+ }
+ }
+
+ async function run() {
+ const line = input.value;
+ const promptCwd = cwd;
+ setInput("");
+ if (line.trim() && recall[recall.length - 1] !== line) {
+ recall.push(line);
+ if (recall.length > MAX_HISTORY) recall.shift();
+ }
+ recallIndex = recall.length;
+ draft = "";
+
+ const first = line.trim().split(/\s+/)[0] || "";
+ // like the TUI, clear wipes the screen without echoing itself
+ if (first !== "clear") appendLine(promptNodes(user, displayPath(home, promptCwd), line), "shell-prompt");
+ if (!line.trim()) return scrollToPrompt();
+
+ let fs;
+ try {
+ fs = await loadFS();
+ } catch {
+ out("prime8: filesystem unavailable, try again in a bit", true);
+ return scrollToPrompt();
+ }
+ if (fs.user && fs.user !== user) {
+ user = fs.user;
+ home = fs.home;
+ cwd = home;
+ }
+ exec(fs.root, line);
+ render();
+ scrollToPrompt();
+ }
+
+ async function complete() {
+ const value = input.value;
+ if (!value) return;
+ const idx = Math.max(value.lastIndexOf(" "), value.lastIndexOf("\t"));
+ if (idx < 0) {
+ const matches = COMMANDS.filter((c) => c.startsWith(value));
+ if (matches.length === 1) setInput(matches[0] + " ");
+ else {
+ const p = commonPrefix(matches);
+ if (p.length > value.length) setInput(p);
+ }
+ return;
+ }
+ let fs;
+ try {
+ fs = await loadFS();
+ } catch {
+ return;
+ }
+ const word = value.slice(idx + 1);
+ const slash = word.lastIndexOf("/");
+ const dirPart = slash >= 0 ? word.slice(0, slash + 1) : "";
+ const base = slash >= 0 ? word.slice(slash + 1) : word;
+ const dir = lookup(fs.root, resolvePath(home, cwd, dirPart || "."));
+ if (!dir || !dir.dir) return;
+ const nodes = sortedChildren(dir).filter((c) => c.name.startsWith(base));
+ if (nodes.length === 0) return;
+ let completed = commonPrefix(nodes.map((c) => c.name));
+ if (nodes.length === 1) completed += nodes[0].dir ? "/" : " ";
+ if (input.value === value) setInput(value.slice(0, idx + 1) + dirPart + completed);
+ }
+
+ function disconnect() {
+ historyEl.replaceChildren();
+ layout?.classList.add("shell-cleared");
+ appendLine("logout", "shell-out");
+ appendLine("Connection to prime8.dev closed.", "shell-out shell-dim");
+ const again = document.createElement("button");
+ again.type = "button";
+ again.className = "shell-reconnect";
+ again.textContent = "[ reconnect ]";
+ again.addEventListener("click", reconnect, { once: true });
+ const row = document.createElement("div");
+ row.className = "shell-out";
+ row.appendChild(again);
+ historyEl.appendChild(row);
+ live.hidden = true;
+ input.blur();
+ again.focus({ preventScroll: true });
+ }
+
+ function reconnect() {
+ historyEl.replaceChildren();
+ layout?.classList.remove("shell-cleared");
+ live.hidden = false;
+ cwd = home;
+ setInput("");
+ input.focus({ preventScroll: true });
+ scrollToPrompt();
+ }
+
+ function deleteWord() {
+ const caret = input.selectionStart ?? input.value.length;
+ const head = input.value.slice(0, caret).replace(/\S+\s*$/, "");
+ const tail = input.value.slice(caret);
+ input.value = head + tail;
+ input.setSelectionRange(head.length, head.length);
+ render();
+ }
+
+ input.addEventListener("keydown", (e) => {
+ if (e.isComposing) return;
+ const ctrl = e.ctrlKey && !e.metaKey && !e.altKey;
+ if (ctrl) {
+ switch (e.key.toLowerCase()) {
+ case "l":
+ e.preventDefault();
+ clearScreen();
+ return;
+ case "u": {
+ e.preventDefault();
+ const caret = input.selectionStart ?? 0;
+ input.value = input.value.slice(caret);
+ input.setSelectionRange(0, 0);
+ render();
+ return;
+ }
+ case "w":
+ e.preventDefault();
+ deleteWord();
+ return;
+ case "c":
+ if (input.selectionStart !== input.selectionEnd) return; // let copy work
+ e.preventDefault();
+ appendLine(promptNodes(user, displayPath(home, cwd), input.value + "^C"), "shell-prompt");
+ setInput("");
+ recallIndex = recall.length;
+ scrollToPrompt();
+ return;
+ case "d":
+ e.preventDefault();
+ if (input.value === "") disconnect();
+ return;
+ default:
+ return;
+ }
+ }
+
+ switch (e.key) {
+ case "Enter":
+ e.preventDefault();
+ run();
+ break;
+ case "Tab":
+ // keep the tab-cycling shortcut out of the prompt
+ e.preventDefault();
+ e.stopPropagation();
+ complete();
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ e.stopPropagation();
+ if (recallIndex === recall.length) draft = input.value;
+ if (recallIndex > 0) setInput(recall[--recallIndex]);
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ e.stopPropagation();
+ if (recallIndex < recall.length) {
+ recallIndex++;
+ setInput(recallIndex === recall.length ? draft : recall[recallIndex]);
+ }
+ break;
+ case "Escape":
+ if (input.value) setInput("");
+ else input.blur();
+ break;
+ default:
+ break;
+ }
+ });
+
+ input.addEventListener("input", render);
+ input.addEventListener("select", render);
+ document.addEventListener("selectionchange", () => {
+ if (document.activeElement === input) render();
+ });
+ input.addEventListener("focus", () => {
+ section.classList.add("focused");
+ render();
+ loadFS().catch(() => {});
+ });
+ input.addEventListener("blur", () => section.classList.remove("focused"));
+
+ // Typing with nothing focused goes to the prompt, but the tab/help
+ // shortcuts (digits, ?, space, arrows, tab) keep working, like the TUI.
+ if (shellHandlers.keydown) document.removeEventListener("keydown", shellHandlers.keydown);
+ shellHandlers.keydown = (e) => {
+ if (!section.isConnected) {
+ document.removeEventListener("keydown", shellHandlers.keydown);
+ shellHandlers.keydown = null;
+ return;
+ }
+ if (live.hidden || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
+ if (isEditableTarget(e.target) || window.isLightboxOpen?.()) return;
+ const active = document.activeElement;
+ if (active && active !== document.body && active !== document.documentElement) return;
+ if (document.getElementById("help-overlay")?.classList.contains("visible")) return;
+ if (e.key.length !== 1 || /[0-9? ]/.test(e.key)) return;
+ e.preventDefault();
+ input.focus({ preventScroll: true });
+ setInput(input.value + e.key);
+ scrollToPrompt();
+ };
+ document.addEventListener("keydown", shellHandlers.keydown);
+
+ render();
+}
diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css
@@ -270,14 +270,103 @@
}
}
+/* interactive prompt (modules/shell.js) */
+.shell {
+ cursor: text;
+}
+
+.shell-live {
+ position: relative;
+ display: block;
+ cursor: text;
+}
+
+.shell-live[hidden] {
+ display: none;
+}
+
+/* the real input stays invisible; the spans around .shell-cursor draw it */
+.shell-input {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 1px;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ opacity: 0;
+ font-size: 16px; /* keeps iOS from zooming in on focus */
+ caret-color: transparent;
+ pointer-events: none;
+}
+
+.shell-line {
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.shell-history {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.shell-history:empty {
+ display: none;
+}
+
+.shell-out {
+ color: var(--fg);
+ font-size: var(--fs-14);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ margin-bottom: 3px;
+}
+
+.shell-err {
+ color: var(--red);
+}
+
+.shell-dim {
+ color: var(--fg-dim);
+}
+
+.shell-dir {
+ color: var(--blue);
+ font-weight: bold;
+}
+
+.shell-reconnect {
+ font: inherit;
+ color: var(--accent);
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+}
+
+.shell-reconnect:hover,
+.shell-reconnect:focus-visible {
+ color: var(--fg);
+ outline: none;
+ text-decoration: underline;
+}
+
+/* clear / ctrl+l hides the intro, like the TUI */
+.about-layout.shell-cleared > .section:not(.shell) {
+ display: none;
+}
+
/* trailing prompt */
.shell-cursor {
/* inline nbsp with a background = exactly one monospace cell, sits on the text baseline */
display: inline;
background: var(--fg);
+ color: var(--bg);
animation: cursorBlink 1.1s steps(1) infinite;
}
-@keyframes cursorBlink { 50% { opacity: 0; } }
+@keyframes cursorBlink { 50% { background: transparent; color: inherit; } }
@media (prefers-reduced-motion: reduce) {
.shell-cursor { animation: none !important; }
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -22,12 +22,12 @@
</div>
<div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat bio</span></div>
+ <div class="shell-prompt"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">cat bio</span></div>
<p class="bio">{{.About.Bio}}</p>
</div>
<div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls interests/</span></div>
+ <div class="shell-prompt"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls interests/</span></div>
<div class="tags">
{{range .About.Interests}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
</div>
@@ -35,20 +35,21 @@
{{if .About.Hobbies}}
<div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls hobbies/</span></div>
+ <div class="shell-prompt"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">ls hobbies/</span></div>
<div class="tags">
{{range .About.Hobbies}}{{if eq (tagClass .) "hobby-audi"}}<span class="i5-host tag {{tagClass .}}" title="1-2-4-5-3" tabindex="0"><span class="i5-label">{{.}}</span><span class="i5" aria-hidden="true"><i style="--fire:0"></i><i style="--fire:1"></i><i style="--fire:4"></i><i style="--fire:2"></i><i style="--fire:3"></i></span></span>{{else}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}{{end}}
</div>
</div>
{{end}}
- <div class="section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cursor" aria-hidden="true"> </span></div>
+ <div class="section shell" id="shell" data-user="{{shellUser}}">
+ <div class="shell-history" aria-live="polite"></div>
+ <label class="shell-prompt shell-live"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-line"><span class="shell-cmd" data-before></span><span class="shell-cursor" aria-hidden="true"> </span><span class="shell-cmd" data-after></span></span><input class="shell-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" aria-label="shell: type help for commands"></label>
</div>
{{if wakaTimeWeeklyEnabled}}
<div class="section" id="activity-section">
- <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
+ <div class="shell-prompt"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
<div class="weekly-stats" id="activity-stats">
<div class="loading-text">loading stats...</div>
</div>