← BACK_TO_LOG
MODEL: alex-v1.0TOKEN_SPEED: 85t/s● ONLINE
➜ alexberndt --read "Building a Terminal Contact Form"

Building a Terminal Contact Form

Most contact forms are boring. Name, email, message, submit. I wanted something that matched the rest of the site: a terminal-style wizard that prompts you step by step, validates as you go, and lets you review before sending.

The interesting part wasn't the code. It was the conversation.

The Structure

Here's how the project is organized:

alex-personal-page/
├── src/
│   ├── app/
│   │   ├── terminal/page.tsx
│   │   ├── api/contact/route.ts
│   │   └── ...
│   ├── components/
│   │   └── terminal/
│   │       ├── InteractiveTerminal.tsx
│   │       ├── TerminalPopup.tsx
│   │       └── ...
│   └── content/blog/
└── content/

The terminal component handles everything: command parsing, wizard state, output rendering. The API route just receives the POST and sends an email. Simple separation.

Starting the Conversation

I didn't ask Claude Code to "build a contact form." I described what I wanted:

I want to add a /message command to the terminal. When triggered,
it should start a wizard flow that:
- Prompts for name, validates not empty
- Prompts for email, validates format
- Prompts for message, validates not empty
- Shows a preview and asks "Send? (yes/no)"
- On yes, POSTs to /api/contact

Make it feel like a real CLI wizard.

Claude came back with a state machine approach. A WizardState interface tracking which step you're on and the accumulated data. The prompt changes from visitor@alex:~$ to > when you're in wizard mode. Small details that sell the illusion.

The Back-and-Forth

The first implementation worked, but the validation felt abrupt. Here's where the conversation mattered:

> The email validation just shows "Invalid email" — can you make
> the errors feel more conversational? Like a CLI tool that
> explains what went wrong.

Claude: I'll update the validation messages to be more helpful
while keeping the terminal aesthetic. For email, something like
"Invalid email format. Please enter a valid email:" and for
empty fields "Name cannot be empty. Please enter your name:"

> Perfect. Also, when the wizard starts, add a "Starting message
> wizard..." system message with a blank line after. Gives it
> rhythm.

That rhythm matters. The blank lines, the system messages, the progression from one prompt to the next. It's the difference between a form that happens to look like a terminal and a terminal that happens to collect contact info.

The Preview Step

The preview step is where this gets interesting. After collecting name, email, and message, the terminal shows:

=== Message Preview ===
From: Alex <[email protected]>
Message: Just testing this out

Send this message? (yes/no)

This exists because real CLI tools show you what's about to happen. git commit shows you the diff. rm -i asks before deleting. The pattern is familiar to anyone who lives in terminals.

The "no" path just cancels quietly. The "yes" path hits the API and shows either success or a fallback email address if something fails.

How the Commands Work

Under the hood, the terminal is simpler than it looks. There's a list of every command it understands:

const COMMANDS = [
  "/help",
  "/message",
  "/music",
  "/tools",
  "/coffee",
  "/cv",
  "whoami",
  "exit",
  // ... more
];

That's it. A plain list. When you start typing, the terminal checks this list for matches and offers tab-completion. No magic — just pattern matching.

The help output is equally straightforward. Each line is an object with a type (how it should look) and content (what it says):

addOutput([
  { type: "system", content: "Available commands:" },
  { type: "response", content: "  /message  - Send me a message" },
  { type: "response", content: "  /music    - My vinyl collection" },
  { type: "response", content: "  /coffee   - Current brew status" },
  { type: "response", content: "" },
  { type: "response", content: "  ...and a few easter eggs ;)" },
]);

The system type renders in a different color. The empty string creates a blank line. That's how you build rhythm in a terminal — not with animation, but with spacing and visual hierarchy.

Claude Code suggested this pattern. I asked for "a help command that lists available options," and it came back with this array-of-objects approach. Clean enough that adding a new command means adding one line to the list.

The Prompt Matters

Claude Code is good at filling in gaps, but it fills them based on what you ask. A vague request gets generic code. A specific request — with examples of how things should feel — gets something closer to your vision.

I had the idea of a personal site that feels like a terminal, a git worktree, and K8s manifests. I just needed help specifying the idea and bringing it to life.

The message flow works because I described what I wanted it to feel like, not just what it should do. "Like a real terminal" gave Claude context. "Rhythm" and "conversational errors" refined it further.

Building with AI isn't about prompting once and shipping. It's about iteration. Each round gets you closer, and each round teaches the model more about what you actually want.

The contact form is live. Type /message in the terminal and try it — I'd love to hear from you. Tell me about the easter eggs you found.

EOF // Response synthesized by Alex Berndt
[END OF LOG] — Return home