├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── documentation.md │ ├── feature_request.md │ ├── question.md │ ├── theme_request.md │ └── tool_request.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .python-version ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── demo.png ├── feature1.gif └── logo.png ├── dasshh ├── __init__.py ├── __main__.py ├── apps │ ├── __init__.py │ ├── general.py │ └── os │ │ ├── __init__.py │ │ ├── file_operations.py │ │ ├── network_tools.py │ │ ├── process_management.py │ │ └── system_info.py ├── core │ ├── __init__.py │ ├── exceptions.py │ ├── logging.py │ ├── registry.py │ ├── runtime.py │ └── tools │ │ ├── __init__.py │ │ ├── base.py │ │ ├── decorator.py │ │ └── function_tool.py ├── data │ ├── __init__.py │ ├── client.py │ ├── models.py │ └── session.py └── ui │ ├── __init__.py │ ├── app.py │ ├── components │ ├── __init__.py │ ├── chat │ │ ├── __init__.py │ │ ├── action.py │ │ ├── actions_panel.py │ │ ├── chat_input.py │ │ ├── chat_panel.py │ │ ├── history_item.py │ │ ├── history_panel.py │ │ └── message.py │ ├── navbar.py │ └── settings │ │ ├── __init__.py │ │ ├── checkbox.py │ │ ├── dasshh_config.py │ │ ├── model_config.py │ │ └── settings_section.py │ ├── events.py │ ├── screens │ ├── __init__.py │ ├── help.py │ └── main.py │ ├── themes │ ├── __init__.py │ └── lime.py │ ├── types.py │ ├── utils.py │ └── views │ ├── __init__.py │ ├── about.py │ ├── chat.py │ └── settings.py ├── docs ├── README.md ├── api │ ├── data.md │ ├── events.md │ ├── runtime.md │ ├── tools.md │ └── ui.md ├── assets │ ├── actions.png │ ├── chat.png │ ├── demo.png │ ├── demo2.gif │ ├── favicon.png │ ├── feature1.gif │ ├── feature2.gif │ ├── feature3.gif │ ├── feature4.gif │ ├── logo.png │ ├── sessions.png │ ├── settings.png │ └── tools.gif ├── contributing │ └── contributing.md ├── getting-started │ ├── installation.md │ ├── introduction.md │ └── quick-start.md ├── guide │ ├── abilities.md │ ├── basics.md │ ├── configuration.md │ ├── keybindings.md │ ├── own-tools.md │ └── themes.md ├── index.md └── stylesheets │ └── extra.css ├── mkdocs.yml ├── pyproject.toml ├── tests ├── README.md ├── __init__.py ├── conftest.py └── unit │ ├── core │ ├── __init__.py │ ├── conftest.py │ ├── test_logging.py │ ├── test_registry.py │ ├── test_runtime.py │ └── tools │ │ ├── __init__.py │ │ ├── test_base.py │ │ ├── test_decorator.py │ │ └── test_function_tool.py │ ├── data │ ├── __init__.py │ ├── conftest.py │ ├── test_client.py │ ├── test_models.py │ └── test_session.py │ ├── test_main.py │ └── ui │ ├── __init__.py │ ├── components │ ├── __init__.py │ ├── chat │ │ ├── __init__.py │ │ ├── test_action.py │ │ └── test_message.py │ └── test_navbar.py │ ├── test_events.py │ └── test_types.py └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Create a report to help us improve dasshh 4 | title: "[BUG] " 5 | labels: ["bug", "triage"] 6 | assignees: [] 7 | --- 8 | 9 | ## 🐛 Bug Description 10 | 11 | 12 | 13 | ## 🔄 Steps to Reproduce 14 | 15 | 16 | 17 | ## ✅ Expected Behavior 18 | 19 | 20 | 21 | ## ❌ Actual Behavior 22 | 23 | 24 | 25 | ## 📱 Screenshots/Logs 26 | 27 | 28 | 29 | ## 🖥️ Environment 30 | 31 | **Please complete the following information:** 32 | 33 | - **OS**: [e.g. macOS 14.0, Ubuntu 22.04, Windows 11] 34 | - **Python Version**: [e.g. 3.13.0] 35 | - **dasshh Version**: [e.g. 0.1.3] (run `dasshh --version`) 36 | - **Installation Method**: [e.g. pip, uv, from source] 37 | - **Terminal**: [e.g. iTerm2, Terminal.app, Command Prompt, PowerShell] 38 | - **Shell**: [e.g. bash, zsh, fish, cmd] 39 | 40 | ## 🔧 Additional Context 41 | 42 | 43 | 44 | ## 🔍 Possible Solution 45 | 46 | 47 | 48 | ## 📋 Checklist 49 | 50 | Before submitting this issue, please confirm: 51 | 52 | - [ ] I have searched existing issues to make sure this isn't a duplicate 53 | - [ ] I have provided all the requested information above 54 | - [ ] I have tested this with the latest version of dasshh 55 | - [ ] I have included relevant logs/screenshots 56 | - [ ] I have checked the [documentation](https://blog.vgnshiyer.dev/dasshh) for solutions -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 📖 Documentation 4 | url: https://blog.vgnshiyer.dev/dasshh 5 | about: Check our documentation for answers to common questions 6 | - name: 💬 Discussions 7 | url: https://github.com/vgnshiyer/dasshh/discussions 8 | about: Ask questions and discuss ideas with the community 9 | - name: 🐦 Twitter 10 | url: https://twitter.com/vgnshiyer 11 | about: Follow for updates and announcements 12 | - name: 💼 LinkedIn 13 | url: https://www.linkedin.com/in/vgnshiyer/ 14 | about: Connect with the maintainer -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📚 Documentation 3 | about: Report an issue with documentation or suggest improvements 4 | title: "[DOCS] " 5 | labels: ["documentation", "triage"] 6 | assignees: [] 7 | --- 8 | 9 | ## 📚 Documentation Issue 10 | 11 | **What type of documentation issue is this?** 12 | 13 | - [ ] 📝 Missing documentation 14 | - [ ] ❌ Incorrect information 15 | - [ ] 🔄 Outdated content 16 | - [ ] 🧩 Unclear explanation 17 | - [ ] 🔗 Broken links 18 | - [ ] 📖 Suggest new documentation 19 | - [ ] 🌐 Translation issue 20 | - [ ] Other: [Please describe] 21 | 22 | ## 📍 Location 23 | 24 | **Where is the documentation issue located? (Provide only one)** 25 | 26 | - **Page/Section**: [e.g., README.md, Installation Guide, API Reference] 27 | - **URL**: [If applicable, provide the direct link] 28 | - **File Path**: [e.g., docs/getting-started.md, line 42] 29 | 30 | ## 🐛 Current Problem 31 | 32 | 33 | 34 | ## ✅ Suggested Improvement 35 | 36 | 37 | 38 | ## 📝 Content Suggestion 39 | 40 | 41 | 42 | ## 📝 Additional Context 43 | 44 | 45 | 46 | ## 📋 Checklist 47 | 48 | Before submitting this documentation issue, please confirm: 49 | 50 | - [ ] I have searched existing issues to make sure this isn't a duplicate 51 | - [ ] I have provided specific location information 52 | - [ ] I have clearly described the problem and suggested improvement 53 | - [ ] I have checked the latest version of the documentation -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | about: Suggest an idea for dasshh 4 | title: "[FEATURE] " 5 | labels: ["enhancement", "triage"] 6 | assignees: [] 7 | --- 8 | 9 | ## 🚀 Feature Description 10 | 11 | 12 | 13 | ## 🎯 Problem Statement 14 | 15 | 16 | 17 | ## 💡 Proposed Solution 18 | 19 | 20 | 21 | ## 🔄 Use Cases 22 | 23 | 24 | 25 | 29 | 30 | ## 🖥️ Mockups/Examples 31 | 32 | 33 | 34 | 36 | 37 | ## 🔀 Alternatives Considered 38 | 39 | 40 | 41 | ## 📊 Expected Impact 42 | 43 | **How would this feature benefit users?** 44 | 45 | - [ ] Improves user experience 46 | - [ ] Saves time 47 | - [ ] Reduces complexity 48 | - [ ] Adds new functionality 49 | - [ ] Improves performance 50 | - [ ] Other: [Please describe] 51 | 52 | ## 📝 Additional Context 53 | 54 | 55 | 56 | ## 📋 Checklist 57 | 58 | Before submitting this feature request, please confirm: 59 | 60 | - [ ] I have searched existing issues to make sure this isn't a duplicate 61 | - [ ] I have clearly described the problem this feature would solve 62 | - [ ] I have provided specific use cases 63 | - [ ] I have considered how this fits with dasshh's existing functionality 64 | - [ ] I have checked if this feature already exists in the current version -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: Ask a question or get support 4 | title: "[QUESTION] " 5 | labels: ["question", "support"] 6 | assignees: [] 7 | --- 8 | 9 | ## ❓ Question 10 | 11 | **What would you like to know?** 12 | 13 | 14 | 15 | ## 🎯 Context 16 | 17 | **What are you trying to accomplish?** 18 | 19 | 20 | 21 | ## 🔍 What You've Tried 22 | 23 | **What have you already attempted?** 24 | 25 | - [ ] Searched the documentation 26 | - [ ] Searched existing issues 27 | - [ ] Tried different approaches 28 | - [ ] Asked in community forums 29 | 30 | ## 🖥️ Environment (if relevant) 31 | 32 | - **OS**: [e.g. macOS 14.0, Ubuntu 22.04, Windows 11] 33 | - **Python Version**: [e.g. 3.13.0] 34 | - **dasshh Version**: [e.g. 0.1.3] 35 | - **Installation Method**: [e.g. pip, uv, from source] 36 | 37 | ## 📝 Additional Context 38 | 39 | 40 | 41 | 42 | 43 | ## 🎯 Expected Answer 44 | 45 | **What type of answer are you looking for?** 46 | 47 | - [ ] Step-by-step instructions 48 | - [ ] Code example 49 | - [ ] Conceptual explanation 50 | - [ ] Link to documentation 51 | - [ ] Confirmation that something is/isn't possible 52 | - [ ] Best practices recommendation 53 | 54 | ## 📋 Checklist 55 | 56 | Before submitting this question, please confirm: 57 | 58 | - [ ] I have searched existing issues and discussions 59 | - [ ] I have checked the [documentation](https://blog.vgnshiyer.dev/dasshh) 60 | - [ ] I have provided sufficient context about what I'm trying to achieve 61 | - [ ] This is not a bug report (use the bug template instead) 62 | - [ ] This is not a feature request (use the feature request template instead) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/theme_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🎨 Theme Request 3 | about: Suggest a new theme for dasshh 4 | title: "[THEME] " 5 | labels: ["theme-request", "enhancement", "triage"] 6 | assignees: [] 7 | --- 8 | 9 | ## 🎨 Theme Description 10 | 11 | 12 | 13 | ## 🌈 Theme Inspiration 14 | 15 | 16 | 17 | - [ ] 🌙 Popular dark themes (e.g., Dracula, Nord, One Dark) 18 | - [ ] ☀️ Popular light themes (e.g., Solarized Light, GitHub Light) 19 | - [ ] 🎮 Gaming/retro themes (e.g., cyberpunk, neon, terminal green) 20 | - [ ] 🌿 Nature-inspired (e.g., forest, ocean, sunset) 21 | - [ ] 🎯 Brand/company colors 22 | - [ ] 🎭 Accessibility-focused (high contrast, colorblind-friendly) 23 | - [ ] 🌍 Cultural/regional themes 24 | - [ ] Other: [Please describe] 25 | 26 | ## 🎨 Color Palette 27 | 28 | 29 | 30 | ## 🖼️ Visual References 31 | 32 | 33 | 34 | ## 📋 Checklist 35 | 36 | Before submitting this theme request, please confirm: 37 | 38 | - [ ] I have searched existing issues to make sure this theme isn't already requested 39 | - [ ] I have provided a clear description of the theme concept 40 | - [ ] I have considered accessibility implications 41 | - [ ] I have included color specifications or references where possible 42 | - [ ] I have checked existing themes to avoid duplicates 43 | - [ ] This is specifically a theme request (not a general UI change) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tool_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🔧 Tool Request 3 | about: Suggest a new tool for dasshh 4 | title: "[TOOL] " 5 | labels: ["tool-request", "enhancement", "triage"] 6 | assignees: [] 7 | --- 8 | 9 | ## 🔧 Tool Description 10 | 11 | 12 | 13 | ## 🎯 Problem Statement 14 | 15 | 16 | 17 | ## 💡 Proposed Functionality 18 | 19 | 20 | 21 | ## 🔄 Use Cases 22 | 23 | 24 | 25 | 29 | 30 | ## 🖥️ Example Usage 31 | 32 | 46 | 47 | ## 📊 Tool Category 48 | 49 | **What category does this tool belong to?** 50 | 51 | - [ ] 🖥️ System monitoring (CPU, memory, disk usage) 52 | - [ ] 📁 File operations (create, move, search files) 53 | - [ ] 🌐 Network utilities (ping, speed test, connectivity) 54 | - [ ] 🔧 Development tools (git operations, code analysis) 55 | - [ ] 📊 Data processing (CSV, JSON manipulation) 56 | - [ ] 🎨 Media tools (image processing, file conversion) 57 | - [ ] 🔒 Security tools (password generation, encryption) 58 | - [ ] 📱 Communication (email, notifications) 59 | - [ ] 🌍 Web scraping/API integration 60 | - [ ] Other: [Please describe] 61 | 62 | ## 🛠️ Implementation Ideas 63 | 64 | 65 | 66 | ## 🔀 Alternatives Considered 67 | 68 | 69 | 70 | ## 📝 Additional Context 71 | 72 | 73 | 74 | ## 📋 Checklist 75 | 76 | Before submitting this tool request, please confirm: 77 | 78 | - [ ] I have searched existing issues to make sure this tool isn't already requested 79 | - [ ] I have clearly described what the tool should do 80 | - [ ] I have provided specific use cases 81 | - [ ] I have considered implementation challenges 82 | - [ ] I have checked if similar functionality exists in other tools 83 | - [ ] This is specifically a tool request (not a general feature request) -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📋 Pull Request Description 2 | 3 | ### What does this PR do? 4 | 5 | 6 | ### 🔗 Related Issue(s) 7 | 8 | - Fixes # 9 | - Related to # 10 | 11 | ## 🧪 Type of Change 12 | 13 | Please select the relevant option(s): 14 | 15 | - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) 16 | - [ ] ✨ New feature (non-breaking change that adds functionality) 17 | - [ ] 📚 Documentation update 18 | - [ ] 🧹 Code cleanup/refactoring 19 | - [ ] 🧪 Test additions or improvements 20 | 21 | ## 📱 Screenshots/Demo (if applicable) 22 | 23 | 24 | 25 | ## 🤝 Review Checklist for Reviewers 26 | 27 | ### Functionality 28 | - [ ] The code accomplishes what it's supposed to do 29 | - [ ] Edge cases are handled appropriately 30 | - [ ] Error handling is implemented where needed 31 | 32 | ### Code Quality 33 | - [ ] Code is readable and maintainable 34 | - [ ] No code duplication 35 | - [ ] Functions are appropriately sized 36 | - [ ] Variable names are clear and descriptive 37 | 38 | ### Testing 39 | - [ ] Tests are comprehensive and cover edge cases 40 | - [ ] Tests are well-written and maintainable 41 | - [ ] Test coverage is adequate 42 | 43 | ## 📝 Additional Notes 44 | 45 | 46 | 47 | ## 🙏 Acknowledgments 48 | 49 | 50 | 51 | --- 52 | 53 | Thank you for contributing to dasshh! 🚀 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.13"] 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install uv 26 | run: | 27 | curl -Ls https://astral.sh/uv/install.sh | sh 28 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 29 | 30 | - name: Create virtual environment 31 | run: uv venv 32 | 33 | - name: Install dependencies 34 | run: | 35 | uv pip install -e ".[dev]" 36 | uv pip install build 37 | 38 | - name: Lint with ruff (if available) 39 | run: | 40 | uv pip install ruff || echo "Ruff not available, skipping linting" 41 | uv run ruff check . || echo "No ruff configuration found, skipping linting" 42 | uv run ruff format --check . || echo "No ruff configuration found, skipping linting" 43 | continue-on-error: true 44 | 45 | - name: Run tests with pytest 46 | run: | 47 | uv run pytest tests/ -v --cov=dasshh --cov-report=xml --cov-report=term-missing --cov-report=html 48 | 49 | - name: Upload coverage reports to Codecov 50 | uses: codecov/codecov-action@v5 51 | with: 52 | file: ./coverage.xml 53 | fail_ci_if_error: false 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | 56 | - name: Upload HTML coverage report 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: coverage-html-report 60 | path: htmlcov/ 61 | if-no-files-found: warn 62 | 63 | - name: Test build package 64 | run: | 65 | uv run python -m build 66 | 67 | - name: Test package installation 68 | run: | 69 | uv pip install dist/*.whl -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docs/**' 9 | - 'mkdocs.yml' 10 | - '.github/workflows/docs.yml' 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.12' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install mkdocs-material 28 | - name: Deploy docs 29 | run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python package 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-publish: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/project/dasshh/ 13 | permissions: 14 | contents: read 15 | id-token: write 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.x' 25 | 26 | - name: Install uv 27 | run: | 28 | curl -Ls https://astral.sh/uv/install.sh | sh 29 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 30 | 31 | - name: Setup virtual environment and install dependencies 32 | run: | 33 | uv venv 34 | uv pip install build twine 35 | 36 | - name: Build the package 37 | run: | 38 | uv run python -m build 39 | 40 | - name: Publish package 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | with: 43 | skip-existing: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | .DS_Store -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to dasshh 2 | 3 | Thank you for your interest in contributing to dasshh! 🎉 4 | 5 | This guide will help you get started with contributing to the project. 6 | 7 | ## Table of Contents 8 | 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Getting Started](#getting-started) 11 | - [Development Setup](#development-setup) 12 | - [Development Workflow](#development-workflow) 13 | - [Testing](#testing) 14 | - [Code Style](#code-style) 15 | - [Pull Request Process](#pull-request-process) 16 | - [Issue Reporting](#issue-reporting) 17 | - [Documentation](#documentation) 18 | 19 | ## Code of Conduct 20 | 21 | By participating in this project, you agree to abide by our Code of Conduct. Please be respectful and constructive in all interactions. 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | - Python 3.13 or higher 28 | - [uv](https://docs.astral.sh/uv/) package manager 29 | - Git 30 | 31 | ### Fork and Clone 32 | 33 | 1. Fork the repository on GitHub 34 | 2. Clone your fork locally: 35 | ```bash 36 | git clone https://github.com/YOUR-USERNAME/dasshh.git 37 | cd dasshh 38 | ``` 39 | 40 | ## Development Setup 41 | 42 | 1. **Install uv** (if not already installed): 43 | ```bash 44 | curl -LsSf https://astral.sh/uv/install.sh | sh 45 | ``` 46 | 47 | 2. **Create and activate virtual environment**: 48 | ```bash 49 | uv venv 50 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 51 | ``` 52 | 53 | 3. **Install development dependencies**: 54 | ```bash 55 | uv sync 56 | ``` 57 | 58 | 4. **Running the application**: 59 | ```bash 60 | python -m dasshh 61 | ``` 62 | 63 | ## Development Workflow 64 | 65 | ### Branch Strategy 66 | 67 | - `main` - Production-ready code 68 | - `feature/feature-name` - New features 69 | - `bugfix/issue-description` - Bug fixes 70 | - `docs/update-description` - Documentation updates 71 | 72 | ### Making Changes 73 | 74 | 1. **Create a new branch**: 75 | ```bash 76 | git checkout -b feature/your-feature-name 77 | ``` 78 | 79 | 2. **Make your changes** following the project conventions 80 | 81 | 3. **Run tests locally**: 82 | ```bash 83 | pytest -v --cov=dasshh --cov-report=html 84 | ``` 85 | 86 | 4. **Run linting** (if configured): 87 | ```bash 88 | uv pip install ruff 89 | ruff check . 90 | ruff format . 91 | ``` 92 | 93 | 5. **Commit your changes**: 94 | ```bash 95 | git add . 96 | git commit -m "feat: add your feature description" 97 | ``` 98 | 99 | ### Commit Message Convention 100 | 101 | We follow conventional commit format: 102 | 103 | - `feat:` - New features 104 | - `fix:` - Bug fixes 105 | - `docs:` - Documentation changes 106 | - `test:` - Test additions or modifications 107 | - `refactor:` - Code refactoring 108 | - `chore:` - Maintenance tasks 109 | 110 | Examples: 111 | ``` 112 | feat: add new command for file analysis 113 | fix: resolve issue with terminal output formatting 114 | docs: update installation instructions 115 | test: add unit tests for config parser 116 | ``` 117 | 118 | ## Testing 119 | 120 | ### Writing Tests 121 | 122 | - Place tests in the `tests/` directory 123 | - Use descriptive test names: `test_should_parse_config_when_valid_file_provided` 124 | - Follow AAA pattern: Arrange, Act, Assert 125 | - Mock external dependencies 126 | 127 | Example test structure: 128 | ```python 129 | def test_should_process_command_when_valid_input_provided(): 130 | # Arrange 131 | command = "test command" 132 | expected_result = "processed" 133 | 134 | # Act 135 | result = process_command(command) 136 | 137 | # Assert 138 | assert result == expected_result 139 | ``` 140 | 141 | ## Code Style 142 | 143 | ### Python Style Guidelines 144 | 145 | - Follow [PEP 8](https://pep8.org/) 146 | - Use type hints where appropriate 147 | - Write docstrings for public functions and classes 148 | - Keep functions small and focused 149 | - Use descriptive variable names 150 | 151 | ## Pull Request Process 152 | 153 | 1. **Ensure your branch is up to date**: 154 | ```bash 155 | git checkout main 156 | git pull origin main 157 | git checkout your-feature-branch 158 | git rebase main 159 | ``` 160 | 161 | 2. **Push your branch**: 162 | ```bash 163 | git push origin your-feature-branch 164 | ``` 165 | 166 | 3. **Create a Pull Request** on GitHub with: 167 | - Clear title and description 168 | - Reference any related issues 169 | - Include screenshots/demos if relevant 170 | - Ensure all CI checks pass 171 | 172 | 4. **Address review feedback** promptly and respectfully 173 | 174 | 5. **Squash commits** if requested before merging 175 | 176 | ## Issue Reporting 177 | 178 | When reporting issues, please include: 179 | 180 | - **Environment details**: OS, Python version, dasshh version 181 | - **Steps to reproduce** the issue 182 | - **Expected vs actual behavior** 183 | - **Error messages** or logs 184 | - **Screenshots** if relevant 185 | 186 | ## Documentation 187 | 188 | ### Building Documentation 189 | 190 | ```bash 191 | # Install documentation dependencies 192 | uv pip install mkdocs-material 193 | 194 | # Serve documentation locally 195 | mkdocs serve 196 | 197 | # Build documentation 198 | mkdocs build 199 | ``` 200 | 201 | ## Getting Help 202 | 203 | - Check the [documentation](https://blog.vgnshiyer.dev/dasshh) 204 | - [Open an issue](https://github.com/vgnshiyer/dasshh/issues) for bugs 205 | - [Start a discussion](https://github.com/vgnshiyer/dasshh/discussions) for questions 206 | - Contact maintainers at vgnshiyer@gmail.com or via [LinkedIn](https://www.linkedin.com/in/vgnshiyer/) 207 | 208 | ## Recognition 209 | 210 | Contributors will be acknowledged in: 211 | - Release notes 212 | - GitHub contributors page 213 | 214 | Thank you for contributing to dasshh! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Vignesh Iyer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 🗲   *Dasshh*   🗲 4 | 5 | ***An AI Agent on your terminal, to preserve your brain juice.*** 6 | 7 | Dasshh is a tui built with [textual](https://textual.textualize.io/) that allows you to interact with your computer using natural language. 8 | 9 |
10 | 11 |
12 | 13 | [![PyPI](https://img.shields.io/pypi/v/dasshh.svg)](https://pypi.org/project/dasshh/) 14 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 15 | [![CI](https://github.com/vgnshiyer/dasshh/workflows/CI/badge.svg)](https://github.com/vgnshiyer/dasshh/actions/workflows/ci.yml) 16 | [![](https://img.shields.io/badge/Follow-vgnshiyer-0A66C2?logo=linkedin)](https://www.linkedin.com/comm/mynetwork/discovery-see-all?usecase=PEOPLE_FOLLOWS&followMember=vgnshiyer) 17 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-yellow.svg?logo=buymeacoffee)](https://www.buymeacoffee.com/vgnshiyer) 18 | 19 | Dasshh Demo 20 | 21 | **Note:** This project is still in early development. Suggestions and contributions are welcome! 22 | 23 | ## ✨ Features 24 | 25 | - Interactive & minimal chat UI 26 | - Chat with your personal assistant on your terminal 27 | - Perform actions on your computer with plain English 28 | - Extensible with your own tools 29 | 30 | ## 📦 Installation 31 | 32 | ### Using `uv` (Recommended) 33 | 34 | If you haven't tried [uv](https://github.com/astral-sh/uv) yet, it's highly recommended for fast Python package management. 35 | 36 | ```bash 37 | # Install uv on macOS 38 | brew install uv 39 | 40 | # Or using curl 41 | curl -LsSf https://astral.sh/uv/install.sh | sh 42 | 43 | # Install dasshh 44 | uv tool install dasshh 45 | ``` 46 | 47 | ### Using `pipx` 48 | 49 | ```bash 50 | # Install pipx if you haven't already 51 | pip install --user pipx 52 | pipx ensurepath 53 | 54 | # Install dasshh 55 | pipx install dasshh 56 | ``` 57 | 58 | ### Verify Installation 59 | 60 | ```bash 61 | dasshh --version 62 | ``` 63 | 64 | ## 🚀 Quick Start 65 | 66 | ### 1. Initialize Configuration 67 | 68 | ```bash 69 | dasshh init-config 70 | ``` 71 | 72 | This creates a config file at `~/.dasshh/config.yaml`. 73 | 74 | ### 2. Configure Your Model 75 | 76 | Edit the config file to set your model and API key: 77 | 78 | ```yaml 79 | model: 80 | name: gemini/gemini-2.0-flash 81 | api_key: 82 | ``` 83 | 84 | > See [litellm docs](https://docs.litellm.ai/docs/providers) for all supported models and providers. 85 | 86 | ### 3. Launch Dasshh 87 | 88 | ```bash 89 | dasshh 90 | ``` 91 | 92 | ### 4. Start Chatting 93 | 94 | Ask Dasshh to help with system tasks: 95 | 96 | ``` 97 | • What's the current CPU usage? 98 | • Show me the top memory-intensive processes 99 | • List files in my downloads folder 100 | • Create a new directory called "projects" 101 | ``` 102 | 103 | **Exit:** Press `Ctrl+C` to terminate. 104 | 105 | ## 📖 Documentation 106 | 107 | For comprehensive documentation, visit [https://vgnshiyer.github.io/dasshh/](https://vgnshiyer.github.io/dasshh/). 108 | 109 | ## 🤝 Contributing 110 | 111 | We welcome contributions! 🎉 112 | 113 | - **Read our [Contributing Guide](CONTRIBUTING.md)** for development setup and guidelines 114 | - **Report bugs** using our [issue templates](.github/ISSUE_TEMPLATE/) 115 | - **Request features** or ask questions in our [discussions](https://github.com/vgnshiyer/dasshh/discussions) 116 | - **Improve documentation** - we appreciate all help! 117 | 118 | ## 📝 License 119 | 120 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 121 | 122 | -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/assets/demo.png -------------------------------------------------------------------------------- /assets/feature1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/assets/feature1.gif -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/assets/logo.png -------------------------------------------------------------------------------- /dasshh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/__init__.py -------------------------------------------------------------------------------- /dasshh/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import time 3 | from rich.console import Console 4 | 5 | from dasshh.core.logging import setup_logging 6 | from dasshh.ui.utils import load_config, DEFAULT_CONFIG_PATH 7 | 8 | __version__ = "0.1.3" 9 | 10 | 11 | @click.group( 12 | context_settings={ 13 | "help_option_names": ["-h", "--help"], 14 | }, 15 | invoke_without_command=True, 16 | ) 17 | @click.version_option(version=__version__) 18 | @click.option( 19 | "--log-file", 20 | help="Path to log file. Default is ~/.dasshh/logs/dasshh.log", 21 | type=click.Path(), 22 | ) 23 | @click.option( 24 | "--debug", 25 | is_flag=True, 26 | help="Enable debug logging", 27 | ) 28 | @click.pass_context 29 | def main(ctx, version: bool = False, log_file=None, debug=False) -> None: 30 | import logging 31 | 32 | log_level = logging.DEBUG if debug else logging.INFO 33 | setup_logging(log_file=log_file, log_level=log_level) 34 | logger = logging.getLogger("dasshh.main") 35 | 36 | if version: 37 | click.echo(__version__) 38 | logger.debug(f"Version {__version__} requested") 39 | ctx.exit() 40 | 41 | if ctx.invoked_subcommand is None: 42 | console = Console() 43 | with console.status("Starting Dasshh 🗲 ", spinner="dots"): 44 | time.sleep(1.2) 45 | console.clear() 46 | from dasshh.ui.app import Dasshh 47 | 48 | app = Dasshh() 49 | app.run() 50 | 51 | 52 | @main.command() 53 | def init_config(): 54 | """Initialize the configuration file.""" 55 | load_config() 56 | click.echo(f"Config file created at: {DEFAULT_CONFIG_PATH}") 57 | click.echo( 58 | "Please edit this file to set your model API key before starting the application." 59 | ) 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /dasshh/apps/__init__.py: -------------------------------------------------------------------------------- 1 | from .general import get_available_tools 2 | from . import os 3 | 4 | __all__ = ["get_available_tools", "os"] 5 | -------------------------------------------------------------------------------- /dasshh/apps/general.py: -------------------------------------------------------------------------------- 1 | from dasshh.core.registry import Registry 2 | from dasshh.core.tools.decorator import tool 3 | 4 | 5 | @tool 6 | def get_available_tools() -> dict: 7 | """ 8 | Get all available tools from the registry with their details. 9 | 10 | Returns: 11 | A list of dictionaries containing tool information (name, description, parameters). 12 | """ 13 | registry = Registry() 14 | tools = registry.get_tools() 15 | 16 | return {"available_tools": [tool.get_declaration() for tool in tools]} 17 | -------------------------------------------------------------------------------- /dasshh/apps/os/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_operations import ( 2 | current_directory, 3 | list_files, 4 | file_info, 5 | create_directory, 6 | delete_file, 7 | copy_file, 8 | move_file, 9 | ) 10 | 11 | from .system_info import ( 12 | system_info, 13 | cpu_info, 14 | memory_info, 15 | disk_info, 16 | network_info, 17 | ) 18 | 19 | from .process_management import ( 20 | process_list, 21 | find_process, 22 | get_process_info, 23 | kill_process, 24 | run_command, 25 | ) 26 | 27 | from .network_tools import ping, get_ip_address, check_port, public_ip, traceroute 28 | 29 | # Import all tools to ensure they are registered 30 | __all__ = [ 31 | # File operations 32 | "current_directory", 33 | "list_files", 34 | "file_info", 35 | "create_directory", 36 | "delete_file", 37 | "copy_file", 38 | "move_file", 39 | # System info 40 | "system_info", 41 | "cpu_info", 42 | "memory_info", 43 | "disk_info", 44 | "network_info", 45 | # Process management 46 | "process_list", 47 | "find_process", 48 | "get_process_info", 49 | "kill_process", 50 | "run_command", 51 | # Network tools 52 | "ping", 53 | "get_ip_address", 54 | "check_port", 55 | "public_ip", 56 | "traceroute", 57 | ] 58 | -------------------------------------------------------------------------------- /dasshh/apps/os/file_operations.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from datetime import datetime 4 | from typing import List, Dict 5 | 6 | from dasshh.core.tools.decorator import tool 7 | 8 | 9 | @tool 10 | def current_directory() -> str: 11 | """Get the current working directory. 12 | 13 | Returns: 14 | str: The current working directory. 15 | """ 16 | return os.getcwd() 17 | 18 | 19 | @tool 20 | def list_files(directory: str) -> List[Dict]: 21 | """List all files and directories in the specified directory. 22 | 23 | Args: 24 | directory (str): The path to the directory to list. 25 | """ 26 | if not os.path.exists(directory): 27 | return [{"error": f"Directory '{directory}' does not exist"}] 28 | 29 | files = [] 30 | for item in os.listdir(directory): 31 | path = os.path.join(directory, item) 32 | stat = os.stat(path) 33 | files.append( 34 | { 35 | "name": item, 36 | "path": path, 37 | "size": stat.st_size, 38 | "is_dir": os.path.isdir(path), 39 | "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), 40 | } 41 | ) 42 | return files 43 | 44 | 45 | @tool 46 | def file_info(path: str) -> Dict: 47 | """ 48 | Get detailed information about a file or directory. 49 | 50 | Args: 51 | path (str): The path to the file or directory to get information about. 52 | """ 53 | if not os.path.exists(path): 54 | return {"error": f"Path '{path}' does not exist"} 55 | 56 | stat = os.stat(path) 57 | return { 58 | "name": os.path.basename(path), 59 | "path": os.path.abspath(path), 60 | "size": stat.st_size, 61 | "is_dir": os.path.isdir(path), 62 | "is_file": os.path.isfile(path), 63 | "created": datetime.fromtimestamp(stat.st_ctime).isoformat(), 64 | "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), 65 | "accessed": datetime.fromtimestamp(stat.st_atime).isoformat(), 66 | "permissions": oct(stat.st_mode)[-3:], 67 | } 68 | 69 | 70 | @tool 71 | def read_file(path: str) -> Dict: 72 | """Read the contents of a file. 73 | 74 | Args: 75 | path (str): The path to the file to read. 76 | """ 77 | if not os.path.exists(path): 78 | return {"error": f"Path '{path}' does not exist"} 79 | 80 | with open(path, "r") as file: 81 | return file.read() 82 | 83 | 84 | @tool 85 | def create_directory(path: str) -> Dict: 86 | """ 87 | Create a new directory at the specified path. 88 | 89 | Args: 90 | path (str): The path to the new directory. 91 | """ 92 | if os.path.exists(path): 93 | return {"error": f"Path '{path}' already exists"} 94 | 95 | try: 96 | os.makedirs(path, exist_ok=True) 97 | return {"success": True, "path": path} 98 | except Exception as e: 99 | return {"success": False, "error": str(e)} 100 | 101 | 102 | @tool 103 | def delete_file(path: str, recursive: bool = False) -> Dict: 104 | """ 105 | Delete a file or directory. 106 | 107 | Args: 108 | path (str): The path to the file or directory to delete. 109 | recursive (bool): Whether to delete non-empty directories recursively. 110 | """ 111 | if not os.path.exists(path): 112 | return {"error": f"Path '{path}' does not exist"} 113 | 114 | try: 115 | if os.path.isdir(path): 116 | if recursive: 117 | shutil.rmtree(path) 118 | else: 119 | os.rmdir(path) 120 | else: 121 | os.remove(path) 122 | return {"success": True, "path": path} 123 | except Exception as e: 124 | return {"success": False, "error": str(e)} 125 | 126 | 127 | @tool 128 | def copy_file(source: str, destination: str) -> Dict: 129 | """ 130 | Copy a file or directory from source to destination. 131 | 132 | Args: 133 | source (str): The path to the source file or directory. 134 | destination (str): The path to the destination file or directory. 135 | """ 136 | if not os.path.exists(source): 137 | return {"error": f"Source '{source}' does not exist"} 138 | 139 | try: 140 | if os.path.isdir(source): 141 | shutil.copytree(source, destination) 142 | else: 143 | shutil.copy2(source, destination) 144 | return {"success": True, "source": source, "destination": destination} 145 | except Exception as e: 146 | return {"success": False, "error": str(e)} 147 | 148 | 149 | @tool 150 | def move_file(source: str, destination: str) -> Dict: 151 | """ 152 | Move a file or directory from source to destination. 153 | 154 | Args: 155 | source (str): The path to the source file or directory. 156 | destination (str): The path to the destination file or directory. 157 | """ 158 | if not os.path.exists(source): 159 | return {"error": f"Source '{source}' does not exist"} 160 | 161 | try: 162 | shutil.move(source, destination) 163 | return {"success": True, "source": source, "destination": destination} 164 | except Exception as e: 165 | return {"success": False, "error": str(e)} 166 | -------------------------------------------------------------------------------- /dasshh/apps/os/network_tools.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import subprocess 3 | import platform 4 | import requests 5 | from typing import Dict, Optional 6 | 7 | from dasshh.core.tools.decorator import tool 8 | 9 | 10 | @tool 11 | def ping(host: str, count: int = 4) -> Dict: 12 | """Ping a host and return the results""" 13 | param = "-n" if platform.system().lower() == "windows" else "-c" 14 | command = f"ping {param} {count} {host}" 15 | 16 | try: 17 | result = subprocess.run( 18 | command, shell=True, capture_output=True, text=True, timeout=10 19 | ) 20 | 21 | return { 22 | "host": host, 23 | "command": command, 24 | "success": result.returncode == 0, 25 | "output": result.stdout, 26 | "error": result.stderr, 27 | } 28 | except Exception as e: 29 | return {"host": host, "success": False, "error": str(e)} 30 | 31 | 32 | @tool 33 | def get_ip_address(hostname: Optional[str] = None) -> Dict: 34 | """Get IP address information for a hostname or the local machine""" 35 | if hostname: 36 | try: 37 | ip = socket.gethostbyname(hostname) 38 | return {"hostname": hostname, "ip": ip} 39 | except socket.gaierror as e: 40 | return { 41 | "hostname": hostname, 42 | "error": f"Could not resolve hostname: {str(e)}", 43 | } 44 | else: 45 | # Get local hostname and IP 46 | hostname = socket.gethostname() 47 | try: 48 | ip = socket.gethostbyname(hostname) 49 | return {"hostname": hostname, "ip": ip} 50 | except socket.gaierror as e: 51 | return { 52 | "hostname": hostname, 53 | "error": f"Could not resolve local hostname: {str(e)}", 54 | } 55 | 56 | 57 | @tool 58 | def check_port(port: int) -> Dict: 59 | """Check if a specific port is in use on a localhost""" 60 | host = "localhost" 61 | timeout = 3 62 | try: 63 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 64 | sock.settimeout(timeout) 65 | result = sock.connect_ex((host, port)) 66 | sock.close() 67 | 68 | return {"host": host, "port": port, "in_use": result == 0} 69 | except Exception as e: 70 | return {"host": host, "port": port, "error": str(e)} 71 | 72 | 73 | @tool 74 | def public_ip() -> Dict: 75 | """Get the public IP address of the current machine""" 76 | try: 77 | response = requests.get("https://api.ipify.org?format=json", timeout=5) 78 | return response.json() 79 | except Exception as e: 80 | return {"error": f"Could not get public IP: {str(e)}"} 81 | 82 | 83 | @tool 84 | def traceroute(host: str) -> Dict: 85 | """Perform a traceroute to a host""" 86 | command = ( 87 | f"traceroute {host}" 88 | if platform.system().lower() != "windows" 89 | else f"tracert {host}" 90 | ) 91 | 92 | try: 93 | result = subprocess.run( 94 | command, shell=True, capture_output=True, text=True, timeout=30 95 | ) 96 | 97 | return { 98 | "host": host, 99 | "command": command, 100 | "success": result.returncode == 0, 101 | "output": result.stdout, 102 | "error": result.stderr, 103 | } 104 | except Exception as e: 105 | return {"host": host, "success": False, "error": str(e)} 106 | -------------------------------------------------------------------------------- /dasshh/apps/os/process_management.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | import subprocess 3 | from typing import Dict, List, Optional 4 | 5 | from dasshh.core.tools.decorator import tool 6 | 7 | 8 | @tool 9 | def process_list() -> List[Dict]: 10 | """ 11 | List all running processes with basic information. 12 | """ 13 | processes = [] 14 | for proc in psutil.process_iter( 15 | ["pid", "name", "username", "memory_percent", "cpu_percent"] 16 | ): 17 | processes.append(proc.info) 18 | 19 | return processes 20 | 21 | 22 | @tool 23 | def find_process(name: str) -> List[Dict]: 24 | """ 25 | Find all processes matching a name pattern. 26 | 27 | Args: 28 | name (str): The name pattern to search for. 29 | """ 30 | matching_processes = [] 31 | for proc in psutil.process_iter(["pid", "name", "username", "cmdline"]): 32 | if name.lower() in proc.info["name"].lower(): 33 | matching_processes.append(proc.info) 34 | return matching_processes 35 | 36 | 37 | @tool 38 | def get_process_info(pid: int) -> Dict: 39 | """ 40 | Get detailed information about a specific process by its PID. 41 | 42 | Args: 43 | pid (int): The PID of the process to get information about. 44 | """ 45 | try: 46 | proc = psutil.Process(pid) 47 | return { 48 | "pid": proc.pid, 49 | "name": proc.name(), 50 | "status": proc.status(), 51 | "created": proc.create_time(), 52 | "username": proc.username(), 53 | "cmdline": proc.cmdline(), 54 | "cwd": proc.cwd(), 55 | "memory_info": proc.memory_info()._asdict(), 56 | "cpu_percent": proc.cpu_percent(interval=0.1), 57 | "num_threads": proc.num_threads(), 58 | "open_files": [f._asdict() for f in proc.open_files()], 59 | "connections": [c._asdict() for c in proc.connections()], 60 | } 61 | except psutil.NoSuchProcess: 62 | return {"error": f"No process with PID {pid} found"} 63 | except psutil.AccessDenied: 64 | return {"error": f"Access denied to process with PID {pid}"} 65 | 66 | 67 | @tool 68 | def kill_process(pid: int) -> Dict: 69 | """ 70 | Terminate a process by its PID. 71 | 72 | Args: 73 | pid (int): The PID of the process to terminate. 74 | """ 75 | try: 76 | proc = psutil.Process(pid) 77 | proc_name = proc.name() 78 | proc.terminate() 79 | 80 | # Wait for process to terminate (max 3 seconds) 81 | gone, still_alive = psutil.wait_procs([proc], timeout=3) 82 | 83 | if still_alive: 84 | # If still alive, kill it more forcefully 85 | proc.kill() 86 | return { 87 | "success": True, 88 | "pid": pid, 89 | "name": proc_name, 90 | "force_killed": True, 91 | } 92 | else: 93 | return { 94 | "success": True, 95 | "pid": pid, 96 | "name": proc_name, 97 | "force_killed": False, 98 | } 99 | except psutil.NoSuchProcess: 100 | return {"error": f"No process with PID {pid} found"} 101 | except psutil.AccessDenied: 102 | return {"error": f"Access denied when trying to kill process with PID {pid}"} 103 | 104 | 105 | @tool 106 | def run_command(command: str, timeout: Optional[int] = None) -> Dict: 107 | """ 108 | Run a shell command and return its output. 109 | 110 | Args: 111 | command (str): The command to run. 112 | timeout (int, optional): The timeout for the command. 113 | """ 114 | try: 115 | result = subprocess.run( 116 | command, shell=True, capture_output=True, text=True, timeout=timeout 117 | ) 118 | 119 | return { 120 | "command": command, 121 | "success": result.returncode == 0, 122 | "return_code": result.returncode, 123 | "stdout": result.stdout, 124 | "stderr": result.stderr, 125 | } 126 | except subprocess.TimeoutExpired: 127 | return { 128 | "command": command, 129 | "success": False, 130 | "error": f"Command timed out after {timeout} seconds", 131 | } 132 | except Exception as e: 133 | return {"command": command, "success": False, "error": str(e)} 134 | -------------------------------------------------------------------------------- /dasshh/apps/os/system_info.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import psutil 3 | from typing import Dict 4 | 5 | from dasshh.core.tools.decorator import tool 6 | 7 | 8 | @tool 9 | def system_info() -> Dict: 10 | """ 11 | Get detailed information about the operating system. 12 | """ 13 | info = { 14 | "system": platform.system(), 15 | "node": platform.node(), 16 | "release": platform.release(), 17 | "version": platform.version(), 18 | "machine": platform.machine(), 19 | "processor": platform.processor(), 20 | "python_version": platform.python_version(), 21 | } 22 | return info 23 | 24 | 25 | @tool 26 | def cpu_info() -> Dict: 27 | """ 28 | Get CPU information and current usage. 29 | """ 30 | cpu_count = psutil.cpu_count() 31 | cpu_percent = psutil.cpu_percent(interval=1, percpu=True) 32 | 33 | return { 34 | "physical_cores": psutil.cpu_count(logical=False), 35 | "total_cores": cpu_count, 36 | "usage_per_core": cpu_percent, 37 | "total_usage": sum(cpu_percent) / len(cpu_percent), 38 | "frequency": psutil.cpu_freq()._asdict() if psutil.cpu_freq() else None, 39 | } 40 | 41 | 42 | @tool 43 | def memory_info() -> Dict: 44 | """ 45 | Get memory (RAM) information and usage. 46 | """ 47 | mem = psutil.virtual_memory() 48 | swap = psutil.swap_memory() 49 | 50 | return { 51 | "total": mem.total, 52 | "available": mem.available, 53 | "used": mem.used, 54 | "percent": mem.percent, 55 | "swap_total": swap.total, 56 | "swap_used": swap.used, 57 | "swap_percent": swap.percent, 58 | } 59 | 60 | 61 | @tool 62 | def disk_info(path: str = "/") -> Dict: 63 | """ 64 | Get disk space information for the specified path. 65 | """ 66 | disk = psutil.disk_usage(path) 67 | 68 | return { 69 | "path": path, 70 | "total": disk.total, 71 | "used": disk.used, 72 | "free": disk.free, 73 | "percent": disk.percent, 74 | } 75 | 76 | 77 | @tool 78 | def network_info() -> Dict: 79 | """ 80 | Get network interface information. 81 | """ 82 | interfaces = psutil.net_if_addrs() 83 | io_counters = psutil.net_io_counters(pernic=True) 84 | 85 | result = {} 86 | for interface, addresses in interfaces.items(): 87 | result[interface] = { 88 | "addresses": [addr._asdict() for addr in addresses], 89 | "stats": io_counters.get(interface)._asdict() 90 | if interface in io_counters 91 | else None, 92 | } 93 | 94 | return result 95 | -------------------------------------------------------------------------------- /dasshh/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/core/__init__.py -------------------------------------------------------------------------------- /dasshh/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /dasshh/core/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | from pathlib import Path 4 | import litellm 5 | 6 | DEFAULT_LOG_DIR = Path.home() / ".dasshh" / "logs" 7 | DEFAULT_LOG_FILE = DEFAULT_LOG_DIR / "dasshh.log" 8 | 9 | DEFAULT_LOG_DIR.mkdir(parents=True, exist_ok=True) 10 | 11 | 12 | def setup_logging(log_file=None, log_level=logging.INFO): 13 | log_file = log_file or DEFAULT_LOG_FILE 14 | formatter = logging.Formatter( 15 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 16 | ) 17 | 18 | litellm_logger = litellm.verbose_logger 19 | root_logger = logging.getLogger() 20 | 21 | for handler in root_logger.handlers[:] + litellm_logger.handlers[:]: 22 | litellm_logger.removeHandler(handler) 23 | root_logger.removeHandler(handler) 24 | 25 | file_handler = RotatingFileHandler( 26 | log_file, 27 | maxBytes=10 * 1024 * 1024, # 10 MB 28 | backupCount=5, 29 | encoding="utf-8", 30 | ) 31 | file_handler.setFormatter(formatter) 32 | root_logger.addHandler(file_handler) 33 | root_logger.setLevel(log_level) 34 | litellm_logger.addHandler(file_handler) 35 | litellm_logger.setLevel(log_level) 36 | logging.info(f"-- Dasshh logging initialized. Log file: {log_file} --") 37 | 38 | 39 | def get_logger(name): 40 | return logging.getLogger(name) 41 | -------------------------------------------------------------------------------- /dasshh/core/registry.py: -------------------------------------------------------------------------------- 1 | from dasshh.core.tools.base import BaseTool 2 | 3 | 4 | class Registry: 5 | """ 6 | A registry for tools. 7 | """ 8 | 9 | _instance: "Registry" = None 10 | """The singleton instance of the registry.""" 11 | tools: dict[str, BaseTool] = {} 12 | """The tools in the registry.""" 13 | 14 | def __new__(cls, *args, **kwargs): 15 | if cls._instance is None: 16 | cls._instance = super().__new__(cls, *args, **kwargs) 17 | return cls._instance 18 | 19 | def add_tool(self, tool: BaseTool): 20 | """ 21 | Add a tool to the registry. 22 | """ 23 | if tool.name in self.tools: 24 | raise ValueError( 25 | f"Tool name must be unique, there is already a tool named {tool.name}" 26 | ) 27 | self.tools[tool.name] = tool 28 | 29 | def get_tools(self) -> list[BaseTool]: 30 | """ 31 | Get all registered tools. 32 | """ 33 | return list(self.tools.values()) 34 | 35 | def get_tool(self, tool_name: str) -> BaseTool | None: 36 | """ 37 | Get a tool by name. 38 | """ 39 | return self.tools.get(tool_name, None) 40 | 41 | def get_tool_declarations(self) -> list[dict]: 42 | """ 43 | Get all registered tool declarations. 44 | """ 45 | return [tool.get_declaration() for tool in self.get_tools()] 46 | -------------------------------------------------------------------------------- /dasshh/core/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/core/tools/__init__.py -------------------------------------------------------------------------------- /dasshh/core/tools/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | 4 | class BaseTool(ABC): 5 | """ 6 | Base class for all tools. 7 | """ 8 | 9 | name: str 10 | """The name of the tool.""" 11 | description: str 12 | """The description of the tool.""" 13 | parameters: dict 14 | """The parameters of the tool.""" 15 | 16 | def __init__(self, name: str, description: str, parameters: dict): 17 | self.name = name 18 | self.description = description 19 | self.parameters = parameters 20 | 21 | def __call__(self, *args, **kwargs): 22 | raise NotImplementedError("This tool has no implementation") 23 | 24 | def get_declaration(self) -> dict: 25 | """ 26 | Get the declaration of the tool. 27 | """ 28 | raise NotImplementedError("This tool has no implementation") 29 | -------------------------------------------------------------------------------- /dasshh/core/tools/decorator.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from dasshh.core.registry import Registry 4 | from dasshh.core.tools.function_tool import FunctionTool 5 | 6 | 7 | def tool(func: Callable): 8 | """ 9 | A decorator to convert a function into a tool. 10 | """ 11 | 12 | tool_instance = FunctionTool( 13 | name=func.__name__, 14 | description=func.__doc__, 15 | parameters=func.__annotations__, 16 | func=func, 17 | ) 18 | 19 | registry = Registry() 20 | registry.add_tool(tool_instance) 21 | 22 | return tool_instance 23 | -------------------------------------------------------------------------------- /dasshh/core/tools/function_tool.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from litellm.utils import function_to_dict 3 | 4 | from dasshh.core.tools.base import BaseTool 5 | 6 | 7 | class FunctionTool(BaseTool): 8 | """ 9 | A tool is a function that can be used to help the user. 10 | """ 11 | 12 | func: Callable = None 13 | """The function of the tool.""" 14 | 15 | def __init__( 16 | self, name: str, description: str, parameters: dict, func: Callable = None 17 | ): 18 | super().__init__(name, description, parameters) 19 | self.func = func 20 | 21 | def __call__(self, *args, **kwargs): 22 | if self.func: 23 | return self.func(*args, **kwargs) 24 | raise NotImplementedError("This tool has no implementation") 25 | 26 | def get_declaration(self) -> dict: 27 | """ 28 | Get the declaration of the tool. 29 | """ 30 | return function_to_dict(self.func) 31 | -------------------------------------------------------------------------------- /dasshh/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/data/__init__.py -------------------------------------------------------------------------------- /dasshh/data/client.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Generator 3 | 4 | from sqlalchemy.orm import Session, sessionmaker 5 | from sqlalchemy import create_engine, Engine 6 | from sqlalchemy.orm import DeclarativeBase 7 | 8 | 9 | class Base(DeclarativeBase): 10 | """Base class for all database models.""" 11 | 12 | pass 13 | 14 | 15 | class DBClient: 16 | """Dasshh database client.""" 17 | 18 | db_path = Path.home() / ".dasshh" / "db" / "dasshh.db" 19 | 20 | def __init__(self): 21 | self.db_path.parent.mkdir(parents=True, exist_ok=True) 22 | self.engine: Engine = create_engine(f"sqlite:///{self.db_path}") 23 | self.DatabaseSessionFactory: sessionmaker = sessionmaker(bind=self.engine) 24 | 25 | Base.metadata.create_all(bind=self.engine) 26 | 27 | def get_db(self) -> Generator[Session, None, None]: 28 | """Get a database session.""" 29 | db: Session = self.DatabaseSessionFactory() 30 | return db 31 | -------------------------------------------------------------------------------- /dasshh/data/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime, timezone 3 | from sqlalchemy import Column, String, DateTime, ForeignKey, JSON 4 | from sqlalchemy.orm import relationship 5 | 6 | from dasshh.data.client import Base 7 | 8 | 9 | class StorageSession(Base): 10 | __tablename__ = "sessions" 11 | 12 | id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 13 | detail = Column(String) 14 | created_at = Column(DateTime, default=datetime.now(timezone.utc)) 15 | updated_at = Column(DateTime, default=datetime.now(timezone.utc)) 16 | 17 | events = relationship("StorageEvent", back_populates="session") 18 | 19 | 20 | class StorageEvent(Base): 21 | __tablename__ = "events" 22 | 23 | id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 24 | invocation_id = Column(String) 25 | session_id = Column(String, ForeignKey("sessions.id")) 26 | created_at = Column(DateTime, default=datetime.now(timezone.utc)) 27 | content = Column(JSON) 28 | 29 | session = relationship("StorageSession", back_populates="events") 30 | -------------------------------------------------------------------------------- /dasshh/data/session.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from sqlalchemy.orm import noload 4 | 5 | from dasshh.data.client import DBClient 6 | from dasshh.data.models import StorageSession, StorageEvent 7 | 8 | 9 | class SessionService: 10 | """Dasshh database session service.""" 11 | 12 | def __init__(self, db_client: DBClient): 13 | self.db_client = db_client 14 | 15 | def new_session( 16 | self, 17 | detail: str = "New Session", 18 | ) -> StorageSession: 19 | """Create a new session.""" 20 | session = StorageSession(detail=detail) 21 | with self.db_client.get_db() as db: 22 | db.add(session) 23 | db.commit() 24 | db.refresh(session) 25 | return session 26 | 27 | def get_session(self, *, session_id: str) -> StorageSession | None: 28 | """Get a session by its ID.""" 29 | with self.db_client.get_db() as db: 30 | session: StorageSession | None = db.get(StorageSession, session_id) 31 | if session is None: 32 | return None 33 | return session 34 | 35 | def get_events(self, *, session_id: str) -> list[StorageEvent]: 36 | """Get all events for a session.""" 37 | with self.db_client.get_db() as db: 38 | events = ( 39 | db.query(StorageEvent) 40 | .filter(StorageEvent.session_id == session_id) 41 | .all() 42 | ) 43 | return events 44 | 45 | def get_recent_session(self) -> StorageSession | None: 46 | """Get the most recent session.""" 47 | with self.db_client.get_db() as db: 48 | session: StorageSession | None = ( 49 | db.query(StorageSession) 50 | .order_by(StorageSession.updated_at.desc()) 51 | .first() 52 | ) 53 | if not session: 54 | return None 55 | return session 56 | 57 | def update_session( 58 | self, 59 | *, 60 | session_id: str, 61 | detail: str, 62 | ) -> None: 63 | """Update a session.""" 64 | with self.db_client.get_db() as db: 65 | session = db.get(StorageSession, session_id) 66 | session.detail = detail 67 | session.updated_at = datetime.now(timezone.utc) 68 | db.commit() 69 | 70 | def list_sessions(self, include_events: bool = False) -> list[StorageSession]: 71 | """List all sessions.""" 72 | with self.db_client.get_db() as db: 73 | if include_events: 74 | sessions = db.query(StorageSession).all() 75 | else: 76 | sessions = ( 77 | db.query(StorageSession) 78 | .options(noload(StorageSession.events)) 79 | .all() 80 | ) 81 | return sessions 82 | 83 | def delete_session(self, *, session_id: str) -> None: 84 | """Delete a session by its ID.""" 85 | with self.db_client.get_db() as db: 86 | session = db.get(StorageSession, session_id) 87 | if session is None: 88 | return 89 | db.delete(session) 90 | db.commit() 91 | 92 | def add_event( 93 | self, 94 | *, 95 | invocation_id: str, 96 | session_id: str, 97 | content: dict, 98 | ) -> None: 99 | """Append an event to a session.""" 100 | event = StorageEvent( 101 | invocation_id=invocation_id, 102 | session_id=session_id, 103 | content=content, 104 | ) 105 | with self.db_client.get_db() as db: 106 | db.add(event) 107 | db.commit() 108 | db.refresh(event) 109 | -------------------------------------------------------------------------------- /dasshh/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/ui/__init__.py -------------------------------------------------------------------------------- /dasshh/ui/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import yaml 3 | from textual.app import App 4 | from textual.command import CommandPalette 5 | from textual.theme import ThemeProvider 6 | 7 | from dasshh.ui.screens.main import MainScreen 8 | from dasshh.data.client import DBClient 9 | from dasshh.data.session import SessionService 10 | from dasshh.core.runtime import DasshhRuntime 11 | from dasshh.ui.utils import load_tools, load_config, DEFAULT_CONFIG_PATH 12 | from dasshh.ui.themes.lime import lime 13 | 14 | 15 | class Dasshh(App): 16 | """Dasshh 🗲""" 17 | 18 | SCREENS = { 19 | "main": MainScreen, 20 | } 21 | 22 | BINDINGS = [ 23 | ("ctrl+c", "quit", "Quit"), 24 | ("ctrl+t", "toggle_theme", "Toggle Theme"), 25 | ] 26 | 27 | ENABLE_COMMAND_PALETTE = False 28 | 29 | logger: logging.Logger 30 | """Dasshh logger.""" 31 | 32 | runtime: DasshhRuntime 33 | """Dasshh runtime.""" 34 | 35 | session_service: SessionService 36 | """The database service.""" 37 | 38 | config: dict 39 | """Dasshh config.""" 40 | 41 | def __init__(self, *args, **kwargs): 42 | super().__init__(*args, **kwargs) 43 | self.config = load_config() 44 | load_tools() 45 | 46 | self.session_service = SessionService(DBClient()) 47 | self.runtime = DasshhRuntime(self.session_service) 48 | 49 | self.logger = logging.getLogger("dasshh.app") 50 | self.logger.debug("-- Dasshh 🗲 initialized --") 51 | 52 | async def on_mount(self): 53 | self.startup() 54 | self.logger.debug("Pushing main screen") 55 | self.push_screen("main") 56 | await self.runtime.start() 57 | 58 | async def on_unmount(self): 59 | self.logger.debug("Application shutting down") 60 | await self.runtime.stop() 61 | 62 | def startup(self): 63 | self.register_theme(lime) 64 | self.theme = self.config.get("dasshh", {}).get("theme", "lime") 65 | self.logger.debug(f"Theme set to {self.theme}") 66 | 67 | def action_toggle_theme(self) -> None: 68 | self.push_screen( 69 | CommandPalette( 70 | providers=[ThemeProvider], 71 | placeholder="Search for themes…", 72 | ), 73 | ) 74 | 75 | def watch_theme(self, theme: str) -> None: 76 | self.config["dasshh"]["theme"] = theme 77 | self.update_config() 78 | 79 | def update_config(self) -> None: 80 | self.logger.debug(f"Updating config: {self.config}") 81 | DEFAULT_CONFIG_PATH.write_text(yaml.dump(self.config)) 82 | 83 | 84 | if __name__ == "__main__": 85 | app = Dasshh() 86 | app.run() 87 | -------------------------------------------------------------------------------- /dasshh/ui/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/ui/components/__init__.py -------------------------------------------------------------------------------- /dasshh/ui/components/chat/__init__.py: -------------------------------------------------------------------------------- 1 | from dasshh.ui.components.chat.chat_panel import ChatPanel, ChatMessage, ChatInput 2 | from dasshh.ui.components.chat.history_panel import HistoryPanel 3 | from dasshh.ui.components.chat.actions_panel import ActionsPanel 4 | 5 | __all__ = ["ChatPanel", "ChatMessage", "ChatInput", "HistoryPanel", "ActionsPanel"] 6 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/action.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from textual.widgets import Static 4 | from textual.reactive import reactive 5 | from rich.syntax import Syntax 6 | from rich.console import Group 7 | from rich.text import Text 8 | 9 | 10 | class Action(Static): 11 | """A action display component.""" 12 | 13 | DEFAULT_CSS = """ 14 | Action { 15 | width: 100%; 16 | margin: 1 0; 17 | padding: 1; 18 | border-left: thick $success; 19 | background: $panel 15%; 20 | } 21 | """ 22 | 23 | name: reactive[str] = reactive("", layout=True) 24 | args: reactive[str] = reactive("", layout=True) 25 | result: reactive[str] = reactive("", layout=True) 26 | 27 | def __init__( 28 | self, 29 | invocation_id: str, 30 | tool_call_id: str, 31 | name: str, 32 | args: str, 33 | result: str, 34 | *a: Any, 35 | **kw: Any, 36 | ) -> None: 37 | super().__init__(*a, **kw) 38 | self.invocation_id = invocation_id 39 | self.tool_call_id = tool_call_id 40 | self.name = name 41 | self.args = args 42 | self.result = result 43 | 44 | def render(self): 45 | tool_call_title = Text(f"󰓦 Using tool: {self.name}", style="bold green") 46 | panel_color = self.app.get_css_variables().get("panel", "") 47 | args_syntax = Syntax( 48 | self.args, "json", background_color=panel_color, word_wrap=True 49 | ) 50 | 51 | if self.result: 52 | result_title = Text(f"󰄬 Result: {self.name}", style="bold blue") 53 | result_syntax = Syntax( 54 | self.result, "json", background_color=panel_color, word_wrap=True 55 | ) 56 | return Group( 57 | tool_call_title, args_syntax, Text(""), result_title, result_syntax 58 | ) 59 | else: 60 | return Group(tool_call_title, args_syntax) 61 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/actions_panel.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from textual.widget import Widget 3 | from textual.widgets import Static 4 | from textual.app import ComposeResult 5 | from textual.containers import ScrollableContainer 6 | 7 | from dasshh.ui.components.chat.action import Action 8 | from dasshh.ui.types import UIAction 9 | 10 | 11 | class ActionsPanel(Widget): 12 | DEFAULT_CSS = """ 13 | ActionsPanel { 14 | border: round $secondary; 15 | layout: vertical; 16 | 17 | &:focus, &:hover, &:focus-within { 18 | border: round $primary; 19 | } 20 | 21 | #actions-header { 22 | height: auto; 23 | text-align: center; 24 | text-style: bold; 25 | } 26 | 27 | #actions-container { 28 | height: 1fr; 29 | } 30 | 31 | ScrollableContainer { 32 | scrollbar-color: $secondary $background; 33 | scrollbar-background: $background; 34 | scrollbar-corner-color: $background; 35 | scrollbar-size: 1 1; 36 | scrollbar-gutter: stable; 37 | margin: 0 1; 38 | } 39 | } 40 | """ 41 | 42 | def compose(self) -> ComposeResult: 43 | yield Static("Actions", id="actions-header") 44 | yield ScrollableContainer(id="actions-container") 45 | 46 | def on_show(self) -> None: 47 | """Actions panel shown.""" 48 | self.query_one("#actions-container").scroll_end(animate=False) 49 | 50 | def reset(self) -> None: 51 | """Reset the actions panel.""" 52 | container = self.query_one("#actions-container") 53 | container.remove_children() 54 | 55 | def load_actions(self, actions: List[UIAction]): 56 | container = self.query_one("#actions-container", ScrollableContainer) 57 | container.remove_children() 58 | for action in actions: 59 | self.add_action(action) 60 | container.scroll_end() 61 | 62 | def add_action(self, action: UIAction) -> None: 63 | """Add a action to the actions panel.""" 64 | container = self.query_one("#actions-container", ScrollableContainer) 65 | action_widget = Action( 66 | invocation_id=action.invocation_id, 67 | tool_call_id=action.tool_call_id, 68 | name=action.name, 69 | args=action.args, 70 | result=action.result, 71 | ) 72 | container.mount(action_widget) 73 | container.scroll_end() 74 | 75 | def update_action(self, invocation_id: str, tool_call_id: str, result: str) -> None: 76 | """Update an action in the actions panel.""" 77 | action_widget = self.get_action_widget(invocation_id, tool_call_id) 78 | if action_widget: 79 | action_widget.result = result 80 | container = self.query_one("#actions-container", ScrollableContainer) 81 | container.scroll_end() 82 | 83 | def get_action_widget(self, invocation_id: str, tool_call_id: str) -> Action | None: 84 | """Get an action widget by invocation id and tool call id.""" 85 | container = self.query_one("#actions-container", ScrollableContainer) 86 | for action in container.query(Action): 87 | if ( 88 | action.invocation_id == invocation_id 89 | and action.tool_call_id == tool_call_id 90 | ): 91 | return action 92 | return None 93 | 94 | def handle_error(self, error: str) -> None: 95 | """Handle an error during an action by showing a toast notification.""" 96 | self.notify(f"Tool Error: {error}", severity="error", timeout=5) 97 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/chat_input.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Input, Button 2 | from textual.widget import Widget 3 | from textual.app import ComposeResult 4 | from textual import on 5 | from textual.binding import Binding 6 | from textual.containers import Horizontal 7 | 8 | from dasshh.ui.events import NewMessage 9 | 10 | 11 | class ChatInput(Widget): 12 | """Input area for chat messages.""" 13 | 14 | BINDINGS = [Binding("escape", "blur_input", "Blur Input")] 15 | 16 | DEFAULT_CSS = """ 17 | ChatInput { 18 | dock: bottom; 19 | width: 100%; 20 | height: 3; 21 | text-overflow: ellipsis; 22 | 23 | ScrollView { 24 | overflow-y: hidden; 25 | overflow-x: hidden; 26 | } 27 | } 28 | 29 | #message-input { 30 | width: 1fr; 31 | border: round $secondary; 32 | background: $background; 33 | 34 | &:focus, &:hover { 35 | border: round $primary; 36 | background: $background; 37 | background-tint: $background; 38 | } 39 | 40 | &:disabled { 41 | border: round $panel-darken-1; 42 | color: $text-muted; 43 | } 44 | } 45 | 46 | #send-button { 47 | width: auto; 48 | min-width: 10; 49 | border: round $secondary; 50 | color: $secondary; 51 | background: $background; 52 | text-style: bold; 53 | 54 | &:disabled { 55 | border: round $panel-darken-1; 56 | color: $text-muted; 57 | } 58 | } 59 | 60 | #send-button:focus, #send-button:hover { 61 | border: round $primary; 62 | color: $primary; 63 | background: $background; 64 | background-tint: $background; 65 | text-style: bold; 66 | 67 | &.-active { 68 | tint: $background; 69 | } 70 | } 71 | """ 72 | 73 | def compose(self) -> ComposeResult: 74 | with Horizontal(): 75 | yield Input(placeholder="Type your message here...", id="message-input") 76 | yield Button("Send", id="send-button", variant="primary") 77 | 78 | @on(Input.Submitted) 79 | def on_input_submitted(self) -> None: 80 | """Handle when the user presses Enter in the input field.""" 81 | self.send_message() 82 | 83 | @on(Button.Pressed, "#send-button") 84 | def on_button_pressed(self) -> None: 85 | """Handle when the send button is clicked.""" 86 | self.send_message() 87 | 88 | def action_blur_input(self) -> None: 89 | """Remove focus from the input field when Escape is pressed.""" 90 | self.screen.set_focus(None) 91 | 92 | def send_message(self) -> None: 93 | """Send the message from the input field.""" 94 | input_field = self.query_one("#message-input", Input) 95 | message = input_field.value 96 | 97 | if message.strip(): 98 | input_field.value = "" 99 | self.post_message(NewMessage(message=message)) 100 | 101 | def disable(self) -> None: 102 | """Disable the input field and send button.""" 103 | input_field = self.query_one("#message-input", Input) 104 | send_button = self.query_one("#send-button", Button) 105 | 106 | input_field.disabled = True 107 | send_button.disabled = True 108 | 109 | def enable(self) -> None: 110 | """Enable the input field and send button.""" 111 | input_field = self.query_one("#message-input", Input) 112 | send_button = self.query_one("#send-button", Button) 113 | 114 | input_field.disabled = False 115 | send_button.disabled = False 116 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/chat_panel.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from textual.widget import Widget 4 | from textual.widgets import Static 5 | from textual.app import ComposeResult 6 | from textual.containers import ScrollableContainer 7 | from rich.text import Text 8 | from dasshh.ui.types import UIMessage 9 | 10 | from dasshh.ui.components.chat.message import ChatMessage 11 | from dasshh.ui.components.chat.chat_input import ChatInput 12 | 13 | 14 | class ChatPanel(Widget): 15 | """Main chat panel containing the chat history and input area.""" 16 | 17 | DEFAULT_CSS = """ 18 | ChatPanel { 19 | layout: vertical; 20 | border: round $secondary; 21 | 22 | &:focus, &:hover, &:focus-within { 23 | border: round $primary; 24 | } 25 | 26 | #messages-container { 27 | height: 1fr; 28 | overflow-y: auto; 29 | } 30 | 31 | #chat-header { 32 | height: auto; 33 | text-align: center; 34 | text-style: bold; 35 | } 36 | 37 | ScrollableContainer { 38 | scrollbar-color: $secondary $background; 39 | scrollbar-background: $background; 40 | scrollbar-corner-color: $background; 41 | scrollbar-size: 1 1; 42 | scrollbar-gutter: stable; 43 | } 44 | } 45 | """ 46 | 47 | def compose(self) -> ComposeResult: 48 | yield Static("Chat", id="chat-header") 49 | yield ScrollableContainer(id="messages-container") 50 | yield ChatInput(id="chat-input") 51 | 52 | def on_show(self) -> None: 53 | """Chat panel shown.""" 54 | self.query_one("#messages-container").scroll_end(animate=False) 55 | 56 | def reset(self) -> None: 57 | """Reset the chat panel.""" 58 | container = self.query_one("#messages-container") 59 | container.remove_children() 60 | 61 | text = Static( 62 | Text("Start a new session or load a previous one.", style="dim"), 63 | classes="chat-message", 64 | ) 65 | text.styles.text_align = "center" 66 | text.styles.margin = (1, 1, 1, 1) 67 | container.mount(text) 68 | 69 | chat_input = self.query_one(ChatInput) 70 | chat_input.disable() 71 | 72 | def load_messages(self, messages: List[UIMessage]) -> None: 73 | """Load messages from a previous chat session.""" 74 | container = self.query_one("#messages-container") 75 | container.remove_children() 76 | for message in messages: 77 | self.add_new_message(message) 78 | chat_input = self.query_one(ChatInput) 79 | chat_input.enable() 80 | 81 | def add_new_message(self, message: UIMessage) -> None: 82 | """Add a new message to the chat history.""" 83 | container = self.query_one("#messages-container") 84 | message_widget = ChatMessage( 85 | invocation_id=message.invocation_id, 86 | role=message.role, 87 | content=message.content, 88 | classes="chat-message", 89 | ) 90 | container.mount(message_widget) 91 | container.scroll_end() 92 | 93 | def update_assistant_message( 94 | self, *, invocation_id: str, content: str, final: bool = False 95 | ) -> None: 96 | """Update the content of the most recent assistant message (used for streaming).""" 97 | message_widget = self.get_message_widget(invocation_id) 98 | if not message_widget: 99 | return 100 | 101 | if final: 102 | message_widget.content = content 103 | else: 104 | message_widget.content += content 105 | 106 | container = self.query_one("#messages-container") 107 | container.scroll_end() 108 | 109 | def get_message_widget(self, invocation_id: str) -> ChatMessage | None: 110 | """Get the message widget for a given invocation id.""" 111 | container = self.query_one("#messages-container") 112 | for message in container.query(ChatMessage): 113 | if message.invocation_id == invocation_id: 114 | return message 115 | return None 116 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/history_item.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any 3 | 4 | from textual.widgets import Static 5 | from textual.reactive import reactive 6 | from rich.console import Group 7 | from rich.text import Text 8 | 9 | from dasshh.ui.events import LoadSession, DeleteSession 10 | 11 | 12 | class DeleteIcon(Static): 13 | """A static widget that displays a delete icon.""" 14 | 15 | DEFAULT_CSS = """ 16 | DeleteIcon { 17 | padding: 0 1; 18 | color: $text-muted; 19 | align: center middle; 20 | text-align: center; 21 | 22 | &:hover { 23 | color: $error; 24 | } 25 | } 26 | """ 27 | 28 | selected: reactive[bool] = reactive(False, layout=True) 29 | 30 | def __init__(self, session_id: str, *args: Any, **kwargs: Any) -> None: 31 | super().__init__(*args, **kwargs) 32 | self.session_id = session_id 33 | 34 | def watch_selected(self, selected: bool) -> None: 35 | """Watch for changes to the selected state.""" 36 | if selected: 37 | self.add_class("selected") 38 | else: 39 | self.remove_class("selected") 40 | 41 | def on_click(self) -> None: 42 | """Handle click events on this widget.""" 43 | self.post_message(DeleteSession(self.session_id)) 44 | 45 | def render(self): 46 | return Text("󰆴 Delete") 47 | 48 | 49 | class HistoryItem(Static): 50 | """A history item display component for chat sessions.""" 51 | 52 | DEFAULT_CSS = """ 53 | HistoryItem { 54 | margin-top: 1; 55 | padding: 1; 56 | 57 | } 58 | """ 59 | 60 | detail: reactive[str] = reactive("", layout=True) 61 | selected: reactive[bool] = reactive(False, layout=True) 62 | 63 | def __init__( 64 | self, 65 | session_id: str, 66 | detail: str, 67 | created_at: datetime, 68 | *args: Any, 69 | **kwargs: Any, 70 | ) -> None: 71 | super().__init__(*args, **kwargs) 72 | self.session_id = session_id 73 | self.detail = detail 74 | self.created_at = created_at 75 | 76 | def watch_selected(self, selected: bool) -> None: 77 | """Watch for changes to the selected state.""" 78 | if selected: 79 | self.add_class("selected") 80 | else: 81 | self.remove_class("selected") 82 | 83 | def on_click(self) -> None: 84 | """Handle click events on this widget.""" 85 | self.post_message(LoadSession(self.session_id)) 86 | 87 | def render(self): 88 | truncated_detail = ( 89 | (self.detail[:40] + "...") if len(self.detail) > 40 else self.detail 90 | ) 91 | 92 | local_timestamp = self.created_at.replace(tzinfo=timezone.utc).astimezone( 93 | tz=None 94 | ) 95 | 96 | now = datetime.now() 97 | time_str = local_timestamp.strftime("%I:%M %p") 98 | 99 | if local_timestamp.date() == now.date(): 100 | date_str = f"Today at {time_str}" 101 | elif (now.date() - local_timestamp.date()).days == 1: 102 | date_str = f"Yesterday at {time_str}" 103 | else: 104 | date_str = local_timestamp.strftime("%b %d at %I:%M %p") 105 | 106 | title = Text(truncated_detail, style="bold") 107 | date = Text(date_str, style="dim") 108 | 109 | return Group(title, date) 110 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/history_panel.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from textual.widget import Widget 4 | from textual.widgets import Static, Button 5 | from textual.app import ComposeResult 6 | from textual.containers import ScrollableContainer 7 | from textual import on 8 | 9 | from dasshh.ui.events import NewSession, DeleteSession 10 | from dasshh.ui.components.chat.history_item import HistoryItem, DeleteIcon 11 | from dasshh.ui.types import UISession 12 | 13 | 14 | class HistoryPanel(Widget): 15 | DEFAULT_CSS = """ 16 | HistoryPanel { 17 | border: round $secondary; 18 | layout: vertical; 19 | 20 | &:focus, &:hover, &:focus-within { 21 | border: round $primary; 22 | } 23 | 24 | #history-header { 25 | height: auto; 26 | text-align: center; 27 | text-style: bold; 28 | } 29 | 30 | #history-container { 31 | height: 1fr; 32 | margin: 1; 33 | } 34 | 35 | #new-session { 36 | width: 100%; 37 | min-width: 10; 38 | border: round $secondary; 39 | color: $secondary; 40 | background: $background; 41 | text-style: bold; 42 | } 43 | 44 | #new-session:focus, #new-session:hover { 45 | border: round $primary; 46 | color: $primary; 47 | background: $background; 48 | background-tint: $background; 49 | text-style: bold; 50 | 51 | &.-active { 52 | tint: $background; 53 | } 54 | } 55 | 56 | ScrollableContainer { 57 | scrollbar-color: $secondary $background; 58 | scrollbar-background: $background; 59 | scrollbar-corner-color: $background; 60 | scrollbar-size: 1 1; 61 | scrollbar-gutter: stable; 62 | } 63 | 64 | HistoryItem, DeleteIcon { 65 | width: 100%; 66 | height: auto; 67 | border-left: thick $accent-darken-2; 68 | background: $panel-darken-1; 69 | 70 | &:hover { 71 | border-left: thick $accent; 72 | background: $panel 20%; 73 | } 74 | 75 | &.selected { 76 | border-left: thick $success; 77 | background: $panel-lighten-1; 78 | } 79 | } 80 | } 81 | """ 82 | 83 | def compose(self) -> ComposeResult: 84 | yield Static("Sessions", id="history-header") 85 | yield ScrollableContainer(id="history-container") 86 | yield Button("New Session", id="new-session") 87 | 88 | def on_show(self) -> None: 89 | """History panel shown.""" 90 | self.query_one("#history-container").scroll_end(animate=False) 91 | 92 | @on(Button.Pressed, "#new-session") 93 | def on_button_pressed(self) -> None: 94 | """Handle button presses.""" 95 | self.post_message(NewSession()) 96 | 97 | @on(DeleteSession) 98 | def on_delete_session(self, event: DeleteSession) -> None: 99 | """Handle session deletion request.""" 100 | item = self.get_history_item_widget(event.session_id) 101 | if item: 102 | item.remove() 103 | 104 | container = self.query_one("#history-container", ScrollableContainer) 105 | for item in container.query(DeleteIcon): 106 | if item.session_id == event.session_id: 107 | item.remove() 108 | break 109 | 110 | def load_sessions(self, sessions: List[UISession], current: str) -> None: 111 | """Load session history from a list of sessions.""" 112 | container = self.query_one("#history-container", ScrollableContainer) 113 | container.remove_children() 114 | 115 | for session in sessions: 116 | history_item = HistoryItem( 117 | session_id=session.id, 118 | detail=session.detail, 119 | created_at=session.updated_at, 120 | ) 121 | delete_icon = DeleteIcon(session_id=session.id) 122 | # Mark current session 123 | if session.id == current: 124 | history_item.selected = True 125 | delete_icon.selected = True 126 | container.mount(history_item, delete_icon) 127 | container.scroll_end() 128 | 129 | def add_session(self, session: UISession) -> None: 130 | """Add a session to the history panel.""" 131 | container = self.query_one("#history-container", ScrollableContainer) 132 | history_item = HistoryItem( 133 | session_id=session.id, 134 | detail=session.detail, 135 | created_at=session.updated_at, 136 | ) 137 | delete_icon = DeleteIcon(session_id=session.id) 138 | container.mount(history_item, delete_icon) 139 | container.scroll_end() 140 | 141 | def set_current_session(self, session_id: str) -> None: 142 | """Set the current selected session.""" 143 | # Update selected state for all items 144 | container = self.query_one("#history-container", ScrollableContainer) 145 | for item in container.query(HistoryItem): 146 | item.selected = item.session_id == session_id 147 | for item in container.query(DeleteIcon): 148 | item.selected = item.session_id == session_id 149 | 150 | def get_history_item_widget(self, session_id: str) -> HistoryItem | None: 151 | """Get a history item widget by session id.""" 152 | container = self.query_one("#history-container", ScrollableContainer) 153 | for item in container.query(HistoryItem): 154 | if item.session_id == session_id: 155 | return item 156 | return None 157 | -------------------------------------------------------------------------------- /dasshh/ui/components/chat/message.py: -------------------------------------------------------------------------------- 1 | from textual.reactive import reactive 2 | from textual.widgets import Static 3 | from rich.markdown import Markdown 4 | from rich.console import Group 5 | from rich.text import Text 6 | from typing import Any 7 | 8 | 9 | class ChatMessage(Static): 10 | """A chat message display component.""" 11 | 12 | DEFAULT_CSS = """ 13 | ChatMessage { 14 | width: 100%; 15 | margin: 1 1; 16 | padding: 0 1; 17 | } 18 | 19 | .user { 20 | border-left: thick $primary; 21 | background: $background-lighten-1; 22 | padding: 1; 23 | margin: 1 5 1 1; 24 | } 25 | 26 | .assistant { 27 | border-left: thick $accent; 28 | background: $background-lighten-2; 29 | padding: 1; 30 | margin: 1 1 1 5; 31 | } 32 | """ 33 | user_icon: str = "󰀄" 34 | # assistant_icon: str = "󱙺" 35 | assistant_icon: str = "🗲" 36 | 37 | content: reactive[str] = reactive("", layout=True) 38 | 39 | def __init__( 40 | self, invocation_id: str, role: str, content: str, *args: Any, **kwargs: Any 41 | ) -> None: 42 | super().__init__(*args, **kwargs) 43 | self.invocation_id = invocation_id 44 | if role == "user": 45 | self.role = "you" 46 | elif role == "assistant": 47 | self.role = "dasshh" 48 | self.content = content 49 | 50 | # Add CSS class based on role 51 | self.add_class(role) 52 | 53 | def render(self): 54 | # Show typing indicator if the message is from assistant but empty 55 | if self.role == "dasshh" and not self.content: 56 | return Text("typing...", style="italic dim") 57 | 58 | role_icon = self.user_icon if self.role == "you" else self.assistant_icon 59 | role_style = "bold #ffff8d" if self.role == "you" else "bold #76ff03" 60 | 61 | title = Text(f"{role_icon} {self.role.capitalize()}", style=role_style) 62 | text = Markdown(self.content) if self.content else Text("") 63 | 64 | return Group(title, text) 65 | -------------------------------------------------------------------------------- /dasshh/ui/components/navbar.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import Container, Horizontal 4 | from textual.widgets import Static 5 | 6 | from dasshh.ui.events import ChangeView 7 | 8 | 9 | class NavItem(Static): 10 | """A navigation item.""" 11 | 12 | DEFAULT_CSS = """ 13 | NavItem { 14 | content-align: center middle; 15 | width: auto; 16 | height: auto; 17 | padding: 0 2; 18 | color: $text-muted; 19 | } 20 | 21 | NavItem:hover { 22 | color: $primary-lighten-1; 23 | } 24 | 25 | NavItem.active { 26 | color: $primary-lighten-1; 27 | text-style: bold; 28 | } 29 | """ 30 | 31 | def __init__(self, route: str, label: str, icon: str = "", **kwargs): 32 | super().__init__(f"{icon} {label}", **kwargs) 33 | self.route = route 34 | 35 | def on_click(self) -> None: 36 | self.add_class("active") 37 | self.post_message(ChangeView(self.route)) 38 | 39 | 40 | class Logo(Static): 41 | """The logo.""" 42 | 43 | DEFAULT_CSS = """ 44 | Logo { 45 | color: $primary; 46 | content-align: center middle; 47 | text-style: italic bold; 48 | width: auto; 49 | height: 100%; 50 | margin-top: -1; 51 | } 52 | 53 | Logo:hover { 54 | color: $primary-darken-1; 55 | } 56 | """ 57 | 58 | txt = """ 59 | ╭──────────╮ 60 | │ Dasshh 🗲 │ 61 | ╰──────────╯ 62 | """ 63 | 64 | def render(self) -> str: 65 | return self.txt 66 | 67 | 68 | class Navbar(Container): 69 | """The Navbar""" 70 | 71 | DEFAULT_CSS = """ 72 | Navbar { 73 | content-align: center middle; 74 | layout: horizontal; 75 | background: $background-lighten-1; 76 | width: 100%; 77 | height: 3; 78 | } 79 | 80 | Navbar > #logo { 81 | width: 1fr; 82 | align: right middle; 83 | } 84 | 85 | Navbar > #nav-items { 86 | width: 1fr; 87 | align: left middle; 88 | } 89 | """ 90 | 91 | def compose(self) -> ComposeResult: 92 | """Create the navbar items.""" 93 | yield Logo(id="logo") 94 | with Horizontal(id="nav-items"): 95 | yield NavItem(route="chat", label="Chat", icon="󰭹", id="chat") 96 | yield NavItem(route="settings", label="Settings", icon="", id="settings") 97 | yield NavItem(route="about", label="About", icon="", id="about") 98 | 99 | def on_mount(self) -> None: 100 | self.query_one("#chat").add_class("active") 101 | 102 | @on(ChangeView) 103 | def change_view(self, event: ChangeView): 104 | for item in self.query("NavItem"): 105 | item.remove_class("active") 106 | self.query_one(f"#{event.view}").add_class("active") 107 | -------------------------------------------------------------------------------- /dasshh/ui/components/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .checkbox import Checkbox 2 | from .settings_section import SettingsSection 3 | from .dasshh_config import DasshhConfig 4 | from .model_config import ModelConfig 5 | 6 | __all__ = [ 7 | "Checkbox", 8 | "SettingsSection", 9 | "DasshhConfig", 10 | "ModelConfig", 11 | ] 12 | -------------------------------------------------------------------------------- /dasshh/ui/components/settings/checkbox.py: -------------------------------------------------------------------------------- 1 | from textual.widgets._checkbox import Checkbox as TextualCheckbox 2 | from textual.content import Content 3 | 4 | 5 | class Checkbox(TextualCheckbox): 6 | """A checkbox widget that displays a checkmark or cross.""" 7 | 8 | DEFAULT_CSS = """ 9 | Checkbox { 10 | & > .toggle--button { 11 | color: $secondary; 12 | background: $background; 13 | } 14 | 15 | &.-on > .toggle--button { 16 | color: $primary; 17 | background: $background; 18 | } 19 | } 20 | """ 21 | 22 | checked_label = "✓" 23 | unchecked_label = "✗" 24 | 25 | def render(self) -> Content: 26 | """Render the content of the widget.""" 27 | button_style = self.get_visual_style("toggle--button") 28 | label_style = self.get_visual_style("toggle--label") 29 | label = self._label.stylize_before(label_style) 30 | spacer = " " if label else "" 31 | 32 | if self.value: 33 | button = (self.checked_label, button_style) 34 | else: 35 | button = (self.unchecked_label, button_style) 36 | 37 | if self._button_first: 38 | content = Content.assemble(button, spacer, label) 39 | else: 40 | content = Content.assemble(label, spacer, button) 41 | return content 42 | -------------------------------------------------------------------------------- /dasshh/ui/components/settings/dasshh_config.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.widgets import Static, Input, Select 3 | from textual.validation import Regex 4 | from textual import on 5 | 6 | from .settings_section import SettingsSection 7 | from .checkbox import Checkbox 8 | 9 | 10 | class DasshhConfig(SettingsSection): 11 | """Dasshh Configuration section component.""" 12 | 13 | def __init__(self, **kwargs): 14 | super().__init__("Dasshh Configuration", **kwargs) 15 | 16 | def compose(self) -> ComposeResult: 17 | yield from super().compose() 18 | 19 | yield Checkbox(id="skip-summarization", label="Skip Summarization") 20 | 21 | yield Static("System Prompt:") 22 | yield Input(placeholder="Custom system prompt...", id="system-prompt", valid_empty=True) 23 | 24 | yield Static("Theme:") 25 | yield Select([("lime", "lime")], prompt="Select theme", id="theme", allow_blank=False) 26 | 27 | yield Static("Tool Directories:") 28 | yield Input( 29 | placeholder="Comma-separated paths (e.g., /path/to/tool1,/path/to/tool2)", 30 | id="tool-directories", 31 | type="text", 32 | validators=[ 33 | Regex( 34 | r"^[a-zA-Z0-9/_-]+(,[a-zA-Z0-9/_-]+)*$", 35 | failure_description="Tool directories must be valid comma-separated paths", 36 | ) 37 | ], 38 | valid_empty=True 39 | ) 40 | 41 | @on(Checkbox.Changed, "#skip-summarization") 42 | def on_skip_summarization_changed(self, event: Checkbox.Changed) -> None: 43 | settings_widget = self.parent.parent 44 | if hasattr(settings_widget, 'skip_summarization'): 45 | settings_widget.skip_summarization = event.value 46 | 47 | @on(Input.Changed, "#system-prompt") 48 | def on_system_prompt_changed(self, event: Input.Changed) -> None: 49 | settings_widget = self.parent.parent 50 | if hasattr(settings_widget, 'system_prompt'): 51 | settings_widget.system_prompt = event.value if event.value else None 52 | 53 | @on(Select.Changed, "#theme") 54 | def on_theme_changed(self, event: Select.Changed) -> None: 55 | settings_widget = self.parent.parent 56 | if hasattr(settings_widget, 'app'): 57 | settings_widget.app.theme = event.value 58 | 59 | @on(Input.Changed, "#tool-directories") 60 | def on_tool_directories_changed(self, event: Input.Changed) -> None: 61 | settings_widget = self.parent.parent 62 | if event.validation_result.is_valid: 63 | if hasattr(settings_widget, 'tool_directories'): 64 | settings_widget.tool_directories = event.value if event.value else None 65 | else: 66 | if hasattr(settings_widget, 'notify'): 67 | failure_descriptions = "\n".join(event.validation_result.failure_descriptions) 68 | settings_widget.notify(failure_descriptions, severity="error", timeout=5) 69 | -------------------------------------------------------------------------------- /dasshh/ui/components/settings/model_config.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.widgets import Static, Input 3 | from textual.validation import Number, Function 4 | from textual import on 5 | 6 | from .settings_section import SettingsSection 7 | 8 | 9 | class ModelConfig(SettingsSection): 10 | """Model Configuration section component.""" 11 | 12 | def __init__(self, **kwargs): 13 | super().__init__("Model Configuration", **kwargs) 14 | 15 | def compose(self) -> ComposeResult: 16 | yield from super().compose() 17 | 18 | yield Static("Model Name:") 19 | yield Input( 20 | placeholder="e.g., gemini/gemini-2.0-flash", 21 | id="model-name", 22 | validators=[ 23 | Function(lambda value: value is not None and value.strip()), 24 | ], 25 | valid_empty=False 26 | ) 27 | 28 | yield Static("API Base:") 29 | yield Input(placeholder="API base URL (optional)", id="api-base", valid_empty=True) 30 | 31 | yield Static("API Key:") 32 | yield Input( 33 | placeholder="Your API key", password=True, id="api-key", 34 | validators=[ 35 | Function(lambda value: value is not None and value.strip()), 36 | ], 37 | valid_empty=False 38 | ) 39 | 40 | yield Static("API Version:") 41 | yield Input(placeholder="API version (optional)", id="api-version", valid_empty=True) 42 | 43 | yield Static("Temperature:") 44 | yield Input( 45 | placeholder="0.0 - 1.0 (default: 1.0)", 46 | id="temperature", 47 | type="number", 48 | validators=[ 49 | Number( 50 | minimum=0.0, 51 | maximum=1.0, 52 | failure_description="Temperature must be a value in the range 0.0 to 1.0", 53 | ) 54 | ], 55 | valid_empty=False 56 | ) 57 | 58 | yield Static("Top P:") 59 | yield Input( 60 | placeholder="0.0 - 1.0 (default: 1.0)", 61 | id="top-p", 62 | type="number", 63 | validators=[ 64 | Number( 65 | minimum=0.0, 66 | maximum=1.0, 67 | failure_description="Top P must be a value in the range 0.0 to 1.0", 68 | ) 69 | ], 70 | valid_empty=False 71 | ) 72 | 73 | yield Static("Max Tokens:") 74 | yield Input( 75 | placeholder="Maximum tokens (optional)", 76 | id="max-tokens", 77 | type="integer", 78 | valid_empty=True 79 | ) 80 | 81 | yield Static("Max Completion Tokens:") 82 | yield Input( 83 | placeholder="Maximum completion tokens (optional)", 84 | id="max-completion-tokens", 85 | type="integer", 86 | valid_empty=True 87 | ) 88 | 89 | @on(Input.Changed, "#model-name") 90 | def on_model_name_changed(self, event: Input.Changed) -> None: 91 | settings_widget = self.parent.parent 92 | if event.validation_result.is_valid and event.value: 93 | if hasattr(settings_widget, 'model_name'): 94 | settings_widget.model_name = event.value 95 | 96 | if not event.validation_result.is_valid: 97 | if hasattr(settings_widget, 'notify'): 98 | settings_widget.notify("Model name is required", severity="error", timeout=5) 99 | 100 | @on(Input.Changed, "#api-base") 101 | def on_api_base_changed(self, event: Input.Changed) -> None: 102 | settings_widget = self.parent.parent 103 | if hasattr(settings_widget, 'api_base'): 104 | settings_widget.api_base = event.value if event.value else None 105 | 106 | @on(Input.Changed, "#api-key") 107 | def on_api_key_changed(self, event: Input.Changed) -> None: 108 | settings_widget = self.parent.parent 109 | if event.validation_result.is_valid and event.value: 110 | if hasattr(settings_widget, 'api_key'): 111 | settings_widget.api_key = event.value 112 | 113 | if not event.validation_result.is_valid: 114 | if hasattr(settings_widget, 'notify'): 115 | settings_widget.notify("API key is required", severity="error", timeout=5) 116 | 117 | @on(Input.Changed, "#api-version") 118 | def on_api_version_changed(self, event: Input.Changed) -> None: 119 | settings_widget = self.parent.parent 120 | if hasattr(settings_widget, 'api_version'): 121 | settings_widget.api_version = event.value if event.value else None 122 | 123 | @on(Input.Changed, "#temperature") 124 | def on_temperature_changed(self, event: Input.Changed) -> None: 125 | settings_widget = self.parent.parent 126 | if event.validation_result.is_valid and event.value: 127 | if hasattr(settings_widget, 'temperature'): 128 | settings_widget.temperature = float(event.value) 129 | 130 | if not event.validation_result.is_valid: 131 | if hasattr(settings_widget, 'notify'): 132 | settings_widget.notify( 133 | "Temperature must be a value in the range 0.0 to 1.0", 134 | severity="error", 135 | timeout=5 136 | ) 137 | 138 | @on(Input.Changed, "#top-p") 139 | def on_top_p_changed(self, event: Input.Changed) -> None: 140 | settings_widget = self.parent.parent 141 | if event.validation_result.is_valid and event.value: 142 | if hasattr(settings_widget, 'top_p'): 143 | settings_widget.top_p = float(event.value) 144 | 145 | if not event.validation_result.is_valid: 146 | if hasattr(settings_widget, 'notify'): 147 | settings_widget.notify( 148 | "Top P must be a value in the range 0.0 to 1.0", 149 | severity="error", 150 | timeout=5 151 | ) 152 | 153 | @on(Input.Changed, "#max-tokens") 154 | def on_max_tokens_changed(self, event: Input.Changed) -> None: 155 | settings_widget = self.parent.parent 156 | if hasattr(settings_widget, 'max_tokens'): 157 | settings_widget.max_tokens = int(event.value) if event.value else None 158 | 159 | @on(Input.Changed, "#max-completion-tokens") 160 | def on_max_completion_tokens_changed(self, event: Input.Changed) -> None: 161 | settings_widget = self.parent.parent 162 | if hasattr(settings_widget, 'max_completion_tokens'): 163 | settings_widget.max_completion_tokens = int(event.value) if event.value else None 164 | -------------------------------------------------------------------------------- /dasshh/ui/components/settings/settings_section.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Container 3 | from textual.widgets import Label 4 | 5 | 6 | class SettingsSection(Container): 7 | """A section container for grouping related settings.""" 8 | 9 | DEFAULT_CSS = """ 10 | SettingsSection { 11 | layout: vertical; 12 | width: 100%; 13 | height: auto; 14 | padding: 2; 15 | border: round $secondary; 16 | } 17 | 18 | SettingsSection > .section-title { 19 | text-style: bold; 20 | color: $primary; 21 | margin-top: 0; 22 | margin-bottom: 1; 23 | dock: top; 24 | } 25 | 26 | SettingsSection > Static { 27 | margin-top: 1; 28 | margin-left: 1; 29 | } 30 | 31 | SettingsSection > Input, 32 | SettingsSection > Checkbox, 33 | SettingsSection > Select { 34 | border: round $secondary; 35 | background: $background; 36 | 37 | &:focus, &:hover { 38 | border: round $primary; 39 | background: $background; 40 | background-tint: $background; 41 | } 42 | 43 | &:disabled { 44 | border: round $panel-darken-1; 45 | color: $text-muted; 46 | } 47 | 48 | &.-invalid, &.-invalid:focus { 49 | border: round $error; 50 | background: $background; 51 | background-tint: $background; 52 | } 53 | } 54 | 55 | SettingsSection > Input { 56 | width: 75%; 57 | } 58 | 59 | SettingsSection > Checkbox { 60 | width: 25%; 61 | margin-left: 0; 62 | margin-top: 1; 63 | } 64 | 65 | SettingsSection > Select { 66 | width: 75%; 67 | 68 | & > SelectCurrent, &:focus > SelectCurrent { 69 | border: none; 70 | background: $background; 71 | background-tint: $background; 72 | } 73 | 74 | & > SelectOverlay { 75 | border: round $primary; 76 | background: $background; 77 | background-tint: $background; 78 | scrollbar-color: $secondary $background; 79 | scrollbar-background: $background; 80 | scrollbar-corner-color: $background; 81 | scrollbar-size: 1 1; 82 | scrollbar-gutter: stable; 83 | } 84 | } 85 | """ 86 | 87 | def __init__(self, title: str, **kwargs): 88 | super().__init__(**kwargs) 89 | self.title = title 90 | 91 | def compose(self) -> ComposeResult: 92 | yield Label(self.title, classes="section-title") 93 | -------------------------------------------------------------------------------- /dasshh/ui/events.py: -------------------------------------------------------------------------------- 1 | from textual.message import Message 2 | 3 | 4 | # -- Main screen events -- # 5 | 6 | 7 | class ChangeView(Message): 8 | """Change the view on the main screen.""" 9 | 10 | def __init__(self, view: str): 11 | super().__init__() 12 | self.view = view 13 | 14 | 15 | # -- Chat events -- # 16 | 17 | 18 | class NewMessage(Message): 19 | """Send a message to the chat.""" 20 | 21 | def __init__(self, message: str): 22 | super().__init__() 23 | self.message = message 24 | 25 | 26 | class NewSession(Message): 27 | """Create a new session.""" 28 | 29 | def __init__(self): 30 | super().__init__() 31 | 32 | 33 | class LoadSession(Message): 34 | """Load a previous session.""" 35 | 36 | def __init__(self, session_id: str): 37 | super().__init__() 38 | self.session_id = session_id 39 | 40 | 41 | class DeleteSession(Message): 42 | """Delete an existing session.""" 43 | 44 | def __init__(self, session_id: str): 45 | super().__init__() 46 | self.session_id = session_id 47 | 48 | 49 | # -- Agent runtime events -- # 50 | 51 | 52 | class AssistantResponseStart(Message): 53 | """Event triggered before agent starts processing a query.""" 54 | 55 | def __init__(self, invocation_id: str): 56 | super().__init__() 57 | self.invocation_id = invocation_id 58 | 59 | 60 | class AssistantResponseUpdate(Message): 61 | """Event triggered when agent returns a partial response.""" 62 | 63 | def __init__(self, invocation_id: str, content: str): 64 | super().__init__() 65 | self.invocation_id = invocation_id 66 | self.content = content 67 | 68 | 69 | class AssistantResponseComplete(Message): 70 | """Event triggered when agent completes processing a query.""" 71 | 72 | def __init__(self, invocation_id: str, content: str): 73 | super().__init__() 74 | self.invocation_id = invocation_id 75 | self.content = content 76 | 77 | 78 | class AssistantResponseError(Message): 79 | """Event triggered when agent encounters an error.""" 80 | 81 | def __init__(self, invocation_id: str, error: str): 82 | super().__init__() 83 | self.invocation_id = invocation_id 84 | self.error = error 85 | 86 | 87 | class AssistantToolCallStart(Message): 88 | """Event triggered when agent starts a tool call.""" 89 | 90 | def __init__( 91 | self, invocation_id: str, tool_call_id: str, tool_name: str, args: str 92 | ): 93 | super().__init__() 94 | self.invocation_id = invocation_id 95 | self.tool_call_id = tool_call_id 96 | self.tool_name = tool_name 97 | self.args = args 98 | 99 | 100 | class AssistantToolCallComplete(Message): 101 | """Event triggered when agent completes a tool call.""" 102 | 103 | def __init__( 104 | self, invocation_id: str, tool_call_id: str, tool_name: str, result: str 105 | ): 106 | super().__init__() 107 | self.invocation_id = invocation_id 108 | self.tool_call_id = tool_call_id 109 | self.tool_name = tool_name 110 | self.result = result 111 | 112 | 113 | class AssistantToolCallError(Message): 114 | """Event triggered when agent encounters an error during a tool call.""" 115 | 116 | def __init__( 117 | self, invocation_id: str, tool_call_id: str, tool_name: str, error: str 118 | ): 119 | super().__init__() 120 | self.invocation_id = invocation_id 121 | self.tool_call_id = tool_call_id 122 | self.tool_name = tool_name 123 | self.error = error 124 | -------------------------------------------------------------------------------- /dasshh/ui/screens/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/dasshh/ui/screens/__init__.py -------------------------------------------------------------------------------- /dasshh/ui/screens/help.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Static 2 | from textual.app import ComposeResult 3 | from textual.widget import Widget 4 | 5 | 6 | class Help(Widget): 7 | def compose(self) -> ComposeResult: 8 | yield Static("Help") 9 | -------------------------------------------------------------------------------- /dasshh/ui/screens/main.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.screen import Screen 3 | from textual.widgets import ContentSwitcher 4 | 5 | from dasshh.ui.events import ChangeView 6 | from dasshh.ui.views import Chat, Settings, About 7 | from dasshh.ui.components.navbar import Navbar 8 | 9 | 10 | class MainScreen(Screen): 11 | """Main screen""" 12 | 13 | DEFAULT_CSS = """ 14 | MainScreen { 15 | layout: vertical; 16 | height: 1fr; 17 | width: 1fr; 18 | overflow: hidden; 19 | } 20 | 21 | ContentSwitcher { 22 | align: center middle; 23 | padding: 0 0 4 0; 24 | } 25 | """ 26 | 27 | def compose(self): 28 | yield Navbar() 29 | with ContentSwitcher(id="content", initial="chat"): 30 | yield Chat(id="chat") 31 | yield Settings(id="settings") 32 | yield About(id="about") 33 | 34 | @on(ChangeView) 35 | def change_view(self, event: ChangeView): 36 | self.query_one(ContentSwitcher).current = event.view 37 | -------------------------------------------------------------------------------- /dasshh/ui/themes/__init__.py: -------------------------------------------------------------------------------- 1 | from .lime import lime 2 | 3 | __all__ = ["lime"] 4 | -------------------------------------------------------------------------------- /dasshh/ui/themes/lime.py: -------------------------------------------------------------------------------- 1 | from textual.theme import Theme 2 | 3 | lime = Theme( 4 | name="lime", 5 | primary="#76ff03", 6 | secondary="#64dd17", 7 | accent="#4caf50", 8 | foreground="#f0f0f0", 9 | background="#101a10", 10 | success="#b9f6ca", 11 | warning="#ffff8d", 12 | error="#ff8a80", 13 | surface="#162316", 14 | panel="#1e2a1e", 15 | dark=True, 16 | variables={ 17 | "footer-key-foreground": "#76ff03", 18 | "input-selection-background": "#64dd1740", 19 | "block-cursor-background": "#76ff03", 20 | "block-cursor-foreground": "#101a10", 21 | "border": "#76ff03", 22 | "scrollbar": "#4caf50", 23 | "scrollbar-hover": "#64dd17", 24 | "scrollbar-active": "#76ff03", 25 | "link-color": "#9cff57", 26 | "link-color-hover": "#76ff03", 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /dasshh/ui/types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal, List 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class UIMessage(BaseModel): 7 | """A message to be displayed in the UI.""" 8 | 9 | invocation_id: str = Field("", description="The invocation id of the message.") 10 | role: Literal["user", "assistant"] = Field( 11 | ..., description="The role of the message." 12 | ) 13 | content: str = Field(..., description="The content of the message.") 14 | 15 | 16 | class UIAction(BaseModel): 17 | """An action to be displayed in the UI.""" 18 | 19 | tool_call_id: str = Field(..., description="The tool call id of the action.") 20 | invocation_id: str = Field(..., description="The invocation id of the action.") 21 | name: str = Field(..., description="The name of the action.") 22 | args: str = Field( 23 | ..., 24 | description="The arguments of the action. This has to be a JSON string with indent=2.", 25 | ) 26 | result: str = Field( 27 | ..., 28 | description="The result of the action. This has to be a JSON string with indent=2.", 29 | ) 30 | 31 | 32 | class UISession(BaseModel): 33 | """A session to be displayed in the UI.""" 34 | 35 | id: str = Field(..., description="The id of the session.") 36 | detail: str = Field(..., description="The detail of the session.") 37 | created_at: datetime = Field(..., description="The creation time of the session.") 38 | updated_at: datetime = Field( 39 | ..., description="The last update time of the session." 40 | ) 41 | messages: List[UIMessage] = Field(..., description="The messages of the session.") 42 | actions: List[UIAction] = Field(..., description="The actions of the session.") 43 | -------------------------------------------------------------------------------- /dasshh/ui/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import yaml 5 | from pathlib import Path 6 | from typing import List 7 | from importlib import import_module 8 | import importlib.resources as pkg_resources 9 | 10 | from dasshh.data.models import StorageSession, StorageEvent 11 | from dasshh.ui.types import ( 12 | UISession, 13 | UIMessage, 14 | UIAction, 15 | ) 16 | from dasshh.core.logging import get_logger 17 | 18 | logger = get_logger(__name__) 19 | 20 | 21 | DEFAULT_CONFIG_PATH = Path.home() / ".dasshh" / "config.yaml" 22 | try: 23 | DASSHH_EXEC_PATH = str(Path(pkg_resources.files("dasshh"))) 24 | except (ImportError, TypeError): 25 | DASSHH_EXEC_PATH = str(Path(__file__).parent.parent) 26 | 27 | DEFAULT_TOOLS_PATH = str(Path(DASSHH_EXEC_PATH) / "apps") 28 | 29 | DEFAULT_CONFIG = f""" 30 | dasshh: 31 | skip_summarization: false 32 | system_prompt: 33 | tool_directories: 34 | - {DEFAULT_TOOLS_PATH} 35 | theme: lime 36 | 37 | model: 38 | name: gemini/gemini-2.0-flash 39 | api_base: 40 | api_key: 41 | api_version: 42 | temperature: 1.0 43 | top_p: 1.0 44 | max_tokens: 45 | max_completion_tokens: 46 | """ 47 | 48 | 49 | def convert_session_obj( 50 | session_obj: StorageSession, events: List[StorageEvent] | None = None 51 | ) -> UISession: 52 | messages, actions = [], {} 53 | if events: 54 | for event in events: 55 | invocation_id = event.invocation_id 56 | content = event.content 57 | if content["role"] == "assistant" and "tool_calls" in content: 58 | for tool_call in content["tool_calls"]: 59 | tool_call_id = tool_call["id"] 60 | args = json.dumps( 61 | json.loads(tool_call["function"]["arguments"]), indent=2 62 | ) 63 | actions[tool_call_id] = UIAction( 64 | invocation_id=invocation_id, 65 | tool_call_id=tool_call_id, 66 | name=tool_call["function"]["name"], 67 | args=args, 68 | result="", 69 | ) 70 | elif content["role"] == "tool": 71 | tool_call_id = content["tool_call_id"] 72 | actions[tool_call_id].result = content["content"] 73 | elif content["role"] in ["user", "assistant"]: 74 | messages.append( 75 | UIMessage( 76 | invocation_id=invocation_id, 77 | role=content["role"], 78 | content=content["content"], 79 | ) 80 | ) 81 | return UISession( 82 | id=session_obj.id, 83 | detail=session_obj.detail, 84 | created_at=session_obj.created_at, 85 | updated_at=session_obj.updated_at, 86 | messages=messages, 87 | actions=list(actions.values()), 88 | ) 89 | 90 | 91 | def load_tools() -> None: 92 | """ 93 | Load all tools from the given directories recursively. 94 | 95 | Args: 96 | dirs: A list of directory paths to load tools from (absolute file paths). 97 | If None, will use paths from config or fall back to default. 98 | """ 99 | tool_dirs_config = get_from_config("dasshh.tool_directories") 100 | if tool_dirs_config: 101 | dirs = tool_dirs_config 102 | else: 103 | dirs = [DEFAULT_TOOLS_PATH] 104 | 105 | for dir_path in dirs: 106 | if os.path.exists(dir_path): 107 | for root, dirs, files in os.walk(dir_path): 108 | if "__init__.py" in files: 109 | # Determine full module path 110 | rel_path = os.path.relpath(root, dir_path) 111 | if rel_path == ".": 112 | module_path = os.path.basename(dir_path) 113 | module_parent = os.path.dirname(dir_path) 114 | else: 115 | module_path = rel_path.replace(os.sep, ".") 116 | module_parent = dir_path 117 | 118 | if module_parent not in sys.path: 119 | sys.path.append(module_parent) 120 | 121 | try: 122 | import_module(module_path) 123 | logger.info(f"Imported module: {module_path}") 124 | except ImportError as e: 125 | logger.error(f"Failed to import {module_path}: {e}") 126 | 127 | 128 | def load_config() -> dict: 129 | """Load the configuration file.""" 130 | if DEFAULT_CONFIG_PATH.exists(): 131 | return yaml.safe_load(DEFAULT_CONFIG_PATH.read_text()) 132 | 133 | DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 134 | DEFAULT_CONFIG_PATH.write_text(DEFAULT_CONFIG) 135 | return yaml.safe_load(DEFAULT_CONFIG) 136 | 137 | 138 | def get_from_config(key: str) -> dict | str | List | None: 139 | """Get a value from the configuration file.""" 140 | if not DEFAULT_CONFIG_PATH.exists(): 141 | return None 142 | 143 | with open(DEFAULT_CONFIG_PATH, "r") as f: 144 | config = yaml.safe_load(f) 145 | 146 | if "." in key: 147 | parts = key.split(".") 148 | curr = config 149 | for part in parts: 150 | if curr is None or part not in curr: 151 | return None 152 | curr = curr.get(part) 153 | return curr 154 | 155 | return config.get(key, None) 156 | -------------------------------------------------------------------------------- /dasshh/ui/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat import Chat 2 | from .settings import Settings 3 | from .about import About 4 | 5 | __all__ = ["Chat", "Settings", "About"] 6 | -------------------------------------------------------------------------------- /dasshh/ui/views/about.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.widgets import Static 3 | from textual.widget import Widget 4 | from textual.containers import ScrollableContainer 5 | 6 | 7 | class About(Widget): 8 | """About""" 9 | 10 | DEFAULT_CSS = """ 11 | About { 12 | layout: vertical; 13 | height: 1fr; 14 | width: 1fr; 15 | align: center middle; 16 | padding: 2 4; 17 | } 18 | 19 | .about-content { 20 | text-align: center; 21 | content-align: center middle; 22 | padding: 1; 23 | width: 90%; 24 | max-width: 100; 25 | } 26 | """ 27 | 28 | def compose(self) -> ComposeResult: 29 | with ScrollableContainer(classes="about-content"): 30 | yield Static( 31 | "Dasshh 🗲 is your friendly assistant built right into the terminal - " 32 | "where you spend most of your time.\n\n" 33 | "Designed to reduce cognitive load, Dasshh handles repetitive tasks " 34 | "on your behalf so that you can focus on what truly matters.\n\n" 35 | "The Goal: Prompt to Action!\n\n" 36 | "Note: This project is under active development. Contributions are welcome!\n\n" 37 | "Star it to show your support: https://github.com/vgnshiyer/dasshh", 38 | ) 39 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Dasshh Documentation 2 | 3 | This directory contains the documentation for the Dasshh project, built with [MkDocs](https://www.mkdocs.org/) and the [Material theme](https://squidfunk.github.io/mkdocs-material/). 4 | 5 | ## Running the documentation locally 6 | 7 | ```bash 8 | # Install dependencies 9 | uv pip install mkdocs-material 10 | 11 | # Serve the documentation 12 | mkdocs serve 13 | ``` 14 | 15 | Then visit [http://localhost:8000](http://localhost:8000) in your browser. 16 | 17 | ## Building the documentation 18 | 19 | ```bash 20 | mkdocs build 21 | ``` 22 | 23 | This will create a `site` directory with the static HTML files. 24 | 25 | ## Deploying the documentation 26 | 27 | The documentation is automatically deployed to GitHub Pages when changes are pushed to the main branch. 28 | 29 | You can also manually deploy the documentation by running: 30 | 31 | ```bash 32 | mkdocs gh-deploy 33 | ``` -------------------------------------------------------------------------------- /docs/api/tools.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | This page lays out all the components available in the tools module. 4 | 5 | !!! tip 6 | Tools are the core mechanism for extending Dasshh's capabilities. Use the `@tool` decorator to convert functions into tools that the AI assistant can use. 7 | 8 | 9 | 10 | ## BaseTool 11 | 12 | Abstract base class for all tools in Dasshh. 13 | 14 | ### `attr` name 15 | 16 | The name of the tool 17 | 18 | ```python 19 | name: str 20 | ``` 21 | 22 | ### `attr` description 23 | 24 | The description of the tool 25 | 26 | ```python 27 | description: str 28 | ``` 29 | 30 | ### `attr` parameters 31 | 32 | The parameters of the tool 33 | 34 | ```python 35 | parameters: dict 36 | ``` 37 | 38 | ### `method` __init__ 39 | 40 | ```python 41 | __init__(name: str, description: str, parameters: dict) 42 | ``` 43 | 44 | Initialize a new BaseTool instance 45 | 46 | **Parameters:** 47 | 48 | | Param |
Default
| Description | 49 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 50 | | name | | The name of the tool | 51 | | description | | The description of what the tool does | 52 | | parameters | | Dictionary containing the tool's parameters | 53 | 54 | ### `method` __call__ 55 | 56 | ```python 57 | __call__(*args, **kwargs) 58 | ``` 59 | 60 | Execute the tool with the given arguments 61 | 62 | **Parameters:** 63 | 64 | | Param |
Default
| Description | 65 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 66 | | *args | | Positional arguments to pass to the tool | 67 | | **kwargs | | Keyword arguments to pass to the tool | 68 | 69 | **Raises:** 70 | 71 | | Type |
Default
| Description | 72 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 73 | | NotImplementedError | | If the tool has no implementation | 74 | 75 | ### `method` get_declaration 76 | 77 | ```python 78 | get_declaration() -> dict 79 | ``` 80 | 81 | Get the declaration of the tool for the AI model 82 | 83 | **Returns:** 84 | 85 | | Type |
Default
| Description | 86 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 87 | | dict | | Tool declaration formatted for the AI model | 88 | 89 | **Raises:** 90 | 91 | | Type |
Default
| Description | 92 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 93 | | NotImplementedError | | If the tool has no implementation | 94 | 95 | 96 | 97 | ## FunctionTool 98 | 99 | A concrete implementation of BaseTool that wraps Python functions. 100 | 101 | ### `attr` func 102 | 103 | The function wrapped by this tool 104 | 105 | ```python 106 | func: Callable = None 107 | ``` 108 | 109 | ### `method` __init__ 110 | 111 | ```python 112 | __init__(name: str, description: str, parameters: dict, func: Callable = None) 113 | ``` 114 | 115 | Initialize a new FunctionTool instance 116 | 117 | **Parameters:** 118 | 119 | | Param |
Default
| Description | 120 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 121 | | name | | The name of the tool | 122 | | description | | The description of what the tool does | 123 | | parameters | | Dictionary containing the tool's parameters | 124 | | func | None | The callable function to wrap | 125 | 126 | ### `method` __call__ 127 | 128 | ```python 129 | __call__(*args, **kwargs) 130 | ``` 131 | 132 | Execute the wrapped function with the given arguments 133 | 134 | **Parameters:** 135 | 136 | | Param |
Default
| Description | 137 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 138 | | *args | | Positional arguments to pass to the function | 139 | | **kwargs | | Keyword arguments to pass to the function | 140 | 141 | **Returns:** 142 | 143 | | Type |
Default
| Description | 144 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 145 | | Any | | The return value of the wrapped function | 146 | 147 | **Raises:** 148 | 149 | | Type |
Default
| Description | 150 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 151 | | NotImplementedError | | If no function is set | 152 | 153 | ### `method` get_declaration 154 | 155 | ```python 156 | get_declaration() -> dict 157 | ``` 158 | 159 | Get the declaration of the tool formatted for the AI model 160 | 161 | **Returns:** 162 | 163 | | Type |
Default
| Description | 164 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 165 | | dict | | Tool declaration using litellm's function_to_dict format | 166 | 167 | 168 | 169 | ## @tool Decorator 170 | 171 | The main decorator for creating tools in Dasshh. 172 | 173 | ### `decorator` tool 174 | 175 | ```python 176 | @tool 177 | def your_function(): 178 | pass 179 | ``` 180 | 181 | Convert a function into a tool and register it with Dasshh 182 | 183 | **Usage:** 184 | 185 | ```python 186 | from dasshh.core.tools.decorator import tool 187 | from typing import Dict 188 | 189 | @tool 190 | def hello_world(name: str = "World") -> Dict: 191 | """ 192 | A simple greeting tool. 193 | 194 | Args: 195 | name (str, optional): The name to greet. Defaults to "World". 196 | 197 | Returns: 198 | Dict: A dictionary containing the greeting message. 199 | """ 200 | return {"message": f"Hello, {name}!"} 201 | ``` 202 | 203 | **Parameters:** 204 | 205 | | Param |
Default
| Description | 206 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 207 | | func | | The function to convert into a tool | 208 | 209 | **Returns:** 210 | 211 | | Type |
Default
| Description | 212 | | ------------- | :----------------: | :----------------------------------------------------------------------------------------| 213 | | FunctionTool | | A FunctionTool instance wrapping the original function | 214 | 215 | **Notes:** 216 | 217 | - The decorator automatically extracts the function name as the tool name 218 | - The function's docstring becomes the tool description 219 | - Function annotations are used to define tool parameters 220 | - The tool is automatically registered with the global Registry 221 | 222 | !!! tip 223 | Checkout this [guide](../guide/own-tools.md) to build your own tools. 224 | -------------------------------------------------------------------------------- /docs/assets/actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/actions.png -------------------------------------------------------------------------------- /docs/assets/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/chat.png -------------------------------------------------------------------------------- /docs/assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/demo.png -------------------------------------------------------------------------------- /docs/assets/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/demo2.gif -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/feature1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/feature1.gif -------------------------------------------------------------------------------- /docs/assets/feature2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/feature2.gif -------------------------------------------------------------------------------- /docs/assets/feature3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/feature3.gif -------------------------------------------------------------------------------- /docs/assets/feature4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/feature4.gif -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/sessions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/sessions.png -------------------------------------------------------------------------------- /docs/assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/settings.png -------------------------------------------------------------------------------- /docs/assets/tools.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgnshiyer/dasshh/a025e0672393df30a87fa7d515c3287be6cf0361/docs/assets/tools.gif -------------------------------------------------------------------------------- /docs/contributing/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to dasshh 2 | 3 | Thank you for your interest in contributing to dasshh! 🎉 4 | 5 | This guide will help you get started with contributing to the project. 6 | 7 | ## Table of Contents 8 | 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Getting Started](#getting-started) 11 | - [Development Setup](#development-setup) 12 | - [Development Workflow](#development-workflow) 13 | - [Testing](#testing) 14 | - [Code Style](#code-style) 15 | - [Pull Request Process](#pull-request-process) 16 | - [Issue Reporting](#issue-reporting) 17 | - [Documentation](#documentation) 18 | 19 | ## Code of Conduct 20 | 21 | By participating in this project, you agree to abide by our Code of Conduct. Please be respectful and constructive in all interactions. 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | - Python 3.13 or higher 28 | - [uv](https://docs.astral.sh/uv/) package manager 29 | - Git 30 | 31 | ### Fork and Clone 32 | 33 | 1. Fork the repository on GitHub 34 | 2. Clone your fork locally: 35 | ```bash 36 | git clone https://github.com/YOUR-USERNAME/dasshh.git 37 | cd dasshh 38 | ``` 39 | 40 | ## Development Setup 41 | 42 | 1. **Install uv** (if not already installed): 43 | ```bash 44 | curl -LsSf https://astral.sh/uv/install.sh | sh 45 | ``` 46 | 47 | 2. **Create and activate virtual environment**: 48 | ```bash 49 | uv venv 50 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 51 | ``` 52 | 53 | 3. **Install development dependencies**: 54 | ```bash 55 | uv sync 56 | ``` 57 | 58 | 4. **Running the application**: 59 | ```bash 60 | python -m dasshh 61 | ``` 62 | 63 | ## Development Workflow 64 | 65 | ### Branch Strategy 66 | 67 | - `main` - Production-ready code 68 | - `feature/feature-name` - New features 69 | - `bugfix/issue-description` - Bug fixes 70 | - `docs/update-description` - Documentation updates 71 | 72 | ### Making Changes 73 | 74 | 1. **Create a new branch**: 75 | ```bash 76 | git checkout -b feature/your-feature-name 77 | ``` 78 | 79 | 2. **Make your changes** following the project conventions 80 | 81 | 3. **Run tests locally**: 82 | ```bash 83 | uv run pytest tests/ -v --cov=dasshh --cov-report=html 84 | ``` 85 | 86 | 4. **Run linting** (if configured): 87 | ```bash 88 | uv pip install ruff 89 | ruff check . 90 | ruff format . 91 | ``` 92 | 93 | 5. **Commit your changes**: 94 | ```bash 95 | git add . 96 | git commit -m "feat: add your feature description" 97 | ``` 98 | 99 | ### Commit Message Convention 100 | 101 | We follow conventional commit format: 102 | 103 | - `feat:` - New features 104 | - `fix:` - Bug fixes 105 | - `docs:` - Documentation changes 106 | - `test:` - Test additions or modifications 107 | - `refactor:` - Code refactoring 108 | - `chore:` - Maintenance tasks 109 | 110 | Examples: 111 | ``` 112 | feat: add new command for file analysis 113 | fix: resolve issue with terminal output formatting 114 | docs: update installation instructions 115 | test: add unit tests for config parser 116 | ``` 117 | 118 | ## Testing 119 | 120 | ### Running Tests 121 | 122 | ```bash 123 | # Run all tests 124 | uv run pytest 125 | 126 | # Run tests with coverage 127 | uv run pytest --cov=dasshh --cov-report=html 128 | 129 | # Run specific test file 130 | uv run pytest tests/unit/test_specific.py 131 | 132 | # Run tests in verbose mode 133 | uv run pytest -v 134 | ``` 135 | 136 | ### Writing Tests 137 | 138 | - Place tests in the `tests/` directory 139 | - Use descriptive test names: `test_should_parse_config_when_valid_file_provided` 140 | - Follow AAA pattern: Arrange, Act, Assert 141 | - Mock external dependencies 142 | 143 | Example test structure: 144 | ```python 145 | def test_should_process_command_when_valid_input_provided(): 146 | # Arrange 147 | command = "test command" 148 | expected_result = "processed" 149 | 150 | # Act 151 | result = process_command(command) 152 | 153 | # Assert 154 | assert result == expected_result 155 | ``` 156 | 157 | ## Code Style 158 | 159 | ### Python Style Guidelines 160 | 161 | - Follow [PEP 8](https://pep8.org/) 162 | - Use type hints where appropriate 163 | - Write docstrings for public functions and classes 164 | - Keep functions small and focused 165 | - Use descriptive variable names 166 | 167 | ### Code Formatting 168 | 169 | We recommend using `ruff` for formatting: 170 | 171 | ```bash 172 | # Install ruff 173 | uv pip install ruff 174 | 175 | # Format code 176 | ruff format . 177 | 178 | # Check for issues 179 | ruff check . 180 | ``` 181 | 182 | ## Pull Request Process 183 | 184 | 1. **Ensure your branch is up to date**: 185 | ```bash 186 | git checkout main 187 | git pull origin main 188 | git checkout your-feature-branch 189 | git rebase main 190 | ``` 191 | 192 | 2. **Push your branch**: 193 | ```bash 194 | git push origin your-feature-branch 195 | ``` 196 | 197 | 3. **Create a Pull Request** on GitHub with: 198 | - Clear title and description 199 | - Reference any related issues 200 | - Include screenshots/demos if relevant 201 | - Ensure all CI checks pass 202 | 203 | 4. **Address review feedback** promptly and respectfully 204 | 205 | 5. **Squash commits** if requested before merging 206 | 207 | ## Issue Reporting 208 | 209 | When reporting issues, please include: 210 | 211 | - **Environment details**: OS, Python version, dasshh version 212 | - **Steps to reproduce** the issue 213 | - **Expected vs actual behavior** 214 | - **Error messages** or logs 215 | - **Screenshots** if relevant 216 | 217 | ## Documentation 218 | 219 | ### Building Documentation 220 | 221 | ```bash 222 | # Install documentation dependencies 223 | uv pip install mkdocs-material 224 | 225 | # Serve documentation locally 226 | mkdocs serve 227 | 228 | # Build documentation 229 | mkdocs build 230 | ``` 231 | 232 | ## Getting Help 233 | 234 | - Check the [documentation](https://blog.vgnshiyer.dev/dasshh) 235 | - [Open an issue](https://github.com/vgnshiyer/dasshh/issues) for bugs 236 | - [Start a discussion](https://github.com/vgnshiyer/dasshh/discussions) for questions 237 | - Contact maintainers at vgnshiyer@gmail.com or via [LinkedIn](https://www.linkedin.com/in/vgnshiyer/) 238 | 239 | ## Recognition 240 | 241 | Contributors will be acknowledged in: 242 | - Release notes 243 | - GitHub contributors page 244 | 245 | Thank you for contributing to dasshh! -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Dasshh can be installed on MacOS, Linux and Windows using various package managers. Choose the one that works best for you. 4 | 5 | ## Using `uv` (Recommended) 6 | 7 | If you haven't tried [uv](https://github.com/astral-sh/uv) yet, it's one of those *"I will rewrite this in rust"* projects that is actually awesome. Highly recommend trying it out. 8 | 9 | You can install `uv` using `brew` if you are on macOS: 10 | 11 | ```bash 12 | # Install uv on macOS 13 | brew install uv 14 | ``` 15 | 16 | Or using `curl`. 17 | 18 | ```bash 19 | curl -LsSf https://astral.sh/uv/install.sh | sh 20 | ``` 21 | 22 | Install `dasshh` using `uv`: 23 | 24 | ```bash 25 | uv tool install dasshh 26 | ``` 27 | 28 | ## Using `pipx` 29 | 30 | You can also use [pipx](https://pypa.github.io/pipx/) to install Dasshh. 31 | 32 | ```bash 33 | # Install pipx if you haven't already 34 | pip install --user pipx 35 | pipx ensurepath 36 | 37 | # Install dasshh with pipx 38 | pipx install dasshh 39 | ``` 40 | 41 | ## Verifying Installation 42 | 43 | After installation, you should be able to run Dasshh from your terminal: 44 | 45 | ```bash 46 | dasshh --version 47 | ``` 48 | 49 | If the installation was successful, you should see the version of Dasshh printed to the terminal. 50 | -------------------------------------------------------------------------------- /docs/getting-started/introduction.md: -------------------------------------------------------------------------------- 1 | # Welcome to Dasshh! 2 | 3 | ## What is Dasshh? 4 | 5 | Dasshh is an open-source terminal-based AI assistant that allows you to interact with your computer using natural language. It's designed to save your cognitive energy by enabling you to accomplish repetitive tasks on your computer with simple, conversational commands. 6 | 7 | Instead of remembering arcane command-line syntax or navigating complex directory structures, Dasshh lets you describe what you want to do in plain English, and it handles the technical details for you. 8 | 9 | Moreover, you can add your own tools to Dasshh to make it even more powerful, allowing it to interact with your applications and services. 10 | 11 | ## Technical Foundation 12 | 13 | Dasshh is built using several powerful Python libraries: 14 | 15 | - [**Textual**](https://textual.textualize.io/) - The modern TUI (Text User Interface) framework that powers Dasshh's beautiful terminal interface 16 | - [**LiteLLM**](https://docs.litellm.ai/) - Provides the AI model connection layer, making it easy to work with various LLM providers 17 | - [**SQLAlchemy**](https://www.sqlalchemy.org/) - Manages persistent data storage for chat history and application state 18 | - [**Click**](https://click.palletsprojects.com/) - Powers the command-line interface for Dasshh 19 | 20 | ## Development Status 21 | 22 | Dasshh is currently in early development. While it already offers valuable functionality, we're actively working on expanding its capabilities, improving stability, and enhancing the user experience. 23 | 24 | We welcome contributions, suggestions, and feedback from the community to help shape the future of Dasshh. 25 | 26 | ## Getting Started 27 | 28 | Ready to try Dasshh? Head over to the [Installation](/dasshh/getting-started/installation) guide to get started, or check out the [Usage Guide](/dasshh/getting-started/quick-start) to learn how to make the most of your new terminal AI assistant. 29 | -------------------------------------------------------------------------------- /docs/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | This little guide will help you get started with Dasshh. 4 | 5 | ## Configure a Model 6 | 7 | Dasshh uses LLM models via the [litellm](https://docs.litellm.ai/docs/providers) library, which provides a unified interface to various AI providers. 8 | 9 | Setup your config file by running: 10 | 11 | ```bash 12 | dasshh init-config 13 | ``` 14 | 15 | This will create a config file at `~/.dasshh/config.yaml`. 16 | 17 | To get started, you need to set the model you want to use. 18 | 19 | 1. `model.name`: The model provider and model name. 20 | 2. `model.api_key`: Your API key for the chosen provider. 21 | 22 | **Example:** 23 | 24 | ```yaml 25 | model: 26 | name: gemini/gemini-2.0-flash 27 | api_key: 28 | ``` 29 | 30 | You can also set the model name and API key in the settings menu. 31 | 32 | Dasshh Settings 33 | 34 | !!! tip 35 | Checkout the list of supported models and providers [here](https://docs.litellm.ai/docs/providers). 36 | 37 | ## Launch Dasshh 38 | 39 | Once you have configured the model and your API key, you can launch Dasshh by running: 40 | 41 | ```bash 42 | dasshh 43 | ``` 44 | 45 | This will open the Dasshh interface in your terminal. 46 | 47 | ## Basic Interaction 48 | 49 | Dasshh provides a conversational interface to interact with your computer. 50 | 51 | Dasshh Demo 52 | 53 | You can: 54 | 55 | 1. Ask questions 56 | 2. Request information about your system 57 | 3. Execute commands on your behalf 58 | 59 | to name a few. 60 | 61 | More capabilities will be added in the future, across different applications. If you have any suggestions for new tools, please [open an issue](https://github.com/vgnshiyer/dasshh/issues). 62 | 63 | ### Example Questions 64 | 65 | Here are some examples of what you can ask Dasshh to do: 66 | 67 | ``` 68 | # Ask for information 69 | 1. What's the current CPU usage? 70 | 2. Show me the top memory-intensive processes 71 | 72 | # File operations 73 | 1. List files in my downloads folder 74 | 2. Create a new directory called "projects" in the current directory 75 | ``` 76 | 77 | ## Terminating Dasshh 78 | 79 | To exit Dasshh, you can press `Ctrl+C` to terminate the application. 80 | -------------------------------------------------------------------------------- /docs/guide/abilities.md: -------------------------------------------------------------------------------- 1 | # Abilities 2 | 3 | Dasshh supports various tools to interact with your system using natural language commands. 4 | 5 | More tools will be added in future versions. Please feel free to drop your suggestions [here](https://github.com/vgnshiyer/dasshh/issues). 6 | 7 | !!! note 8 | Permission based tool execution is currently under development. As of now, Dasshh will perform a tool call if it seems appropriate. 9 | 10 | ## Available Tools 11 | 12 | In the current version of Dasshh, the following tools are available: 13 | 14 | ### System Information 15 | 16 | These tools provide information about your system's hardware and software: 17 | 18 | | Tool Name | Description | Example Commands | 19 | |-----------|-------------|-----------------| 20 | | `system_info` | Get detailed information about your operating system | "What OS am I running?" | 21 | | `cpu_info` | Get information about CPU and its current usage | "Show my CPU usage" | 22 | | `memory_info` | Get memory (RAM) information and usage | "How much RAM do I have available?" | 23 | | `disk_info` | Get disk space information for a specified path | "How much disk space is left on my main drive?" | 24 | | `network_info` | Get network interface information | "Show my network interface details" | 25 | 26 | ### Process Management 27 | 28 | These tools help you manage running processes on your system: 29 | 30 | | Tool Name | Description | Example Commands | 31 | |-----------|-------------|-----------------| 32 | | `process_list` | List all running processes with basic information | "Show all running processes" | 33 | | `find_process` | Find processes matching a specific name pattern | "Find all Chrome processes" | 34 | | `get_process_info` | Get detailed information about a specific process by PID | "Give me details about process 1234" | 35 | | `kill_process` | Terminate a process by its PID | "Kill process 1234" | 36 | | `run_command` | Run a shell command and return its output | "Run ls -la" | 37 | 38 | ### File Operations 39 | 40 | These tools help you manage files and directories: 41 | 42 | | Tool Name | Description | Example Commands | 43 | |-----------|-------------|-----------------| 44 | | `current_directory` | Get the current working directory | "What's my current directory?" | 45 | | `list_files` | List all files and directories in a specified directory | "List files in ~/Downloads" | 46 | | `file_info` | Get detailed information about a file or directory | "Tell me about file.txt" | 47 | | `read_file` | Read the contents of a file | "Read config.json" | 48 | | `create_directory` | Create a new directory at the specified path | "Create a directory called 'projects'" | 49 | | `delete_file` | Delete a file or directory | "Delete file.txt" | 50 | | `copy_file` | Copy a file or directory from source to destination | "Copy file.txt to ~/backup/" | 51 | | `move_file` | Move a file or directory from source to destination | "Move file.txt to ~/archive/" | 52 | 53 | ### Network Tools 54 | 55 | These tools help you with network-related tasks: 56 | 57 | | Tool Name | Description | Example Commands | 58 | |-----------|-------------|-----------------| 59 | | `ping` | Ping a host and return the results | "Ping google.com" | 60 | | `get_ip_address` | Get IP address for a hostname or the local machine | "What's my IP address?" | 61 | | `check_port` | Check if a specific port is in use on localhost | "Is port 8080 in use?" | 62 | | `public_ip` | Get the public IP address of the current machine | "What's my public IP?" | 63 | | `traceroute` | Perform a traceroute to a host | "Run traceroute to github.com" | 64 | 65 | !!! note 66 | Based on your query, Dasshh can use multiple tools together to perform complex tasks. -------------------------------------------------------------------------------- /docs/guide/basics.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | Dasshh is a terminal-based AI assistant that helps you interact with your computer using natural language. Here's what you need to know to get started. 4 | 5 | ## Core Components 6 | 7 | ### Chat Interface 8 | 9 | The main component you'll interact with is the chat interface, which allows you to: 10 | 11 | With a focus on simplicity, Dasshh's chat interface is divided into three main panels: 12 | 13 | Dasshh Demo 14 | 15 | ### Sessions Panel 16 | 17 | The Sessions Panel helps manage your conversation history. 18 | 19 | Sessions Panel 20 | 21 | - Displays all your previous chat sessions 22 | - Allows you to switch between different conversations 23 | - Shows your last message on the session card 24 | - Lets you create new sessions or delete existing ones 25 | 26 | ### Chat Panel 27 | 28 | The Chat Panel is the main area where your conversation happens. 29 | 30 | Chat Panel 31 | 32 | - Shows the conversation history between you and the assistant 33 | - Displays your messages and the assistant's responses 34 | - Includes an input box at the bottom where you type your messages 35 | 36 | ### Actions Panel 37 | 38 | The Actions Panel shows detailed information about tools used by the assistant. 39 | 40 | Actions Panel 41 | 42 | - Lists all the tools called during the current session 43 | - Shows the parameters passed to each tool 44 | - Displays the results returned by each tool 45 | 46 | ## AI Assistant 47 | 48 | The AI assistant is the brain of Dasshh that: 49 | 50 | - Understands your requests 51 | - Provides helpful responses 52 | - Determines when to use tools to accomplish tasks 53 | 54 | !!! tip 55 | You can update the default `system_prompt` in the config file to change the behavior of the AI assistant. 56 | 57 | ## Tools 58 | 59 | Tools are nothing but the functions that the AI assistant can use to perform actions on your computer. You can find the list of tools [here](abilities.md). 60 | 61 | Natural Language Control 62 | 63 | Additionally, Dasshh allows you to build your own tools. You can find the guide [here](own-tools.md). 64 | 65 | ### Requesting a new tool 66 | 67 | If you have a tool you'd like to see added to Dasshh, please [open an issue](https://github.com/vgnshiyer/dasshh/issues/new?template=tool_request.md) with your request. 68 | 69 | **Future Plans** 70 | 71 | - MCP support 72 | - API call tools 73 | 74 | ## How It Works 75 | 76 | 1. You type a request in natural language 77 | 2. The AI assistant interprets what you want to do 78 | 3. If needed, the assistant uses tools to perform actions on your computer 79 | 4. Results are displayed in the chat interface 80 | 81 | ## Example Interactions 82 | 83 | ```bash 84 | # system information 85 | What's my current CPU and memory usage? 86 | 87 | # file operations 88 | Create a new folder called "projects". 89 | 90 | # process management 91 | Which process is using the most memory? 92 | ``` 93 | 94 | ## Getting Help 95 | 96 | If you need help while using Dasshh, simply ask questions like `What can you do?`. 97 | 98 | Dasshh Demo 99 | 100 | The assistant will guide you through the available features and capabilities. 101 | 102 | ## Important Paths 103 | 104 | | Path | Description | 105 | |------|-------------| 106 | | `~/.dasshh/config.yaml` | Dasshh configuration file | 107 | | `~/.dasshh/db/dasshh.db` | Dasshh database | 108 | | `~/.dasshh/logs/dasshh.log` | Dasshh logs file | -------------------------------------------------------------------------------- /docs/guide/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Below is a list of available configuration options. 4 | 5 | ## Initial Setup 6 | 7 | The default configuration file is located at `~/.dasshh/config.yaml`. 8 | 9 | Below command helps you initialize the configuration file. 10 | 11 | ```bash 12 | dasshh init-config 13 | ``` 14 | 15 | ## Configuration Options 16 | 17 | ### Dasshh Configuration 18 | 19 | | Option | Description | 20 | |--------|-------------| 21 | | `skip_summarization` | Skip summarization of tool call results | 22 | | `system_prompt` | The system prompt for the assistant | 23 | | `theme` | The theme to use for the UI | 24 | | `tool_directories` | The directories to search for tools | 25 | 26 | ### Model Configuration 27 | 28 | | Option | Description | 29 | |--------|-------------| 30 | | `model.name` | The model provider and model name | 31 | | `model.api_key` | Your API key for the chosen provider | 32 | | `model.api_base` | The base URL for the chosen provider | 33 | | `model.api_version` | The version of the API to use | 34 | | `model.temperature` | The temperature for the model | 35 | | `model.top_p` | The top-p value for the model | 36 | | `model.max_tokens` | The maximum number of tokens to generate | 37 | | `model.max_completion_tokens` | The maximum number of tokens to generate for the completion | 38 | 39 | ## Example Configuration File 40 | 41 | The configuration file is a YAML file with the following structure. 42 | 43 | ```yaml 44 | # Dasshh configuration 45 | dasshh: 46 | skip_summarization: false 47 | system_prompt: | 48 | You are a helpful assistant that can help with tasks on the system. 49 | Your goal is to save user's time by performing tasks on their behalf. 50 | theme: lime 51 | tool_directories: 52 | - /Users/viiyer/repos/dasshh/dasshh/apps 53 | 54 | # Model configuration 55 | model: 56 | name: gemini/gemini-2.0-flash 57 | api_base: 58 | api_key: 59 | api_version: 60 | temperature: 1.0 61 | top_p: 1.0 62 | max_tokens: 1000 63 | max_completion_tokens: 1000 64 | ``` 65 | 66 | ## Supported Models 67 | 68 | Dasshh supports a variety of models through [LiteLLM](https://docs.litellm.ai/docs/providers). 69 | 70 | ### Format for specifying a model 71 | 72 | ```yaml 73 | model: 74 | name: / 75 | api_key: 76 | ``` -------------------------------------------------------------------------------- /docs/guide/keybindings.md: -------------------------------------------------------------------------------- 1 | # Keybindings 2 | 3 | ## Current Keybindings 4 | 5 | Dasshh currently supports a minimal set of keyboard shortcuts: 6 | 7 | | Key Combination | Action | 8 | | --------------- | ------ | 9 | | Ctrl+c | Quit the application | 10 | | Ctrl+t | Toggle theme | 11 | 12 | ## Future Plans 13 | 14 | More customizable keybindings are currently in development and will be added in future releases. These will include: 15 | 16 | - Navigation shortcuts 17 | - Message editing shortcuts 18 | - Session management shortcuts 19 | - Vim based text interactions 20 | - Customizable user-defined keybindings 21 | 22 | Stay tuned for updates as we continue to enhance the keyboard navigation experience in Dasshh. 23 | 24 | If you have suggestions for specific keybindings you'd like to see implemented, please [open an issue](https://github.com/vgnshiyer/dasshh/issues) on our GitHub repository. 25 | -------------------------------------------------------------------------------- /docs/guide/own-tools.md: -------------------------------------------------------------------------------- 1 | # Build your own tools 2 | 3 | Dasshh allows you to extend its capabilities by creating your own custom tools. This guide will walk you through the process of building, testing, and integrating your own tools into Dasshh. 4 | 5 | ## Tool basics 6 | 7 | In Dasshh, a tool is a Python function decorated with the `@tool` decorator. When you create a tool, it's automatically registered with Dasshh's tool registry, making it available for the AI assistant to use in conversations. 8 | 9 | ## Creating a simple tool 10 | 11 | Let's create a simple "hello world" tool to understand the basics: 12 | 13 | 1. Create a new Python file in your desired location (we'll use a custom directory for this example) 14 | 2. Define a function with proper type annotations and docstring 15 | 3. Decorate it with the `@tool` decorator 16 | 17 | Here's an example: 18 | 19 | ```python 20 | from dasshh.core.tools.decorator import tool 21 | from typing import Dict 22 | 23 | @tool 24 | def hello_world(name: str = "World") -> Dict: 25 | """ 26 | A simple greeting tool that says hello to the provided name. 27 | 28 | Args: 29 | name (str, optional): The name to greet. Defaults to "World". 30 | 31 | Returns: 32 | Dict: A dictionary containing the greeting message. 33 | """ 34 | return { 35 | "message": f"Hello, {name}!" 36 | } 37 | ``` 38 | 39 | !!! note 40 | Functions must return a python dictionary. 41 | 42 | ## Tool components 43 | 44 | Every tool can have the following components: 45 | 46 | 1. **Function Name**: This becomes the tool's name in the registry (e.g., `hello_world`) 47 | 2. **Docstring**: This becomes the tool's description, explaining what it does 48 | 3. **Type Annotations**: Define the input parameters and return type 49 | 4. **Implementation**: The actual code that executes when the tool is called 50 | 51 | ## Setting up your custom tools directory 52 | 53 | To organize your custom tools, it's best to create a dedicated directory structure like this: 54 | 55 | ``` 56 | my_dasshh_tools/ 57 | ├── __init__.py 58 | └── weather/ 59 | ├── __init__.py 60 | └── weather_tools.py 61 | ``` 62 | 63 | ### Step 1: Create your directory structure 64 | 65 | ```bash 66 | mkdir -p my_dasshh_tools/weather 67 | touch my_dasshh_tools/__init__.py 68 | touch my_dasshh_tools/weather/__init__.py 69 | touch my_dasshh_tools/weather/weather_tools.py 70 | ``` 71 | 72 | ### Step 2: Add your tools 73 | 74 | For example, in `my_dasshh_tools/weather/weather_tools.py`: 75 | 76 | ```python 77 | from typing import Dict 78 | from dasshh.core.tools.decorator import tool 79 | 80 | @tool 81 | def get_weather(city: str) -> Dict: 82 | """ 83 | Get the current weather for a specified city. 84 | 85 | Args: 86 | city (str): The name of the city 87 | 88 | Returns: 89 | Dict: Weather information for the specified city 90 | """ 91 | if city == "San Francisco": 92 | return { 93 | "temperature": 15, 94 | "description": "sunny" 95 | } 96 | else: 97 | return { 98 | "temperature": None, 99 | "description": "Weather not available for this city" 100 | } 101 | ``` 102 | 103 | !!! tip 104 | Don't worry about your interpreter complaining about `dasshh.core.tools.decorator`. When you run dasshh from the global binary, it will be available during runtime. 105 | 106 | ### Step 3: Import your tools in the `__init__.py` files 107 | 108 | In `my_dasshh_tools/__init__.py`: 109 | 110 | ```python 111 | from .weather.weather_tools import myweather 112 | 113 | __all__ = ['myweather'] 114 | ``` 115 | 116 | The `__init__.py` inside the `weather/` directory can be empty. It is only used to indicate that the `weather/` directory is a package. 117 | 118 | ## Registering your tools with Dasshh 119 | 120 | To make your tools available to Dasshh, you need to add your custom directory to Dasshh's configuration. 121 | 122 | 1. Open your Dasshh configuration file at `~/.dasshh/config.yaml` 123 | 2. Add your custom directory to the `tool_directories` list. 124 | 125 | ```yaml 126 | dasshh: 127 | skip_summarization: false 128 | system_prompt: | 129 | You are a helpful assistant that can help with tasks on the system. 130 | tool_directories: 131 | - /path/to/dasshh/apps # Default tools directory 132 | - /path/to/my_dasshh_tools # Your custom tools directory 133 | ``` 134 | 135 | ## Tool design best practices 136 | 137 | When creating your tools, follow these best practices: 138 | 139 | 1. **Clear Names**: Use descriptive function names that indicate what the tool does 140 | 2. **Detailed Docstrings**: Write clear descriptions of what the tool does, including parameter explanations 141 | 3. **Proper Type Annotations**: Use Python type hints for all parameters and return values 142 | 4. **Error Handling**: Handle exceptions gracefully and return informative error messages 143 | 5. **Return Structured Data**: Always return dictionaries or similar structured data that's easy to parse 144 | 6. **Keep It Focused**: Each tool should do one thing well rather than many things 145 | 146 | ## Testing tool discovery 147 | 148 | To test if your tools are being discovered, ask Dasshh to list all tools. 149 | 150 | List Tools 151 | 152 | If you see your tools listed, you're good to go! 153 | 154 | ## Debugging your tools 155 | 156 | If your tool isn't working as expected within Dasshh: 157 | 158 | 1. Check the Dasshh logs at `~/.dasshh/logs/dasshh.log` 159 | 2. Ensure your tool directory is correctly listed in the configuration 160 | 3. Verify that your tool is being imported correctly 161 | 4. Check that your function signatures and type annotations are correct 162 | 163 | ## Understanding how tools are registered 164 | 165 | When you decorate a function with `@tool`, the following happens: 166 | 167 | 1. A `FunctionTool` instance is created with the function's name, docstring, and type annotations 168 | 2. This tool is added to the global `Registry` 169 | 3. When Dasshh starts, it scans all directories listed in `tool_directories` and imports them 170 | 4. During import, the decorators run and register all tools 171 | 5. The assistant can then access this registry to use the tools 172 | 173 | Now you're ready to create your own custom tools for Dasshh! 174 | 175 | ## Additional Resources 176 | 177 | - [Function Calling best practices (OpenAI)](https://platform.openai.com/docs/guides/function-calling?api-mode=responses) 178 | -------------------------------------------------------------------------------- /docs/guide/themes.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | Dasshh supports multiple themes. 4 | 5 | ## Creating a custom theme 6 | 7 | A theme is a simple Python object that inherits from `textual.theme.Theme`. 8 | 9 | ```python 10 | from textual.theme import Theme 11 | 12 | my_theme = Theme( 13 | name="my_theme", 14 | primary="#76ff03", 15 | secondary="#64dd17", 16 | accent="#4caf50", 17 | foreground="#f0f0f0", 18 | background="#101a10", 19 | success="#b9f6ca", 20 | warning="#ffff8d", 21 | error="#ff8a80", 22 | surface="#162316", 23 | panel="#1e2a1e", 24 | dark=True, 25 | variables={ 26 | "footer-key-foreground": "#76ff03", 27 | "input-selection-background": "#64dd1740", 28 | "block-cursor-background": "#76ff03", 29 | "block-cursor-foreground": "#101a10", 30 | "border": "#76ff03", 31 | "scrollbar": "#4caf50", 32 | "scrollbar-hover": "#64dd17", 33 | "scrollbar-active": "#76ff03", 34 | "link-color": "#9cff57", 35 | "link-color-hover": "#76ff03", 36 | }, 37 | ) 38 | ``` 39 | 40 | ## Registering a theme 41 | 42 | You can register a theme to Dasshh by overriding the `startup` method in your `App` subclass. 43 | 44 | ```python 45 | from dasshh.ui.app import Dasshh 46 | 47 | class MyApp(Dasshh): 48 | def startup(self): 49 | super().startup() 50 | self.register_theme(my_theme) 51 | self.theme = "my_theme" 52 | ``` 53 | 54 | Once you have registered a theme, you can toggle between themes using the `Ctrl+t` keybinding. 55 | 56 | !!! tip 57 | For more information on theme variables, see the [Textual documentation](https://textual.textualize.io/guide/design/#theme-variables). 58 | 59 | ## Requesting a new theme 60 | 61 | If you have a theme you'd like to see added to Dasshh, please [open an issue](https://github.com/vgnshiyer/dasshh/issues/new?template=theme_request.md) with your request. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Dasshh 2 | 3 |

An AI Agent on your terminal, to preserve your brain juice.

4 | 5 | Dasshh is an open source tui-application built with [textual](https://textual.textual.sh/) that allows you to interact with your computer using natural language. 6 | 7 | 8 | Dasshh Demo 9 | 10 | !!! note 11 | This project is still in early development. Suggestions and contributions are welcome! 12 | 13 |

Features

14 | 15 |
16 |
17 |
18 |

Natural Language Computer Control

19 |
20 |
21 | Natural Language Control 22 |
23 |
    24 |
  • Execute system commands with plain English
  • 25 |
  • Manage files and directories conversationally
  • 26 |
  • Get real-time system information
  • 27 |
  • Automate repetitive tasks
  • 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 |

Interactive & Minimal Chat UI

36 |
37 |
38 | Interactive Chat UI 39 |
40 |
    41 |
  • Clean, distraction-free interface
  • 42 | 43 |
  • Syntax highlighting for code snippets
  • 44 |
  • Multiple themes to choose from
  • 45 |
  • Persistent chat memory across sessions
  • 46 |
47 |
48 |
49 | 50 |
51 |
52 |
53 |

Extensible Tool System

54 |
55 |
56 | Extensible Tools 57 |
58 |
    59 |
  • Add custom tools easily
  • 60 |
  • Integrate with your existing workflows
  • 61 |
  • MCP Support (Coming Soon)
  • 62 |
  • Share tools with the community
  • 63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 |

Terminal-Native Experience

71 |
72 |
73 | Terminal Native 74 |
75 |
    76 |
  • Works where you already work - your terminal
  • 77 |
  • Lightweight and fast
  • 78 |
  • Cross-platform compatibility
  • 79 |
  • No GUI dependencies
  • 80 |
81 |
82 |
83 | 84 |

Get Involved

85 | 86 | Dasshh is evolving rapidly and your feedback is essential to its development. The future direction of this project will be heavily influenced by community input. 87 | 88 |

How to Contribute

89 | 90 | - [Try it out](https://github.com/vgnshiyer/dasshh/releases) and share your experience 91 | - [Spread the word](https://twitter.com/intent/tweet?text=Check%20out%20Dasshh%20-%20an%20AI%20Agent%20for%20your%20terminal!%20%F0%9F%97%B2%20&url=https%3A%2F%2Fgithub.com%2Fvgnshiyer%2Fdasshh) about Dasshh 92 | - [Suggest features](https://github.com/vgnshiyer/dasshh/issues/new?template=feature_request.md) or [report bugs](https://github.com/vgnshiyer/dasshh/issues/new?template=bug_report.md) 93 | - [Request a new tool](https://github.com/vgnshiyer/dasshh/issues/new?template=tool_request.md) 94 | - [Share ideas](https://github.com/vgnshiyer/dasshh/discussions) for new tools and integrations 95 | - [Start a discussion](https://github.com/vgnshiyer/dasshh/discussions) for questions 96 | 97 | Join us in building an AI assistant that truly understands how developers work in the terminal! 98 | 99 | 124 | 125 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Dasshh 2 | site_description: An AI Agent on your terminal, to preserve your brain juice 3 | site_url: https://vgnshiyer.github.io/dasshh/ 4 | repo_url: https://github.com/vgnshiyer/dasshh 5 | repo_name: vgnshiyer/dasshh 6 | 7 | theme: 8 | name: material 9 | favicon: assets/favicon.png 10 | logo: assets/logo.png 11 | font: 12 | text: Roboto Mono 13 | code: Roboto Mono 14 | palette: 15 | # Dark mode (default) 16 | - scheme: slate 17 | primary: light green 18 | accent: lime 19 | 20 | features: 21 | - navigation.tabs 22 | - navigation.sections 23 | - navigation.path 24 | - navigation.top 25 | - navigation.footer 26 | - search.suggest 27 | - search.highlight 28 | - content.tabs.link 29 | - content.code.copy 30 | - toc.integrate 31 | - home.page 32 | 33 | # Custom CSS 34 | extra_css: 35 | - stylesheets/extra.css 36 | 37 | markdown_extensions: 38 | - admonition 39 | - pymdownx.details 40 | - pymdownx.superfences 41 | - pymdownx.highlight: 42 | anchor_linenums: true 43 | line_spans: __span 44 | pygments_lang_class: true 45 | - pymdownx.inlinehilite 46 | - pymdownx.snippets 47 | - pymdownx.tabbed: 48 | alternate_style: true 49 | - tables 50 | 51 | plugins: 52 | - search 53 | 54 | # Navigation structure 55 | nav: 56 | - "": index.md 57 | - Getting Started: 58 | - Introduction: getting-started/introduction.md 59 | - Installation: getting-started/installation.md 60 | - Quick Start: getting-started/quick-start.md 61 | - Guide: 62 | - Basics: guide/basics.md 63 | - Configuration: guide/configuration.md 64 | - Themes: guide/themes.md 65 | - Keybindings: guide/keybindings.md 66 | - Abilities: guide/abilities.md 67 | - Build your own tools: guide/own-tools.md 68 | - Contributing: contributing/contributing.md 69 | - API Reference: 70 | - Runtime: api/runtime.md 71 | - Tools: api/tools.md 72 | - Data: api/data.md 73 | - Events: api/events.md 74 | - UI: api/ui.md 75 | 76 | extra: 77 | generator: false 78 | social: 79 | - icon: fontawesome/brands/github 80 | link: https://github.com/vgnshiyer/dasshh 81 | - icon: fontawesome/brands/linkedin 82 | link: https://www.linkedin.com/in/vgnshiyer/ 83 | - icon: fontawesome/solid/blog 84 | link: https://blog.vgnshiyer.dev 85 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dasshh" 3 | version = "0.1.4" 4 | description = "An AI Agent on your terminal, to preserve your brain juice." 5 | readme = "README.md" 6 | license = { text = "MIT" } 7 | authors = [ 8 | { name="Vignesh Iyer", email="vgnshiyer@gmail.com" } 9 | ] 10 | requires-python = ">=3.13" 11 | dependencies = [ 12 | "click>=8.2.0", 13 | "litellm>=1.69.3", 14 | "numpydoc>=1.8.0", 15 | "psutil>=7.0.0", 16 | "sqlalchemy>=2.0.41", 17 | "textual>=3.2.0", 18 | ] 19 | 20 | [project.urls] 21 | Documentation = "https://blog.vgnshiyer.dev/dasshh" 22 | Homepage = "https://github.com/vgnshiyer/dasshh" 23 | Repository = "https://github.com/vgnshiyer/dasshh" 24 | "Bug Tracker" = "https://github.com/vgnshiyer/dasshh/issues" 25 | 26 | [build-system] 27 | requires = ["setuptools>=61.0"] 28 | build-backend = "setuptools.build_meta" 29 | 30 | [tool.pytest.ini_options] 31 | testpaths = ["tests"] 32 | python_files = "test_*.py" 33 | python_functions = "test_*" 34 | python_classes = "Test*" 35 | markers = ["asyncio: mark a test as an asyncio test"] 36 | 37 | [project.optional-dependencies] 38 | dev = [ 39 | "pytest>=7.4.0", 40 | "pytest-cov>=4.1.0", 41 | "pytest-asyncio>=0.21.0", 42 | "mkdocs-material>=9.6.0", 43 | ] 44 | 45 | [tool.setuptools.packages.find] 46 | include = ["dasshh*"] 47 | 48 | [project.scripts] 49 | dasshh = "dasshh.__main__:main" 50 | 51 | [tool.coverage.run] 52 | source = ["dasshh"] 53 | omit = ["tests/*"] 54 | 55 | [tool.coverage.report] 56 | exclude_lines = [ 57 | "pragma: no cover", 58 | "def __repr__", 59 | "raise NotImplementedError", 60 | "if __name__ == .__main__.:", 61 | "pass", 62 | "raise ImportError", 63 | ] 64 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing Dasshh 2 | 3 | This directory contains tests for Dasshh. 4 | 5 | ## Structure 6 | 7 | - `unit/`: Unit tests for individual components 8 | - `core/`: Tests for core functionality 9 | - `ui/`: Tests for UI components 10 | - `apps/`: Tests for application modules 11 | - `data/`: Tests for data models and storage 12 | 13 | ## Running Tests 14 | 15 | To run all tests: 16 | 17 | ```bash 18 | python -m pytest 19 | ``` 20 | 21 | To run tests with coverage: 22 | 23 | ```bash 24 | python -m pytest --cov=dasshh 25 | ``` 26 | 27 | To generate a coverage report: 28 | 29 | ```bash 30 | python -m pytest --cov=dasshh --cov-report=html 31 | ``` 32 | 33 | This will create a directory called `htmlcov` with an HTML report of the coverage. 34 | 35 | ## Adding Tests 36 | 37 | When adding new functionality, please add corresponding tests. Test files should follow the naming convention `test_*.py` and test functions should be named `test_*`. -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for dasshh.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared pytest fixtures and configuration. 3 | """ 4 | 5 | import os 6 | import sys 7 | import pytest 8 | 9 | # Add the project root directory to the path so we can import the package 10 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 11 | 12 | 13 | @pytest.fixture 14 | def cli_runner(): 15 | """Provide a click CLI test runner.""" 16 | from click.testing import CliRunner 17 | 18 | return CliRunner() 19 | -------------------------------------------------------------------------------- /tests/unit/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the core module.""" 2 | -------------------------------------------------------------------------------- /tests/unit/core/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fixtures for the core module. 3 | """ 4 | 5 | import os 6 | import tempfile 7 | from unittest.mock import MagicMock, patch 8 | 9 | import pytest 10 | 11 | from dasshh.core.registry import Registry 12 | from dasshh.core.tools.base import BaseTool 13 | from dasshh.data.session import SessionService 14 | 15 | 16 | @pytest.fixture 17 | def mock_logger(): 18 | """Mock logger for testing.""" 19 | with patch("logging.getLogger") as mock_get_logger: 20 | with patch("logging.FileHandler"), patch("logging.StreamHandler"): 21 | mock_logger = MagicMock() 22 | mock_get_logger.return_value = mock_logger 23 | yield mock_logger 24 | 25 | 26 | @pytest.fixture 27 | def test_log_file(): 28 | """Create a temporary log file.""" 29 | fd, path = tempfile.mkstemp() 30 | try: 31 | yield path 32 | finally: 33 | os.close(fd) 34 | os.unlink(path) 35 | 36 | 37 | @pytest.fixture 38 | def reset_registry(): 39 | """Reset the Registry singleton between tests.""" 40 | Registry._instance = None 41 | Registry.tools = {} 42 | yield 43 | Registry._instance = None 44 | Registry.tools = {} 45 | 46 | 47 | @pytest.fixture 48 | def mock_tool(): 49 | """Create a mock tool for testing.""" 50 | tool_parameters = { 51 | "type": "object", 52 | "properties": { 53 | "test_param": {"type": "string", "description": "Test parameter"} 54 | }, 55 | } 56 | 57 | class TestTool(BaseTool): 58 | def __init__(self): 59 | super().__init__( 60 | name="test_tool", description="A test tool", parameters=tool_parameters 61 | ) 62 | 63 | def __call__(self, test_param=None): 64 | return {"result": f"Test result with {test_param}"} 65 | 66 | def get_declaration(self): 67 | return { 68 | "type": "function", 69 | "function": { 70 | "name": self.name, 71 | "description": self.description, 72 | "parameters": self.parameters, 73 | }, 74 | } 75 | 76 | return TestTool() 77 | 78 | 79 | @pytest.fixture 80 | def mock_session_service(): 81 | """Create a mock session service for testing.""" 82 | return MagicMock(spec=SessionService) 83 | -------------------------------------------------------------------------------- /tests/unit/core/test_logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the logging module. 3 | """ 4 | 5 | from pathlib import Path 6 | from unittest.mock import patch, MagicMock 7 | 8 | from dasshh.core.logging import get_logger, DEFAULT_LOG_DIR, DEFAULT_LOG_FILE 9 | 10 | 11 | def test_get_logger(): 12 | """Test get_logger function.""" 13 | with patch("logging.getLogger") as mock_get_logger: 14 | mock_logger = MagicMock() 15 | mock_get_logger.return_value = mock_logger 16 | logger = get_logger("test_logger") 17 | mock_get_logger.assert_called_once_with("test_logger") 18 | assert logger == mock_logger 19 | 20 | 21 | def test_default_log_dir_exists(): 22 | """Test that the default log directory exists.""" 23 | assert DEFAULT_LOG_DIR.exists() 24 | assert DEFAULT_LOG_DIR == Path.home() / ".dasshh" / "logs" 25 | 26 | 27 | def test_default_log_file_path(): 28 | """Test the default log file path.""" 29 | assert DEFAULT_LOG_FILE == DEFAULT_LOG_DIR / "dasshh.log" 30 | -------------------------------------------------------------------------------- /tests/unit/core/test_registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the registry module. 3 | """ 4 | 5 | import pytest 6 | from unittest.mock import Mock 7 | 8 | from dasshh.core.registry import Registry 9 | from dasshh.core.tools.base import BaseTool 10 | 11 | 12 | def test_registry_singleton(reset_registry): 13 | """Test that Registry is a singleton.""" 14 | registry1 = Registry() 15 | registry2 = Registry() 16 | assert registry1 is registry2 17 | 18 | tool = Mock(spec=BaseTool) 19 | tool.name = "test_tool" 20 | registry1.add_tool(tool) 21 | 22 | assert "test_tool" in registry2.tools 23 | 24 | 25 | def test_add_tool(reset_registry, mock_tool): 26 | """Test adding a tool to the registry.""" 27 | registry = Registry() 28 | 29 | registry.add_tool(mock_tool) 30 | 31 | assert mock_tool.name in registry.tools 32 | assert registry.tools[mock_tool.name] is mock_tool 33 | 34 | 35 | def test_add_duplicate_tool(reset_registry, mock_tool): 36 | """Test adding a tool with a duplicate name.""" 37 | registry = Registry() 38 | registry.add_tool(mock_tool) 39 | 40 | duplicate_tool = Mock(spec=BaseTool) 41 | duplicate_tool.name = mock_tool.name 42 | 43 | with pytest.raises( 44 | ValueError, 45 | match=f"Tool name must be unique, there is already a tool named {mock_tool.name}", 46 | ): 47 | registry.add_tool(duplicate_tool) 48 | 49 | 50 | def test_get_tools(reset_registry, mock_tool): 51 | """Test getting all tools from the registry.""" 52 | registry = Registry() 53 | registry.add_tool(mock_tool) 54 | tools = registry.get_tools() 55 | assert len(tools) == 1 56 | assert tools[0] is mock_tool 57 | 58 | 59 | def test_get_tool(reset_registry, mock_tool): 60 | """Test getting a specific tool by name.""" 61 | registry = Registry() 62 | registry.add_tool(mock_tool) 63 | tool = registry.get_tool(mock_tool.name) 64 | assert tool is mock_tool 65 | 66 | 67 | def test_get_nonexistent_tool(reset_registry): 68 | """Test getting a tool that doesn't exist.""" 69 | registry = Registry() 70 | tool = registry.get_tool("nonexistent_tool") 71 | assert tool is None 72 | 73 | 74 | def test_get_tool_declarations(reset_registry, mock_tool): 75 | """Test getting tool declarations.""" 76 | registry = Registry() 77 | registry.add_tool(mock_tool) 78 | declarations = registry.get_tool_declarations() 79 | assert len(declarations) == 1 80 | assert declarations[0] == mock_tool.get_declaration() 81 | -------------------------------------------------------------------------------- /tests/unit/core/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the core.tools module.""" 2 | -------------------------------------------------------------------------------- /tests/unit/core/tools/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the base tool class. 3 | """ 4 | 5 | import pytest 6 | 7 | from dasshh.core.tools.base import BaseTool 8 | 9 | 10 | class TestTool(BaseTool): 11 | """Test implementation of BaseTool.""" 12 | 13 | def __init__(self, name="test_tool", description="Test tool", parameters=None): 14 | parameters = parameters or {"type": "object", "properties": {}} 15 | super().__init__(name, description, parameters) 16 | 17 | def __call__(self, *args, **kwargs): 18 | return {"result": "test_result"} 19 | 20 | def get_declaration(self): 21 | return { 22 | "type": "function", 23 | "function": { 24 | "name": self.name, 25 | "description": self.description, 26 | "parameters": self.parameters, 27 | }, 28 | } 29 | 30 | 31 | def test_base_tool_initialization(): 32 | """Test initializing a BaseTool subclass.""" 33 | name = "test_tool" 34 | description = "A test tool" 35 | parameters = {"type": "object", "properties": {"test_param": {"type": "string"}}} 36 | 37 | tool = TestTool(name, description, parameters) 38 | 39 | assert tool.name == name 40 | assert tool.description == description 41 | assert tool.parameters == parameters 42 | 43 | 44 | def test_base_tool_call_not_implemented(): 45 | """Test that calling BaseTool directly raises NotImplementedError.""" 46 | tool = BaseTool( 47 | name="base_tool", 48 | description="Base tool for testing", 49 | parameters={"type": "object", "properties": {}}, 50 | ) 51 | 52 | with pytest.raises(NotImplementedError, match="This tool has no implementation"): 53 | tool() 54 | 55 | 56 | def test_base_tool_get_declaration_not_implemented(): 57 | """Test that get_declaration on BaseTool raises NotImplementedError.""" 58 | tool = BaseTool( 59 | name="base_tool", 60 | description="Base tool for testing", 61 | parameters={"type": "object", "properties": {}}, 62 | ) 63 | 64 | with pytest.raises(NotImplementedError, match="This tool has no implementation"): 65 | tool.get_declaration() 66 | 67 | 68 | def test_tool_implementation(): 69 | """Test a concrete implementation of BaseTool.""" 70 | tool = TestTool() 71 | 72 | result = tool() 73 | assert result == {"result": "test_result"} 74 | 75 | declaration = tool.get_declaration() 76 | assert declaration["type"] == "function" 77 | assert declaration["function"]["name"] == "test_tool" 78 | assert declaration["function"]["description"] == "Test tool" 79 | assert declaration["function"]["parameters"] == {"type": "object", "properties": {}} 80 | -------------------------------------------------------------------------------- /tests/unit/core/tools/test_decorator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the tool decorator. 3 | """ 4 | 5 | from unittest.mock import patch, MagicMock 6 | 7 | from dasshh.core.tools.decorator import tool 8 | from dasshh.core.tools.function_tool import FunctionTool 9 | 10 | 11 | def test_tool_decorator(): 12 | """Test the @tool decorator.""" 13 | mock_registry = MagicMock() 14 | 15 | def test_function(param1: str = "default") -> dict: 16 | """Test function docstring.""" 17 | return {"result": param1} 18 | 19 | with patch("dasshh.core.tools.decorator.Registry", return_value=mock_registry): 20 | decorated = tool(test_function) 21 | 22 | assert isinstance(decorated, FunctionTool) 23 | 24 | mock_registry.add_tool.assert_called_once_with(decorated) 25 | 26 | assert decorated.name == "test_function" 27 | assert decorated.description == "Test function docstring." 28 | assert decorated.func == test_function 29 | 30 | assert decorated.parameters == test_function.__annotations__ 31 | 32 | result = decorated(param1="test") 33 | assert result == {"result": "test"} 34 | 35 | 36 | def test_tool_decorator_integration(): 37 | """Test the @tool decorator in a more realistic scenario.""" 38 | with patch("dasshh.core.tools.decorator.Registry") as MockRegistry: 39 | mock_registry_instance = MagicMock() 40 | MockRegistry.return_value = mock_registry_instance 41 | 42 | @tool 43 | def example_tool(param1: str = "default") -> dict: 44 | """Example tool docstring.""" 45 | return {"result": param1} 46 | 47 | assert isinstance(example_tool, FunctionTool) 48 | 49 | mock_registry_instance.add_tool.assert_called_once() 50 | 51 | result = example_tool(param1="test_value") 52 | assert result == {"result": "test_value"} 53 | -------------------------------------------------------------------------------- /tests/unit/core/tools/test_function_tool.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the function tool class. 3 | """ 4 | 5 | import pytest 6 | from unittest.mock import patch 7 | 8 | from dasshh.core.tools.function_tool import FunctionTool 9 | 10 | 11 | def test_function_tool_initialization(): 12 | """Test initializing a FunctionTool.""" 13 | name = "test_function" 14 | description = "A test function" 15 | parameters = {"type": "object", "properties": {"test_param": {"type": "string"}}} 16 | 17 | def test_func(test_param=None): 18 | """A test function.""" 19 | return {"result": f"Test result with {test_param}"} 20 | 21 | tool = FunctionTool(name, description, parameters, func=test_func) 22 | 23 | assert tool.name == name 24 | assert tool.description == description 25 | assert tool.parameters == parameters 26 | assert tool.func == test_func 27 | 28 | 29 | def test_function_tool_call(): 30 | """Test calling a FunctionTool.""" 31 | 32 | def test_func(test_param=None): 33 | """A test function.""" 34 | return {"result": f"Test result with {test_param}"} 35 | 36 | tool = FunctionTool( 37 | name="test_function", 38 | description="A test function", 39 | parameters={"type": "object", "properties": {}}, 40 | func=test_func, 41 | ) 42 | 43 | result = tool(test_param="test_value") 44 | 45 | assert result == {"result": "Test result with test_value"} 46 | 47 | 48 | def test_function_tool_call_no_implementation(): 49 | """Test calling a FunctionTool with no implementation.""" 50 | tool = FunctionTool( 51 | name="test_function", 52 | description="A test function", 53 | parameters={"type": "object", "properties": {}}, 54 | ) 55 | 56 | with pytest.raises(NotImplementedError, match="This tool has no implementation"): 57 | tool() 58 | 59 | 60 | def test_function_tool_get_declaration(): 61 | """Test getting the declaration of a FunctionTool.""" 62 | 63 | # Define a simple test function 64 | def test_func(test_param=None): 65 | """A test function.""" 66 | return {"result": f"Test result with {test_param}"} 67 | 68 | expected_declaration = { 69 | "name": "test_function", 70 | "description": "A test function", 71 | "parameters": { 72 | "type": "object", 73 | "properties": { 74 | "test_param": {"type": "string", "description": "Test parameter"} 75 | }, 76 | }, 77 | } 78 | 79 | with patch( 80 | "dasshh.core.tools.function_tool.function_to_dict", 81 | return_value=expected_declaration, 82 | ) as mock_fn_to_dict: 83 | tool = FunctionTool( 84 | name="test_function", 85 | description="A test function", 86 | parameters={"type": "object", "properties": {}}, 87 | func=test_func, 88 | ) 89 | 90 | declaration = tool.get_declaration() 91 | 92 | mock_fn_to_dict.assert_called_once_with(test_func) 93 | 94 | assert declaration == expected_declaration 95 | -------------------------------------------------------------------------------- /tests/unit/data/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the data module.""" 2 | -------------------------------------------------------------------------------- /tests/unit/data/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fixtures for the data module. 3 | """ 4 | 5 | import os 6 | import tempfile 7 | import pytest 8 | from sqlalchemy import create_engine 9 | from sqlalchemy.orm import sessionmaker 10 | 11 | from dasshh.data.client import Base, DBClient 12 | from dasshh.data.session import SessionService 13 | 14 | 15 | @pytest.fixture 16 | def test_db_file(): 17 | """Create a temporary database file.""" 18 | fd, path = tempfile.mkstemp() 19 | try: 20 | yield path 21 | finally: 22 | os.close(fd) 23 | os.unlink(path) 24 | 25 | 26 | @pytest.fixture 27 | def test_db_client(monkeypatch, test_db_file): 28 | """Create a test database client with a temporary database.""" 29 | 30 | class TestDBClient(DBClient): 31 | def __init__(self, db_path): 32 | self.db_path = db_path 33 | self.engine = create_engine(f"sqlite:///{self.db_path}") 34 | self.DatabaseSessionFactory = sessionmaker(bind=self.engine) 35 | Base.metadata.create_all(bind=self.engine) 36 | 37 | client = TestDBClient(test_db_file) 38 | return client 39 | 40 | 41 | @pytest.fixture 42 | def test_session_service(test_db_client): 43 | """Create a test session service.""" 44 | return SessionService(test_db_client) 45 | -------------------------------------------------------------------------------- /tests/unit/data/test_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the database client. 3 | """ 4 | 5 | from sqlalchemy import Engine 6 | from sqlalchemy.orm import Session 7 | 8 | from dasshh.data.client import DBClient 9 | 10 | 11 | def test_db_client_initialization(): 12 | """Test creating a DBClient instance.""" 13 | client = DBClient() 14 | assert client.db_path.parent.exists() 15 | assert isinstance(client.engine, Engine) 16 | assert client.DatabaseSessionFactory is not None 17 | 18 | 19 | def test_db_client_get_db(test_db_client): 20 | """Test getting a database session.""" 21 | db = test_db_client.get_db() 22 | assert db is not None 23 | assert isinstance(db, Session) 24 | 25 | 26 | def test_db_client_get_db_as_context_manager(test_db_client): 27 | """Test using the database session as a context manager.""" 28 | with test_db_client.get_db() as db: 29 | assert db is not None 30 | assert isinstance(db, Session) 31 | -------------------------------------------------------------------------------- /tests/unit/data/test_models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for database models. 3 | """ 4 | 5 | import uuid 6 | from datetime import datetime 7 | 8 | from dasshh.data.models import StorageSession, StorageEvent 9 | 10 | 11 | def test_storage_session_model(): 12 | """Test the StorageSession model structure.""" 13 | session_id = str(uuid.uuid4()) 14 | now = datetime.now() 15 | session = StorageSession( 16 | id=session_id, detail="Test Session", created_at=now, updated_at=now 17 | ) 18 | 19 | assert session.id == session_id 20 | assert session.detail == "Test Session" 21 | assert session.created_at == now 22 | assert session.updated_at == now 23 | 24 | assert hasattr(session, "events") 25 | 26 | 27 | def test_storage_event_model(): 28 | """Test the StorageEvent model structure.""" 29 | event_id = str(uuid.uuid4()) 30 | session_id = str(uuid.uuid4()) 31 | invocation_id = str(uuid.uuid4()) 32 | now = datetime.now() 33 | content = {"message": "Test event"} 34 | 35 | event = StorageEvent( 36 | id=event_id, 37 | invocation_id=invocation_id, 38 | session_id=session_id, 39 | created_at=now, 40 | content=content, 41 | ) 42 | 43 | assert event.id == event_id 44 | assert event.invocation_id == invocation_id 45 | assert event.session_id == session_id 46 | assert event.created_at == now 47 | assert event.content == content 48 | -------------------------------------------------------------------------------- /tests/unit/data/test_session.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the session service. 3 | """ 4 | 5 | import uuid 6 | 7 | from dasshh.data.models import StorageSession 8 | 9 | 10 | def test_new_session(test_session_service): 11 | """Test creating a new session.""" 12 | session = test_session_service.new_session(detail="Test Session") 13 | assert session is not None 14 | assert isinstance(session, StorageSession) 15 | assert session.detail == "Test Session" 16 | 17 | 18 | def test_get_session(test_session_service): 19 | """Test getting a session by ID.""" 20 | session = test_session_service.new_session(detail="Test Session") 21 | retrieved_session = test_session_service.get_session(session_id=session.id) 22 | assert retrieved_session is not None 23 | assert retrieved_session.id == session.id 24 | assert retrieved_session.detail == "Test Session" 25 | 26 | 27 | def test_get_nonexistent_session(test_session_service): 28 | """Test getting a session that doesn't exist.""" 29 | session = test_session_service.get_session(session_id=str(uuid.uuid4())) 30 | assert session is None 31 | 32 | 33 | def test_update_session(test_session_service): 34 | """Test updating a session.""" 35 | session = test_session_service.new_session(detail="Test Session") 36 | 37 | test_session_service.update_session( 38 | session_id=session.id, 39 | detail="Updated Session", 40 | ) 41 | 42 | updated_session = test_session_service.get_session(session_id=session.id) 43 | 44 | assert updated_session is not None 45 | assert updated_session.detail == "Updated Session" 46 | 47 | 48 | def test_list_sessions(test_session_service): 49 | """Test listing sessions.""" 50 | session1 = test_session_service.new_session(detail="Test Session 1") 51 | session2 = test_session_service.new_session(detail="Test Session 2") 52 | 53 | sessions = test_session_service.list_sessions() 54 | assert len(sessions) >= 2 55 | assert any(s.id == session1.id for s in sessions) 56 | assert any(s.id == session2.id for s in sessions) 57 | 58 | 59 | def test_list_sessions_with_include_events(test_session_service): 60 | """Test listing sessions with include_events parameter.""" 61 | session = test_session_service.new_session(detail="Test Session") 62 | 63 | invocation_id = str(uuid.uuid4()) 64 | content = {"message": "Test event"} 65 | test_session_service.add_event( 66 | invocation_id=invocation_id, 67 | session_id=session.id, 68 | content=content, 69 | ) 70 | 71 | sessions_with_events = test_session_service.list_sessions(include_events=True) 72 | 73 | assert len(sessions_with_events) > 0 74 | assert any(s.id == session.id for s in sessions_with_events) 75 | 76 | sessions_without_events = test_session_service.list_sessions(include_events=False) 77 | assert len(sessions_without_events) > 0 78 | 79 | 80 | def test_delete_session(test_session_service): 81 | """Test deleting a session.""" 82 | # Create a new session 83 | session = test_session_service.new_session(detail="Test Session") 84 | 85 | test_session_service.delete_session(session_id=session.id) 86 | 87 | deleted_session = test_session_service.get_session(session_id=session.id) 88 | 89 | assert deleted_session is None 90 | 91 | 92 | def test_delete_nonexistent_session(test_session_service): 93 | """Test deleting a session that doesn't exist.""" 94 | nonexistent_id = str(uuid.uuid4()) 95 | 96 | test_session_service.delete_session(session_id=nonexistent_id) 97 | 98 | 99 | def test_get_recent_session(test_session_service): 100 | """Test getting the most recent session.""" 101 | session1 = test_session_service.new_session(detail="Test Session 1") 102 | test_session_service.new_session(detail="Test Session 2") 103 | 104 | recent_session = test_session_service.get_recent_session() 105 | 106 | assert recent_session is not None 107 | assert recent_session.id == session1.id 108 | 109 | 110 | def test_add_event(test_session_service): 111 | """Test adding an event to a session.""" 112 | session = test_session_service.new_session(detail="Test Session") 113 | 114 | invocation_id = str(uuid.uuid4()) 115 | content = {"message": "Test event"} 116 | test_session_service.add_event( 117 | invocation_id=invocation_id, 118 | session_id=session.id, 119 | content=content, 120 | ) 121 | 122 | events = test_session_service.get_events(session_id=session.id) 123 | 124 | assert len(events) == 1 125 | assert events[0].invocation_id == invocation_id 126 | assert events[0].session_id == session.id 127 | assert events[0].content == content 128 | -------------------------------------------------------------------------------- /tests/unit/test_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the main CLI entry point. 3 | """ 4 | 5 | from unittest.mock import patch 6 | 7 | from dasshh.__main__ import main 8 | 9 | 10 | def test_version_option(cli_runner): 11 | """Test the --version option.""" 12 | result = cli_runner.invoke(main, ["--version"]) 13 | assert result.exit_code == 0 14 | 15 | 16 | @patch("dasshh.ui.app.Dasshh") 17 | def test_main_no_command(mock_dasshh, cli_runner): 18 | """Test invoking the main CLI without a subcommand.""" 19 | mock_app_instance = mock_dasshh.return_value 20 | result = cli_runner.invoke(main) 21 | assert mock_dasshh.called 22 | assert mock_app_instance.run.called 23 | assert result.exit_code == 0 24 | -------------------------------------------------------------------------------- /tests/unit/ui/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the UI module.""" 2 | -------------------------------------------------------------------------------- /tests/unit/ui/components/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the UI components.""" 2 | -------------------------------------------------------------------------------- /tests/unit/ui/components/chat/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the chat components.""" 2 | -------------------------------------------------------------------------------- /tests/unit/ui/components/chat/test_action.py: -------------------------------------------------------------------------------- 1 | """Tests for the Action component.""" 2 | 3 | from unittest.mock import MagicMock, patch, PropertyMock 4 | 5 | from rich.console import Group 6 | from rich.syntax import Syntax 7 | from rich.text import Text 8 | 9 | from dasshh.ui.components.chat.action import Action 10 | 11 | 12 | def test_action_initialization(): 13 | """Test Action initialization.""" 14 | action = Action( 15 | invocation_id="test_invocation", 16 | tool_call_id="test_tool_call", 17 | name="test_tool", 18 | args='{"param": "value"}', 19 | result='{"result": "success"}', 20 | ) 21 | 22 | assert action.invocation_id == "test_invocation" 23 | assert action.tool_call_id == "test_tool_call" 24 | assert action.name == "test_tool" 25 | assert action.args == '{"param": "value"}' 26 | assert action.result == '{"result": "success"}' 27 | 28 | 29 | @patch("dasshh.ui.components.chat.action.Syntax") 30 | @patch.object(Action, "app", new_callable=PropertyMock) 31 | def test_action_render_with_result(mock_app_prop, mock_syntax): 32 | """Test rendering an action with a result.""" 33 | mock_app = MagicMock() 34 | mock_app.get_css_variables.return_value = {"panel": "#123456"} 35 | mock_app_prop.return_value = mock_app 36 | 37 | mock_args_syntax = MagicMock(spec=Syntax) 38 | mock_result_syntax = MagicMock(spec=Syntax) 39 | mock_syntax.side_effect = [mock_args_syntax, mock_result_syntax] 40 | 41 | action = Action( 42 | invocation_id="test_invocation", 43 | tool_call_id="test_tool_call", 44 | name="test_tool", 45 | args='{"param": "value"}', 46 | result='{"result": "success"}', 47 | ) 48 | 49 | rendered = action.render() 50 | 51 | assert mock_syntax.call_count == 2 52 | args_call, result_call = mock_syntax.call_args_list 53 | 54 | assert args_call[0][0] == '{"param": "value"}' 55 | assert args_call[0][1] == "json" 56 | assert args_call[1]["background_color"] == "#123456" 57 | assert result_call[0][0] == '{"result": "success"}' 58 | assert result_call[0][1] == "json" 59 | assert result_call[1]["background_color"] == "#123456" 60 | 61 | assert isinstance(rendered, Group) 62 | assert len(rendered.renderables) == 5 63 | 64 | assert isinstance(rendered.renderables[0], Text) 65 | assert "Using tool: test_tool" in rendered.renderables[0].plain 66 | 67 | assert rendered.renderables[1] == mock_args_syntax 68 | 69 | assert isinstance(rendered.renderables[2], Text) 70 | assert rendered.renderables[2].plain == "" 71 | 72 | assert isinstance(rendered.renderables[3], Text) 73 | assert "Result: test_tool" in rendered.renderables[3].plain 74 | assert rendered.renderables[4] == mock_result_syntax 75 | 76 | 77 | @patch("dasshh.ui.components.chat.action.Syntax") 78 | @patch.object(Action, "app", new_callable=PropertyMock) 79 | def test_action_render_without_result(mock_app_prop, mock_syntax): 80 | """Test rendering an action without a result.""" 81 | mock_app = MagicMock() 82 | mock_app.get_css_variables.return_value = {"panel": "#123456"} 83 | mock_app_prop.return_value = mock_app 84 | mock_args_syntax = MagicMock(spec=Syntax) 85 | mock_syntax.return_value = mock_args_syntax 86 | 87 | action = Action( 88 | invocation_id="test_invocation", 89 | tool_call_id="test_tool_call", 90 | name="test_tool", 91 | args='{"param": "value"}', 92 | result="", 93 | ) 94 | 95 | rendered = action.render() 96 | 97 | mock_syntax.assert_called_once_with( 98 | '{"param": "value"}', "json", background_color="#123456", word_wrap=True 99 | ) 100 | 101 | assert isinstance(rendered, Group) 102 | assert len(rendered.renderables) == 2 103 | 104 | assert isinstance(rendered.renderables[0], Text) 105 | assert "Using tool: test_tool" in rendered.renderables[0].plain 106 | assert rendered.renderables[1] == mock_args_syntax 107 | -------------------------------------------------------------------------------- /tests/unit/ui/components/chat/test_message.py: -------------------------------------------------------------------------------- 1 | """Tests for the ChatMessage component.""" 2 | 3 | from rich.console import Group 4 | from rich.markdown import Markdown 5 | from rich.text import Text 6 | 7 | from dasshh.ui.components.chat.message import ChatMessage 8 | 9 | 10 | def test_chat_message_initialization_user(): 11 | """Test ChatMessage initialization with user role.""" 12 | message = ChatMessage(invocation_id="test_id", role="user", content="Hello, world!") 13 | 14 | assert message.invocation_id == "test_id" 15 | assert message.role == "you" 16 | assert message.content == "Hello, world!" 17 | assert "user" in message.classes 18 | 19 | 20 | def test_chat_message_initialization_assistant(): 21 | """Test ChatMessage initialization with assistant role.""" 22 | message = ChatMessage( 23 | invocation_id="test_id", role="assistant", content="Hello, I'm the assistant!" 24 | ) 25 | 26 | assert message.invocation_id == "test_id" 27 | assert message.role == "dasshh" 28 | assert message.content == "Hello, I'm the assistant!" 29 | assert "assistant" in message.classes 30 | 31 | 32 | def test_chat_message_render_user(): 33 | """Test rendering a user message.""" 34 | message = ChatMessage(invocation_id="test_id", role="user", content="Hello, world!") 35 | 36 | rendered = message.render() 37 | 38 | assert isinstance(rendered, Group) 39 | assert len(rendered.renderables) == 2 40 | 41 | title = rendered.renderables[0] 42 | assert isinstance(title, Text) 43 | assert "You" in title.plain 44 | 45 | content = rendered.renderables[1] 46 | assert isinstance(content, Markdown) 47 | assert content.markup == "Hello, world!" 48 | 49 | 50 | def test_chat_message_render_assistant(): 51 | """Test rendering an assistant message.""" 52 | message = ChatMessage( 53 | invocation_id="test_id", role="assistant", content="Hello, I'm the assistant!" 54 | ) 55 | rendered = message.render() 56 | 57 | assert isinstance(rendered, Group) 58 | assert len(rendered.renderables) == 2 59 | 60 | title = rendered.renderables[0] 61 | assert isinstance(title, Text) 62 | assert "Dasshh" in title.plain 63 | 64 | content = rendered.renderables[1] 65 | assert isinstance(content, Markdown) 66 | assert content.markup == "Hello, I'm the assistant!" 67 | 68 | 69 | def test_chat_message_empty_assistant(): 70 | """Test rendering an empty assistant message (typing indicator).""" 71 | message = ChatMessage(invocation_id="test_id", role="dasshh", content="") 72 | message.role = "assistant" 73 | rendered = message.render() 74 | 75 | assert isinstance(rendered, Group) 76 | assert len(rendered.renderables) == 2 77 | -------------------------------------------------------------------------------- /tests/unit/ui/components/test_navbar.py: -------------------------------------------------------------------------------- 1 | """Tests for the Navbar component.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | import pytest 6 | from textual.containers import Horizontal 7 | 8 | from dasshh.ui.components.navbar import Navbar, NavItem, Logo 9 | from dasshh.ui.events import ChangeView 10 | 11 | 12 | @pytest.fixture 13 | def mock_query(): 14 | """Mock for query_one and query methods.""" 15 | mock = MagicMock() 16 | mock.add_class = MagicMock() 17 | mock.remove_class = MagicMock() 18 | return mock 19 | 20 | 21 | class TestNavItem: 22 | """Tests for the NavItem component.""" 23 | 24 | def test_initialization(self): 25 | """Test NavItem initialization.""" 26 | nav_item = NavItem(route="test", label="Test", icon="T") 27 | assert nav_item.route == "test" 28 | # The rendered text should include the icon and label 29 | assert nav_item.renderable == "T Test" 30 | 31 | def test_on_click(self): 32 | """Test NavItem click event posts ChangeView message.""" 33 | nav_item = NavItem(route="test", label="Test") 34 | nav_item.post_message = MagicMock() 35 | nav_item.add_class = MagicMock() 36 | 37 | nav_item.on_click() 38 | 39 | nav_item.add_class.assert_called_once_with("active") 40 | nav_item.post_message.assert_called_once() 41 | 42 | # Check that the posted message is ChangeView with correct route 43 | posted_message = nav_item.post_message.call_args[0][0] 44 | assert isinstance(posted_message, ChangeView) 45 | assert posted_message.view == "test" 46 | 47 | 48 | class TestLogo: 49 | """Tests for the Logo component.""" 50 | 51 | def test_render(self): 52 | """Test Logo renders correctly.""" 53 | logo = Logo() 54 | rendered = logo.render() 55 | 56 | assert "Dasshh 🗲" in rendered 57 | assert isinstance(rendered, str) 58 | 59 | 60 | class TestNavbar: 61 | """Tests for the Navbar component.""" 62 | 63 | @patch("dasshh.ui.components.navbar.Navbar.compose") 64 | def test_compose(self, mock_compose): 65 | """Test Navbar composition.""" 66 | navbar = Navbar() 67 | 68 | # Mock the compose method to avoid Textual app dependencies 69 | mock_logo = MagicMock(spec=Logo) 70 | mock_horizontal = MagicMock(spec=Horizontal) 71 | mock_horizontal.id = "nav-items" 72 | 73 | mock_compose.return_value = [mock_logo, mock_horizontal] 74 | 75 | # Get the components yielded by compose 76 | components = list(navbar.compose()) 77 | 78 | # Check that we have a Logo and a Horizontal container 79 | assert len(components) == 2 80 | assert components[0] == mock_logo 81 | assert components[1] == mock_horizontal 82 | assert components[1].id == "nav-items" 83 | 84 | @patch("dasshh.ui.components.navbar.Navbar.query_one") 85 | def test_on_mount(self, mock_query_one): 86 | """Test on_mount adds active class to chat NavItem.""" 87 | navbar = Navbar() 88 | mock_chat_item = MagicMock() 89 | mock_query_one.return_value = mock_chat_item 90 | 91 | navbar.on_mount() 92 | 93 | mock_query_one.assert_called_once_with("#chat") 94 | mock_chat_item.add_class.assert_called_once_with("active") 95 | 96 | @patch("dasshh.ui.components.navbar.Navbar.query") 97 | @patch("dasshh.ui.components.navbar.Navbar.query_one") 98 | def test_change_view(self, mock_query_one, mock_query): 99 | """Test change_view event handler.""" 100 | navbar = Navbar() 101 | 102 | # Mock NavItems returned by query 103 | mock_items = [MagicMock(), MagicMock(), MagicMock()] 104 | mock_query.return_value = mock_items 105 | 106 | # Mock the selected NavItem 107 | mock_selected_item = MagicMock() 108 | mock_query_one.return_value = mock_selected_item 109 | 110 | # Create a ChangeView event 111 | event = ChangeView("settings") 112 | 113 | # Call the event handler 114 | navbar.change_view(event) 115 | 116 | # Check that remove_class was called on all NavItems 117 | for item in mock_items: 118 | item.remove_class.assert_called_once_with("active") 119 | 120 | # Check that query_one was called with the correct selector 121 | mock_query_one.assert_called_once_with("#settings") 122 | 123 | # Check that add_class was called on the selected NavItem 124 | mock_selected_item.add_class.assert_called_once_with("active") 125 | -------------------------------------------------------------------------------- /tests/unit/ui/test_events.py: -------------------------------------------------------------------------------- 1 | """Tests for the UI events module.""" 2 | 3 | from dasshh.ui.events import ( 4 | ChangeView, 5 | NewMessage, 6 | NewSession, 7 | LoadSession, 8 | DeleteSession, 9 | AssistantResponseStart, 10 | AssistantResponseUpdate, 11 | AssistantResponseComplete, 12 | AssistantResponseError, 13 | AssistantToolCallStart, 14 | AssistantToolCallComplete, 15 | AssistantToolCallError, 16 | ) 17 | 18 | 19 | def test_change_view_event(): 20 | """Test initialization of ChangeView event.""" 21 | event = ChangeView("chat") 22 | assert event.view == "chat" 23 | 24 | 25 | def test_new_message_event(): 26 | """Test initialization of NewMessage event.""" 27 | message = "Hello, world!" 28 | event = NewMessage(message) 29 | assert event.message == message 30 | 31 | 32 | def test_new_session_event(): 33 | """Test initialization of NewSession event.""" 34 | event = NewSession() 35 | assert isinstance(event, NewSession) 36 | 37 | 38 | def test_load_session_event(): 39 | """Test initialization of LoadSession event.""" 40 | session_id = "test_session_id" 41 | event = LoadSession(session_id) 42 | assert event.session_id == session_id 43 | 44 | 45 | def test_delete_session_event(): 46 | """Test initialization of DeleteSession event.""" 47 | session_id = "test_session_id" 48 | event = DeleteSession(session_id) 49 | assert event.session_id == session_id 50 | 51 | 52 | def test_assistant_response_start_event(): 53 | """Test initialization of AssistantResponseStart event.""" 54 | invocation_id = "test_invocation_id" 55 | event = AssistantResponseStart(invocation_id) 56 | assert event.invocation_id == invocation_id 57 | 58 | 59 | def test_assistant_response_update_event(): 60 | """Test initialization of AssistantResponseUpdate event.""" 61 | invocation_id = "test_invocation_id" 62 | content = "Partial response content" 63 | event = AssistantResponseUpdate(invocation_id, content) 64 | assert event.invocation_id == invocation_id 65 | assert event.content == content 66 | 67 | 68 | def test_assistant_response_complete_event(): 69 | """Test initialization of AssistantResponseComplete event.""" 70 | invocation_id = "test_invocation_id" 71 | content = "Complete response content" 72 | event = AssistantResponseComplete(invocation_id, content) 73 | assert event.invocation_id == invocation_id 74 | assert event.content == content 75 | 76 | 77 | def test_assistant_response_error_event(): 78 | """Test initialization of AssistantResponseError event.""" 79 | invocation_id = "test_invocation_id" 80 | error = "Error message" 81 | event = AssistantResponseError(invocation_id, error) 82 | assert event.invocation_id == invocation_id 83 | assert event.error == error 84 | 85 | 86 | def test_assistant_tool_call_start_event(): 87 | """Test initialization of AssistantToolCallStart event.""" 88 | invocation_id = "test_invocation_id" 89 | tool_call_id = "test_tool_call_id" 90 | tool_name = "test_tool" 91 | args = '{"param": "value"}' 92 | 93 | event = AssistantToolCallStart(invocation_id, tool_call_id, tool_name, args) 94 | 95 | assert event.invocation_id == invocation_id 96 | assert event.tool_call_id == tool_call_id 97 | assert event.tool_name == tool_name 98 | assert event.args == args 99 | 100 | 101 | def test_assistant_tool_call_complete_event(): 102 | """Test initialization of AssistantToolCallComplete event.""" 103 | invocation_id = "test_invocation_id" 104 | tool_call_id = "test_tool_call_id" 105 | tool_name = "test_tool" 106 | result = '{"result": "success"}' 107 | 108 | event = AssistantToolCallComplete(invocation_id, tool_call_id, tool_name, result) 109 | 110 | assert event.invocation_id == invocation_id 111 | assert event.tool_call_id == tool_call_id 112 | assert event.tool_name == tool_name 113 | assert event.result == result 114 | 115 | 116 | def test_assistant_tool_call_error_event(): 117 | """Test initialization of AssistantToolCallError event.""" 118 | invocation_id = "test_invocation_id" 119 | tool_call_id = "test_tool_call_id" 120 | tool_name = "test_tool" 121 | error = "Tool call error message" 122 | 123 | event = AssistantToolCallError(invocation_id, tool_call_id, tool_name, error) 124 | 125 | assert event.invocation_id == invocation_id 126 | assert event.tool_call_id == tool_call_id 127 | assert event.tool_name == tool_name 128 | assert event.error == error 129 | -------------------------------------------------------------------------------- /tests/unit/ui/test_types.py: -------------------------------------------------------------------------------- 1 | """Tests for the UI types module.""" 2 | 3 | import datetime 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from dasshh.ui.types import UIMessage, UIAction, UISession 8 | 9 | 10 | def test_ui_message_initialization(): 11 | """Test that UIMessage can be initialized with valid parameters.""" 12 | message = UIMessage(invocation_id="test_id", role="user", content="Hello, world!") 13 | 14 | assert message.invocation_id == "test_id" 15 | assert message.role == "user" 16 | assert message.content == "Hello, world!" 17 | 18 | 19 | def test_ui_message_default_invocation_id(): 20 | """Test that UIMessage initializes with empty string as default invocation_id.""" 21 | message = UIMessage(role="assistant", content="Hello!") 22 | 23 | assert message.invocation_id == "" 24 | assert message.role == "assistant" 25 | assert message.content == "Hello!" 26 | 27 | 28 | def test_ui_message_invalid_role(): 29 | """Test that UIMessage validates role field.""" 30 | with pytest.raises(ValidationError): 31 | UIMessage(role="invalid_role", content="Hello!") 32 | 33 | 34 | def test_ui_action_initialization(): 35 | """Test that UIAction can be initialized with valid parameters.""" 36 | action = UIAction( 37 | tool_call_id="tool_1", 38 | invocation_id="inv_1", 39 | name="test_tool", 40 | args='{\n "param": "value"\n}', 41 | result='{\n "result": "success"\n}', 42 | ) 43 | 44 | assert action.tool_call_id == "tool_1" 45 | assert action.invocation_id == "inv_1" 46 | assert action.name == "test_tool" 47 | assert action.args == '{\n "param": "value"\n}' 48 | assert action.result == '{\n "result": "success"\n}' 49 | 50 | 51 | def test_ui_session_initialization(): 52 | """Test that UISession can be initialized with valid parameters.""" 53 | now = datetime.datetime.now() 54 | 55 | session = UISession( 56 | id="session_1", 57 | detail="Test session", 58 | created_at=now, 59 | updated_at=now, 60 | messages=[UIMessage(role="user", content="Hello")], 61 | actions=[ 62 | UIAction( 63 | tool_call_id="tool_1", 64 | invocation_id="inv_1", 65 | name="test_tool", 66 | args="{}", 67 | result="{}", 68 | ) 69 | ], 70 | ) 71 | 72 | assert session.id == "session_1" 73 | assert session.detail == "Test session" 74 | assert session.created_at == now 75 | assert session.updated_at == now 76 | assert len(session.messages) == 1 77 | assert len(session.actions) == 1 78 | assert session.messages[0].role == "user" 79 | assert session.actions[0].name == "test_tool" 80 | --------------------------------------------------------------------------------