Every break reminder I have ever installed died the same death: a toast slid into the corner of the screen, I thought "in a minute", and that was that. The reminder was polite. Polite is ignorable.
So for Hacktoberfest Week 4, whose theme is Ghost in the Machine, I built the opposite: haunt, a terminal ghost that interrupts you with a horror still, a whisper from a local model, and then refuses to give the screen back until you have stood up and looked at something that is not a monitor.
It is deliberately a little mean. That is the health feature.
What it does
Every few minutes (randomised, so you cannot brace for it), haunt:
- asks a local open-weight model to write one unsettling line and one concrete break instruction;
- slams a horror image, GIF, or video over your terminal;
- replaces it with a calm panel that holds a countdown while you breathe, stretch, and walk away;
- logs the break so
--statscan tell you how long you actually spent away from the screen.
$ python3 haunt.py --demo
your last commit message was a whisper too.
[ a 90x28 frame of a haunted house at night, rendered in 24-bit colour ]
╭──────────────────────────────────────────────────────────────╮
│ the spirit releases you │
│ │
│ Stand up and roll your shoulders back five times. │
│ The ghost will wait. │
│ │
│ ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ 14s away from the screen │
╰──────────────────────────────────────────────────────────────╯
breathe in 4, hold 4, out 6. ctrl+c ends the haunting.
When you quit, it prints the damage: 23 breaks, 46.0 minutes away from the screen.
How it works
flowchart LR
T[randomised timer] --> O[local model<br/>llama3.2:3b via Ollama]
O --> W[whisper + health tip]
T --> A[Wikimedia Commons asset<br/>still, GIF or video]
A --> C[local cache + attribution]
A -. network down .-> P[procedural Pillow art]
W --> R[ANSI half-block renderer]
C --> R
P --> R
R --> B[calm break panel<br/>countdown + breathing]
B --> L[break log]
No graphics protocol, no browser, no image library in the terminal. Frames are downsampled with Pillow and printed as half-block cells, two vertical pixels per character, with 24-bit colour escapes:
def ansi_frame(img, cols, rows):
fitted = ImageOps.fit(img.convert("RGB"), (cols, rows * 2),
method=Image.Resampling.LANCZOS)
data = fitted.tobytes()
lines = []
for y in range(rows):
top = (y * 2) * cols * 3
bottom = (y * 2 + 1) * cols * 3
chunks = []
for x in range(cols):
i, j = top + x * 3, bottom + x * 3
chunks.append(
f"\x1b[38;2;{data[i]};{data[i+1]};{data[i+2]}m"
f"\x1b[48;2;{data[j]};{data[j+1]};{data[j+2]}m▀"
)
lines.append("".join(chunks))
return "\n".join(lines) + "\x1b[0m"
The ghost itself is a prompt. The model returns JSON with three fields, and the app treats every field defensively, because a small model on a laptop will eventually return something strange:
prompt = (
"You are the ghost in the machine, haunting a developer's terminal to push them "
"away from the screen for their health. Reply with JSON keys whisper, health_tip, "
"theme. whisper: one eerie line under 110 characters, unsettling not gory, lowercase, "
"second person. health_tip: one concrete ergonomic break instruction under 110 "
"characters that makes the human leave the screen (eyes, posture, movement, water). "
"theme: exactly one of ghost, haunted-house, skeleton, cemetery, pumpkin, ofrenda."
)
data = ollama_json(prompt, model)
if len(data.get("health_tip", "")) < 20:
data["health_tip"] = random.choice(FALLBACK_TIPS)
If Ollama is missing, slow, or returns garbage, curated lines take over. The haunting never fails open into silence; it fails into a break, which is the point.
The break panel is a loop with a progress bar and a countdown, not a notification:
start = time.time()
while True:
remaining = max(0.0, seconds - (time.time() - start))
if remaining <= 0:
break
bar = "█" * int(bar_width * (1 - remaining / seconds))
draw_panel(tip, bar, remaining)
time.sleep(0.12)
The only way past it is time, or Ctrl+C, and Ctrl+C still restores the terminal and prints your stats.
Where the horror comes from
Assets are searched and downloaded from Wikimedia Commons using the public MediaWiki API, filtered to images, animated GIFs, and WebM video, and cached locally in ~/.cache/haunt/assets. Every file keeps its artist and licence, which python3 haunt.py --credits ghost prints:
ghost
Apparition by Eugeniu Voinescu.jpg — Ruth Tillman (CC BY-SA 4.0)
Athenodorus - The Greek Stoic Philosopher ... — Henry Justice Ford (Public domain)
La bimba dal vestitino rosso.webm — Museo Italiano Dell'Immaginario Folklorico (CC BY-SA 4.0)
Commons rate-limits aggressively, so downloads retry with backoff and refuse anything over 12 MB. If the network is gone, the ghost paints its own scene with Pillow: a gradient night, fog, a silhouette house, glowing windows, a moon, scratch marks. --offline never touches the network.
Why open matters here
This tool is supposed to change a habit, and habits fail when the tool fails. Open pieces are what make it keep working:
-
A local model means no key, no cost, and no data leaving the machine. The whisper is generated by
llama3.2:3bthrough Ollama. I can run it on a plane, and nothing about my terminal or my work goes to a provider. -
Swappable models. The prompt is the contract, not an SDK.
--modeltakes anything Ollama can serve, from a 1B model on a laptop to something larger. - Offline is a first-class mode. If Commons is unreachable, procedural art and curated lines still produce a complete haunting. The health habit does not depend on a service that might disappear.
- The media is genuinely reusable. Public domain and Creative Commons assets, with attribution preserved and printable, instead of scraped images with unclear provenance.
- You can read all of it. The renderer, the model call, the break loop: nothing is hidden behind a hosted API, so you can change how the ghost behaves or how long it holds you.
A note on safety
A scare tool has to be careful about being a scare tool. --safe disables the audio and the single inverted-frame flash, and audio is off by default whenever you ask for safe mode. The flash is one slow pulse, not a strobe. The health advice is ordinary workplace ergonomics: 20-20-20, standing, movement, water, blinking, posture, wrists. It is not medical advice, and the ghost is not a doctor. It is just rude enough to make you stand up.
Run it
git clone https://github.com/karan-2004/haunted-terminal
cd haunted-terminal
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
ollama pull llama3.2:3b
python3 haunt.py --demo # one haunting now, short break
python3 haunt.py # haunt roughly every 5 to 9 minutes
python3 haunt.py --safe # no sound, no flash
python3 haunt.py --stats # how long you stayed away
It needs Python 3.10+, Pillow, requests, numpy, and Ollama. paplay/aplay are optional for the sound; ffmpeg is optional for Commons videos. No login, no API keys, nothing to configure.
MIT licensed. Built during the Hacktoberfest Open-Source AI Challenge Week 4 window, with the model running locally and the lights off.
Here is the session where it was built, if you want to see the messy middle:
Top comments (0)