+
+
+
[1] 首页
+
+
+
+
+
+
+```
+
+### 3. 支持的 CSS 属性
+
+TUI 只支持终端能够渲染的样式:
+
+| CSS 属性 | TUI 转换 | 说明 |
+|---------|---------|------|
+| `font-weight: bold` | ANSI 加粗 | `\x1b[1m` |
+| `font-style: italic` | ANSI 斜体 | `\x1b[3m` |
+| `text-decoration: underline` | ANSI 下划线 | `\x1b[4m` |
+| `background-color` | ANSI 背景色 | 仅支持基础 8 色 |
+| `color` | ANSI 前景色 | 仅支持基础 8 色 |
+| `text-align` | 文本对齐 | left/center/right |
+
+### 4. 支持的 JS 交互
+
+TUI 只支持基础的终端交互:
+
+- **鼠标位置** - 通过 ANSI 鼠标协议获取
+- **点击事件** - 转换为选择操作
+- **按键输入** - 完整的键盘支持
+
+```javascript
+// TUI 配置中的键盘映射
+{
+ "keyboard": {
+ "1": {"action": "navigate", "target": "/"},
+ "ArrowUp": {"action": "navigate_up"},
+ "Enter": {"action": "select"},
+ "q": {"action": "quit"}
+ }
+}
+```
+
+## 文件结构
+
+```
+webui/tui/
+├── __init__.py # 包初始化
+├── main.py # TUI 插件主程序
+├── converter.py # HTML 到 TUI 转换层
+├── index.html # TUI 入口页面(含特殊标记)
+├── manifest.json # 插件清单
+└── README.md # 本文档
+```
+
+## 使用方式
+
+### 启动 NebulaShell
+
+```bash
+# 正常启动,WebUI 和 TUI 会同时运行
+python main.py serve
+
+# 或通过 CLI
+python -m oss.cli serve
+```
+
+### TUI 快捷键
+
+| 按键 | 功能 |
+|-----|------|
+| `1` | 首页 |
+| `2` | 仪表盘 |
+| `3` | 日志 |
+| `4` | 终端 |
+| `5` | 插件 |
+| `6` | 设置 |
+| `r` | 刷新 |
+| `h` | 帮助 |
+| `↑/↓` | 上下导航 |
+| `Enter` | 确认 |
+| `q` | 退出 TUI |
+
+## 开发指南
+
+### 创建 TUI 兼容页面
+
+1. 在 WebUI 插件中创建页面时,添加 TUI 标记
+2. 使用 `data-tui-*` 属性定义交互行为
+3. 在 `
+
+
+ ''',
+ nav_item={'icon': 'ri-star-line', 'text': '我的页面'}
+ )
+```
+
+## 技术细节
+
+### 转换流程
+
+1. TUI 插件启动时访问 `/tui/index.html`
+2. `HTMLToTUIConverter` 解析 HTML 提取:
+ - 文本内容
+ - 按钮和链接
+ - TUI 配置(键盘映射、样式)
+3. `TUIRenderer` 将元素渲染为 ANSI 转义序列
+4. `TUICanvas` 管理终端显示缓冲区
+5. `TUIInputHandler` 处理键盘/鼠标输入
+
+### ANSI 颜色映射
+
+```python
+COLOR_MAP = {
+ '#000000': '\x1b[30m', # black
+ '#ff0000': '\x1b[31m', # red
+ '#00ff00': '\x1b[32m', # green
+ '#ffff00': '\x1b[33m', # yellow
+ '#0000ff': '\x1b[34m', # blue
+ '#ff00ff': '\x1b[35m', # magenta
+ '#00ffff': '\x1b[36m', # cyan
+ '#ffffff': '\x1b[37m', # white
+}
+```
+
+## 许可证
+
+MIT License - NebulaShell Project
diff --git a/store/@{NebulaShell}/webui/tui/__init__.py b/store/@{NebulaShell}/webui/tui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/store/@{NebulaShell}/webui/tui/converter.py b/store/@{NebulaShell}/webui/tui/converter.py
new file mode 100644
index 0000000..55384e4
--- /dev/null
+++ b/store/@{NebulaShell}/webui/tui/converter.py
@@ -0,0 +1,1063 @@
+"""TUI 转换层 - 强大的 WebUI 到终端界面转换引擎
+
+本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力:
+- HTML 解析:识别 data-tui-* 标记,转换为终端元素
+- CSS 转换:仅支持终端兼容样式(ANSI 颜色、字体样式、边框)
+- JS 交互:模拟鼠标位置、点击事件、键盘绑定
+- 布局引擎:支持 flex/grid 布局的终端适配
+- 组件系统:按钮、面板、列表、表单等终端组件
+
+架构设计参考 opencode 风格,提供现代化终端体验。
+"""
+import re
+import json
+import html
+from pathlib import Path
+from typing import Dict, List, Any, Optional, Callable, Tuple
+from dataclasses import dataclass, field
+from enum import Enum
+import os
+import sys
+
+
+class TUIElementType(Enum):
+ """TUI 元素类型"""
+ CONTAINER = "container"
+ PANEL = "panel"
+ BUTTON = "button"
+ LABEL = "label"
+ INPUT = "input"
+ LIST = "list"
+ LIST_ITEM = "list_item"
+ SEPARATOR = "separator"
+ HEADER = "header"
+ FOOTER = "footer"
+ NAV = "nav"
+ TABLE = "table"
+ PROGRESS = "progress"
+ SPINNER = "spinner"
+
+
+class ANSIStyle:
+ """ANSI 样式常量"""
+ RESET = '\x1b[0m'
+ BOLD = '\x1b[1m'
+ DIM = '\x1b[2m'
+ ITALIC = '\x1b[3m'
+ UNDERLINE = '\x1b[4m'
+ BLINK = '\x1b[5m'
+ REVERSE = '\x1b[7m'
+ HIDDEN = '\x1b[8m'
+
+ # 前景色
+ FG_BLACK = '\x1b[30m'
+ FG_RED = '\x1b[31m'
+ FG_GREEN = '\x1b[32m'
+ FG_YELLOW = '\x1b[33m'
+ FG_BLUE = '\x1b[34m'
+ FG_MAGENTA = '\x1b[35m'
+ FG_CYAN = '\x1b[36m'
+ FG_WHITE = '\x1b[37m'
+ FG_DEFAULT = '\x1b[39m'
+
+ # 背景色
+ BG_BLACK = '\x1b[40m'
+ BG_RED = '\x1b[41m'
+ BG_GREEN = '\x1b[42m'
+ BG_YELLOW = '\x1b[43m'
+ BG_BLUE = '\x1b[44m'
+ BG_MAGENTA = '\x1b[45m'
+ BG_CYAN = '\x1b[46m'
+ BG_WHITE = '\x1b[47m'
+ BG_DEFAULT = '\x1b[49m'
+
+ # 256 色支持
+ @staticmethod
+ def fg_256(color: int) -> str:
+ return f'\x1b[38;5;{color}m'
+
+ @staticmethod
+ def bg_256(color: int) -> str:
+ return f'\x1b[48;5;{color}m'
+
+
+class BorderStyle:
+ """边框样式"""
+ NONE = ("", "", "", "", "", "", "", "")
+ SINGLE = ("┌", "─", "┐", "│", "│", "└", "─", "┘")
+ DOUBLE = ("╔", "═", "╗", "║", "║", "╚", "═", "╝")
+ ROUNDED = ("╭", "─", "╮", "│", "│", "╰", "─", "╯")
+ BOLD = ("┏", "━", "┓", "┃", "┃", "┗", "━", "┛")
+ ASCII = ("+", "-", "+", "|", "|", "+", "-", "+")
+
+
+@dataclass
+class TUIStyle:
+ """TUI 样式"""
+ fg_color: str = ""
+ bg_color: str = ""
+ bold: bool = False
+ dim: bool = False
+ underline: bool = False
+ italic: bool = False
+ reverse: bool = False
+
+ def apply(self, text: str) -> str:
+ """应用样式到文本"""
+ result = text
+ if self.bold:
+ result = f"{ANSIStyle.BOLD}{result}"
+ if self.dim:
+ result = f"{ANSIStyle.DIM}{result}"
+ if self.underline:
+ result = f"{ANSIStyle.UNDERLINE}{result}"
+ if self.italic:
+ result = f"{ANSIStyle.ITALIC}{result}"
+ if self.reverse:
+ result = f"{ANSIStyle.REVERSE}{result}"
+ if self.fg_color:
+ result = f"{self.fg_color}{result}"
+ if self.bg_color:
+ result = f"{self.bg_color}{result}"
+ if any([self.bold, self.dim, self.underline, self.italic, self.reverse, self.fg_color, self.bg_color]):
+ result = f"{result}{ANSIStyle.RESET}"
+ return result
+
+
+@dataclass
+class TUIElement:
+ """TUI 元素基类"""
+ id: str = ""
+ element_type: TUIElementType = TUIElementType.CONTAINER
+ classes: List[str] = field(default_factory=list)
+ text: str = ""
+ x: int = 0
+ y: int = 0
+ width: int = 80
+ height: int = 1
+ style: TUIStyle = field(default_factory=TUIStyle)
+ children: List['TUIElement'] = field(default_factory=list)
+ attributes: Dict[str, Any] = field(default_factory=dict)
+ parent: Optional['TUIElement'] = None
+
+ def render(self) -> str:
+ """渲染元素"""
+ return self.style.apply(self.text)
+
+ def get_bounds(self) -> Tuple[int, int, int, int]:
+ """获取边界 (x, y, width, height)"""
+ return (self.x, self.y, self.width, self.height)
+
+
+@dataclass
+class TUIButton(TUIElement):
+ """按钮"""
+ action: str = ""
+ target: str = ""
+ clickable: bool = True
+ shortcut: str = ""
+
+ def render(self) -> str:
+ text = self.text
+ if self.shortcut:
+ text = f"[{self.shortcut}] {text}"
+
+ # 按钮样式
+ btn_text = f"▌ {text} ▐"
+ styled = self.style.apply(btn_text)
+
+ # 填充到指定宽度
+ padding = self.width - len(btn_text)
+ if padding > 0:
+ styled += " " * padding
+
+ return styled
+
+
+@dataclass
+class TUILabel(TUIElement):
+ """标签"""
+ alignment: str = "left" # left, center, right
+
+ def render(self) -> str:
+ text = self.style.apply(self.text)
+
+ if self.alignment == "center":
+ padding = (self.width - len(self.text)) // 2
+ text = " " * padding + text
+ elif self.alignment == "right":
+ padding = self.width - len(self.text)
+ text = " " * padding + text
+
+ # 填充剩余空间
+ remaining = self.width - len(self.text)
+ if remaining > 0 and self.alignment == "left":
+ text += " " * remaining
+
+ return text
+
+
+@dataclass
+class TUIPanel(TUIElement):
+ """面板/卡片"""
+ border_style: str = "single"
+ title: str = ""
+ show_border: bool = True
+
+ def render(self) -> str:
+ borders = getattr(BorderStyle, self.border_style.upper(), BorderStyle.SINGLE)
+
+ lines = []
+ width = self.width - 2 if self.show_border else self.width
+
+ # 顶部边框
+ if self.show_border:
+ if self.title:
+ title_padding = (width - len(self.title)) // 2
+ top = borders[0] + borders[1] * title_padding + f" {self.title} " + borders[1] * (width - title_padding - len(self.title) - 1) + borders[2]
+ else:
+ top = borders[0] + borders[1] * width + borders[2]
+ lines.append(top)
+
+ # 内容
+ for child in self.children:
+ content = child.render()
+ if self.show_border:
+ # 截断过长的内容
+ content = content[:width].ljust(width)
+ lines.append(f"{borders[3]} {content} {borders[4]}")
+ else:
+ lines.append(content)
+
+ # 底部边框
+ if self.show_border:
+ bottom = borders[5] + borders[6] * width + borders[7]
+ lines.append(bottom)
+
+ return "\n".join(lines)
+
+
+@dataclass
+class TUILayout(TUIElement):
+ """布局容器"""
+ layout_type: str = "vertical" # vertical, horizontal, grid
+ gap: int = 1
+
+ def render(self, width: int = 80, height: int = 24) -> str:
+ if self.layout_type == "vertical":
+ rendered = []
+ for i, child in enumerate(self.children):
+ child.y = self.y + sum(len(r.render().split('\n')) for r in rendered) + (i * self.gap)
+ rendered.append(child)
+ return "\n".join(el.render() for el in rendered)
+
+ elif self.layout_type == "horizontal":
+ rendered = []
+ current_x = self.x
+ for child in self.children:
+ child.x = current_x
+ rendered.append(child)
+ current_x += child.width + self.gap
+ return " ".join(el.render() for el in rendered)
+
+ else:
+ return "\n".join(el.render() for el in self.children)
+
+
+@dataclass
+class TUIList(TUIElement):
+ """列表"""
+ items: List[str] = field(default_factory=list)
+ selected_index: int = 0
+ show_numbers: bool = True
+
+ def render(self) -> str:
+ lines = []
+ for i, item in enumerate(self.items):
+ prefix = f"{i + 1}. " if self.show_numbers else " "
+ marker = "► " if i == self.selected_index else " "
+ line = f"{marker}{prefix}{item}"
+ if len(line) < self.width:
+ line += " " * (self.width - len(line))
+ lines.append(line[:self.width])
+ return "\n".join(lines)
+
+
+@dataclass
+class TUISeparator(TUIElement):
+ """分隔线"""
+ char: str = "─"
+
+ def render(self) -> str:
+ return self.char * self.width
+
+
+@dataclass
+class TUIProgressBar(TUIElement):
+ """进度条"""
+ progress: float = 0.0 # 0.0 to 1.0
+ filled_char: str = "█"
+ empty_char: str = "░"
+
+ def render(self) -> str:
+ filled_width = int(self.width * self.progress)
+ empty_width = self.width - filled_width
+ bar = self.filled_char * filled_width + self.empty_char * empty_width
+ percentage = f" {int(self.progress * 100)}%"
+ return f"{bar}{percentage}"
+
+
+@dataclass
+class TUISpinner(TUIElement):
+ """加载动画"""
+ frames: List[str] = field(default_factory=lambda: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
+ current_frame: int = 0
+
+ def render(self) -> str:
+ frame = self.frames[self.current_frame % len(self.frames)]
+ return f"{frame} {self.text}"
+
+ def next_frame(self):
+ self.current_frame += 1
+
+
+class HTMLToTUIConverter:
+ """强大的 HTML 到 TUI 转换器
+
+ 支持:
+ - 解析 HTML 结构和 data-tui-* 标记
+ - 提取 CSS 样式并转换为 ANSI
+ - 解析 JS 交互配置
+ - 智能布局适配
+ """
+
+ COLOR_MAP = {
+ '#000000': ANSIStyle.FG_BLACK,
+ '#0000ff': ANSIStyle.FG_BLUE,
+ '#008000': ANSIStyle.FG_GREEN,
+ '#00ffff': ANSIStyle.FG_CYAN,
+ '#ff0000': ANSIStyle.FG_RED,
+ '#ff00ff': ANSIStyle.FG_MAGENTA,
+ '#ffff00': ANSIStyle.FG_YELLOW,
+ '#ffffff': ANSIStyle.FG_WHITE,
+ '#808080': ANSIStyle.DIM,
+ '#c0c0c0': ANSIStyle.FG_WHITE,
+ 'black': ANSIStyle.FG_BLACK,
+ 'blue': ANSIStyle.FG_BLUE,
+ 'green': ANSIStyle.FG_GREEN,
+ 'cyan': ANSIStyle.FG_CYAN,
+ 'red': ANSIStyle.FG_RED,
+ 'magenta': ANSIStyle.FG_MAGENTA,
+ 'yellow': ANSIStyle.FG_YELLOW,
+ 'white': ANSIStyle.FG_WHITE,
+ 'gray': ANSIStyle.DIM,
+ 'grey': ANSIStyle.DIM,
+ }
+
+ BG_COLOR_MAP = {
+ '#000000': ANSIStyle.BG_BLACK,
+ '#0000ff': ANSIStyle.BG_BLUE,
+ '#008000': ANSIStyle.BG_GREEN,
+ '#00ffff': ANSIStyle.BG_CYAN,
+ '#ff0000': ANSIStyle.BG_RED,
+ '#ff00ff': ANSIStyle.BG_MAGENTA,
+ '#ffff00': ANSIStyle.BG_YELLOW,
+ '#ffffff': ANSIStyle.BG_WHITE,
+ 'black': ANSIStyle.BG_BLACK,
+ 'blue': ANSIStyle.BG_BLUE,
+ 'green': ANSIStyle.BG_GREEN,
+ 'cyan': ANSIStyle.BG_CYAN,
+ 'red': ANSIStyle.BG_RED,
+ 'magenta': ANSIStyle.BG_MAGENTA,
+ 'yellow': ANSIStyle.BG_YELLOW,
+ 'white': ANSIStyle.BG_WHITE,
+ }
+
+ def __init__(self, width: int = 80, height: int = 24):
+ self.width = width
+ self.height = height
+ self.keyboard_bindings: Dict[str, Dict] = {}
+ self.mouse_handlers: Dict[str, Callable] = {}
+ self.css_styles: Dict[str, TUIStyle] = {}
+
+ def parse(self, html_content: str) -> TUILayout:
+ """解析 HTML 并转换为 TUI 元素树"""
+ # 移除 script 标签(除了 TUI 配置脚本)
+ html_clean = self._extract_tui_scripts(html_content)
+ html_no_script = re.sub(r'', '', html_clean, flags=re.DOTALL)
+
+ # 提取 TUI 配置
+ self._parse_tui_config(html_content)
+
+ # 提取 CSS
+ self._parse_tui_css(html_content)
+
+ # 创建布局
+ layout = TUILayout(layout_type="vertical")
+
+ # 提取标题
+ title_match = re.search(r'
(.*?)', html_no_script, re.IGNORECASE)
+ if title_match:
+ header = TUILabel(
+ text=title_match.group(1).strip(),
+ style=TUIStyle(bold=True),
+ width=self.width
+ )
+ layout.children.append(header)
+ layout.children.append(TUISeparator())
+
+ # 提取主体内容
+ body_match = re.search(r']*>(.*?)', html_no_script, re.IGNORECASE | re.DOTALL)
+ if body_match:
+ body_html = body_match.group(1)
+ elements = self._parse_elements(body_html)
+ layout.children.extend(elements)
+
+ # 提取导航
+ nav_elements = self._extract_nav(html_no_script)
+ if nav_elements:
+ layout.children.append(TUISeparator(char="─"))
+ layout.children.append(TUILabel(text="导航菜单", style=TUIStyle(dim=True)))
+ layout.children.extend(nav_elements)
+
+ # 提取按钮
+ btn_elements = self._extract_buttons(html_no_script)
+ if btn_elements:
+ layout.children.append(TUISeparator(char="─"))
+ layout.children.extend(btn_elements)
+
+ return layout
+
+ def _extract_tui_scripts(self, html: str) -> str:
+ """提取 TUI 配置脚本"""
+ # 保存 TUI 配置脚本
+ for match in re.finditer(r'', html, re.DOTALL):
+ try:
+ config = json.loads(match.group(1).strip())
+ if 'keyboard' in config:
+ self.keyboard_bindings = config['keyboard']
+ except json.JSONDecodeError:
+ pass
+ return html
+
+ def _parse_tui_config(self, html: str):
+ """解析 TUI 配置"""
+ for match in re.finditer(r'', html, re.DOTALL):
+ try:
+ config = json.loads(match.group(1).strip())
+ if 'keyboard' in config:
+ self.keyboard_bindings = config['keyboard']
+ if 'mouse' in config:
+ mouse_config = config['mouse']
+ if mouse_config.get('enabled'):
+ self.mouse_handlers['click'] = lambda x, y: {'action': 'select'}
+ if 'display' in config:
+ display = config['display']
+ self.width = display.get('width', self.width)
+ self.height = display.get('height', self.height)
+ except json.JSONDecodeError:
+ pass
+
+ def _parse_tui_css(self, html: str):
+ """解析 TUI CSS"""
+ for match in re.finditer(r'', html, re.DOTALL):
+ css = match.group(1)
+ # 简单的 CSS 解析
+ for rule_match in re.finditer(r'([.#]?[\w-]+)\s*\{([^}]+)\}', css):
+ selector = rule_match.group(1)
+ properties = rule_match.group(2)
+ style = self._parse_css_properties(properties)
+ self.css_styles[selector] = style
+
+ def _parse_css_properties(self, css_text: str) -> TUIStyle:
+ """解析 CSS 属性为 TUI 样式"""
+ style = TUIStyle()
+
+ # 背景色
+ bg_match = re.search(r'background(-color)?:\s*(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)', css_text)
+ if bg_match:
+ color = bg_match.group(2).lower()
+ style.bg_color = self.BG_COLOR_MAP.get(color, "")
+
+ # 文字颜色
+ color_match = re.search(r'color:\s*(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)', css_text)
+ if color_match:
+ color = color_match.group(1).lower()
+ style.fg_color = self.COLOR_MAP.get(color, "")
+
+ # 字体样式
+ if 'font-weight: bold' in css_text or 'font-weight:bold' in css_text:
+ style.bold = True
+ if 'font-style: italic' in css_text:
+ style.italic = True
+ if 'text-decoration: underline' in css_text:
+ style.underline = True
+
+ return style
+
+ def _parse_elements(self, html: str) -> List[TUIElement]:
+ """解析 HTML 元素"""
+ elements = []
+
+ # 解析带 data-tui-* 标记的元素
+ for match in re.finditer(r'<(\w+)([^>]*)>(.*?)\1>', html, re.DOTALL):
+ tag = match.group(1)
+ attrs_str = match.group(2)
+ content = match.group(3)
+
+ # 解析属性
+ attrs = self._parse_attributes(attrs_str)
+
+ # 检查是否是 TUI 元素
+ if 'data-tui-type' in attrs or self._is_tui_element(tag, attrs):
+ element = self._create_tui_element(tag, attrs, content)
+ if element:
+ elements.append(element)
+
+ # 也解析自闭合标签
+ for match in re.finditer(r'<(\w+)([^/]*)/>', html):
+ tag = match.group(1)
+ attrs_str = match.group(2)
+ attrs = self._parse_attributes(attrs_str)
+
+ if 'data-tui-type' in attrs or self._is_tui_element(tag, attrs):
+ element = self._create_tui_element(tag, attrs, "")
+ if element:
+ elements.append(element)
+
+ return elements
+
+ def _parse_attributes(self, attrs_str: str) -> Dict[str, Any]:
+ """解析 HTML 属性"""
+ attrs = {}
+ for match in re.finditer(r'([\w-]+)=["\']([^"\']*)["\']', attrs_str):
+ key = match.group(1)
+ value = match.group(2)
+ attrs[key] = value
+
+ # 处理布尔属性
+ for match in re.finditer(r'([\w-]+)(?=\s|>|/>)', attrs_str):
+ key = match.group(1)
+ if key not in attrs:
+ attrs[key] = True
+
+ return attrs
+
+ def _is_tui_element(self, tag: str, attrs: Dict) -> bool:
+ """判断是否是 TUI 元素"""
+ tui_tags = ['header', 'footer', 'nav', 'section', 'article', 'aside', 'main']
+ tui_attrs = ['data-tui-type', 'data-tui-action', 'data-tui-key', 'data-tui-layout']
+
+ return tag in tui_tags or any(attr in attrs for attr in tui_attrs)
+
+ def _create_tui_element(self, tag: str, attrs: Dict, content: str) -> Optional[TUIElement]:
+ """创建 TUI 元素"""
+ # 清理 HTML 标签
+ text = re.sub(r'<[^>]+>', '', content).strip()
+ text = html.unescape(text)
+
+ # 获取样式
+ style = self._get_style_for_element(attrs)
+
+ # 根据标签和属性创建元素
+ tui_type = attrs.get('data-tui-type', '').lower()
+
+ if tag == 'button' or tui_type == 'button' or 'data-tui-key' in attrs:
+ return TUIButton(
+ id=attrs.get('id', ''),
+ text=text or attrs.get('data-tui-key', 'Button'),
+ classes=attrs.get('class', '').split(),
+ style=style,
+ width=self.width,
+ action=attrs.get('data-tui-action', ''),
+ target=attrs.get('href', attrs.get('data-tui-target', '')),
+ shortcut=attrs.get('data-tui-key', '')
+ )
+
+ elif tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header'] or tui_type == 'header':
+ style.bold = True
+ return TUILabel(
+ id=attrs.get('id', ''),
+ text=text,
+ classes=attrs.get('class', '').split(),
+ style=style,
+ width=self.width,
+ alignment="center" if tag == 'h1' else "left"
+ )
+
+ elif tag == 'nav' or tui_type == 'nav':
+ # 导航特殊处理
+ return None # 由 _extract_nav 处理
+
+ elif tag == 'hr' or tag == 'separator' or tui_type == 'separator':
+ char = attrs.get('data-tui-char', '─')
+ return TUISeparator(char=char, width=self.width)
+
+ elif tag == 'ul' or tag == 'ol':
+ items = []
+ for li_match in re.finditer(r'
]*>(.*?)', content, re.DOTALL):
+ item_text = re.sub(r'<[^>]+>', '', li_match.group(1)).strip()
+ items.append(html.unescape(item_text))
+ return TUIList(items=items, width=self.width, show_numbers=(tag == 'ol'))
+
+ elif tag == 'footer' or tui_type == 'footer':
+ style.dim = True
+ return TUILabel(
+ id=attrs.get('id', ''),
+ text=text,
+ classes=attrs.get('class', '').split(),
+ style=style,
+ width=self.width
+ )
+
+ elif 'data-tui-layout' in attrs or tag in ['div', 'section', 'main', 'article']:
+ layout_type = attrs.get('data-tui-layout', 'vertical')
+ return TUILayout(
+ id=attrs.get('id', ''),
+ layout_type=layout_type,
+ classes=attrs.get('class', '').split(),
+ style=style,
+ width=self.width
+ )
+
+ else:
+ # 默认标签
+ return TUILabel(
+ id=attrs.get('id', ''),
+ text=text,
+ classes=attrs.get('class', '').split(),
+ style=style,
+ width=self.width
+ )
+
+ def _get_style_for_element(self, attrs: Dict) -> TUIStyle:
+ """获取元素样式"""
+ style = TUIStyle()
+
+ # 检查 class
+ classes = attrs.get('class', '').split()
+ for cls in classes:
+ selector = f".{cls}"
+ if selector in self.css_styles:
+ base_style = self.css_styles[selector]
+ style.fg_color = base_style.fg_color or style.fg_color
+ style.bg_color = base_style.bg_color or style.bg_color
+ style.bold = style.bold or base_style.bold
+ style.dim = style.dim or base_style.dim
+ style.underline = style.underline or base_style.underline
+
+ # 检查 data-tui-style
+ tui_style = attrs.get('data-tui-style', '')
+ if 'bold' in tui_style:
+ style.bold = True
+ if 'dim' in tui_style:
+ style.dim = True
+ if 'underline' in tui_style:
+ style.underline = True
+ if 'reverse' in tui_style:
+ style.reverse = True
+
+ return style
+
+ def _extract_nav(self, html: str) -> List[TUIElement]:
+ """提取导航元素"""
+ elements = []
+
+ for match in re.finditer(r'
', html, re.DOTALL | re.IGNORECASE):
+ nav_html = match.group(1)
+
+ for link_match in re.finditer(r'
]*href=["\']([^"\']*)["\'][^>]*>(.*?)', nav_html, re.DOTALL | re.IGNORECASE):
+ href = link_match.group(1)
+ link_text = re.sub(r'<[^>]+>', '', link_match.group(2)).strip()
+ link_text = html.unescape(link_text) if hasattr(html, 'unescape') else link_text
+
+ # 获取快捷键
+ attrs_str = link_match.group(0)
+ shortcut = ""
+ shortcut_match = re.search(r'data-tui-key=["\']([^"\']*)["\']', attrs_str)
+ if shortcut_match:
+ shortcut = shortcut_match.group(1)
+
+ btn = TUIButton(
+ text=f"{link_text}",
+ target=href,
+ shortcut=shortcut,
+ action="navigate",
+ width=self.width
+ )
+ elements.append(btn)
+
+ return elements
+
+ def _extract_buttons(self, html: str) -> List[TUIElement]:
+ """提取按钮"""
+ elements = []
+
+ for match in re.finditer(r'
', html, re.DOTALL | re.IGNORECASE):
+ attrs_str = match.group(0)
+ text = re.sub(r'<[^>]+>', '', match.group(1)).strip()
+ text = html.unescape(text)
+
+ onclick = ""
+ onclick_match = re.search(r'onclick=["\']([^"\']*)["\']', attrs_str)
+ if onclick_match:
+ onclick = onclick_match.group(1)
+
+ btn = TUIButton(
+ text=text or "Button",
+ action=onclick,
+ width=self.width
+ )
+ elements.append(btn)
+
+ return elements
+
+ def get_keyboard_bindings(self) -> Dict[str, Dict]:
+ """获取键盘绑定"""
+ return self.keyboard_bindings
+
+
+class TUIRenderer:
+ """TUI 渲染器"""
+
+ def __init__(self, width: int = 80, height: int = 24):
+ self.width = width
+ self.height = height
+ self.converter = HTMLToTUIConverter(width, height)
+ self.screen_buffer: List[List[str]] = []
+
+ def render(self, html: str) -> str:
+ """渲染 HTML 到终端字符串"""
+ layout = self.converter.parse(html)
+ return self.render_layout(layout)
+
+ def render_layout(self, layout: TUILayout) -> str:
+ """渲染布局"""
+ self._init_buffer()
+ self._render_element(layout, 0, 0)
+ return self._buffer_to_string()
+
+ def _init_buffer(self):
+ """初始化缓冲区"""
+ self.screen_buffer = [[' ' for _ in range(self.width)] for _ in range(self.height)]
+
+ def _render_element(self, element: TUIElement, x: int, y: int):
+ """渲染元素到缓冲区"""
+ rendered = element.render()
+ lines = rendered.split('\n')
+
+ for i, line in enumerate(lines):
+ if y + i >= self.height:
+ break
+
+ # 清理 ANSI 码计算实际长度
+ clean_line = re.sub(r'\x1b\[[0-9;]*m', '', line)
+
+ for j, char in enumerate(line):
+ if x + j >= self.width:
+ break
+ self.screen_buffer[y + i][x + j] = char
+
+ def _buffer_to_string(self) -> str:
+ """缓冲区转字符串"""
+ return '\n'.join(''.join(row) for row in self.screen_buffer)
+
+ def render_with_frame(self, html: str, title: str = "NebulaShell TUI") -> str:
+ """渲染带边框的页面"""
+ content = self.render(html)
+ lines = content.split('\n')
+
+ # 计算最大宽度
+ max_content_width = max(len(re.sub(r'\x1b\[[0-9;]*m', '', line)) for line in lines) if lines else 0
+ frame_width = min(max_content_width + 2, self.width)
+
+ result = []
+
+ # 顶部
+ top = "╔" + "═" * (frame_width - 2) + "╗"
+ if title:
+ title_text = f" {title} "
+ padding = (frame_width - 2 - len(title_text)) // 2
+ top = "╔" + "═" * padding + title_text + "═" * (frame_width - 2 - padding - len(title_text)) + "╗"
+ result.append(top)
+
+ # 内容
+ for line in lines:
+ clean_len = len(re.sub(r'\x1b\[[0-9;]*m', '', line))
+ padding = frame_width - 2 - clean_len
+ if padding > 0:
+ line = line + " " * padding
+ result.append(f"║ {line} ║")
+
+ # 底部
+ result.append("╚" + "═" * (frame_width - 2) + "╝")
+
+ return '\n'.join(result)
+
+
+class TUIInputHandler:
+ """TUI 输入处理器
+
+ 支持:
+ - 键盘事件(包括功能键、方向键)
+ - 鼠标事件(点击、移动)
+ - 自定义键绑定
+ """
+
+ def __init__(self):
+ self.key_bindings: Dict[str, Callable] = {}
+ self.mouse_handlers: Dict[str, Callable] = {}
+ self.mouse_x = 0
+ self.mouse_y = 0
+ self.running = True
+
+ def bind_key(self, key: str, handler: Callable):
+ """绑定按键"""
+ self.key_bindings[key] = handler
+
+ def bind_mouse(self, event: str, handler: Callable):
+ """绑定鼠标事件"""
+ self.mouse_handlers[event] = handler
+
+ def handle_key(self, key: str) -> bool:
+ """处理按键"""
+ if key in self.key_bindings:
+ self.key_bindings[key]()
+ return True
+ return False
+
+ def handle_mouse(self, x: int, y: int, button: str = 'left') -> bool:
+ """处理鼠标"""
+ self.mouse_x = x
+ self.mouse_y = y
+
+ handler_key = f"{button}"
+ if handler_key in self.mouse_handlers:
+ self.mouse_handlers[handler_key](x, y)
+ return True
+ return False
+
+ def read_key(self) -> str:
+ """读取按键(原始模式)"""
+ import sys
+ import tty
+ import termios
+
+ fd = sys.stdin.fileno()
+ old_settings = termios.tcgetattr(fd)
+
+ try:
+ tty.setraw(fd)
+ char = sys.stdin.read(1)
+
+ # 处理转义序列
+ if char == '\x1b':
+ char += sys.stdin.read(2)
+
+ return char
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+
+
+class TUICanvas:
+ """TUI 画布"""
+
+ def __init__(self, width: int = 80, height: int = 24):
+ self.width = width
+ self.height = height
+ self.buffer = [[' ' for _ in range(width)] for _ in range(height)]
+ self.renderer = TUIRenderer(width, height)
+
+ def clear(self):
+ """清屏"""
+ self.buffer = [[' ' for _ in range(self.width)] for _ in range(self.height)]
+
+ def draw_text(self, text: str, x: int, y: int, style: TUIStyle = None):
+ """绘制文本"""
+ if style:
+ text = style.apply(text)
+
+ lines = text.split('\n')
+ for i, line in enumerate(lines):
+ if y + i >= self.height:
+ break
+ for j, char in enumerate(line):
+ if x + j >= self.width:
+ break
+ self.buffer[y + i][x + j] = char
+
+ def draw_box(self, x: int, y: int, width: int, height: int, style: str = "single"):
+ """绘制方框"""
+ borders = getattr(BorderStyle, style.upper(), BorderStyle.SINGLE)
+
+ # 顶边
+ self.draw_text(borders[0] + borders[1] * (width - 2) + borders[2], x, y)
+
+ # 侧边
+ for i in range(1, height - 1):
+ self.draw_text(f"{borders[3]}{' ' * (width - 2)}{borders[4]}", x, y + i)
+
+ # 底边
+ self.draw_text(borders[5] + borders[6] * (width - 2) + borders[7], x, y + height - 1)
+
+ def render(self) -> str:
+ """渲染画布"""
+ return '\n'.join(''.join(row) for row in self.buffer)
+
+ def display(self):
+ """显示到终端"""
+ sys.stdout.write('\x1b[2J\x1b[H') # 清屏
+ sys.stdout.write(self.render())
+ sys.stdout.flush()
+
+
+class TUIEventManager:
+ """TUI 事件管理器"""
+
+ def __init__(self):
+ self.events: Dict[str, List[Callable]] = {}
+
+ def on(self, event: str, handler: Callable):
+ """注册事件处理器"""
+ if event not in self.events:
+ self.events[event] = []
+ self.events[event].append(handler)
+
+ def emit(self, event: str, *args, **kwargs):
+ """触发事件"""
+ if event in self.events:
+ for handler in self.events[event]:
+ handler(*args, **kwargs)
+
+
+class TUIManager:
+ """TUI 管理器 - 核心管理类
+
+ 功能:
+ - 页面管理
+ - 渲染控制
+ - 事件循环
+ - 输入处理
+ """
+
+ _instance: Optional['TUIManager'] = None
+
+ def __init__(self, width: int = 80, height: int = 24):
+ self.width = width
+ self.height = height
+ self.canvas = TUICanvas(width, height)
+ self.renderer = TUIRenderer(width, height)
+ self.converter = HTMLToTUIConverter(width, height)
+ self.input_handler = TUIInputHandler()
+ self.event_manager = TUIEventManager()
+
+ self.pages: Dict[str, str] = {} # path -> html
+ self.current_page = ""
+ self.running = False
+ self.selected_index = 0
+ self.nav_items: List[Dict] = []
+
+ @classmethod
+ def get_instance(cls, width: int = 80, height: int = 24) -> 'TUIManager':
+ """获取单例实例"""
+ if cls._instance is None:
+ cls._instance = TUIManager(width, height)
+ return cls._instance
+
+ def load_page(self, path: str, html_content: str):
+ """加载页面"""
+ self.pages[path] = html_content
+ self.current_page = path
+
+ def navigate(self, path: str):
+ """导航到页面"""
+ if path in self.pages:
+ self.current_page = path
+ self.render_current()
+ else:
+ self.show_error(f"Page not found: {path}")
+
+ def render_current(self):
+ """渲染当前页面"""
+ if not self.current_page or self.current_page not in self.pages:
+ return
+
+ html = self.pages[self.current_page]
+ output = self.renderer.render_with_frame(html, title=f"NebulaShell - {self.current_page}")
+
+ self.canvas.clear()
+ self.canvas.draw_text(output, 0, 0)
+ self.canvas.display()
+
+ def show_error(self, message: str):
+ """显示错误"""
+ error_html = f"""
+
+
+
❌ 错误
+
{message}
+
按任意键返回
+
+
+ """
+ self.load_page("/error", error_html)
+ self.render_current()
+
+ def setup_default_bindings(self):
+ """设置默认键绑定"""
+ self.input_handler.bind_key('q', self.quit)
+ self.input_handler.bind_key('Q', self.quit)
+ self.input_handler.bind_key('\x03', self.quit) # Ctrl+C
+ self.input_handler.bind_key('\x04', self.quit) # Ctrl+D
+
+ def setup_keyboard_navigation(self):
+ """从当前页面提取键盘绑定"""
+ if self.current_page not in self.pages:
+ return
+
+ html = self.pages[self.current_page]
+ converter = HTMLToTUIConverter(self.width, self.height)
+ converter.parse(html)
+
+ for key, config in converter.get_keyboard_bindings().items():
+ action = config.get('action', '')
+ target = config.get('target', '')
+
+ if action == 'navigate' and target:
+ self.input_handler.bind_key(key, lambda t=target: self.navigate(t))
+ elif action == 'quit':
+ self.input_handler.bind_key(key, self.quit)
+ elif action == 'refresh':
+ self.input_handler.bind_key(key, self.render_current)
+
+ def run_event_loop(self):
+ """运行事件循环"""
+ self.running = True
+ self.setup_default_bindings()
+
+ while self.running:
+ self.setup_keyboard_navigation()
+ key = self.input_handler.read_key()
+ self.input_handler.handle_key(key)
+
+ def quit(self):
+ """退出"""
+ self.running = False
+
+ def start(self):
+ """启动 TUI"""
+ if self.current_page:
+ self.render_current()
+ self.run_event_loop()
+
+
+# 全局实例
+_tui_manager_instance: Optional[TUIManager] = None
+
+
+def get_tui_manager(width: int = 80, height: int = 24) -> TUIManager:
+ """获取 TUI 管理器实例"""
+ global _tui_manager_instance
+ if _tui_manager_instance is None:
+ _tui_manager_instance = TUIManager(width, height)
+ return _tui_manager_instance
diff --git a/store/@{NebulaShell}/webui/tui/index.html b/store/@{NebulaShell}/webui/tui/index.html
new file mode 100644
index 0000000..e0b2df7
--- /dev/null
+++ b/store/@{NebulaShell}/webui/tui/index.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+
NebulaShell TUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/store/@{NebulaShell}/webui/tui/main.py b/store/@{NebulaShell}/webui/tui/main.py
new file mode 100644
index 0000000..bdbb08b
--- /dev/null
+++ b/store/@{NebulaShell}/webui/tui/main.py
@@ -0,0 +1,378 @@
+"""TUI 插件 - 终端用户界面,与 WebUI 双启动"""
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from oss.logger.logger import Log
+from oss.plugin.types import Plugin, Response, register_plugin_type
+
+from .tui.converter import TUIManager, TUIRenderer, HTMLToTUIConverter
+
+
+class TUIPlugin(Plugin):
+ """TUI 插件 - 提供终端界面,通过访问 WebUI 的 /tui 接口获取 HTML"""
+
+ def __init__(self):
+ self.webui = None
+ self.http_api = None
+ self.tui_manager = None
+ self.running = False
+ self.tui_thread = None
+
+ def meta(self):
+ from oss.plugin.types import Metadata, PluginConfig, Manifest
+ return Manifest(
+ metadata=Metadata(
+ name="tui",
+ version="1.0.0",
+ author="NebulaShell",
+ description="终端用户界面 - 与 WebUI 双启动"
+ ),
+ config=PluginConfig(enabled=True, args={}),
+ dependencies=["http-api", "webui"]
+ )
+
+ def set_webui(self, webui):
+ """注入 webui 引用"""
+ self.webui = webui
+
+ def set_http_api(self, http_api):
+ """注入 http_api 引用"""
+ self.http_api = http_api
+
+ def init(self, deps: dict = None):
+ """初始化 TUI"""
+ Log.info("tui", "TUI 插件初始化中...")
+
+ # 创建 TUI 管理器
+ self.tui_manager = TUIManager.get_instance()
+
+ # 注册 /tui 路由供 TUI 访问 WebUI 页面
+ if self.http_api and self.http_api.router:
+ # 注册 TUI 专用 API
+ self.http_api.router.get("/tui/index.html", self._handle_tui_index)
+ self.http_api.router.get("/tui/page", self._handle_tui_page)
+ self.http_api.router.get("/tui/css", self._handle_tui_css)
+ self.http_api.router.post("/tui/interact", self._handle_tui_interact)
+ Log.ok("tui", "已注册 TUI API 路由")
+ else:
+ Log.warn("tui", "警告:未找到 http-api 依赖")
+
+ # 加载默认页面(从 WebUI 获取)
+ self._load_default_pages()
+
+ Log.ok("tui", "TUI 插件初始化完成")
+
+ def _load_default_pages(self):
+ """从 WebUI 加载默认页面到 TUI"""
+ # 模拟访问 WebUI 页面并缓存
+ default_pages = ["/", "/dashboard", "/logs", "/terminal"]
+
+ for path in default_pages:
+ try:
+ # 这里会通过内部调用获取 WebUI 渲染的 HTML
+ html = self._fetch_webui_page(path)
+ if html:
+ self.tui_manager.load_page(path, html)
+ Log.info("tui", f"已加载页面:{path}")
+ except Exception as e:
+ Log.warn("tui", f"加载页面 {path} 失败:{e}")
+
+ def _fetch_webui_page(self, path: str) -> str:
+ """从 WebUI 获取页面 HTML"""
+ if not self.webui or not hasattr(self.webui, 'server'):
+ return ""
+
+ # 模拟请求获取 WebUI 页面
+ # 由于我们在同一进程,可以直接调用 server 的路由处理
+ try:
+ from oss.plugin.types import Request
+ request = Request(method="GET", path=path, headers={}, body="")
+
+ # 查找匹配的路由
+ router = self.webui.server.router
+ if hasattr(router, 'routes'):
+ for route_path, handler in router.routes.items():
+ if route_path == path or (route_path.endswith('*') and path.startswith(route_path[:-1])):
+ response = handler(request)
+ if response and hasattr(response, 'body'):
+ return response.body.decode('utf-8') if isinstance(response.body, bytes) else response.body
+ except Exception as e:
+ Log.warn("tui", f"获取 WebUI 页面失败:{e}")
+
+ return ""
+
+ def start(self):
+ """启动 TUI(在后台线程运行)"""
+ Log.info("tui", "TUI 启动中...")
+ self.running = True
+
+ # 在后台线程运行 TUI
+ self.tui_thread = threading.Thread(target=self._tui_loop, daemon=True)
+ self.tui_thread.start()
+
+ Log.ok("tui", "TUI 已启动(后台模式)")
+ Log.info("tui", "提示:按 'q' 退出 TUI,WebUI 仍在运行")
+
+ def _tui_loop(self):
+ """TUI 主循环"""
+ try:
+ # 显示欢迎界面
+ self._show_welcome()
+
+ # 主事件循环
+ self._event_loop()
+
+ except Exception as e:
+ Log.error("tui", f"TUI 循环异常:{e}")
+ finally:
+ self.running = False
+
+ def _show_welcome(self):
+ """显示欢迎界面"""
+ welcome_html = """
+
+
+
NebulaShell TUI
+
+
👋 欢迎使用 NebulaShell TUI
+
终端用户界面已启动
+
WebUI 同时运行在:http://localhost:8080
+
+
可用命令:
+
+ - [1] 首页
+ - [2] 仪表盘
+ - [3] 日志
+ - [4] 终端
+ - [q] 退出 TUI
+ - [r] 刷新
+
+
+
+ """
+ self.tui_manager.load_page("/welcome", welcome_html)
+ self._render_current("/welcome")
+
+ def _render_current(self, path: str = None):
+ """渲染当前页面到终端"""
+ if path is None:
+ path = self.tui_manager.current_page or "/welcome"
+
+ output = self.tui_manager.render_page(path)
+
+ # 清屏并输出
+ sys.stdout.write('\x1b[2J\x1b[H')
+ sys.stdout.write(output)
+ sys.stdout.write('\n\n')
+ sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出\x1b[0m\n')
+ sys.stdout.flush()
+
+ def _event_loop(self):
+ """简单的事件循环"""
+ import sys
+ import tty
+ import termios
+
+ fd = sys.stdin.fileno()
+ old_settings = termios.tcgetattr(fd)
+
+ try:
+ tty.setraw(fd)
+
+ while self.running:
+ char = sys.stdin.read(1)
+
+ if char == '\x03': # Ctrl+C
+ break
+ elif char == '\x04': # Ctrl+D
+ break
+ elif char == 'q':
+ Log.info("tui", "用户退出 TUI")
+ break
+ elif char == '1':
+ self._render_current("/")
+ elif char == '2':
+ self._render_current("/dashboard")
+ elif char == '3':
+ self._render_current("/logs")
+ elif char == '4':
+ self._render_current("/terminal")
+ elif char == 'r':
+ self._load_default_pages()
+ self._render_current()
+ elif char == '\n' or char == '\r':
+ # Enter 刷新当前页
+ self._render_current()
+
+ except Exception as e:
+ Log.error("tui", f"事件循环错误:{e}")
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+
+ def _handle_tui_index(self, request):
+ """处理 /tui/index.html 请求"""
+ # 返回特殊标记的 HTML,TUI 会识别并转换
+ html = """
+
+
+
+
NebulaShell TUI
+
+
+
+
+
NebulaShell TUI
+
终端界面就绪
+
+
+
+
+
+"""
+ return Response(
+ status=200,
+ headers={"Content-Type": "text/html; charset=utf-8"},
+ body=html
+ )
+
+ def _handle_tui_page(self, request):
+ """处理 /tui/page 请求 - 获取任意页面的 TUI 版本"""
+ from urllib.parse import parse_qs, urlparse
+
+ parsed = urlparse(request.path)
+ params = parse_qs(parsed.query)
+ page_path = params.get('path', ['/'])[0]
+
+ # 从 WebUI 获取原始 HTML
+ html = self._fetch_webui_page(page_path)
+
+ if html:
+ # 添加 TUI 标记
+ html = html.replace('', '')
+
+ return Response(
+ status=200,
+ headers={"Content-Type": "text/html; charset=utf-8"},
+ body=html
+ )
+ else:
+ return Response(
+ status=404,
+ headers={"Content-Type": "text/html"},
+ body="Page not found"
+ )
+
+ def _handle_tui_css(self, request):
+ """处理 /tui/css 请求 - 返回终端兼容的 CSS"""
+ # 只返回终端支持的 CSS 属性
+ css = """/* TUI 兼容 CSS */
+.tui-page {
+ /* 背景色 - 仅支持 ANSI 颜色 */
+ background-color: #000000;
+ color: #ffffff;
+}
+
+.tui-body {
+ font-family: monospace;
+ font-weight: normal;
+}
+
+/* 字体样式 - TUI 支持 */
+.bold { font-weight: bold; }
+.underline { text-decoration: underline; }
+
+/* 布局 - TUI 简化处理 */
+.tui-container {
+ padding: 0;
+ margin: 0;
+}
+
+/* 交互元素标记 */
+[data-tui-action] {
+ cursor: pointer;
+}
+"""
+ return Response(
+ status=200,
+ headers={"Content-Type": "text/css"},
+ body=css
+ )
+
+ def _handle_tui_interact(self, request):
+ """处理 TUI 交互请求"""
+ import json
+
+ try:
+ body = json.loads(request.body)
+ action = body.get('action', '')
+ target = body.get('target', '')
+
+ # 处理交互
+ if action == 'navigate':
+ # 导航到指定页面
+ html = self._fetch_webui_page(target)
+ if html:
+ self.tui_manager.load_page(target, html)
+ return Response(
+ status=200,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps({'success': True, 'page': target})
+ )
+ elif action == 'click':
+ # 处理点击
+ return Response(
+ status=200,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps({'success': True})
+ )
+ elif action == 'keypress':
+ # 处理按键
+ key = body.get('key', '')
+ return Response(
+ status=200,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps({'success': True, 'key': key})
+ )
+
+ return Response(
+ status=400,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps({'success': False, 'error': 'Unknown action'})
+ )
+
+ except Exception as e:
+ return Response(
+ status=500,
+ headers={"Content-Type": "application/json"},
+ body=json.dumps({'success': False, 'error': str(e)})
+ )
+
+ def stop(self):
+ """停止 TUI"""
+ Log.info("tui", "TUI 停止中...")
+ self.running = False
+
+ if self.tui_thread:
+ self.tui_thread.join(timeout=2)
+
+ Log.ok("tui", "TUI 已停止")
+
+
+register_plugin_type("TUIPlugin", TUIPlugin)
+
+
+def New():
+ return TUIPlugin()
diff --git a/store/@{NebulaShell}/webui/tui/manifest.json b/store/@{NebulaShell}/webui/tui/manifest.json
new file mode 100644
index 0000000..3605d3d
--- /dev/null
+++ b/store/@{NebulaShell}/webui/tui/manifest.json
@@ -0,0 +1,28 @@
+{
+ "metadata": {
+ "name": "tui",
+ "version": "1.0.0",
+ "author": "NebulaShell",
+ "description": "终端用户界面 - 与 WebUI 双启动,通过访问 /tui 接口获取 HTML 并转换为终端显示",
+ "type": "tui"
+ },
+ "config": {
+ "enabled": true,
+ "args": {
+ "width": 80,
+ "height": 24,
+ "theme": "dark",
+ "enable_mouse": false,
+ "keyboard_shortcuts": {
+ "1": "/",
+ "2": "/dashboard",
+ "3": "/logs",
+ "4": "/terminal",
+ "q": "quit",
+ "r": "refresh"
+ }
+ }
+ },
+ "dependencies": ["http-api", "webui"],
+ "permissions": ["read:pages", "execute:commands"]
+}