Coverage for src/lilbee/cli/tui/widgets/fork_picker.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Modal that picks the answer a fork of the conversation ends on."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.binding import Binding, BindingType
10from textual.containers import Vertical
11from textual.content import Content
12from textual.screen import ModalScreen
13from textual.widgets import OptionList, Static
14from textual.widgets.option_list import Option
16from lilbee.cli.tui import messages as msg
17from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
18from lilbee.sessions import MessageRole, SessionMessage, derive_title
21@dataclass(frozen=True, slots=True)
22class ForkPoint:
23 """A place to fork: after one answer, with the question it answers if there is one."""
25 message_count: int
26 answer: str
27 question: str | None
30def fork_points(messages: tuple[SessionMessage, ...]) -> list[ForkPoint]:
31 """One point after each answer in the saved log, newest first."""
32 points: list[ForkPoint] = []
33 question: str | None = None
34 for index, message in enumerate(messages):
35 if message.role == MessageRole.USER:
36 question = message.content
37 else:
38 points.append(ForkPoint(index + 1, message.content, question))
39 return points[::-1]
42def _label(point: ForkPoint) -> Content:
43 """The answer's first line, and under it the question it answers."""
44 answer = Content(derive_title(point.answer))
45 if point.question is None:
46 return answer
47 question = msg.FORK_PICKER_ANSWER_TO.format(question=derive_title(point.question))
48 return Content.assemble(answer, "\n", Content.styled(question, "$text-muted"))
51class ForkPicker(ModalScreen[int | None]):
52 """Dismisses with the number of leading messages to copy, or ``None`` on escape."""
54 CSS_PATH = "fork_picker.tcss"
56 BINDINGS: ClassVar[list[BindingType]] = [
57 Binding("escape", "cancel", "Close", show=True),
58 ]
60 def __init__(self, points: list[ForkPoint]) -> None:
61 super().__init__()
62 self._points = points
64 def compose(self) -> ComposeResult:
65 with Vertical(id="fork-root"):
66 yield Static(msg.FORK_PICKER_TITLE, id="fork-title")
67 yield ClampedOptionList(
68 *(Option(_label(point)) for point in self._points), id="fork-list"
69 )
70 yield Static(msg.FORK_PICKER_HINT, id="fork-hint")
72 def on_mount(self) -> None:
73 self.query_one("#fork-list", OptionList).focus()
75 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
76 event.stop()
77 self.dismiss(self._points[event.option_index].message_count)
79 def action_cancel(self) -> None:
80 self.dismiss(None)