agent-todo-tracker/agent_todos/cli_repl.py
Hermes Agent 7a03de305a feat(box): ADHD-friendly time-boxing mode (issue #16)
- TimeBox model with explicit recoverable BoxState state machine
- Store layer: time_boxes schema, CRUD, active-box recovery
- BoxController: start/pause/resume/interrupt/complete/expire with elapsed math
- CLI  group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus)
- Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit
- Interruptions logged with timestamp + reason for pattern recognition
- Non-interactive helpers + auto-resume on entry
- 65 tests green, strict vertical RED-GREEN TDD
2026-08-22 21:49:35 +00:00

183 lines
6.4 KiB
Python

"""ADHD-friendly live focus REPL (issue #16).
Single-key, calm, interruptible. Designed to be testable without a real TTY:
every interaction is driven by injected `wait_input` and `display` callables.
"""
from __future__ import annotations
import time
from typing import Callable, Optional
from .box import BoxController
from .timebox import BoxState
DisplayFn = Callable[[str], None]
InputFn = Callable[[float], Optional[str]]
def _mmss(seconds) -> str:
seconds = max(0, int(float(seconds)))
return f"{seconds // 60:02d}:{seconds % 60:02d}"
HELP = (
"commands (single key, Enter to repeat):\n"
" p - pause the timer (nothing lost)\n"
" r - resume where you left off\n"
" i <why> - interrupt with a reason (recoverable)\n"
" c - complete the box\n"
" q - quit (abandons the box, no judgment)\n"
" h - show this help\n"
" Enter - refresh the countdown"
)
def _headline(box) -> str:
if box.state == BoxState.running:
return (
f"focusing - {_mmss(box.remaining_seconds())} left "
f"of {_mmss(box.duration_seconds)}"
)
if box.state == BoxState.paused:
return f"paused - {_mmss(box.remaining_seconds)} left"
if box.state == BoxState.interrupted:
return f"interrupted - {_mmss(box.remaining_seconds())} left"
if box.state == BoxState.open:
return "fresh - starting now"
return box.state.value
def _progress_bar(box) -> str:
progress = box.progress_percent()
bar_len = 20
filled = bar_len * progress // 100
bar = "#" * filled + "-" * (bar_len - filled)
return f" [{bar}] {progress}%"
def _refresh(box, display: DisplayFn) -> None:
display(_headline(box))
if box.state == BoxState.running:
display(_progress_bar(box))
def _auto_enter(store, box, ctrl, display: DisplayFn):
"""On entry, auto-start a fresh box or auto-resume a paused one."""
if box.state == BoxState.open:
box = ctrl.start(box)
display("starting your focus session...")
_refresh(box, display)
elif box.state in (BoxState.paused, BoxState.interrupted):
box = ctrl.resume(box)
display("resuming where you left off...")
_refresh(box, display)
return box
def run_repl(
store,
box,
wait_input: InputFn,
display: DisplayFn,
tick_interval: float,
) -> ...:
"""Run the live focus loop and return the final box state.
Args:
store: a Store-like object supporting get_timebox / update_timebox.
box: the TimeBox to work on (its current state is respected).
wait_input: callable(timeout) -> input line or None on timeout.
display: callable(str) -> emits one line of output.
tick_interval: seconds between idle redraws (0 for event-driven).
"""
ctrl = BoxController(store)
display("focus mode (press h for help)")
display(HELP)
display("-" * 40)
# Re-fetch from store so external updates are visible.
box = store.get_timebox(box.id) if box.id is not None else box
box = _auto_enter(store, box, ctrl, display)
try:
while not box.is_terminal():
# Auto-expire if the timer ran out between ticks.
if box.state == BoxState.running and box.remaining_seconds() <= 0:
box = ctrl.expire(box)
display("")
display("timer ran out. take a breather.")
_refresh(box, display)
break
# Idle refresh while running (only when there's a tick cadence).
if tick_interval > 0:
_refresh(box, display)
time.sleep(tick_interval)
box = store.get_timebox(box.id) if box.id is not None else box
continue
# Event-driven: poll input non-blocking.
line = wait_input(0.0)
if line is None:
# No input yet; re-check expiry without blocking forever.
box = store.get_timebox(box.id) if box.id is not None else box
if box.state == BoxState.running and box.remaining_seconds() <= 0:
box = ctrl.expire(box)
display("timer ran out. take a breather.")
break
# Tiny yield so a pure-0 interval doesn't busy-spin in real use.
if tick_interval == 0:
continue
time.sleep(tick_interval)
continue
line = line.strip()
if line == "":
_refresh(box, display)
continue
parts = line.split(None, 1)
cmd = parts[0].lower()
arg = parts[1] if len(parts) > 1 else ""
try:
if cmd == "h":
display(HELP)
elif cmd == "p":
box = ctrl.pause(box)
display(f"paused - {_mmss(box.remaining_seconds())} saved")
elif cmd == "r":
box = ctrl.resume(box)
display(f"resumed - {_mmss(box.remaining_seconds())} to go")
elif cmd == "i":
if not arg:
display("an interrupt needs a reason: type 'i <why>'")
display("(this helps you notice patterns later)")
continue
box = ctrl.interrupt(box, reason=arg)
display(f"interrupted - {_mmss(box.remaining_seconds())} saved for later")
display(f" reason: {arg}")
elif cmd == "c":
box = ctrl.complete(box)
display(f"completed after {_mmss(box.elapsed_seconds())} - well done")
elif cmd == "q":
reason = arg or ""
box = ctrl.abandon(box, reason=reason)
display("abandoned. it's okay - come back when you want.")
else:
display(f"unknown command: '{cmd}'. press h for help.")
except ValueError as e:
display(f"can't do that: {e}")
display("press h for help.")
except KeyboardInterrupt:
# Ctrl+C in real use: abandon cleanly, no judgment.
if not box.is_terminal():
box = ctrl.abandon(box, reason="keyboard interrupt")
display("")
display("interrupted. box abandoned - it's okay, come back anytime.")
display("-" * 40)
display(f"box #{box.id} [{box.state.value}]")
return box