From ac7d57bfeb4fc64e32b4b53378804cae48aa0c68 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 11 Jun 2026 08:41:39 -0400 Subject: [PATCH] feat: add delayed prompt modal --- src/hqt/tui/screens/schedule_prompt.py | 67 +++++++++++++++++++++++ src/hqt/tui/styles.tcss | 9 +++- tests/test_tui.py | 74 ++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/hqt/tui/screens/schedule_prompt.py diff --git a/src/hqt/tui/screens/schedule_prompt.py b/src/hqt/tui/screens/schedule_prompt.py new file mode 100644 index 0000000..2a7c574 --- /dev/null +++ b/src/hqt/tui/screens/schedule_prompt.py @@ -0,0 +1,67 @@ +import re +from datetime import timedelta + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label + + +_DELAY_RE = re.compile( + r"^(?:(?P\d+)h)?(?:(?P\d+)m)?(?:(?P\d+)s)?$" +) + + +def parse_delay(value: str) -> timedelta: + match = _DELAY_RE.fullmatch(value) + if match is None or not value: + raise ValueError("invalid delay") + + total_seconds = ( + int(match.group("hours") or 0) * 3600 + + int(match.group("minutes") or 0) * 60 + + int(match.group("seconds") or 0) + ) + if total_seconds <= 0: + raise ValueError("invalid delay") + return timedelta(seconds=total_seconds) + + +class SchedulePromptScreen(ModalScreen[tuple[str, timedelta] | None]): + def compose(self) -> ComposeResult: + with Vertical(id="schedule-prompt-dialog"): + yield Label("Schedule Prompt") + yield Label("Prompt:") + yield Input(placeholder="prompt to send later", id="prompt-input") + yield Label("Delay:") + yield Input(placeholder="30m", id="delay-input") + yield Label("", id="schedule-error") + with Horizontal(classes="dialog-actions"): + yield Button("OK", variant="primary", id="ok-btn") + yield Button("Cancel", id="cancel-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "ok-btn": + self._submit() + else: + self.dismiss(None) + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._submit() + + def _submit(self) -> None: + prompt = self.query_one("#prompt-input", Input).value.strip() + if not prompt: + self._set_error("Prompt cannot be empty") + return + + try: + delay = parse_delay(self.query_one("#delay-input", Input).value.strip()) + except ValueError: + self._set_error("Use a delay like 90s, 30m, 2h, or 1h30m") + return + + self.dismiss((prompt, delay)) + + def _set_error(self, message: str) -> None: + self.query_one("#schedule-error", Label).update(message) diff --git a/src/hqt/tui/styles.tcss b/src/hqt/tui/styles.tcss index 2b5ad03..359a427 100644 --- a/src/hqt/tui/styles.tcss +++ b/src/hqt/tui/styles.tcss @@ -23,11 +23,11 @@ SessionList { background: $background; } -ProjectFormScreen, NewSessionScreen { +ProjectFormScreen, NewSessionScreen, SchedulePromptScreen { align: center middle; } -#project-form-dialog, #new-session-dialog { +#project-form-dialog, #new-session-dialog, #schedule-prompt-dialog { width: 60; height: auto; padding: 1 2; @@ -35,6 +35,11 @@ ProjectFormScreen, NewSessionScreen { border: solid $border; } +#schedule-error { + color: $error; + height: 1; +} + /* Action row: flat, single-row buttons in a centered row. Textual's default Button uses `tall` top/bottom borders, which force a 3-row beveled "block" that reads as heavy and dated. Dropping the bevel (border: none) collapses diff --git a/tests/test_tui.py b/tests/test_tui.py index 5ee64a4..d813ebd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1050,6 +1050,80 @@ async def test_session_list_renders_harness_name_brackets(): assert any("[claude]" in t for t in texts), f"harness name missing: {texts}" +# --------------------------------------------------------------------------- +# SchedulePromptScreen: parse + submit +# --------------------------------------------------------------------------- + + +def test_parse_delay_accepts_compact_seconds_minutes_hours(): + from datetime import timedelta + + from hqt.tui.screens.schedule_prompt import parse_delay + + assert parse_delay("90s") == timedelta(seconds=90) + assert parse_delay("30m") == timedelta(minutes=30) + assert parse_delay("2h") == timedelta(hours=2) + assert parse_delay("1h30m") == timedelta(hours=1, minutes=30) + + +@pytest.mark.parametrize("value", ["", "0m", "abc", "1d", "1h 30m"]) +def test_parse_delay_rejects_invalid_values(value): + from hqt.tui.screens.schedule_prompt import parse_delay + + with pytest.raises(ValueError): + parse_delay(value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("submit_action", ["enter", "ok"]) +async def test_schedule_prompt_screen_submits_prompt_and_delay(submit_action): + from datetime import timedelta + + from hqt.tui.screens.schedule_prompt import SchedulePromptScreen + from textual.widgets import Button, Input + + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + results = [] + app.push_screen(SchedulePromptScreen(), results.append) + await pilot.pause() + + prompt = app.screen.query_one("#prompt-input", Input) + prompt.value = "continue work" + app.screen.query_one("#delay-input", Input).value = "1h30m" + prompt.focus() + await pilot.pause() + if submit_action == "enter": + await pilot.press("enter") + else: + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + + assert results == [("continue work", timedelta(hours=1, minutes=30))] + + +@pytest.mark.asyncio +async def test_schedule_prompt_screen_invalid_delay_stays_open_with_error(): + from hqt.tui.screens.schedule_prompt import SchedulePromptScreen + from textual.widgets import Button, Input, Label + + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + results = [] + app.push_screen(SchedulePromptScreen(), results.append) + await pilot.pause() + + app.screen.query_one("#prompt-input", Input).value = "continue work" + app.screen.query_one("#delay-input", Input).value = "1h 30m" + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + + assert isinstance(app.screen, SchedulePromptScreen) + assert results == [] + error = app.screen.query_one("#schedule-error", Label) + assert "Use a delay like" in str(error.render()) + + # --------------------------------------------------------------------------- # RenameSessionScreen: prefill + submit # ---------------------------------------------------------------------------