Coverage for src/lilbee/cli/tui/widgets/arg_hint.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Status rows that mirror the active slash command's signature, and the analyze tip for /add."""
3from __future__ import annotations
5import textwrap
6from pathlib import Path
7from typing import ClassVar
9from textual.content import Content
10from textual.widgets import Static
12from lilbee.app.analyze import tip_shows
13from lilbee.cli.tui import messages as msg
14from lilbee.cli.tui.command_registry import COMMANDS
15from lilbee.cli.tui.thread_safe import call_from_thread
16from lilbee.core.config import cfg
18_CSS_FILE = Path(__file__).parent / "arg_hint.tcss"
20_REGISTRY = {cmd.name: cmd for cmd in COMMANDS}
21for _cmd in COMMANDS:
22 for _alias in _cmd.aliases:
23 _REGISTRY[_alias] = _cmd
25TIP_COMMAND = "/add"
26_TIP_GROUP = "analyze-tip"
27_TIP_GAP = 2 # cells between the tip label and its text
28_TIP_MIN_WIDTH = 20 # before the first layout the width reads 0
31class ArgHintLine(Static):
32 """Reactive hint that mirrors the active slash command's signature.
34 Under ``/add`` it adds the analyze tip while the project should see it, before ingest starts.
35 """
37 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
39 def __init__(self, *, id: str | None = None) -> None:
40 super().__init__("", id=id)
41 self.display = False
42 self._text = ""
43 self._tip = False
45 def update_for_input(self, text: str) -> None:
46 """Render the appropriate hint for the current chat input contents."""
47 if _command(text) == TIP_COMMAND and _command(self._text) != TIP_COMMAND:
48 self.refresh_tip()
49 self._text = text
50 self._render_hint()
52 def refresh_tip(self) -> None:
53 """Re-read off the loop whether the project should see the analyze tip."""
54 self.run_worker(self._read_tip, thread=True, group=_TIP_GROUP, exclusive=True)
56 def _read_tip(self) -> None:
57 call_from_thread(self, self._set_tip, tip_shows(cfg.data_root))
59 def _set_tip(self, shows: bool) -> None:
60 self._tip = shows
61 self._render_hint()
63 def on_resize(self) -> None:
64 """Wrap the tip again for the new width."""
65 if self._tip:
66 self._render_hint()
68 def _render_hint(self) -> None:
69 rendered = _hint_for(self._text)
70 if rendered is None:
71 self.update("")
72 self.display = False
73 return
74 if self._tip and _command(self._text) == TIP_COMMAND:
75 rendered = Content("\n").join([rendered, _tip_lines(self.content_size.width)])
76 self.update(rendered)
77 self.display = True
80def _command(text: str) -> str | None:
81 """The slash command *text* starts with, once a space ends the command name."""
82 command, space, _argument = text.partition(" ")
83 if not command.startswith("/") or not space:
84 return None
85 return command.lower()
88def _tip_lines(width: int) -> Content:
89 """The tip in *width* cells, each wrapped line indented under the first line's text."""
90 label = f" {msg.ANALYZE_TIP_LABEL}"
91 indent = " " * (len(label) + _TIP_GAP)
92 wrapped = textwrap.wrap(msg.ANALYZE_TIP, width=max(width - len(indent), _TIP_MIN_WIDTH))
93 first, *rest = wrapped
94 lines = [
95 Content.assemble(
96 Content.styled(label, "$accent"), Content.styled(" " * _TIP_GAP + first, "$text-muted")
97 ),
98 *(Content.styled(indent + line, "$text-muted") for line in rest),
99 ]
100 return Content("\n").join(lines)
103def _hint_for(text: str) -> Content | None:
104 """Build the hint content for *text*, or ``None`` when nothing should show."""
105 command = _command(text)
106 cmd = None if command is None else _REGISTRY.get(command)
107 if cmd is None:
108 return None
110 name_part = Content.styled(f" {cmd.name}", "$success")
111 args_part = Content.styled(f" {cmd.args_hint}", "$text-muted") if cmd.args_hint else Content("")
112 sep = Content.styled(" · ", "$text-muted")
113 help_part = Content.styled(cmd.help_text, "$text-muted")
114 return Content.assemble(name_part, args_part, sep, help_part)