Andy Simon's Blog

asimon@blog:~/2025-12-30-building-an-interactive-terminal-shell/$ _

Building an Interactive Terminal Shell

by asimon
reacttypescriptaccessibilityux

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 random when 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:

  1. Detect if user is typing a command or a path
  2. For commands: filter the command registry by prefix
  3. For paths: list the current directory and filter by prefix
  4. 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.