to switch (downloads once)
/clear clear the screen and forget the conversation
/exit back to the shell (also Ctrl+C or Ctrl+D)
Shift+Tab cycles modes: default, accept edits, plan
Anything else is sent to the model. It remembers the last few turns.`
fn chatWelcome() {
`✻ Welcome to claude (home edition)
` + llmName() + ` running locally in your browser. No backend, nothing leaves this tab.
/help for commands · shift+tab to cycle modes · /exit or Ctrl+C to leave` + "\n\n"
}
// chatStatus is the footer under the input, one line per mode like the real CLI
fn chatStatus() {
match chatMode {
1 => `⏵⏵ accept edits on` + dim(" (shift+tab to cycle) · there are no edits, but it feels good")
2 => `⏸ plan mode on` + dim(" (shift+tab to cycle) · answers come as a plan")
_ => `⏵⏵ local model on` + dim(" (" + llmName() + " in your browser) · shift+tab to cycle · ctrl+c to leave")
}
}
// chatCycleMode handles Shift+Tab
fn chatCycleMode() {
chatMode = (chatMode + 1) % 3
render()
}
fn chatEcho(raw) {
`> ` + escapeHtml(raw) + `
` + "\n"
}
// The chat look (boxed input, status line) is body.claude in index.html
fn enterChat() {
mode = "claude"
chatConsent = false
doc.body.classList.add("claude")
}
fn leaveChat() {
mode = "shell"
chatConsent = false
chatOnYes = null
chatOnNo = null
doc.body.classList.remove("claude")
}
fn cmdClaude(c) {
let q = c.args.join(" ").trim()
enterChat()
terminalContent = "" // takes over the screen, like the real one
chatHistory = [] // and every session starts fresh
chatMode = 0
let op = llmBegin(_ => {
terminalContent += "^C\n"
leaveChat()
})
animBuffer = dim("starting...") + "\n"
render()
op.ensure(false, () => {
endAnim()
terminalContent += chatWelcome()
render()
if q != "" {
chatInput(q)
}
}, () => {
chatConsentFor(() => {
terminalContent += chatWelcome()
}, () => {
terminalContent += dim("Maybe later. Back to the shell.") + "\n"
leaveChat()
}, null)
})
""
}
// chatConsentFor asks before a download. On y the selected model is loaded
// and then loaded runs; anything else runs declined; failed runs if the
// download breaks (may be null).
fn chatConsentFor(loaded, declined, failed) {
chatConsent = true
terminalContent += "This downloads " + llmName() + " (" + llmSize() + ") once; it stays in this browser's cache. Continue? " + yellow("[y/N]") + "\n"
chatOnYes = () => {
let op = llmBegin(_ => {
terminalContent += "^C\n"
leaveChat()
})
op.onFail = failed
op.loadModel(() => {
endAnim()
loaded()
render()
})
}
chatOnNo = declined
}
fn chatInput(raw) {
raw = raw.trim()
if chatConsent {
terminalContent += chatEcho(raw)
chatConsent = false
let yes = chatOnYes
let no = chatOnNo
chatOnYes = null
chatOnNo = null
let l = raw.lower()
if l == "y" || l == "yes" {
yes()
} else {
no()
render()
}
return null
}
if raw == "" {
render()
return null
}
terminalContent += chatEcho(raw)
let l = raw.lower()
if l in ["/exit", "/quit", "/q", "exit", "quit"] {
terminalContent += dim("Goodbye!") + "\n"
leaveChat()
} else if l == "/clear" {
terminalContent = ""
chatHistory = []
} else if l in ["/help", "help", "?"] {
terminalContent += chatHelp + "\n\n"
} else if l in ["/model", "/models"] {
chatListModels()
return null
} else if l.startsWith("/model ") {
chatSwitchModel(raw.slice(7).trim())
return null
} else if raw.startsWith("/") {
terminalContent += dim("Unknown command " + escapeHtml(raw) + ". Try /help.") + "\n\n"
} else {
chatAsk(raw)
return null
}
render()
}
// chatClock is "3:04 PM", the time in the footer of an answer
fn chatClock() {
let t = timeFormat(now(), "hh:mm A")
if t.startsWith("0") { t.slice(1) } else { t }
}
// chatPast turns the verb into the footer's past tense: Baking -> Baked
fn chatPast(verb) {
let first = verb.split(" ")[0]
if first.endsWith("ing") { first = first.slice(0, -3) }
first + "ed"
}
fn chatAsk(q) {
if isInsult(q) {
prank("● ")
return null
}
if isContactQuestion(q) {
terminalContent += "● " + contactInfo + "\n\n"
chatRemember(q, "You can reach Miguel at contact@targa.me, github.com/migueltarga or linkedin.com/in/migueltarga.")
render()
return null
}
let verb = chatVerbs[rand(chatVerbs.length)]
let tip = chatTips[rand(chatTips.length)]
let start = clock()
let tokens = 0
let frame = 0
let partial = ""
let op = llmBegin(op => {
if op.partial != "" {
terminalContent += "● " + escapeHtml(op.partial.trim()) + "\n"
}
terminalContent += dim("Interrupted · Ctrl+C again to leave") + "\n\n"
chatRemember(q, op.partial)
})
let draw = () => {
let secs = itoa(toInt((clock() - start) / 1000))
let buf = ""
if partial != "" {
buf = "● " + escapeHtml(partial) + "\n\n"
}
buf += `` + chatSpinner[frame % chatSpinner.length] + ` ` + verb + "… "
+ dim("(" + secs + "s · ↓ " + itoa(tokens) + " tokens)") + "\n" + dim(" Tip: " + tip) + "\n"
animBuffer = buf
render()
}
every(120, () => {
frame += 1
draw()
})
let msgs = [{role: "system", content: askFacts}] + chatHistory
let ask = q
if chatMode == 2 {
ask += "\n\nAnswer as a short numbered plan, one step per line."
}
msgs.insert({role: "user", content: ask})
op.stream(msgs, p => {
if p != "" {
tokens += 1
}
partial = p
draw()
}, text => {
let secs = itoa(toInt((clock() - start) / 1000))
let past = chatPast(verb)
if text == "" {
text = "(no answer; try rephrasing)"
}
let footer = "✻ " + past + " for " + secs + "s · done " + chatClock()
if chatMode == 1 {
footer += " · 0 edits accepted"
}
terminalContent += "● " + escapeHtml(text) + "\n\n" + dim(footer) + "\n\n"
chatRemember(q, text)
endAnim()
})
}
// chatListModels prints the registry, marking what is downloaded and current
fn chatListModels() {
let op = llmBegin(_ => { terminalContent += "^C\n\n" })
op.withScript(() => {
op.cached(urls => {
endAnim()
let b = dim("Models (all run in your browser; /model to switch):") + "\n"
repeat m in llmModels {
let mark = " "
let note = ""
if m.id == llmCur.id {
mark = `▸ `
note = dim(" current")
} else if urls[m.url] == true {
note = dim(" downloaded")
}
b += mark + yellow(pad(m.id, 13)) + pad(m.name, 14) + dim(pad(m.size, 8)) + note + "\n"
}
terminalContent += b + "\n"
render()
})
})
}
// chatSwitchModel selects another model, downloading it after consent
fn chatSwitchModel(id) {
let m = findModel(id)
if m == null {
terminalContent += dim("Unknown model " + escapeHtml(id) + ". /model lists them.") + "\n\n"
render()
return null
}
if m.id == llmCur.id {
terminalContent += dim("Already using " + m.name + ".") + "\n\n"
render()
return null
}
let prev = llmCur
llmCur = m
let switched = () => {
chatHistory = []
terminalContent += dim("✻ Switched to " + m.name + ". The conversation starts over.") + "\n\n"
}
let op = llmBegin(_ => {
llmCur = prev
terminalContent += "^C\n\n"
})
op.onFail = () => { llmCur = prev }
animBuffer = dim("loading " + m.name + "...") + "\n"
render()
op.ensure(false, () => {
endAnim()
switched()
render()
}, () => {
chatConsentFor(switched, () => {
llmCur = prev
terminalContent += dim("Kept " + prev.name + ".") + "\n\n"
}, () => { llmCur = prev })
})
}
// isInsult spots people being rude about Miguel to the model
fn isInsult(q) {
q = " " + q.lower() + " "
let about = [
"miguel", "targa", " he ", "he's", " his ", " you", "this guy", "the owner", "this site", "this website", "the author",
" ele ", " dele", "esse cara", "o dono", "você", "voce", "esse site", "o autor",
].some(w => q.contains(w))
if !about { return false }
[
"stupid", "idiot", "dumb", "suck", "loser", "ugly", "hate", "terrible", "awful", "worst", "trash", "garbage", "lame", "incompetent",
"useless", "fraud", "noob", "moron", " bad", "overrated", "boring", "fake", "scam", "clown", "fail", "pathetic", "gay",
// pt-BR
"burro", "idiota", "otário", "otario", "babaca", "imbecil", "lixo", "horrível", "horrivel", "péssimo", "pessimo", "feio", "merda",
"bosta", "porra", "caralho", "viado", "bicha", "corno", "fdp", "filho da puta", "puta", "vagabundo", "incompetente", "inútil", "inutil",
"ridículo", "ridiculo", "nojento", "chato", "odeio", "trouxa", "mané", "cuzão", "cuzao", "arrombado", "escroto", "palhaço", "palhaco",
].some(w => q.contains(w))
}
// prank is what the rude get instead of an answer: a fake counter-hack with
// the screen going red, ending with them thrown out to a Google search on
// manners. prefix is "● " in chat, "" in the shell. The "locating" step is
// real-ish: Cloudflare's /cdn-cgi/trace (same origin, no backend) tells us
// the IP, country and nearest datacenter.
fn prank(prefix) {
let root = doc.documentElement.style
let before = toString(root.getPropertyValue("--fg"))
root.setProperty("--fg", "#ff4444")
let restore = () => { root.setProperty("--fg", before) }
terminalContent += prefix + "Wrong answer.\n"
beginAnim(() => {
terminalContent += "^C\n"
restore()
}, "ctrl-c")
render()
let started = false
let start = where => {
if started || !busy { return null }
started = true
sequence([
{delay: 500, text: `✻ Deploying countermeasures…` + "\n"},
{delay: 900, text: " ⎿ Locating you… " + where + "\n"},
{delay: 900, text: " ⎿ Scanning ports… " + dim("22/tcp open. Cute.") + "\n"},
{delay: 900, text: " ⎿ Reverse shell… " + dim("connected.") + "\n"},
{delay: 700, text: " ⎿ rm -rf ~/Documents … " + dim("done") + "\n"},
{delay: 500, text: " ⎿ rm -rf ~/Photos … " + dim("done") + "\n"},
{delay: 500, text: " ⎿ rm -rf ~/.ssh … " + dim("done (you didn't need those)") + "\n"},
{delay: 1000, text: " ⎿ Forwarding your browser history to your mother… " + dim("sent.") + "\n"},
{delay: 1200, text: prefix + "Come back when you've learned some manners.\n"},
{delay: 1200, text: "Connection to targa.me closed by remote host.\n"},
], true, () => {
inputLine.style.display = "none" // stays "closed" until the redirect
after(2500, () => {
js.location.href = "https://www.google.com/search?q=how+to+be+nice+to+people"
})
})
let interrupted = onCancel // Ctrl+C must not leave the screen red
onCancel = () => {
interrupted()
restore()
}
}
let fallback = _ => { start(dim("found (somewhere on Earth)")) }
after(2500, () => { fallback(null) })
then(js.fetch("/cdn-cgi/trace"), resp => {
then(resp.text(), t => {
let [ip, loc, colo] = traceFields(toString(t))
if ip == "" {
fallback(null)
return null
}
start(traceWhere(ip, loc, colo))
}, fallback)
}, fallback)
}
// traceFields picks [ip, loc, colo] out of a /cdn-cgi/trace response
fn traceFields(t) {
let ip = ""
let loc = ""
let colo = ""
repeat line in t.split("\n") {
let i = line.indexOf("=")
let k = if i < 0 { line } else { line.slice(0, i) }
let v = if i < 0 { "" } else { line.slice(i + 1) }
match k {
"ip" => { ip = v }
"loc" => { loc = v }
"colo" => { colo = v }
_ => null
}
}
[ip, loc, colo]
}
// traceWhere is the "Locating you…" text: the IP, the country and the city
// of the datacenter
fn traceWhere(ip, loc, colo) {
let where = yellow(escapeHtml(ip))
if loc != "" {
where += ", " + escapeHtml(loc)
}
let city = if colos.has(colo) { colos[colo] } else { null }
if city != null {
where += ", near " + city
} else if colo != "" {
where += ", near " + escapeHtml(colo)
}
where
}
// Cloudflare datacenters (IATA codes) visitors are likely to hit
let colos = {
MIA: "Miami", ATL: "Atlanta", DFW: "Dallas", ORD: "Chicago", IAD: "Washington", EWR: "Newark", JFK: "New York",
LAX: "Los Angeles", SJC: "San Jose", SEA: "Seattle", DEN: "Denver", PHX: "Phoenix", BOS: "Boston", YYZ: "Toronto", YVR: "Vancouver",
GRU: "São Paulo", GIG: "Rio de Janeiro", CNF: "Belo Horizonte", BSB: "Brasília", CWB: "Curitiba", POA: "Porto Alegre",
FOR: "Fortaleza", REC: "Recife", SSA: "Salvador", EZE: "Buenos Aires", SCL: "Santiago", LIM: "Lima", BOG: "Bogotá", MEX: "Mexico City",
LHR: "London", CDG: "Paris", AMS: "Amsterdam", FRA: "Frankfurt", MAD: "Madrid", LIS: "Lisbon", MXP: "Milan", DUB: "Dublin",
WAW: "Warsaw", STO: "Stockholm", ZRH: "Zurich", VIE: "Vienna", IST: "Istanbul", DXB: "Dubai", BOM: "Mumbai", DEL: "Delhi",
SIN: "Singapore", HKG: "Hong Kong", NRT: "Tokyo", ICN: "Seoul", SYD: "Sydney", MEL: "Melbourne", AKL: "Auckland",
JNB: "Johannesburg", CPT: "Cape Town",
}
// chatRemember keeps the last few turns so follow-up questions make sense
fn chatRemember(q, a) {
chatHistory.insert({role: "user", content: q})
chatHistory.insert({role: "assistant", content: a})
if chatHistory.length > 8 {
chatHistory = chatHistory.slice(chatHistory.length - 8)
}
}
// ── main.tg ─────────────────────────────────────────────────────────────
// main.tg — the shell: parsing (quotes, $VARS, pipes, && and ;), history,
// Tab completion, the keyboard and the boot. It comes last, after the files
// it uses.
let doc = js.document
let terminal = null
let inputLine = null
let inputField = null
let promptEl = null
let statusEl = null
let terminalContent = ""
let cwd = home
let history = []
let historyIndex = 0
let draft = ""
let lastStatus = 0
let mode = "shell" // shell, vim, closed, targascript or claude
let closedMessage = ""
let env = {
USER: "guest", HOME: home, SHELL: "/bin/tsh", TERM: "xterm-256color",
LANG: "en_US.UTF-8", PATH: "/usr/local/bin:/usr/bin:/bin:/usr/games",
EDITOR: "vim", HOSTNAME: "targa.me", PWD: home,
}
// ---------------------------------------------------------------------------
// The context of a command and the command table
// ---------------------------------------------------------------------------
struct Ctx {
args = [], stdin = "", piped = false, raw = ""
// arg is the i-th argument that isn't a flag, or ""
fn arg(i) {
this.args.filter(a => !(a.startsWith("-") && a.length > 1))[i] ?? ""
}
// flag reports whether a short flag, like l in -la, was given
fn flag(letter) {
this.args.some(a => a.startsWith("-") && !a.startsWith("--") && a.slice(1).contains(letter))
}
fn has(word) { word in this.args }
// input is what a command reads: the file named by argument i, or what
// came through the pipe. It returns [text, error].
fn input(cmd, i) {
let p = this.arg(i)
if p != "" {
let path = resolve(p)
let e = find(path)
if e == null { return ["", errf(cmd, p + ": No such file or directory")] }
if e.dir { return ["", errf(cmd, p + ": Is a directory")] }
if denied(path, e) { return ["", errf(cmd, p + ": Permission denied")] }
return [text(e), ""]
}
if this.piped { return [this.stdin, ""] }
["", errf(cmd, "missing file operand")]
}
}
// A command is {name, usage, desc, cat, hidden, run}, with cat one of core,
// files, text, system, network, fun or egg. Eggs are hidden: not in help,
// completion, man or /bin.
let commands = coreCommands + eggCommands
let commandIndex = commands.map(c => [c.name, c]).toObject()
let aliases = {
ll: "ls -l", la: "ls -a", please: "sudo", cls: "clear", dir: "ls", matrix: "cmatrix",
vi: "vim", nvim: "vim", more: "cat", less: "cat", printenv: "env", screenfetch: "neofetch",
gpt: "ai", chatgpt: "ai", "ai,": "ai", "ai:": "ai", ip: "ifconfig", ss: "netstat", htop: "top",
python3: "python", nodejs: "node", "apt-get": "apt", poweroff: "shutdown", halt: "shutdown",
logout: "exit", "?": "help", tg: "targascript", ts: "targascript", tgs: "targascript", calc: "bc", expr: "bc", hexdump: "xxd", hd: "xxd",
}
// ---------------------------------------------------------------------------
// Parsing: quotes, variables, pipes and separators
// ---------------------------------------------------------------------------
fn tokenize(s) {
let out = []
let cur = ""
let quote = ""
let inToken = false
repeat ch in s {
if quote != "" {
if ch == quote { quote = "" } else { cur += ch }
} else if ch == "\"" || ch == "'" {
quote = ch
inToken = true
} else if ch == " " || ch == "\t" {
if inToken {
out.insert(cur)
cur = ""
inToken = false
}
} else {
cur += ch
inToken = true
}
}
if inToken { out.insert(cur) }
out
}
fn isWordChar(c) {
c == "_" || c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c >= "0" && c <= "9"
}
fn expandVars(s) {
if !s.contains("$") { return s }
let chars = s.chars()
let out = ""
let i = 0
repeat i < chars.length {
if chars[i] != "$" || i == chars.length - 1 {
out += chars[i]
i += 1
continue
}
let j = i + 1
let braced = chars[j] == "{"
if braced { j += 1 }
let k = j
repeat k < chars.length && (isWordChar(chars[k]) || k == j && chars[k] == "?") {
k += 1
if chars[k - 1] == "?" { break }
}
let name = chars.slice(j, k).join("")
if braced && k < chars.length && chars[k] == "}" { k += 1 }
if name == "" {
out += "$"
i += 1
continue
}
out += lookupVar(name)
i = k
}
out
}
fn lookupVar(name) {
match name {
"?" => toString(lastStatus)
"RANDOM" => toString(rand(32768))
"PWD" => cwd
_ => env[name] ?? ""
}
}
// splitTop splits s at sep where it isn't inside quotes
fn splitTop(s, sep) {
let out = []
let quote = ""
let start = 0
let i = 0
repeat i < s.length {
let ch = s[i]
if quote != "" {
if ch == quote { quote = "" }
} else if ch == "\"" || ch == "'" {
quote = ch
} else if s.slice(i).startsWith(sep) {
out.insert(s.slice(start, i))
start = i + sep.length
i = start
continue
}
i += 1
}
out.insert(s.slice(start))
out
}
// ---------------------------------------------------------------------------
// Running commands
// ---------------------------------------------------------------------------
fn promptText() {
match mode {
"vim", "closed" => ""
"targascript" => ">>"
"claude" => ">"
_ => display(cwd) + if root { " #" } else { " $" }
}
}
// statusText is the Claude Code style line under the input, in the chat
fn statusText() {
if mode != "claude" { return "" }
chatStatus()
}
fn echoLine(raw) {
"" + promptText() + " " + escapeHtml(raw) + "\n"
}
// submit handles the Enter key
fn submit(typed) {
let raw = typed.trim()
match mode {
"closed" => {
mode = "shell"
terminalContent = banner
render()
return null
}
"vim" => {
vimInput(raw)
return null
}
"targascript" => {
tgReplInput(raw)
return null
}
"claude" => {
chatInput(raw)
return null
}
_ => null
}
terminalContent += echoLine(raw)
if raw == "" {
render()
return null
}
// History expansion
if raw == "!!" && history.length > 0 {
raw = history.last()
terminalContent += escapeHtml(raw) + "\n"
} else if raw.startsWith("!") && raw.length > 1 {
let n = try { toInt(raw.slice(1)) } catch e { 0 }
if n >= 1 && n <= history.length {
raw = history[n - 1]
terminalContent += escapeHtml(raw) + "\n"
}
}
history.insert(raw)
historyIndex = history.length
draft = ""
let out = execLine(raw)
if out != "" {
terminalContent += out + "\n"
}
render()
}
// execLine runs a whole command line: a && b; c | d
fn execLine(raw) {
if raw.contains(":(){") {
lastStatus = 1
return red("tsh: fork bomb detected. Nice try.")
}
let typed = raw.trim()
if typed.startsWith("targa{") && sha256Hex(typed) == flagHash {
return cmdFlagFound()
}
let out = []
repeat chunk in splitTop(raw, ";") {
repeat segment in splitTop(chunk, "&&") {
let seg = segment.trim()
if seg == "" { continue }
let r = execPipeline(seg)
if r != "" { out.insert(r) }
if lastStatus != 0 { break }
}
}
out.join("\n")
}
fn execPipeline(seg) {
if seg.contains(">") && !seg.contains("->") {
lastStatus = 1
return red("tsh: cannot create file: Read-only file system")
}
let out = ""
repeat i, stage in splitTop(seg, "|") {
let args = tokenize(stage).map(expandVars)
if args.length == 0 {
lastStatus = 2
return red("tsh: syntax error near unexpected token '|'")
}
let name = (if args[0].startsWith("/") { args[0].slice(1) } else { args[0] }).lower()
let alias = aliases[name]
if alias != null {
args = tokenize(alias) + args.slice(1)
name = args[0]
}
let c = commandIndex[name]
if c == null {
lastStatus = 127
return red("tsh: " + escapeHtml(args[0]) + ": command not found")
}
lastStatus = 0
out = c.run(Ctx(args.slice(1), unescape(stripTags(out)), i > 0, stage))
}
out
}
fn render() {
terminal.innerHTML = terminalContent + animBuffer
terminal.scrollTop = terminal.scrollHeight
promptEl.textContent = promptText()
promptEl.className = if root && mode == "shell" { "root" } else { "" }
let status = statusText()
statusEl.innerHTML = status
statusEl.hidden = status == ""
}
// ---------------------------------------------------------------------------
// The keyboard
// ---------------------------------------------------------------------------
fn selecting() { inputField.selectionStart != inputField.selectionEnd }
fn handleKey(event) {
let key = event.key
let ctrl = event.ctrlKey
let meta = event.metaKey || event.altKey
if interrupt(key, ctrl) {
if !(ctrl && key == "c") || !selecting() {
event.preventDefault()
}
return null
}
// Typing anywhere on the page goes to the input
if !js.Object.is(event.target, inputField) && !ctrl && !meta && key.length == 1 {
inputField.focus()
return null
}
if ctrl && key == "c" {
if selecting() { return null } // copying selected text
event.preventDefault()
match mode {
"vim" => { terminalContent += "Type :qa! and press to abandon all changes and exit Vim\n" }
"targascript" => { terminalContent += ">> " + escapeHtml(inputField.value) + "^C\n" }
"claude" => {
terminalContent += chatEcho(inputField.value + "^C") + "\n"
leaveChat()
}
_ => { terminalContent += echoLine(inputField.value + "^C") }
}
inputField.value = ""
historyIndex = history.length
render()
} else if ctrl && key == "l" {
event.preventDefault()
terminalContent = if mode == "vim" { vimScreen() } else { "" }
render()
} else if ctrl && key == "d" {
event.preventDefault()
if mode == "targascript" {
tgReplInput("exit")
} else if mode == "claude" {
chatInput("/exit")
} else if mode == "shell" && inputField.value == "" {
terminalContent += echoLine("")
terminalContent += execLine("exit") + "\n"
render()
}
} else if key == "Enter" {
let raw = inputField.value
inputField.value = ""
submit(raw)
} else if key == "ArrowUp" {
event.preventDefault()
if historyIndex > 0 {
if historyIndex == history.length { draft = inputField.value }
historyIndex -= 1
inputField.value = history[historyIndex]
}
} else if key == "ArrowDown" {
event.preventDefault()
if historyIndex < history.length {
historyIndex += 1
inputField.value = if historyIndex == history.length { draft } else { history[historyIndex] }
}
} else if key == "Tab" {
event.preventDefault()
if mode == "shell" {
complete()
} else if mode == "claude" && event.shiftKey {
chatCycleMode()
}
}
}
// complete does Tab completion of command names and paths
fn complete() {
let current = inputField.value
// Only the last stage of a pipeline matters
let stageStart = 0
repeat sep in ["|", "&&", ";"] {
let i = lastIndex(current, sep)
if i >= 0 && i + sep.length > stageStart { stageStart = i + sep.length }
}
let stage = current.slice(stageStart)
let trimmed = stage.trimStart()
let space = lastIndex(trimmed, " ")
let matches = []
let shown = []
let prefix = current.slice(0, stageStart) + stage.slice(0, stage.length - trimmed.length)
if space < 0 {
repeat c in commands {
if !c.hidden && c.name.startsWith(trimmed.lower()) {
matches.insert(c.name + " ")
shown.insert(c.name)
}
}
} else {
prefix += trimmed.slice(0, space + 1)
let arg = trimmed.slice(space + 1)
let [dirPart, base] = splitPath(arg)
let dir = cwd
if arg.contains("/") {
if dirPart == "" { dirPart = "/" }
dir = resolve(dirPart)
if !dirPart.endsWith("/") { dirPart += "/" }
} else if arg == "~" {
matches.insert("~/")
shown.insert("~/")
}
repeat e in children(dir) {
if e.name.startsWith(base) && (base.startsWith(".") || !e.name.startsWith(".")) {
if e.dir {
matches.insert(dirPart + e.name + "/")
shown.insert(e.name + "/")
} else {
matches.insert(dirPart + e.name + " ")
shown.insert(e.name)
}
}
}
}
if matches.length == 1 {
inputField.value = prefix + matches[0]
} else if matches.length > 1 {
inputField.value = prefix + commonPrefix(matches)
terminalContent += echoLine(current) + dim(shown.join(" ")) + "\n"
render()
}
}
fn commonPrefix(items) {
let p = items[0]
repeat s in items.slice(1) {
repeat !s.startsWith(p) { p = p.slice(0, -1) }
}
p
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
fn mount() {
let body = doc.body
doc.getElementById("fallback")?.remove()
terminal = doc.createElement("div")
terminal.id = "terminal"
terminal.setAttribute("aria-live", "polite")
body.appendChild(terminal)
inputLine = doc.createElement("div")
inputLine.id = "input-line"
promptEl = doc.createElement("label")
promptEl.id = "prompt"
promptEl.setAttribute("for", "input")
inputLine.appendChild(promptEl)
inputField = doc.createElement("input")
inputField.id = "input"
inputField.type = "text"
inputField.setAttribute("autocomplete", "off")
inputField.setAttribute("autocapitalize", "none")
inputField.setAttribute("spellcheck", "false")
inputField.setAttribute("aria-label", "Terminal input")
inputLine.appendChild(inputField)
body.appendChild(inputLine)
statusEl = doc.createElement("div")
statusEl.id = "status"
body.appendChild(statusEl)
doc.addEventListener("keydown", handleKey)
// On phones the on-screen keyboard shrinks the visual viewport but not
// 100dvh, hiding the input behind it: size the page to what is visible
let viewport = js.visualViewport
if viewport != null {
let fit = () => {
body.style.height = "${toInt(viewport.height)}px"
js.scrollTo(0, 0)
terminal.scrollTop = terminal.scrollHeight
}
viewport.addEventListener("resize", fit)
viewport.addEventListener("scroll", fit)
}
// Coming back, as from the prank's redirect, must not bring the old
// screen back from the back-forward cache: start over instead
js.addEventListener("pageshow", event => {
if event.persisted { js.location.reload() }
})
// Clicking empty space focuses the input again, but never at the cost
// of a text selection or a link click
body.addEventListener("click", event => {
if event.target.tagName == "A" { return null }
if js.getSelection().toString() != "" { return null }
inputField.focus()
})
terminalContent = banner
render()
inputField.focus()
}
populateBin()
mount()