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)
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")
Key concepts:
-
Verticalcontainer 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
Key features:
-
VerticalScrollmakes 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()
Textual messages:
- Custom
MessageSubmittedmessage 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:
- User types message and presses Enter
-
on_input_submitted()is called - Creates
MessageSubmittedmessage with the text - Parent widget receives it via
on_message_submitted() - 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())
π‘ 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()
Key points:
- We inject
PiecesClientfor 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"]
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()
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
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
- β
src/pieces_copilot_tui/chat_message.py- Individual message widget - β
src/pieces_copilot_tui/chat_panel.py- Scrollable message display - β
src/pieces_copilot_tui/chat_input.py- User input widget - β
src/pieces_copilot_tui/chat_view.py- Simple view (basic version) - β
src/pieces_copilot_tui/app.py- Main application - β
src/pieces_copilot_tui/__init__.py- Package definition - β
src/pieces_copilot_tui/__main__.py- Module entry point
What's Next in Part 3?
In Part 3: Advanced Features & Integration, we'll add:
- π Conversations List - Sidebar showing all your chats
- π Real Streaming - Connect to Pieces OS for actual AI responses
- π― Full Orchestration - Complete chat_view.py with all features
- π§ LTM Support - Long-Term Memory toggle
- β‘ Advanced Features - Delete, rename, create conversations
- π§ͺ 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
UI Not Updating
# Make sure you're using self.mount() to add widgets
self.mount(message)
# Not just appending to a list
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)