1"""

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

3

4 OpenCode/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

12 Requirements: pip install textual

13

14 Usage:

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

22 from __future__ import annotations

23

24 import asyncio

25 import json

26 import os

27 import time

28 from dataclasses import dataclass, field

29 from pathlib import Path

30 from typing import Any, Optional

31

32 try:

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

44 except ImportError:

45 TEXTUAL_AVAILABLE = False

46

47

48# ── Models ──

49

50@dataclass

51 class 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

80 if 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

90 if 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

567 def 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

602 if __name__ == "__main__":

603 launch_tui()