初步规划TuUi模式,并预留接口

This commit is contained in:
Falck
2026-05-02 13:32:51 +08:00
parent 9f7ca46f96
commit 0783428f80
24 changed files with 597 additions and 116 deletions

View File

@@ -4,9 +4,14 @@
```bash
pip install -r requirements.txt
python -m oss.cli serve # start server on :8080
pip install -e . # register nebula CLI
nebula serve # start server on :8080
# or: python main.py
# or: oss serve (after pip install -e .)
## CLI modes (前后端分离)
- `nebula serve` — 启动后端服务HTTP API + WebUI
- `nebula cli` — 启动 TUI 前端,连接现有后端(默认 localhost:8080
```
## Architecture (minimal core philosophy)
@@ -22,8 +27,9 @@ python -m oss.cli serve # start server on :8080
| Action | Command |
|--------|---------|
| Start server | `python -m oss.cli serve` |
| Show info | `python -m oss.cli info` |
| Start server | `nebula serve` |
| CLI / TUI mode | `nebula cli` (TBD) |
| Show info | `nebula info` |
| Hidden achievements | Prefix with `!!` (e.g., `!!help`, `!!list`, `!!stats`, `!!debug`) |
| Docker | `docker-compose up` (ports 8080-8082) |

170
ai.md
View File

@@ -1,5 +1,8 @@
# NebulaShell AI 开发文档
> **架构决策**`nebula cli` 采用前后端分离设计TUI 前端直连后端 JSON API
> 不使用 HTML→ANSI 转换引擎。详见下文 [TUI 架构决策](#tui-架构决策)。
## 项目介绍
NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念是「一切皆为插件」。它提供了一个最小化的核心系统,仅负责加载 `plugin-loader` 插件,其余 26+ 个官方插件均由该加载器管理。
@@ -23,130 +26,77 @@ NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念
---
## TUI + WebUI 双启动架构
## TUI 架构决策
### 架构概述
### 废弃方案HTML→ANSI 动态转换层v1.3
系统现在默认同时启动 WebUI 和 TUI
- **WebUI**:在浏览器中运行,提供完整的图形界面
- **TUI**:在终端中运行,通过强大的转换层 (v1.3) 自动解析 WebUI 的 `/tui` 接口
**已废弃。** 早期方案通过 `oss/tui/converter.py`1430 行)在运行时将 WebUI 的 HTML 页面解析为终端元素,存在以下问题
### TUI 转换层核心能力 (v1.3)
| 问题 | 说明 |
|------|------|
| **布局失真** | CSS Flex/Grid 布局模型无法映射到终端字符网格 |
| **交互断层** | JavaScript 事件系统只能在终端模拟,与真实浏览器行为不一致 |
| **维护成本高** | 1430 行转换引擎 + 每个 WebUI 页面需维护 TUI 兼容标记 |
| **渲染性能差** | 每次导航需对整个 HTML 进行 DOM 解析和布局计算 |
| **调试困难** | 终端渲染错误难以定位是 HTML 问题还是转换器 Bug |
TUI 转换层是一个强大的渲染引擎,能够自动访问 WebUI 开放的 `/tui` 接口,解析特殊的 `.html` 文件(入口为 `index.html`),并将其转换为终端界面。
#### 支持的组件类型 (64+)
### 当前方案:前后端分离,原生 ANSI 渲染
```
基础组件: text, heading, paragraph, span, divider, spacer
容器组件: container, box, panel, card, grid, flex, stack
表单组件: input, button, checkbox, radio, select, textarea, slider
数据组件: table, list, tree, progress, gauge, chart, stat
导航组件: navbar, sidebar, menu, breadcrumb, tabs, pagination
反馈组件: alert, toast, modal, spinner, tooltip, badge
布局组件: row, col, section, article, aside, header, footer
特殊组件: code, pre, blockquote, mark, kbd, time, avatar
nebula serve ─── JSON API ───→ nebula cli (TUI 前端)
(后端) (原生 ANSI 终端渲染)
```
#### CSS 样式支持
**后端职责**`nebula serve`
- 提供 RESTful JSON API`/api/dashboard/stats`
- WebSocket 实时推送
- 不感知 TUI 存在
转换层支持终端兼容的 CSS 样式
**前端职责**`nebula cli`
- 通过 HTTP/WebSocket 消费后端 JSON 数据
- 使用 ANSI 转义码直接在终端绘制界面
- 不依赖任何 HTML/CSS 解析
```css
/* 颜色系统 */
color: #RGB, #RRGGBB, rgb(), rgba(), hsl(), 颜色名称
background-color: 同上
border-color: 同上
/* 字体排版 */
font-size: small, medium, large, x-large, numeric(pt)
font-weight: normal, bold, bolder, lighter, numeric(100-900)
font-style: normal, italic, oblique
text-decoration: none, underline, overline, line-through
text-align: left, center, right, justify
/* 边框样式 */
border: width style color
border-style: none, solid, double, dashed, rounded, heavy, ascii
border-width: thin, medium, thick, numeric
border-radius: numeric (仅支持 rounded 样式)
/* 布局与间距 */
margin: numeric
padding: numeric
width: numeric, percentage, auto
height: numeric, percentage, auto
display: block, inline, flex, grid, none
/* 特殊效果 */
opacity: 0.0-1.0 (通过字符密度模拟)
white-space: normal, nowrap, pre
overflow: visible, hidden, scroll
```
#### JavaScript 交互支持
转换层模拟基础 JS 交互功能:
```javascript
// 键盘事件
document.addEventListener('keydown', (e) => { ... })
document.addEventListener('keyup', (e) => { ... })
// 鼠标事件 (如果终端支持)
element.addEventListener('click', (e) => { ... })
element.addEventListener('mouseover', (e) => { ... })
element.addEventListener('mouseout', (e) => { ... })
// 焦点管理
element.focus()
element.blur()
// 类名切换
element.classList.add('active')
element.classList.remove('active')
element.classList.toggle('active')
```
### 使用方式
#### 启动服务
```bash
# 方式 1: 直接启动
python main.py
# 方式 2: 模块方式
python -m oss.cli serve
# 方式 3: Docker
docker run -p 8080:8080 nebulashell:latest
```
启动后:
- WebUI 自动在默认浏览器打开 (通常是 http://localhost:8080)
- TUI 在终端中自动渲染,显示相同内容
#### TUI 交互
- **方向键**:导航焦点元素
- **Enter/Space**:激活按钮/复选框
- **Tab/Shift+Tab**:切换焦点
- **q / Ctrl+C**:退出 TUI (WebUI 继续运行)
- **鼠标点击**:如果终端支持鼠标,可直接点击交互
#### 访问 /tui 接口
WebUI 需要开放 `/tui` 路径提供特殊 HTML
#### 技术要点
```
GET /tui/index.html - TUI 主页面
GET /tui/*.html - 其他 TUI 页面
GET /tui/*.css - TUI 专用样式
GET /tui/*.js - TUI 交互逻辑
终端控制:
raw mode ─── tty.setraw(),单字节读取
ONLCR ─── 重新开启 \n→\r\n 映射,避免阶梯乱码
SGR 鼠标 ─── \x1b[?1000h\x1b[?1006h解析 \x1b[<button;x;y;M
SIGWINCH ─── 捕获终端 resize全屏重绘
ANSI 绘制:
24-bit 真彩色 ─── \x1b[38;2;R;G;Bm / \x1b[48;2;R;G;Bm
字符图形 ─── █ ░ ▓ 等 Unicode 块字符作进度条
光标定位 ─── \x1b[{row};{col}H
页面导航:
热点区域映射 ─── 预计算每行点击区域 (y → page_id)
键盘备选 ─── 数字键 1-5 作为鼠标候补
```
这些文件不包含给用户直接查看的内容,而是包含特殊的 `data-tui-*` 标记供转换层解析。
### 开放代码opencodeTUI 架构分析
[opencode](https://opencode.ai) 是目前最成熟的终端 AI 编程助手,其 TUI 架构可参考:
| 特性 | opencode 实现 |
|------|-------------|
| **渲染引擎** | 原生终端渲染,无 HTML 中间层。直接操作终端缓冲区 |
| **组件系统** | 类似 React 的组件化方案,但所有组件直接输出 ANSI 字符串 |
| **输入处理** | raw mode + SGR 鼠标 + 组合键解析,支持 `Tab` 切换模式 |
| **布局** | 弹性布局引擎,组件根据终端宽度自动折行/折叠 |
| **状态管理** | 全局状态树,每次状态变更触发受控重绘(非全屏重绘)|
| **会话管理** | 多会话并行,每个会话独立维护上下文和渲染状态 |
| **主题系统** | 完整的配色方案,支持暗色/亮色主题切换 |
**关键差异**opencode 不使用任何 Web 技术栈做 TUI其所有界面元素输入框、按钮、列表、状态栏、侧边栏都是直接通过 ANSI 转义码在终端绘制的。每个组件是一个纯 Python/TypeScript 类,`render()` 方法返回 ANSI 字符串。
**对我们的启发**
1. 放弃 HTML 转换层是正确的方向
2. 直接 ANSI 渲染的架构更可控、性能更好
3. 需要设计自己的组件化终端渲染库(参考 opencode 的组件系统)
4. `nebula cli` 命令已预留,后续在此框架上构建原生 TUI
---

View File

@@ -1,5 +1,5 @@
{
"root_dir": "/workspace/data/website",
"root_dir": "/root/NebulaShell/data/website",
"index_file": "index.html",
"static_prefix": "/static"
}

Binary file not shown.

View File

@@ -41,7 +41,7 @@ def cli(ctx, config):
@click.option('--tcp-port', type=int, default=None, help='HTTP TCP 端口')
@click.pass_context
def serve(ctx, host, port, tcp_port):
"""启动 NebulaShell"""
"""启动 NebulaShell 服务端"""
config = ctx.obj.get('config', get_config())
# 命令行参数覆盖配置
@@ -113,7 +113,27 @@ def info(ctx):
click.echo("🤔 听说有人用 !! 开头的命令发现了不得了的东西...")
@cli.command(name="cli")
@click.option('--connect-host', default='127.0.0.1', help='后端地址(默认 127.0.0.1')
@click.option('--connect-port', default=8080, help='后端端口(默认 8080')
def cli_command(connect_host, connect_port):
"""启动 TUI 前端(前后端分离,连接已有后端)"""
click.echo("NebulaShell TUI 客户端(待实现)")
click.echo(f"目标后端:{connect_host}:{connect_port}")
def main():
# 检测是否通过已弃用的 oss 命令调用
cmd_name = os.path.basename(sys.argv[0])
if cmd_name in ("oss", "oss.exe"):
print("╔══════════════════════════════════════════╗")
print("║ ⚠ oss 命令已弃用,请使用 nebula 替代 ║")
print("║ 例如: nebula serve ║")
print("║ nebula info ║")
print("║ nebula version ║")
print("╚══════════════════════════════════════════╝")
sys.exit(1)
# 检查隐藏命令前缀
if len(sys.argv) > 1 and sys.argv[1].startswith("!!"):
if _ACHIEVEMENTS_ENABLED:

491
oss/tui/client.py Normal file
View File

@@ -0,0 +1,491 @@
"""TUI 客户端 - 前后端分离的 TUI 前端
通过 HTTP 连接后端 nebula serve消费 JSON API
直接使用 ANSI 转义码绘制专业终端界面。
支持鼠标点击导航。
"""
import sys
import json
import time
import tty
import termios
import signal
import socket
import urllib.request
import urllib.error
import shutil
import re
from typing import Optional
# ── ANSI 工具 ────────────────────────────────────────────
def fg(r, g, b): return f"\x1b[38;2;{r};{g};{b}m"
def bg(r, g, b): return f"\x1b[48;2;{r};{g};{b}m"
def bold(s): return f"\x1b[1m{s}\x1b[22m"
def dim(s): return f"\x1b[2m{s}\x1b[22m"
def rst(): return "\x1b[0m"
C = {
"header_bg": (30, 30, 46),
"status_bg": (30, 30, 46),
"accent": (0, 255, 135),
"green": (0, 255, 135),
"yellow": (255, 220, 80),
"red": (255, 80, 80),
"cyan": (80, 200, 255),
"dim": (100, 100, 120),
"white": (220, 220, 240),
"bar_bg": (50, 50, 70),
}
# ── 鼠标转义 ────────────────────────────────────────────
_MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1006h"
_MOUSE_OFF = "\x1b[?1006l\x1b[?1002l\x1b[?1000l"
# ── HTTP 请求 ────────────────────────────────────────────
def http_get(url: str, timeout=5) -> Optional[str]:
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8")
except Exception:
return None
def backend_alive(host="127.0.0.1", port=8080) -> bool:
try:
s = socket.create_connection((host, port), timeout=2)
s.close()
return True
except OSError:
return False
# ── 布局工具 ────────────────────────────────────────────
def term_size():
return shutil.get_terminal_size((80, 24))
def hbar(width: int, percent: float, color_fg=(0, 255, 135), color_bg=(50, 50, 70), char=""):
filled = max(0, min(width, int(width * percent / 100)))
empty = width - filled
bar = fg(*color_fg) + char * filled + rst() + fg(*color_bg) + "" * empty + rst()
return bar
# ── TUI 客户端 ──────────────────────────────────────────
Page = dict # {"id": str, "label": str, "desc": str}
class TUIClient:
_resize_flag = False
@classmethod
def _sigwinch(cls, sig, frame):
cls._resize_flag = True
PAGES: list[Page] = [
{"id": "welcome", "label": "首页", "desc": "系统概览"},
{"id": "dashboard", "label": "仪表盘", "desc": "CPU · 内存 · 磁盘 · 网络"},
{"id": "logs", "label": "日志", "desc": "实时日志输出"},
{"id": "terminal", "label": "终端", "desc": "Shell"},
{"id": "plugins", "label": "插件", "desc": "插件管理"},
]
def __init__(self, host="127.0.0.1", port=8080):
self.host = host
self.port = port
self.base_url = f"http://{host}:{port}"
self.running = False
self.current_page = "welcome"
self.width = 80
self.height = 24
self._stats_cache = {}
self._stats_time = 0
# 鼠标点击区域: list of (y, page_id)
self._click_zones: list[tuple[int, str]] = []
def _fetch_stats(self) -> dict:
now = time.time()
if now - self._stats_time < 1 and self._stats_cache:
return self._stats_cache
raw = http_get(f"{self.base_url}/api/dashboard/stats")
if raw:
try:
self._stats_cache = json.loads(raw)
self._stats_time = now
except json.JSONDecodeError:
pass
return self._stats_cache
# ── 鼠标事件 ──────────────────────────────────────────
@staticmethod
def _parse_sgr_mouse(data: str):
"""解析 SGR 鼠标事件 \x1b[<button;x;y;M/m → (button, x, y)"""
m = re.match(r"^\x1b\[<(\d+);(\d+);(\d+)([Mm])$", data)
if m:
return int(m.group(1)), int(m.group(2)), int(m.group(3))
return None
# ── 屏幕渲染 ──────────────────────────────────────────
def _draw_header(self):
w = self.width
alive = backend_alive(self.host, self.port)
status_icon = "" if alive else ""
status_color = C["green"] if alive else C["red"]
print(bg(*C["header_bg"]), end="")
print(" " * w, end="")
print(f"\r{bg(*C['header_bg'])} ", end="")
title = " NebulaShell TUI "
print(fg(*C["accent"]) + bold(title) + rst(), end="")
print(bg(*C["header_bg"]), end="")
right = f" {fg(*status_color)}{status_icon}{rst()}{bg(*C['header_bg'])} {fg(*C['dim'])}{self.host}:{self.port}{rst()}"
print(f"\x1b[{w - len(right) + 1}G{right}", end="")
print(rst())
def _draw_status_bar(self):
w = self.width
print(bg(*C["status_bg"]), end="")
print(" " * w, end="")
print(f"\r{bg(*C['status_bg'])} ", end="")
nav_hint = f"{fg(*C['dim'])}数字/点击导航 q 退出 r 刷新{rst()}"
page_name = self.current_page.upper()
page_info = f"{fg(*C['cyan'])}{page_name}{rst()}"
print(f"\r{bg(*C['status_bg'])} {page_info}", end="")
print(f"\x1b[{w - len(nav_hint) + 1}G{nav_hint}", end="")
print(rst())
def _clear(self):
print("\x1b[2J\x1b[H", end="")
def _render_all(self):
self._click_zones.clear()
self._clear()
self._draw_header()
print()
content_top = 2 # header(1) + blank(1) = 2
alive = backend_alive(self.host, self.port)
if self.current_page == "welcome":
self._render_welcome(alive, content_top)
elif self.current_page == "dashboard":
self._render_dashboard()
elif self.current_page == "logs":
self._render_logs()
elif self.current_page == "terminal":
self._render_terminal()
elif self.current_page == "plugins":
self._render_plugins()
else:
self._render_home()
used = self._content_lines + 4
for _ in range(self.height - used):
print()
self._draw_status_bar()
sys.stdout.flush()
# ── 页面内容 ──────────────────────────────────────────
def _render_welcome(self, alive: bool, top: int):
w = self.width
self._content_lines = 0
logo = [
"███╗ ██╗███████╗██████╗ ██╗ ██╗██╗ █████╗ ",
"████╗ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔══██╗",
"██╔██╗ ██║█████╗ ██████╔╝██║ ██║██║ ███████║",
"██║╚██╗██║██╔══╝ ██╔══██╗██║ ██║██║ ██╔══██║",
"██║ ╚████║███████╗██████╔╝╚██████╔╝███████╗██║ ██║",
"╚═╝ ╚═══╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝",
]
for line in logo:
print(" " + fg(*C["accent"]) + line + rst())
self._content_lines += 1
print()
self._content_lines += 1
print(f" {fg(*C['dim'])}一切皆为插件的开发者工具运行时框架{rst()}")
self._content_lines += 1
print()
self._content_lines += 1
if alive:
print(f" {fg(*C['green'])}● 后端已连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
else:
print(f" {fg(*C['red'])}○ 后端未连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
self._content_lines += 1
print(f" {fg(*C['dim'])}请先启动后端: nebula serve{rst()}")
self._content_lines += 1
print()
self._content_lines += 1
print(f" {fg(*C['dim'])}─ 点击或按键导航 ─{rst()}")
self._content_lines += 1
for i, pg in enumerate(self.PAGES):
line_y = top + self._content_lines
self._click_zones.append((line_y, pg["id"]))
key = str(i + 1) if i < 9 else "0"
print(f" [{fg(*C['accent'])}{key}{rst()}] {bold(pg['label'])} {fg(*C['dim'])}{pg['desc']}{rst()}")
self._content_lines += 1
def _render_home(self):
w = self.width
self._content_lines = 0
stats = self._fetch_stats()
if not stats:
print(f" {fg(*C['dim'])}无法获取系统信息{rst()}")
self._content_lines += 1
return
uptime = stats.get("uptime", "N/A")
processes = stats.get("processes", 0)
cpu = stats.get("cpu", {})
mem = stats.get("ram", {})
disk = stats.get("disk", {})
print(f" {bold('系统概览')}")
self._content_lines += 1
print(f" {fg(*C['dim'])}运行时间: {uptime} 进程数: {processes}{rst()}")
self._content_lines += 1
print()
self._content_lines += 1
bar_w = w - 30
cpu_p = cpu.get("percent", 0)
print(f" CPU {fg(*C['dim'])}{str(cpu_p).rjust(5)}%{rst()} {hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])}")
self._content_lines += 1
ram_p = mem.get("percent", 0)
ram_u = mem.get("used", 0)
ram_t = mem.get("total", 0)
print(f" 内存 {fg(*C['dim'])}{str(ram_p).rjust(5)}%{rst()} {hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
self._content_lines += 1
disk_p = disk.get("percent", 0)
disk_u = disk.get("used", 0)
disk_t = disk.get("total", 0)
print(f" 磁盘 {fg(*C['dim'])}{str(disk_p).rjust(5)}%{rst()} {hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
self._content_lines += 1
net = stats.get("network", {})
recv = net.get("recv_rate", 0)
sent = net.get("sent_rate", 0)
latency = stats.get("latency", 0)
print(f" 网络 {fg(*C['dim'])}{self._fmt_bytes(recv)}/s ▲ {self._fmt_bytes(sent)}/s 延迟: {latency}ms{rst()}")
self._content_lines += 1
load = stats.get("load", {})
l1 = load.get("load1", 0)
l5 = load.get("load5", 0)
l15 = load.get("load15", 0)
print(f" 负载 {fg(*C['dim'])}1m: {l1} 5m: {l5} 15m: {l15}{rst()}")
self._content_lines += 1
def _render_dashboard(self):
w = self.width
self._content_lines = 0
stats = self._fetch_stats()
if not stats:
print(f" {fg(*C['dim'])}无法获取仪表盘数据{rst()}")
self._content_lines += 1
return
print(f" {bold('系统仪表盘')} 实时监控")
self._content_lines += 1
cpu = stats.get("cpu", {})
mem = stats.get("ram", {})
disk = stats.get("disk", {})
net = stats.get("network", {})
disk_io = stats.get("disk_io", {})
load = stats.get("load", {})
latency = stats.get("latency", 0)
processes = stats.get("processes", 0)
uptime = stats.get("uptime", "N/A")
bar_w = w - 36
print()
self._content_lines += 1
cpu_p = cpu.get("percent", 0)
cpu_cores = cpu.get("cores", 0)
print(f" {fg(*C['cyan'])}CPU {rst()}{hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])} {fg(*C['white'])}{cpu_p}%{rst()} {fg(*C['dim'])}({cpu_cores} 核){rst()}")
self._content_lines += 1
ram_p = mem.get("percent", 0)
ram_u = mem.get("used", 0)
ram_t = mem.get("total", 0)
print(f" {fg(*C['cyan'])}内存 {rst()}{hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['white'])}{ram_p}%{rst()} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
self._content_lines += 1
disk_p = disk.get("percent", 0)
disk_u = disk.get("used", 0)
disk_t = disk.get("total", 0)
print(f" {fg(*C['cyan'])}磁盘 {rst()}{hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['white'])}{disk_p}%{rst()} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
self._content_lines += 1
print()
self._content_lines += 1
recv = net.get("recv_rate", 0)
sent = net.get("sent_rate", 0)
tr = net.get("total_recv", 0)
ts = net.get("total_sent", 0)
print(f" {fg(*C['cyan'])}网络 {rst()}{fg(*C['green'])}{self._fmt_bytes(recv)}/s{rst()}{fg(*C['yellow'])}{self._fmt_bytes(sent)}/s{rst()} {fg(*C['dim'])}总量: {self._fmt_bytes(tr)} / {self._fmt_bytes(ts)}{rst()}")
self._content_lines += 1
disk_r = disk_io.get("read_rate", 0)
disk_w = disk_io.get("write_rate", 0)
print(f" {fg(*C['cyan'])}磁盘IO {rst()}{fg(*C['green'])}{self._fmt_bytes(disk_r)}/s{rst()}{fg(*C['yellow'])}{self._fmt_bytes(disk_w)}/s{rst()}")
self._content_lines += 1
print()
self._content_lines += 1
l1 = load.get("load1", 0)
l5 = load.get("load5", 0)
l15 = load.get("load15", 0)
print(f" {fg(*C['cyan'])}负载 {rst()}1m: {fg(*C['white'])}{l1}{rst()} 5m: {fg(*C['white'])}{l5}{rst()} 15m: {fg(*C['white'])}{l15}{rst()} 进程: {fg(*C['white'])}{processes}{rst()} 延迟: {fg(*C['white'])}{latency}ms{rst()}")
self._content_lines += 1
print(f" {fg(*C['cyan'])}运行 {rst()}{fg(*C['dim'])}{uptime}{rst()}")
self._content_lines += 1
def _render_logs(self):
self._content_lines = 0
print(f" {bold('系统日志')}")
self._content_lines += 1
print(f" {fg(*C['dim'])}实时日志输出(待实现){rst()}")
self._content_lines += 1
def _render_terminal(self):
self._content_lines = 0
print(f" {bold('终端')}")
self._content_lines += 1
print(f" {fg(*C['dim'])}Shell 终端(待实现){rst()}")
self._content_lines += 1
def _render_plugins(self):
self._content_lines = 0
print(f" {bold('插件管理')}")
self._content_lines += 1
print(f" {fg(*C['dim'])}插件列表(待实现){rst()}")
self._content_lines += 1
# ── 工具 ──────────────────────────────────────────────
@staticmethod
def _fmt_bytes(b):
if b > 1024**3:
return f"{b/(1024**3):.1f}G"
if b > 1024**2:
return f"{b/(1024**2):.1f}M"
if b > 1024:
return f"{b/1024:.1f}K"
return f"{b:.0f}B"
# ── 主循环 ────────────────────────────────────────────
def _navigate(self, page_id: str):
self._stats_cache = {}
self._stats_time = 0
self.current_page = page_id
self.width, self.height = term_size()
self._render_all()
def run(self):
self.width, self.height = term_size()
self.running = True
self._render_all()
signal.signal(signal.SIGWINCH, TUIClient._sigwinch)
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
# setraw 会关闭 ONLCR\n→\r\n重新开启避免阶梯乱码
attrs = termios.tcgetattr(fd)
attrs[1] = attrs[1] | termios.ONLCR
termios.tcsetattr(fd, termios.TCSANOW, attrs)
sys.stdout.write(_MOUSE_ON)
sys.stdout.flush()
buf = ""
while self.running:
# 终端 resize 检测
if TUIClient._resize_flag:
TUIClient._resize_flag = False
self.width, self.height = term_size()
self._render_all()
ch = sys.stdin.read(1)
buf += ch
# 检测 SGR 鼠标事件结束符 M/m
if buf.startswith("\x1b[<") and ch in ("M", "m"):
ev = self._parse_sgr_mouse(buf)
buf = ""
if ev:
button, mx, my = ev
if button == 0 and ch == "M": # 左键按下
for zy, page_id in self._click_zones:
if my == zy + 1: # 鼠标坐标 1-based
self._navigate(page_id)
break
continue
# 非鼠标序列 → 重置缓冲区
if not buf.startswith("\x1b"):
pass
elif buf.startswith("\x1b[<"):
continue # 等待更多字符
elif len(buf) > 1:
buf = "" # 其他转义序列,丢弃
# 处理单字符输入
if len(buf) == 1:
c = buf
buf = ""
if c in ("q", "Q", "\x03", "\x04"):
break
elif c == "1":
self._navigate("welcome")
elif c == "2":
self._navigate("dashboard")
elif c == "3":
self._navigate("logs")
elif c == "4":
self._navigate("terminal")
elif c == "5":
self._navigate("plugins")
elif c in ("r", "R"):
self._stats_cache = {}
self._stats_time = 0
self._render_all()
except Exception:
pass
finally:
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
sys.stdout.write(_MOUSE_OFF)
sys.stdout.flush()
termios.tcsetattr(fd, termios.TCSADRAIN, old)
print("\x1b[2J\x1b[H\x1b[0mTUI 已退出\n")

View File

@@ -1344,6 +1344,14 @@ class TUIManager:
else:
self.show_error(f"Page not found: {path}")
def render_page(self, path: str = None) -> str:
"""渲染指定页面,返回终端文本(不写入画布)"""
path = path or self.current_page
if not path or path not in self.pages:
return ""
html = self.pages[path]
return self.renderer.render_with_frame(html, title=f"NebulaShell - {path}")
def render_current(self):
"""渲染当前页面"""
if not self.current_page or self.current_page not in self.pages:

View File

@@ -620,6 +620,11 @@ export default TUI;
})
)
def wait_for_exit(self):
"""前台阻塞等待 TUI 退出(用于 CLI 模式)"""
if self.tui_thread and self.tui_thread.is_alive():
self.tui_thread.join()
def stop(self):
"""停止 TUI"""
Log.info("tui", "TUI 停止中...")

View File

@@ -14,6 +14,7 @@ dependencies = [
]
[project.scripts]
nebula = "oss.cli:main"
oss = "oss.cli:main"
[tool.setuptools.packages.find]