"""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 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)