From 23558adda1a077586ba680121abafe7e0f3f18b9 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Thu, 5 Mar 2026 20:52:20 +0000 Subject: [PATCH 01/13] Use cheap models for linting and docs --- .github/agents/docs.agent.md | 1 + .github/agents/lint.agent.md | 1 + 2 files changed, 2 insertions(+) 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/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. From 6ace083e3101b452f6fd19e7f4f6e3df44bc64bc Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Thu, 5 Mar 2026 21:03:42 +0000 Subject: [PATCH 02/13] Make AGENTS more general and move some instructions to the examples agent --- .github/agents/examples.agent.md | 22 ++++++++++++++++++++++ examples/AGENTS.md | 22 ++-------------------- 2 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 .github/agents/examples.agent.md diff --git a/.github/agents/examples.agent.md b/.github/agents/examples.agent.md new file mode 100644 index 00000000..3e3346c2 --- /dev/null +++ b/.github/agents/examples.agent.md @@ -0,0 +1,22 @@ +--- +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: ['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. + - Demo notebooks should be organized by domain into folders. + +## 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/examples/AGENTS.md b/examples/AGENTS.md index 8917cfdd..bad57fc7 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 From 99a4dffea67d9be5f54f3453c7c328d6cc28b74d Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Thu, 5 Mar 2026 21:13:17 +0000 Subject: [PATCH 03/13] Add researcher agent --- .github/agents/examples.agent.md | 3 ++- .github/agents/researcher.agent.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .github/agents/researcher.agent.md diff --git a/.github/agents/examples.agent.md b/.github/agents/examples.agent.md index 3e3346c2..8ac2953b 100644 --- a/.github/agents/examples.agent.md +++ b/.github/agents/examples.agent.md @@ -2,7 +2,7 @@ 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: ['docs', 'lint'] +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. @@ -16,6 +16,7 @@ You are responsible for building high quality tutorials and demo examples for th - 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. - Demo notebooks should be organized by domain into folders. +- 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. ## 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. 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 From 8bb005f7dfa0ad5f2840f805a9b595ce3eb6344a Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Thu, 5 Mar 2026 21:32:21 +0000 Subject: [PATCH 04/13] More reorg --- .github/agents/examples.agent.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/agents/examples.agent.md b/.github/agents/examples.agent.md index 8ac2953b..5aba458e 100644 --- a/.github/agents/examples.agent.md +++ b/.github/agents/examples.agent.md @@ -15,9 +15,28 @@ You are responsible for building high quality tutorials and demo examples for th - 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. - - Demo notebooks should be organized by domain into folders. - 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 From a43ee6267cea611b943a5ccb80a0aaab1038f57f Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Thu, 5 Mar 2026 21:35:13 +0000 Subject: [PATCH 05/13] Remove specifics from AGENTS --- examples/AGENTS.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/examples/AGENTS.md b/examples/AGENTS.md index bad57fc7..a0464a60 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -226,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` From 6e561c20015e405f06bb40bca15ce62577fd9560 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Fri, 6 Mar 2026 19:01:42 +0000 Subject: [PATCH 06/13] Add dependencies --- pyproject.toml | 8 ++++++ uv.lock | 75 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) 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/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" From c14d5866c5978c5b92b0ee41b60d3e4449f33b27 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Fri, 6 Mar 2026 19:16:58 +0000 Subject: [PATCH 07/13] Initial work on TUI app --- plugboard/cli/__init__.py | 2 + plugboard/cli/go/AGENTS.md | 1 + plugboard/cli/go/__init__.py | 44 +++ plugboard/cli/go/agent.py | 179 +++++++++ plugboard/cli/go/app.py | 679 +++++++++++++++++++++++++++++++++++ plugboard/cli/go/tools.py | 115 ++++++ tests/unit/test_cli_go.py | 377 +++++++++++++++++++ 7 files changed, 1397 insertions(+) create mode 120000 plugboard/cli/go/AGENTS.md create mode 100644 plugboard/cli/go/__init__.py create mode 100644 plugboard/cli/go/agent.py create mode 100644 plugboard/cli/go/app.py create mode 100644 plugboard/cli/go/tools.py create mode 100644 tests/unit/test_cli_go.py 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..74a66aba --- /dev/null +++ b/plugboard/cli/go/AGENTS.md @@ -0,0 +1 @@ +/Users/tobycoleman/source/plugboard/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..cda5b8af --- /dev/null +++ b/plugboard/cli/go/__init__.py @@ -0,0 +1,44 @@ +"""Plugboard Go CLI - interactive AI-powered model builder.""" + +import typer + + +app = typer.Typer(rich_markup_mode="rich", pretty_exceptions_show_locals=False) + + +def _check_dependencies() -> None: + """Check that optional 'go' dependencies are installed.""" + missing: list[str] = [] + try: + import copilot # noqa: F401 + except ImportError: + missing.append("github-copilot") + try: + import textual # noqa: F401 + except ImportError: + missing.append("textual") + if missing: + typer.echo( + f"Missing dependencies: {', '.join(missing)}\n" + "Install them with:\n\n" + " pip install plugboard[go]\n" + ) + raise typer.Exit(1) + + +@app.callback(invoke_without_command=True) +def go( + model: str = typer.Option( + "gpt-4o", + "--model", + "-m", + help="LLM model to use (e.g. gpt-4o, claude-sonnet-4, gpt-5).", + ), +) -> None: + """Launch the interactive Plugboard model builder powered by GitHub Copilot.""" + _check_dependencies() + + from plugboard.cli.go.app import PlugboardGoApp + + tui = PlugboardGoApp(model_name=model) + tui.run() diff --git a/plugboard/cli/go/agent.py b/plugboard/cli/go/agent.py new file mode 100644 index 00000000..eaaa52ee --- /dev/null +++ b/plugboard/cli/go/agent.py @@ -0,0 +1,179 @@ +"""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 + +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.""" + + 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[[dict, dict], _t.Awaitable[dict]] | 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() + + system_prompt = _load_system_prompt() + + tools = [ + create_run_model_tool(), + create_mermaid_diagram_tool(on_url_generated=self._on_mermaid_url), + ] + + session_config: dict[str, _t.Any] = { + "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) + + 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 + if self._session is not None: + await self._session.destroy() + if self._client is not None: + system_prompt = _load_system_prompt() + tools = [ + create_run_model_tool(), + create_mermaid_diagram_tool(on_url_generated=self._on_mermaid_url), + ] + session_config: dict[str, _t.Any] = { + "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 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..d5499411 --- /dev/null +++ b/plugboard/cli/go/app.py @@ -0,0 +1,679 @@ +"""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, + Footer, + Header, + Input, + Markdown, + OptionList, + Static, +) +from textual.widgets.option_list import Option + +from plugboard import __version__ +from plugboard.cli.go.agent import PlugboardAgent + + +# -- 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 = """ + ChatMessage { + padding: 1 2; + margin: 0 0 1 0; + } + ChatMessage.user { + background: #1C0F13; + border-left: thick #075D7A; + color: #F9F9F9; + } + ChatMessage.assistant { + background: #0A3D4D; + border-left: thick #CC9C4A; + color: #F9F9F9; + } + ChatMessage.system { + background: #1C0F13; + border-left: thick #49726A; + color: #C2C2C2; + } + """ + + def __init__( + self, + content: str, + role: str = "assistant", + **kwargs: _t.Any, + ) -> None: + super().__init__(**kwargs) + self._role = role + self._content = content + self.add_class(role) + + 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 += delta + try: + md = self.query_one(".message-body", Markdown) + md.update(self._content) + except NoMatches: + pass + + +class ModelSelector(Static): + """Displays selected model and allows changing it.""" + + DEFAULT_CSS = """ + ModelSelector { + dock: top; + height: 1; + padding: 0 1; + background: #075D7A; + color: #F9F9F9; + text-style: bold; + } + """ + + model_name: reactive[str] = reactive("gpt-4o") + + def render(self) -> str: + """Render the model selector.""" + return f"Model: {self.model_name} (press [bold]m[/bold] to change)" + + +class MermaidLink(Static): + """Displays a link to the mermaid diagram.""" + + DEFAULT_CSS = """ + MermaidLink { + dock: bottom; + height: 1; + padding: 0 1; + background: #075D7A; + color: #F9F9F9; + } + """ + + 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" + + +# -- Model Selection Screen -------------------------------------------------- + + +class ModelSelectionOverlay(VerticalScroll): + """Overlay for selecting a model.""" + + DEFAULT_CSS = """ + ModelSelectionOverlay { + dock: top; + layer: overlay; + width: 60; + max-height: 20; + margin: 3 5; + padding: 1 2; + background: #1C0F13; + color: #F9F9F9; + border: thick #CC9C4A; + } + """ + + 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 = """ + Screen { + background: #1C0F13; + color: #F9F9F9; + } + + Header { + background: #075D7A; + color: #F9F9F9; + border-bottom: solid #CC9C4A; + } + + Footer { + background: #075D7A; + color: #F9F9F9; + border-top: solid #CC9C4A; + } + + #title-banner { + dock: top; + height: 3; + padding: 1 2; + background: #075D7A; + color: #F9F9F9; + text-align: center; + } + + #main-container { + width: 1fr; + height: 1fr; + } + + #chat-panel { + width: 3fr; + height: 1fr; + background: #1C0F13; + } + + #sidebar { + width: 1fr; + min-width: 30; + max-width: 50; + height: 1fr; + border-left: thick #CC9C4A; + background: #0A3D4D; + } + + #chat-scroll { + height: 1fr; + background: #1C0F13; + } + + #chat-input { + dock: bottom; + margin: 0 1; + border: solid #075D7A; + } + + #chat-input Input { + background: #1C0F13; + border: solid #075D7A; + color: #F9F9F9; + } + + #file-tree-label { + padding: 0 1; + text-style: bold; + background: #075D7A; + color: #F9F9F9; + } + + DirectoryTree { + height: 1fr; + background: #0A3D4D; + color: #F9F9F9; + } + + ModelSelectionOverlay { + display: none; + } + + ModelSelectionOverlay.visible { + display: block; + } + """ + + BINDINGS = [ + Binding( + "m", + "select_model", + "Change Model", + show=True, + ), + Binding("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[dict] | None = None + self._waiting_for_user_input = 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 Header() + # Title banner with Plugboard branding + yield Static( + f"[bold #CC9C4A]Plugboard[/] [#C2C2C2]v{__version__}[/]", + id="title-banner", + ) + yield ModelSelector(id="model-selector") + 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 Input( + placeholder="Describe your model...", + id="chat-input", + ) + with Vertical(id="sidebar"): + yield Static("Files", id="file-tree-label") + yield DirectoryTree( + str(Path.cwd()), + id="file-tree", + ) + yield ModelSelectionOverlay(id="model-overlay") + yield Footer() + + async def on_mount(self) -> None: + """Start up the Copilot agent when the app mounts.""" + selector = self.query_one( + "#model-selector", + ModelSelector, + ) + selector.model_name = self.model_name + 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.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: dict, + invocation: dict, + ) -> dict: + """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 + + 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 _add_chat_message( + self, + text: str, + role: str = "assistant", + ) -> None: + """Add a message to the chat scroll area.""" + chat = self.query_one("#chat-scroll", VerticalScroll) + msg = ChatMessage(text, 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( + {"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._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}") + + # -- 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") + 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) + + @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 + selector = self.query_one( + "#model-selector", + ModelSelector, + ) + selector.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}**", + ) + 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..083df683 --- /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 +from typing import Callable + +from copilot import 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() -> object: + """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: Callable[[str], None] | None = None, +) -> object: + """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/tests/unit/test_cli_go.py b/tests/unit/test_cli_go.py new file mode 100644 index 00000000..64cbce56 --- /dev/null +++ b/tests/unit/test_cli_go.py @@ -0,0 +1,377 @@ +"""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.dict("sys.modules", {"copilot": None}): + # Force reimport so the check re-runs + import importlib + + import plugboard.cli.go as go_mod + + importlib.reload(go_mod) + + result = runner.invoke(app, ["go"]) + assert result.exit_code == 1 + assert "Missing dependencies" 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.dict("sys.modules", {"textual": None}): + import importlib + + import plugboard.cli.go as go_mod + + importlib.reload(go_mod) + + result = runner.invoke(app, ["go"]) + assert result.exit_code == 1 + assert "Missing dependencies" 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 + + +# --------------------------------------------------------------------------- +# 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_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 + + @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 system message should have been added + chat_scroll = app.query_one("#chat-scroll") + # At least the welcome message + status message + from plugboard.cli.go.app import ChatMessage + + messages = chat_scroll.query(ChatMessage) + assert len(messages) >= 2 From e6c6f3458daf67dfb2ae0d949675a8018675b21b Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Fri, 6 Mar 2026 19:56:39 +0000 Subject: [PATCH 08/13] Remove AGENTS --- plugboard/cli/go/AGENTS.md | 1 - 1 file changed, 1 deletion(-) delete mode 120000 plugboard/cli/go/AGENTS.md diff --git a/plugboard/cli/go/AGENTS.md b/plugboard/cli/go/AGENTS.md deleted file mode 120000 index 74a66aba..00000000 --- a/plugboard/cli/go/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -/Users/tobycoleman/source/plugboard/examples/AGENTS.md \ No newline at end of file From 481dba1f8c9eaf9ddd34109bd058f71d5fc6023c Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Fri, 6 Mar 2026 19:56:54 +0000 Subject: [PATCH 09/13] Replace with symlink --- plugboard/cli/go/AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 plugboard/cli/go/AGENTS.md diff --git a/plugboard/cli/go/AGENTS.md b/plugboard/cli/go/AGENTS.md new file mode 120000 index 00000000..2dbef552 --- /dev/null +++ b/plugboard/cli/go/AGENTS.md @@ -0,0 +1 @@ +examples/AGENTS.md \ No newline at end of file From 14021c2ae3c203786db52439fb500d7ce67a5987 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Fri, 6 Mar 2026 21:45:16 +0000 Subject: [PATCH 10/13] Fix symlink --- plugboard/cli/go/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugboard/cli/go/AGENTS.md b/plugboard/cli/go/AGENTS.md index 2dbef552..17086c0c 120000 --- a/plugboard/cli/go/AGENTS.md +++ b/plugboard/cli/go/AGENTS.md @@ -1 +1 @@ -examples/AGENTS.md \ No newline at end of file +../../../examples/AGENTS.md \ No newline at end of file From 4b2724cc850260ffe0e9e9200e416b468b526b23 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Sun, 8 Mar 2026 10:25:13 +0000 Subject: [PATCH 11/13] Add colours --- plugboard/utils/theme.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 plugboard/utils/theme.py 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" From d3a137b22c32b9b45000434e361494b3a84e8ec5 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Sun, 8 Mar 2026 16:18:37 +0000 Subject: [PATCH 12/13] Improved version --- plugboard/cli/go/__init__.py | 43 +++---- plugboard/cli/go/agent.py | 79 +++++++----- plugboard/cli/go/app.py | 235 ++++++++++++++++++++++++----------- plugboard/cli/go/tools.py | 10 +- tests/unit/test_cli_go.py | 85 ++++++++++--- 5 files changed, 297 insertions(+), 155 deletions(-) diff --git a/plugboard/cli/go/__init__.py b/plugboard/cli/go/__init__.py index cda5b8af..886e0c75 100644 --- a/plugboard/cli/go/__init__.py +++ b/plugboard/cli/go/__init__.py @@ -2,43 +2,34 @@ import typer +from plugboard.utils.dependencies import depends_on_optional + app = typer.Typer(rich_markup_mode="rich", pretty_exceptions_show_locals=False) -def _check_dependencies() -> None: - """Check that optional 'go' dependencies are installed.""" - missing: list[str] = [] - try: - import copilot # noqa: F401 - except ImportError: - missing.append("github-copilot") - try: - import textual # noqa: F401 - except ImportError: - missing.append("textual") - if missing: - typer.echo( - f"Missing dependencies: {', '.join(missing)}\n" - "Install them with:\n\n" - " pip install plugboard[go]\n" - ) - raise typer.Exit(1) +@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-4o", + "gpt-5-mini", "--model", "-m", - help="LLM model to use (e.g. gpt-4o, claude-sonnet-4, gpt-5).", + 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.""" - _check_dependencies() - - from plugboard.cli.go.app import PlugboardGoApp - - tui = PlugboardGoApp(model_name=model) - tui.run() + 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 index eaaa52ee..0b6d66f1 100644 --- a/plugboard/cli/go/agent.py +++ b/plugboard/cli/go/agent.py @@ -6,7 +6,8 @@ from pathlib import Path import typing as _t -from copilot import CopilotClient, CopilotSession, PermissionHandler +from copilot import CopilotClient, CopilotSession, PermissionHandler, SessionConfig +from copilot.types import UserInputRequest, UserInputResponse from plugboard.cli.go.tools import ( create_mermaid_diagram_tool, @@ -49,13 +50,26 @@ def _load_system_prompt() -> str: 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[[dict, dict], _t.Awaitable[dict]] | 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: @@ -78,6 +92,7 @@ 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() @@ -86,15 +101,15 @@ async def start(self) -> None: create_mermaid_diagram_tool(on_url_generated=self._on_mermaid_url), ] - session_config: dict[str, _t.Any] = { - "model": self._model, - "streaming": True, - "tools": tools, - "system_message": { + session_config = SessionConfig( + model=self._model, + streaming=True, + tools=tools, + system_message={ "content": system_prompt, }, - "on_permission_request": PermissionHandler.approve_all, - } + 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 @@ -102,6 +117,29 @@ async def start(self) -> None: 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) @@ -141,27 +179,8 @@ async def list_models(self) -> list[str]: async def change_model(self, model: str) -> None: """Change the model by destroying and recreating the session.""" self._model = model - if self._session is not None: - await self._session.destroy() - if self._client is not None: - system_prompt = _load_system_prompt() - tools = [ - create_run_model_tool(), - create_mermaid_diagram_tool(on_url_generated=self._on_mermaid_url), - ] - session_config: dict[str, _t.Any] = { - "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) + await self.stop() + await self.start() async def stop(self) -> None: """Clean up the Copilot client and session.""" diff --git a/plugboard/cli/go/app.py b/plugboard/cli/go/app.py index d5499411..5bc07490 100644 --- a/plugboard/cli/go/app.py +++ b/plugboard/cli/go/app.py @@ -15,7 +15,6 @@ from textual.reactive import reactive from textual.widgets import ( DirectoryTree, - Footer, Header, Input, Markdown, @@ -26,6 +25,27 @@ 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 --------------------------------------------------------- @@ -89,27 +109,35 @@ def __init__(self, text: str) -> None: class ChatMessage(Static): """A single chat message displayed in the conversation.""" - DEFAULT_CSS = """ + DEFAULT_CSS = _theme_css(""" ChatMessage { - padding: 1 2; - margin: 0 0 1 0; + padding: 0 2; + margin: 0; } ChatMessage.user { - background: #1C0F13; - border-left: thick #075D7A; - color: #F9F9F9; + background: __PB_BLACK__; + border-left: thick __PB_BLUE__; + color: __PB_WHITE__; } ChatMessage.assistant { - background: #0A3D4D; - border-left: thick #CC9C4A; - color: #F9F9F9; + background: __PB_ACCENT3__; + border-left: thick __PB_ACCENT1__; + color: __PB_WHITE__; } ChatMessage.system { - background: #1C0F13; - border-left: thick #49726A; - color: #C2C2C2; + 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, @@ -119,9 +147,19 @@ def __init__( ) -> None: super().__init__(**kwargs) self._role = role - self._content = content + 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": @@ -138,7 +176,16 @@ def compose(self) -> ComposeResult: def append_content(self, delta: str) -> None: """Append streaming content to the message body.""" - self._content += delta + 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) @@ -149,16 +196,16 @@ def append_content(self, delta: str) -> None: class ModelSelector(Static): """Displays selected model and allows changing it.""" - DEFAULT_CSS = """ + DEFAULT_CSS = _theme_css(""" ModelSelector { dock: top; height: 1; padding: 0 1; - background: #075D7A; - color: #F9F9F9; + background: __PB_BLUE__; + color: __PB_WHITE__; text-style: bold; } - """ + """) model_name: reactive[str] = reactive("gpt-4o") @@ -170,15 +217,14 @@ def render(self) -> str: class MermaidLink(Static): """Displays a link to the mermaid diagram.""" - DEFAULT_CSS = """ + DEFAULT_CSS = _theme_css(""" MermaidLink { - dock: bottom; - height: 1; + height: auto; padding: 0 1; - background: #075D7A; - color: #F9F9F9; + background: __PB_BLUE__; + color: __PB_WHITE__; } - """ + """) url: reactive[str] = reactive("") @@ -189,13 +235,32 @@ def render(self) -> str: return "No diagram generated yet" +class TitleBanner(Static): + """Displays the Plugboard title and version.""" + + DEFAULT_CSS = _theme_css(""" + TitleBanner { + dock: top; + height: 4; + padding: 1 2 0 2; + background: __PB_BLUE__; + color: __PB_WHITE__; + content-align: center middle; + } + """) + + def render(self) -> str: + """Render the title banner.""" + return f"[bold {Theme.PB_ACCENT1}]P L U G B O A R D[/]\n[{Theme.PB_GRAY}]v{__version__}[/]" + + # -- Model Selection Screen -------------------------------------------------- class ModelSelectionOverlay(VerticalScroll): """Overlay for selecting a model.""" - DEFAULT_CSS = """ + DEFAULT_CSS = _theme_css(""" ModelSelectionOverlay { dock: top; layer: overlay; @@ -203,11 +268,11 @@ class ModelSelectionOverlay(VerticalScroll): max-height: 20; margin: 3 5; padding: 1 2; - background: #1C0F13; - color: #F9F9F9; - border: thick #CC9C4A; + background: __PB_BLACK__; + color: __PB_WHITE__; + border: thick __PB_ACCENT1__; } - """ + """) def compose(self) -> ComposeResult: """Compose the model selection overlay.""" @@ -239,31 +304,35 @@ class PlugboardGoApp(App[None]): TITLE = "Plugboard Go" SUB_TITLE = f"v{__version__}" - CSS = """ + CSS = _theme_css(""" Screen { - background: #1C0F13; - color: #F9F9F9; + background: __PB_BLACK__; + color: __PB_WHITE__; } Header { - background: #075D7A; - color: #F9F9F9; - border-bottom: solid #CC9C4A; + background: __PB_BLUE__; + color: __PB_WHITE__; + border-bottom: solid __PB_ACCENT1__; } - Footer { - background: #075D7A; - color: #F9F9F9; - border-top: solid #CC9C4A; + #shortcut-hint { + height: auto; + padding: 0 1; + background: __PB_BLUE__; + color: __PB_WHITE__; + border-top: solid __PB_ACCENT1__; } #title-banner { dock: top; - height: 3; - padding: 1 2; - background: #075D7A; - color: #F9F9F9; + height: 4; + padding: 1 2 0 2; + background: __PB_BLUE__; + color: __PB_WHITE__; text-align: center; + content-align: center middle; + text-style: bold; } #main-container { @@ -274,7 +343,7 @@ class PlugboardGoApp(App[None]): #chat-panel { width: 3fr; height: 1fr; - background: #1C0F13; + background: __PB_BLACK__; } #sidebar { @@ -282,38 +351,33 @@ class PlugboardGoApp(App[None]): min-width: 30; max-width: 50; height: 1fr; - border-left: thick #CC9C4A; - background: #0A3D4D; + border-left: thick __PB_ACCENT1__; + background: __PB_ACCENT3__; } #chat-scroll { height: 1fr; - background: #1C0F13; + background: __PB_BLACK__; } #chat-input { - dock: bottom; margin: 0 1; - border: solid #075D7A; - } - - #chat-input Input { - background: #1C0F13; - border: solid #075D7A; - color: #F9F9F9; + background: __PB_BLACK__; + border: solid __PB_BLUE__; + color: __PB_WHITE__; } #file-tree-label { padding: 0 1; text-style: bold; - background: #075D7A; - color: #F9F9F9; + background: __PB_BLUE__; + color: __PB_WHITE__; } DirectoryTree { height: 1fr; - background: #0A3D4D; - color: #F9F9F9; + background: __PB_ACCENT3__; + color: __PB_WHITE__; } ModelSelectionOverlay { @@ -323,7 +387,7 @@ class PlugboardGoApp(App[None]): ModelSelectionOverlay.visible { display: block; } - """ + """) BINDINGS = [ Binding( @@ -347,7 +411,7 @@ def __init__( self.model_name = model_name self._agent: PlugboardAgent | None = None self._current_assistant_msg: ChatMessage | None = None - self._user_input_future: asyncio.Future[dict] | None = None + self._user_input_future: asyncio.Future[UserInputResponse] | None = None self._waiting_for_user_input = False def compose(self) -> ComposeResult: @@ -361,17 +425,17 @@ def compose(self) -> ComposeResult: "plan the components, connections, and data flow." ) yield Header() - # Title banner with Plugboard branding - yield Static( - f"[bold #CC9C4A]Plugboard[/] [#C2C2C2]v{__version__}[/]", - id="title-banner", - ) + yield TitleBanner(id="title-banner") yield ModelSelector(id="model-selector") 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( + "[bold]q[/bold] Quit [bold]m[/bold] Change Model", + id="shortcut-hint", + ) yield Input( placeholder="Describe your model...", id="chat-input", @@ -383,7 +447,6 @@ def compose(self) -> ComposeResult: id="file-tree", ) yield ModelSelectionOverlay(id="model-overlay") - yield Footer() async def on_mount(self) -> None: """Start up the Copilot agent when the app mounts.""" @@ -410,6 +473,12 @@ async def _start_agent(self) -> None: ) try: await self._agent.start() + self.model_name = self._agent.model + selector = self.query_one( + "#model-selector", + ModelSelector, + ) + selector.model_name = self.model_name self.post_message( AgentStatus("Connected to GitHub Copilot."), ) @@ -438,9 +507,9 @@ def _handle_agent_tool(self, tool_name: str) -> None: async def _handle_user_input( self, - request: dict, - invocation: dict, - ) -> dict: + request: UserInputRequest, + invocation: dict[str, str], + ) -> UserInputResponse: """Handle the agent asking the user a question.""" question = request.get("question", "") choices = request.get("choices") @@ -539,14 +608,28 @@ def _add_system_message(self, text: str) -> None: """Add a system message to the chat.""" self._add_chat_message(text, role="system") + 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, role=role) + msg = ChatMessage(text.rstrip(), role=role) chat.mount(msg) msg.scroll_visible() @@ -566,7 +649,7 @@ def handle_chat_submit( 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( - {"answer": text, "wasFreeform": True}, + UserInputResponse(answer=text, wasFreeform=True), ) inp = self.query_one("#chat-input", Input) inp.placeholder = "Describe your model..." @@ -600,6 +683,7 @@ def action_select_model(self) -> None: overlay.remove_class("visible") return overlay.add_class("visible") + overlay.focus() self._fetch_models() @work(exclusive=True, thread=False) @@ -630,6 +714,8 @@ def _populate_model_list( 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( @@ -665,6 +751,7 @@ async def _change_model(self, model: str) -> None: 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}", diff --git a/plugboard/cli/go/tools.py b/plugboard/cli/go/tools.py index 083df683..793c532a 100644 --- a/plugboard/cli/go/tools.py +++ b/plugboard/cli/go/tools.py @@ -3,9 +3,9 @@ from __future__ import annotations from pathlib import Path -from typing import Callable +import typing as _t -from copilot import define_tool +from copilot import Tool, define_tool import msgspec from pydantic import BaseModel, Field @@ -43,7 +43,7 @@ def _build_process(config: ConfigSpec) -> Process: return ProcessBuilder.build(config.plugboard.process) -def create_run_model_tool() -> object: +def create_run_model_tool() -> Tool: """Create the run_model tool for Copilot.""" @define_tool( @@ -76,8 +76,8 @@ async def run_plugboard_model(params: RunModelParams) -> str: def create_mermaid_diagram_tool( - on_url_generated: Callable[[str], None] | None = None, -) -> object: + on_url_generated: _t.Callable[[str], None] | None = None, +) -> Tool: """Create the mermaid_diagram tool for Copilot. Args: diff --git a/tests/unit/test_cli_go.py b/tests/unit/test_cli_go.py index 64cbce56..ab47f03d 100644 --- a/tests/unit/test_cli_go.py +++ b/tests/unit/test_cli_go.py @@ -25,31 +25,24 @@ class TestGoCliEntrypoint: def test_go_missing_copilot_dependency(self) -> None: """Should exit with an error when the 'copilot' package is missing.""" - with patch.dict("sys.modules", {"copilot": None}): - # Force reimport so the check re-runs - import importlib - - import plugboard.cli.go as go_mod - - importlib.reload(go_mod) - + 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 "Missing dependencies" in result.stdout + 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.dict("sys.modules", {"textual": None}): - import importlib - - import plugboard.cli.go as go_mod - - importlib.reload(go_mod) - + 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 "Missing dependencies" in result.stdout + 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.""" @@ -200,6 +193,23 @@ async def test_stop_is_safe_without_start(self) -> None: 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 @@ -322,6 +332,16 @@ def test_app_construction(self) -> None: 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 @@ -352,6 +372,8 @@ async def test_app_compose_mounts(self) -> 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: @@ -368,10 +390,33 @@ async def test_app_handles_agent_status_message(self) -> None: async with app.run_test(size=(120, 40)) as pilot: # noqa: F841 app.post_message(AgentStatus("Test status")) await asyncio.sleep(0.1) - # The system message should have been added + # 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") - # At least the welcome message + status message from plugboard.cli.go.app import ChatMessage messages = chat_scroll.query(ChatMessage) - assert len(messages) >= 2 + 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 From fd7b9be88ecfd242e2e935e0f2472c41b4bf2775 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Sun, 8 Mar 2026 18:08:59 +0000 Subject: [PATCH 13/13] More improvements --- plugboard/cli/go/app.py | 110 ++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/plugboard/cli/go/app.py b/plugboard/cli/go/app.py index 5bc07490..df945624 100644 --- a/plugboard/cli/go/app.py +++ b/plugboard/cli/go/app.py @@ -15,7 +15,6 @@ from textual.reactive import reactive from textual.widgets import ( DirectoryTree, - Header, Input, Markdown, OptionList, @@ -193,25 +192,29 @@ def replace_content(self, content: str) -> None: pass -class ModelSelector(Static): - """Displays selected model and allows changing it.""" +class HeaderBanner(Static): + """Combined header showing title, model, and a blank line.""" DEFAULT_CSS = _theme_css(""" - ModelSelector { + HeaderBanner { dock: top; - height: 1; + height: 3; padding: 0 1; background: __PB_BLUE__; color: __PB_WHITE__; - text-style: bold; + content-align: center middle; } """) model_name: reactive[str] = reactive("gpt-4o") def render(self) -> str: - """Render the model selector.""" - return f"Model: {self.model_name} (press [bold]m[/bold] to change)" + """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): @@ -235,23 +238,12 @@ def render(self) -> str: return "No diagram generated yet" -class TitleBanner(Static): - """Displays the Plugboard title and version.""" - - DEFAULT_CSS = _theme_css(""" - TitleBanner { - dock: top; - height: 4; - padding: 1 2 0 2; - background: __PB_BLUE__; - color: __PB_WHITE__; - content-align: center middle; - } - """) +class FilteredDirectoryTree(DirectoryTree): + """A DirectoryTree that hides hidden (dot-prefixed) files.""" - def render(self) -> str: - """Render the title banner.""" - return f"[bold {Theme.PB_ACCENT1}]P L U G B O A R D[/]\n[{Theme.PB_GRAY}]v{__version__}[/]" + 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 -------------------------------------------------- @@ -316,7 +308,7 @@ class PlugboardGoApp(App[None]): border-bottom: solid __PB_ACCENT1__; } - #shortcut-hint { + #footer-bar { height: auto; padding: 0 1; background: __PB_BLUE__; @@ -324,17 +316,6 @@ class PlugboardGoApp(App[None]): border-top: solid __PB_ACCENT1__; } - #title-banner { - dock: top; - height: 4; - padding: 1 2 0 2; - background: __PB_BLUE__; - color: __PB_WHITE__; - text-align: center; - content-align: center middle; - text-style: bold; - } - #main-container { width: 1fr; height: 1fr; @@ -391,12 +372,12 @@ class PlugboardGoApp(App[None]): BINDINGS = [ Binding( - "m", + "ctrl+m", "select_model", "Change Model", show=True, ), - Binding("q", "quit", "Quit", show=True), + Binding("ctrl+q", "quit", "Quit", show=True), ] model_name: reactive[str] = reactive("gpt-4o") @@ -413,6 +394,7 @@ def __init__( 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.""" @@ -424,17 +406,15 @@ def compose(self) -> ComposeResult: "your model at a high level and I'll help you " "plan the components, connections, and data flow." ) - yield Header() - yield TitleBanner(id="title-banner") - yield ModelSelector(id="model-selector") + 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( - "[bold]q[/bold] Quit [bold]m[/bold] Change Model", - id="shortcut-hint", + "", + id="footer-bar", ) yield Input( placeholder="Describe your model...", @@ -442,7 +422,7 @@ def compose(self) -> ComposeResult: ) with Vertical(id="sidebar"): yield Static("Files", id="file-tree-label") - yield DirectoryTree( + yield FilteredDirectoryTree( str(Path.cwd()), id="file-tree", ) @@ -450,11 +430,12 @@ def compose(self) -> ComposeResult: async def on_mount(self) -> None: """Start up the Copilot agent when the app mounts.""" - selector = self.query_one( - "#model-selector", - ModelSelector, + banner = self.query_one( + "#header-banner", + HeaderBanner, ) - selector.model_name = self.model_name + banner.model_name = self.model_name + self._update_footer() self._start_agent() # -- Agent lifecycle ------------------------------------------------------ @@ -474,11 +455,11 @@ async def _start_agent(self) -> None: try: await self._agent.start() self.model_name = self._agent.model - selector = self.query_one( - "#model-selector", - ModelSelector, + banner = self.query_one( + "#header-banner", + HeaderBanner, ) - selector.model_name = self.model_name + banner.model_name = self.model_name self.post_message( AgentStatus("Connected to GitHub Copilot."), ) @@ -594,6 +575,8 @@ def on_agent_mermaid_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, @@ -608,6 +591,17 @@ 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) @@ -656,6 +650,8 @@ def handle_chat_submit( 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) @@ -670,6 +666,8 @@ async def _send_to_agent(self, text: str) -> None: 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 ------------------------------------------------------ @@ -726,11 +724,11 @@ def handle_model_selected( model_id = event.option.id if model_id and model_id != self.model_name: self.model_name = model_id - selector = self.query_one( - "#model-selector", - ModelSelector, + banner = self.query_one( + "#header-banner", + HeaderBanner, ) - selector.model_name = model_id + banner.model_name = model_id self._add_system_message( f"Switching to model: **{model_id}**...", )