Files
AstrBot/astrbot/tui/screen.py
LIghtJUNction a3fbfd3540 feat: SDK integration and various improvements
- Refactor core modules for better SDK integration
- Improve skill manager with better caching and loading
- Update dashboard routes for plugin and tools management
- Fix and enhance computer skill synchronization
- Various bug fixes and test improvements
2026-03-22 23:38:27 +08:00

325 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Curses screen management for AstrBot TUI - Modern Design."""
from __future__ import annotations
import curses
from collections.abc import Callable
from enum import Enum
class ColorPair(Enum):
WHITE = 1
CYAN = 2
GREEN = 3
YELLOW = 4
RED = 5
MAGENTA = 6
DIM = 7
BOLD = 8
HEADER_BG = 9
HEADER_FG = 10
USER_MSG = 11
BOT_MSG = 12
SYSTEM_MSG = 13
INPUT_BG = 14
STATUS_BG = 15
BORDER = 16
_COLOR_MAP = {
ColorPair.WHITE: curses.COLOR_WHITE,
ColorPair.CYAN: curses.COLOR_CYAN,
ColorPair.GREEN: curses.COLOR_GREEN,
ColorPair.YELLOW: curses.COLOR_YELLOW,
ColorPair.RED: curses.COLOR_RED,
ColorPair.MAGENTA: curses.COLOR_MAGENTA,
ColorPair.DIM: curses.COLOR_WHITE,
ColorPair.HEADER_BG: curses.COLOR_BLUE,
ColorPair.HEADER_FG: curses.COLOR_WHITE,
ColorPair.USER_MSG: curses.COLOR_GREEN,
ColorPair.BOT_MSG: curses.COLOR_CYAN,
ColorPair.SYSTEM_MSG: curses.COLOR_YELLOW,
ColorPair.INPUT_BG: curses.COLOR_BLACK,
ColorPair.STATUS_BG: curses.COLOR_BLUE,
ColorPair.BORDER: curses.COLOR_CYAN,
}
# Box drawing characters
BOX_VERT = ""
BOX_HORIZ = ""
BOX_TL = ""
BOX_TR = ""
BOX_BL = ""
BOX_BR = ""
BOX_LT = ""
BOX_RT = ""
BOX_BT = ""
BOX_TT = ""
BOX_CROSS = ""
class Screen:
def __init__(self, stdscr: curses.window):
self.stdscr = stdscr
self.height, self.width = stdscr.getmaxyx()
self._header_height = 1
self._input_height = 2
self._status_height = 1
self._chat_height = (
self.height - self._header_height - self._input_height - self._status_height
)
self._header_win: curses.window | None = None
self._chat_win: curses.window | None = None
self._input_win: curses.window | None = None
self._status_win: curses.window | None = None
self._running = False
self._color_pairs: dict[int, int] = {}
def setup_colors(self) -> None:
curses.start_color()
curses.use_default_colors()
curses.curs_set(1)
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
for i, (name, fg) in enumerate(_COLOR_MAP.items(), start=1):
if name in (ColorPair.HEADER_BG, ColorPair.INPUT_BG, ColorPair.STATUS_BG):
curses.init_pair(i, fg, curses.COLOR_BLACK)
elif name == ColorPair.DIM:
curses.init_pair(i, fg, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i) | curses.A_DIM
elif name == ColorPair.BOLD:
curses.init_pair(i, curses.COLOR_WHITE, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i) | curses.A_BOLD
else:
curses.init_pair(i, fg, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i)
def get_color(self, pair: ColorPair) -> int:
return self._color_pairs.get(pair.value, curses.color_pair(pair.value))
def layout_windows(self) -> None:
self.height, self.width = self.stdscr.getmaxyx()
self._chat_height = max(
1,
self.height
- self._header_height
- self._input_height
- self._status_height,
)
self._header_win = curses.newwin(self._header_height, self.width, 0, 0)
self._header_win.nodelay(True)
self._chat_win = curses.newwin(
self._chat_height, self.width, self._header_height, 0
)
self._chat_win.scrollok(False)
self._chat_win.idlok(True)
self._chat_win.keypad(True)
self._input_win = curses.newwin(
self._input_height, self.width, self._header_height + self._chat_height, 0
)
self._input_win.keypad(True)
self._input_win.timeout(100)
self._status_win = curses.newwin(
self._status_height, self.width, self.height - self._status_height, 0
)
self._status_win.nodelay(True)
self._running = True
@property
def chat_win(self):
return self._chat_win
@property
def input_win(self):
return self._input_win
@property
def status_win(self):
return self._status_win
@property
def header_win(self):
return self._header_win
def draw_header(self) -> None:
if not self._header_win:
return
self._header_win.clear()
title = " AstrBot TUI "
try:
self._header_win.bkgdset(curses.color_pair(ColorPair.HEADER_FG.value))
self._header_win.erase()
title_attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self._header_win.addstr(0, 0, title, title_attr)
if self.width > len(title) + 2:
border_attr = self.get_color(ColorPair.HEADER_FG)
remaining = self.width - len(title)
self._header_win.addstr(
0, len(title), BOX_HORIZ * remaining, border_attr
)
except curses.error:
pass
self._header_win.refresh()
def draw_border_line(self) -> None:
"""Draw the border line between chat and input."""
if not self._chat_win:
return
# Draw bottom border of chat window
try:
border = BOX_TL + BOX_HORIZ * (self.width - 2) + BOX_TR
self._chat_win.addstr(
self._chat_height - 1, 0, border, self.get_color(ColorPair.BORDER)
)
except curses.error:
pass
def draw_chat_log(self, lines: list[tuple[str, str]]) -> None:
if not self._chat_win:
return
self._chat_win.clear()
y = 0
max_y = self._chat_height - 1 # Leave room for border
visible_lines = lines[-max_y:] if len(lines) > max_y else lines
for sender, text in visible_lines:
if y >= max_y:
break
if sender == "user":
indicator = ">"
color = self.get_color(ColorPair.USER_MSG)
elif sender == "bot":
indicator = ""
color = self.get_color(ColorPair.BOT_MSG)
else:
indicator = ""
color = self.get_color(ColorPair.SYSTEM_MSG)
max_text_width = self.width - 4
if max_text_width < 1:
continue
words = text.split()
current_line = ""
lines_buffer = []
for word in words:
test_line = current_line + (" " if current_line else "") + word
if len(test_line) <= max_text_width:
current_line = test_line
else:
if current_line:
lines_buffer.append(current_line)
current_line = word
if current_line:
lines_buffer.append(current_line)
for i, line_text in enumerate(lines_buffer):
if y >= max_y:
break
try:
if i == 0:
self._chat_win.addstr(
y, 0, f"{indicator} ", color | curses.A_BOLD
)
self._chat_win.addstr(y, 2, line_text, color)
else:
self._chat_win.addstr(y, 0, " ", color)
self._chat_win.addstr(y, 2, line_text, color)
except curses.error:
pass
y += 1
self._chat_win.refresh()
def draw_input(self, text: str, cursor_x: int) -> None:
if not self._input_win:
return
self._input_win.clear()
prompt = "> "
prompt_len = 2
max_input_width = self.width - 2
try:
self._input_win.addstr(
0, 0, prompt, curses.color_pair(ColorPair.GREEN.value) | curses.A_BOLD
)
display_text = text[: max_input_width - prompt_len]
self._input_win.addstr(
0, prompt_len, display_text, curses.color_pair(ColorPair.WHITE.value)
)
cursor_pos = min(cursor_x + prompt_len, self.width - 1)
self._input_win.chgat(0, cursor_pos, 1, curses.A_REVERSE)
except curses.error:
pass
self._input_win.refresh()
def draw_status(self, status: str) -> None:
if not self._status_win:
return
self._status_win.clear()
status_text = status[: self.width - 2]
try:
attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self._status_win.addstr(0, 0, " " + status_text, attr)
remaining = self.width - len(status_text) - 1
if remaining > 0:
self._status_win.addstr(0, len(status_text) + 1, " " * remaining, attr)
self._status_win.chgat(0, 0, self.width, attr)
except curses.error:
pass
self._status_win.refresh()
def draw_all(
self, lines: list[tuple[str, str]], input_text: str, cursor_x: int, status: str
) -> None:
self.draw_header()
self.draw_border_line()
self.draw_chat_log(lines)
self.draw_input(input_text, cursor_x)
self.draw_status(status)
def resize(self) -> bool:
self.height, self.width = self.stdscr.getmaxyx()
self.layout_windows()
return True
def clear_status(self) -> None:
if not self._status_win:
return
try:
attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self._status_win.addstr(0, 0, " " * self.width, attr)
self._status_win.refresh()
except curses.error:
pass
def run_curses(main_loop: Callable[[curses.window], None]):
def _main(stdscr: curses.window):
main_loop(stdscr)
curses.wrapper(_main)