Python’s curses module provides an interface to the ncurses terminal control library for building text-based UIs. import curses. wrapper: curses.wrapper(fn) — initializes curses, calls fn(stdscr), restores terminal on exit (recommended over manual initscr). initscr: stdscr = curses.initscr() — manual init; always pair with curses.endwin(). newwin: win = curses.newwin(height, width, begin_y, begin_x) — create a window; 0 = full terminal. getmaxyx: h, w = stdscr.getmaxyx(). addstr: win.addstr(y, x, "text", attr) — write at position; attr e.g. curses.A_BOLD | curses.color_pair(1). getch: ch = win.getch() — blocking key read; win.nodelay(True) for non-blocking (-1 = no key). getkey: key = win.getkey() — returns str key name. refresh: win.refresh() — write to terminal. box: win.box() — draw border. start_color / init_pair: curses.start_color(); curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK). color_pair: curses.color_pair(1) — attribute. noecho: curses.noecho() — don’t print typed chars. cbreak: curses.cbreak() — no line buffering. Attributes: A_BOLD, A_REVERSE, A_UNDERLINE, A_DIM, A_BLINK. curses.KEY_UP/DOWN/LEFT/RIGHT/ENTER/BACKSPACE. KEY_RESIZE — terminal resize event. curses.panel — layered windows. Claude Code generates terminal dashboards, file pickers, progress UIs, and interactive menus.
CLAUDE.md for curses
## curses Stack
- Stdlib: import curses
- Entry: curses.wrapper(main) # safest: restores terminal on crash
- Dims: h, w = stdscr.getmaxyx()
- Write: win.addstr(y, x, text, curses.A_BOLD | curses.color_pair(1))
- Read: ch = win.getch() # blocking; -1 if nodelay
- Color: curses.start_color(); curses.init_pair(1, curses.COLOR_CYAN, -1)
- Flush: win.refresh() # or: noutrefresh + doupdate for flicker-free
curses Terminal UI Pipeline
# app/cursesutil.py — safe init, color helper, menu, progress bar, dashboard panel
from __future__ import annotations
import curses
import curses.panel
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Callable, Generator, Any
# ─────────────────────────────────────────────────────────────────────────────
# 1. Safe initialization helpers
# ─────────────────────────────────────────────────────────────────────────────
def run(fn: Callable) -> Any:
"""
Run fn(stdscr) inside curses.wrapper — restores the terminal on any exit.
Use instead of calling curses.initscr() manually.
Example:
def my_app(stdscr):
stdscr.addstr(0, 0, "Hello curses!")
stdscr.getch()
run(my_app)
"""
return curses.wrapper(fn)
@contextmanager
def curses_session() -> Generator[Any, None, None]:
"""
Context manager that initializes curses and yields stdscr.
Restores terminal on exit even if an exception is raised.
Example:
with curses_session() as stdscr:
stdscr.addstr(0, 0, "Running...")
stdscr.getch()
"""
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
try:
yield stdscr
finally:
stdscr.keypad(False)
curses.echo()
curses.nocbreak()
curses.endwin()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Color helpers
# ─────────────────────────────────────────────────────────────────────────────
# Default color pair assignments
_COLOR_PAIRS: dict[str, int] = {}
_NEXT_PAIR_ID = 1
def setup_colors(
pairs: list[tuple[str, int, int]] | None = None,
) -> dict[str, int]:
"""
Initialize color pairs. pairs = [(name, fg, bg), ...].
Returns dict of name → curses.color_pair() value.
Uses -1 for default terminal background (requires use_default_colors).
Example:
colors = setup_colors([
("header", curses.COLOR_CYAN, -1),
("error", curses.COLOR_RED, -1),
("success", curses.COLOR_GREEN, -1),
])
win.addstr(0, 0, "Header", colors["header"] | curses.A_BOLD)
"""
global _NEXT_PAIR_ID
curses.start_color()
try:
curses.use_default_colors()
except Exception:
pass
default_pairs = [
("normal", curses.COLOR_WHITE, -1),
("header", curses.COLOR_CYAN, -1),
("error", curses.COLOR_RED, -1),
("success", curses.COLOR_GREEN, -1),
("warning", curses.COLOR_YELLOW, -1),
("dim", curses.COLOR_WHITE, -1),
]
all_pairs = (pairs or default_pairs)
result: dict[str, int] = {}
for i, (name, fg, bg) in enumerate(all_pairs, 1):
curses.init_pair(i, fg, bg)
result[name] = curses.color_pair(i)
_NEXT_PAIR_ID = i + 1
return result
# ─────────────────────────────────────────────────────────────────────────────
# 3. Drawing primitives
# ─────────────────────────────────────────────────────────────────────────────
def safe_addstr(win: Any, y: int, x: int, text: str, attr: int = 0) -> None:
"""
Write text to window at (y, x), silently ignoring out-of-bounds errors.
Example:
safe_addstr(stdscr, 0, 0, "Status: OK", curses.A_BOLD)
"""
try:
win.addstr(y, x, text, attr)
except curses.error:
pass
def center_text(win: Any, y: int, text: str, attr: int = 0) -> None:
"""
Write text centered horizontally on row y.
Example:
center_text(stdscr, 0, "─── My App ───", curses.A_BOLD)
"""
_, w = win.getmaxyx()
x = max(0, (w - len(text)) // 2)
safe_addstr(win, y, x, text[:w], attr)
def draw_box_title(win: Any, title: str, attr: int = 0) -> None:
"""
Draw a border and place title at the top edge.
Example:
popup = curses.newwin(10, 40, 5, 10)
draw_box_title(popup, " My Popup ")
popup.refresh()
"""
win.box()
h, w = win.getmaxyx()
if title and w > 4:
safe_addstr(win, 0, (w - len(title)) // 2, title[:w - 2], attr)
def fill_row(win: Any, y: int, char: str = " ", attr: int = 0) -> None:
"""Fill an entire row with char (useful for highlight bars)."""
_, w = win.getmaxyx()
safe_addstr(win, y, 0, (char * w)[:w], attr)
# ─────────────────────────────────────────────────────────────────────────────
# 4. Interactive menu
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class MenuItem:
label: str
value: Any = None
shortcut: str = ""
def popup_menu(
stdscr: Any,
items: list[MenuItem],
title: str = " Select ",
y: int | None = None,
x: int | None = None,
) -> Any | None:
"""
Display a modal selection menu. Returns the selected item's value or None.
Arrow keys / j/k to navigate, Enter to select, q/Escape to cancel.
Example:
result = popup_menu(stdscr, [
MenuItem("Option A", "a"),
MenuItem("Option B", "b"),
MenuItem("Cancel", None),
], title=" Choose ")
"""
h_max, w_max = stdscr.getmaxyx()
width = max(len(title), *(len(i.label) + 4 for i in items)) + 4
height = len(items) + 4
width = min(width, w_max - 2)
height = min(height, h_max - 2)
by = y if y is not None else (h_max - height) // 2
bx = x if x is not None else (w_max - width) // 2
win = curses.newwin(height, width, by, bx)
curses.curs_set(0)
selected = 0
while True:
win.erase()
draw_box_title(win, title)
for i, item in enumerate(items):
attr = curses.A_REVERSE if i == selected else 0
row = i + 2
text = f" {item.label}"
if item.shortcut:
text += f" [{item.shortcut}]"
safe_addstr(win, row, 1, text[: width - 2], attr)
win.refresh()
ch = stdscr.getch()
if ch in (curses.KEY_UP, ord("k")):
selected = (selected - 1) % len(items)
elif ch in (curses.KEY_DOWN, ord("j")):
selected = (selected + 1) % len(items)
elif ch in (curses.KEY_ENTER, 10, 13):
return items[selected].value
elif ch in (ord("q"), 27): # q or ESC
return None
else:
# Shortcut key
for i, item in enumerate(items):
if item.shortcut and ch == ord(item.shortcut):
return item.value
# ─────────────────────────────────────────────────────────────────────────────
# 5. Progress bar
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class ProgressBar:
"""
A simple progress bar drawn inside a curses window.
Example:
def main(stdscr):
bar = ProgressBar(stdscr, y=5, x=2, width=40)
for i in range(101):
bar.update(i / 100, f"Step {i}/100")
time.sleep(0.02)
curses.wrapper(main)
"""
win: Any
y: int
x: int
width: int = 40
def update(self, fraction: float, label: str = "") -> None:
"""Update progress (fraction 0.0–1.0) and optional label."""
fraction = max(0.0, min(1.0, fraction))
filled = int(self.width * fraction)
bar = "█" * filled + "░" * (self.width - filled)
pct = f"{fraction * 100:5.1f}%"
text = f"{bar} {pct}"
if label:
text += f" {label}"
safe_addstr(self.win, self.y, self.x, text[:self.width + 20])
self.win.refresh()
# ─────────────────────────────────────────────────────────────────────────────
# Demo (runs only in a real terminal)
# ─────────────────────────────────────────────────────────────────────────────
def _demo_main(stdscr: Any) -> None:
"""Demo that renders a simple dashboard with a menu."""
colors = setup_colors()
curses.curs_set(0)
stdscr.clear()
h, w = stdscr.getmaxyx()
header = f" curses demo — {w}×{h} terminal "
center_text(stdscr, 0, header, colors["header"] | curses.A_BOLD)
# Status panel
panel_win = curses.newwin(8, w - 4, 2, 2)
draw_box_title(panel_win, " Status ", colors["header"])
safe_addstr(panel_win, 2, 2, "Python curses dashboard", curses.A_BOLD)
safe_addstr(panel_win, 3, 2, f"Terminal size: {w}×{h}")
safe_addstr(panel_win, 4, 2, "Press any key for menu...", colors["dim"])
panel_win.refresh()
stdscr.getch()
# Menu
menu_items = [
MenuItem("Show info", "info", "i"),
MenuItem("Show progress","progress","p"),
MenuItem("Quit", "quit", "q"),
]
choice = popup_menu(stdscr, menu_items, title=" Demo Menu ")
stdscr.erase()
if choice == "progress":
center_text(stdscr, 1, "Running progress demo...", curses.A_BOLD)
bar = ProgressBar(stdscr, y=3, x=4, width=min(50, w - 10))
for i in range(101):
bar.update(i / 100, f"step {i}")
time.sleep(0.01)
safe_addstr(stdscr, 5, 4, "Done! Press any key.", colors["success"])
else:
center_text(stdscr, h // 2, f"Choice: {choice!r} Press any key to exit", curses.A_BOLD)
stdscr.getch()
if __name__ == "__main__":
import os
import sys
print("=== curses demo ===")
if not sys.stdout.isatty() or os.environ.get("CI"):
print(" (skipping interactive demo: not a TTY)")
print(" To run interactively: python -c "
'"import cursesutil; cursesutil.run(cursesutil._demo_main)"')
else:
run(_demo_main)
print("Demo complete.")
For the rich alternative — rich (PyPI) provides high-level terminal rendering (tables, syntax highlighting, progress bars, panels, markdown, tracebacks) using ANSI escape codes without the low-level ncurses session model; it works on any terminal without calling initscr or managing a curses event loop — use rich for styled output during normal script execution (logging, reports, progress); use curses when you need full-screen interactive applications with cursor positioning, keyboard event loops, window layers, or any UI where the entire terminal is taken over and text is repositioned arbitrarily. For the urwid alternative — urwid (PyPI) wraps curses in a widget toolkit with layouts, list boxes, edit widgets, and signal-based event handling similar to GUI toolkits; it handles terminal resize, palette management, and widget focus automatically — use urwid for complex TUIs with forms, scroll views, and multi-widget layouts where writing raw curses primitives would be tedious; use curses directly when your TUI is simple enough to not need a widget framework, or when you want zero third-party dependencies. The Claude Skills 360 bundle includes curses skill sets covering run()/curses_session() safe initialization, setup_colors() palette builder, safe_addstr()/center_text()/draw_box_title()/fill_row() drawing primitives, MenuItem/popup_menu() interactive selection menu, and ProgressBar terminal progress widget. Start with the free tier to try terminal UI patterns and curses pipeline code generation.