prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
Acmd/server/handlers/fs.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/server/handlers/fs_test.go | 33+++++++++++++++++++++++++++++++++
Mcmd/server/main.go | 1+
Minternal/web/renderer.go | 2++
Minternal/web/scripts/app.js | 2++
Ainternal/web/scripts/modules/shell.js | 456+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/styles/about.css | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/web/templates/partials/about.gohtml | 13+++++++------
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">&nbsp;</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">&nbsp;</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>