DEV Community

Pieces 🌟 for Pieces.app

Posted on

Building a TUI with Pieces SDK - Part 2: UI Components

Building a Pieces Copilot TUI - Part 2: Basic UI Components

Note: This tutorial is part of the Pieces CLI project. We welcome contributions! Feel free to open issues, submit PRs, or suggest improvements.

Introduction

Welcome to Part 2! In Part 1, we learned how to use the Pieces OS SDK. Now, we'll start building the Terminal User Interface (TUI) using Textual.

What we'll build in Part 2:

  • Understanding Textual fundamentals
  • Message display widget
  • Chat panel for conversations
  • User input widget
  • A working basic chat interface

What we'll add in Part 3:

  • Conversations list sidebar
  • Streaming handler integration
  • Full view orchestration
  • Testing & optimization

Understanding Textual

Textual is a modern Python framework for building TUIs. It uses a reactive, component-based architecture similar to React.

Project Structure

pieces-copilot-tui/
β”œβ”€β”€ venv/                                    # Virtual environment
β”œβ”€β”€ requirements.txt                         # Dependencies
β”œβ”€β”€ run.py                                   # Entry point
└── src/                                     # Source code
    └── pieces_copilot_tui/                  # Main package
        β”œβ”€β”€ __init__.py                      # Package initialization
        β”œβ”€β”€ app.py                           # Main TUI application
        β”œβ”€β”€ chat_message.py                  # Individual message widget (Part 2)
        β”œβ”€β”€ chat_panel.py                    # Message display panel (Part 2)
        β”œβ”€β”€ chat_input.py                    # User input widget (Part 2)
        β”œβ”€β”€ streaming_handler.py             # Pieces SDK streaming logic (Part 3)
        β”œβ”€β”€ chats_list.py                    # Conversations list (Part 3)
        └── chat_view.py                     # Main view orchestrator (Part 3)
Enter fullscreen mode Exit fullscreen mode

In Part 2, we'll build the core UI components. In Part 3, we'll add conversations management and full integration.


Step 1: Create the Message Widget

Let's start with the foundation - src/pieces_copilot_tui/chat_message.py:

# src/pieces_copilot_tui/chat_message.py
"""Chat message widget for displaying individual messages."""

from datetime import datetime
from textual.widgets import Static
from textual.containers import Vertical


class ChatMessage(Vertical):
    """Widget to display a single chat message."""

    DEFAULT_CSS = """
    ChatMessage {
        width: 100%;
        height: auto;
        padding: 1 2;
        margin-bottom: 1;
    }

    ChatMessage.user-message {
        background: $primary 20%;
        border-left: thick $primary;
    }

    ChatMessage.assistant-message {
        background: $accent 20%;
        border-left: thick $accent;
    }

    ChatMessage.system-message {
        background: $warning 20%;
        border-left: thick $warning;
    }

    ChatMessage .message-header {
        color: $text-muted;
        text-style: italic;
        margin-bottom: 1;
    }

    ChatMessage .message-content {
        color: $text;
    }
    """

    def __init__(self, role: str, content: str, timestamp: str = None, **kwargs):
        super().__init__(**kwargs)
        self.role = role
        self.content = content
        self.timestamp = timestamp or datetime.now().strftime("Today %I:%M %p")

        # Set CSS class based on role
        self.add_class(f"{role}-message")

    def compose(self):
        """Compose the message widget."""
        role_emoji = {
            "user": "πŸ‘€",
            "assistant": "πŸ€–",
            "system": "βš™οΈ"
        }
        emoji = role_emoji.get(self.role, "")

        yield Static(
            f"{emoji} {self.role.title()} - {self.timestamp}",
            classes="message-header"
        )
        yield Static(self.content, classes="message-content")
Enter fullscreen mode Exit fullscreen mode

Key concepts:

  • Vertical container stacks children vertically
  • compose() defines child widgets
  • CSS classes are added dynamically based on message role
  • Emojis make it visually appealing! 🎨

CSS Variables:

  • $primary, $accent, $warning - Textual's built-in color variables
  • $text, $text-muted - Text color variables
  • You can customize these in your app's theme

Step 2: Create the Chat Panel

Now src/pieces_copilot_tui/chat_panel.py - the scrollable message display:

# src/pieces_copilot_tui/chat_panel.py
"""Chat panel widget for displaying conversation messages."""

from datetime import datetime
from typing import Optional
from textual.widgets import Static
from textual.containers import VerticalScroll

from .chat_message import ChatMessage


class ChatPanel(VerticalScroll):
    """Panel to display chat messages."""

    DEFAULT_CSS = """
    ChatPanel {
        width: 100%;
        height: 1fr;
        border: solid $primary;
        background: $background;
        padding: 1 2;
    }

    ChatPanel:focus {
        border: solid $accent;
    }

    ChatPanel .thinking-indicator {
        color: $warning;
        text-style: italic bold blink;
        text-align: center;
        background: $surface;
        border: dashed $warning;
        padding: 1;
        margin: 1;
    }

    ChatPanel .streaming-message {
        border-left: thick $accent;
        text-style: bold;
    }

    ChatPanel .welcome-message {
        text-align: center;
        margin: 4 2;
        padding: 3;
        border: dashed $primary;
        background: $primary 10%;
        color: $text;
        width: 100%;
        height: auto;
    }
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.border_title = "Chat"
        self._messages = []
        self._thinking_widget = None
        self._streaming_widget = None

    def add_message(self, role: str, content: str, timestamp: str = None):
        """Add a new message to the chat panel."""
        self._clear_thinking_indicator()

        timestamp = timestamp or datetime.now().strftime("Today %I:%M %p")
        message = ChatMessage(role, content, timestamp)
        self._messages.append(message)
        self.mount(message)
        self.scroll_end(animate=False)

    def add_thinking_indicator(self):
        """Add a thinking indicator."""
        self._clear_thinking_indicator()
        self._thinking_widget = Static("πŸ€” Thinking...", classes="thinking-indicator")
        self.mount(self._thinking_widget)
        self.scroll_end(animate=False)

    def add_streaming_message(self, role: str, content: str):
        """Add a streaming message with cursor."""
        self._clear_thinking_indicator()

        timestamp = datetime.now().strftime("Today %I:%M %p")
        self._streaming_widget = ChatMessage(role, content + " β–Œ", timestamp)
        self._streaming_widget.add_class("streaming-message")
        self.mount(self._streaming_widget)
        self.scroll_end(animate=False)

    def update_streaming_message(self, content: str):
        """Update the streaming message content."""
        if self._streaming_widget:
            # Update the content of the last Static widget in the streaming message
            content_widget = self._streaming_widget.query(".message-content").first()
            if content_widget:
                content_widget.update(content + " β–Œ")
            self.scroll_end(animate=False)

    def finalize_streaming_message(self):
        """Convert streaming message to permanent message."""
        if self._streaming_widget:
            content_widget = self._streaming_widget.query(".message-content").first()
            if content_widget:
                # Remove cursor
                current_content = str(content_widget.renderable).replace(" β–Œ", "")
                content_widget.update(current_content)
            self._streaming_widget.remove_class("streaming-message")
            self._messages.append(self._streaming_widget)
            self._streaming_widget = None

    def clear_messages(self):
        """Clear all messages from the chat panel."""
        self._clear_thinking_indicator()
        if self._streaming_widget:
            self._streaming_widget.remove()
            self._streaming_widget = None

        for message in self._messages:
            if message.is_mounted:
                message.remove()
        self._messages.clear()

    def show_welcome(self):
        """Show welcome message."""
        welcome_text = """🎯 Pieces Copilot TUI

Type your message below to start chatting!

Press Ctrl+Q to quit."""

        welcome = Static(welcome_text, classes="welcome-message")
        self.mount(welcome)

    def _clear_thinking_indicator(self):
        """Remove thinking indicator if present."""
        if self._thinking_widget:
            if self._thinking_widget.is_mounted:
                self._thinking_widget.remove()
            self._thinking_widget = None

    def is_streaming_active(self) -> bool:
        """Check if streaming or thinking is currently active."""
        return self._streaming_widget is not None or self._thinking_widget is not None
Enter fullscreen mode Exit fullscreen mode

Key features:

  • VerticalScroll makes it scrollable
  • Thinking indicator shows "πŸ€” Thinking..."
  • Streaming shows content + cursor (" β–Œ")
  • Auto-scrolls to bottom on new messages
  • Welcome message for first-time users

Step 3: Create the Input Widget

Simple but crucial - src/pieces_copilot_tui/chat_input.py:

# src/pieces_copilot_tui/chat_input.py
"""Chat input widget for user message entry."""

from textual.widgets import Input
from textual.message import Message


class MessageSubmitted(Message):
    """Message emitted when user submits input."""

    def __init__(self, text: str):
        super().__init__()
        self.text = text


class ChatInput(Input):
    """Input widget for chat messages."""

    DEFAULT_CSS = """
    ChatInput {
        width: 100%;
        height: 3;
        background: $surface;
        border: solid $primary;
        padding: 0 1;
        margin: 0;
        dock: bottom;
    }

    ChatInput:focus {
        border: solid $accent;
        background: $panel;
    }
    """

    def __init__(self, **kwargs):
        super().__init__(placeholder="Type your message here...", **kwargs)

    async def on_input_submitted(self, event: Input.Submitted) -> None:
        """Handle input submission."""
        if event.value.strip():
            self.post_message(MessageSubmitted(event.value.strip()))
            self.value = ""
            event.stop()

    def on_mount(self) -> None:
        """Focus the input when mounted."""
        self.focus()
Enter fullscreen mode Exit fullscreen mode

Textual messages:

  • Custom MessageSubmitted message bubbles up to parent
  • Parent widgets can listen for this message
  • event.stop() prevents further propagation
  • post_message() sends the message up the widget tree

How it works:

  1. User types message and presses Enter
  2. on_input_submitted() is called
  3. Creates MessageSubmitted message with the text
  4. Parent widget receives it via on_message_submitted()
  5. Input is cleared for next message

Step 4: Create a Simple View (For Testing)

Let's create a basic src/pieces_copilot_tui/chat_view.py to test our components:

# src/pieces_copilot_tui/chat_view.py
"""Simple chat view for testing (will be expanded in Part 3)."""

from textual.screen import Screen
from textual.containers import Vertical
from textual.binding import Binding
from textual.widgets import Footer

from pieces_os_client.wrapper import PiecesClient

from .chat_panel import ChatPanel
from .chat_input import ChatInput, MessageSubmitted


class SimpleChatView(Screen):
    """Simple chat view for testing our components."""

    BINDINGS = [
        Binding("ctrl+q", "quit", "Quit"),
    ]

    DEFAULT_CSS = """
    SimpleChatView {
        layout: vertical;
    }

    SimpleChatView Vertical {
        width: 100%;
        height: 100%;
    }
    """

    def __init__(self, pieces_client: PiecesClient, **kwargs):
        super().__init__(**kwargs)
        self.pieces_client = pieces_client
        self.chat_panel = None
        self.chat_input = None

    def compose(self):
        """Compose the view."""
        with Vertical():
                self.chat_panel = ChatPanel()
                yield self.chat_panel

                self.chat_input = ChatInput()
                yield self.chat_input

        yield Footer()

    def on_mount(self) -> None:
        """Initialize the view."""
        # Show welcome message
        self.chat_panel.show_welcome()

    def on_message_submitted(self, message: MessageSubmitted) -> None:
        """Handle user message submission."""
        # Add user message
        self.chat_panel.add_message("user", message.text)

        # Show thinking indicator
        self.chat_panel.add_thinking_indicator()

        # In Part 3, we'll connect this to the streaming handler
        # For now, just simulate a response
        self.simulate_response(message.text)

    def simulate_response(self, user_message: str):
        """Simulate a response (will be replaced with real streaming in Part 3)."""
        import asyncio

        async def add_response():
            await asyncio.sleep(1)
            self.chat_panel.add_message(
                "assistant",
                f"Echo: {user_message}\n\n(Real AI responses coming in Part 3!)"
            )

        asyncio.create_task(add_response())
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Note: This is a simplified version for testing. In Part 3, we'll replace simulate_response() with real streaming from Pieces OS and add the conversations list sidebar.


Step 5: Create the Main Application

Now let's create src/pieces_copilot_tui/app.py:

# src/pieces_copilot_tui/app.py
"""Pieces Copilot TUI - A Terminal User Interface for Pieces OS."""

from textual.app import App

from pieces_os_client.wrapper import PiecesClient
from .chat_view import SimpleChatView


class PiecesCopilotTUI(App):
    """Pieces Copilot TUI (Basic version for Part 2)."""

    # CSS styles for the entire app
    DEFAULT_CSS = """
    Screen {
        background: $background;
        color: $text;
    }

    .error {
        color: $error;
        text-style: bold;
    }

    .success {
        color: $success;
        text-style: bold;
    }
    """

    def __init__(self, pieces_client: PiecesClient = None, **kwargs):
        super().__init__(**kwargs)
        self.pieces_client = pieces_client or PiecesClient()
        self.chat_view = None

    def on_mount(self) -> None:
        """Initialize the application."""
        self.title = "Pieces Copilot TUI"

        # Initialize Pieces client
        if not self.pieces_client.connect_websocket():
            self.notify("Failed to connect to Pieces OS", severity="error")
            self.exit()
            return

        # Create and push the simple chat view
        self.chat_view = SimpleChatView(pieces_client=self.pieces_client)
        self.push_screen(self.chat_view)


def run_tui():
    """Run the Pieces Copilot TUI application."""
    # Initialize Pieces client
    pieces_client = PiecesClient()

    # Check if Pieces OS is running
    if not pieces_client.is_pieces_running():
        print("Error: Pieces OS is not running. Please start Pieces OS first.")
        return

    app = PiecesCopilotTUI(pieces_client=pieces_client)
    app.run()


if __name__ == "__main__":
    run_tui()
Enter fullscreen mode Exit fullscreen mode

Key points:

  • We inject PiecesClient for better testability
  • CSS is used for styling (similar to web CSS!)
  • on_mount() is called when the app starts
  • Error handling for Pieces OS connection

Step 6: Create the Package Initialization

Create src/pieces_copilot_tui/__init__.py to make it a proper Python package:

# src/pieces_copilot_tui/__init__.py
"""Pieces Copilot TUI Package."""

__version__ = "0.1.0"

from .app import PiecesCopilotTUI, run_tui

__all__ = ["PiecesCopilotTUI", "run_tui"]
Enter fullscreen mode Exit fullscreen mode

This allows the relative imports (.chat_message, .chat_panel, etc.) to work correctly and exports the main functions.


Step 7: Create the Module Entry Point

Create src/pieces_copilot_tui/__main__.py to make the package executable:

# src/pieces_copilot_tui/__main__.py
"""
Command-line entry point for Pieces Copilot TUI.

This allows the package to be run with: python -m pieces_copilot_tui
"""

from .app import run_tui

if __name__ == "__main__":
    run_tui()
Enter fullscreen mode Exit fullscreen mode

This is the Pythonic way to make a package runnable with python -m package_name.


Step 8: Run Your Basic TUI

The cleanest way to run the TUI is from the src directory:

# Navigate to src directory
cd src

# Run the module
python -m pieces_copilot_tui
Enter fullscreen mode Exit fullscreen mode

That's it! No complex setup, no PYTHONPATH manipulation, just clean Python!

What you should see:

  • A welcome message in the center
  • An input box at the bottom
  • You can type messages and see them displayed
  • Simulated responses after 1 second
  • Press Ctrl+Q to quit

Recap

In Part 2, we built the foundation of our TUI:

Files Created

  1. βœ… src/pieces_copilot_tui/chat_message.py - Individual message widget
  2. βœ… src/pieces_copilot_tui/chat_panel.py - Scrollable message display
  3. βœ… src/pieces_copilot_tui/chat_input.py - User input widget
  4. βœ… src/pieces_copilot_tui/chat_view.py - Simple view (basic version)
  5. βœ… src/pieces_copilot_tui/app.py - Main application
  6. βœ… src/pieces_copilot_tui/__init__.py - Package definition
  7. βœ… src/pieces_copilot_tui/__main__.py - Module entry point

What's Next in Part 3?

In Part 3: Advanced Features & Integration, we'll add:

  1. πŸ“‹ Conversations List - Sidebar showing all your chats
  2. 🌊 Real Streaming - Connect to Pieces OS for actual AI responses
  3. 🎯 Full Orchestration - Complete chat_view.py with all features
  4. 🧠 LTM Support - Long-Term Memory toggle
  5. ⚑ Advanced Features - Delete, rename, create conversations
  6. πŸ§ͺ Testing & Optimization - Make it production-ready

Preview of Part 3 components:

  • chats_list.py - Full conversations management
  • streaming_handler.py - Real-time streaming from Pieces
  • chat_view.py - Complete orchestrator (expanded version)
  • Testing, debugging, and performance tips

Troubleshooting

Textual Not Found

pip install textual>=0.47.0
Enter fullscreen mode Exit fullscreen mode

UI Not Updating

# Make sure you're using self.mount() to add widgets
self.mount(message)

# Not just appending to a list
Enter fullscreen mode Exit fullscreen mode

Ready for Part 3?

You now have a working TUI foundation! In Part 3, we'll transform this into a fully-featured Pieces Copilot interface with real AI streaming, conversations management, and all the bells and whistles.

Continue to Part 3 when you're ready! πŸš€

Top comments (0)