Coverage for agentos/desktop/tui.py: 11%

310 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1""" 

2Terminal User Interface (TUI) — Textual-based native terminal agent cockpit. 

3 

4OpenCode/Cursor-style terminal experience: 

5 - Four-panel layout: file tree | chat/result | terminal/editor | market 

6 - Full keyboard navigation (vim-style + standard shortcuts) 

7 - Real-time streaming output 

8 - Session persistence 

9 - Dark/light themes 

10 - Skill marketplace browser (ctrl+m) 

11 

12Requirements: pip install textual 

13 

14Usage: 

15 agentos tui # Launch TUI 

16 agentos tui --safe # Safe mode (read-only) 

17 agentos tui --theme dark # Dark theme (default) 

18 agentos tui --market # Open market panel on start 

19 agentos tui --store-url :18900 # Custom skill store server URL 

20""" 

21 

22from __future__ import annotations 

23 

24import asyncio 

25import json 

26import os 

27import time 

28from dataclasses import dataclass, field 

29from pathlib import Path 

30from typing import Any, Optional 

31 

32try: 

33 from textual.app import App, ComposeResult 

34 from textual.widgets import ( 

35 Header, Footer, Tree, TextArea, Input, Static, RichLog, 

36 ListView, ListItem, Label, Button, TabbedContent, TabPane, 

37 ) 

38 from textual.containers import Horizontal, Vertical, Container, ScrollableContainer 

39 from textual.binding import Binding 

40 from textual.reactive import reactive 

41 from textual.screen import ModalScreen 

42 from textual.message import Message 

43 TEXTUAL_AVAILABLE = True 

44except ImportError: 

45 TEXTUAL_AVAILABLE = False 

46 

47 

48# ── Models ── 

49 

50@dataclass 

51class TUIConfig: 

52 """TUI persistent configuration.""" 

53 theme: str = "dark" 

54 work_dir: str = field(default_factory=lambda: str(Path.home())) 

55 font_size: int = 14 

56 max_history: int = 500 

57 auto_scroll: bool = True 

58 store_url: str = "http://127.0.0.1:18900" 

59 

60 def save(self, path: str = "~/.agentos/tui.json"): 

61 p = Path(path).expanduser() 

62 p.parent.mkdir(parents=True, exist_ok=True) 

63 p.write_text(json.dumps({ 

64 "theme": self.theme, "work_dir": self.work_dir, 

65 "font_size": self.font_size, "max_history": self.max_history, 

66 "auto_scroll": self.auto_scroll, "store_url": self.store_url, 

67 }, indent=2)) 

68 

69 @classmethod 

70 def load(cls, path: str = "~/.agentos/tui.json") -> "TUIConfig": 

71 p = Path(path).expanduser() 

72 if p.exists(): 

73 data = json.loads(p.read_text()) 

74 return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) 

75 return cls() 

76 

77 

78# ── Stubs (when textual not installed) ── 

79 

80if not TEXTUAL_AVAILABLE: 

81 class FileTree: pass 

82 class ChatArea: pass 

83 class TerminalPanel: pass 

84 class StatusBar: pass 

85 class MarketPanel: pass 

86 class _StubApp: 

87 def run(self): raise RuntimeError("textual not installed: pip install textual") 

88 

89 

90if TEXTUAL_AVAILABLE: 

91 

92 class FileTree(Vertical): 

93 """Left panel: clickable file tree.""" 

94 def compose(self) -> ComposeResult: 

95 yield Static(" File Tree ", id="panel-title") 

96 yield Tree("~/", id="file-tree") 

97 

98 def on_mount(self) -> None: 

99 self._populate_tree() 

100 

101 def _populate_tree(self) -> None: 

102 tree = self.query_one("#file-tree", Tree) 

103 tree.clear() 

104 root = tree.root 

105 root.set_label(str(Path.home())) 

106 try: 

107 items = sorted(Path.home().iterdir(), key=lambda p: (p.is_file(), p.name)) 

108 for item in items[:50]: 

109 icon = " " if item.is_dir() else " " 

110 node = root.add(f"{icon}{item.name}", data=str(item)) 

111 if item.is_dir(): 

112 self._add_dir_children(node, item, depth=0) 

113 except PermissionError: 

114 pass 

115 

116 def _add_dir_children(self, parent, path: Path, depth: int): 

117 if depth > 1: 

118 return 

119 try: 

120 for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name))[:15]: 

121 icon = " " if child.is_dir() else " " 

122 node = parent.add(f"{icon}{child.name}", data=str(child)) 

123 if child.is_dir(): 

124 self._add_dir_children(node, child, depth + 1) 

125 except PermissionError: 

126 pass 

127 

128 

129 class ChatArea(Vertical): 

130 """Center panel: chat interface.""" 

131 def compose(self) -> ComposeResult: 

132 yield Static(" Chat ", id="panel-title") 

133 yield RichLog(id="chat-log", highlight=True, markup=True) 

134 yield Input(placeholder="Ask anything... (Enter to send)", id="chat-input") 

135 

136 def add_message(self, role: str, text: str): 

137 log = self.query_one("#chat-log", RichLog) 

138 if role == "user": 

139 log.write(f"\n[bold green]>[/bold green] {text}") 

140 elif role == "error": 

141 log.write(f"\n[bold red]![/bold red] {text}") 

142 else: 

143 log.write(f"\n[bold blue]•[/bold blue] {text}") 

144 

145 

146 class TerminalPanel(Vertical): 

147 """Right panel: terminal / shell output.""" 

148 def compose(self) -> ComposeResult: 

149 yield Static(" Terminal ", id="panel-title") 

150 yield RichLog(id="terminal-log", highlight=True, markup=True) 

151 yield Input(placeholder="$ command...", id="terminal-input") 

152 

153 def on_mount(self) -> None: 

154 log = self.query_one("#terminal-log", RichLog) 

155 log.write("[dim]$ agentos tui started[/dim]") 

156 log.write(f"[dim] cwd: {os.getcwd()}[/dim]") 

157 

158 

159 class StatusBar(Horizontal): 

160 """Bottom status bar with metrics.""" 

161 def compose(self) -> ComposeResult: 

162 yield Static(" Sessions: 0 ", id="status-sessions") 

163 yield Static(" Tasks: 0 ", id="status-tasks") 

164 yield Static(" Mode: READY ", id="status-mode") 

165 

166 

167 class MarketPanel(ScrollableContainer): 

168 """Skill marketplace panel with embedded web-like view. 

169 

170 Shows skill sources from multiple marketplaces (OpenClaw, ClawHub, 

171 SkillsMP, LobeHub, etc.) and supports one-click install for compatible 

172 sources. External sources open in the system browser. 

173 

174 Requires the skill store server: agentos skill-store 

175 """ 

176 

177 class SkillInstalled(Message): 

178 """Posted when a skill is installed via the market.""" 

179 def __init__(self, skill_name: str, source: str) -> None: 

180 self.skill_name = skill_name 

181 self.source = source 

182 super().__init__() 

183 

184 def __init__(self, store_url: str = "http://127.0.0.1:18900", **kwargs): 

185 super().__init__(**kwargs) 

186 self._store_url = store_url 

187 self._sources: list[dict] = [] 

188 self._installed: set[str] = set() 

189 self._active_source: str = "openclaw" 

190 self._skills: list[dict] = [] 

191 

192 def compose(self) -> ComposeResult: 

193 yield Static(" Skill Marketplace ", id="panel-title") 

194 with Horizontal(): 

195 with Vertical(classes="market-sidebar", id="market-sidebar-container"): 

196 yield Static(" Sources", classes="market-section-title") 

197 yield ListView(id="market-source-list") 

198 with Vertical(classes="market-content", id="market-content-container"): 

199 yield Static(" Skills", classes="market-section-title") 

200 yield Input(placeholder="Search skills...", id="market-search") 

201 yield RichLog(id="market-skill-log", highlight=True, markup=True) 

202 

203 def on_mount(self) -> None: 

204 self._init_sources() 

205 self._load_skills() 

206 

207 def _init_sources(self) -> None: 

208 try: 

209 import urllib.request, json as _json 

210 with urllib.request.urlopen(f"{self._store_url}/api/sources", timeout=5) as resp: 

211 self._sources = _json.loads(resp.read()) 

212 except Exception: 

213 self._sources = [ 

214 {"id": "openclaw", "name": "OpenClaw Skill Store", "skill_count": "14+", 

215 "installable": True, "web_url": "https://github.com/nicepkg/openclaw-skill-store", 

216 "description": "OpenClaw 官方社区技能商店"}, 

217 {"id": "clawhub", "name": "ClawHub", "skill_count": "5,700+", 

218 "installable": False, "web_url": "https://github.com/clawhub-community/skills", 

219 "description": "ClawHub 社区技能聚合"}, 

220 {"id": "skillsmp", "name": "SkillsMP", "skill_count": "164万+", 

221 "installable": False, "web_url": "https://skills.mp/", 

222 "description": "技能界的 Google,最大索引平台"}, 

223 {"id": "lobehub", "name": "LobeHub Skills", "skill_count": "28万+", 

224 "installable": False, "web_url": "https://lobehub.com/skills", 

225 "description": "LobeHub 生态精品平台"}, 

226 {"id": "skillhub", "name": "SkillHub Club", "skill_count": "1.6万+", 

227 "installable": False, "web_url": "https://skillhub.club/", 

228 "description": "AI 评分品质筛选市集"}, 

229 {"id": "skills_sh", "name": "skills.sh", "skill_count": "67万+", 

230 "installable": False, "web_url": "https://skills.sh/", 

231 "description": "Vercel Labs 一键安装平台"}, 

232 {"id": "awesome", "name": "awesome-agent-skills", "skill_count": "380+", 

233 "installable": False, "web_url": "https://github.com/nicepkg/awesome-agent-skills", 

234 "description": "人工审核精选技能合集"}, 

235 ] 

236 

237 lst = self.query_one("#market-source-list", ListView) 

238 lst.clear() 

239 for src in self._sources: 

240 icon = "[bold green]↓[/bold green]" if src.get("installable") else "[bold blue]↗[/bold blue]" 

241 cnt = src.get("skill_count", "?") 

242 lst.append(ListItem( 

243 Label(f"{icon} {src['name']} [dim]({cnt})[/dim]"), 

244 name=src["id"], 

245 )) 

246 

247 def on_list_view_selected(self, event: ListView.Selected) -> None: 

248 if event.item.name: 

249 raw = event.item.name 

250 self._active_source = raw.value if hasattr(raw, 'value') else str(raw) 

251 self._load_skills() 

252 

253 def on_input_submitted(self, event: Input.Submitted) -> None: 

254 if event.input.id == "market-search": 

255 self._filter_skills(event.value.strip()) 

256 

257 def _load_skills(self) -> None: 

258 log = self.query_one("#market-skill-log", RichLog) 

259 log.clear() 

260 src = next((s for s in self._sources if s["id"] == self._active_source), None) 

261 if not src: 

262 log.write("[bold red]Source not found[/bold red]") 

263 return 

264 

265 if not src.get("installable"): 

266 log.write(f"[bold blue]{src['name']}[/bold blue]") 

267 log.write(f"[dim]{src.get('description', 'External marketplace')}[/dim]\n") 

268 log.write(f"[bold]Open in browser:[/bold]") 

269 log.write(f" [link={src['web_url']}]{src['web_url']}[/link]") 

270 log.write(f" [link={src.get('url', src['web_url'])}]{src.get('url', '')}[/link]\n") 

271 log.write("[dim]External marketplace — open the URL above in your browser to browse and install.[/dim]") 

272 return 

273 

274 try: 

275 import urllib.request, json as _json 

276 url = f"{self._store_url}/api/skills?source={self._active_source}" 

277 with urllib.request.urlopen(url, timeout=5) as resp: 

278 data = _json.loads(resp.read()) 

279 self._skills = data.get("skills", []) 

280 except Exception: 

281 self._skills = self._fallback_skills() 

282 

283 log.write(f"[bold blue]{src['name']}[/bold blue] [dim]({len(self._skills)} skills)[/dim]\n") 

284 for skill in self._skills: 

285 name = skill["name"] 

286 desc = skill.get("description", "") 

287 tags = " ".join(f"[dim]#{t}[/dim]" for t in skill.get("tags", [])) 

288 installed = "[bold green]✓[/bold green]" if name in self._installed else "[dim]○[/dim]" 

289 log.write(f" {installed} [bold]{name}[/bold] {tags}") 

290 log.write(f" [dim]{desc}[/dim]") 

291 log.write("") 

292 log.write(f"[dim]Use 'agentos skill-store' to start the web UI for one-click install.[/dim]") 

293 

294 def _fallback_skills(self) -> list[dict]: 

295 return [ 

296 {"name": "skill-creator", "description": "Create new skills from templates", "tags": ["meta"]}, 

297 {"name": "pdf-tools", "description": "PDF manipulation, merge, split, extract", "tags": ["document"]}, 

298 {"name": "xlsx-tools", "description": "Excel/Spreadsheet processing", "tags": ["document"]}, 

299 {"name": "docx-tools", "description": "Word document processing", "tags": ["document"]}, 

300 {"name": "pptx-tools", "description": "PowerPoint generation", "tags": ["document"]}, 

301 {"name": "image-tools", "description": "Image processing, resize, convert", "tags": ["media"]}, 

302 {"name": "web-search", "description": "Advanced web search with multiple engines", "tags": ["search"]}, 

303 {"name": "browser-automation", "description": "Browser automation with Playwright", "tags": ["browser"]}, 

304 {"name": "code-review", "description": "Automated code review", "tags": ["code"]}, 

305 {"name": "git-tools", "description": "Git workflow automation", "tags": ["git"]}, 

306 {"name": "file-organizer", "description": "File organization and cleanup", "tags": ["files"]}, 

307 {"name": "data-analysis", "description": "Data analysis and visualization", "tags": ["data"]}, 

308 {"name": "api-tester", "description": "API testing and docs generation", "tags": ["api"]}, 

309 {"name": "markdown-tools", "description": "Markdown editing and conversion", "tags": ["document"]}, 

310 ] 

311 

312 def _filter_skills(self, query: str) -> None: 

313 log = self.query_one("#market-skill-log", RichLog) 

314 if not query: 

315 self._load_skills() 

316 return 

317 log.clear() 

318 matched = [s for s in self._skills if 

319 query.lower() in s["name"].lower() or 

320 query.lower() in s.get("description", "").lower()] 

321 log.write(f"[dim]Search: '{query}' — {len(matched)} results[/dim]\n") 

322 for skill in matched: 

323 name = skill["name"] 

324 desc = skill.get("description", "") 

325 installed = "[bold green]✓[/bold green]" if name in self._installed else "[dim]○[/dim]" 

326 log.write(f" {installed} [bold]{name}[/bold]") 

327 log.write(f" [dim]{desc}[/dim]") 

328 

329 def set_store_url(self, url: str) -> None: 

330 self._store_url = url 

331 

332 @property 

333 def installed_skills(self) -> set[str]: 

334 return self._installed 

335 

336 

337 # ── Main Application ── 

338 

339 class AgentOSTUI(App): 

340 """Main TUI application — four-panel agent cockpit.""" 

341 

342 CSS = """ 

343 Screen { 

344 layout: grid; 

345 grid-size: 3 3; 

346 grid-gutter: 1 2; 

347 background: $surface; 

348 } 

349 

350 #panel-file { 

351 row-span: 2; 

352 border: solid $primary; 

353 background: $panel; 

354 } 

355 

356 #panel-chat { 

357 row-span: 2; 

358 border: solid $primary; 

359 background: $panel; 

360 } 

361 

362 #panel-terminal { 

363 row-span: 2; 

364 border: solid $primary; 

365 background: $panel; 

366 } 

367 

368 #panel-market { 

369 row-span: 2; 

370 border: solid $success; 

371 background: $panel; 

372 } 

373 

374 #panel-status { 

375 column-span: 3; 

376 height: 1; 

377 background: $primary-darken-2; 

378 color: $text; 

379 } 

380 

381 #panel-title { 

382 background: $primary-darken-1; 

383 color: $text; 

384 text-style: bold; 

385 padding: 0 1; 

386 height: 1; 

387 } 

388 

389 #file-tree { 

390 height: 1fr; 

391 overflow-y: auto; 

392 } 

393 

394 #chat-log { 

395 height: 1fr; 

396 overflow-y: auto; 

397 } 

398 

399 #terminal-log { 

400 height: 1fr; 

401 overflow-y: auto; 

402 } 

403 

404 #chat-input, #terminal-input { 

405 dock: bottom; 

406 height: 3; 

407 } 

408 

409 .market-sidebar { 

410 width: 32; 

411 border: solid $primary-darken-2; 

412 } 

413 

414 .market-section-title { 

415 background: $primary-darken-1; 

416 color: $text; 

417 text-style: bold; 

418 height: 1; 

419 } 

420 

421 #market-source-list { 

422 height: 1fr; 

423 } 

424 

425 #market-search { 

426 dock: top; 

427 height: 3; 

428 } 

429 

430 #market-skill-log { 

431 height: 1fr; 

432 overflow-y: auto; 

433 } 

434 """ 

435 

436 BINDINGS = [ 

437 Binding("ctrl+q", "quit", "Quit", show=True), 

438 Binding("ctrl+s", "save", "Save", show=True), 

439 Binding("ctrl+r", "refresh", "Refresh", show=True), 

440 Binding("ctrl+t", "focus_terminal", "Terminal", show=True), 

441 Binding("ctrl+c", "focus_chat", "Chat", show=True), 

442 Binding("ctrl+f", "focus_files", "Files", show=True), 

443 Binding("ctrl+m", "toggle_market", "Market", show=True), 

444 Binding("f5", "refresh", "Refresh", show=False), 

445 ] 

446 

447 _config: TUIConfig = TUIConfig() 

448 _message_handler: Optional[callable] = None 

449 _market_visible: bool = False 

450 

451 def __init__(self, config: TUIConfig = None, start_market: bool = False): 

452 super().__init__() 

453 if config: 

454 self._config = config 

455 self._market_visible = start_market 

456 

457 def compose(self) -> ComposeResult: 

458 yield Header("NexusAgentOS", icon="") 

459 yield FileTree(id="panel-file") 

460 yield ChatArea(id="panel-chat") 

461 yield TerminalPanel(id="panel-terminal") 

462 yield StatusBar(id="panel-status") 

463 yield Footer() 

464 

465 def on_mount(self) -> None: 

466 self.title = "NexusAgentOS TUI" 

467 self.sub_title = f"v1.7.6 — {self._config.work_dir}" 

468 

469 # Mount market panel 

470 market = MarketPanel(store_url=self._config.store_url, id="panel-market") 

471 market.display = self._market_visible 

472 self.mount(market, before="#panel-status") 

473 

474 # Update status 

475 status_sessions = self.query_one("#status-sessions", Static) 

476 status_sessions.update(" Sessions: 0 ") 

477 

478 # ── Actions ── 

479 

480 def action_quit(self) -> None: 

481 self.exit() 

482 

483 def action_save(self) -> None: 

484 self._config.save() 

485 chat = self.query_one("#chat-log", RichLog) 

486 chat.write("[dim]Config saved.[/dim]") 

487 

488 def action_refresh(self) -> None: 

489 self.query_one("#file-tree", Tree).root.remove_children() 

490 self.query_one("#panel-file", FileTree)._populate_tree() 

491 

492 def action_focus_terminal(self) -> None: 

493 self.query_one("#terminal-input", Input).focus() 

494 

495 def action_focus_chat(self) -> None: 

496 self.query_one("#chat-input", Input).focus() 

497 

498 def action_focus_files(self) -> None: 

499 self.query_one("#file-tree", Tree).focus() 

500 

501 def action_toggle_market(self) -> None: 

502 market = self.query_one("#panel-market", MarketPanel) 

503 self._market_visible = not self._market_visible 

504 market.display = self._market_visible 

505 if self._market_visible: 

506 market._init_sources() 

507 market._load_skills() 

508 market.query_one("#market-search", Input).focus() 

509 self.sub_title = f"v1.7.6 — Market | {self._config.work_dir}" 

510 else: 

511 self.sub_title = f"v1.7.6 — {self._config.work_dir}" 

512 

513 # ── Input Handlers ── 

514 

515 def on_input_submitted(self, event: Input.Submitted) -> None: 

516 if event.input.id == "chat-input" and event.value.strip(): 

517 self._handle_chat(event.value.strip()) 

518 event.input.clear() 

519 elif event.input.id == "terminal-input" and event.value.strip(): 

520 self._handle_terminal(event.value.strip()) 

521 event.input.clear() 

522 

523 def _handle_chat(self, message: str) -> None: 

524 chat = self.query_one("#panel-chat", ChatArea) 

525 chat.add_message("user", message) 

526 if self._message_handler: 

527 asyncio.create_task(self._dispatch_to_handler(message)) 

528 else: 

529 chat.add_message("agent", f"Echo: {message}") 

530 

531 async def _dispatch_to_handler(self, message: str) -> None: 

532 chat = self.query_one("#panel-chat", ChatArea) 

533 try: 

534 result = await self._message_handler(message) 

535 chat.add_message("agent", str(result)) 

536 except Exception as e: 

537 chat.add_message("error", f"Error: {e}") 

538 

539 def _handle_terminal(self, command: str) -> None: 

540 log = self.query_one("#terminal-log", RichLog) 

541 log.write(f"\n$ {command}") 

542 try: 

543 import subprocess 

544 result = subprocess.run( 

545 command, shell=True, capture_output=True, 

546 text=True, timeout=30, cwd=self._config.work_dir, 

547 ) 

548 if result.stdout: 

549 log.write(result.stdout.rstrip()) 

550 if result.stderr: 

551 log.write(f"[bold red]{result.stderr.rstrip()}[/bold red]") 

552 except Exception as e: 

553 log.write(f"[bold red]{e}[/bold red]") 

554 

555 # ── Public API ── 

556 

557 def set_message_handler(self, handler: callable): 

558 self._message_handler = handler 

559 

560 def add_message(self, role: str, text: str): 

561 chat = self.query_one("#panel-chat", ChatArea) 

562 chat.add_message(role, text) 

563 

564 

565# ── Entry Point ── 

566 

567def launch_tui( 

568 message_handler=None, 

569 work_dir: str = "", 

570 theme: str = "dark", 

571 start_market: bool = False, 

572 store_url: str = "http://127.0.0.1:18900", 

573) -> None: 

574 """Launch the TUI application. 

575 

576 Args: 

577 message_handler: async callable(msg: str) -> str for chat responses. 

578 work_dir: Working directory for file tree. 

579 theme: 'dark' or 'light'. 

580 start_market: Open market panel on launch. 

581 store_url: URL of the skill store server. 

582 """ 

583 if not TEXTUAL_AVAILABLE: 

584 print("ERROR: textual not installed. Run: pip install textual") 

585 return 

586 

587 config = TUIConfig.load() 

588 if work_dir: 

589 config.work_dir = work_dir 

590 if theme: 

591 config.theme = theme 

592 if store_url: 

593 config.store_url = store_url 

594 

595 app = AgentOSTUI(config=config, start_market=start_market) 

596 if message_handler: 

597 app.set_message_handler(message_handler) 

598 

599 app.run() 

600 

601 

602if __name__ == "__main__": 

603 launch_tui()