- 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
224 lines
8.0 KiB
Python
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)
|