Coverage for src / lilbee / cli / tui / __init__.py: 100%

58 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +0000

1"""Textual TUI for lilbee -- full-screen interactive knowledge base.""" 

2 

3from __future__ import annotations 

4 

5import io 

6import logging 

7import os 

8import sys 

9from dataclasses import dataclass 

10from pathlib import Path 

11 

12from lilbee.app.services import reset_services 

13from lilbee.cli.tui.log_routing import setup_tui_log_file 

14 

15 

16def _silence_stderr_log_handlers() -> None: 

17 """Drop stderr/stdout-streaming log handlers before mounting the TUI. (bb-82ce) 

18 

19 lilbee logs route to ``cfg.data_root/logs/tui.log`` for the duration of 

20 the TUI session via :func:`setup_tui_log_file`. FileHandler instances 

21 are skipped explicitly here so that file route survives. 

22 """ 

23 root = logging.getLogger() 

24 for handler in list(root.handlers): 

25 if not isinstance(handler, logging.StreamHandler): 

26 continue 

27 if isinstance(handler, logging.FileHandler): 

28 continue 

29 if handler.stream in (sys.stderr, sys.stdout): 

30 root.removeHandler(handler) 

31 

32 

33@dataclass 

34class _StderrRedirect: 

35 """State captured by ``_redirect_native_stderr_to`` for restoration on exit.""" 

36 

37 saved_fd: int 

38 saved_sys_stderr: object 

39 saved_sys_dunder_stderr: object 

40 

41 

42def _redirect_native_stderr_to(log_path: Path) -> _StderrRedirect | None: 

43 """Send native fd-2 writes to *log_path* without breaking Textual's render. 

44 

45 Textual's Linux/macOS driver writes its alternate-screen ANSI to 

46 ``sys.__stderr__`` (see 

47 ``textual.drivers.linux_driver.LinuxDriver.write``). Native deps 

48 like kreuzberg's vendored tesseract write directly to fd 2 and leak 

49 onto the same buffer, e.g. "Detected N diacritics", which corrupts 

50 the TUI. 

51 

52 Strategy: dup the original fd 2 to a saved fd, repoint 

53 ``sys.__stderr__`` and ``sys.stderr`` at that saved fd so Textual 

54 keeps drawing to the real terminal, then dup2 fd 2 itself to the 

55 log file so any fd-2 writer (kreuzberg, tesseract, poppler) lands 

56 in ``tui.log`` instead of on top of the screen. 

57 """ 

58 try: 

59 log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) 

60 except OSError: 

61 return None 

62 saved_fd = os.dup(2) 

63 terminal_stderr = io.TextIOWrapper( 

64 io.FileIO(saved_fd, "w", closefd=False), 

65 encoding=sys.stderr.encoding or "utf-8", 

66 errors="replace", 

67 write_through=True, 

68 ) 

69 saved_sys_stderr = sys.stderr 

70 saved_sys_dunder_stderr = sys.__stderr__ 

71 sys.stderr = terminal_stderr 

72 sys.__stderr__ = terminal_stderr # type: ignore[misc] 

73 os.dup2(log_fd, 2) 

74 os.close(log_fd) 

75 return _StderrRedirect( 

76 saved_fd=saved_fd, 

77 saved_sys_stderr=saved_sys_stderr, 

78 saved_sys_dunder_stderr=saved_sys_dunder_stderr, 

79 ) 

80 

81 

82def _restore_native_stderr(redirect: _StderrRedirect | None) -> None: 

83 """Undo ``_redirect_native_stderr_to``; safe to call when *redirect* is None.""" 

84 if redirect is None: 

85 return 

86 os.dup2(redirect.saved_fd, 2) 

87 os.close(redirect.saved_fd) 

88 sys.stderr = redirect.saved_sys_stderr # type: ignore[assignment] 

89 sys.__stderr__ = redirect.saved_sys_dunder_stderr # type: ignore[misc,assignment] 

90 

91 

92def run_tui(*, initial_view: str | None = None) -> None: 

93 """Launch the full-screen Textual TUI. 

94 

95 *initial_view* deep-links to a named view (e.g. ``"Catalog"``) after 

96 the default chat screen is mounted. Used by ``lilbee model browse``. 

97 """ 

98 # heavy: cli.sync transitively imports ingest -> store -> pyarrow (~1.8s 

99 # cold-start on bare runners); only needed at TUI shutdown. (bb-oae5) 

100 from lilbee.cli.sync import shutdown_executor 

101 from lilbee.cli.tui.app import LilbeeApp 

102 

103 log_path = setup_tui_log_file() 

104 _silence_stderr_log_handlers() 

105 stderr_redirect = _redirect_native_stderr_to(log_path) 

106 

107 app = LilbeeApp(initial_view=initial_view) 

108 try: 

109 app.run() 

110 except KeyboardInterrupt: 

111 pass # Ctrl-C exits the TUI; cleanup runs in the finally block 

112 finally: 

113 _restore_native_stderr(stderr_redirect) 

114 shutdown_executor() 

115 reset_services()