diff --git a/.github/agents/docs.agent.md b/.github/agents/docs.agent.md index 2fa4d8be..50c313c4 100644 --- a/.github/agents/docs.agent.md +++ b/.github/agents/docs.agent.md @@ -1,6 +1,7 @@ --- description: 'Maintain Plugboard documentation' tools: ['execute', 'read', 'edit', 'search', 'web', 'github.vscode-pull-request-github/activePullRequest', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment', 'todo'] +model: ['GPT-5 mini (copilot)', 'GPT-4.1 (copilot)'] --- You are an expert technical writer responsible for maintaining the documentation of the Plugboard project. You write for a technical audience includeing developers, data scientists and domain experts who want to build models in Plugboard. diff --git a/.github/agents/examples.agent.md b/.github/agents/examples.agent.md new file mode 100644 index 00000000..5aba458e --- /dev/null +++ b/.github/agents/examples.agent.md @@ -0,0 +1,42 @@ +--- +name: examples +description: Develops example Plugboard models to demonstrate the capabilities of the framework +argument-hint: A description of the example to generate, along with any specific requirements, ideas about structure, or constraints. +agents: ['researcher', 'docs', 'lint'] +--- + +You are responsible for building high quality tutorials and demo examples for the Plugboard framework. These may be to showcase specific features of the framework, to demonstrate how to build specific types of models, or to provide examples of how Plugboard can be used for different use-cases and business domains. + +## Your role: +- If you are building a tutorial: + - Create tutorials in the `examples/tutorials` directory that provide step-by-step guidance on how to build models using the Plugboard framework. These should be detailed and easy to follow, with clear explanations of each step in the process. + - Create markdown documentation alongside code. You can delegate to the `docs` subagent to make these updates. + - Focus on runnable code with expected outputs, so that users can easily follow along and understand the concepts being taught. +- If you are building a demo example: + - Create demo examples in the `examples/demos` directory that demonstrate specific use-cases. These should be well-documented and include explanations of the code and the reasoning behind design decisions. + - Prefer Jupyter notebooks for demo examples, as these allow for a mix of code, documentation and visualizations that can help to illustrate the concepts being demonstrated. +- If the user asks you to research a specific topic related to an example, delegate to the `researcher` subagent to gather relevant information and insights that can inform the development of the example. + + +## Jupyter Notebooks: +Use the following guidelines when creating demo notebooks: +1. **Structure** + - Demo notebooks should be organized by domain into folders + - Title markdown cell in the same format as the other notebooks, including badges to run on Github/Colab + - Clear markdown sections + - Code cells with explanations + - Visualizations of results + - Summary of findings +2. **Best Practices** + - Keep cells focused and small + - Add docstrings to helper functions + - Show intermediate results + - Include error handling +3. **Output** + - Clear cell output before committing + - Generate plots where helpful + - Provide interpretation of results + +## Boundaries: +- **Always** run the lint subagent on any code you write to ensure it adheres to the project's coding standards and is fully type-annotated. +- **Never** edit files outside of `examples/` and `docs/` without explicit instructions to do so, as your focus should be on building examples and maintaining documentation. \ No newline at end of file diff --git a/.github/agents/lint.agent.md b/.github/agents/lint.agent.md index 1998b07f..e30ee243 100644 --- a/.github/agents/lint.agent.md +++ b/.github/agents/lint.agent.md @@ -1,6 +1,7 @@ --- description: 'Maintain code quality by running linting tools and resolving issues' tools: ['execute', 'read', 'edit', 'search', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment'] +model: ['GPT-5 mini (copilot)', 'GPT-4.1 (copilot)'] --- You are responsible for maintaining code quality in the Plugboard project by running linting tools and resolving any issues that arise. diff --git a/.github/agents/researcher.agent.md b/.github/agents/researcher.agent.md new file mode 100644 index 00000000..27c1f45a --- /dev/null +++ b/.github/agents/researcher.agent.md @@ -0,0 +1,20 @@ +--- +name: researcher +description: Researches specific topics on the internet and gathers relevant information. +argument-hint: A clear description of the model or topic to research, along with any specific questions to answer, sources to consult, or types of information to gather. +tools: ['vscode', 'read', 'agent', 'search', 'web', 'todo'] +--- + +You are a subject-matter expert researcher responsible for gathering information on specific topics related to the Plugboard project. Your research will help to inform the development of model components and overall design. + +## Your role: +Focus on gathering information about: +- Approaches to modeling the specific process or system being researched, including any relevant theories, frameworks, or best practices +- How the model or simulation can be structured into components, and what the inputs and outputs of those components should be +- What the data flow between components should look like, and any data structures required +- Any specific algorithms or equations that need to be implemented inside the components + +## Boundaries: +- **Always** provide clear and concise summaries of the information you gather. +- Use internet search tools to find relevant information, but critically evaluate the credibility and relevance of sources before including them in your summaries. +- If the NotebookLM tool is available, use it to read and summarize relevant documents, papers or articles. Ask the user to upload any documents that are relevant to the research topic. \ No newline at end of file diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 8917cfdd..a0464a60 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,24 +1,6 @@ -# AI Agent Instructions for Plugboard Examples +# AI Agent Instructions for Plugboard Models -This document provides guidelines for AI agents working with Plugboard example code, demonstrations, and tutorials. - -## Purpose - -These examples demonstrate how to use Plugboard to model and simulate complex processes. Help users build intuitive, well-documented examples that showcase Plugboard's capabilities. - -## Example Categories - -### Tutorials (`tutorials/`) - -Step-by-step learning materials for new users. Focus on: -- Clear explanations of concepts. -- Progressive complexity. -- Runnable code with expected outputs. -- Markdown documentation alongside code. You can delegate to the `docs` agent to make these updates. - -### Demos (`demos/`) - -Practical applications are organized by domain into folders. +This document provides guidelines for AI agents working with specific models implemented in Plugboard. ## Creating a Plugboard model @@ -244,28 +226,6 @@ Later, load and run via CLI plugboard process run my-model.yaml ``` -## Jupyter Notebooks - -Use the following guidelines when creating demo notebooks: - -1. **Structure** - - Title markdown cell in the same format as the other notebooks, including badges to run on Github/Colab - - Clear markdown sections - - Code cells with explanations - - Visualizations of results - - Summary of findings - -2. **Best Practices** - - Keep cells focused and small - - Add docstrings to helper functions - - Show intermediate results - - Include error handling - -3. **Output** - - Clear cell output before committing - - Generate plots where helpful - - Provide interpretation of results - ## Resources - **Library Components**: `plugboard.library` diff --git a/plugboard/cli/__init__.py b/plugboard/cli/__init__.py index 33a46c31..2a799d5d 100644 --- a/plugboard/cli/__init__.py +++ b/plugboard/cli/__init__.py @@ -3,6 +3,7 @@ import typer from plugboard import __version__ +from plugboard.cli.go import app as go_app from plugboard.cli.process import app as process_app from plugboard.cli.server import app as server_app from plugboard.cli.version import app as version_app @@ -14,6 +15,7 @@ help=f"[bold]Plugboard CLI[/bold]\n\nVersion {__version__}", pretty_exceptions_show_locals=False, ) +app.add_typer(go_app, name="go") app.add_typer(process_app, name="process") app.add_typer(server_app, name="server") app.add_typer(version_app, name="version") diff --git a/plugboard/cli/go/AGENTS.md b/plugboard/cli/go/AGENTS.md new file mode 120000 index 00000000..17086c0c --- /dev/null +++ b/plugboard/cli/go/AGENTS.md @@ -0,0 +1 @@ +../../../examples/AGENTS.md \ No newline at end of file diff --git a/plugboard/cli/go/__init__.py b/plugboard/cli/go/__init__.py new file mode 100644 index 00000000..886e0c75 --- /dev/null +++ b/plugboard/cli/go/__init__.py @@ -0,0 +1,35 @@ +"""Plugboard Go CLI - interactive AI-powered model builder.""" + +import typer + +from plugboard.utils.dependencies import depends_on_optional + + +app = typer.Typer(rich_markup_mode="rich", pretty_exceptions_show_locals=False) + + +@depends_on_optional("textual", extra="go") +@depends_on_optional("copilot", extra="go") +def _run_go(model: str) -> None: + """Launch the Plugboard Go TUI.""" + from plugboard.cli.go.app import PlugboardGoApp + + tui = PlugboardGoApp(model_name=model) + tui.run() + + +@app.callback(invoke_without_command=True) +def go( + model: str = typer.Option( + "gpt-5-mini", + "--model", + "-m", + help="LLM model to use (e.g. gpt-5-mini, claude-sonnet-4, gpt-5.4).", + ), +) -> None: + """Launch the interactive Plugboard model builder powered by GitHub Copilot.""" + try: + _run_go(model) + except ImportError as exc: + typer.echo(str(exc)) + raise typer.Exit(1) from exc diff --git a/plugboard/cli/go/agent.py b/plugboard/cli/go/agent.py new file mode 100644 index 00000000..0b6d66f1 --- /dev/null +++ b/plugboard/cli/go/agent.py @@ -0,0 +1,198 @@ +"""Copilot SDK agent integration for Plugboard Go.""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path +import typing as _t + +from copilot import CopilotClient, CopilotSession, PermissionHandler, SessionConfig +from copilot.types import UserInputRequest, UserInputResponse + +from plugboard.cli.go.tools import ( + create_mermaid_diagram_tool, + create_run_model_tool, +) + + +_FALLBACK_SYSTEM_PROMPT = ( + "You are a helpful assistant that helps users design and implement " + "Plugboard models. Plugboard is an event-driven modelling framework " + "in Python." +) + + +def _load_system_prompt() -> str: + """Load the system prompt from bundled package data. + + The AGENTS.md file is shipped inside the ``plugboard.cli.go`` package + so that it is available even when plugboard is installed from a wheel. + Falls back to ``examples/AGENTS.md`` in the working directory for + local development, or to a short built-in prompt if neither is found. + """ + # 1. Load from package data (works in installed wheels) + try: + ref = resources.files("plugboard.cli.go").joinpath("AGENTS.md") + text = ref.read_text(encoding="utf-8") + if text: + return text + except (FileNotFoundError, TypeError, ModuleNotFoundError): + pass + + # 2. Fallback for local dev: check examples/ in the working directory + cwd_candidate = Path.cwd() / "examples" / "AGENTS.md" + if cwd_candidate.exists(): + return cwd_candidate.read_text(encoding="utf-8") + + return _FALLBACK_SYSTEM_PROMPT + + +class PlugboardAgent: + """Manages the Copilot client and session for the Plugboard Go TUI.""" + + _PREFERRED_MODEL_PREFIXES = ( + "gpt-5-mini", + "gpt-4.1", + "gpt", + "o3", + "o1", + "claude", + ) + + def __init__( + self, + model: str, + on_assistant_delta: _t.Callable[[str], None] | None = None, + on_assistant_message: _t.Callable[[str], None] | None = None, + on_tool_start: _t.Callable[[str], None] | None = None, + on_user_input_request: _t.Callable[ + [UserInputRequest, dict[str, str]], + _t.Awaitable[UserInputResponse], + ] + | None = None, + on_mermaid_url: _t.Callable[[str], None] | None = None, + on_idle: _t.Callable[[], None] | None = None, + ) -> None: + self._model = model + self._on_assistant_delta = on_assistant_delta + self._on_assistant_message = on_assistant_message + self._on_tool_start = on_tool_start + self._on_user_input_request_cb = on_user_input_request + self._on_mermaid_url = on_mermaid_url + self._on_idle = on_idle + self._client: CopilotClient | None = None + self._session: CopilotSession | None = None + + @property + def model(self) -> str: + """Return the current model name.""" + return self._model + + async def start(self) -> None: + """Start the Copilot client and create a session.""" + self._client = CopilotClient({"log_level": "error"}) + await self._client.start() + self._model = await self._resolve_model(self._model) + + system_prompt = _load_system_prompt() + + tools = [ + create_run_model_tool(), + create_mermaid_diagram_tool(on_url_generated=self._on_mermaid_url), + ] + + session_config = SessionConfig( + model=self._model, + streaming=True, + tools=tools, + system_message={ + "content": system_prompt, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + if self._on_user_input_request_cb is not None: + session_config["on_user_input_request"] = self._on_user_input_request_cb + + self._session = await self._client.create_session(session_config) + self._session.on(self._handle_event) + + async def _resolve_model(self, requested_model: str) -> str: + """Resolve the requested model to an available preferred model.""" + if self._client is None: + return requested_model + + try: + models = await self._client.list_models() + except Exception: + return requested_model + + model_ids = [model.id for model in models] if models else [] + if not model_ids: + return requested_model + if requested_model in model_ids: + return requested_model + + for prefix in self._PREFERRED_MODEL_PREFIXES: + for model_id in model_ids: + if model_id.startswith(prefix): + return model_id + + return model_ids[0] + + def _handle_event(self, event: _t.Any) -> None: + """Route session events to callbacks.""" + event_type = event.type.value if hasattr(event.type, "value") else str(event.type) + + if event_type == "assistant.message_delta": + delta = event.data.delta_content or "" + if delta and self._on_assistant_delta: + self._on_assistant_delta(delta) + elif event_type == "assistant.message": + content = event.data.content or "" + if self._on_assistant_message: + self._on_assistant_message(content) + elif event_type == "tool.execution_start": + tool_name = event.data.tool_name if hasattr(event.data, "tool_name") else "unknown" + if self._on_tool_start: + self._on_tool_start(tool_name) + elif event_type == "session.idle": + if self._on_idle: + self._on_idle() + + async def send(self, prompt: str) -> None: + """Send a user prompt to the agent.""" + if self._session is None: + raise RuntimeError("Agent not started. Call start() first.") + await self._session.send({"prompt": prompt}) + + async def list_models(self) -> list[str]: + """List available models.""" + if self._client is None: + raise RuntimeError("Agent not started. Call start() first.") + try: + models = await self._client.list_models() + return [m.id for m in models] if models else [] + except Exception: + return ["gpt-4o", "gpt-5", "claude-sonnet-4", "claude-sonnet-4-thinking", "o3"] + + async def change_model(self, model: str) -> None: + """Change the model by destroying and recreating the session.""" + self._model = model + await self.stop() + await self.start() + + async def stop(self) -> None: + """Clean up the Copilot client and session.""" + if self._session is not None: + try: + await self._session.destroy() + except Exception: # noqa: S110 + pass # Best-effort cleanup + self._session = None + if self._client is not None: + try: + await self._client.stop() + except Exception: # noqa: S110 + pass # Best-effort cleanup + self._client = None diff --git a/plugboard/cli/go/app.py b/plugboard/cli/go/app.py new file mode 100644 index 00000000..df945624 --- /dev/null +++ b/plugboard/cli/go/app.py @@ -0,0 +1,764 @@ +"""Textual TUI application for Plugboard Go.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import typing as _t + +from textual import on, work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.css.query import NoMatches +from textual.message import Message +from textual.reactive import reactive +from textual.widgets import ( + DirectoryTree, + Input, + Markdown, + OptionList, + Static, +) +from textual.widgets.option_list import Option + +from plugboard import __version__ +from plugboard.cli.go.agent import PlugboardAgent +from plugboard.utils.theme import PlugboardTheme as Theme + + +if _t.TYPE_CHECKING: + from copilot.types import UserInputRequest + +from copilot.types import UserInputResponse + + +def _theme_css(css: str) -> str: + """Substitute theme color placeholders into Textual CSS.""" + return ( + css.replace("__PB_BLUE__", Theme.PB_BLUE) + .replace("__PB_BLACK__", Theme.PB_BLACK) + .replace("__PB_GRAY__", Theme.PB_GRAY) + .replace("__PB_ACCENT1__", Theme.PB_ACCENT1) + .replace("__PB_ACCENT2__", Theme.PB_ACCENT2) + .replace("__PB_ACCENT3__", Theme.PB_ACCENT3) + .replace("__PB_WHITE__", Theme.PB_WHITE) + .replace("__PB_PINK__", Theme.PB_PINK) + ) + + +# -- Custom Messages --------------------------------------------------------- + + +class AgentDelta(Message): + """Streaming text delta from the assistant.""" + + def __init__(self, delta: str) -> None: + super().__init__() + self.delta = delta + + +class AgentMessage(Message): + """Complete assistant message.""" + + def __init__(self, content: str) -> None: + super().__init__() + self.content = content + + +class AgentToolStart(Message): + """Tool execution started.""" + + def __init__(self, tool_name: str) -> None: + super().__init__() + self.tool_name = tool_name + + +class AgentQuestion(Message): + """Agent is asking the user a question.""" + + def __init__(self, text: str) -> None: + super().__init__() + self.text = text + + +class AgentMermaidUrl(Message): + """New mermaid diagram URL generated.""" + + def __init__(self, url: str) -> None: + super().__init__() + self.url = url + + +class AgentIdle(Message): + """Agent session went idle.""" + + +class AgentStatus(Message): + """System status message.""" + + def __init__(self, text: str) -> None: + super().__init__() + self.text = text + + +# -- Custom Widgets ---------------------------------------------------------- + + +class ChatMessage(Static): + """A single chat message displayed in the conversation.""" + + DEFAULT_CSS = _theme_css(""" + ChatMessage { + padding: 0 2; + margin: 0; + } + ChatMessage.user { + background: __PB_BLACK__; + border-left: thick __PB_BLUE__; + color: __PB_WHITE__; + } + ChatMessage.assistant { + background: __PB_ACCENT3__; + border-left: thick __PB_ACCENT1__; + color: __PB_WHITE__; + } + ChatMessage.system { + background: __PB_BLACK__; + border-left: thick __PB_ACCENT3__; + color: __PB_GRAY__; + } + ChatMessage .message-header { + margin: 0; + padding: 0; + } + ChatMessage .message-body { + margin: 0; + padding: 0; + } + """) + + def __init__( + self, + content: str, + role: str = "assistant", + **kwargs: _t.Any, + ) -> None: + super().__init__(**kwargs) + self._role = role + self._content: str = content.rstrip() + self.add_class(role) + + @property + def role(self) -> str: + """Return the message role.""" + return self._role + + @property + def content(self) -> str: + """Return the message content.""" + return self._content + + def compose(self) -> ComposeResult: + """Compose the chat message widget.""" + if self._role == "user": + label = "You" + elif self._role == "system": + label = "System" + else: + label = "Copilot" + yield Static( + f"[bold]{label}[/bold]", + classes="message-header", + ) + yield Markdown(self._content, classes="message-body") + + def append_content(self, delta: str) -> None: + """Append streaming content to the message body.""" + self._content = f"{self._content}{delta}".rstrip() + try: + md = self.query_one(".message-body", Markdown) + md.update(self._content) + except NoMatches: + pass + + def replace_content(self, content: str) -> None: + """Replace the message content.""" + self._content = content.rstrip() + try: + md = self.query_one(".message-body", Markdown) + md.update(self._content) + except NoMatches: + pass + + +class HeaderBanner(Static): + """Combined header showing title, model, and a blank line.""" + + DEFAULT_CSS = _theme_css(""" + HeaderBanner { + dock: top; + height: 3; + padding: 0 1; + background: __PB_BLUE__; + color: __PB_WHITE__; + content-align: center middle; + } + """) + + model_name: reactive[str] = reactive("gpt-4o") + + def render(self) -> str: + """Render the header banner.""" + return ( + f"[bold {Theme.PB_ACCENT1}]PLUGBOARD[/] " + f"[{Theme.PB_GRAY}]v{__version__}[/]\n" + f"Model: {self.model_name}\n" + ) + + +class MermaidLink(Static): + """Displays a link to the mermaid diagram.""" + + DEFAULT_CSS = _theme_css(""" + MermaidLink { + height: auto; + padding: 0 1; + background: __PB_BLUE__; + color: __PB_WHITE__; + } + """) + + url: reactive[str] = reactive("") + + def render(self) -> str: + """Render the mermaid diagram link.""" + if self.url: + return f"Diagram: {self.url}" + return "No diagram generated yet" + + +class FilteredDirectoryTree(DirectoryTree): + """A DirectoryTree that hides hidden (dot-prefixed) files.""" + + def filter_paths(self, paths: _t.Iterable[Path]) -> _t.Iterable[Path]: + """Filter out hidden files and directories.""" + return [p for p in paths if not p.name.startswith(".")] + + +# -- Model Selection Screen -------------------------------------------------- + + +class ModelSelectionOverlay(VerticalScroll): + """Overlay for selecting a model.""" + + DEFAULT_CSS = _theme_css(""" + ModelSelectionOverlay { + dock: top; + layer: overlay; + width: 60; + max-height: 20; + margin: 3 5; + padding: 1 2; + background: __PB_BLACK__; + color: __PB_WHITE__; + border: thick __PB_ACCENT1__; + } + """) + + def compose(self) -> ComposeResult: + """Compose the model selection overlay.""" + yield Static( + "[bold]Select a model[/bold]\n", + id="model-title", + ) + yield OptionList(id="model-list") + + def set_models( + self, + models: list[str], + current: str, + ) -> None: + """Populate the model list.""" + option_list = self.query_one("#model-list", OptionList) + option_list.clear_options() + for m in models: + label = f"* {m} (current)" if m == current else f" {m}" + option_list.add_option(Option(label, id=m)) + + +# -- Main Application -------------------------------------------------------- + + +class PlugboardGoApp(App[None]): + """Plugboard Go - Interactive AI model builder.""" + + TITLE = "Plugboard Go" + SUB_TITLE = f"v{__version__}" + + CSS = _theme_css(""" + Screen { + background: __PB_BLACK__; + color: __PB_WHITE__; + } + + Header { + background: __PB_BLUE__; + color: __PB_WHITE__; + border-bottom: solid __PB_ACCENT1__; + } + + #footer-bar { + height: auto; + padding: 0 1; + background: __PB_BLUE__; + color: __PB_WHITE__; + border-top: solid __PB_ACCENT1__; + } + + #main-container { + width: 1fr; + height: 1fr; + } + + #chat-panel { + width: 3fr; + height: 1fr; + background: __PB_BLACK__; + } + + #sidebar { + width: 1fr; + min-width: 30; + max-width: 50; + height: 1fr; + border-left: thick __PB_ACCENT1__; + background: __PB_ACCENT3__; + } + + #chat-scroll { + height: 1fr; + background: __PB_BLACK__; + } + + #chat-input { + margin: 0 1; + background: __PB_BLACK__; + border: solid __PB_BLUE__; + color: __PB_WHITE__; + } + + #file-tree-label { + padding: 0 1; + text-style: bold; + background: __PB_BLUE__; + color: __PB_WHITE__; + } + + DirectoryTree { + height: 1fr; + background: __PB_ACCENT3__; + color: __PB_WHITE__; + } + + ModelSelectionOverlay { + display: none; + } + + ModelSelectionOverlay.visible { + display: block; + } + """) + + BINDINGS = [ + Binding( + "ctrl+m", + "select_model", + "Change Model", + show=True, + ), + Binding("ctrl+q", "quit", "Quit", show=True), + ] + + model_name: reactive[str] = reactive("gpt-4o") + mermaid_url: reactive[str] = reactive("") + + def __init__( + self, + model_name: str = "gpt-4o", + **kwargs: _t.Any, + ) -> None: + super().__init__(**kwargs) + self.model_name = model_name + self._agent: PlugboardAgent | None = None + self._current_assistant_msg: ChatMessage | None = None + self._user_input_future: asyncio.Future[UserInputResponse] | None = None + self._waiting_for_user_input = False + self._agent_busy = False + + def compose(self) -> ComposeResult: + """Compose the main application layout.""" + welcome = ( + "Welcome to **Plugboard Go**! I'm your AI " + "assistant powered by GitHub Copilot. Tell me " + "what model you'd like to build and I'll help " + "you design, implement, and run it.\n\nDescribe " + "your model at a high level and I'll help you " + "plan the components, connections, and data flow." + ) + yield HeaderBanner(id="header-banner") + with Horizontal(id="main-container"): + with Vertical(id="chat-panel"): + with VerticalScroll(id="chat-scroll"): + yield ChatMessage(welcome, role="system") + yield MermaidLink(id="mermaid-link") + yield Static( + "", + id="footer-bar", + ) + yield Input( + placeholder="Describe your model...", + id="chat-input", + ) + with Vertical(id="sidebar"): + yield Static("Files", id="file-tree-label") + yield FilteredDirectoryTree( + str(Path.cwd()), + id="file-tree", + ) + yield ModelSelectionOverlay(id="model-overlay") + + async def on_mount(self) -> None: + """Start up the Copilot agent when the app mounts.""" + banner = self.query_one( + "#header-banner", + HeaderBanner, + ) + banner.model_name = self.model_name + self._update_footer() + self._start_agent() + + # -- Agent lifecycle ------------------------------------------------------ + + @work(exclusive=True, thread=False) + async def _start_agent(self) -> None: + """Initialize the Copilot agent in a worker.""" + self._agent = PlugboardAgent( + model=self.model_name, + on_assistant_delta=self._handle_agent_delta, + on_assistant_message=self._handle_agent_msg, + on_tool_start=self._handle_agent_tool, + on_user_input_request=self._handle_user_input, + on_mermaid_url=self._handle_mermaid_url, + on_idle=self._handle_agent_idle, + ) + try: + await self._agent.start() + self.model_name = self._agent.model + banner = self.query_one( + "#header-banner", + HeaderBanner, + ) + banner.model_name = self.model_name + self.post_message( + AgentStatus("Connected to GitHub Copilot."), + ) + except Exception as e: + self.post_message( + AgentStatus( + f"Failed to connect to Copilot: {e}" + "\n\nMake sure the GitHub Copilot CLI " + "is installed and you are authenticated.", + ), + ) + + # -- Agent callbacks ------------------------------------------------------ + # Invoked by the Copilot SDK from within the same async event + # loop. We forward them as Textual Messages so that all UI + # mutations happen through normal message dispatch. + + def _handle_agent_delta(self, delta: str) -> None: + self.post_message(AgentDelta(delta)) + + def _handle_agent_msg(self, content: str) -> None: + self.post_message(AgentMessage(content)) + + def _handle_agent_tool(self, tool_name: str) -> None: + self.post_message(AgentToolStart(tool_name)) + + async def _handle_user_input( + self, + request: UserInputRequest, + invocation: dict[str, str], + ) -> UserInputResponse: + """Handle the agent asking the user a question.""" + question = request.get("question", "") + choices = request.get("choices") + + prompt_text = question + if choices: + prompt_text += "\n\nChoices:\n" + "\n".join(f"- {c}" for c in choices) + self.post_message(AgentQuestion(prompt_text)) + + loop = asyncio.get_running_loop() + self._user_input_future = loop.create_future() + self._waiting_for_user_input = True + + result = await self._user_input_future + self._waiting_for_user_input = False + return result + + def _handle_mermaid_url(self, url: str) -> None: + self.post_message(AgentMermaidUrl(url)) + + def _handle_agent_idle(self) -> None: + self.post_message(AgentIdle()) + + # -- Textual message handlers (run on main thread) ------------------------ + + def on_agent_delta(self, message: AgentDelta) -> None: + """Append streaming chunk to the assistant message.""" + if self._current_assistant_msg is None: + chat = self.query_one( + "#chat-scroll", + VerticalScroll, + ) + self._current_assistant_msg = ChatMessage( + "", + role="assistant", + ) + chat.mount(self._current_assistant_msg) + self._current_assistant_msg.append_content( + message.delta, + ) + self._current_assistant_msg.scroll_visible() + + def on_agent_message( + self, + message: AgentMessage, + ) -> None: + """Finalize the current assistant message.""" + if self._current_assistant_msg is not None: + self._current_assistant_msg.append_content("") + self._current_assistant_msg = None + + def on_agent_tool_start( + self, + message: AgentToolStart, + ) -> None: + """Show a system note when a tool starts.""" + self._add_system_message( + f"Running tool: `{message.tool_name}`...", + ) + + def on_agent_question( + self, + message: AgentQuestion, + ) -> None: + """Display the agent question and prompt the user.""" + self._add_chat_message( + message.text, + role="assistant", + ) + inp = self.query_one("#chat-input", Input) + inp.placeholder = "Type your answer..." + + def on_agent_mermaid_url( + self, + message: AgentMermaidUrl, + ) -> None: + """Update the mermaid diagram link.""" + self.mermaid_url = message.url + link = self.query_one("#mermaid-link", MermaidLink) + link.url = message.url + + def on_agent_idle(self, message: AgentIdle) -> None: + """Reset state when the session goes idle.""" + self._current_assistant_msg = None + self._agent_busy = False + self._update_footer() + + def on_agent_status( + self, + message: AgentStatus, + ) -> None: + """Display a system status message.""" + self._add_system_message(message.text) + + # -- UI helpers ----------------------------------------------------------- + + def _add_system_message(self, text: str) -> None: + """Add a system message to the chat.""" + self._add_chat_message(text, role="system") + + def _update_footer(self) -> None: + """Update the footer bar with current status.""" + parts = ["[bold]^Q[/bold] Quit", "[bold]^M[/bold] Change Model"] + if self._agent_busy: + parts.append(f"[{Theme.PB_ACCENT1}]● Waiting for Copilot...[/]") + try: + footer = self.query_one("#footer-bar", Static) + footer.update(" ".join(parts)) + except NoMatches: + pass + + def _append_to_last_message(self, text: str, role: str) -> bool: + """Append text to the last message when the role matches.""" + chat = self.query_one("#chat-scroll", VerticalScroll) + messages = list(chat.query(ChatMessage)) + if not messages or messages[-1].role != role: + return False + last_message = messages[-1] + combined = f"{last_message.content}\n\n{text.rstrip()}".strip() + last_message.replace_content(combined) + last_message.scroll_visible() + return True + + def _add_chat_message( + self, + text: str, + role: str = "assistant", + ) -> None: + """Add a message to the chat scroll area.""" + if self._append_to_last_message(text, role): + return + chat = self.query_one("#chat-scroll", VerticalScroll) + msg = ChatMessage(text.rstrip(), role=role) + chat.mount(msg) + msg.scroll_visible() + + # -- Input Handling ------------------------------------------------------- + + @on(Input.Submitted, "#chat-input") + def handle_chat_submit( + self, + event: Input.Submitted, + ) -> None: + """Handle user submitting a chat message.""" + text = event.value.strip() + if not text: + return + event.input.clear() + + if self._waiting_for_user_input and self._user_input_future is not None: + self._add_chat_message(text, role="user") + self._user_input_future.set_result( + UserInputResponse(answer=text, wasFreeform=True), + ) + inp = self.query_one("#chat-input", Input) + inp.placeholder = "Describe your model..." + return + + self._add_chat_message(text, role="user") + self._agent_busy = True + self._update_footer() + self._send_to_agent(text) + + @work(exclusive=True, thread=False) + async def _send_to_agent(self, text: str) -> None: + """Send a message to the agent in a worker.""" + if self._agent is None: + self._add_system_message( + "Agent is not connected yet. Please wait...", + ) + return + try: + await self._agent.send(text) + except Exception as e: + self._add_system_message(f"Error: {e}") + self._agent_busy = False + self._update_footer() + + # -- Model Selection ------------------------------------------------------ + + def action_select_model(self) -> None: + """Show the model selection overlay.""" + overlay = self.query_one( + "#model-overlay", + ModelSelectionOverlay, + ) + if overlay.has_class("visible"): + overlay.remove_class("visible") + return + overlay.add_class("visible") + overlay.focus() + self._fetch_models() + + @work(exclusive=True, thread=False) + async def _fetch_models(self) -> None: + """Fetch available models from the agent.""" + fallback = [ + "gpt-4o", + "gpt-5", + "claude-sonnet-4", + "claude-sonnet-4-thinking", + "o3", + ] + if self._agent is None: + self._populate_model_list(fallback) + return + models = await self._agent.list_models() + if not models: + models = fallback + self._populate_model_list(models) + + def _populate_model_list( + self, + models: list[str], + ) -> None: + """Populate the model selection overlay.""" + overlay = self.query_one( + "#model-overlay", + ModelSelectionOverlay, + ) + overlay.set_models(models, self.model_name) + option_list = overlay.query_one("#model-list", OptionList) + option_list.focus() + + @on(OptionList.OptionSelected, "#model-list") + def handle_model_selected( + self, + event: OptionList.OptionSelected, + ) -> None: + """Handle model selection.""" + model_id = event.option.id + if model_id and model_id != self.model_name: + self.model_name = model_id + banner = self.query_one( + "#header-banner", + HeaderBanner, + ) + banner.model_name = model_id + self._add_system_message( + f"Switching to model: **{model_id}**...", + ) + self._change_model(model_id) + + overlay = self.query_one( + "#model-overlay", + ModelSelectionOverlay, + ) + overlay.remove_class("visible") + + @work(exclusive=True, thread=False) + async def _change_model(self, model: str) -> None: + """Change the agent model.""" + if self._agent is not None: + try: + await self._agent.change_model(model) + self._add_system_message( + f"Now using model: **{model}**", + ) + self.query_one("#chat-input", Input).focus() + except Exception as e: + self._add_system_message( + f"Error changing model: {e}", + ) + + # -- Cleanup -------------------------------------------------------------- + + async def action_quit(self) -> None: + """Clean up and quit.""" + if self._agent is not None: + await self._agent.stop() + self.exit() diff --git a/plugboard/cli/go/tools.py b/plugboard/cli/go/tools.py new file mode 100644 index 00000000..793c532a --- /dev/null +++ b/plugboard/cli/go/tools.py @@ -0,0 +1,115 @@ +"""Tool definitions for the Plugboard Go Copilot agent.""" + +from __future__ import annotations + +from pathlib import Path +import typing as _t + +from copilot import Tool, define_tool +import msgspec +from pydantic import BaseModel, Field + +from plugboard.diagram.mermaid import MermaidDiagram +from plugboard.process import Process, ProcessBuilder +from plugboard.schemas import ConfigSpec +from plugboard.utils import add_sys_path + + +class RunModelParams(BaseModel): + """Parameters for running a Plugboard model from a YAML config file.""" + + yaml_path: str = Field( + description="Path to the YAML config file for the Plugboard model.", + ) + + +class MermaidDiagramParams(BaseModel): + """Parameters for generating a Mermaid diagram URL from a YAML file.""" + + yaml_path: str = Field( + description="Path to the YAML config file for the Plugboard model.", + ) + + +def _read_yaml(path: Path) -> ConfigSpec: + """Read and validate a YAML configuration file.""" + with open(path, "rb") as f: + data = msgspec.yaml.decode(f.read()) + return ConfigSpec.model_validate(data) + + +def _build_process(config: ConfigSpec) -> Process: + """Build a process from a config spec.""" + return ProcessBuilder.build(config.plugboard.process) + + +def create_run_model_tool() -> Tool: + """Create the run_model tool for Copilot.""" + + @define_tool( + name="run_plugboard_model", + description=( + "Run a Plugboard model from a YAML configuration file. " + "Returns the result of the model run, including any output or errors." + ), + ) + async def run_plugboard_model(params: RunModelParams) -> str: + yaml_path = Path(params.yaml_path).resolve() + if not yaml_path.exists(): + return f"Error: YAML file not found at {yaml_path}" + if yaml_path.suffix not in (".yaml", ".yml"): + return f"Error: File must be a .yaml or .yml file, got {yaml_path.suffix}" + + try: + config = _read_yaml(yaml_path) + with add_sys_path(yaml_path.parent): + process = _build_process(config) + + async with process: + await process.run() + + return f"Model ran successfully from {yaml_path}" + except Exception as e: + return f"Error running model: {type(e).__name__}: {e}" + + return run_plugboard_model + + +def create_mermaid_diagram_tool( + on_url_generated: _t.Callable[[str], None] | None = None, +) -> Tool: + """Create the mermaid_diagram tool for Copilot. + + Args: + on_url_generated: Optional callback invoked with the generated URL. + """ + + @define_tool( + name="get_mermaid_diagram_url", + description=( + "Generate a Mermaid diagram URL for a Plugboard model " + "defined in a YAML configuration file. Returns a URL to " + "the Mermaid Live Editor where it can be viewed and edited." + ), + ) + async def get_mermaid_diagram_url(params: MermaidDiagramParams) -> str: + yaml_path = Path(params.yaml_path).resolve() + if not yaml_path.exists(): + return f"Error: YAML file not found at {yaml_path}" + + try: + config = _read_yaml(yaml_path) + with add_sys_path(yaml_path.parent): + process = _build_process(config) + + diagram = MermaidDiagram.from_process(process) + url = diagram.url + + if on_url_generated is not None: + on_url_generated(url) + + return f"Mermaid diagram URL: {url}" + except Exception as e: + return f"Error generating diagram: {type(e).__name__}: {e}" + + return get_mermaid_diagram_url diff --git a/plugboard/utils/theme.py b/plugboard/utils/theme.py new file mode 100644 index 00000000..552789f2 --- /dev/null +++ b/plugboard/utils/theme.py @@ -0,0 +1,14 @@ +"""Shared Plugboard theme colors.""" + + +class PlugboardTheme: + """Color definitions shared across Plugboard UIs.""" + + PB_BLUE = "#075D7A" + PB_BLACK = "#1C0F13" + PB_GRAY = "#C2C2C2" + PB_ACCENT1 = "#CC9C4A" + PB_ACCENT2 = "#8A875A" + PB_ACCENT3 = "#49726A" + PB_WHITE = "#F9F9F9" + PB_PINK = "#D65780" diff --git a/pyproject.toml b/pyproject.toml index 63b2bb4c..de410eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ llm = [ "llama-index-core>=0.12.30,<1", "llama-index-llms-openai>=0.3.33,<1", ] +go = [ + "github-copilot-sdk>=0.1.30,<1.0", + "textual>=1.0.0,<2", +] # Pinning jsonschema due to performance issues with Lark and rfc3987-syntax parser # https://github.com/python-jsonschema/jsonschema/issues/1392 ray = ["ray[default,tune]>=2.47.1,<3", "jsonschema<4.25.0", "optuna>=3.0,<5"] @@ -113,6 +117,10 @@ source = "vcs" # Use fallback to all dependabot to run https://github.com/dependabot/dependabot-core/issues/12340 fallback_version = "0.0.0" +[tool.hatch.build.targets.wheel] +packages = ["plugboard"] +artifacts = ["plugboard/cli/go/AGENTS.md"] + [tool.uv] package = true default-groups = ["all"] diff --git a/tests/unit/test_cli_go.py b/tests/unit/test_cli_go.py new file mode 100644 index 00000000..ab47f03d --- /dev/null +++ b/tests/unit/test_cli_go.py @@ -0,0 +1,422 @@ +"""Unit tests for the Plugboard Go CLI module.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from plugboard.cli import app + + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# CLI entry-point / dependency-check tests +# --------------------------------------------------------------------------- + + +class TestGoCliEntrypoint: + """Tests for the ``plugboard go`` CLI sub-command entry-point.""" + + def test_go_missing_copilot_dependency(self) -> None: + """Should exit with an error when the 'copilot' package is missing.""" + with patch( + "plugboard.utils.dependencies.find_spec", + side_effect=lambda name: object() if name == "textual" else None, + ): + result = runner.invoke(app, ["go"]) + assert result.exit_code == 1 + assert "Optional dependency copilot not found" in result.stdout + assert "pip install plugboard[go]" in result.stdout + + def test_go_missing_textual_dependency(self) -> None: + """Should exit with an error when the 'textual' package is missing.""" + with patch( + "plugboard.utils.dependencies.find_spec", + side_effect=lambda name: None if name == "textual" else object(), + ): + result = runner.invoke(app, ["go"]) + assert result.exit_code == 1 + assert "Optional dependency textual not found" in result.stdout + + def test_go_default_model_option(self) -> None: + """The --model flag should default to gpt-4o.""" + with patch("plugboard.cli.go.app.PlugboardGoApp") as mock_app_cls: + mock_app = MagicMock() + mock_app_cls.return_value = mock_app + result = runner.invoke(app, ["go"]) + assert result.exit_code == 0 + mock_app_cls.assert_called_once_with(model_name="gpt-4o") + mock_app.run.assert_called_once() + + def test_go_custom_model_option(self) -> None: + """The --model flag should accept a custom model name.""" + with patch("plugboard.cli.go.app.PlugboardGoApp") as mock_app_cls: + mock_app = MagicMock() + mock_app_cls.return_value = mock_app + result = runner.invoke(app, ["go", "--model", "claude-sonnet-4"]) + assert result.exit_code == 0 + mock_app_cls.assert_called_once_with(model_name="claude-sonnet-4") + + def test_go_short_model_flag(self) -> None: + """The -m short flag should work.""" + with patch("plugboard.cli.go.app.PlugboardGoApp") as mock_app_cls: + mock_app = MagicMock() + mock_app_cls.return_value = mock_app + result = runner.invoke(app, ["go", "-m", "gpt-5"]) + assert result.exit_code == 0 + mock_app_cls.assert_called_once_with(model_name="gpt-5") + + +# --------------------------------------------------------------------------- +# System prompt loading tests +# --------------------------------------------------------------------------- + + +class TestLoadSystemPrompt: + """Tests for ``_load_system_prompt``.""" + + def test_loads_from_package_data(self) -> None: + """Should load AGENTS.md from importlib.resources (package data).""" + from plugboard.cli.go.agent import _load_system_prompt + + prompt = _load_system_prompt() + # The bundled AGENTS.md starts with this heading + assert "Plugboard" in prompt + assert len(prompt) > 100 + + def test_falls_back_to_cwd(self, tmp_path: Path) -> None: + """Should fall back to examples/AGENTS.md in cwd when package data unavailable.""" + from plugboard.cli.go.agent import _load_system_prompt + + # Create fake AGENTS.md in tmp_path + examples_dir = tmp_path / "examples" + examples_dir.mkdir() + agents_file = examples_dir / "AGENTS.md" + agents_file.write_text("# Test prompt from cwd") + + with ( + patch( + "plugboard.cli.go.agent.resources.files", + side_effect=FileNotFoundError, + ), + patch("plugboard.cli.go.agent.Path.cwd", return_value=tmp_path), + ): + prompt = _load_system_prompt() + + assert prompt == "# Test prompt from cwd" + + def test_falls_back_to_builtin(self) -> None: + """Should use the built-in fallback when neither source is available.""" + from plugboard.cli.go.agent import ( + _FALLBACK_SYSTEM_PROMPT, + _load_system_prompt, + ) + + with ( + patch( + "plugboard.cli.go.agent.resources.files", + side_effect=FileNotFoundError, + ), + patch( + "plugboard.cli.go.agent.Path.cwd", + return_value=Path("/nonexistent"), + ), + ): + prompt = _load_system_prompt() + + assert prompt == _FALLBACK_SYSTEM_PROMPT + + +# --------------------------------------------------------------------------- +# PlugboardAgent tests +# --------------------------------------------------------------------------- + + +class TestPlugboardAgent: + """Tests for PlugboardAgent initialization and callbacks.""" + + def test_agent_init(self) -> None: + """Agent should store model and callbacks.""" + from plugboard.cli.go.agent import PlugboardAgent + + delta_cb = MagicMock() + msg_cb = MagicMock() + + agent = PlugboardAgent( + model="gpt-4o", + on_assistant_delta=delta_cb, + on_assistant_message=msg_cb, + ) + assert agent.model == "gpt-4o" + assert agent._on_assistant_delta is delta_cb + assert agent._on_assistant_message is msg_cb + + def test_agent_model_property(self) -> None: + """The model property should return the configured model.""" + from plugboard.cli.go.agent import PlugboardAgent + + agent = PlugboardAgent(model="gpt-5") + assert agent.model == "gpt-5" + + @pytest.mark.asyncio + async def test_send_raises_without_start(self) -> None: + """send() should raise RuntimeError if agent not started.""" + from plugboard.cli.go.agent import PlugboardAgent + + agent = PlugboardAgent(model="gpt-4o") + with pytest.raises(RuntimeError, match="not started"): + await agent.send("hello") + + @pytest.mark.asyncio + async def test_list_models_raises_without_start(self) -> None: + """list_models() should raise RuntimeError if agent not started.""" + from plugboard.cli.go.agent import PlugboardAgent + + agent = PlugboardAgent(model="gpt-4o") + with pytest.raises(RuntimeError, match="not started"): + await agent.list_models() + + @pytest.mark.asyncio + async def test_stop_is_safe_without_start(self) -> None: + """stop() should not raise even if agent was never started.""" + from plugboard.cli.go.agent import PlugboardAgent + + agent = PlugboardAgent(model="gpt-4o") + # Should not raise + await agent.stop() + assert agent._client is None + assert agent._session is None + + @pytest.mark.asyncio + async def test_change_model_restarts_agent(self) -> None: + """change_model() should restart the agent cleanly.""" + from plugboard.cli.go.agent import PlugboardAgent + + agent = PlugboardAgent(model="gpt-4o") + + with ( + patch.object(agent, "stop", new=AsyncMock()) as mock_stop, + patch.object(agent, "start", new=AsyncMock()) as mock_start, + ): + await agent.change_model("gpt-5") + + assert agent.model == "gpt-5" + mock_stop.assert_awaited_once() + mock_start.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# App widget / message tests +# --------------------------------------------------------------------------- + + +class TestAppWidgets: + """Tests for Plugboard Go TUI widgets and messages.""" + + def test_chat_message_roles(self) -> None: + """ChatMessage should accept user/assistant/system roles.""" + from plugboard.cli.go.app import ChatMessage + + for role in ("user", "assistant", "system"): + msg = ChatMessage("test content", role=role) + assert role in msg.classes + + def test_agent_delta_message(self) -> None: + """AgentDelta message should store delta text.""" + from plugboard.cli.go.app import AgentDelta + + msg = AgentDelta("hello") + assert msg.delta == "hello" + + def test_agent_message(self) -> None: + """AgentMessage should store content.""" + from plugboard.cli.go.app import AgentMessage + + msg = AgentMessage("full response") + assert msg.content == "full response" + + def test_agent_tool_start_message(self) -> None: + """AgentToolStart should store tool name.""" + from plugboard.cli.go.app import AgentToolStart + + msg = AgentToolStart("run_plugboard_model") + assert msg.tool_name == "run_plugboard_model" + + def test_agent_question_message(self) -> None: + """AgentQuestion should store text.""" + from plugboard.cli.go.app import AgentQuestion + + msg = AgentQuestion("What model?") + assert msg.text == "What model?" + + def test_agent_mermaid_url_message(self) -> None: + """AgentMermaidUrl should store URL.""" + from plugboard.cli.go.app import AgentMermaidUrl + + msg = AgentMermaidUrl("https://example.com") + assert msg.url == "https://example.com" + + def test_agent_idle_message(self) -> None: + """AgentIdle should be constructable.""" + from plugboard.cli.go.app import AgentIdle + + msg = AgentIdle() + assert isinstance(msg, AgentIdle) + + def test_agent_status_message(self) -> None: + """AgentStatus should store text.""" + from plugboard.cli.go.app import AgentStatus + + msg = AgentStatus("Connected") + assert msg.text == "Connected" + + def test_model_selector_default(self) -> None: + """ModelSelector default model should be gpt-4o.""" + from plugboard.cli.go.app import ModelSelector + + selector = ModelSelector() + assert selector.model_name == "gpt-4o" + + def test_mermaid_link_default(self) -> None: + """MermaidLink default URL should be empty.""" + from plugboard.cli.go.app import MermaidLink + + link = MermaidLink() + assert link.url == "" + + +# --------------------------------------------------------------------------- +# Tool parameter model tests +# --------------------------------------------------------------------------- + + +class TestToolParams: + """Tests for Copilot tool parameter models.""" + + def test_run_model_params(self) -> None: + """RunModelParams should validate yaml_path.""" + from plugboard.cli.go.tools import RunModelParams + + params = RunModelParams(yaml_path="/path/to/model.yaml") + assert params.yaml_path == "/path/to/model.yaml" + + def test_mermaid_diagram_params(self) -> None: + """MermaidDiagramParams should validate yaml_path.""" + from plugboard.cli.go.tools import MermaidDiagramParams + + params = MermaidDiagramParams(yaml_path="config.yml") + assert params.yaml_path == "config.yml" + + +# --------------------------------------------------------------------------- +# App construction test (Textual pilot) +# --------------------------------------------------------------------------- + + +class TestPlugboardGoApp: + """Tests for the main PlugboardGoApp.""" + + def test_app_construction(self) -> None: + """App should initialize with default and custom model names.""" + from plugboard.cli.go.app import PlugboardGoApp + + app = PlugboardGoApp() + assert app.model_name == "gpt-4o" + + app2 = PlugboardGoApp(model_name="claude-sonnet-4") + assert app2.model_name == "claude-sonnet-4" + + def test_app_uses_shared_theme_colors(self) -> None: + """App CSS should use the shared theme constants.""" + from plugboard.cli.go.app import PlugboardGoApp + from plugboard.utils.theme import PlugboardTheme + + app = PlugboardGoApp() + assert PlugboardTheme.PB_BLUE in app.CSS + assert PlugboardTheme.PB_WHITE in app.CSS + assert PlugboardTheme.PB_ACCENT1 in app.CSS + + def test_app_has_bindings(self) -> None: + """App should have model-select and quit bindings.""" + from plugboard.cli.go.app import PlugboardGoApp + + app = PlugboardGoApp() + binding_keys = [b.key for b in app.BINDINGS] + assert "m" in binding_keys + assert "q" in binding_keys + + @pytest.mark.asyncio + async def test_app_compose_mounts(self) -> None: + """App should compose without errors using Textual test pilot.""" + from plugboard.cli.go.app import PlugboardGoApp + + # Patch the agent start so it doesn't actually connect + with patch( + "plugboard.cli.go.app.PlugboardAgent", + ) as mock_agent_cls: + mock_agent = AsyncMock() + mock_agent_cls.return_value = mock_agent + + app = PlugboardGoApp(model_name="gpt-4o") + async with app.run_test(size=(120, 40)): + # Main widgets should be mounted + assert app.query_one("#chat-scroll") is not None + assert app.query_one("#chat-input") is not None + assert app.query_one("#model-selector") is not None + assert app.query_one("#mermaid-link") is not None + assert app.query_one("#file-tree") is not None + assert app.query_one("#model-overlay") is not None + assert app.query_one("#shortcut-hint") is not None + assert app.query_one("#title-banner") is not None + + @pytest.mark.asyncio + async def test_app_handles_agent_status_message(self) -> None: + """AgentStatus messages should appear as system messages.""" + from plugboard.cli.go.app import AgentStatus, PlugboardGoApp + + with patch( + "plugboard.cli.go.app.PlugboardAgent", + ) as mock_agent_cls: + mock_agent = AsyncMock() + mock_agent_cls.return_value = mock_agent + + app = PlugboardGoApp(model_name="gpt-4o") + async with app.run_test(size=(120, 40)) as pilot: # noqa: F841 + app.post_message(AgentStatus("Test status")) + await asyncio.sleep(0.1) + # The status message should be merged into the existing + # welcome system message rather than creating a new card. + chat_scroll = app.query_one("#chat-scroll") + from plugboard.cli.go.app import ChatMessage + + messages = chat_scroll.query(ChatMessage) + assert len(messages) == 1 + assert "Test status" in messages.first()._content + + @pytest.mark.asyncio + async def test_app_collapses_consecutive_user_messages(self) -> None: + """Consecutive messages with the same role should collapse.""" + from plugboard.cli.go.app import ChatMessage, PlugboardGoApp + + with patch( + "plugboard.cli.go.app.PlugboardAgent", + ) as mock_agent_cls: + mock_agent = AsyncMock() + mock_agent_cls.return_value = mock_agent + + app = PlugboardGoApp(model_name="gpt-4o") + async with app.run_test(size=(120, 40)): + app._add_chat_message("First user line", role="user") + app._add_chat_message("Second user line", role="user") + + chat_scroll = app.query_one("#chat-scroll") + messages = list(chat_scroll.query(ChatMessage)) + + assert messages[-1].role == "user" + assert "First user line\nSecond user line" in messages[-1]._content diff --git a/uv.lock b/uv.lock index 59a810db..e2ac588b 100644 --- a/uv.lock +++ b/uv.lock @@ -692,10 +692,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -1379,6 +1385,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "github-copilot-sdk" +version = "0.1.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/37/92b8037c0673999ac1c49e9d079cf6d36283e6ee3453d66b54878da81bc8/github_copilot_sdk-0.1.30-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:47e95246a63beeebf192db6013662c5f39778ccfa6b1b718b79cbec6b6a88bf8", size = 58182964, upload-time = "2026-03-03T17:21:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/08/79/9d0628fa819df73e92ebbd4af949cdd82850cc4bde79b3e78040fcd8ed80/github_copilot_sdk-0.1.30-py3-none-macosx_11_0_arm64.whl", hash = "sha256:601cbe1c5a576906b73cbf8591429451c91148bff5a564e56e1e83ff99b2dc10", size = 54935274, upload-time = "2026-03-03T17:21:57.494Z" }, + { url = "https://files.pythonhosted.org/packages/10/5d/f407e9c9155f912780b4587ab74abf3b94fae91af0463bad317cc8aacdfe/github_copilot_sdk-0.1.30-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:735fb90683bea27a418a0d45df430492db2a395e5ae88d575ac138be49d6cf07", size = 61071530, upload-time = "2026-03-03T17:22:01.601Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/5c2ab2baf5f185150058c774da2b5e4c613b4532c48b499ce127419da461/github_copilot_sdk-0.1.30-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:21ade06dfe5ca111663c42fff000ab3ec6595e51b1cf4ab56ff550cdd7a2992f", size = 59252204, upload-time = "2026-03-03T17:22:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/4e72ccdc8868250ba8c5d48a1fef5a8244361c2a586820de9b77df0c79ed/github_copilot_sdk-0.1.30-py3-none-win_amd64.whl", hash = "sha256:f1be9e49da2af370a914d4425bfecbc2daecf8e5de0074beaa1e22735bdd5da6", size = 53691358, upload-time = "2026-03-03T17:22:09.474Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/25ff085d0d5d50d1197fd6ae9a53adc4cc8298940212f5a69f7ced68c33e/github_copilot_sdk-0.1.30-py3-none-win_arm64.whl", hash = "sha256:3e0691eb3030c385f629d63d74ded938e0577fcd98f452259efd5d7fb2283576", size = 51699653, upload-time = "2026-03-03T17:22:13.215Z" }, +] + [[package]] name = "google-api-core" version = "2.29.0" @@ -2485,6 +2508,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/bd/606e2f7eb0da042bffd8711a7427f7a28ca501aa6b1e3367ae3c7d4dc489/licensecheck-2025.1.0-py3-none-any.whl", hash = "sha256:eb20131cd8f877e5396958fd7b00cdb2225436c37a59dba4cf36d36079133a17", size = 26681, upload-time = "2025-03-26T22:58:03.145Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "llama-index-core" version = "0.14.13" @@ -2630,6 +2665,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -3823,6 +3866,10 @@ azure = [ gcp = [ { name = "gcsfs" }, ] +go = [ + { name = "github-copilot-sdk" }, + { name = "textual" }, +] llm = [ { name = "llama-index-core" }, { name = "llama-index-llms-openai" }, @@ -3928,6 +3975,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.29.0,<1" }, { name = "fsspec", specifier = ">=2024.9.0" }, { name = "gcsfs", marker = "extra == 'gcp'", specifier = ">=2024.9.0" }, + { name = "github-copilot-sdk", marker = "extra == 'go'", specifier = ">=0.1.30,<1.0" }, { name = "httpx", specifier = ">=0.27,<1" }, { name = "jsonschema", marker = "extra == 'ray'", specifier = "<4.25.0" }, { name = "llama-index-core", marker = "extra == 'llm'", specifier = ">=0.12.30,<1" }, @@ -3945,12 +3993,13 @@ requires-dist = [ { name = "s3fs", marker = "extra == 'aws'", specifier = ">=2024.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" }, { name = "structlog", specifier = ">=25.1.0,<26" }, + { name = "textual", marker = "extra == 'go'", specifier = ">=1.0.0,<2" }, { name = "that-depends", specifier = ">=3.4.1,<4" }, { name = "typer", specifier = ">=0.12,<1" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0,<1" }, { name = "websockets", marker = "extra == 'websockets'", specifier = ">=14.2,<15" }, ] -provides-extras = ["aws", "azure", "gcp", "llm", "ray", "websockets"] +provides-extras = ["aws", "azure", "gcp", "go", "llm", "ray", "websockets"] [package.metadata.requires-dev] all = [ @@ -5405,6 +5454,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "textual" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/b6/59b1de04bb4dca0f21ed7ba0b19309ed7f3f5de4396edf20cc2855e53085/textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399", size = 1532733, upload-time = "2024-12-12T10:42:03.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/bb/5fb6656c625019cd653d5215237d7cd6e0b12e7eae4195c3d1c91b2136fc/textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f", size = 660456, upload-time = "2024-12-12T10:42:00.375Z" }, +] + [[package]] name = "that-depends" version = "3.9.1" @@ -5718,6 +5782,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uri-template" version = "1.3.0"