Building an Interactive Terminal Shell
How I built a fully-featured terminal overlay for navigating the blog, complete with tab completion and command history
title: "Building an Interactive Terminal Shell" summary: "How I built a fully-featured terminal overlay for navigating the blog, complete with tab completion and command history" date: "2025-12-30" tags: ["react", "typescript", "accessibility", "ux"] topics: ["react-patterns", "typescript", "accessibility", "developer-experience"] prerequisites: ["2025-12-28-architecture-of-a-modern-static-blog"] related: ["2025-07-31-buy-vs-build-vibe-era-simple"] author: "asimon" published: true
Building an Interactive Terminal Shell
This blog has a hidden feature: an interactive terminal shell. Press the backtick key (`) anywhere on the site and a terminal slides up from the bottom of the screen. From there, you can navigate posts, change themes, and explore the site using familiar command-line patterns.
Try It Now
Press the backtick key () to open the terminal, or click the terminal icon in the header. Type help` to see available commands.
This post covers what the terminal can do and how I built it.
Part 1: What You Can Do
Available Commands
The terminal supports 11 commands organized into three categories:
Navigation Commands
| Command | Description |
|---------|-------------|
| open <slug> | Open a post or page in the browser |
| latest | Jump to the most recent post |
| random | Open a random post (great for discovery) |
Filesystem Commands
| Command | Description |
|---------|-------------|
| ls [path] | List directory contents |
| cd [path] | Change directory |
| pwd | Print current directory |
| cat <file> | Display file contents |
Utility Commands
| Command | Description |
|---------|-------------|
| help | Show all available commands |
| clear | Clear the terminal output |
| theme [mode] | Toggle or set theme (dark/light/system) |
| whoami | Display your visitor identity |
Keyboard Shortcuts
Beyond typing commands, the terminal supports several keyboard shortcuts:
- Backtick (`) - Toggle terminal open/closed
- Escape - Close terminal
- Arrow Up/Down - Navigate command history
- Tab - Auto-complete commands and paths
The backtick shortcut is disabled when you're focused on any input field, so it won't interfere with typing.
The Virtual Filesystem
The terminal includes a virtual filesystem that maps the site's content:
/
āāā posts/ # All blog posts as .mdx files
āāā sandbox/ # Interactive tools
ā āāā roi.txt
ā āāā matrix.txt
āāā about.txt # About page
āāā readme.txt # Welcome message
You can navigate this filesystem just like a real one:
$ cd posts
$ ls
2025-12-30-building-an-interactive-terminal-shell.mdx
2025-12-28-architecture-of-a-modern-static-blog.mdx
...
$ cat 2025-12-28-architecture-of-a-modern-static-blog.mdx
Title: Architecture of a Modern Static Blog
Date: 2025-12-28
...
Tips and Tricks
Power User Tips
- Use
randomwhen you're not sure what to read next - Tab completion works for both commands and file paths
- Your command history persists across browser sessions
- The terminal remembers your last directory
Part 2: How I Built It
For those curious about the implementation, here's how the terminal works under the hood.
Component Architecture
The terminal is built from several React components:
src/components/terminal/
āāā Terminal.tsx # Main overlay container
āāā TerminalContext.tsx # Global state management
āāā TerminalInput.tsx # Command input with history
āāā TerminalOutput.tsx # Scrollable output display
āāā TerminalToggle.tsx # Header button
āāā hooks/
ā āāā useCommandHistory.ts
ā āāā useTabCompletion.ts
ā āāā useFocusTrap.ts
āāā commands/
ā āāā index.ts # Command registry
ā āāā filesystem.ts
ā āāā navigation.ts
ā āāā utility.ts
āāā filesystem/
āāā VirtualFS.ts # Virtual filesystem
State Management
The terminal uses React Context with a reducer pattern for state management:
interface TerminalState {
isOpen: boolean;
cwd: string; // Current working directory
outputHistory: OutputLine[];
}
This state is global so any component can open/close the terminal or respond to state changes.
The Command Registry
Commands are registered in a central registry with a consistent interface:
interface Command {
name: string;
aliases?: string[];
description: string;
execute: (args: string[], ctx: CommandContext) => CommandResult;
}
When you type a command, the registry looks it up (including aliases like cls for clear) and executes it with the current context.
Virtual Filesystem Design
The virtual filesystem is an in-memory tree structure built from the site's post metadata:
interface DirectoryNode {
type: 'directory';
name: string;
children: Map<string, FSNode>;
}
interface FileNode {
type: 'file';
name: string;
content: string;
metadata?: PostMetadata;
}
No Real Filesystem Access
The VFS is entirely in-memory. There's no connection to the actual server filesystem - it's built from the post metadata that's already embedded in the static site.
Path resolution supports ., .., absolute paths, and relative paths, just like a real shell.
Tab Completion
Tab completion was one of the trickier features to implement. The algorithm:
- Detect if user is typing a command or a path
- For commands: filter the command registry by prefix
- For paths: list the current directory and filter by prefix
- On repeated Tab presses, cycle through matches
The state tracks the current matches and cycles through them:
const { complete, resetCompletion } = useTabCompletion({ fs, cwd });
// On Tab press:
const completed = complete(currentInput);
setInput(completed);
Focus Trapping
For accessibility, the terminal traps keyboard focus when open. This means:
- Tab cycles between the input and close button
- Focus returns to the previously-focused element when closed
- Screen readers announce the terminal as a dialog
useFocusTrap({ isOpen: state.isOpen, containerRef });
localStorage Persistence
Two pieces of state persist across sessions:
- Command history (up to 100 commands)
- Current working directory
Both use localStorage with graceful error handling for private browsing modes.
Lessons Learned
Ship in Phases
I split the terminal into two phases:
MVP-0 (First Ship):
- Basic commands:
help,ls,cd,pwd,open,clear - Toggle button and Escape key
- Core functionality only
MVP-1 (Fast Follow):
- Extended commands:
cat,random,latest,theme,whoami - Tab completion
- Command history
- Focus trapping
This let me ship something usable quickly, then polish it with additional features.
Test Early
I wrote Playwright E2E tests alongside the implementation. The test suite now covers:
- All 11 commands
- Keyboard shortcuts
- History navigation
- Tab completion
- Mobile responsiveness (terminal hidden on small screens)
Having tests from the start caught several regressions during the MVP-1 phase.
Accessibility From Day One
It's much easier to build accessibility in from the start than to retrofit it later. The terminal includes:
- ARIA attributes (
role="dialog",aria-modal,aria-live) - Focus trapping
- Keyboard navigation
- Reduced motion support
These were designed into the component architecture, not added as an afterthought.
What's Next
The terminal is complete for now, but there's room for future enhancements:
- Additional commands as new features are added
- Easter eggs (maybe)
- Integration with upcoming sandbox tools
For now, give it a try. Press backtick, type help, and explore. And if you find any bugs, let me know.
Related: Architecture of a Modern Static Blog - the infrastructure that powers this site.