feat: Phase 1 - TUI Foundation with Textual

- Added Textual TUI framework with modern terminal UI
- Created main TUI app with navigation, message view, event log, state inspector
- Added comprehensive CSS styling for Discord-like appearance
- Implemented navigation pane, message area, event log, state tree, command bar
- Added keyboard shortcuts (Ctrl+Q quit, Ctrl+C command, Ctrl+N/P nav, etc.)
- Integrated TUI with existing REST/Gateway servers and state management
- Added CLI flag --tui/--no-tui to launch TUI mode
- Added Textual and textual-dev to optional dependencies

All 19 tests passing.
This commit is contained in:
k.eaven committed 2026-11-01 03:09:40 +00:00
1 parent 5669048f89
commit 2466acf837
11 files changed
+826 -1

No files matched your search

+4
View File
@@ -35,6 +35,10 @@ dev = [
"ruff>=0.1.0",
"mypy>=1.5.0",
]
tui = [
"textual>=0.52.0",
"textual-dev>=0.52.0",
]
[project.scripts]
apicord = "apicord.main:main"
+3
View File
@@ -27,6 +27,9 @@ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.5.0; extra == "dev"
Provides-Extra: tui
Requires-Dist: textual>=0.52.0; extra == "tui"
Requires-Dist: textual-dev>=0.52.0; extra == "tui"
Dynamic: license-file
# APIcord
+4
View File
@@ -12,3 +12,7 @@ pytest-cov>=4.1.0
black>=23.0.0
ruff>=0.1.0
mypy>=1.5.0
[tui]
textual>=0.52.0
textual-dev>=0.52.0
Binary file not shown.
+12 -1
View File
@@ -48,11 +48,22 @@ logger = logging.getLogger("apicord")
default=5,
help="Number of test users (if no scenario)",
)
@click.option(
"--tui/--no-tui",
default=False,
help="Launch with Terminal User Interface (TUI)",
)
@click.pass_context
def start(ctx: click.Context, scenario: Optional[Path], guilds: int, channels: int, users: int) -> None:
def start(ctx: click.Context, scenario: Optional[Path], guilds: int, channels: int, users: int, tui: bool) -> None:
"""Start the APIcord emulator server."""
config = ctx.obj["config"] if ctx.obj else get_config()
if tui:
# Import and run TUI
from apicord.tui.app import run_tui
run_tui(config=config, headless=False)
return
async def run_server():
# Initialize state manager
state_manager = StateManager(config)
+2
View File
@@ -12,6 +12,7 @@ from apicord.models.discord import (
Interaction,
InteractionType,
InteractionCallbackType,
InteractionResponse,
ComponentType,
ButtonStyle,
Snowflake,
@@ -37,6 +38,7 @@ __all__ = [
"Interaction",
"InteractionType",
"InteractionCallbackType",
"InteractionResponse",
"ComponentType",
"ButtonStyle",
"Snowflake",
+1
View File
@@ -0,0 +1 @@
"""TUI Package for APIcord"""
+325
View File
@@ -0,0 +1,325 @@
"""
Main TUI Application for APIcord.
This is the main Textual application that provides the terminal user interface
for interacting with the Discord API emulator.
"""
import asyncio
import logging
from typing import Optional
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Button,
Footer,
Header,
Input,
Label,
ListItem,
ListView,
Static,
TabbedContent,
TabPane,
Tree,
)
from apicord.config import EmulatorConfig, get_config
from apicord.state.manager import StateManager
from apicord.rest.server import RESTServer
from apicord.gateways.server import GatewayServer
logger = logging.getLogger("apicord.tui")
class NavigationPane(Static):
"""Left navigation pane with guild/channel/user/event navigation."""
def compose(self) -> ComposeResult:
yield Label("📁 NAVIGATION", classes="nav-title")
yield ListView(
ListItem(Label("🏠 Guilds"), id="nav-guilds"),
ListItem(Label("💬 Channels"), id="nav-channels"),
ListItem(Label("👥 Users"), id="nav-users"),
ListItem(Label("📨 Messages"), id="nav-messages"),
ListItem(Label("📋 Events"), id="nav-events"),
ListItem(Label("🔧 State"), id="nav-state"),
id="nav-list"
)
class MessageArea(Static):
"""Main message display area with embeds and components."""
def compose(self) -> ComposeResult:
yield Static("No channel selected", id="message-placeholder", classes="message-entry")
yield ListView(id="message-list")
class EventLog(Static):
"""Real-time event log with filtering."""
def compose(self) -> ComposeResult:
yield ListView(id="event-list")
class StateTree(Static):
"""State inspector tree view."""
def compose(self) -> ComposeResult:
yield Tree("State", id="state-tree")
class CommandBar(Static):
"""Command input bar at bottom."""
def compose(self) -> ComposeResult:
with Horizontal():
yield Label(">", classes="prompt")
yield Input(placeholder="Type / for commands, or raw JSON...", id="command-input")
class APIcordApp(App):
"""Main APIcord TUI Application."""
CSS_PATH = "css/app.tcss"
BINDINGS = [
Binding("ctrl+q", "quit", "Quit"),
Binding("ctrl+c", "command_focus", "Command"),
Binding("ctrl+n", "nav_next", "Next Nav"),
Binding("ctrl+p", "nav_prev", "Prev Nav"),
Binding("ctrl+r", "refresh", "Refresh"),
Binding("ctrl+e", "toggle_events", "Events"),
Binding("ctrl+s", "toggle_state", "State"),
Binding("f1", "help", "Help"),
]
def __init__(self, config: Optional[EmulatorConfig] = None, headless: bool = False):
super().__init__()
self.config = config or get_config()
self.headless = headless
self.state_manager: Optional[StateManager] = None
self.rest_server: Optional[RESTServer] = None
self.gateway_server: Optional[GatewayServer] = None
self._servers_started = False
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal():
yield NavigationPane(id="navigation-pane")
with Vertical():
with TabbedContent(id="main-tabs"):
with TabPane("Messages", id="tab-messages"):
yield MessageArea(id="message-area")
with TabPane("Events", id="tab-events"):
yield EventLog(id="event-log")
with TabPane("State", id="tab-state"):
yield StateTree(id="state-tree")
yield CommandBar(id="command-bar")
yield Footer()
async def on_mount(self) -> None:
"""Initialize the application."""
self.title = "APIcord"
self.sub_title = "Discord API Emulator"
# Initialize state manager
self.state_manager = StateManager(self.config)
await self.state_manager.create_default_state()
# Start servers if not headless
if not self.headless:
await self._start_servers()
# Populate initial state
await self._populate_navigation()
await self._refresh_state_tree()
# Focus command input
self.query_one("#command-input", Input).focus()
async def _start_servers(self) -> None:
"""Start REST and Gateway servers."""
self.rest_server = RESTServer(self.config, self.state_manager)
self.gateway_server = GatewayServer(self.config, self.state_manager)
await self.rest_server.start()
await self.gateway_server.start()
self._servers_started = True
logger.info("Servers started")
# Update status
self._update_status_bar()
async def _populate_navigation(self) -> None:
"""Populate navigation with current state."""
if not self.state_manager:
return
nav_list = self.query_one("#nav-list", ListView)
# Clear existing items except first 6
while len(nav_list.children) > 6:
nav_list.remove_child(nav_list.children[-1])
# Add guilds
for guild in self.state_manager.list_guilds():
nav_list.append(ListItem(Label(f" 🏰 {guild.name}"), id=f"nav-guild-{guild.id}"))
async def _refresh_state_tree(self) -> None:
"""Refresh the state inspector tree."""
if not self.state_manager:
return
tree = self.query_one("#state-tree", Tree)
tree.clear()
# Guilds
guilds_node = tree.root.add("🏰 Guilds", expand=True)
for guild in self.state_manager.list_guilds():
guild_node = guilds_node.add(f"{guild.name} ({guild.id})")
guild_node.add(f"Channels: {len(guild.channels)}")
guild_node.add(f"Roles: {len(guild.roles)}")
# Channels
channels_node = tree.root.add("💬 Channels", expand=True)
for channel in self.state_manager.channels.values():
ch_node = channels_node.add(f"#{channel.name} ({channel.id})")
ch_node.add(f"Type: {channel.type.name}")
ch_node.add(f"Guild: {channel.guild_id}")
ch_node.add(f"Messages: {len(channel.message_ids)}")
# Users
users_node = tree.root.add("👥 Users", expand=True)
for user in self.state_manager.users.values():
u_node = users_node.add(f"{user.username}#{user.discriminator} ({user.id})")
u_node.add(f"Bot: {user.bot}")
for guild_id, roles in user.guild_roles.items():
u_node.add(f"Guild {guild_id}: {len(roles)} roles")
# Stats
stats = self.state_manager.get_state_summary()
stats_node = tree.root.add("📊 Statistics", expand=True)
for key, value in stats.items():
stats_node.add(f"{key}: {value}")
def _update_status_bar(self) -> None:
"""Update footer status bar."""
if self.gateway_server:
conn_count = len(self.gateway_server.connections)
self.sub_title = f"REST: {self.config.rest_port} | Gateway: {self.config.gateway_port} | Connections: {conn_count}"
# --- Actions ---
async def action_quit(self) -> None:
"""Quit the application."""
await self._shutdown()
self.exit()
async def action_command_focus(self) -> None:
"""Focus the command input."""
self.query_one("#command-input", Input).focus()
async def action_nav_next(self) -> None:
"""Navigate to next nav item."""
nav_list = self.query_one("#nav-list", ListView)
if nav_list.index is not None:
nav_list.index = min(nav_list.index + 1, len(nav_list.children) - 1)
async def action_nav_prev(self) -> None:
"""Navigate to previous nav item."""
nav_list = self.query_one("#nav-list", ListView)
if nav_list.index is not None:
nav_list.index = max(nav_list.index - 1, 0)
async def action_refresh(self) -> None:
"""Refresh state tree."""
await self._refresh_state_tree()
async def action_toggle_events(self) -> None:
"""Toggle events tab."""
tabs = self.query_one("#main-tabs", TabbedContent)
tabs.active = "tab-events" if tabs.active != "tab-events" else "tab-messages"
async def action_toggle_state(self) -> None:
"""Toggle state tab."""
tabs = self.query_one("#main-tabs", TabbedContent)
tabs.active = "tab-state" if tabs.active != "tab-state" else "tab-messages"
async def action_help(self) -> None:
"""Show help modal."""
self.notify("Help: Ctrl+C=Command, Ctrl+N/P=Nav, Ctrl+R=Refresh, Ctrl+E/S=Tabs, F1=Help", timeout=5)
# --- Event Handlers ---
@on(ListView.Selected, "#nav-list")
async def on_nav_selected(self, event: ListView.Selected) -> None:
"""Handle navigation selection."""
item_id = event.item.id
if item_id and item_id.startswith("nav-guild-"):
guild_id = item_id.replace("nav-guild-", "")
self.notify(f"Selected guild: {guild_id}")
@on(Input.Submitted, "#command-input")
async def on_command_submitted(self, event: Input.Submitted) -> None:
"""Handle command input."""
command = event.value.strip()
if not command:
return
# Clear input
event.input.value = ""
# Handle commands
if command.startswith("/"):
await self._handle_slash_command(command)
else:
# Try to parse as JSON
await self._handle_raw_json(command)
async def _handle_slash_command(self, command: str) -> None:
"""Handle slash command input."""
parts = command[1:].split()
cmd = parts[0] if parts else ""
args = parts[1:] if len(parts) > 1 else []
self.notify(f"Slash command: /{cmd} {' '.join(args)}")
# TODO: Implement actual command handling
# This will send INTERACTION_CREATE to the gateway
async def _handle_raw_json(self, text: str) -> None:
"""Handle raw JSON input."""
import json
try:
data = json.loads(text)
self.notify("JSON parsed successfully")
# Could send raw gateway event
except json.JSONDecodeError as e:
self.notify(f"Invalid JSON: {e}", severity="error")
async def _shutdown(self) -> None:
"""Shutdown servers and cleanup."""
if self.rest_server:
await self.rest_server.stop()
if self.gateway_server:
await self.gateway_server.stop()
if self.config.persist_state and self.state_manager:
await self.state_manager.save_state()
logger.info("APIcord TUI shutdown complete")
def run_tui(config: Optional[EmulatorConfig] = None, headless: bool = False) -> None:
"""Run the TUI application."""
app = APIcordApp(config=config, headless=headless)
app.run()
if __name__ == "__main__":
run_tui()
+474
View File
@@ -0,0 +1,474 @@
/* APIcord TUI Styles */
Screen {
background: $surface;
color: $text;
}
#navigation-pane {
width: 22;
background: $panel;
border-right: solid $primary;
padding: 1;
}
#navigation-pane .nav-title {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#navigation-pane .nav-item {
height: 3;
margin: 0 1;
padding: 0 1;
background: $surface;
border: solid $background;
}
#navigation-pane .nav-item:hover {
background: $primary 20%;
border: solid $primary;
}
#navigation-pane .nav-item:focus {
background: $primary 30%;
border: solid $accent;
}
#navigation-pane .nav-item .nav-icon {
width: 3;
text-align: center;
color: $text-muted;
}
#navigation-pane .nav-item .nav-label {
width: 1fr;
}
#navigation-pane .nav-item .nav-badge {
width: 5;
text-align: right;
color: $accent;
text-style: bold;
}
#main-content {
width: 1fr;
background: $surface;
layout: vertical;
}
#message-area {
height: 1fr;
border: solid $primary;
margin: 1;
padding: 1;
}
#message-list {
height: 1fr;
overflow-y: scroll;
}
.message-entry {
height: auto;
min-height: 3;
margin: 0 0 1 0;
padding: 1;
background: $panel;
border: solid $background;
}
.message-entry:hover {
border: solid $primary;
}
.message-header {
height: 1;
layout: horizontal;
}
.message-author {
color: $accent;
text-style: bold;
}
.message-timestamp {
color: $text-muted;
text-align: right;
width: 20;
}
.message-content {
margin-top: 1;
color: $text;
}
.message-embed {
margin-top: 1;
padding: 1;
background: $surface;
border: solid $primary;
}
.embed-title {
color: $accent;
text-style: bold;
}
.embed-description {
margin: 1 0;
color: $text;
}
.embed-fields {
layout: horizontal;
}
.embed-field {
width: 1fr;
margin: 0 1;
padding: 1;
background: $surface;
border: solid $background;
}
.embed-field.inline {
width: auto;
min-width: 20;
}
.embed-field-name {
color: $text-muted;
text-style: bold;
text-transform: uppercase;
font-size: 80%;
}
.embed-field-value {
color: $text;
margin-top: 1;
}
.embed-footer {
margin-top: 1;
color: $text-muted;
font-size: 80%;
text-align: right;
}
.message-components {
margin-top: 1;
layout: horizontal;
}
.component-button {
min-width: 16;
height: 3;
margin: 0 1;
border: solid $primary;
}
.component-button.primary {
background: $primary;
color: $surface;
}
.component-button.secondary {
background: $secondary;
color: $surface;
}
.component-button.success {
background: $success;
color: $surface;
}
.component-button.danger {
background: $error;
color: $surface;
}
.component-button.link {
background: transparent;
color: $primary;
text-style: underline;
}
.component-select {
min-width: 30;
height: 3;
margin: 0 1;
border: solid $primary;
background: $surface;
}
#inspector-pane {
width: 36;
background: $panel;
border-left: solid $primary;
padding: 1;
}
#inspector-pane .section-title {
text-style: bold underline;
color: $accent;
margin: 1 0;
}
#inspector-pane .state-tree {
height: 1fr;
overflow-y: scroll;
}
.tree-node {
height: 1;
padding: 0 1;
}
.tree-node.expanded > .node-label {
text-style: bold;
}
.tree-node .node-icon {
width: 2;
}
.tree-node .node-label {
width: 1fr;
}
.tree-node .node-value {
width: 12;
text-align: right;
color: $text-muted;
}
#event-log {
height: 1fr;
border: solid $primary;
margin: 1;
padding: 1;
overflow-y: scroll;
}
.event-entry {
height: 1;
padding: 0 1;
border-bottom: solid $background 50%;
}
.event-entry:hover {
background: $primary 10%;
}
.event-time {
width: 10;
color: $text-muted;
text-style: bold;
}
.event-type {
width: 20;
color: $accent;
text-style: bold;
}
.event-detail {
width: 1fr;
color: $text;
}
#command-bar {
height: 3;
background: $panel;
border-top: solid $primary;
padding: 0 1;
layout: horizontal;
}
#command-bar .prompt {
width: 3;
color: $accent;
text-style: bold;
content-align: center middle;
}
#command-input {
width: 1fr;
height: 3;
background: $surface;
border: solid $primary;
padding: 0 1;
}
#command-input:focus {
border: solid $accent;
}
#status-bar {
height: 1;
background: $primary;
color: $surface;
padding: 0 1;
layout: horizontal;
}
.status-item {
width: auto;
margin-right: 2;
}
#footer-hints {
height: 1;
background: $surface;
color: $text-muted;
padding: 0 1;
text-align: center;
}
.tabbed-content {
height: 1fr;
}
.tab-header {
height: 1;
layout: horizontal;
background: $panel;
border-bottom: solid $primary;
}
.tab-button {
min-width: 12;
height: 1;
padding: 0 1;
background: transparent;
border: none;
color: $text-muted;
}
.tab-button.active {
color: $accent;
text-style: bold;
border-bottom: solid $accent;
}
.tab-button:hover {
color: $text;
background: $surface;
}
.tab-panel {
height: 1fr;
display: none;
}
.tab-panel.active {
display: block;
}
/* Modal / Dialog */
.modal {
align: center middle;
width: 60;
height: 30;
background: $panel;
border: thick $accent;
padding: 1;
}
.modal-title {
text-style: bold;
color: $accent;
margin-bottom: 1;
text-align: center;
}
.modal-content {
height: 1fr;
overflow-y: scroll;
}
.modal-buttons {
height: 3;
layout: horizontal;
margin-top: 1;
}
.modal-button {
width: 1fr;
margin: 0 1;
}
/* Command Palette */
.command-palette {
align: center middle;
width: 80;
height: 40;
background: $panel;
border: thick $accent;
padding: 1;
}
.command-palette-input {
width: 1fr;
height: 3;
margin-bottom: 1;
}
.command-palette-results {
height: 1fr;
overflow-y: scroll;
}
.command-palette-item {
height: 3;
padding: 0 1;
layout: horizontal;
}
.command-palette-item:hover {
background: $primary 20%;
}
.command-palette-item .cmd-name {
width: 24;
color: $accent;
text-style: bold;
}
.command-palette-item .cmd-desc {
width: 1fr;
color: $text-muted;
}
.command-palette-item .cmd-shortcut {
width: 20;
text-align: right;
color: $text-muted;
font-size: 80%;
}
/* Connection status indicators */
.status-connected {
color: $success;
}
.status-disconnected {
color: $error;
}
.status-connecting {
color: $warning;
}
/* Scrollbar styling */
ScrollBar {
background: $surface;
color: $primary;
}
ScrollBar:hover {
color: $accent;
}
+1
View File
@@ -0,0 +1 @@
"""TUI Screens Package"""