Split terminal into flat command, achievement, and vim modules

Move canned command data (terminal-commands.js), the achievement
system (terminal-achievements.js), and the vim simulator
(terminal-vim.js) out of terminal.js, which keeps the core
terminal loop. Load order is fixed in index.html with the
modules before terminal.js. Also share expand/collapse helpers
with the hero click handler and drop the real fetch I/O in
favour of simulated curl/wget output.
This commit is contained in:
2026-09-18 17:23:25 -04:00
parent 2dcf44bc0b
commit 7f4f0b4ef1
5 changed files with 756 additions and 754 deletions
+444
View File
@@ -0,0 +1,444 @@
// Simulated vim editor for the fake terminal.
// The overlay is appended to <body> while active; on exit the terminal's
// content is reset and focus returns to it.
//
// Parameters:
// content .terminal-content element (reset on exit)
// terminal .terminal-display element (refocused on exit)
// mobileInput the hidden <input>; hidden while vim owns the keyboard
// filename argument to `vim <file>` (may be '' for the splash)
// setVimMode callback(active) so the host keeps its own state flag
function launchVimSimulator({ content, terminal, mobileInput, filename, setVimMode }) {
let vimBuffer = ['Welcome to vim (simulated)!'];
let vimLineNum = 0;
let vimStatus = '~';
let vimCol = 0;
let showSplash = false;
setVimMode(true);
// Hide the mobile input so it doesn't intercept vim keystrokes
mobileInput.style.display = 'none';
// Create a full-screen vim overlay
const vimOverlay = document.createElement('div');
vimOverlay.style.position = 'fixed';
vimOverlay.style.inset = '0';
vimOverlay.style.background = '#0a0a0c';
vimOverlay.style.fontFamily = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
vimOverlay.style.fontSize = '0.85rem';
vimOverlay.style.lineHeight = '1.5';
vimOverlay.style.color = '#22c55e';
vimOverlay.style.display = 'flex';
vimOverlay.style.flexDirection = 'column';
vimOverlay.style.zIndex = '10000';
vimOverlay.style.padding = '12px 24px';
vimOverlay.style.overflow = 'hidden';
document.body.appendChild(vimOverlay);
const lineNums = [];
const lineTexts = [];
const vimLines = [];
const linesContainer = document.createElement('div');
linesContainer.style.flexGrow = '1';
linesContainer.style.minHeight = '0';
linesContainer.style.overflowY = 'auto';
linesContainer.style.display = 'flex';
linesContainer.style.flexDirection = 'column';
linesContainer.style.scrollbarWidth = 'none';
linesContainer.style.msOverflowStyle = 'none';
function createLineElement() {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const numSpan = document.createElement('span');
numSpan.style.color = '#666';
numSpan.style.marginRight = '8px';
numSpan.style.userSelect = 'none';
numSpan.style.display = 'inline-block';
numSpan.style.width = '3ch';
const textSpan = document.createElement('span');
line.appendChild(numSpan);
line.appendChild(textSpan);
return line;
}
function createSplashLine(text) {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const textSpan = document.createElement('span');
line.appendChild(textSpan);
return { line, textSpan };
}
if (!filename) {
showSplash = true;
const splashText = [
'',
' VIM - Vi sIgnificantly worse M',
'',
' version 0.0.0-alpha',
' by disappointed programmers et al.',
' Modified by https://www.reddit.com/r/vim/',
' Vim is open source and you will figure it out',
'',
' Please send help.',
'',
' type :q<Enter> to escape this regret',
' type :help<Enter> or <F1> for on-line help',
' type :help version0<Enter> for version info',
'',
];
for (const text of splashText) {
const { line, textSpan } = createSplashLine(text);
linesContainer.appendChild(line);
lineTexts.push(textSpan);
vimLines.push(line);
}
vimBuffer = splashText;
vimLineNum = 0;
vimCol = 0;
} else {
const line = createLineElement();
linesContainer.appendChild(line);
lineNums.push(line.children[0]);
lineTexts.push(line.children[1]);
vimLines.push(line);
vimBuffer = ['Welcome to ' + filename + '!'];
vimLineNum = 0;
vimCol = 0;
}
vimOverlay.appendChild(linesContainer);
// Status bar at the bottom
const statusLine = document.createElement('div');
statusLine.style.marginTop = 'auto';
statusLine.style.borderTop = '1px solid #444';
statusLine.style.paddingTop = '4px';
statusLine.style.color = '#ccc';
statusLine.style.flexShrink = '0';
vimOverlay.appendChild(statusLine);
// File info at the very bottom
const fileInfo = document.createElement('div');
fileInfo.style.color = '#888';
fileInfo.style.flexShrink = '0';
vimOverlay.appendChild(fileInfo);
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function vimRender() {
// Ensure we have enough line elements
while (vimLines.length < vimBuffer.length) {
let newLine;
if (showSplash) {
const { line, textSpan } = createSplashLine('');
newLine = line;
linesContainer.appendChild(line);
lineTexts.push(textSpan);
} else {
newLine = createLineElement();
linesContainer.appendChild(newLine);
lineNums.push(newLine.children[0]);
lineTexts.push(newLine.children[1]);
}
vimLines.push(newLine);
}
// Update all visible lines
for (let i = 0; i < vimBuffer.length; i++) {
if (showSplash) {
lineTexts[i].textContent = vimBuffer[i] || '';
} else {
lineNums[i].textContent = (i + 1);
const lineText = vimBuffer[i] || '';
if (i === vimLineNum && (vimStatus === '-- INSERT --' || vimStatus === '~')) {
const col = vimCol;
const before = lineText.slice(0, col);
const after = lineText.slice(col);
if (after.length > 0) {
const cursorChar = after.charAt(0);
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">' + escapeHtml(cursorChar) + '</span>' + escapeHtml(after.slice(1));
} else {
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">&nbsp;</span>';
}
} else {
lineTexts[i].textContent = lineText;
}
}
vimLines[i].style.display = '';
}
// Hide unused line elements
for (let i = vimBuffer.length; i < vimLines.length; i++) {
vimLines[i].style.display = 'none';
}
// Auto-scroll when line is above or below the viewport
const lineEl = vimLines[vimLineNum];
if (lineEl && lineEl.style.display !== 'none') {
const lineBottom = lineEl.offsetTop + lineEl.offsetHeight;
const lineTop = lineEl.offsetTop;
if (lineBottom > linesContainer.scrollTop + linesContainer.clientHeight) {
linesContainer.scrollTop = lineBottom - linesContainer.clientHeight;
} else if (lineTop < linesContainer.scrollTop + 12) {
linesContainer.scrollTop = lineTop - 12;
}
}
if (showSplash) {
statusLine.textContent = '';
fileInfo.textContent = '"VIM - Vi sIgnificantly worse M"';
} else {
statusLine.textContent = vimStatus;
fileInfo.textContent = '"' + filename + '"';
}
}
function vimExit(message) {
document.removeEventListener('keydown', vimHandler);
setVimMode(false);
mobileInput.style.display = '';
vimOverlay.remove();
content.innerHTML = '';
if (message) {
const msgLine = document.createElement('div');
msgLine.style.whiteSpace = 'pre-wrap';
msgLine.style.color = '#ccc';
msgLine.textContent = message;
content.appendChild(msgLine);
}
const newLine = document.createElement('div');
newLine.innerHTML = '<span class="terminal-prompt">$</span> ';
content.appendChild(newLine);
const newCursor = document.createElement('span');
newCursor.className = 'terminal-cursor';
newLine.appendChild(newCursor);
content.scrollTop = content.scrollHeight;
terminal.focus();
}
const vimHandler = (e) => {
e.preventDefault();
if (showSplash) {
if (e.key === 'q' && e.shiftKey) {
vimExit();
return;
}
if (e.key === 'Escape' || e.key === 'q') {
vimExit();
return;
}
vimRender();
return;
}
if (vimStatus.startsWith(':')) {
if (e.key === 'Enter') {
const cmd = vimStatus.slice(1).trim();
if (cmd === 'q' || cmd === 'q!') {
vimExit();
return;
}
if (cmd === 'wq' || cmd === 'x') {
vimExit('File saved (simulated).');
return;
}
if (cmd === 'w') {
vimStatus = 'File written (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd.startsWith('%s/')) {
vimStatus = 'Pattern not found (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd === 'help') {
vimStatus = ':q quit :w save :wq save & quit :q! force quit dd delete line i insert o open line';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 3000);
return;
}
vimStatus = 'Unknown command: ' + cmd;
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 2000);
return;
}
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'Backspace') {
vimStatus = vimStatus.slice(0, -1);
vimRender();
return;
}
if (e.key.length === 1) {
vimStatus += e.key;
vimRender();
}
return;
}
if (vimStatus === '-- INSERT --') {
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + e.key + vimBuffer[vimLineNum].slice(vimCol);
vimCol++;
vimRender();
return;
}
if (e.key === 'Enter') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimRender();
return;
}
if (e.key === 'Backspace') {
if (vimCol > 0) {
vimCol--;
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + vimBuffer[vimLineNum].slice(vimCol + 1);
}
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
vimRender();
return;
}
// Normal mode (~)
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'i') {
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'I') {
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'a') {
vimCol = Math.min(vimCol + 1, (vimBuffer[vimLineNum] || '').length);
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'A') {
vimCol = (vimBuffer[vimLineNum] || '').length;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'o') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'O') {
vimBuffer.splice(vimLineNum, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'x') {
if (vimBuffer[vimLineNum]) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, -1);
}
vimRender();
return;
}
// "dd" — delete the current line (Shift+D, or Alt+D as a fallback).
// The buffer always keeps at least one (empty) line so insert
// mode can never index a missing line.
if ((e.key === 'D' && e.shiftKey) || (e.key === 'd' && e.altKey)) {
vimBuffer.splice(vimLineNum, 1);
if (vimBuffer.length === 0) vimBuffer.push('');
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length);
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
if (e.key === 'G') {
vimLineNum = vimBuffer.length - 1;
vimRender();
return;
}
if (e.key === 'g') {
vimLineNum = 0;
vimRender();
return;
}
if (e.key === ':') {
vimStatus = ':';
vimRender();
return;
}
vimRender();
};
document.addEventListener('keydown', vimHandler);
}