agent-todo-tracker/agent_todos/cli_box.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

224 lines
8.0 KiB
Python

"""CLI for the ADHD-friendly time-boxing workflow (issue #16).
Calm, explicit, accessible: every command reports the resulting state in plain
language, interruptions are recoverable, and no one is shamed for switching.
"""
from __future__ import annotations
import json
import math
from typing import Optional
import click
from .box import BoxController
from . import cli_repl
from .store import Store
from .timebox import BoxState
def _mmss(seconds) -> str:
seconds = max(0, int(float(seconds)))
return f"{seconds // 60:02d}:{seconds % 60:02d}"
def _calm_state_phrase(box) -> str:
"""Plain-language description of box state for the user."""
if box.state == BoxState.open:
return "fresh — press start when you're ready"
if box.state == BoxState.running:
return f"focusing — {_mmss(box.remaining_seconds())} left of {_mmss(box.duration_seconds)}"
if box.state == BoxState.paused:
return f"paused with {_mmss(box.remaining_seconds())} left — you can resume anytime"
if box.state == BoxState.interrupted:
return f"interrupted — {_mmss(box.remaining_seconds())} left; resume when the distraction passes"
if box.state == BoxState.completed:
return "completed — nice work"
if box.state == BoxState.abandoned:
return "abandoned — no judgment, come back when you want"
if box.state == BoxState.expired:
return "timer ran out"
return box.state.value
def _format_box(box, title: Optional[str] = None) -> str:
head = f"Box #{box.id}"
if title:
head += f" on \"{title}\""
head += f"{_calm_state_phrase(box)}"
parts = [head]
if box.interruptions:
parts.append(f" interruptions: {len(box.interruptions)}")
for i in box.interruptions:
parts.append(f" - [{i.get('at', '?')[:19]}] {i.get('reason', '(no reason)')}")
if box.reason and box.state in (BoxState.abandoned, BoxState.completed):
parts.append(f" note: {box.reason}")
return "\n".join(parts)
@click.group(name="box")
def box_group() -> None:
"""ADHD-friendly time-boxing (issue #16). Calm focus sessions, recoverable interruptions."""
pass
def _active_box(store: Store):
"""Return the most-recent active box, or raise a friendly ClickException."""
box = store.recover_timebox()
if box is None:
raise click.ClickException(
"no active time-box. run `box start [minutes]` to begin one."
)
return box
def _run(store: Store, fn):
"""Run a controller action and surface ValueError as a friendly ClickException."""
try:
return fn()
except ValueError as e:
raise click.ClickException(str(e))
@box_group.command()
@click.argument("minutes", type=float, required=False, default=25)
@click.option("--todo", "todo_id", type=int, default=None, help="Link this box to an existing TODO id.")
@click.option("--path", default=".agent_todos.db")
def start(minutes: float, todo_id: Optional[int], path: str) -> None:
"""Start a fresh focus box (default 25m)."""
if not (0.5 < minutes <= 240):
raise click.ClickException("minutes must be between 0.5 and 240")
store = Store(db_path=path)
duration = int(math.ceil(minutes * 60))
box = store.create_timebox(
__import__("agent_todos.timebox", fromlist=["TimeBox"]).TimeBox(
id=None, todo_id=todo_id, duration_seconds=duration
)
)
box = _run(store, lambda: BoxController(store).start(box))
click.echo(f"started box #{box.id}{_mmss(box.duration_seconds)} on the clock")
click.echo(_calm_state_phrase(box))
@box_group.command()
@click.option("--path", default=".agent_todos.db")
def status(path: str) -> None:
"""Show the active box and its countdown."""
store = Store(db_path=path)
box = _active_box(store)
click.echo(_format_box(box))
@box_group.command()
@click.option("--path", default=".agent_todos.db")
def pause(path: str) -> None:
"""Pause the active box. Time stops; nothing is lost."""
store = Store(db_path=path)
box = _active_box(store)
box = _run(store, lambda: BoxController(store).pause(box))
click.echo(f"paused box #{box.id}{_mmss(box.remaining_seconds())} saved")
@box_group.command()
@click.option("--path", default=".agent_todos.db")
def resume(path: str) -> None:
"""Resume a paused or interrupted box."""
store = Store(db_path=path)
box = _active_box(store)
box = _run(store, lambda: BoxController(store).resume(box))
click.echo(f"resumed box #{box.id}{_mmss(box.remaining_seconds())} to go")
@box_group.command()
@click.argument("reason")
@click.option("--path", default=".agent_todos.db")
def interrupt(reason: str, path: str) -> None:
"""Interrupt the active box with a reason (recoverable)."""
store = Store(db_path=path)
box = _active_box(store)
box = _run(store, lambda: BoxController(store).interrupt(box, reason=reason))
click.echo(f"interrupted box #{box.id} — saved {_mmss(box.remaining_seconds())} for later")
click.echo(f" reason: {reason}")
@box_group.command()
@click.option("--reason", default="", help="Optional note about how it went.")
@click.option("--path", default=".agent_todos.db")
def complete(reason: str, path: str) -> None:
"""Mark the active box complete."""
store = Store(db_path=path)
box = _active_box(store)
box = _run(store, lambda: BoxController(store).complete(box))
elapsed = box.elapsed_seconds()
click.echo(f"completed box #{box.id} after {_mmss(elapsed)} — well done")
if reason:
click.echo(f" note: {reason}")
@box_group.command()
@click.option("--reason", default="", help="Optional note about why.")
@click.option("--path", default=".agent_todos.db")
def abandon(reason: str, path: str) -> None:
"""Abandon the active box. No judgment."""
store = Store(db_path=path)
box = _active_box(store)
box = BoxController(store).abandon(box, reason=reason or "")
click.echo(f"abandoned box #{box.id}. it's okay — come back when you want.")
@box_group.command()
@click.option("--path", default=".agent_todos.db")
def recover(path: str) -> None:
"""Recover a box from a previous session (after restart/crash)."""
store = Store(db_path=path)
box = store.recover_timebox()
if box is None:
click.echo("nothing to recover — start a new box with `box start`")
return
click.echo(f"recovered box #{box.id}")
click.echo(_format_box(box))
@box_group.command(name="list")
@click.option("--all", "show_all", is_flag=True, help="Include completed/abandoned boxes too.")
@click.option("--path", default=".agent_todos.db")
def list_boxes(show_all: bool, path: str) -> None:
"""List time-boxes."""
store = Store(db_path=path)
boxes = store.list_timeboxes(active_only=not show_all)
if not boxes:
click.echo("no boxes yet — run `box start` to create one")
return
for b in boxes:
click.echo(f"#{b.id} [{b.state.value}] {_mmss(b.duration_seconds)} box "
f"{_mmss(b.elapsed_seconds())} used, {_mmss(b.remaining_seconds())} left")
@box_group.command()
@click.option("--path", default=".agent_todos.db")
def focus(path: str) -> None:
"""Enter the live focus loop for the active box (or start a fresh one).
A calm, single-key REPL: p pause · r resume · i <why> interrupt ·
c complete · q quit · h help. Auto-resumes a paused box; auto-starts a
fresh one if none exists.
"""
import sys
store = Store(db_path=path)
box = store.recover_timebox()
if box is None:
box = store.create_timebox(
__import__("agent_todos.timebox", fromlist=["TimeBox"]).TimeBox(
id=None, todo_id=None, duration_seconds=1500
)
)
def _input(timeout):
import select
ready, _, _ = select.select([sys.stdin], [], [], timeout)
if ready:
return sys.stdin.readline().rstrip("\n")
return None
cli_repl.run_repl(store, box, wait_input=_input, display=click.echo, tick_interval=1.0)