Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 8 additions & 25 deletions src/lilbee/cli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sys
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path, PurePath
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
from typing import TYPE_CHECKING, Any, ClassVar, cast

from rich.console import Console
from textual import work
Expand All @@ -22,7 +22,6 @@
from textual.reactive import reactive
from textual.screen import Screen
from textual.signal import Signal
from textual.widget import Widget
from textual.widgets import Input, TextArea

from lilbee.app.services import get_services, peek_services
Expand All @@ -41,6 +40,7 @@
from lilbee.cli.tui.commands import LilbeeCommandProvider
from lilbee.cli.tui.screens.command_palette import LilbeeCommandPalette
from lilbee.cli.tui.thread_safe import call_from_thread
from lilbee.cli.tui.widgets.drawer import first_direct_child
from lilbee.cli.tui.widgets.status_bar import ViewTabs
from lilbee.config_meta import MODEL_ROLE_FIELDS
from lilbee.core.config import cfg
Expand Down Expand Up @@ -133,23 +133,6 @@ def get_views() -> dict[str, Callable[[], Screen]]:
return {name: _VIEW_FACTORIES[name] for name in msg.get_nav_views() if name in _VIEW_FACTORIES}


_ChildT = TypeVar("_ChildT", bound=Widget)


def _first_direct_child(node: Widget, widget_type: type[_ChildT]) -> _ChildT | None:
"""The first immediate child of *node* that is a *widget_type*, or None.

A drawer mounts directly on the screen, so this answers "is a drawer
open" without ``screen.query()``'s whole-subtree walk. check_action
calls this on every focus change across the whole app, so the walk's
cost multiplies with the size of whichever screen is on top.
"""
for child in node.children:
if isinstance(child, widget_type):
return child
return None


class LilbeeApp(App[None]):
"""Full-screen TUI for lilbee knowledge base."""

Expand Down Expand Up @@ -878,7 +861,7 @@ def _shows_placement_full_screen(self) -> bool:
"""
from lilbee.cli.tui.widgets.fleet_body import FleetBody

return _first_direct_child(self.screen, FleetBody) is not None
return first_direct_child(self.screen, FleetBody) is not None

def _shows_sessions_full_screen(self) -> bool:
"""True when the current screen hosts a SessionListPanel directly, not via the drawer.
Expand All @@ -887,7 +870,7 @@ def _shows_sessions_full_screen(self) -> bool:
"""
from lilbee.cli.tui.widgets.session_list import SessionListPanel

return _first_direct_child(self.screen, SessionListPanel) is not None
return first_direct_child(self.screen, SessionListPanel) is not None

def _toggle_fleet_is_noop(self) -> bool:
"""True when ctrl+g would do nothing, mirroring the action's own order.
Expand All @@ -898,15 +881,15 @@ def _toggle_fleet_is_noop(self) -> bool:
"""
from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer

if _first_direct_child(self.screen, FleetDrawer) is not None:
if first_direct_child(self.screen, FleetDrawer) is not None:
return False
return self._shows_placement_full_screen()

def _toggle_sessions_is_noop(self) -> bool:
"""True when ctrl+o would do nothing. See :meth:`_toggle_fleet_is_noop`."""
from lilbee.cli.tui.widgets.sessions_drawer import SessionsDrawer

if _first_direct_child(self.screen, SessionsDrawer) is not None:
if first_direct_child(self.screen, SessionsDrawer) is not None:
return False
return self._shows_sessions_full_screen()

Expand All @@ -916,7 +899,7 @@ def action_toggle_fleet(self) -> None:
already shows the full placement editor."""
from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer

drawer = _first_direct_child(self.screen, FleetDrawer)
drawer = first_direct_child(self.screen, FleetDrawer)
if drawer is not None:
drawer.remove()
return
Expand Down Expand Up @@ -944,7 +927,7 @@ async def action_toggle_sessions(self) -> None:
return
from lilbee.cli.tui.widgets.sessions_drawer import SessionsDrawer

drawer = _first_direct_child(self.screen, SessionsDrawer)
drawer = first_direct_child(self.screen, SessionsDrawer)
if drawer is not None:
drawer.remove()
return
Expand Down
7 changes: 3 additions & 4 deletions src/lilbee/cli/tui/screens/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
)
from lilbee.cli.tui.widgets.chat_input import ChatInput
from lilbee.cli.tui.widgets.context_chip import ContextChip
from lilbee.cli.tui.widgets.drawer import drawer_holding
from lilbee.cli.tui.widgets.drawer import drawer_holding, first_direct_child
from lilbee.cli.tui.widgets.fleet_body import FleetBody
from lilbee.cli.tui.widgets.fleet_drawer import FleetDrawer
from lilbee.cli.tui.widgets.fork_picker import ForkPicker, fork_points
Expand Down Expand Up @@ -2581,11 +2581,10 @@ def _tab_into_fleet_or_next(self) -> None:
"""Jump Tab into the open Fleet drawer's first toggle so the placement
editor is reachable without tabbing past every widget; once focus is
inside the drawer, Tab cycles within it as usual."""
drawers = self.screen.query(FleetDrawer)
if not drawers:
drawer = first_direct_child(self.screen, FleetDrawer)
if drawer is None:
self.screen.focus_next()
return
drawer = drawers.first()
focused = self.screen.focused
inside = focused is not None and drawer in focused.ancestors_with_self
toggles = drawer.query(".dev-toggle")
Expand Down
19 changes: 18 additions & 1 deletion src/lilbee/cli/tui/widgets/drawer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Protocol, runtime_checkable
from typing import Protocol, TypeVar, runtime_checkable

from textual.await_remove import AwaitRemove
from textual.containers import Vertical
Expand Down Expand Up @@ -66,3 +66,20 @@ def drawer_holding(widget: Widget | None) -> Drawer | None:
if widget is None:
return None
return next((node for node in widget.ancestors_with_self if isinstance(node, Drawer)), None)


_ChildT = TypeVar("_ChildT", bound=Widget)


def first_direct_child(node: Widget, widget_type: type[_ChildT]) -> _ChildT | None:
"""The first immediate child of *node* that is a *widget_type*, or None.

A drawer mounts directly on the screen, so this answers "is a drawer
open" without ``screen.query()``'s whole-subtree walk. check_action
calls this on every focus change across the whole app, so the walk's
cost multiplies with the size of whichever screen is on top.
"""
for child in node.children:
if isinstance(child, widget_type):
return child
return None
6 changes: 3 additions & 3 deletions tests/test_tui_settings_perf.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async def test_first_direct_child_finds_only_immediate_children():
from textual.containers import Vertical
from textual.widgets import Static

from lilbee.cli.tui.app import _first_direct_child
from lilbee.cli.tui.widgets.drawer import first_direct_child

class _ProbeApp(App[None]):
def compose(self) -> ComposeResult:
Expand All @@ -124,8 +124,8 @@ def compose(self) -> ComposeResult:
outer = app.query_one("#outer", Vertical)
inner = outer.query_one(Vertical)

assert _first_direct_child(outer, Vertical) is inner
assert _first_direct_child(outer, Static) is None
assert first_direct_child(outer, Vertical) is inner
assert first_direct_child(outer, Static) is None


def test_stable_footer_skips_when_app_not_focused():
Expand Down
Loading