feat: add delayed prompt modal
This commit is contained in:
@@ -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<hours>\d+)h)?(?:(?P<minutes>\d+)m)?(?:(?P<seconds>\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)
|
||||||
@@ -23,11 +23,11 @@ SessionList {
|
|||||||
background: $background;
|
background: $background;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectFormScreen, NewSessionScreen {
|
ProjectFormScreen, NewSessionScreen, SchedulePromptScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#project-form-dialog, #new-session-dialog {
|
#project-form-dialog, #new-session-dialog, #schedule-prompt-dialog {
|
||||||
width: 60;
|
width: 60;
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
@@ -35,6 +35,11 @@ ProjectFormScreen, NewSessionScreen {
|
|||||||
border: solid $border;
|
border: solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#schedule-error {
|
||||||
|
color: $error;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Action row: flat, single-row buttons in a centered row. Textual's default
|
/* 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"
|
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
|
that reads as heavy and dated. Dropping the bevel (border: none) collapses
|
||||||
|
|||||||
@@ -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}"
|
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
|
# RenameSessionScreen: prefill + submit
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user